Skip to content

Draw a COLR version 1 font in colour (#76) - #299

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

jakejackson1 merged 1 commit into
gravitypdffrom
feature/76-colrv1

Conversation

@jakejackson1

@jakejackson1 jakejackson1 commented Sep 21, 2026

Copy link
Copy Markdown
Member

Phase 4 of the colour emoji plan for #76. The substitution fix the paints showed up, spaces between emoji staying in the document font, was split out as #303, now merged; the snapshots here include it. Noto Color Emoji's vector build, the one Google Fonts serves, now draws in colour.

What changes

  • ColrV1Source walks each colour glyph's paint graph as the glyph is drawn. How each paint maps:

    Paint Drawn as
    PaintColrLayers each layer in turn
    PaintColrGlyph the other glyph's graph
    PaintGlyph the outline as a clip, W n; with a solid child, the outline filled, as a v0 layer is
    PaintSolid the clip filled in a colour of the first palette, or in the colour of the text
    PaintLinearGradient an axial shading (ShadingType 2); the three points are folded into two by moving the second onto the line through the first parallel to the colour lines
    PaintRadialGradient a radial shading (ShadingType 3)
    Transform, Translate, Scale, Rotate, Skew, and those around a centre cm
    PaintComposite, blend modes /BM, the source as a group blended into an isolated group with the backdrop
    PaintComposite, Porter-Duff soft masks from the alpha of the source or backdrop, inverted through /TR for the OUT modes; ATOP and XOR are sums of IN and OUT (table in COMPOSITES)
    • A gradient whose alpha varies is masked by the brightness of a grey shading of the same shape.
    • REPEAT and REFLECT are written out by repeating the stops over as many spans as cover the clip.
    • Stops are sorted, and of stops sharing an offset only the first and last are kept, as the spec says.
    • Var paints are read as the paint they vary, which gives the default instance.
    • Each glyph is clipped to its clip box, or to the font's bounding box where it has none.
  • What isn't drawn as the font asks, logged as a warning:

    • a sweep gradient, drawn in the colour of its middle stop (no Noto emoji has one);
    • PLUS, drawn as SRC_OVER;
    • a paint format the spec doesn't define;
    • a graph that loops or runs deeper than 64 paints.

    Every read is checked to lie within COLR.

  • ColorLine: a gradient's stops sorted, taken onto 0 to 1, and spread for REPEAT and REFLECT.

  • GlyphResources gains blend(), shading(), group() and softMask(). Type3FontWriter registers them per subset and writes them with the subset's resource dictionary. That dictionary now carries /Shading and more /XObject and /ExtGState entries:

    • Shadings are written once for the document.
    • Groups and soft masks are written per subset, since each group draws with its subset's dictionary.
  • ColorFormats::sources() lists every format a font carries, in SOURCES order. A COLRv1 glyph with no paint is therefore drawn from its v0 layers, and a font carrying two bitmap formats falls back from one to the other per glyph. COLRv1 comes first in SOURCES.

  • The palette, the palette-index-to-colour rule and the fill a layer is drawn with are shared by both COLR sources. They live in ColorFontFile::palette() / colour() and a FillsInColour trait. The v0 snapshot's content is unchanged.

  • CACHE_FORMAT goes from 7 to 8. A COLRv1-only font cached before this was cached as not drawable, without its glyph map. The SOURCES docblock now says to raise it when a format is added.

  • The fixture TestEmoji-COLRv1.ttf is rebuilt so each emoji shows another kind of paint. It covers radial, linear, reflected-with-varying-alpha and repeated gradients, an affine transform, scale about a centre, rotate plus skew, translate plus scale, a clip box, PaintColrGlyph, MULTIPLY, SRC_IN, DEST_OUT, a sweep and a Var paint. The recipe is in build.py; only that font changes.

Fixed after a review against the spec

A read of the COLR and CPAL specs, with checks against Noto-COLRv1, found five places where drawing strayed from the spec. They're fixed here:

  • Radial gradients whose radius passes 0. The negative radius was clamped to 0 without moving the centre, which misplaced every ring of a repeated gradient and the colours of a padded one. The circles and stops are now cut where the radius reaches 0, as the spec's note on radial gradients describes (nonNegative(), ColorLine::between()). Noto has none of these.
  • Premultiplied interpolation. CPAL asks for colours to be interpolated with alpha premultiplied. A PDF shading interpolates the colour and its alpha mask separately.
    • ColorLine::premultiplied() adds stops to the colour shading wherever both colour and alpha change between two stops, and gives an alpha-0 stop its neighbour's colour on each side.
    • How many stops it adds comes from a closed-form bound, so the colour stays within half of one 8-bit step, capped at 32 parts.
    • The alpha mask is left alone, since alpha is already linear between the stops.
    • This affects Noto: 53 gradient segments were more than 5% off, up to about 10%. The added stops cost about nothing, because the segments with an alpha-0 end need none.
    • Interpolation in linear light, which CPAL also asks for, isn't done; a PDF shading can't.
  • An unknown composite mode now draws nothing, as the spec's CLEAR requires; it used to draw as SRC_OVER. PLUS is still drawn as SRC_OVER, with a warning.
  • Stops that all share one offset are a hard edge there, padded both ways. They used to be filled with the middle stop.
  • SRC_ATOP, DEST_ATOP and XOR are now documented as what they are: the second part drawn over the first, exact where each alpha is 0 or 1. The docblock used to call this "summed".

The ClipList's format byte is now checked too. The color-emoji-colrv1 snapshot is unchanged, since the fixture has no gradient where both colour and alpha vary.

Checked against a real font

  • Noto-COLRv1:
    • Ghostscript and Poppler draw the same page of emoji, covering SRC_IN (glasses, laptop), SOFT_LIGHT (the waving flags), transforms, gradients and ZWJ skin-tone sequences.
    • Drawing costs about 1 ms per distinct emoji, two thirds of it decoding outlines.
  • The fixture: checked in both renderers, beside the new color-emoji-colrv1 snapshot.

Kept as it is

  • Type3FontWriter stays one class. It now registers and writes images, graphics states, shadings, groups and masks, as well as the fonts. Splitting the GlyphResources side into a class of its own is worth doing before SVG (Phase 5) adds more; doing it here would move Draw a CBDT colour font in colour, as Type3 fonts (#76) #291's image code in a PR about paints.
  • Soft-mask graphics states are written by the Type3 writer, not AddExtGState(). Each names a group that is only numbered once its subset's groups are written.
  • Groups and masks are held until writeResources(). They could be written straight after their subset, which would save memory, not time.
  • The colour, clip and base-glyph lists are read when the font is first drawn. This takes 2–4 ms for Noto, once per document.

Tests

  • ColrV1SourceTest: each paint kind through the fixture. Synthetic COLR tables cover radial gradients cut at radius 0 (padded and repeated), hard edges, unknown composite modes and PLUS, loops, unknown formats, reads past COLR, degenerate gradients, a scale to nothing, a transparent colour, and a VarColorLine.
  • ColorLineTest, including premultiplied stops, their error bound and cap, stops at alpha 0, and hard edges.
  • ColorFontTest: the shadings, groups and masks as written to the PDF.
  • The color-emoji-colrv1 snapshot.

🤖 Generated with Claude Code

Walk each colour glyph's paint graph as it is drawn: layers, glyph clips,
solid fills, linear and radial gradients as axial and radial shadings,
the transforms as cm, and composites through blend modes and soft masks.
A sweep gradient is drawn in the colour of its middle stop, with a
warning. Noto Color Emoji's vector build now draws in colour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakejackson1
jakejackson1 merged commit 61a22a3 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 documentation Improvements or additions to documentation enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant