Skip to content

Read variable fonts: gvar, CFF2, avar and HVAR (#412, step 6) - #471

Merged
dsn27 merged 21 commits into
masterfrom
feature/variable-fonts
Oct 19, 2026
Merged

dsn27 merged 21 commits into
masterfrom
feature/variable-fonts

Conversation

@dsn27

@dsn27 dsn27 commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #412, step 6 (fonts). This PR is stacked on #470 (CFF outlines). Its base is feature/cff-outlines, so the diff shows only the variable font work. It will be retargeted once #470 is merged.

What changes

ManagedFontProvider now reads variable OpenType fonts. Each named instance of a variable font becomes a face of its own, named the way GDI names it:

  • "Bold" and "Italic"/"Oblique" in the instance name become the style.
  • "Regular" is dropped.
  • The remaining words are appended to the family name, so "Light Condensed" of "Bahnschrift" becomes the family "Bahnschrift Light Condensed".
  • The instances of an italic font stay italic.

This removes the limitation described in #468:

  • TrueType variable fonts such as Bahnschrift and Segoe UI Variable no longer show up as their default instance only.
  • CFF2 fonts are no longer left out of the font list.

Parsing

  • FontVariations (new, internal) parses fvar, avar, the gvar header and HVAR. It normalizes user coordinates through avar and computes tuple scalars with the same semantics as fontTools' supportScalar, covering both peak and intermediate regions.
  • TrueType outlines apply gvar deltas:
    • shared and private packed point numbers, and packed deltas;
    • interpolation of untouched points (IUP);
    • the four phantom points;
    • deltas on the offsets of composite components.
  • CFF2 outlines in CffOutlines: 32-bit INDEX counts, the Top DICT outside an INDEX, FDSelect format 4, vsindex and blend with the ItemVariationStore.
  • Advance widths come from HVAR (with DeltaSetIndexMap). Without HVAR they come from the phantom points. They are fractional and not rounded.
  • TrueTypeFont gets internal IsVariable, Axes, NamedInstances, NormalizedCoordinates and WithVariation(double[]). GetAdvanceWidth now returns double.

Not included

Public API

No public API is removed or changed. ManagedFontProvider simply lists more faces than before.

Tests

Test fonts are in tests/CADability.Tests/Files/Fonts/:

  • VarTestSans.ttf (gvar + HVAR), VarTestSans-NoHVAR.ttf (phantom-point advances) and VarTestSans.otf (CFF2).
  • They are subsets of Source Sans 3 VF (OFL-1.1) taken from the npm package source-sans. They are renamed to "VarTest Sans" because "Source" is a Reserved Font Name.
  • make_variable_test_fonts.py creates the fonts and writes their reference outlines and advance widths with fontTools.
  • The REUSE.toml entries are added.

VariableFontTests checks:

  • the axes, the named instances and the header-only reading;
  • the advance widths of every character at every instance against fontTools: exact with HVAR; within 0.5 without HVAR, because fontTools rounds phantom-point widths;
  • the CFF2 outlines command by command (1e-5);
  • the TrueType outlines by Hausdorff distance (1e-3 font units) at a named instance and at two locations between instances;
  • that the varied outlines really differ from the default;
  • that the provider exposes the instances as GDI-named faces;
  • IUP with a made-up glyph, because the subsets have no untouched points outside their reference points.

Mutation checks that make tests fail:

  • IUP switched off;
  • IUP without clamping;
  • wrong intermediate scalar;
  • blend ignoring deltas;
  • "Regular" not dropped;
  • "Bold" not recognized.

Beyond the test subsets, the full Source Sans 3 VF fonts (Upright and Italic, TTF and OTF; 2,750 glyphs each at 11 locations) were compared against fontTools with a local harness:

  • widths: no difference;
  • CFF2 commands: at most 3e-13;
  • TrueType outlines: Hausdorff distance at most 1.2e-4 font units.

Locally, all 657 tests of CADability.Tests pass (12 skipped, Windows only), and all 88 tests of CADability.ImportTests pass. CADability.Forms builds.

A second commit corrects the documentation of HasOutlines and HasCffOutlines, which still said that CFF2 cannot be read.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YXvDW91oXpy7RquGHNxgR5

First part of step 6 of #412. Text gets its glyphs from GDI today
(GraphicsPath.AddString, gdi32 for widths and kerning), which does not
exist outside Windows. The new internal class TrueTypeFont reads font
files itself:

- .ttf files and every font of a .ttc collection; CFF-based .otf and
  bitmap-only fonts are recognized and report HasTrueTypeOutlines=false
- glyph contours from glyf/loca with composite glyphs resolved (offsets,
  scales, 2x2 transforms, point matching)
- cmap formats 0, 4, 6 and 12, including symbol fonts
- advance widths (hmtx), metrics (head, hhea, OS/2) and names (name)
- kerning pairs of the version 0 kern table, as GDI reads them
- GetPathData returns the outline in the layout of a GDI+ GraphicsPath
  after AddString, so GlyphShapeBuilder.PathDataToPath2D can take it
  unchanged; the quadratic segments become cubic ones by exact degree
  elevation

Nothing uses it yet: the font service that replaces GDI in Text and
FontCache follows in the next pull request.

The tests compare names, metrics, glyph indices, advance widths, all
contours of 112 characters and the kerning with reference data that
fontTools wrote from the same font (Liberation Sans, SIL OFL 1.1, added
under tests/CADability.Tests/Files/Fonts together with the script that
wrote the reference). They also check that the outline is exactly the
quadratic curves, a font collection, and invalid input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXvDW91oXpy7RquGHNxgR5
…DI (#412)

Second part of step 6 of #412. Text and FontCache no longer use GDI or
GDI+: the glyph outlines, advance widths, kerning, metrics and the list
of font families come from the static, settable FontProvider.Current,
not from FrameImpl.MainFrame, so texts work without a frame and on
every platform.

- FontProvider and FontFace (public, new) describe what the kernel
  needs. A face returns its glyphs as Bezier path data in the layout of
  GraphicsPath.AddString, which the kernel turns into geometry with
  GlyphShapeBuilder as before, so no provider can flatten the outlines.
- ManagedFontProvider (public, new) is the default on all platforms,
  as decided for #412. It indexes the TrueType fonts of the system font
  directories by reading only their header tables, and loads a font
  file when a face is used. Files and font data can be added.
- FontCache no longer creates a GDI device context in its constructor
  or deletes it in a finalizer, which ended the process outside Windows.
- Without any font, painting and the extent of a text leave it out;
  GetOutline and similar explicit requests throw an
  InvalidOperationException that says what to do.
- ShowPropertyFont and DimensionStyle list the families of the provider.
- CADability.Forms.GdiFontProvider (public, new) is the old GDI
  implementation for applications that want it.

TrueTypeFont reads the underline and strikeout metrics, adds both as
rectangles like GDI+ does, and can read only the header tables of a
font file.

Tests: both test projects register Liberation Sans in an assembly
initializer, so texts have a font even on a machine without fonts. The
text tests that were inconclusive on Linux ("text needs GDI") now run
there; new FontProviderTests cover text without a frame, the default
face for unknown fonts, the exception without fonts, underline and
strikeout, style fallback, header-only reading and the finalizer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXvDW91oXpy7RquGHNxgR5
)

Step 6, part 2b of #412. The managed reader of #466 skipped OpenType
fonts with CFF outlines, which many .otf files have, so a text in such
a font fell back to the default face. The new internal CffOutlines
reads the "CFF " table:

- INDEX and DICT structures, the FontMatrix (which is stored rounded,
  e.g. 0.00048828 for 1/2048)
- the Type 2 charstring interpreter: all path operators including the
  specialized curves and lines, flex, hflex, hflex1, flex1, local and
  global subroutines with their bias, widths, hints and hint masks
  (skipped, they do not change the outline), the arithmetic operators
- CID-keyed fonts with FDArray and FDSelect (formats 0 and 3)

The cubic curves go into the GDI+ path data unchanged. Not supported:
CFF2 (variable OpenType fonts) and the deprecated accent composition of
endchar (seac). TrueTypeFont gets HasCffOutlines and HasOutlines, and
ManagedFontProvider indexes .otf and .otc files too.

Tests: make_cff_test_fonts.py makes two small test fonts from a subset
of Inter (SIL OFL 1.1): InterCffTest.otf rewritten to use subroutines,
the specialized operators, flex and hint masks, and InterCidTest.otf,
the same glyphs as a CID-keyed font with two font dicts. The outlines
of both are compared command by command with fontTools. Checked
locally against fontTools on 18,672 glyphs of the 47 CFF fonts of the
Linux container, 7 of them CID-keyed: all equal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXvDW91oXpy7RquGHNxgR5
The managed font reader now handles variable OpenType fonts. The named
instances of a variable font become faces of their own, with family names
as GDI makes them ("Bahnschrift Light Condensed", "Segoe UI Variable
Display Semibold"), so these fonts no longer fall back to their default
instance or, for CFF2, disappear from the font list.

- FontVariations parses fvar, avar, the gvar header and HVAR, normalizes
  user coordinates and computes tuple scalars like fontTools'
  supportScalar (peak and intermediate regions).
- TrueType outlines: gvar deltas with shared and private point numbers,
  packed deltas, interpolation of untouched points (IUP), phantom points
  and the offsets of composite components.
- CFF2 outlines: 32-bit INDEX counts, the Top DICT outside an INDEX,
  FDSelect format 4, vsindex and blend with the ItemVariationStore.
- Advance widths come from HVAR, or from the phantom points without HVAR.
  They are not rounded. MVAR is not applied.

The test fonts are subsets of Source Sans 3 VF (OFL-1.1), renamed to
"VarTest Sans" because "Source" is a Reserved Font Name. Their reference
outlines and advance widths were written with fontTools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXvDW91oXpy7RquGHNxgR5
Resolves the conflict with #497 in FontCache: the managed font calls of
this branch now run under the same lock (this) that #497 introduced, and
Clear disposes the glyph display lists and empties all caches, including
the faces. FontProvider.Current, which calls Clear, documents that the
display lists of the views need to be rebuilt when it is set after texts
were painted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXvDW91oXpy7RquGHNxgR5
claude added 12 commits October 17, 2026 08:50
ClearingTheFontCacheDisposesTheGlyphLists reflected on the 'kerning' field
of the GDI FontCache, which this branch replaces by the 'faces' of
FontProvider.Current, so it failed with a NullReferenceException on
Windows. It now checks that Clear empties the faces, and it runs on every
platform, because the font cache no longer needs GDI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXvDW91oXpy7RquGHNxgR5
FontFace.GetAdvanceWidth, GetKerning and GetGlyphPath took a char, a UTF-16
code unit. A character outside the Basic Multilingual Plane, e.g. most emoji,
is a surrogate pair of two chars in a string, so it could never reach a font:
the text went through the font char by char. They now take a code point
(int). FontFace is new public API of this PR, so this is the time to change
it; after a release, every custom font provider would break.

- Text and FontCache go through the text as code points (Text.CodePoints):
  a surrogate pair is one character, a lone surrogate stays the char it is.
- The managed face passes the code point on to the cmap, which already
  supports format 12.
- The GDI face measures a character beyond U+FFFF with
  GetTextExtentPoint32, because GetCharABCWidths only takes UTF-16 chars,
  and reads the kerning pairs as unsigned values.

The new FontCodePointTests check with a recording font provider that "a",
an emoji and "b" reach the font as three characters, with their widths,
kerning and outlines; they fail when the surrogate pair is not combined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXvDW91oXpy7RquGHNxgR5
With StepImport.Parallel, CreateEntity runs on several threads, but it added
mapped items to the HashSet mappedItems and, for assemblies, products to the
HashSet products without a lock (the other place that adds products locks
it). Concurrent Add calls can corrupt a HashSet, and the enumeration of
mappedItems after the creation then met a null entry: a
NullReferenceException in ImportStep.Read.

StepCyclicReferenceTest from #498 imports a file with mapped items 20 times
in parallel and showed this now and then on the 2-core CI runner. With the
test process limited to 2 cores, it failed 3 of 30 times without this change
and 0 of 60 times with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXvDW91oXpy7RquGHNxgR5
(cherry picked from commit 85e21f1)
@dsn27
dsn27 changed the base branch from feature/cff-outlines to master October 19, 2026 10:43
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXvDW91oXpy7RquGHNxgR5
@dsn27
dsn27 merged commit eea2d35 into master Oct 19, 2026
2 checks passed
@dsn27
dsn27 deleted the feature/variable-fonts branch October 19, 2026 10:52
@dsn27 dsn27 mentioned this pull request Oct 19, 2026
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