Skip to content

Answer a curated font substitution before building the metadata tier, and stop preloading theme script fonts - #258

Open
ikashapov wants to merge 2 commits into
nerdy-pro:mainfrom
ikashapov:issue-188-font-absent-cost
Open

ikashapov wants to merge 2 commits into
nerdy-pro:mainfrom
ikashapov:issue-188-font-absent-cost

Conversation

@ikashapov

Copy link
Copy Markdown
Contributor

Answer a curated font substitution before building the metadata tier, and stop preloading theme script fonts

Refs #188 — the conversion-time half. The GIL half landed earlier (py.detach in src/lib.rs).

What was slow

A document set in Arial converts in 0.57 s with Arial installed and 44 s with it hidden from fontconfig; peak RSS 50 MB → 297 MB. The cost is not the per-glyph fallback pass — its (face, codepoint) memo is sound and it never asks the host when the substitute covers the text. It is the catalogue's deep tier: the first request for a name the host lacks as a family reaches step 4 of the resolver, which builds tier 2 by instantiating every face on the host and reading four tables from each. On CoreText that is 44 ms. On fontconfig/FreeType every new_typeface is an FT_Open_Face with an mmap, enumerate walks every font on the host per family, and the manager retains up to 1024 opened faces — seconds and hundreds of megabytes, proportional to the host's font count rather than to the document. Only after that scan did step 7 consult the table that answers "Arial → Liberation Sans".

Two things made every Word document pay, not just Arial ones: preload resolved four style variants of every family the document names, and collect_font_families added every per-script font a theme lists — thirty-odd families (宋体, Nyala, Tunga, Vrinda…) that no run can ever request, every one absent on a Linux desktop.

Changes

  1. Step 3b — a substitution-table key whose own family the host lacks is answered from the table before any metadata is read. The guard keeps the metadata path for keys whose base family is present ("Segoe UI Light" with Segoe UI installed still looks for the real Light face). What the shortcut gives up is a face carrying the key only as a typographic or localized alias (Arial Narrow for "Arial"); for a request by family name the metric substitute is the better answer, and the resolver's step table now says so.
  2. family_faces remembers what the manager said about a name it did not enumerate, both ways — each absent family was probed four times, and a matchable unenumerated one gained a duplicate slot per probe that the deep tier then read as well.
  3. host_default is fetched once per effective style instead of once per unresolvable family per variant.
  4. Theme per-script fonts are no longer preloaded. resolve_theme_font_ref maps only the latin / east-asian / complex-script slots, so none of them served a run; a theme font a run does use still resolves lazily through FontCache::get. Output is unchanged; the document stops warning about fonts it never asked for.
  5. The resolver logs which request first reached the metadata tier, and catalog.rs's module doc states that its cost table was measured on CoreText and what the same operations cost on fontconfig.

Evidence

Reproduced in miniature on macOS (debug build, relative numbers): a copy of a five-page document in which every family resolves spends 14–23 ms in the registry; the same document with only its body font absent builds the deep tier (848 faces, 370–480 ms) and doubles wall time, with layout, subsetting and painting unchanged and zero fallback lookups. The Linux magnitude is inferred from Skia's fontconfig port (SkFontMgr_fontconfig.cpp: createTypefaceFromFcPattern, FontAccessible, the 1024-face fTFCache); the reporter's RUST_LOG=debug run before and after — the [font] catalogue deep tier: N faces … line — is the confirmation this PR asks for.

A document naming an absent family that is not a substitution-table key still builds the tier once; bounding that (the typeface-cache limit during the scan) needs a Linux measurement first and is deliberately not in this PR.

Tests

Each watched fail first, with a mutation check:

  • a_table_key_whose_family_is_absent_substitutes_without_the_metadata_tier — "Calibri" with only Carlito installed → Substitution, and deep_is_built() stays false (restoring the old order builds it).
  • a_table_key_whose_base_family_is_present_still_reaches_the_metadata_tier — "Segoe UI Light" with Segoe UI → ParsedFaceName, tier built (dropping the guard turns it into a substitution).
  • an_unenumerable_name_is_probed_once, the_host_default_is_fetched_once_per_style — the two memos, via counters.
  • theme_script_fonts_are_not_preloaded.
  • FaceCatalog::from_faces_without_deep_tier is the test-support constructor that makes "did this request reach the tier" observable at all; from_faces pre-builds it.

… tier

