Skip to content

fix(fonts): refile a face that shadows another design's family - #1936

Merged
developer0hye merged 1 commit into
mainfrom
fix/issue-1837-gill-sans-family-tie
Sep 28, 2026
Merged

developer0hye merged 1 commit into
mainfrom
fix/issue-1837-gill-sans-family-tie

Conversation

@developer0hye

Copy link
Copy Markdown
Owner

File submission policy

  • Any submitted sample files or attachments satisfy the submission policy, or none are submitted.

Summary

A run naming plain Gill Sans — no style suffix, no w:b — was painted and paced in Gill Sans Ultra Bold.

The font book files a face under its name ID 1 with style suffixes trimmed, so Word's GillSansUltraBold.ttf — whose own name is Gill Sans Ultra Bold — lands under Gill Sans, beside the GillSans.ttc members that carry that name themselves. It declares usWeightClass 400, the class Gill Sans Regular declares:

face file name ID 1 usWeightClass hhea asc/desc/gap
Gill Sans Regular GillSans.ttc member 0 Gill Sans 400 1880/−472/0
Gill Sans Ultra Bold GillSansUltraBold.ttf Gill Sans Ultra Bold 400 2036/−516/0

An unsuffixed request resolves at FontVariant::default(), both score a weight distance of 0, and FontBook::find_best_variant keeps the first index on a tie — so scan order alone decided which of two unrelated designs a document got.

Key changes

  • New font_subst::refile_faces_shadowing_their_family(infos, declared_family) refiles a face under the name it declares itself. It moves one only when all four hold: the face's own name is not the key it was filed under; trimming that name does reach the key (so a Typst PostScript-name exception is never undone); another member declares the key as its own name (the family has an owner); and the intruder's name group shares a FontVariant with that owner. The whole name group moves together, because one name is a whole style group.
  • pdf::discover_fonts, pdf::embedded_fonts and FontSearchContext::with_in_memory_fonts build their book through it, so paint, metrics and is_primary_font_available all read the same filing.
  • font_subst::declared_family_names reads a face's name ID 1 records, falling back to raw ASCII for a record ttf_parser will not decode. GillSans.ttc's members carry only a Macintosh record, so without that fallback the owner of the Gill Sans key reads as nameless and nothing can be told apart from it.
  • ttf-parser becomes a direct dependency of crates/office2pdf. It is already in the tree through typst at 0.25.1 — the Cargo.lock diff is a single line adding the edge, no new package and no version change — and it is the only way to read a face's name table without building a rustybuzz face.

Why the tie could not simply be broken by order

Gill Sans Ultra Bold painted correctly only because of this bug: is_primary_font_available was false (the book lists no such family), so no weight: was emitted, Typst selected at 400 and won the very tie reported here. Reordering alone would have regressed it to Gill Sans Regular outlines while its metrics correctly stayed on Ultra Bold. Refiling fixes the emitted family list and the tie together — the family becomes available, the run emits weight: "extrabold", and the single member under that key answers.

Measurement

Twelve single-spaced 20pt paragraphs, baselines from mutool draw -F trace, pitch as (last − first)/11 so the export's 0.24pt grid cancels. pdffonts names the embedded face.

request embedded face pitch em
Gill Sans native Word 16 (GT) GillSans 22.975pt 1.148727
Gill Sans before GillSans-UltraBold 24.9219pt 1.246094
Gill Sans after GillSans 22.9688pt 1.148438
Gill Sans Ultra Bold native Word 16 (GT) GillSans-UltraBold 24.9164pt 1.245818
Gill Sans Ultra Bold before / after GillSans-UltraBold 24.9219pt 1.246094
Gill Sans Ultra Bold + w:b before GillSans-Bold — —
Gill Sans Ultra Bold + w:b after GillSans-UltraBold — —

Residual on the fixed case is 0.006pt per line, inside the 0.24pt grid the export quantises to. The bold-run row is the third coupling the issue names: the face now agrees with the metrics the run was already paced on.

The CLI also stopped printing Warning: [DOCX] fallback: Gill Sans Ultra Bold rendered as Gill Sans for a font it does in fact resolve.

Cost

The pass reads a name only for a face in a family that holds a repeated FontVariant — 1,058 of this host's 1,581 installed faces — and reads only the table directory and the name table, through ttf_parser::name::Table::parse. Building a Font per face instead instantiated a memoized rustybuzz face apiece and cost 5.3s a process; reading whole files still cost 1s. As committed, a single small DOCX conversion goes from 0.68/0.69/0.70s to 0.88/1.05/0.95s wall clock, and the cost is paid once per process because font discovery is cached.

Scope

