fix(layout): measure nested content at its real containing width - #385
Conversation
…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>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Measurement remains inconsistent for float-constrained boxes and nested percentage padding.
Review effort: Balanced
Findings: 4
Open (4)
What changed in this PR
Improves subtree measurement so nested content uses its containing width, producing accurate wrapping, sizing, and pagination.
Changes:
- Threads containing widths through block, flex, table, and multicol measurement.
- Adds regression coverage for narrow containers and list pagination.
- Makes timeout testing deterministic.
| File | Description |
|---|---|
src/NetPdf.Layout/Layouters/BlockLayouter.cs |
Propagates inline geometry through subtree measurement. |
tests/NetPdf.UnitTests/Rendering/KeepWithNextPaginationTests.cs |
Tests list-item pagination near page boundaries. |
tests/NetPdf.UnitTests/Phase3/FlexLayouterProductionTests.cs |
Tests narrow-container width resolution. |
tests/NetPdf.UnitTests/HtmlPdfFacadeTests.cs |
Stabilizes the cooperative timeout test. |
CHANGELOG.md |
Documents the measurement fix. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| var probe = _measurePurpose.ZeroesCyclicPercentInsets(); | ||
| var pctBase = probe ? 0.0 : containingInlineSize; | ||
| var borderBox = ResolveInFlowBorderBoxInlineSize( | ||
| box, containingInlineSize, containingInlineSize, | ||
| box.Style.ReadLengthOrPercentPx(PropertyId.MarginLeft, pctBase), |
There was a problem hiding this comment.
Fixed in abec7af. MeasureInlineGeometry now subtracts the padding at the same base the border box was built with: an explicit width's own % padding at the containing width (as ResolveInFlowBorderBoxInlineSize does even under a probe), a filled auto width at the probe's zeroed base. This applies to every caller, including the flex first-child estimate.
| // The measured box's own inline geometry at its REAL containing width (mirrors the emit path's | ||
| // ResolveInFlowBorderBoxInlineSize): its border box, and the content width its children see. An | ||
| // anonymous box has no own chrome or width — it spans its containing block. | ||
| var containing = double.IsNaN(containingInlineSize) ? _bfcContentInlineSize : containingInlineSize; |
There was a problem hiding this comment.
Fixed. MeasureSubtreeVisualBlockExtent takes an availableInlineSize (NaN = containing block), and the outer dispatch passes its float-adjusted availInlineSize, so an auto-width block beside a float fills the same range it is emitted in, while % widths still resolve against the containing block. It applies to the measured box only; its children fill its content box, as the recursive emit does.
| // ResolveInFlowBorderBoxInlineSize): its border box, and the content width its children see. An | ||
| // anonymous box has no own chrome or width — it spans its containing block. | ||
| var containing = double.IsNaN(containingInlineSize) ? _bfcContentInlineSize : containingInlineSize; | ||
| var (parentBorderBoxInline, parentContentInline) = MeasureInlineGeometry(parent, containing); |
There was a problem hiding this comment.
Fixed. Block-axis padding (and the children's % margins in the stacking loop, and the flex first-child chrome) now use ReadLengthOrPercentPx with MeasurePercentInsetBase(containing) — the containing inline size, or 0 in an intrinsic probe, the same base as the emit path. Test: Percentage_block_insets_are_measured_so_the_next_box_starts_after_them. While testing this I also found and fixed an older bug in the same function: the bottom padding was dropped whenever it was larger than the content (the descendant-dominant check compared against the whole border box instead of the content-area bottom).
| var wrapperInlineSize = parentBorderBoxInline; | ||
| var contentInlineSize = Math.Max(0, | ||
| wrapperInlineSize - tBorderInlineStart - tPaddingInlineStart | ||
| - tBorderInlineEnd - tPaddingInlineEnd); |
There was a problem hiding this comment.
Fixed. The table branch now uses parentContentInline from MeasureInlineGeometry (the multicol branch too), so % padding is subtracted.
…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>
# Conflicts: # CHANGELOG.md # tests/NetPdf.UnitTests/Phase3/FlexLayouterProductionTests.cs
) * 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>
* 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>

Task 2 of 4. Stacked on #384 (base:
fix/forced-overflow-wrapper-noise); GitHub retargets it tomainwhen #384 merges.Problem
MeasureSubtreeVisualBlockExtentRecursivemeasured every nested text block, flex row, table and multicol at the BFC/page width. Content that wraps inside a narrow box measured as one line:index.html: 4px between the totals table and "PAYMENT DETAILS" (Chrome: 42px);Fix
Thread the containing content width through the measure recursion.
MeasureInlineGeometryresolves each box's border-box / content width exactly like the emit path (ResolveInFlowBorderBoxInlineSize: explicit / % width, box-sizing, min/max, else fill − margins); used by the inline-only, flex, table-wrapper, multicol branches,EstimateFirstInFlowChildExtent, and the keep-with-next lookahead.Results
indexp1 (payment block now 44px below the table, matching Chrome's 42px) and08p2 (highlights box keeps its bottom padding). No page-count change.Narrow_block_is_measured_at_its_own_width_so_wrapped_content_fits_inside(4 width forms) +Heading_and_first_list_item_move_together_instead_of_leaving_one_line— all 5 fail on the old code. Full suite green, no golden changed.A_stage_that_ignores_the_token_past_the_deadline_still_times_outmade deterministic (waits for the deadline instead of a fixed 600ms; it flaked once under full-suite load).Found, not fixed here
Vertical margins between text blocks don't collapse (two default
<p>are 32px apart, browser 16px) — logged as a separate task; it makes most documents taller than in a browser (incl. the 11-certificate, whose one remaining forced-overflow report is genuine for NetPdf's current layout).🤖 Generated with Claude Code