A document set in Arial converts in 0.57 s with Arial installed and 44 s with
it hidden from fontconfig, with peak RSS rising from 50 MB to 297 MB (issue
nerdy-pro#188). The cost is not the per-glyph fallback pass — its memo is sound and it
never asks the host when the substitute covers the text. It is the
catalogue's deep tier: the first request for a name the host lacks as a family
reaches step 4 of the resolver, which builds tier 2 by instantiating every
face on the host and reading four tables from each. On CoreText that is 44 ms.
On fontconfig/FreeType every `new_typeface` is an `FT_Open_Face` with an mmap,
`enumerate` walks every font on the host per family, and the manager retains
up to 1024 opened faces — seconds and hundreds of megabytes, proportional to
the host's font count, not the document. Only after that scan did step 7
consult the table that answers "Arial → Liberation Sans".

Three changes, each pinned by a test watched failing first:

* Step 3b: a substitution-table key whose own family the host lacks is
  answered from the table before any metadata is read. The guard keeps the
  metadata path for keys whose base family is present — "Segoe UI Light" with
  Segoe UI installed still looks for the real Light face. What the shortcut
  gives up is a face carrying the key only as a typographic or localized alias
  (Arial Narrow for "Arial"); for a request by family name the metric
  substitute is the better answer, and the step table says so.
* `family_faces` remembers what the manager said about a name it did not
  enumerate, both ways. `preload` asks four style variants of every family,
  so each absent family was probed four times — and a *matchable* one gained
  a duplicate slot per probe, which the deep tier then read as well.
* `host_default` is fetched once per effective style; every unresolvable
  family reached `legacy_make_typeface` four times.

Reproduced in miniature here: a copy of a five-page document in which every
family resolves spends 14–23 ms in the registry; the same document with only
its body font absent builds the deep tier (848 faces, 370–480 ms) and doubles
wall time. The Linux magnitude remains inferred from Skia's fontconfig port;
the reporter's `RUST_LOG=debug` "catalogue deep tier" line is the
confirmation, and the resolver now also logs which request first reached the
metadata tier.

Refs nerdy-pro#188
`collect_font_families` added every §20.1.4.1.16 script font a theme lists —
thirty-odd families in an ordinary Word theme (宋体, Nyala, Tunga, Vrinda,
Shruti, Microsoft Himalaya…) — and `preload` resolved four style variants of
each. Nothing can request one: `resolve_theme_font_ref` maps only the latin,
east-asian and complex-script slots, so every one of those resolutions served
no run. On a host without them every one was an absent family, and on
fontconfig the first absent family builds the font metadata tier (issue nerdy-pro#188),
so every themed document paid that scan — with the body font installed or not.

Collect the three slots and leave the script fonts alone. A theme font a run
does use still resolves on first measurement through `FontCache::get`, so
output is unchanged; what changes is that a document no longer warns about
fonts it never asked for. On this host the registry phase of a five-page
themed document goes from building the tier to not building it.

Refs nerdy-pro#188
ikashapov added a commit to ikashapov/dxpdf that referenced this pull request Oct 14, 2026
One squashed commit carrying the tree of integration/2026-10-14, which was
previously a chain of ten merges ending at ac5787e (kept locally as
archive/integration-2026-10-14-merges). Base: upstream main 3a5c7ca.

Integrated, each at the head of its open upstream pull request:

- nerdy-pro#252  fix-layout-defects-228-232 (a40d5ce) — issues nerdy-pro#228–nerdy-pro#232: bookmark-only
        paragraphs, nested-table width, list instances over one abstract
        numbering, table width resolution (pct and tcW autofit), column
        balancing and separators
- nerdy-pro#183  issue-150-wmf-svg (6e5aa99) — WMF/EMF and SVG images
- nerdy-pro#184  issue-151-pgborders-docgrid (27204b3) — page borders, document grid
- nerdy-pro#186  issue-153-shaped-clusters (65739f6) — shaped text clusters
- nerdy-pro#187  issue-154-tracked-changes (6982b34) — tracked changes and comments
- nerdy-pro#185  issue-155-smartart-charts (3683223) — SmartArt and charts
- nerdy-pro#256  issue-253-del-instr-text (0656a52) — w:delInstrText tolerated
- nerdy-pro#257  issue-250-image-hyperlinks (cb5ce2b) — a:hlinkClick on pictures
- nerdy-pro#258  issue-188-font-absent-cost (9a63bf0) — no deep catalogue tier before
        the substitution table, no theme script-font preload
- nerdy-pro#259  issue-158-auto-hyphenation (9bfbae2) — w:autoHyphenation

Beyond the union of those branches, the integration itself changed:
- README keeps one row per feature after the merges; the matrix tally is
  recomputed from the rows.
- nerdy-pro#253's tests assert that a deleted field code is never drawn and never run
  text, because nerdy-pro#154 keeps w:del runs instead of dropping them at parse.
- nerdy-pro#250's push_link_over sits after nerdy-pro#151's resolve_line_metrics; nerdy-pro#158's
  HyphenationCtx.measure and rejoin_hyphenated take &dyn SplitMeasure (nerdy-pro#153).

Verified on this exact tree: cargo fmt, clippy and doc clean; cargo test
2977 passed / 0 failed (skipping the capi library freshness guards);
--no-default-features and --features capi build; the twelve-document
harness passes 12/12.

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.

1 participant