Three families are refiled on this host: Gill Sans → Gill Sans Ultra Bold, MingLiU/PMingLiU/MingLiU_HKSCS → their -ExtB members, and Tw Cen MT → Tw Cen MT Condensed and Tw Cen MT Condensed Extra Bold. Each is a distinct design that was previously reachable only by scan order.

A separate defect found while probing this one is filed as #1935: three Franklin Gothic designs tie at 400 under a key no face declares, so this rule deliberately declines to act on them — emptying that key would leave the request with no face at all rather than the wrong weight of the right one.

Related issue

Related: #1837
Related: #1935

Testing

  • cargo test --locked --workspace --profile ci — 3359 lib + 621 integration tests, 0 failures
  • cargo clippy --locked --workspace --all-targets --profile ci — clean
  • cargo fmt --all -- --check — clean
  • cargo check --locked --target wasm32-unknown-unknown -p office2pdf, --features wasm, --no-default-features --features wasm — clean
  • python3 scripts/compare_layout.py --json --audit --fine-shift 0.25, compare_render.py --page 1 --dpi 300 --fine-shift 0.25 --cluster-report … --cluster-dispositions … --strict-clusters, compare_text_layer.py — text layer intact, no codepoint-class or content delta
  • Corpus no-regression sweep: all 389 tracked DOCX/XLSX/PPTX fixtures converted with the main and branch binaries and their mutool draw -F trace output diffed with the <document filename= line stripped: 332 identical, 0 differ. The remaining 57 fail on the main arm too — encrypted and deliberately malformed packages the converter refuses by design — so the branch adds no failure. No tracked fixture names any refiled family.
  • Machine state for the timing numbers: 66% memory free, no other build running, syspolicyd the only foreign process above 10% CPU; arms alternated in fresh processes.
  • New tests: a_face_shadowing_another_designs_family_is_refiled_under_its_own_name, a_family_no_face_claims_by_name_keeps_every_member_where_the_book_filed_it, a_face_the_nearest_weight_search_can_tell_apart_stays_in_its_family, every_face_sharing_a_refiled_name_moves_with_it, a_face_whose_name_is_unreadable_is_left_where_the_book_filed_it, and a_refiled_face_stops_shadowing_the_family_and_becomes_reachable_by_its_name, which asserts the FontBook::select call Typst itself makes — picking the intruder before the rule runs and the owner after.

Visual impact

  • No rendered PDF change
  • Rendered PDF change or visual evidence added
  • Reason:

Visual audit

  • Issue: Fonts: plain Gill Sans paints Gill Sans Ultra Bold — two designs tie at weight 400 under the trimmed family key #1837
  • Fixture: tests/fixtures/docx/issue_1837_unsuffixed_family_tie.docx
  • Page(s): 1
  • Renderer and DPI: pdftoppm, 150 DPI for the committed evidence; compare_render.py cluster sweep at 300 DPI
  • Evidence mode: fix
  • Layout audit report: assets/bugfixes/issue-1837/layout-audit.json
  • Render cluster reports: assets/bugfixes/issue-1837/render-clusters-page-1.json
  • Reference exporter differences: None
  • Fine-detail threshold: 0.25pt
  • Layout audit page count: Pass
  • Layout audit text flow: Pass
  • Layout audit visible fills: Pass
  • Layout audit rectangle geometry: Pass
  • Layout audit large shifts: Pass
  • Layout audit fine shifts: Pass
  • New follow-up issues found in this audit: None
  • Model vision findings: before.jpg is a different typeface from gt.jpg, not a mis-set one — heavy geometric Ultra Bold forms with near-circular bowls, closed 0 counters, a q whose tail is a straight vertical stub, stems roughly three times the GT's width, and words so much wider that Hxpq 12 ends 80 pixels further right at 150 DPI. The block is also taller: twelve lines end 23.4pt (about 49 pixels) below the GT's last baseline, and the first baseline already sits 1.5pt low. after.jpg matches gt.jpg line for line — the same light humanist Gill Sans with its flat-topped 1, single-storey q with a straight tail, narrow x, and the same word spacing and left edge on all twelve rows; the block starts and ends where the GT's does. A matched full-resolution crop of lines 1-4 cut from the 300 DPI GT and output pages at the same offset shows indistinguishable outlines, identical stem weight and identical descender depth on p and q. The 5% fuzz diff image at 300 DPI shows each glyph as a pale grey body ringed by a thin one-pixel darker outline, with no displaced ghost glyph, no filled interior and no cluster of 20pt² or more: 20,315 differing pixels, 0.23% of the page against 0.81% ink coverage — roughly one pixel of perimeter per glyph, which is edge rasterisation rather than geometry. This fixture has no rule, hairline, dash pattern, border or fill to inventory, and no italic or underlined run; the only emphasis is the face's own weight, which now matches. The remaining numeric residual is a worst dy of −0.15pt and 0.17pt of pitch drift, both from the GT alternating on Word's 0.24pt export grid while we emit a constant 22.9688pt.
  • GT: assets/bugfixes/issue-1837/gt.jpg
  • Before: assets/bugfixes/issue-1837/before.jpg
  • After: assets/bugfixes/issue-1837/after.jpg
  • Native: None

Visual comparison

GT Before After
GT Before After

Required inspection

  • Rendered all evidence at 150 DPI or higher
  • Stored progressive JPEG quality 86 assets with metadata stripped
  • Used Codex/Claude vision to inspect the full GT/output pages, diff, and matched crops
  • Inspected matched region crops at full resolution
  • Ran compare_layout.py --audit --fine-shift PT and dispositioned every fine/large text-instance shift, rectangle geometry deviation, painted-text visibility mismatch, and visible-fill occlusion
  • Ran compare_render.py --cluster-report PATH --strict-clusters and dispositioned every material 5% fuzz diff cluster by explicit ID
  • Inventoried hairlines and border dash styles
  • Inventoried font weight, italic, and underline emphasis

Deviation audit

Check Result
Page count/order Matches GT
Element presence Matches GT
Position/size Fixed
Rotation/flip No deviation observed
Fill No deviation observed
Stroke/border No deviation observed
Shape outline geometry No deviation observed
Text content Matches GT
Font family/weight/style Fixed
Text color Matches GT
Alignment Matches GT
Line/paragraph spacing Fixed
Clipping/overflow No deviation observed

Checklist

  • Commits include a Signed-off-by line
  • PR scope contains one root cause
  • Remaining converter or harness deviations each reference an open issue

🤖 Generated with Claude Code

A run naming plain `Gill Sans` was painted and paced in Gill Sans Ultra
Bold. The font book files a face under its `name` ID 1 with style suffixes
trimmed, so Word's `GillSansUltraBold.ttf` -- named `Gill Sans Ultra Bold`
-- lands under `Gill Sans` beside the `GillSans.ttc` members that carry
that name themselves. It declares `usWeightClass` 400, the class Gill Sans
Regular declares, so an unsuffixed request scores both at a weight
distance of 0 and `FontBook::find_best_variant` keeps whichever the scan
pushed first. Book order alone decided which of two unrelated designs a
document got: twelve single-spaced 20pt paragraphs advanced 24.922pt --
Ultra Bold's 1.246094em `hhea` sum -- against a native Word 16 export's
22.975pt, and every glyph was the wrong weight.

`font_subst::refile_faces_shadowing_their_family` refiles such a face
under the name it declares itself. It moves a face only where the family
has an owner -- another member declaring the family key as its own name --
and where the intruder's name group shares a `FontVariant` with that
owner, so the key plus a variant genuinely cannot tell the two apart. A
family no face claims by name keeps every member, and a weight member the
nearest-weight search already reaches, such as `Calibri Light` at 300
against Calibri's 400, is left alone (issues #1286, #1643).

`pdf::discover_fonts`, `pdf::embedded_fonts` and
`FontSearchContext::with_in_memory_fonts` build their book through it, so
paint, metrics and `is_primary_font_available` all read the same filing.
`Gill Sans Ultra Bold` now reaches its own file by name rather than by
winning the tie this removes: the family becomes available, the run emits
`weight: "extrabold"`, and the same run with `w:b` composes to 800 and
paints Ultra Bold instead of Gill Sans Bold.

Reading a face's own name goes through `ttf-parser` -- already in the tree
through typst -- and reads only the table directory and the `name` table.
Building a `Font` for each of the host's 1,058 tied faces instead
instantiated a memoized rustybuzz face apiece and cost 5.3s a process;
reading whole files still cost 1s. A Macintosh record in Mac OS Roman is
read while every byte is ASCII, which is what makes `GillSans.ttc`'s
owner legible at all.

Related: #1837
Related: #1935

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Yonghye Kwon <developer.0hye@gmail.com>
@developer0hye
developer0hye merged commit 4a8bb45 into main Sep 28, 2026
21 checks passed
@developer0hye
developer0hye deleted the fix/issue-1837-gill-sans-family-tie branch September 28, 2026 06:36
@developer0hye

Copy link
Copy Markdown
Owner Author

Correction to the Cost section: the post-fix timings were two warm runs, 0.88s and 1.05s, not three — the 0.95s in the table is not a measurement. The pre-fix warm runs were 0.68/0.67/0.68s. The accurate statement is +0.2–0.35s per process on a warm page cache, with the first cold run higher (1.62s observed); the cost is paid once per process because font discovery is cached. The conclusion in the section is unchanged.

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.

1 participant