Skip to content

Draw a COLR version 0 font in colour, and a colour font's plain glyphs from its outlines (#76) - #293

Merged
jakejackson1 merged 1 commit into
gravitypdffrom
feature/76-colrv0
Sep 22, 2026
Merged

jakejackson1 merged 1 commit into
gravitypdffrom
feature/76-colrv0

Conversation

@jakejackson1

@jakejackson1 jakejackson1 commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

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:

    • simple glyphs with repeated flags and short and long deltas;
    • implied on-curve points;
    • each quadratic drawn as the cubic with control points two-thirds of the way along;
    • composites that are moved, scaled, x/y-scaled or 2×2-transformed.

    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, inside q/Q. A layer with palette index 0xFFFF, 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 through FontReader::fieldsAt() (from Draw an sbix colour font in colour (#76) #292), and a glyph whose layers run past the ones COLR counts draws nothing. COLRv0 comes first in ColorFormats::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 /ExtGState beside its images. _putextgstates() writes only states not yet written, and writeResources() 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 one ColorFontFile, which decodes the font's outlines once and gives each source its reader, logger, unitsPerEm and 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) and selectorsInSequences. CACHE_FORMAT goes from 6 to 7.

  • Supporting pieces.

    • A Table\Loca helper. FontSubsetter::getLOCA() uses its offsets(); GlyphOutline and TTFontFile::hasOutlines() use its range(), which reads one glyph's two entries, so drawing a few glyphs of a 41,000-glyph font doesn't decode all of loca.
    • GlyphOperator gains the XY-values and scaled-offset flags, and argumentsLength(), the size of a component record's arguments and transformation, moved from FontSubsetter so 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, then OutlineSource where 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 check AddFont() and FontSubstitution::widths() share.
    • GsubOutputs reads a ligature subtable's Ligature tables through one helper, for both its outputs and the U+FE0F question.
    • TTFontFile reads GSUB's lookup list once for both.

Checked against a real font

Before the rebase, Twemoji-Mozilla v0.7.0 (COLRv0, 13,723 glyphs):

  • A page of about 90 emoji renders in 0.22s cold and 0.08s warm.
  • Every sequence forms, including the rainbow flag and the keycap with its selector.
  • pdftotext copies 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. ResourceWriter still writes the document's graphics states before the fonts, and Type3FontWriter::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.

  • colorPalette config. The plan mentions it; not added. The first palette is drawn.

Tests

  • GlyphOutlineTest:
    • 11 glyphs against paths fontTools draws, from tests/data/glyphoutline/build.py.
    • A synthetic triangle, and a composite moving it.
    • 9 malformed glyphs, each drawing nothing: short headers and flags, too many contours, long instructions, cut-short coordinates, contour ends out of order, a cut-short component, and a component of itself. Without the new bounds, 8 of them fail.
  • ColrV0SourceTest:
    • The layer colours, the foreground layer, the alpha graphics state, and a glyph with no layers.
    • Synthetic COLR/CPAL tables: a missing, out-of-range, cut-short or unreachable palette falls back to the text's colour; COLR headers, base records and layer records that run short draw nothing.
  • ColorFontTest:
    • A plain glyph is drawn as its outline with no colour.
    • The heart's graphics state appears under /ExtGState in the font's own resources.
    • Under each of the five colour restrictions, a COLR font is drawn from its outlines, with no colour, no PDF/A warning and no log warning.
  • PresentationSelectorTest: Noto-style and Twemoji-style keycaps both form, and a leftover selector isn't drawn. A new TestEmoji-FE0F.ttf fixture covers the Twemoji style.
  • ColorFormatsTest:
    • hasOutlines is false for the bitmap fixtures.
    • blank() applies only to a font without outlines.
    • COLRv0 is chosen ahead of CBDT.
  • Snapshot: 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 cs and PHPStan pass, with no new findings.

🤖 Generated with Claude Code

…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
jakejackson1 merged commit b0d55b8 into gravitypdf Sep 22, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

create-upstream-pr enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant