Skip to content

Compare the managed font reader with GDI on Windows and fix the differences (#412, step 6) - #472

Open
dsn27 wants to merge 7 commits into
feature/variable-fontsfrom
feature/gdi-glyph-comparison
Open

dsn27 wants to merge 7 commits into
feature/variable-fontsfrom
feature/gdi-glyph-comparison

Conversation

@dsn27

@dsn27 dsn27 commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator

Part of #412, step 6 (fonts). This PR is stacked on #471 (variable fonts), so its base is feature/variable-fonts. It will be retargeted once #471 is merged.

The comparison

GdiFontComparisonTests (Windows only, #if WINDOWS) compares the ManagedFontProvider with GDI, which is how CADability got its fonts before #412.

Test What it compares
TheManagedProviderKnowsAllFamiliesOfGdi Every family name that GDI+ lists must resolve to a managed face, including names that GDI cuts to 31 characters.
FacesMatchGdi 12 common fonts in every real style, covering TrueType, a collection (Cambria), variable fonts (Bahnschrift, Segoe UI Variable) and a STAT-named instance.
AllInstalledFontsMatchGdi All installed families (131 on the runner), with fewer characters.

FacesMatchGdi checks the metrics, the advance widths, the outlines and the underline and strikeout lines. AllInstalledFontsMatchGdi checks the metrics, the advance widths and a few outlines.

The references and tolerances:

  • Metrics: the values of GDI+, exactly.
  • Advance widths: GetCharABCWidths, within 1 font unit.
  • Underline and strikeout: the last figure of GraphicsPath.AddString, within 2 font units.
  • Outlines: GDI's unhinted outlines (GetGlyphOutline with GGO_NATIVE | GGO_UNHINTED), Hausdorff distance within 2 font units.

GDI+ is not the reference for the outlines because AddString returns hinted outlines. In strongly hinted fonts these differ from the design by a few font units, for example 17 units for '2' in Calibri and 42 for 'g' in Constantia. For Segoe Script they are simply wrong, up to 641 units. The managed reader matches the unhinted outlines exactly, with a distance of 0 in every case checked.

What the first Windows run found, and the fixes

The causes were confirmed against the raw font tables with a throwaway diagnostic program on the Windows runner (branch diag/gdi-fonts, not for merging):

  1. Family names cut to 31 characters. GDI cuts family names to 31 characters (LF_FACESIZE), e.g. "Bahnschrift SemiBold SemiConden", and texts saved with GDI keep that name. The managed provider now resolves such a name to the only family whose name starts with it.
  2. USE_TYPO_METRICS. When bit 7 of fsSelection is set, GDI+ takes the cell (ascent, descent) and the line spacing from the typographic metrics of OS/2, not from usWinAscent and usWinDescent. This affects Bahnschrift, Cascadia, Sitka, Cambria Math, Gabriola, Nirmala Text and Sans Serif Collection. This changes the line layout of texts in these fonts, to what GDI+ did before Get the fonts of texts from a font service of the kernel instead of GDI #468.
  3. Underline and strikeout positions. GDI+ centers both lines on the positions of post and OS/2. Before, they were drawn below those positions.
  4. The glyph origin. When the xMin of a glyph differs from its left side bearing in hmtx, Windows (and FreeType and fontTools) moves the outline so that the origin is at 0. This affects Times New Roman Italic and Symbol. A composite glyph with USE_MY_METRICS takes the origin of that component instead (Times New Roman Bold 'Ü'). The varied origin of a variable font is taken into account.
  5. Names of variable instances from STAT. Windows builds the names from the axis values in the axis ordering of STAT and leaves out the elidable ones. For example, the fvar instance "Light Display" of Segoe UI Variable becomes "Segoe UI Variable Display Light". Without a usable STAT table, the subfamily name of fvar is used as before.
  6. MVAR. It is now applied to the metrics of an instance: typographic and Windows ascent and descent, underline, and strikeout. Bahnschrift varies the size of its strikeout line this way. This removes the "MVAR is not applied" limitation of Read variable fonts: gvar, CFF2, avar and HVAR (#412, step 6) #471.

One case is a GDI artifact rather than a reader difference. With FW_NORMAL, GDI emboldens a lighter face such as the instance "Cascadia Code ExtraLight". The test therefore creates the GDI font with FW_DONTCARE for a regular face.

Tests on Linux

Each fix has a test that runs on every platform:

  • the origin, by patching the hmtx of Liberation Sans in memory;
  • USE_MY_METRICS, through Liberation Sans 'Ü';
  • USE_TYPO_METRICS, by patching fsSelection;
  • the centered underline;
  • the STAT ordering and elision, by patching the STAT of VarTestSans;
  • MVAR, against fontTools' instancer values, which were added to VarTestSans.reference.json by make_variable_test_fonts.py.

Mutation checks confirmed that these tests catch the following when they are switched off: the origin shift, USE_MY_METRICS, MVAR, the STAT ordering and the elision.

Results

  • Windows (diag/gdi-fonts run of this head): CADability.Tests 675 passed, 2 skipped, 0 failed. This includes the GDI comparison and the reference bitmaps.
  • Linux, local: CADability.Tests 651 passed, 12 skipped; CADability.ImportTests 88 passed. CADability.Forms builds.

The Windows CI job (#469) is not part of this stack, so this PR's own CI runs on Linux only. The integration draft #467 runs both.

No public API change. TrueTypeFont and FontVariations are internal.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YXvDW91oXpy7RquGHNxgR5


Generated by Claude Code

GdiFontComparisonTests checks on the fonts installed on Windows that the
ManagedFontProvider, the default since the font service, gives the same
results as the GdiFontProvider, which is how CADability got its fonts
before:

- every family GDI+ knows is known to the managed provider;
- for a list of common fonts (TrueType, a collection and variable fonts),
  in every real style: the metrics, the advance widths (within one font
  unit) and the outlines (Hausdorff distance within two font units),
  including underline and strikeout;
- for all installed families: the metrics, the advance widths and the
  outlines of a few characters.

The tests only compile for net8.0-windows and run in the Windows CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXvDW91oXpy7RquGHNxgR5
GDI cuts family names to 31 characters (LF_FACESIZE), e.g. "Bahnschrift
SemiBold SemiConden", and texts saved with GDI keep that name. The
ManagedFontProvider now resolves such a name to the only family whose
name starts with it. The comparison with GDI checks that every family
name of GDI resolves to a face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXvDW91oXpy7RquGHNxgR5
The comparison with GDI on Windows found three differences, which a
diagnostic run confirmed against the raw font tables:

- Fonts that set USE_TYPO_METRICS (bit 7 of fsSelection) get their cell
  and line spacing from the typographic metrics of OS/2 in GDI+, not from
  usWinAscent and usWinDescent (Bahnschrift, Cascadia, Sitka, Cambria
  Math, Gabriola, Nirmala Text).
- GDI+ centers the underline and the strikeout line on the positions of
  "post" and "OS/2"; they were drawn below them.
- When xMin of a glyph is not its left side bearing in "hmtx", Windows,
  FreeType and fontTools move the outline so that the origin is at 0
  (Times New Roman Italic, Symbol). The varied origin of a variable font
  is taken into account.

The Windows test now compares the outlines with the unhinted outlines of
GDI (GetGlyphOutline with GGO_UNHINTED): GraphicsPath.AddString returns
hinted outlines, which differ from the design by a few font units in
strongly hinted fonts and are wrong for Segoe Script, while the managed
reader matches the unhinted outlines exactly. The underline and strikeout
lines are still compared with GDI+.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXvDW91oXpy7RquGHNxgR5
The comparison with GDI on Windows found more differences:

- Windows names the instances of a variable font from "STAT": the names
  of the axis values in the axis ordering of "STAT", without the elidable
  ones. Segoe UI Variable has the instance "Light Display" in "fvar",
  which Windows calls "Segoe UI Variable Display Light". Without a usable
  "STAT" table the subfamily name of "fvar" is used as before.
- "MVAR" is now applied to the metrics of an instance (typographic and
  Windows ascent and descent, underline, strikeout). Bahnschrift varies
  the size of its strikeout line.
- A composite glyph with USE_MY_METRICS takes its origin from that
  component (Times New Roman Bold 'Ü').

The Windows test creates the GDI font with FW_DONTCARE for a regular
face, because FW_NORMAL makes GDI embolden a lighter face such as
"Cascadia Code ExtraLight".

The strikeout values of the variable test fonts at their instances were
added to the reference data with fontTools' instancer.

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

This branch has not been deployed

No deployments
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