Public
Edited
Feb 9
Importers
Insert cell
Insert cell
Insert cell
test.outerHTML
Insert cell
test = {
let row = 0;
let col = -1;
let chunk = BatchRenderSync.chunks.at(row)

let item = chunk.textObjects.at(col)

// let url = URL.createObjectURL(chunk.blob)
// invalidation.then(()=>URL.revokeObjectURL(url))

return svg`<svg width="1024" height="${chunk.chunkHeight*4}" style="outline:1px solid red;">
<defs>
<g id="text-renders">
<image id="chunk-${row}" href="${chunk.dataURL}" height="${chunk.chunkHeight}" width="${chunk.chunkWidth}">
</image>

<!-- full-width pattern-->
<pattern id="full-${row}" width=1 height=1>
<use href="#chunk-${row}"></use>
</pattern>

<!-- viewbox pattern-->
<pattern id="view-${row}" viewbox="${item.offset},${0},${item.width},${chunk.chunkHeight}" width=1 height=1>
<use href="#chunk-${row}"></use>
</pattern>

<!-- offset pattern-->
<pattern id="offs-${row}" width="1" height="1">
<use href="#chunk-${row}" x=${-item.offset}></use>
</pattern>

<!-- fixed pattern-->
<pattern id="fixt-${row}" width="${chunk.chunkWidth}" height="${chunk.chunkHeight}" patternUnits="userSpaceOnUse">
<use href="#chunk-${row}"></use>
</pattern>

</g>

<g id="text-paths">
<!-- single clip-path-->
<clipPath id="textClip-${item.id}">
<rect x="${item.offset}" y="0" width="${item.width}" height="${chunk.chunkHeight}" fill="black"></rect>
</clipPath>

<!-- clip + filter-->
<clipPath id="textCrop-${item.id}">
<rect width="${item.width}" height="${chunk.chunkHeight}" fill="black"></rect>
</clipPath>
<filter id="textOffset-${item.id}">
<feOffset in="SourceGraphic" dx="${-item.offset}"/>
</filter>
</g>

</defs>

<!--
<use href="#chunk-${row}" clip-path="url(#textCrop-${item.id})" filter="url(#textOffset-${item.id})"></use>-->

<!--
<svg viewbox="${item.offset},0,${item.width},${chunk.chunkHeight}" width=${item.width}>
<!--<use href="#chunk-${row}"></use>-->
<!--<rect fill="url(#full-${row})" width="${chunk.chunkWidth}" height="${chunk.chunkHeight}"></rect>-->
<!--</svg>-->

<!--
<use x="${-item.offset}" href="#chunk-${row}" clip-path="url('#textClip-${item.id}')"></use>-->


<!--
<rect fill="url(#view-${row})" width=${item.width} height=${chunk.chunkHeight}></rect>-->

<rect fill="url(#offs-${row})" width=${item.width} height=${chunk.chunkHeight}></rect>

<!--
<rect x="${item.offset}" width="${item.width}" height="${chunk.chunkHeight}"
transform="translate(${-item.offset}, 0)" fill="url(#fixt-${row})" fill-opacity="0.5"></rect>-->
</svg>`;
}

