Skip to content

Fix distance grid overflow for glyphs clipped by the canvas edge - #71

Open
cherenkov wants to merge 1 commit into
mapbox:mainfrom
cherenkov:fix/clipped-glyph-grid-overflow
Open

cherenkov wants to merge 1 commit into
mapbox:mainfrom
cherenkov:fix/clipped-glyph-grid-overflow

Conversation

@cherenkov

@cherenkov cherenkov commented Sep 12, 2026

Copy link
Copy Markdown

draw() clips an overflowing glyph to size - buffer, so its padded box reaches size + buffer — one buffer wider than the canvas. The grids and the f/v/z scratch arrays are sized from size, so edt1d runs past their end and the clipped edges come out zeroed.

This is a regression of #29, reintroduced in e83b8c3 when gridSize was folded into size. The two aren't interchangeable: the grid is always size + buffer, because the clip bound is itself derived from size — growing the canvas grows the largest glyph by the same amount.

Repro{fontSize: 20, buffer: 2} (28px canvas), a glyph filling it, padded box 30×30:

before                        after
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

Only glyphs whose bounding box exceeds size - 2 * buffer are affected, so which ones depends on the renderer and the font — it comes down to how large a bounding box the font reports relative to fontSize. In Chrome/macOS sans-serif at the defaults it is Arabic presentation forms: 2.0% of U+FB50–FDFF at fontSize: 24 and 4.9% at 48, up to 3.5% of a glyph's bytes. Latin, Greek, CJK, kana, Hangul and emoji all come out clean there. Under node-canvas the same code corrupts ~55% of the emoji block instead, because emoji measure 1.33em there against Chrome's 1.13em.

Either way it matches the note on #29 that this "happens to be correct for almost any glyph (thus we didn't notice the problem)".

Fix — name the clip bound maxGlyphDim and derive the grid dimension from it, so the two can't drift apart again. Output is byte-identical for every glyph that wasn't already corrupted (565 renders across 5 option sets). Costs ~3.6 KB per instance at the defaults (the two grids go from 36² to 39²).

Test — rasterizes a canvas-filling square through stubbed measureText/fillText, so it needs no particular font. Asserts the padded glyph fits every array and that the SDF stays symmetric. Fails on main.

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) <noreply@anthropic.com>
@cherenkov
cherenkov requested a review from a team as a code owner September 12, 2026 22:19
@ox-security

ox-security Bot commented Sep 12, 2026

Copy link
Copy Markdown

OX Security Logo

OX Security reviewed this pull request — nothing to fix.

No issues found

Branch fix/clipped-glyph-grid-overflowmain

View scan in OX Security →

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant