From b33e313b1e29809297c0763840e938cbeabbb205 Mon Sep 17 00:00:00 2001 From: cherenkov Date: Sun, 13 Sep 2026 06:39:40 +0900 Subject: [PATCH] Fix distance grid overflow for glyphs clipped by the canvas edge The distance grids and the scratch arrays used by the distance transform are sized from the canvas dimension, but a glyph that overflows the canvas is clipped to `size - buffer`, which makes its padded bounding box up to `size + buffer` wide and tall. edt1d then reads and writes past the end of f/v/z, and the bottom and right edges of such a glyph come out zeroed: 128 159 223 255 0 0 0 128 159 223 255 255 255 255 0 0 0 0 0 0 0 -> 128 159 223 223 223 223 223 0 0 0 0 0 0 0 120 146 159 159 159 159 159 Name the clip bound `maxGlyphDim` and derive the grid dimension from it, so the two can't drift apart, and size every array for the largest padded glyph. Co-Authored-By: Claude Opus 5 (1M context) --- index.js | 18 +++++++++++------- test/test.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/index.js b/index.js index 0183c06..435bb3b 100644 --- a/index.js +++ b/index.js @@ -36,13 +36,17 @@ export default class TinySDF { ctx.textAlign = 'left'; // Necessary so that RTL text doesn't have different alignment ctx.fillStyle = 'black'; + // the glyph is rasterized at (buffer, buffer), so the canvas clips it to this + const maxGlyphDim = this.maxGlyphDim = size - buffer; + const gridDim = maxGlyphDim + 2 * buffer; + // 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); - this.z = new Float64Array(size + 1); - this.v = new Uint16Array(size); + this.gridOuter = new Float64Array(gridDim * gridDim); + this.gridInner = new Float64Array(gridDim * gridDim); + this.f = new Float64Array(gridDim); + this.z = new Float64Array(gridDim + 1); + this.v = new Uint16Array(gridDim); } _createCanvas(size) { @@ -71,8 +75,8 @@ export default class TinySDF { 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) - glyphLeft)); - const glyphHeight = Math.max(0, Math.min(this.size - this.buffer, glyphTop + Math.ceil(actualBoundingBoxDescent))); + const glyphWidth = Math.max(0, Math.min(this.maxGlyphDim, Math.ceil(actualBoundingBoxRight) - glyphLeft)); + const glyphHeight = Math.max(0, Math.min(this.maxGlyphDim, glyphTop + Math.ceil(actualBoundingBoxDescent))); const width = glyphWidth + 2 * this.buffer; const height = glyphHeight + 2 * this.buffer; diff --git a/test/test.js b/test/test.js index 8ccbcfe..6f649ce 100644 --- a/test/test.js +++ b/test/test.js @@ -106,6 +106,37 @@ test('does not return negative-width glyphs', () => { assert.equal(glyph.width, 6); // zero-width glyph with 3px buffer }); +test('fits the distance grids and stays symmetric for a canvas-filling square clipped at the edge', () => { + const sdf = new MockTinySDF({fontSize: 20, buffer: 2}); + const {size} = sdf; + + sdf.ctx.measureText = () => ({ + width: 40, + actualBoundingBoxLeft: 0, + actualBoundingBoxRight: 40, + actualBoundingBoxAscent: 30, + actualBoundingBoxDescent: 10 + }); + sdf.ctx.fillText = function () { + this.fillRect(0, 0, size, size); + }; + + const {data, width, height} = sdf.draw('X'); + const longestLine = Math.max(width, height); + + assert.ok(width > size && height > size, 'the padded glyph is bigger than the canvas'); + assert.ok(width * height <= sdf.gridOuter.length, 'the padded glyph fits the distance grids'); + assert.ok(longestLine <= sdf.f.length && longestLine <= sdf.v.length && longestLine < sdf.z.length, + 'the padded glyph fits the 1D transform scratch arrays'); + + for (let y = 0; y < height; y++) { + const row = data.subarray(y * width, (y + 1) * width); + const mirroredRow = data.subarray((height - 1 - y) * width, (height - y) * width); + assert.deepEqual(row, mirroredRow, `row ${y} mirrors row ${height - 1 - y}`); + assert.deepEqual(row, row.slice().reverse(), `row ${y} is left-right symmetric`); + } +}); + test('renders Chinese and Japanese versions of characters', () => { // assumes Noto Sans CJK SC font is installed const sdf1 = new MockTinySDF({fontFamily: 'Noto Sans CJK SC', lang: 'zh'});