Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ All notable changes to NetPdf are documented here. The format follows [Keep a Ch

Post-`1.1.1` improvements accumulate here until the next release is cut.

### Fixed
- **Flex items no longer lose content when flexed below it.** A `flex: 1` item in a column flexbox (for example the feature list inside a card) could be flexed to almost no height; its content was then laid out into that tiny space and everything after the first line was silently dropped. A column item with `height: auto` and `min-height: auto` now keeps at least its content height (CSS Flexbox §4.5), and an item's content never paginates inside a fixed-size item — it overflows it, as in a browser.
- **Absolutely positioned content taller than its box is no longer cut.** It overflows the box (CSS `overflow: visible`) instead of keeping only what fitted before the first "page break" inside the box.
- **`PAGINATION-FORCED-OVERFLOW-001` is reported only for real overflows.** A document whose content sits in one wrapper element reported it on every page; it is now reported once per box that is actually taller than a page.

## [1.1.1]

A patch release: layout fixes found in a visual review of real travel documents against a browser. No public API changes.
Expand Down
8 changes: 6 additions & 2 deletions docs/deferrals.md
Original file line number Diff line number Diff line change
Expand Up @@ -1315,8 +1315,12 @@ grepping the ID).
01-cruise "Email" row): when a line has to shrink, `ResolveFlexLineMainSizes` floors each item with
an auto min and visible overflow at its min-content (the content size suggestion, measured lazily by
`FlexLayouter.CreateRowAutomaticMinimumProvider`), capped by a definite `width` and by `max-width`.
Emission and the BlockLayouter pre-measure share the provider. Still approximated: the COLUMN main
axis (`min-height: auto` stays 0) and the transferred-size suggestion (aspect-ratio items).
Emission and the BlockLayouter pre-measure share the provider. The COLUMN main axis applies it too
(1.1.2): an item with auto `height` + `min-height` and visible overflow is grown to its measured
content height after the content pass (`HasColumnContentAutomaticMinimum`, shared with
`PreMeasureFlexMainExtent`), and an item's content is measured with pagination suppressed when its
budget is the item's own size, so content overflows instead of being dropped. Still approximated:
the transferred-size suggestion (aspect-ratio items).
Percentage min/max-width resolve against the container main size.
- **Shared `FlexItemSizing` model unification** (
architecture recommendation): the
Expand Down
2 changes: 1 addition & 1 deletion docs/diagnostics-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ Severity levels:
| Code | Severity | Meaning |
|---|---|---|
| `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 unbreakable box such as a table row or grid 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). A `break-inside: avoid` element taller than a page is not forced past the edge: it splits between its children like normal content, so it does not report it either. Per CSS Fragmentation L3 §4.4 forward progress. |

---

Expand Down
100 changes: 89 additions & 11 deletions src/NetPdf.Layout/Layouters/BlockLayouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,11 @@ internal sealed class BlockLayouter : ILayouter, IDisposable
// return when it was a resumed entry; the parent consumes it for the cursor advance. -1 = unset.
private double _resumedContainerEmittedExtent = -1;

/// <summary>Set when the nested recursion reported <c>PAGINATION-FORCED-OVERFLOW-001</c> for an oversized
/// descendant, so the top-level forced-overflow path doesn't report the same overflow again for the
/// wrapper that contains it. Reset before each forced-overflow recursion.</summary>
private bool _nestedForcedOverflowReported;

/// <summary>Per Phase 3 Task 11 cycle 1 sub-cycle 1 — optional
/// inline shaper resolver. When non-null, <see cref="AttemptLayout"/>
/// dispatches block containers whose children are entirely
Expand Down Expand Up @@ -2744,7 +2749,7 @@ private LayoutAttemptResult AttemptLayoutInFlow(
// subtree-aware measure. Now reports both own
// border-box size + measured subtree extent so
// consumers can distinguish the two cases.
OptimizingBreakResolver.SafeEmit(diagSink, new PaginateDiagnostic(
var forcedOverflowDiagnostic = new PaginateDiagnostic(
PaginateDiagnosticCodes.PaginationForcedOverflow001,
$"BlockLayouter: forced overflow on fragmentainer page index "
+ $"{fragmentainer.PageIndex}, child index {childIdx} — "
Expand All @@ -2753,7 +2758,23 @@ private LayoutAttemptResult AttemptLayoutInFlow(
+ $"{subtreeBlockExtent:0.##}) is taller than the "
+ $"fragmentainer (block-size={fragmentainer.BlockSize:0.##}). "
+ "Committed anyway to make pagination progress.",
PaginateDiagnosticSeverity.Warning));
PaginateDiagnosticSeverity.Warning);
// A WRAPPER whose own box fits the page but whose subtree doesn't (the ubiquitous
// `<div class="page">…everything…</div>`) is committed here only to be ENTERED: the
// recursion / flex dispatch below splits its children across pages normally, so nothing
// is actually forced past the page edge. Reporting the diagnostic up front fired it on
// EVERY page of every wrapped document. Defer it for an enterable block-flow / flex
// wrapper and report only if the content really didn't paginate (no continuation came
// back); a box whose OWN border box is taller than the page, a table, or a grid still
// reports immediately.
var deferForcedOverflowDiagnostic = borderBoxBlockSize <= fragmentainer.BlockSize
&& pendingTableLayouter is null
&& !IsGridContainer(child)
&& (IsFlexContainer(child) || IsBlockFlowContainerOwnedByBlockLayouter(child));
Comment on lines +2770 to +2773

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).

if (!deferForcedOverflowDiagnostic)
{
OptimizingBreakResolver.SafeEmit(diagSink, forcedOverflowDiagnostic);
}
// First block on the (possibly resumed) page →
// topShift = effectiveTopGap = marginStart; no
// collapse-arithmetic needed.
Expand Down Expand Up @@ -2862,6 +2883,12 @@ private LayoutAttemptResult AttemptLayoutInFlow(

pendingTableLayouter?.Dispose();

var forcedFlexPaginated = forcedFlexResult.Outcome == LayoutAttemptOutcome.PageComplete
&& forcedFlexResult.Continuation is FlexContinuation;
if (deferForcedOverflowDiagnostic && !forcedFlexPaginated)
{
OptimizingBreakResolver.SafeEmit(diagSink, forcedOverflowDiagnostic);
}
if (forcedFlexResult.Outcome == LayoutAttemptOutcome.PageComplete
&& forcedFlexResult.Continuation is FlexContinuation forcedFlexCont)
{
Expand Down Expand Up @@ -2970,6 +2997,11 @@ private LayoutAttemptResult AttemptLayoutInFlow(
forcedRecIncoming = forcedDeeperBlock;
_consumedIncomingBlockContinuationRecursion = true;
}
// Reset the resumed-extent channel so the check below reads THIS wrapper's value, and the
// nested-report flag so an oversized descendant reported by the recursion isn't reported
// a second time for its wrapper.
_resumedContainerEmittedExtent = -1;
_nestedForcedOverflowReported = false;
var forcedNestedRet = EmitBlockSubtreeRecursive(
child,
parentBlockOffset: forcedOverflowChildBlockOffset,
Expand All @@ -2981,6 +3013,22 @@ private LayoutAttemptResult AttemptLayoutInFlow(
propagatingFragmentainer: fragmentainer,
incomingContinuation: forcedRecIncoming,
parentContentBlockSize: contentBlock); // % height base (percent-height cycle).
if (deferForcedOverflowDiagnostic && forcedNestedRet is not BlockContinuation)
{
// No break came back: the wrapper finished on this page. On a RESUMED page (its
// earlier children went to prior pages) the recursion publishes the extent it
// actually emitted here — the measured subtree extent still counts the prior pages,
// so the last page of every wrapped document looked "overflowing". Report only when
// what was emitted really runs past the page.
var emittedExtent = _resumedContainerEmittedExtent >= 0
? _resumedContainerEmittedExtent
: subtreeBlockExtent;
if (!_nestedForcedOverflowReported
&& forcedOverflowChildBlockOffset + emittedExtent > fragmentainer.BlockSize + 0.5)
{
OptimizingBreakResolver.SafeEmit(diagSink, forcedOverflowDiagnostic);
}
}
if (forcedNestedRet is BlockContinuation forcedDeep)
{
return LayoutAttemptResult.PageComplete(
Expand Down Expand Up @@ -4417,16 +4465,18 @@ private void EmitOneAbsoluteBox(
if (child.Children.Count > 0
&& placement.InlineSize > 0 && placement.BlockSize > 0)
{
// Abspos content pagination (overflow past the box block-size)
// is a pre-existing behavior outside this cycle's scope — the
// box-sized fragmentainer paginates + the result is discarded.
// An abspos box is not fragmented, so content taller than the box OVERFLOWS it (CSS
// `overflow: visible`) exactly like a fixed box — lay it out in one pass with pagination
// suppressed. Pre-fix the box-sized inner fragmentainer PAGINATED and the continuation was
// discarded: content past the first break was silently dropped, and a short box (e.g. 1px
// tall) reported a spurious PAGINATION-FORCED-OVERFLOW-001 from the nested pass.
DispatchAbsoluteChildContents(
child,
placement.InlineOffset,
placement.BlockOffset,
placement.InlineSize,
placement.BlockSize,
noPaginate: false,
noPaginate: true,
Comment on lines 4478 to +4479

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.

ref layout,
cancellationToken);
}
Expand Down Expand Up @@ -4609,8 +4659,9 @@ private void EmitOneFixedBox(
/// in one pass + OVERFLOWS the box at its natural position (CSS
/// `overflow: visible`; CSS Position L3 §6.3 — fixed boxes are not
/// paginated) rather than being clipped. The abspos caller passes
/// <c>noPaginate: false</c> (it paginates + discards the result —
/// a separate pre-existing item).</para></summary>
/// <c>noPaginate: true</c> too: an abspos box is not fragmented either,
/// and paginating its content into the box-sized fragmentainer dropped
/// everything past the first break (the continuation was discarded).</para></summary>
/// <summary>RC-4 — for an AUTO-block-size single-anchored abspos / fixed box, re-solve its placement
/// using the box's MEASURED content height instead of the available-extent approximation, so the box
/// (and its background) is content-sized rather than page-sized. A no-op for a definite height, a
Expand Down Expand Up @@ -4807,8 +4858,8 @@ private void DispatchAbsoluteChildContents(
// text (index.pdf's footer text was missing on every page).
layoutRootInlineContent: true);
// The result is intentionally not consumed: with pagination
// suppressed (fixed) the content fully overflows in one pass; the
// abspos path (noPaginate:false) discards it as before.
// suppressed (fixed AND abspos) the content fully lays out — and
// overflows the box if taller — in one pass.
_ = innerLayouter.AttemptLayout(
innerFragmentainer,
ref innerLayout,
Expand Down Expand Up @@ -6336,6 +6387,27 @@ public void UpdateFragmentBlockSize(int cursor, double newBlockSize)
var emittedChildBlockSize = ResolveAutoHeightEmittedBlockSize(
child, childBorderBoxBlockSize, childEffectiveBlockSize,
childBlockOffset, _capturedFragmentainer);
// A nested box whose OWN border box is taller than a whole page (an explicit `height`, a tall
// replaced image) cannot fit any page: it really is forced past the page edge, so report it
// here. (The top-level loop no longer reports the wrapper that merely CONTAINS such content —
// that fired on every page of every wrapped document.) An auto-height container's border box
// is chrome-only here, so wrappers never trip it; flex / grid / table / multicol size and
// paginate their own content.
if (propagatingFragmentainer is { SuppressBlockPagination: false } overflowPf
&& childBorderBoxBlockSize > overflowPf.BlockSize + 0.5
&& !IsFlexContainer(child) && !IsGridContainer(child)
&& child.Kind is not (BoxKind.Table or BoxKind.InlineTable))
{
_nestedForcedOverflowReported = true;
OptimizingBreakResolver.SafeEmit(_capturedDiagSink ?? _diagnostics, new PaginateDiagnostic(
PaginateDiagnosticCodes.PaginationForcedOverflow001,
$"BlockLayouter: forced overflow on fragmentainer page index {overflowPf.PageIndex}, "
+ $"nested child index {childIdx} — block or subtree visual extent (own border-box="
+ $"{childBorderBoxBlockSize:0.##}, subtree extent={childEffectiveBlockSize:0.##}) is "
+ $"taller than the fragmentainer (block-size={overflowPf.BlockSize:0.##}). "
+ "Committed anyway to make pagination progress.",
PaginateDiagnosticSeverity.Warning));
}
_sink.Emit(new BoxFragment(
Box: child,
InlineOffset: childInlineOffset,
Expand Down Expand Up @@ -11671,9 +11743,12 @@ private double PreMeasureFlexMainExtent(
// extent + the wrapper overflows + (paginatable-flex) pagination engages. Mirrors
// grid's content-aware PreMeasureGridRowExtent. Explicit-height items keep their
// (box-sizing-mapped) declared height. Skipped without a shaper.
// §4.5 — an item flexed from a length / % basis (`flex: 1`) with auto height and auto
// min-height can't shrink below its content either, so it measures like a content-sized item
// (the FlexLayouter emission grows it to the same content height).
if (_shaperResolver is not null
&& item.Children.Count > 0
&& IsColumnHeightContentDetermined(item))
&& (IsColumnHeightContentDetermined(item) || FlexLayouter.HasColumnContentAutomaticMinimum(item)))
{
// Measure at the item's cross (inline) CONTENT width: a stretch (auto-width)
// item fills the container content inline size; an explicit-width item uses
Expand Down Expand Up @@ -11714,6 +11789,9 @@ private double PreMeasureFlexMainExtent(
: buf.ContentBlockExtent + blockChrome;
measureCache[item] = measuredBorderBox;
}
// The content-grown height never exceeds `max-height` (the same cap the emission applies).
var (_, maxBorderBox) = item.ResolveFlexItemMinMaxMainSize(PropertyId.MinHeight, PropertyId.MaxHeight);
measuredBorderBox = Math.Min(measuredBorderBox, maxBorderBox);
if (measuredBorderBox > mainExtent) mainExtent = measuredBorderBox;
}
totalMain += mainExtent;
Expand Down
Loading
Loading