Insert cell
function dataURLtoBlob(dataurl) {
var arr = dataurl.split(","),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
}
Insert cell
BatchRenderSync = {
let canvas = document.createElement("canvas");

const options = { family: "Arial", size: 16, fill: "blue", resolution: 1,canvas,gap:0,maxCanvasWidth:4096};

let t1 = performance.now()
let chunks = renderTextToCanvasChunks(texts,options)

// invalidation.then(()=>chunks.forEach(chunk=> URL.revokeObjectURL(chunk.blobURL)))

return {
time: ~~(performance.now()-t1),
chunks
}
}
Insert cell
function renderTextToCanvasChunks(texts, {
gap = 0, // Gap between texts
fill = "black", // Default text color
family = "Arial", // Default font family
size = 10, // Default font size
resolution = 1, // Default resolution scaling
maxCanvasWidth = 4096, // Maximum safe canvas width
canvas = document.createElement('canvas')
} = {}) {
const output = []; // Array to store results: { dataURL, textObjects }

// Helper function to calculate the total width of a given textObjects array
const textObjects = texts.map(text=>measureText(text,arguments[1]))

// Partition the textObjects array into chunks that fit within maxCanvasWidth
let span = textObjects.length;
// let currentChunk = [];
let currentChunkWidth = 0;
let maxHeight = 0;

let last,textObj,prev = 0;
for (var i = 0; i < span; ++i) {
textObj = textObjects[i]
const newChunkWidth = currentChunkWidth + textObj.width + (i > prev /*currentChunk.length > 0*/ ? gap : 0);

if (newChunkWidth > maxCanvasWidth) {
// If adding this text exceeds the max width, process the current chunk
// Subtract last chunk width that exceeded the maxCanvasWidth
output.push(renderChunkToCanvas(textObjects.slice(prev,i), {
chunk: output.length,
gap, fill, family, size, resolution,canvas, chunkWidth: last.offset + last.width, chunkHeight:maxHeight,
}));
// Start a new chunk with the current text => deprecated in favour of array slicing
// currentChunk = [textObj];
last = Object.assign(textObj, {id:i,offset:0})
prev = i ; // store the last index for slicing next chunk
currentChunkWidth = textObj.width;
} else {
// Store maximum text height
if(textObj.height > maxHeight) maxHeight = textObj.height
// Add the text to the current chunk => deprecated in favour of array slicing
// currentChunk.push(textObj);
last = Object.assign(textObj, {id:i,offset:(newChunkWidth - textObj.width)})
currentChunkWidth = newChunkWidth;
}
}

// Process any remaining texts in the last chunk
if (i > prev /*currentChunk.length > 0*/) {
output.push(renderChunkToCanvas(textObjects.slice(prev,i), {
gap, fill, family, size, resolution,canvas,chunkWidth:last.offset + last.width, chunkHeight:maxHeight
}));
}

return output
}
Insert cell
// Helper function to render a single chunk of textObjects to a canvas

function renderChunkToCanvas(textObjects, {
gap = 0,
fill = "black",
family = "Arial",
size = 10, // in points
resolution = 1,
canvas = document.createElement('canvas'),
chunkWidth,chunkHeight,chunk = 0,
}) {

let subpixelOffset = Math.min(Math.max(1,1/resolution),2)
// Calculate the total width and height for the canvas
const totalWidth = chunkWidth ?? textObjects.reduce((sum, textObj) => sum + textObj.width + gap, 0) - gap ;
const maxHeight = (chunkHeight ?? Math.max(...textObjects.map(textObj => textObj.height))) + (subpixelOffset);

// Create a canvas with the calculated dimensions
//const canvas = document.createElement('canvas');
canvas.width = totalWidth;
canvas.height = maxHeight;

const ctx = canvas.getContext("2d");

// Set the font size and family, scaled by resolution
const font = `${size * resolution}pt ${family}`;
ctx.font = font;
ctx.fillStyle = fill;

// Render each text object onto the canvas
// let currentX = 0; // Starting X position

textObjects = textObjects.map(textObj => {
const { id,textContent, width, height, ascent, descent,left,metrics,offset} = textObj;

// Draw the text at the current position
ctx.fillText(
textContent,
offset ?? 0,//currentX + /*Math.abs*/(left), // Offset to align the left edge of the text
metrics.hangingBaseline+subpixelOffset // Align the baseline (ascent) of the text
);

// Move the X position forward by the width of the text plus the gap
// currentX += width + gap;
return {
id,chunk,
textContent,width:Math.round(width/resolution),height:Math.round(height/resolution),offset:Math.round((offset ?? 0)/resolution),
ascent: ascent/resolution, descent: descent/resolution
// metrics not needed anymore
}
});

/*const blob = await new Promise(resolve =>
canvas.toBlob(resolve));*/

/*const blobURL/*OffScreen*//* = await new Promise((keep) => (canvas.convertToBlob || canvas.toBlob).call(canvas).then((blob) => {
let link = URL.createObjectURL(blob)
keep(link)
//return link
}))*/

/*const blobURL = await new Promise(function (resolve, reject) {
canvas.toBlob((blob) => {
let link = URL.createObjectURL(blob);
resolve(link);
},"image/png");
});*/

// Return the rendered canvas as a Data URL and the corresponding textObjects
return {
dataURL: canvas.toDataURL("image/png"),chunkWidth: totalWidth/resolution,chunkHeight:maxHeight/resolution,
textObjects,// blobURL
}
}
Insert cell
function measureText(textContent, {
family = "Arial",
size = 10,
resolution = 1,
canvas = document.createElement('canvas')
} = {}) {
// Get the canvas context
const ctx = canvas.getContext("2d");

// Set the font size and family, scaled by resolution
const font = `${size * resolution}pt ${family}`;
ctx.font = font;

// Measure the text dimensions using advanced text metrics
const metrics = ctx.measureText(textContent);

// Calculate the actual bounding box dimensions
const ascent = Math.ceil(metrics.actualBoundingBoxAscent);
const descent = Math.ceil(metrics.actualBoundingBoxDescent);
const left = Math.round(metrics.actualBoundingBoxLeft);
const right = Math.round(metrics.actualBoundingBoxRight);

// Total width and height of the bounding box
const width = left + right;
const height = ascent + descent

// Return the desired output: textContent, width, and height
return {
textContent,
width,
height,
ascent,
descent,
left,right,
metrics
};
}
Insert cell
Insert cell
Insert cell
// merge imagedata directly

