fix(fonts): refile a face that shadows another design's family - #1936
Merged
Merged
Conversation
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>
Owner
Author
|
Correction to the Cost section: the post-fix timings were two warm runs, 0.88s and 1.05s, not three — the |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
File submission policy
Summary
A run naming plain
Gill Sans— no style suffix, now:b— was painted and paced in Gill Sans Ultra Bold.The font book files a face under its
nameID 1 with style suffixes trimmed, so Word'sGillSansUltraBold.ttf— whose own name isGill Sans Ultra Bold— lands underGill Sans, beside theGillSans.ttcmembers that carry that name themselves. It declaresusWeightClass400, the class Gill Sans Regular declares:nameID 1usWeightClasshheaasc/desc/gapGillSans.ttcmember 0Gill SansGillSansUltraBold.ttfGill Sans Ultra BoldAn unsuffixed request resolves at
FontVariant::default(), both score a weight distance of 0, andFontBook::find_best_variantkeeps the first index on a tie — so scan order alone decided which of two unrelated designs a document got.Key changes
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 aFontVariantwith that owner. The whole name group moves together, because one name is a whole style group.pdf::discover_fonts,pdf::embedded_fontsandFontSearchContext::with_in_memory_fontsbuild their book through it, so paint, metrics andis_primary_font_availableall read the same filing.font_subst::declared_family_namesreads a face'snameID 1 records, falling back to raw ASCII for a recordttf_parserwill not decode.GillSans.ttc's members carry only a Macintosh record, so without that fallback the owner of theGill Sanskey reads as nameless and nothing can be told apart from it.ttf-parserbecomes a direct dependency ofcrates/office2pdf. It is already in the tree through typst at 0.25.1 — theCargo.lockdiff is a single line adding the edge, no new package and no version change — and it is the only way to read a face'snametable without building a rustybuzz face.Why the tie could not simply be broken by order
Gill Sans Ultra Boldpainted correctly only because of this bug:is_primary_font_availablewasfalse(the book lists no such family), so noweight: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 emitsweight: "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)/11so the export's 0.24pt grid cancels.pdffontsnames the embedded face.Gill SansGillSansGill SansGillSans-UltraBoldGill SansGillSansGill Sans Ultra BoldGillSans-UltraBoldGill Sans Ultra BoldGillSans-UltraBoldGill Sans Ultra Bold+w:bGillSans-BoldGill Sans Ultra Bold+w:bGillSans-UltraBoldResidual 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 Sansfor 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 thenametable, throughttf_parser::name::Table::parse. Building aFontper 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-ExtBmembers, andTw Cen MT→Tw Cen MT CondensedandTw 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 Gothicdesigns 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 failurescargo clippy --locked --workspace --all-targets --profile ci— cleancargo fmt --all -- --check— cleancargo check --locked --target wasm32-unknown-unknown -p office2pdf,--features wasm,--no-default-features --features wasm— cleanpython3 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 deltamainand branch binaries and theirmutool draw -F traceoutput diffed with the<document filename=line stripped: 332 identical, 0 differ. The remaining 57 fail on themainarm too — encrypted and deliberately malformed packages the converter refuses by design — so the branch adds no failure. No tracked fixture names any refiled family.syspolicydthe only foreign process above 10% CPU; arms alternated in fresh processes.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, anda_refiled_face_stops_shadowing_the_family_and_becomes_reachable_by_its_name, which asserts theFontBook::selectcall Typst itself makes — picking the intruder before the rule runs and the owner after.Visual impact
Visual audit
Gill Sanspaints Gill Sans Ultra Bold — two designs tie at weight 400 under the trimmed family key #1837fixassets/bugfixes/issue-1837/layout-audit.jsonassets/bugfixes/issue-1837/render-clusters-page-1.jsonbefore.jpgis a different typeface fromgt.jpg, not a mis-set one — heavy geometric Ultra Bold forms with near-circular bowls, closed0counters, aqwhose tail is a straight vertical stub, stems roughly three times the GT's width, and words so much wider thatHxpq 12ends 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.jpgmatchesgt.jpgline for line — the same light humanist Gill Sans with its flat-topped1, single-storeyqwith a straight tail, narrowx, 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 onpandq. 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.assets/bugfixes/issue-1837/gt.jpgassets/bugfixes/issue-1837/before.jpgassets/bugfixes/issue-1837/after.jpgVisual comparison
Required inspection
Deviation audit
Checklist
Signed-off-byline🤖 Generated with Claude Code