Skip to content

[font] fix panics and wrong results in cmap, svg, post, bitmaps, and variation tables - #288

Merged
benoitkugler merged 17 commits into
go-text:mainfrom
egonelbre:fix/font-parsing-bugs
Sep 30, 2026
Merged

benoitkugler merged 17 commits into
go-text:mainfrom
egonelbre:fix/font-parsing-bugs

Conversation

@egonelbre

Copy link
Copy Markdown
Contributor

Sixteen small fixes in the font package, grouped by table so each group can be reviewed on its own. Seven turn panics on malformed fonts into errors or dropped tables, eight fix wrong results from fonts that parse fine.

cmap:

  • Format 4 sliced the glyph array with a negative index when a segment's idRangeOffset pointed before the array, panicking inside NewFont. The 0xFFFF sentinel check also tested the segment start instead of the offset, ignoring real offsets on segments that start at 0xFFFF.
  • The format 4 iterator added the segment delta without wrapping to 16 bits, so it disagreed with Lookup past 0xFFFF.
  • Format 0 skipped byte 0 instead of glyph 0, so byte 0 lost its mapping and unmapped bytes were reported present.
  • Format 10 with a start code at or above 0x80000000 produced a negative firstCode, overflowed in Lookup, and indexed out of range.
  • Lookup for formats 4, 6, 10, 12 and 13 reported code points mapped to glyph 0 as present, and RuneRanges counted them as covered. Both now exclude glyph 0, so font fallback stops picking fonts for characters they only map to .notdef. The fontscan iterator path gets the same filter, with an index cache version bump to 7.

svg:

  • offset+length wrapped in uint32, passing the length check and panicking on the slice.
  • A gzip document whose header parsed but whose stream failed was returned as raw compressed bytes; it now reports the glyph as missing.

post:

  • The format 2.0 name index check was off by one, so GlyphName indexed past the string table.

bitmaps:

  • A subtable with LastGlyph < FirstGlyph gave make a negative length. An empty format 4 glyph array, where numGlyphs+1 wraps to zero, is rejected too.
  • Index format 5 ended glyph i at (ImageSize+1)i instead of ImageSize(i+1), so every format 5 strike returned garbage.
  • Formats 1 and 3 mark a glyph with no bitmap by two equal offsets; imageFor returned that empty entry, so the renderer drew nothing instead of falling back to the outline.

Variations:

  • gvar point numbers were never checked against the glyph's point count, and tuples sized with gvar's axis count were indexed with fvar's. Both are rejected at parse time, and a failed gvar is dropped whole instead of half-built.
  • An avar with a different axis count than fvar was kept and indexed past the coordinate array during normalization. It is dropped.

Also: NewFont documents that only cmap, head, and maxp are required, but aborted on a bad CPAL. A bad CPAL now drops COLR with it.

Merge note: fix/fontscan bumps the same cache version constant to 7 for a different reason. Whichever lands second resolves that one line to a single higher number.

@benoitkugler benoitkugler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice fixes, I've suggested a minor change.

Comment thread font/variations.go Outdated

@benoitkugler benoitkugler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you !

Could you resolve the merge conflicts ?

indexStart went negative when a segment's idRangeOffset pointed before
the glyph array, for instance idRangeOffset=2 on segment 0. newCmap4
then sliced GlyphIDArray with a negative index and panicked inside
NewFont. It now returns an error.

The 0xFFFF sentinel check also tested the segment start rather than
idRangeOffset itself, so newCmap4 ignored real offsets on segments
starting at 0xFFFF. It now tests idRangeOffset.
cmap4Iter.Char added the segment delta at GID width, while Lookup adds
it in uint16 and wraps. When glyph plus delta passed 0xFFFF the
iterator and Lookup disagreed on the glyph. Char now wraps the same
way.
newCmap0 tested the byte value instead of the glyph value. Byte 0 lost
its mapping even when it had one, and bytes mapped to glyph 0 stayed in
the map, so Lookup reported them as present.
A StartCharCode of 0x80000000 or more became a negative firstCode.
Lookup then overflowed on r - firstCode and indexed entries out of
range, and RuneRanges returned a wrong range. No rune can map to such a
subtable, so newCmap10 now returns an empty one.
Lookup for formats 4, 6, 10, 12 and 13 reported a code point as mapped
even when the computed glyph was 0, and RuneRanges counted those code
points as covered. Lookup now returns false for glyph 0.

RuneRanges skips the code points that map to glyph 0. For the explicit
glyph arrays of formats 4, 6 and 10 it checks each entry, before and
after the delta. An arithmetic segment or group of formats 4 and 12 has
at most one such code point, the one whose glyph wraps to 0. A format
13 group maps every code point to one glyph, so a group with glyph 0
is skipped whole. Adjacent ranges still join.
newCoveragesFromCmap added every rune the cmap iterator yields, so a
code point mapped to .notdef counted as covered. The iterator path now
skips glyph 0.

This covers only cmaps without RuneRanges. Formats 4, 6, 10, 12 and 13
implement font.CmapRuneRanger and never expose a glyph id here. For
them, cmap.RuneRanges excludes glyph 0 since the previous commit,
"[font] cmap: exclude glyph 0 from Lookup and RuneRanges".

Caches written by older versions may still count those code points as
covered, so the cache format moves to version 7. The fix/fontscan
branch bumps the same constant for a different reason, so merging both
means resolving that one line to a single higher version.
SvgDocOffset+SvgDocLength wrapped in uint32. The length check passed
and slicing the raw data panicked inside NewFont. newSvg now adds the
two in uint64.
When the gzip header parsed but decompression failed, glyphData
swallowed the error and returned the compressed bytes as the SVG
source. It now reports the glyph as missing.
sanitize accepted a maximum index equal to
numBuiltInPostNames+len(Strings). GlyphName then indexed Strings out of
range and panicked.
Every index format sized its glyph array from the glyph range and
panicked on a negative make length. newBitmapSubtable now rejects the
range once, before the format switch. It also rejects an empty format 4
glyph array, which happens when numGlyphs+1 wraps to 0.
parseIndexSubTable5 ended glyph i at (ImageSize+1)*i instead of
ImageSize*(i+1), so every index format 5 strike returned garbage image
data. The format 3 error text said format 1.
@egonelbre
egonelbre force-pushed the fix/font-parsing-bugs branch from 2f5cc43 to 89ee4bd Compare September 30, 2026 09:49
@benoitkugler

Copy link
Copy Markdown
Contributor

Thank you.

We have a new staticcheck warning coming from #277 (in 3 test files). Could you apply the straightforward fix ?

Index formats 1 and 3 mark a glyph without a bitmap by two equal
consecutive offsets. imageFor still returned a pointer to the empty
entry, so the renderer drew an empty bitmap instead of falling back to
the outline. imageFor now returns nil for those glyphs.
applyDeltasToPoints indexed deltas with tuple.pointNumbers[i] and never
checked it against the glyph point count, so a malformed font panicked.
parsePointNumbers now rejects any point number at or above the point
count, for shared and private point numbers alike.
calculateScalar indexes peak and intermediate tuples with fvar's axis
count, but gvar sizes them with its own axisCount. newGvar now checks
every shared and embedded tuple against fvar, as newMvar already does
for MVAR.

NewFont treats gvar as optional and ignores constructor errors. When
newGvar returned partially parsed variations before building the
shared-axis cache, the earlier glyphs applied their deltas with wrong
scalars. newGvar now returns an empty gvar on serialized-data errors,
as it does for the axis count and the other validation failures.
NewFont kept any avar that parsed, even one with fewer or more axis
maps than fvar has axes. Variation normalization walks the avar maps
and writes normalized[i] for each, so a short table left axes unmapped
and a long one indexed past the coordinates and panicked. NewFont now
drops an avar whose map count does not match fvar.
NewFont documents that only cmap, head and maxp are required, yet it
aborted on a bad CPAL while dropping a bad COLR without a word. CPAL is
now optional too. A bad CPAL drops COLR with it, since one without the
other is unusable.
@egonelbre
egonelbre force-pushed the fix/font-parsing-bugs branch from 89ee4bd to 79b33e6 Compare September 30, 2026 09:56
@benoitkugler

Copy link
Copy Markdown
Contributor

Amazing, thank you !

@benoitkugler
benoitkugler merged commit 001e002 into go-text:main Sep 30, 2026
7 checks passed
@egonelbre
egonelbre deleted the fix/font-parsing-bugs branch September 30, 2026 10:05
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.

2 participants