Skip to content

fix(layout): real-only forced-overflow reports; stop dropping flex and abspos content - #384

Merged
raroche merged 2 commits into
mainfrom
fix/forced-overflow-wrapper-noise
Oct 1, 2026
Merged

raroche merged 2 commits into
mainfrom
fix/forced-overflow-wrapper-noise

Conversation

@raroche

@raroche raroche commented Sep 30, 2026

Copy link
Copy Markdown
Owner

Task 1 of 4 follow-ups from the 1.1.1 corpus review.

Problem

PAGINATION-FORCED-OVERFLOW-001 fired on every page of any document whose content sits in one wrapper element taller than a page (<div class="page">…</div>, or just <body>): the wrapper is committed at the page top only to be entered, and its children then paginate normally. The NetPdf-tester corpus reported ~160 of them across 28 documents, hiding real problems.

Fix

  • The top-level report is deferred for an enterable block-flow / flex wrapper whose own box fits the page, and emitted only if its content really didn't paginate. On a resumed page it uses the extent actually emitted (the measured subtree extent still counts prior pages — that made the last page of every wrapped doc look overflowing).
  • A nested box whose own border box is taller than a page (explicit height, tall image) is reported where it is placed — exactly once (before: once per page, by the wrapper). No double report with its wrapper.

Real content loss found while tracing the remaining reports

  • 02-travel-quote: each package card showed only its first feature. The <ul> is flex: 1 in a stretched column card; it was flexed to ~0, its content laid out into that ~1px budget, paginated, and the continuation discarded. Now:
    • column items with auto height + min-height (visible overflow) honor the §4.5 automatic minimum: grown to their content height (HasColumnContentAutomaticMinimum, shared with PreMeasureFlexMainExtent);
    • item content measured against the item's own size is laid out with pagination suppressed (NestedContentMeasurer(suppressPagination:)) — it overflows instead of dropping.
  • Absolutely positioned content taller than its box was also paginated into the box-sized fragmentainer with the continuation discarded. It now lays out with pagination suppressed, like fixed content.

Results

  • Corpus: forced-overflow reports ~160 → 1 (the remaining one, 11-certificate, is the measure-width over-estimate — next PR). Pixel diff vs 1.1.1: only 02-travel-quote changes (all 17 card features now render; the pricing table correctly continues on page 2 with its repeating tfoot).
  • Tests: ForcedOverflowDiagnosticTests (6, facade) + Column_flex_zero_basis_item_is_not_shorter_than_its_content (2). All 8 fail on the old code. Full suite green; no golden changed.
  • Docs: diagnostics-codes.md (what the code now means), deferrals.md (column automatic minimum), CHANGELOG [Unreleased].

🤖 Generated with Claude Code

…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>
Copilot AI balanced review requested due to automatic review settings September 30, 2026 23:52

@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: d51af3fc3b

ℹ️ 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".

Comment on lines 3711 to 3712
var contentMainBorderBox = buffer.ContainsDecorationOwnerFragment
? buffer.ContentBlockExtent

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 Preserve max-height when applying the automatic minimum

When a column item has an automatic height/min-height but an explicit max-height (for example, .item { flex: 1; max-height: 20px }), ResolveFlexibleMainSizes first clamps it to that maximum, but this post-measure assignment grows it back to the full content height without reapplying the maximum. The item can therefore exceed its authored max-height, altering subsequent placement and pagination; the content-based automatic minimum must be capped by the resolved maximum before updating the used size.

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 8c66c0e. The column content floor (content-sized and flex: 1 items) is now capped by the resolved max-height, in the FlexLayouter emission and in the BlockLayouter column pre-measure. Tests: FlexColumnAutomaticMinimumTests.Content_floor_of_a_flex_one_item_is_capped_by_max_height and Content_sized_item_is_capped_by_max_height.

Comment on lines 4478 to +4479
placement.BlockSize,
noPaginate: false,
noPaginate: true,

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 Respect non-visible overflow on absolute boxes

For an absolutely positioned box with overflow: hidden, clip, auto, or scroll and content taller than its definite height, setting noPaginate: true unconditionally emits every descendant beyond the box. Ordinary layout fragments currently receive no overflow clip rectangle, so this content visibly spills instead of being clipped; only visible overflow should take this path unless an appropriate clip is attached.

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.

Not changed, by design. Overflow clipping (overflow: hidden/clip/scroll/auto) is not implemented for any ordinary box yet (see the BoxFragment remarks: overflow clip stays deferred; only margin boxes have an opt-in ClipRect). An absolutely positioned box now behaves like every other box. The old behavior was not a clip: it paginated the content into the box-sized fragmentainer and silently DROPPED whole blocks after the first break (a rule-7 violation). When overflow clipping lands, it will apply to abspos boxes the same way.

Comment on lines +2770 to +2773
var deferForcedOverflowDiagnostic = borderBoxBlockSize <= fragmentainer.BlockSize
&& pendingTableLayouter is null
&& !IsGridContainer(child)
&& (IsFlexContainer(child) || IsBlockFlowContainerOwnedByBlockLayouter(child));

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 Retain diagnostics for oversized break-inside regions

