Draw a COLR version 0 font in colour, and a colour font's plain glyphs from its outlines (#76) - #293
Merged
Merged
Conversation
jakejackson1
added this pull request to stack #296
September 21, 2026 06:57
This was referenced Sep 21, 2026
jakejackson1
force-pushed
the
feature/76-colrv0
branch
2 times, most recently
from
September 21, 2026 20:34
ed023dd to
6775cfe
Compare
…s from its outlines (#76) GlyphOutline decodes a glyf glyph to a PDF path in font units: simple glyphs with their repeated flags and short and long deltas, implied on-curve points, each quadratic as the cubic that draws it, and composites moved, scaled or transformed. It draws what fontTools draws for Noto Sans' outlines and composites and for the fixture's contour of off-curve points alone, reads a glyph whole and checks each field fits before reading it, and keeps each path it draws. loca is read by one helper, which FontSubsetter uses as well. ColrV0Source fills each layer of a COLR version 0 glyph in its colour from CPAL's first palette, inside q and Q, so a layer in the colour of the text - palette index 0xFFFF - is filled in whatever colour the text is. A layer less than opaque is filled through a graphics state, which the subset's own resource dictionary names beside its images, and which _putextgstates() writes when a glyph registers it after the rest were written. A version 1 font is drawn from the version 0 records beside its paints, ahead of any bitmaps it has. Every source opens the font through ColorFontFile, which decodes its outlines once. A glyph is drawn by the first source that has it: the colour format, then OutlineSource, so a glyph with no colour of its own is its outline in the colour of the text. A colour font with outlines that is not drawn in colour - under PDF/A, say - is drawn from them, and only a font of bitmaps alone is left blank. A font whose ligatures take U+FE0F in, as Twemoji's do, keeps its selectors through GSUB and loses the ones no ligature took, where Noto's leaves them out and they are taken out first. The cache records whether a font has outlines and which kind of sequences it forms, and its format goes to 7. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jakejackson1
force-pushed
the
feature/76-colrv0
branch
from
September 22, 2026 00:08
6775cfe to
0d33ac9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 3 of the colour emoji plan for #76. Stacked on #292, and rebased onto its rewritten version on top of the merged #291. It's one commit. When #292 merges, GitHub will retarget this one to
gravitypdf. Phase 4 (COLRv1 paints) is stacked on this one as #299.What changes
GlyphOutline(glyf outline decoder). Decodes a glyf glyph to a PDF path in font units. It handles:A component placed by matching points is logged and skipped. The decoder reads each glyph with one
bytesAt()and keeps each path it draws. It matches fontTools' pen output for Noto Sans outlines and composites, and for the fixture's contour of off-curve points alone. Each field is checked to fit before it's read, so a glyph that runs short, or claims more points or contours than it holds, draws nothing.ColrV0Source. Fills each COLR v0 layer in its colour from CPAL's first palette, insideq/Q. A layer with palette index0xFFFF, or one naming a colour the palette doesn't hold, is filled in whatever colour the text is. A layer less than opaque goes through a graphics state. Its COLR and CPAL reads go throughFontReader::fieldsAt()(from Draw an sbix colour font in colour (#76) #292), and a glyph whose layers run past the ones COLR counts draws nothing.COLRv0comes first inColorFormats::SOURCES, so a COLRv1 font is drawn from its v0 records ahead of any bitmaps.Graphics states in the font's own resources. Draw a CBDT colour font in colour, as Type3 fonts (#76) #291 gave each Type3 subset its own resource dictionary. That dictionary now names the subset's graphics states under
/ExtGStatebeside its images._putextgstates()writes only states not yet written, andwriteResources()calls it for the ones glyphs registered.Plain glyphs. A glyph with no colour of its own is drawn by
OutlineSource, its outline filled in the colour of the text. The writer tries the colour format first, then the outline. Both read oneColorFontFile, which decodes the font's outlines once and gives each source its reader, logger,unitsPerEmand tables.Drawing without colour. Under PDF/A, PDF/X or
restrictColorSpace, a colour font with outlines is drawn from them in the colour of the text: nothing is left blank, and there's nothing to warn about.ColorFormats::blank()now applies only to a font with no outlines, which is still left blank and logged as Draw a CBDT colour font in colour, as Type3 fonts (#76) #291 does.FontSubstitution::widths()checks the font's cached metrics for this. It loads the font when those metrics come from another cache format.U+FE0F, for Twemoji. Twemoji-Mozilla forms 852 of its sequences only with U+FE0F in them; Noto forms none with it. HarfBuzz matches either way. Here each font decides: a font whose ligatures take U+FE0F in keeps its selectors through GSUB, and any left over are removed afterwards, since Twemoji gives U+FE0F a full em of advance. Other fonts lose their selectors first, as before.
Font cache. Records
hasOutlines(from loca's last entry) andselectorsInSequences.CACHE_FORMATgoes from 6 to 7.Supporting pieces.
Table\Locahelper.FontSubsetter::getLOCA()uses itsoffsets(); GlyphOutline andTTFontFile::hasOutlines()use itsrange(), which reads one glyph's two entries, so drawing a few glyphs of a 41,000-glyph font doesn't decode all of loca.GlyphOperatorgains the XY-values and scaled-offset flags, andargumentsLength(), the size of a component record's arguments and transformation, moved fromFontSubsetterso GlyphOutline's bounds check uses the same rule.ColorFormats::sources()gives the classes that draw a font, in order: its colour format where colour is allowed, thenOutlineSourcewhere it has outlines.blank()is 'that list is empty', and the writer builds its sources from it, so the rule lives in one place.MetricsGenerator::isCurrent()is the cache-format checkAddFont()andFontSubstitution::widths()share.GsubOutputsreads a ligature subtable's Ligature tables through one helper, for both its outputs and the U+FE0F question.Checked against a real font
Before the rebase, Twemoji-Mozilla v0.7.0 (COLRv0, 13,723 glyphs):
pdftotextcopies them back fully qualified.Noto-COLRv1 carries no v0 records, so it isn't drawable here and stays on the TrueType path; #299 draws it.
Kept as it is
Where graphics states are written.
ResourceWriterstill writes the document's graphics states before the fonts, andType3FontWriter::writeResources()writes the ones glyphs registered afterwards. Moving the one call after the fonts would be tidier, but it would renumber the objects of every PDF mPDF writes.A glyph drawn in several subsets is drawn in each. Each subset has its own resource dictionary, so a drawing reused from another subset would leave its images and states out of that dictionary.
Selector matching. The selector question is answered per font, not per sequence. A font mixing both kinds of sequence would need the matcher to skip selectors as HarfBuzz's does, which is a larger change to
Otl.colorPaletteconfig. The plan mentions it; not added. The first palette is drawn.Tests
GlyphOutlineTest:tests/data/glyphoutline/build.py.ColrV0SourceTest:ColorFontTest:/ExtGStatein the font's own resources.PresentationSelectorTest: Noto-style and Twemoji-style keycaps both form, and a leftover selector isn't drawn. A newTestEmoji-FE0F.ttffixture covers the Twemoji style.ColorFormatsTest:hasOutlinesis false for the bitmap fixtures.blank()applies only to a font without outlines.color-emoji-colrv0, including the font's own plain number sign and the mouth layer in the text's colour.composer test(2941 tests),composer csand PHPStan pass, with no new findings.🤖 Generated with Claude Code