Skip to content

Draw an OpenType SVG font in colour (#76) - #300

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

jakejackson1 merged 1 commit into
gravitypdffrom
feature/76-svg

Conversation

@jakejackson1

@jakejackson1 jakejackson1 commented Sep 21, 2026

Copy link
Copy Markdown
Member

Phase 5, the last phase of the colour emoji plan for #76. Stacked on #299; when it merges, GitHub will retarget this one down the stack. Fonts carrying an OpenType SVG table now draw in colour.

What changes

  • SvgSource reads the SVG table.

    • Each document record covers a range of glyphs, and records may share a document.
    • A document is read when a glyph in it is first drawn, and gunzipped where it starts as gzip does.
    • Up to 8 documents are kept, most recently used, since glyphs are drawn in text order and one document can hold many.
    • Records are found by binary search.
    • A glyph whose document can't be read, or has no glyphN element, falls to the next source (its outline). So does every glyph when PHP lacks the DOM extension, or zlib for a gzipped document; each case is logged once.
  • SvgRenderer is a new, small renderer for what the OpenType SVG spec requires of a glyph. Image\Svg is untouched: it has no clipPath, turns group opacity into per-element opacity, and registers gradients and states in the page's lists, with transparent gradients drawn through patterns sized to the page.

    • Shapes: path, rect, circle, ellipse, line, polyline, polygon, filled and stroked (width, caps, joins, miter limit, dashes).
    • Structure: g, use (with x/y), and nested svg drawn as g.
    • Colour: hex, rgb() and named colours.
      • currentColor is the colour of the text: the fill sets no colour, as a COLR foreground layer does.
      • var(--colorN, fallback) reads CPAL through ColorFontFile::colour(), or uses the fallback where the font has no such entry.
    • Gradients: linearGradient and radialGradient, in user space or the bounding box.
      • Supported: gradientTransform, spreadMethod, a focus point, and stops inherited through href.
      • Drawn through the same shading, REPEAT/REFLECT and alpha-mask code COLRv1 uses.
    • clip-path: one shape clips with W n (or W* n for evenodd clip-rule). Several shapes clip through a soft mask of them all, since a clip path's shapes add up while a PDF path's winding may cancel. Both clipPathUnits are supported.
    • mask: a soft mask of the mask content's luminosity, clipped to the mask's region, in either maskUnits and maskContentUnits. PDF takes the luminosity of the colours as they are, where SVG 1.1 converts them to linear RGB first; the two agree for white, black and anything between drawn at an opacity.
    • opacity paints the element as a group; a shape with only a fill takes it into the fill instead. fill-opacity and stroke-opacity are supported.
    • image: a PNG or JPEG data: URL, placed with preserveAspectRatio.
    • The root's viewBox (with width, height and preserveAspectRatio), presentation attributes, and the style attribute.
    • The glyph element is drawn where it sits in the document, as FreeType draws one through librsvg: the root's viewBox and its ancestors' transforms and inherited properties apply.
  • Not drawn, each with a warning:

    • text and the other elements a font must not use;
    • a <style> element's rules;
    • filter, pattern and marker;
    • an image that isn't a PNG or JPEG in the document.

    A gradient stroke is drawn in its middle stop's colour.

  • Mpdf\Image\Svg\Path parses path data into moves, lines and cubics. It lives beside Image\Svg, as Tag\* does beside Tag, because nothing in it is specific to glyphs and Image\Svg can use it later; it writes its numbers through Utils\NumericString::decimal(), so it depends on nothing in Fonts. It handles:

    • every command, absolute and relative, including compact numbers and packed arc flags;
    • quadratics raised to cubics, and arcs as a cubic per quarter turn;
    • drawn up to the first error, as SVG 1.1 says;
    • exact bounds, for bounding-box units.
  • Shared with COLRv1:

    • FillsWithGradients, the gradient fill, is moved out of ColrV1Source. It carries Draw a COLR version 1 font in colour (#76) #299's spec fixes, so SVG gradients get them too: a radial gradient is cut where its radius reaches 0, and colours are interpolated premultiplied by alpha. CPAL asks the latter of SVG glyphs as well as COLR ("For both the COLR and SVG tables, interpolation must be done with alpha pre-multiplied").
    • Geometry holds the matrices and boxes. Its number formatting goes through the new Utils\NumericString::decimal(), which Image\Svg\Path uses too. GlyphOutline::number() does the same and moves to it in a follow-up.
    • ColorLine::middle() is the middle-stop fallback, for the COLR sweep and the SVG gradient stroke.
    • ColorFontFile gains bbox() and warn()/log(), and each warning is now logged once, COLRv1's included.
  • GlyphResources::alpha() sets CA as well as ca, so a semi-transparent stroke is drawn directly rather than through a group. COLR never strokes, so its glyphs are unchanged. The COLRv0 and COLRv1 snapshots are refreshed for the extra key; their pages are the same.

  • SVG sits after COLRv0 in ColorFormats::SOURCES. CACHE_FORMAT goes from 8 to 9, since an SVG-only font cached before this was cached as not drawable.

  • New fixture TestEmoji-SVG.ttf, built by build.py. Each emoji's document shows another feature:

    • a CPAL colour and use;
    • every path command, with a user-space gradient;
    • a bounding-box radial gradient with a focus point, and strokes;
    • a clip of two shapes, and group opacity;
    • a PNG;
    • reflect with varying alpha, and a transformed repeating radial;
    • dashes, text (warned about), and a square hidden by a mask whose content lies outside the mask's region;
    • <style>;
    • a viewBox shift;
    • one document shared by two glyphs, with an ancestor's transform and fill;
    • evenodd;
    • a gzipped document;
    • an href gradient with a bounding-box clip.

    The England flag has no document, so it is drawn from its outline. The other fixture fonts rebuild byte-identical.

Checked against a real font

  • Twemoji Color Font's SVGinOT build: 3838 documents, 170 emoji on a page in about 45 ms.
    • Ghostscript and Poppler draw it the same, and no warnings are logged.
    • Its keycap and England flag are blank because that font doesn't carry 1, U+20E3 or the tag characters. It is meant to sit behind a text font, so this is not an SVG issue.
  • The fixture: checked in both renderers, and against librsvg drawing the same documents glyph by glyph.
  • Noto Color Emoji, Adobe's OpenType-SVG build (v2.100): a subset of 18 emoji draws correctly in Poppler and Ghostscript, and logs no warnings. Its documents are gzipped and drawn in linear and radial gradients turned by gradientTransform.

Faster path parsing

  • Path data is tokenised in one preg_match_all, instead of one anchored preg_match per number. Numbers are read in the same loop, with the command's case worked out once.
    • An arc's flags are a character each and may run into what follows, so 01100 comes out as one number token. A flag takes that token's first character and leaves the rest as the next token.
    • Parsing all 35,362 paths of Twemoji's SVG font (447,402 segments) takes about 20% less time: 639 → 539 ms, best of four each.
    • Drawing every glyph is only about 3% faster (1598 → 1546 ms, within this machine's noise): reading the documents and writing the content dominate.
    • The output is byte-identical for all 3838 glyphs.
  • SvgRenderer keeps each path's parse, by its data, for the life of the document. A shape drawn many times through use, or several paths with the same data, are parsed once. Twemoji has no use, so it gains nothing there.
  • NumericString::decimal() writes a whole number as it is, without rounding it.

Two snapshots

  • color-emoji-svg, the test font. Each TestEmoji-SVG glyph is built to show another part of SVG, not to look like its emoji, so several look unfinished on purpose: a notch in the heart, a clipped half thumb, the England flag as a black square. A table now names each one, says what it tests and why it looks as it does, before the runs of text.
  • color-emoji-svg-noto, a real font (new). A subset of Adobe's OpenType-SVG Noto Color Emoji, NotoColorEmoji-SVG-Subset.ttf (200 KB), so a regression in real artwork is obvious at a glance.
    • subset_noto_svg.py beside it rebuilds it byte for byte from the v2.100 release. It keeps only the glyphs the snapshot's emoji need, and converts the fallback outlines from CFF to TrueType, since mPDF reads only TrueType outlines. The SVG documents are unchanged.
    • The font is under the SIL OFL 1.1, committed beside it as NotoColorEmoji-SVG-OFL.txt; it declares no Reserved Font Names, so the subset keeps its name. Like every fixture it's under tests/, which .gitattributes leaves out of the Composer archive.
    • Real SVG fonts use only a narrow slice of SVG (Noto: paths, gradients, viewBox, opacity; Twemoji: paths, circles, ellipses, transforms), so the test font stays for everything else.

Kept as it is

  • An .otf SVG emoji font, with CFF outlines, still can't be loaded: mPDF reads only TrueType outlines. Converting CFF outlines on load would be a PR of its own.

  • CACHE_FORMAT is still raised by hand when a format is added, as the SOURCES docblock says. Folding the format list into the cache check would be the general fix, but it reaches beyond this PR.

Tests

  • Image\Svg\PathTest: each path command, compact numbers, commas and stray characters, errors, arcs (including degenerate ones and flags run into what follows), bounds, shapes.
  • SvgSourceTest:
    • every fixture emoji;
    • synthetic tables: an unreadable document (logged once), a missing element, a record past the table, an empty viewBox, only elements a font must not use, a use loop;
    • colour syntaxes and a fitted viewBox;
    • a gradient fading from a transparent stop, whose colours are premultiplied;
    • a shape drawn through use twice beside a path with the same data, parsed once;
    • masks in bounding-box content units and user-space units, one naming nothing and one drawing nothing.
  • NumericStringTest: whole numbers, rounding, trailing zeros, -0, and no decimal places.
  • ColorFontTest: the SVG font's image, shading and mask as written to the PDF, and the England flag's outline.
  • ColorFormatsTest: SVG is detected and drawn.
  • Snapshots: color-emoji-svg, now labelled, and color-emoji-svg-noto.

🤖 Generated with Claude Code

@jakejackson1
jakejackson1 added this pull request to stack #301 September 22, 2026 00:18
@jakejackson1 jakejackson1 added documentation Improvements or additions to documentation enhancement New feature or request create-upstream-pr labels Sep 22, 2026
@jakejackson1
jakejackson1 removed this pull request from stack #301 September 22, 2026 00:27
@jakejackson1
jakejackson1 added this pull request to stack #302 September 22, 2026 00:27
Base automatically changed from feature/76-colrv1 to gravitypdf September 22, 2026 02:12
@jakejackson1
jakejackson1 force-pushed the feature/76-svg branch 6 times, most recently from 77a06bb to d4b2d38 Compare September 22, 2026 02:45
@jakejackson1
jakejackson1 force-pushed the feature/76-svg branch 2 times, most recently from 6c41a97 to 8205e99 Compare September 22, 2026 03:29
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakejackson1
jakejackson1 merged commit 8c443ff 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