Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions bench.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import TinySDF from './index.js';
import nodeCanvas from 'canvas';


class MockTinySDF extends TinySDF {
_createCanvas(size) {
return nodeCanvas.createCanvas(size, size);
}
}

const sdf = new MockTinySDF({
fontSize: 48,
buffer: 3
});

// warmup
for (let i = 0; i < 1000; ++i) {
sdf.draw('@');
}

const N = 1e4;
const start = performance.now();
for (let i = 0; i < N; ++i) {
sdf.draw('@');
}
console.log(`${N} iterations took ${Math.round(performance.now() - start)} ms`);

67 changes: 39 additions & 28 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
const INF = 1e20;

// lookup table for gamma-corrected, signed squared alpha distance values
const alphaTable = new Float64Array(256);
for (let i = 0; i < 256; i++) {
const d = 0.5 - Math.pow(i / 255, 1 / 2.2);
alphaTable[i] = d * Math.abs(d);
}
alphaTable[255] = -INF;

export default class TinySDF {
constructor({
fontSize = 24,
Expand All @@ -11,10 +19,10 @@ export default class TinySDF {
fontStyle = 'normal',
lang = null
} = {}) {
this.buffer = buffer;
this.cutoff = cutoff;
this.radius = radius;
this.lang = lang;
this.buffer = buffer; // padding around a glyph's bounding box
this.radius = radius; // how many pixels around the glyph edge are encoded as signed distances
this.cutoff = cutoff; // how much of the SDF byte range represents inside vs outside the edge
this.lang = lang; // language of the Canvas drawing context

// make the canvas size big enough to both have the specified buffer around the glyph
// for "halo", and account for some glyphs possibly being larger than their font size
Expand All @@ -28,7 +36,8 @@ export default class TinySDF {
ctx.textAlign = 'left'; // Necessary so that RTL text doesn't have different alignment
ctx.fillStyle = 'black';

// temporary arrays for the distance transform
// two grids of squared distances: one for the outside of the glyph shape, one for the inside;
// the signed distance is derived as sqrt(outer) - sqrt(inner)
this.gridOuter = new Float64Array(size * size);
this.gridInner = new Float64Array(size * size);
this.f = new Float64Array(size);
Expand All @@ -37,6 +46,9 @@ export default class TinySDF {
}

_createCanvas(size) {
if (typeof OffscreenCanvas !== 'undefined') {
return new OffscreenCanvas(size, size);
}
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
return canvas;
Expand All @@ -51,14 +63,14 @@ export default class TinySDF {
actualBoundingBoxRight
} = this.ctx.measureText(char);

// The integer/pixel part of the top alignment is encoded in metrics.glyphTop
// The integer/pixel part of the alignment is encoded in metrics.glyphTop/glyphLeft
// The remainder is implicitly encoded in the rasterization
const glyphTop = Math.ceil(actualBoundingBoxAscent);
const glyphLeft = 0;
const glyphLeft = Math.floor(actualBoundingBoxLeft);

// If the glyph overflows the canvas size, it will be clipped at the bottom/right
const glyphWidth = Math.max(0, Math.min(this.size - this.buffer, Math.ceil(actualBoundingBoxRight - actualBoundingBoxLeft)));
const glyphHeight = Math.min(this.size - this.buffer, glyphTop + Math.ceil(actualBoundingBoxDescent));
const glyphWidth = Math.max(0, Math.min(this.size - this.buffer, Math.ceil(actualBoundingBoxRight) - glyphLeft));
const glyphHeight = Math.max(0, Math.min(this.size - this.buffer, glyphTop + Math.ceil(actualBoundingBoxDescent)));

const width = glyphWidth + 2 * this.buffer;
const height = glyphHeight + 2 * this.buffer;
Expand All @@ -71,40 +83,39 @@ export default class TinySDF {
const {ctx, buffer, gridInner, gridOuter} = this;
if (this.lang) ctx.lang = this.lang;
ctx.clearRect(buffer, buffer, glyphWidth, glyphHeight);
ctx.fillText(char, buffer, buffer + glyphTop);
ctx.fillText(char, buffer - glyphLeft, buffer + glyphTop);
const imgData = ctx.getImageData(buffer, buffer, glyphWidth, glyphHeight);

// Initialize grids outside the glyph range to alpha 0
// default: outside the glyph (INF distance) for outer, inside (0 distance) for inner
gridOuter.fill(INF, 0, len);
gridInner.fill(0, 0, len);

// for anti-aliased pixels, treat partial coverage as a distance approximation:
// a fully covered pixel gets 0 outer / INF inner; a partial pixel gets a small
// non-zero outer or inner distance based on how far its coverage deviates from 0.5
let imgIdx = 3; // start at the alpha channel of the first pixel
for (let y = 0; y < glyphHeight; y++) {
for (let x = 0; x < glyphWidth; x++) {
const a = imgData.data[4 * (y * glyphWidth + x) + 3] / 255; // alpha value
let j = (y + buffer) * width + buffer;
for (let x = 0; x < glyphWidth; x++, imgIdx += 4, j++) {
const a = imgData.data[imgIdx]; // alpha value
if (a === 0) continue; // empty pixels

const j = (y + buffer) * width + x + buffer;

if (a === 1) { // fully drawn pixels
gridOuter[j] = 0;
gridInner[j] = INF;

} else { // aliased pixels
// gamma correction
const aLin = Math.pow(a, 1.0 / 2.2);
const d = 0.5 - aLin;
gridOuter[j] = d > 0 ? d * d : 0;
gridInner[j] = d < 0 ? d * d : 0;
}
const t = alphaTable[a];
gridOuter[j] = Math.max(0, t);
gridInner[j] = Math.max(0, -t);
}
}

edt(gridOuter, 0, 0, width, height, width, this.f, this.v, this.z);
edt(gridInner, buffer, buffer, glyphWidth, glyphHeight, width, this.f, this.v, this.z);

// encode signed distance as a byte: inside the glyph maps to high values, outside to low,
// with the edge gradient spanning [-radius * cutoff, radius * (1 - cutoff)] pixels around the edge;
// Uint8ClampedArray clamps beyond that
const scale = 255 / this.radius;
const base = 255 * (1 - this.cutoff);
for (let i = 0; i < len; i++) {
const d = Math.sqrt(gridOuter[i]) - Math.sqrt(gridInner[i]);
data[i] = Math.round(255 - 255 * (d / this.radius + this.cutoff));
data[i] = Math.round(base - scale * d);
}

return glyph;
Expand Down
Loading
Loading