Skip to content

feat(fonts): use every face of system font collections (.ttc/.otc) - #386

Merged
raroche merged 9 commits into
mainfrom
feat/font-collections
Oct 1, 2026
Merged

raroche merged 9 commits into
mainfrom
feat/font-collections

Conversation

@raroche

@raroche raroche commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Stacked on #385 (task 2). Task 3 of 4.

Problem

The system-font indexer skipped every .ttc / .otc file. Families that ship only as a collection could not be used. On macOS that includes Helvetica, Helvetica Neue, Avenir, Menlo and Optima. A document asking for "Helvetica Neue" fell back to Arial, which is wider, so text wrapped where a browser keeps it on one line (for example "San Francisco, CA 94123" in the tester's 01-cruise sample).

Fix

  • New FontCollection (NetPdf.Text): bounded random-access reader for the ttcf header and each face's table directory. ExtractFace copies one face into a standalone sfnt (tag-sorted directory, 4-byte aligned tables, recomputed head.checkSumAdjustment). The validator, parser, HarfBuzz and the PDF subsetter work unchanged.
  • The enumerator detects collections by content (not extension). It indexes each face from its name / OS/2 / head tables only, so big CJK collections are never read whole. It skips faces with bitmap/SVG tables, missing required tables, or over the 32 MiB cap.
  • SystemFontResolver extracts and validates the chosen face, with one cache slot per face. A face whose full parse fails resolves to nothing, so the stack falls through to the next family.
  • No public API change.

Tests

  • FontCollectionTests (unit): extraction round-trip and checksum, sorted/aligned directory, file vs memory, malformed headers and tables, indexer skips the bad faces, detection by content.
  • Integration: a resolver over a synthetic collection returns the requested face, and an HTML render embeds the right face (SynthColl-Regular / SynthColl-Bold).
  • Host smoke: the real /System/Library/Fonts/HelveticaNeue.ttc indexes and extracts on macOS.
  • Full solution green locally. The tester corpus has the same page counts. Text that names Helvetica Neue now renders in it on macOS.

🤖 Generated with Claude Code

raroche and others added 3 commits September 30, 2026 19:52
…x/abspos content

- PAGINATION-FORCED-OVERFLOW-001 fired on every page of any document whose
  content sits in one wrapper taller than a page: the wrapper is committed
  at the page top only to be entered, and its children paginate normally.
  The top-level report is now deferred for an enterable block-flow / flex
  wrapper and emitted only if its content really did not paginate (using
  the emitted extent on a resumed page). A nested box whose OWN border box
  is taller than a page is reported where it is placed, once.
- Chasing the remaining reports found real content loss: a `flex: 1` list
  in a stretched column card was flexed to ~0, laid out into that budget,
  paginated, and everything after the first item was discarded
  (02-travel-quote). Column items with auto height/min-height now honor the
  4.5 automatic minimum (grown to their content height, shared with the
  pre-measure), and item content measured against the item's own size never
  paginates (NestedContentMeasurer suppressPagination).
- Absolutely positioned content is laid out with pagination suppressed,
  like fixed content, so content taller than the box overflows it instead
  of being cut after the first break.

Corpus: 02-travel-quote now shows every card feature (was 1 of 5-6); no
other page changes. Forced-overflow reports drop from ~160 to 1 across the
28 docs (the remaining one is the measure-width over-estimate, next PR).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The block measure pass (MeasureSubtreeVisualBlockExtentRecursive) laid
every nested text block, flex row, table and multicol out at the BFC (page)
content width. Text that wraps inside a narrow box was measured as one
line, so:
- a narrow box's painted border was too short (lost its bottom padding);
- spacing after such content was squeezed (index.pdf payment block: 4px
  gap instead of the browser's 42px);
- the mid-split entry under-measured a list's first item, entered the
  list at the page bottom, and line-split it leaving a single line.

The containing content width is now threaded through the recursion
(MeasureInlineGeometry mirrors the emit path's
ResolveInFlowBorderBoxInlineSize: explicit / % width, box-sizing,
min/max, else fill minus margins), and used by the inline-only, flex,
table-wrapper and multicol branches, the first-child estimate, and the
keep-with-next lookahead.

Also makes HtmlPdfFacadeTests' slow-loader timeout test deterministic: it
waits for the deadline to pass instead of a fixed 600ms delay (a busy test
host could fire the 100ms timer late and the render finished first).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…c/.otc)

The system-font indexer skipped collection files, so families that ship
only as a collection (macOS Helvetica, Helvetica Neue, Avenir, Menlo,
Optima; Windows/Linux CJK families) fell back to another, often wider,
font.

- FontCollection: bounded random-access reader for the ttcf header and
  per-face directories, and ExtractFace, which copies one face into a
  standalone sfnt (tag-sorted directory, 4-byte aligned tables,
  recomputed head.checkSumAdjustment) so the validator, parser, HarfBuzz
  and the PDF subsetter need no collection awareness.
- The enumerator detects collections by content and indexes each face
  from its name/OS/2/head tables only, skipping faces with rejected
  tables, missing required tables, or over the byte cap.
- SystemFontResolver extracts and validates the chosen face (one cache
  slot per face); a face whose full parse fails resolves to nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 1, 2026 00:17

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 73efbd37f5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

try
{
var bytes = FontCollection.ExtractFace(path, faceIndex);
if (!FontSafetyValidator.Validate(bytes).IsSafe) return ReadOnlyMemory<byte>.Empty;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept Apple TrueType collection faces during validation

When a collection face uses the supported SfntVersionAppleTrue ('true') signature, ReadFaceDirectory accepts it and ExtractFace preserves it, but this validation call always rejects the extracted bytes because FontSafetyValidator.SniffFormat recognizes only 0x00010000 and OTTO. Such a face is therefore indexed successfully but always resolves to null; either normalize the extracted signature or teach the validator to recognize the Apple TrueType form.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7440dc5. ExtractFace now writes Apple's 'true' signature as the standard 0x00010000 (both mean TrueType outlines), so the validator accepts the extracted face. Test: An_apple_true_signature_face_resolves_as_a_standard_truetype_font.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Collection detection can fail on partial reads, and unusable faces can prematurely terminate generic-family fallback.

Review effort: Balanced
Findings: 2 Medium severity · 2 Low severity

Open (4)
What changed in this PR

Adds per-face indexing and extraction for system TTC/OTC font collections.

Changes:

  • Introduces bounded collection parsing and standalone face extraction.
  • Integrates collection faces into system-font indexing, resolution, caching, and validation.
  • Adds extensive tests and updates compatibility documentation.
File Description
src/​NetPdf.Text/​Fonts/​OpenType/​FontCollection.cs Implements collection parsing and face extraction.
src/​NetPdf.Text/​Fonts/​SystemFonts/​SystemFontEnumerator.cs Detects and indexes collection faces.
src/​NetPdf/​Fonts/​SystemFontResolver.cs Extracts, validates, and caches selected faces.
src/​NetPdf.Text/​Fonts/​SystemFonts/​SystemFontEntry.cs Identifies collection-backed entries.
src/​NetPdf.Text/​Fonts/​FontMetadata.cs Supports metadata extraction from individual tables.
src/​NetPdf.Text/​Fonts/​FontSafetyValidator.cs Adds integer-tag denylist checking.
tests/​NetPdf.UnitTests/​Text/​Fonts/​OpenType/​FontCollectionTests.cs Covers extraction, indexing, resolution, and rendering.
docs/​compatibility-matrix.md Documents collection support.
CHANGELOG.md Announces system-font collection support.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +141 to +143
using var handle = File.OpenHandle(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
Span<byte> tag = stackalloc byte[4];
return RandomAccess.Read(handle, tag, 0) == 4 && FontCollection.IsCollection(tag);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7440dc5: the 4-byte sniff now loops until it has all 4 bytes or reads 0 (EOF).

Comment thread src/NetPdf/Fonts/SystemFontResolver.cs Outdated
Comment on lines +81 to +83
// A face the indexer accepted but whose full parse fails is "not available", like a
// family that is not installed: the caller falls through to its next family.
if (bytes.IsEmpty) return ValueTask.FromResult<FontFaceData?>(null);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7440dc5. ResolveAsync now walks every candidate (CandidateEntries: the direct family, or each installed family of the generic chain) and skips one whose bytes can't be used, so a broken collection face no longer hides a later valid family. Test: A_broken_face_does_not_hide_the_next_generic_fallback_family.

/// rejected up front: the Phase 5 shaper pipeline doesn't own them,
/// and accepting them would invite the SVG-in-OpenType + colored-
/// bitmap attack-surface classes.</summary>
/// <summary>The tag-integer form of <see cref="IsDangerousTableTag(char, char, char, char)"/> (font-collection indexer).</summary>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7440dc5: the original summary is back on the char overload, and the uint overload sits after it with its own single summary.

Comment on lines +68 to +70
if (IsCollectionFile(file))
{
foreach (var face in IndexCollection(file)) yield return face;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7440dc5: the class docs now describe content-based collection detection and per-face indexing.

raroche and others added 5 commits September 30, 2026 20:32
…eight caps the automatic minimum

PR #384 review:
- The column content-height floor (content-sized and flex:1 items) is now
  capped by max-height, in the emission and the BlockLayouter pre-measure.
- A definite height no longer switches the automatic minimum off: the floor
  is min(content, height) (CSS Flexbox 4.5 specified size suggestion).
- diagnostics-codes: a break-inside:avoid element taller than a page splits
  between its children (as the page-break guide says); it is not a forced
  overflow. Test pins that it splits, keeps every paragraph, and does not report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…float-adjusted widths

PR #385 review:
- The measure pass resolves nested boxes' block-axis % padding and %
  margins against the containing inline size (they are not rewritten to
  used px until emission, so px reads treated them as 0).
- Nested table and multicol measures use the content width resolved by
  MeasureInlineGeometry (with % padding) instead of re-reading px insets.
- In an intrinsic probe, an explicit width's padding is subtracted at the
  same base the border box was built with.
- The outer dispatch passes its float-adjusted available range, so an
  auto-width block beside a float is measured at its emitted width.

Found while testing: the descendant-dominant check compared the deepest
child against the parent's whole border box, so a box whose bottom
padding + border exceeded its content (one short line in a padding:24px
card) lost its bottom padding and the next box overlapped it. It now
compares against the content-area bottom, using the deepest child bottom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t broken faces, docs

- ExtractFace writes Apple's 'true' sfnt signature as 0x00010000, so the
  safety validator accepts the extracted face (it was indexed but never
  resolved).
- SystemFontResolver walks every candidate of a CSS-generic chain and skips
  a collection face whose full parse fails, instead of returning null.
- The collection sniff loops until 4 bytes are read (partial reads).
- Docs: one summary per IsDangerousTableTag overload; the enumerator's
  class docs describe per-face collection indexing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@raroche
raroche changed the base branch from fix/measure-inline-width to main October 1, 2026 01:09
@raroche
raroche merged commit ead2ed5 into main Oct 1, 2026
11 of 13 checks passed
raroche added a commit that referenced this pull request Oct 1, 2026
* fix(layout): report forced overflow only when real; stop dropping flex/abspos content

- PAGINATION-FORCED-OVERFLOW-001 fired on every page of any document whose
  content sits in one wrapper taller than a page: the wrapper is committed
  at the page top only to be entered, and its children paginate normally.
  The top-level report is now deferred for an enterable block-flow / flex
  wrapper and emitted only if its content really did not paginate (using
  the emitted extent on a resumed page). A nested box whose OWN border box
  is taller than a page is reported where it is placed, once.
- Chasing the remaining reports found real content loss: a `flex: 1` list
  in a stretched column card was flexed to ~0, laid out into that budget,
  paginated, and everything after the first item was discarded
  (02-travel-quote). Column items with auto height/min-height now honor the
  4.5 automatic minimum (grown to their content height, shared with the
  pre-measure), and item content measured against the item's own size never
  paginates (NestedContentMeasurer suppressPagination).
- Absolutely positioned content is laid out with pagination suppressed,
  like fixed content, so content taller than the box overflows it instead
  of being cut after the first break.

Corpus: 02-travel-quote now shows every card feature (was 1 of 5-6); no
other page changes. Forced-overflow reports drop from ~160 to 1 across the
28 docs (the remaining one is the measure-width over-estimate, next PR).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(layout): measure nested content at its real containing width

The block measure pass (MeasureSubtreeVisualBlockExtentRecursive) laid
every nested text block, flex row, table and multicol out at the BFC (page)
content width. Text that wraps inside a narrow box was measured as one
line, so:
- a narrow box's painted border was too short (lost its bottom padding);
- spacing after such content was squeezed (index.pdf payment block: 4px
  gap instead of the browser's 42px);
- the mid-split entry under-measured a list's first item, entered the
  list at the page bottom, and line-split it leaving a single line.

The containing content width is now threaded through the recursion
(MeasureInlineGeometry mirrors the emit path's
ResolveInFlowBorderBoxInlineSize: explicit / % width, box-sizing,
min/max, else fill minus margins), and used by the inline-only, flex,
table-wrapper and multicol branches, the first-child estimate, and the
keep-with-next lookahead.

Also makes HtmlPdfFacadeTests' slow-loader timeout test deterministic: it
waits for the deadline to pass instead of a fixed 600ms delay (a busy test
host could fire the 100ms timer late and the render finished first).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(fonts): index and use every face of system font collections (.ttc/.otc)

The system-font indexer skipped collection files, so families that ship
only as a collection (macOS Helvetica, Helvetica Neue, Avenir, Menlo,
Optima; Windows/Linux CJK families) fell back to another, often wider,
font.

- FontCollection: bounded random-access reader for the ttcf header and
  per-face directories, and ExtractFace, which copies one face into a
  standalone sfnt (tag-sorted directory, 4-byte aligned tables,
  recomputed head.checkSumAdjustment) so the validator, parser, HarfBuzz
  and the PDF subsetter need no collection awareness.
- The enumerator detects collections by content and indexes each face
  from its name/OS/2/head tables only, skipping faces with rejected
  tables, missing required tables, or over the byte cap.
- SystemFontResolver extracts and validates the chosen face (one cache
  slot per face); a face whose full parse fails resolves to nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(text): per-character font fallback

Each text run used one font, so characters it lacked (✔, ▲, →, other
scripts) were drawn as .notdef boxes even when a later font-family entry
or an installed symbol font had them.

- LineBuilder.Shape: a run that shaped to visible .notdef is split by
  font coverage; each piece is shaped with the first font of the style's
  fallback chain that covers it (ItemizedRun.FontIndex). Marks, variation
  selectors and format characters follow their neighbour's font.
- IShaperResolver gains Resolve(style, fontIndex) and FindFallbackFont
  (default: no fallback). HarfBuzzShaperResolver builds the chain once
  per query: rest of the author stack, default family, then a fixed list
  of symbol / broad-coverage system families; each validated, de-duped
  by content, unusable candidates skipped.
- TextPainter embeds glyphs from the run's own font and keeps the
  primary font's metrics for the baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(layout): cap column flex content floor by max-height; specified height caps the automatic minimum

PR #384 review:
- The column content-height floor (content-sized and flex:1 items) is now
  capped by max-height, in the emission and the BlockLayouter pre-measure.
- A definite height no longer switches the automatic minimum off: the floor
  is min(content, height) (CSS Flexbox 4.5 specified size suggestion).
- diagnostics-codes: a break-inside:avoid element taller than a page splits
  between its children (as the page-break guide says); it is not a forced
  overflow. Test pins that it splits, keeps every paragraph, and does not report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(layout): keep a short box's bottom padding; measure % insets and float-adjusted widths

PR #385 review:
- The measure pass resolves nested boxes' block-axis % padding and %
  margins against the containing inline size (they are not rewritten to
  used px until emission, so px reads treated them as 0).
- Nested table and multicol measures use the content width resolved by
  MeasureInlineGeometry (with % padding) instead of re-reading px insets.
- In an intrinsic probe, an explicit width's padding is subtracted at the
  same base the border box was built with.
- The outer dispatch passes its float-adjusted available range, so an
  auto-width block beside a float is measured at its emitted width.

Found while testing: the descendant-dominant check compared the deepest
child against the parent's whole border box, so a box whose bottom
padding + border exceeded its content (one short line in a padding:24px
card) lost its bottom padding and the next box overlapped it. It now
compares against the content-area bottom, using the deepest child bottom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(fonts): PR #386 review — Apple 'true' faces, generic fallback past broken faces, docs

- ExtractFace writes Apple's 'true' sfnt signature as 0x00010000, so the
  safety validator accepts the extracted face (it was indexed but never
  resolved).
- SystemFontResolver walks every candidate of a CSS-generic chain and skips
  a collection face whose full parse fails, instead of returning null.
- The collection sniff loops until 4 bytes are read (partial reads).
- Docs: one summary per IsDangerousTableTag overload; the enumerator's
  class docs describe per-face collection indexing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(text): PR #387 review — fallback per grapheme cluster, lazy chain, skip unparsable fonts

- LineBuilder picks one font per extended grapheme cluster (UAX #29): the
  first fallback covering ALL of it, else the font covering its base, so a
  base and its marks (or an emoji ZWJ sequence) are never split. Variation
  selectors / format characters are optional for coverage. Malformed UTF-16
  (a lone surrogate) stays on the primary instead of throwing.
- IShaperResolver.FindFallbackFont takes the cluster's codepoints.
- HarfBuzzShaperResolver resolves fallback candidates lazily, in fixed order,
  only until one covers the cluster (stable indexes), and skips a candidate
  that HarfBuzz can't load or the OpenType parser can't parse.
- SyntheticFont.Build(char, char) maps two independent characters (tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@raroche
raroche deleted the feat/font-collections branch October 1, 2026 01:29
@raroche raroche mentioned this pull request Oct 1, 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