When a block-flow wrapper has break-inside: avoid and its children collectively exceed a page, this condition defers the warning just like an ordinary splittable wrapper. The recursive layout then returns a continuation at an avoided child boundary on every non-final page, so the deferred check never emits there, while the final page's emitted extent fits and emits nothing either. The avoid constraint is therefore violated without the promised PAGINATION-FORCED-OVERFLOW-001; wrappers that avoid internal breaks should not use this clean-pagination suppression path.

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.

Checked against the code. A break-inside: avoid block taller than a page does not reach the forced-overflow commit: the break resolver splits it between its children (counting BreakInsideAvoidViolation), which is what docs-site/page-breaks.md promises ("If it is taller than a whole page, it has to split anyway"). The reports seen before this PR for that case came from the enclosing wrapper on every page, not from the avoid region. Nothing is forced past a page edge, so no forced-overflow diagnostic is correct. I fixed the diagnostics doc instead (8c66c0e) and added Break_inside_avoid_region_taller_than_a_page_splits_between_its_children (splits, keeps every paragraph, no diagnostic).

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

Column flex sizing still drops long content and incorrectly handles definite and maximum heights.

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

Open (2)
What changed in this PR

Refines forced-overflow diagnostics and prevents nested flex and absolute-positioned content from being discarded.

Changes:

  • Defers wrapper overflow diagnostics until actual overflow is confirmed.
  • Suppresses inappropriate nested pagination for flex and absolute-positioned content.
  • Adds regression tests and updates documentation.
File Description
CHANGELOG.md Documents the fixes.
docs/​deferrals.md Updates flex sizing limitations.
docs/​diagnostics-codes.md Revises diagnostic semantics.
src/​NetPdf.Layout/​Layouters/​BlockLayouter.cs Refines overflow reporting and abspos layout.
src/​NetPdf.Layout/​Layouters/​FlexLayouter.cs Implements column automatic minimum sizing.
src/​NetPdf.Layout/​Layouters/​NestedContentMeasurer.cs Adds pagination suppression.
tests/​NetPdf.UnitTests/​Phase3/​FlexLayouterProductionTests.cs Tests column flex sizing.
tests/​NetPdf.UnitTests/​Rendering/​ForcedOverflowDiagnosticTests.cs Tests diagnostics and content preservation.

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

internal static bool HasColumnContentAutomaticMinimum(Box item)
{
var st = item.Style;
if (st.Get(PropertyId.Height).Tag is ComputedSlotTag.LengthPx or ComputedSlotTag.Percentage) return false;

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 8c66c0e. A definite height no longer switches the floor off: for block-child content the column floor is now min(content, height) (the specified size suggestion), capped by max-height. Test: Definite_height_item_with_taller_content_is_not_shrunk_below_its_height. Note: while testing I found that column items with a definite height do not shrink at all in a fixed-height column today (same on main), so the shrink case can't occur yet. That is a separate, older gap, tracked as a follow-up.

Comment thread docs/diagnostics-codes.md Outdated
|---|---|---|
| `PAGINATION-OPTIMIZER-FALLBACK-001` | Info | The bounded DP optimizer exceeded its time / candidate-set budget on a long document; greedy pagination (no lookahead) used. PDF still emits cleanly; layout quality is the same as a non-optimizing renderer. |
| `PAGINATION-FORCED-OVERFLOW-001` | Warning | A region marked `break-inside: avoid` (or otherwise un-splittable per the cost model) was taller than a single fragmentainer; forced to split anyway. The first piece occupies the remainder of the current page; the rest cascades onto subsequent pages. PDF renders correctly but the author's break constraint was violated. Per CSS Fragmentation L3 §3.2 last-resort fallback. |
| `PAGINATION-FORCED-OVERFLOW-001` | Warning | Content was forced past a page edge because it cannot fit any page: a box whose own height is taller than a page (an explicit `height`, a tall image), or an un-splittable region (e.g. `break-inside: avoid`) taller than a page. It is committed at the top of a page and overflows it (see also `PDF-CONTENT-OVERFLOW-TRUNCATED-001`). Reported once per such box — a wrapper element that merely CONTAINS paginating content (`<div class="page">…</div>`) does not report it (1.1.2+; earlier versions reported it on every page of every wrapped document). Per CSS Fragmentation L3 §4.4 forward progress. |

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.

Agreed. Fixed the doc in 8c66c0e: the break-inside: avoid example is removed from the forced-overflow contract, and the entry now says such an element splits between its children and does not report. See the reply on the Codex thread for the code path.

…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>
@raroche
raroche merged commit afc8880 into main Oct 1, 2026
12 of 14 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>

* 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>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>

* 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>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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 fix/forced-overflow-wrapper-noise branch October 1, 2026 01:29
@raroche raroche mentioned this pull request Oct 1, 2026
raroche added a commit that referenced this pull request Oct 1, 2026
- VersionPrefix 1.2.0 (Directory.Build.props + build/version.json);
  package-validation baseline 1.1.1.
- CHANGELOG: cut [1.2.0] (font collections, per-character font fallback,
  layout fixes from PRs #384-#387).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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