function mergeImageData(characters, kerningTable) {
// Step 1: Calculate total width and height
let totalWidth = 0;
let maxHeight = 0;

for (let i = 0; i < characters.length; i++) {
const { imageData } = characters[i];
totalWidth += imageData.width;
maxHeight = Math.max(maxHeight, imageData.height);

if (i > 0) {
const prevChar = characters[i - 1].char;
const currChar = characters[i].char;
const kerning = kerningTable[`${prevChar}${currChar}`] || 0;
totalWidth += kerning;
}
}

// Step 2: Create a composite array
const compositeData = new Uint8ClampedArray(totalWidth * maxHeight * 4);
let offsetX = 0;

for (let i = 0; i < characters.length; i++) {
const { char, imageData } = characters[i];
const { width, height, data } = imageData;

// Apply kerning
if (i > 0) {
const prevChar = characters[i - 1].char;
const kerning = kerningTable[`${prevChar}${char}`] || 0;
offsetX += kerning;
}

// Copy pixel data into the composite array
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const srcIndex = (y * width + x) * 4;
const destIndex = ((y * totalWidth) + (offsetX + x)) * 4;

// Blend with existing pixels (handle transparency)
const srcAlpha = data[srcIndex + 3] / 255;
const destAlpha = compositeData[destIndex + 3] / 255;
const alpha = srcAlpha + destAlpha * (1 - srcAlpha);

if (alpha > 0) {
compositeData[destIndex] = (
(data[srcIndex] * srcAlpha + compositeData[destIndex] * destAlpha * (1 - srcAlpha)) / alpha
);
compositeData[destIndex + 1] = (
(data[srcIndex + 1] * srcAlpha + compositeData[destIndex + 1] * destAlpha * (1 - srcAlpha)) / alpha
);
compositeData[destIndex + 2] = (
(data[srcIndex + 2] * srcAlpha + compositeData[destIndex + 2] * destAlpha * (1 - srcAlpha)) / alpha
);
compositeData[destIndex + 3] = alpha * 255;
}
}
}

// Move to the next position
offsetX += width;
}

// Step 3: Return the composite ImageData
return new ImageData(compositeData, totalWidth, maxHeight);
}
Insert cell
Insert cell
Insert cell
Insert cell
/* canvasLimits = await canvasSize.maxArea({
max: 18000,
min: 2048,
step: 1000,
// useWorker: true,
});*/
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more