Skip to content

Phase 2 deep review C-1/C-2/C-3: critical security + correctness fixes#14

Merged
raroche merged 5 commits into
mainfrom
phase-2-deep-review-followup
May 7, 2026
Merged

Phase 2 deep review C-1/C-2/C-3: critical security + correctness fixes#14
raroche merged 5 commits into
mainfrom
phase-2-deep-review-followup

Conversation

@raroche

@raroche raroche commented May 7, 2026

Copy link
Copy Markdown
Owner

Summary

Three Critical findings from the post-Phase-2 deep review, addressing untrusted-input robustness in attacker-reachable code paths. Companion to PR #13 (which addresses the 7 P1/P2 items from the earlier targeted review). Stacks on top of #13's branch so the two can merge in either order. After both land, the deep review's actionable critical / high-severity findings are cleared and Phase 3 can build on a hardened Phase 2.

10 net-new tests, all passing. 3239 unit / 3365 solution-wide (+10 over the prior 3229/3355 baseline). AOT/JIT byte-parity gate green.

Phase 2 deep review — 3 Critical fixes (e29c085)

C-1 [CRITICAL] — SelectorCompiler recursion depth limit

The recursive-descent selector parser had no depth bound on :is() / :not() / :has() nesting. An attacker-supplied stylesheet with a 10k-deep nested selector would crash the host with StackOverflowException (uncatchable, brings down the process). Added MaxRecursionDepth = 64 + a _depth counter tracked across the ParseSelectorList chain — mirrors the existing VarSubstitution.MaxRecursionDepth defense pattern. The guard fires as a catchable SelectorParseException which CascadeResolver wraps in CSS-PARSE-WARNING-001 + skips the rule.

Note: forgiving sub-groups :is() / :where() per CSS Selectors L4 §3.7 correctly swallow the exception + drop the alternative — the guard manifests as a propagated exception only in strict contexts (:not() / :has()), which is spec-correct behavior. Tests verify both the propagation in strict context AND that reasonable nesting (5 levels) still compiles cleanly.

C-2 [CRITICAL] — Diagnostic message sanitization

The CSS-PARSE-WARNING-001 emission in CompileSelectorWithDiagnostics interpolated the raw selector text + the exception reason verbatim into the message string:

\$\"Invalid selector \\\"{raw}\\\" — {ex.Reason}. Rule skipped.\"

Untrusted CSS can carry ANSI escape sequences, NUL / control chars, or extreme length. Emitting verbatim let a hostile stylesheet inject control sequences into any downstream sink that interprets them (terminal log, JSON encoder reading \\u001b, structured-log parsers) AND bloated diagnostic message size for adversarial multi-megabyte selectors. New helper SanitizeForDiagnosticMessage(text, maxLength):

  • Strips C0 (0x00..0x1F) + DEL (0x7F) + C1 (0x80..0x9F); replaces stripped chars with U+FFFD replacement so the redaction is observable to a reader (vs silently dropped).
  • Truncates at maxLength chars (80 for selector, 200 for reason) + appends an ellipsis marker when truncated.

Both the raw selector AND the exception reason are sanitized — the reason field can embed selector fragments verbatim from the parser's error path.

C-3 [CRITICAL] — Color alpha component range validation

Per CSS Color L4 §4.2.1 the alpha component is range-validated at computed-value time (out-of-range → IACVT → declaration discarded), NOT silently clamped to [0, 1]. RGB channels themselves DO legitimately clamp per §4.2 — but alpha is the explicit exception. Cycle-1's TryParseAlphaComponent was clamping silently in both forms:

  • Modern slash syntax: rgb(255 0 0 / 2.0) → resolved to opaque red instead of emitting CSS-PROPERTY-VALUE-INVALID-001.
  • Legacy comma form: rgba(255, 0, 0, 1.5) → same.
  • Percentage form: / 200% → same.

Fix: pct < 0 || pct > 100 reject path for percentage alpha; n < 0 || n > 1 reject path for number alpha. Boundary values (0, 1, 0%, 100%) remain accepted. The clamping path was the right call for RGB channels (preserved) but the wrong call for alpha (now strict).

Tests added

  • tests/NetPdf.UnitTests/Phase2/Phase2DeepReviewCriticalFixesTests.cs — 10 facts:
    • C-1: :has() 200-deep throws (strict context — guard manifests as exception); :not() 200-deep throws; reasonable 5-level nesting still compiles.
    • C-2: ANSI escape sequences + BEL + NUL stripped from diagnostic; multi-megabyte selector truncated with ellipsis; total diagnostic message bounded.
    • C-3: alpha number > 1 rejected; alpha number < 0 rejected; alpha percentage > 100 rejected; legacy rgba(...) alpha > 1 rejected; boundary values 0/1/0%/100% all accepted.

Test deltas

  • 3239 unit / 3365 solution-wide passing (+10 over the prior 3229/3355 baseline).
  • AOT/JIT byte-parity gate green.

Test plan

  • dotnet test NetPdf.slnx -c Release — 3239 unit / 3365 solution-wide passing
  • ./scripts/aot-parity.sh — AOT/JIT byte-parity green
  • All 10 critical-fix facts pass with descriptive failure messages on regression
  • Existing tests stay green (no regression in CascadeResolverTests, ColorResolverTests, SelectorCompilerTests)
  • Roland review cycle on this PR
  • Address Copilot findings if any
  • Merge after PR #13 (or rebase to main if Phase 2 deep review hardening: 7 spec/correctness fixes #13 lands first)

🤖 Generated with Claude Code

raroche and others added 2 commits May 7, 2026 15:56
Addressed all 7 recommendations from the post-Phase-2 deep review.

Rec 1 [P1] — CssParserAdapter style-rule slot alignment by selector
identity, not just by kind. Cycle-1 paired any StyleRule slot with any
next ICssStyleRule from AngleSharp, so a dropped rule like
`li::marker { ... }` (AngleSharp.Css 1.0.0-beta.144 emits an empty-
selector rule for it) consumed the NEXT rule's slot, corrupting source
location ordinals. New SlotMatchesAngleSharpRule two-tier check:
(a) AngleSharp's empty selector pairs with any slot prelude (covers
the ::marker-drop case while preserving parsed declarations);
(b) non-empty selectors must match the slot's normalized prelude
(whitespace-collapsed, combinator-canonicalized) ordinal-equal;
(c) otherwise demote slot to opaque without advancing the cursor.
New AdaptStyleRule overload takes the slot directly + uses its prelude
when AngleSharp's selector is empty.

Rec 2 [P1] — EmitOpaqueFromSlot recovers declarations from RawBody.
Cycle-1 emitted dropped style rules with empty Declarations, losing
every declaration. New CssPreprocessor.ScanAllDeclarations(string)
parses every property:value pair from a body string; EmitOpaqueFromSlot
materializes via ToCssDeclarations. A dropped li::marker { content:
counter(items); color: red } now lands in the AST with both decls
populated, making CSS-CONTENT-FUNCTION-UNSUPPORTED-001 reachable
through the production path.

Rec 3 [P1] — Inline style="" runs through CssPreprocessor recovery.
Cycle-1's CascadeResolver.WalkInlineStylesRecursive fed AngleSharp's
already-corrupted parsed ICssStyleDeclaration directly to the typed
pipeline, so style="color: oklch(...)" was lost. New public
AdaptInlineStyleWithRecovery(style, rawStyleText, location)
constructs a synthetic CssStyleRuleRecovery from
ScanForModernDeclarations(rawStyleText) and feeds the same merge
pipeline as <style> blocks.

Rec 4 [P2] — @supports validates the VALUE per CSS Conditional L3
§4.1.3. Cycle-1's SupportsParser.ParsePrimary returned
PropertyMetadata.NameToId.ContainsKey(prop) — `@supports (color:
not-a-real-color)` returned true. New probe runs the value through
PropertyResolverDispatch.Resolve(propertyId, rawValue, null) and
accepts any ResolutionState != Invalid (Resolved + Deferred both
count, so var()/calc() values that resolve at layout time still
register as supported).

Rec 5 [P2] — CalcResolver finite guards on numeric parsing + binary
arithmetic. Cycle-1 silently propagated 1e500 → Infinity and
1e150 * 1e150 → Infinity into the rewritten CSS text. New literal-time
IsFinite(n) check + Finite(magnitude, unit, reason) wrapper applied at
every Add / Subtract / Multiply / Divide so overflow on composition
also lands in CSS-CALC-INVALID-001 rather than the rewritten value
text.

Rec 6 [P2] — CancellationToken threads INTO each major walker, not
just between stages. New cancellationToken parameter on
CascadeResolver.Resolve + BoxBuilder.Build + SemanticTreeBuilder.Build;
checked at every stylesheet, every DOM element. SemanticTreeBuilder's
WalkContext record struct gains a CancellationToken field so `with`
expressions automatically propagate it. Pre-cancelled token throws
OperationCanceledException from inside the stage, not at the next
boundary.

Rec 7 [P2] — Phase2Result.Dispose returns box-owned ComputedStyle
instances to the pool. Cycle-1's IsBoxOwned flag prevented premature
pool release but had no explicit disposal sweep, so every conversion
leaked one ComputedStyle per styled box / pseudo / fragment-pseudo to
GC. New ComputedStyle.ReleaseFromBox() clears _isBoxOwned + delegates
to Dispose(); new Phase2Result.Dispose() walks the tree once,
deduping by ReferenceEqualityComparer, and calls ReleaseFromBox on
every unique Style + FirstLineStyle + FirstLetterStyle. Phase2Result
becomes IDisposable.

Tests: 3229 unit / 3355 solution-wide passing (+9 over the previous
3220/3346 baseline) — new Phase2DeepReviewHardeningTests covers all
7 recs plus an extra calc-overflow case + a Dispose-idempotency case.
AOT/JIT byte-parity gate stays green. Active-task pointer remains
Phase 3 — Fragmentainer-aware layout + pagination.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Critical findings from the post-Phase-2 deep review, all
addressing untrusted-input robustness in attacker-reachable code paths.
Companion to PR #13 (which addresses the 7 P1/P2 items from the same
review). Lands on top of #13's branch so it can merge in either order.

C-1 [CRITICAL] — SelectorCompiler recursion depth limit. The
recursive-descent parser had no depth bound on `:is()` / `:not()` /
`:has()` nesting; an attacker-supplied stylesheet with a 10k-deep
nested selector would crash the host with StackOverflowException
(uncatchable). Added `MaxRecursionDepth = 64` + a `_depth` counter
tracked across the `ParseSelectorList` chain. Mirrors the existing
`VarSubstitution.MaxRecursionDepth` pattern. The guard fires as a
catchable `SelectorParseException` which CascadeResolver wraps in
`CSS-PARSE-WARNING-001` + skips the rule.

(Note: forgiving sub-groups `:is()` / `:where()` per CSS Selectors L4
§3.7 correctly swallow the exception + drop the alternative — the
guard manifests as a propagated exception only in strict contexts
`:not()` / `:has()`, which is spec-correct.)

C-2 [CRITICAL] — Diagnostic message sanitization. The
`CSS-PARSE-WARNING-001` emission interpolated the raw selector text +
exception reason verbatim into the message string. Untrusted CSS can
carry ANSI escape sequences, control chars, NUL, or extreme length;
emitting verbatim let a hostile stylesheet inject control sequences
into a downstream sink (terminal log, JSON encoder, etc.) AND bloated
diagnostic output for adversarial multi-megabyte selectors. New
`SanitizeForDiagnosticMessage(text, maxLength)`: strips C0 (0x00..0x1F)
+ DEL (0x7F) + C1 (0x80..0x9F), replacing with U+FFFD so redaction is
observable; truncates at maxLength (80 chars for selector, 200 for
reason) + appends an ellipsis marker. Both the raw selector AND the
exception reason are sanitized — the reason can embed selector
fragments.

C-3 [CRITICAL] — Color alpha component range validation per
CSS Color L4 §4.2.1. Cycle-1 silently clamped out-of-range alpha to
[0, 1] in both the modern `rgb(R G B / A)` syntax and the legacy
`rgba(...)` form — `rgb(255 0 0 / 2.0)` resolved to opaque red rather
than emitting `CSS-PROPERTY-VALUE-INVALID-001`. Per spec, alpha is
range-validated (not clamped) at computed-value time; only RGB
channels themselves legitimately clamp per §4.2. New: `pct < 0 || pct
> 100` reject path for percentage alpha, `n < 0 || n > 1` reject path
for number alpha. Boundary values (0, 1, 0%, 100%) remain accepted.

Tests: new Phase2DeepReviewCriticalFixesTests (10 facts) — selector
depth-limit regression for :has() + :not(), reasonable nesting still
compiles, diagnostic message control-char stripping (ANSI/BEL/NUL),
length truncation with ellipsis, alpha-out-of-range rejection in 4
forms (modern number, modern percentage, legacy comma-form, both
above-1 and below-0), boundary-value acceptance.

Suite: 3239 unit / 3365 solution-wide passing (+10 over the prior
3229/3355 baseline). AOT/JIT byte-parity gate stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

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.

Pull request overview

Hardening pass addressing three attacker-reachable robustness/correctness issues in the Phase 2 CSS pipeline: selector compiler recursion bounding (prevent StackOverflowException), diagnostic message sanitization (prevent control-sequence/log injection and limit size), and strict alpha range validation per CSS Color L4.

Changes:

  • Added a recursion-depth guard to SelectorCompiler’s recursive-descent parsing of nested functional pseudo-classes.
  • Sanitized and length-capped selector compilation diagnostics (CSS-PARSE-WARNING-001) to strip C0/C1 controls and truncate hostile inputs.
  • Fixed color alpha parsing to validate ranges (0..1 / 0..100%) instead of silently clamping out-of-range values; added regression tests for all three findings.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
tests/NetPdf.UnitTests/Phase2/Phase2DeepReviewCriticalFixesTests.cs Adds regression tests for recursion depth guard, diagnostic sanitization/truncation, and alpha validation behavior.
src/NetPdf.Css/Selectors/SelectorCompiler.cs Introduces a recursion depth counter/limit around selector-list parsing to prevent unbounded nesting.
src/NetPdf.Css/ComputedValues/PropertyResolvers/ColorResolver.cs Enforces strict alpha range validation (reject out-of-range) for both numeric and percentage alpha forms.
src/NetPdf.Css/Cascade/CascadeResolver.cs Sanitizes selector + reason strings in parse warnings by stripping controls and truncating to bounded lengths.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +216 to +223
private static AngleSharp.Dom.IDocument ParseDoc(string html)
{
var config = Configuration.Default
.WithCss()
.WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = false });
var ctx = BrowsingContext.New(config);
return ctx.OpenAsync(req => req.Content(html).Address("about:blank")).Result;
}

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.

Addressed in 47612a5ParseDoc is now async ParseDocAsync returning Task<IDocument>; both C-2 tests are now async Task and await the open. Matches the async pattern used in Phase2EndToEndTests + CssParserAdapterPreprocessTests.

Comment on lines +152 to +155
// Message length is bounded — message format is roughly
// 'Invalid selector "<truncated 80 chars>…" — <reason>. Rule skipped.'
// so total stays under ~250 chars.
Assert.True(dx.Message.Length < 300, $"diagnostic message too long: {dx.Message.Length} chars");

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.

Addressed in 47612a5 — relaxed the bound from < 300 to < 400 with a comment documenting the worst-case math ("Invalid selector "" prefix 19 + selector max 80 + "" — " 5 + reason max 200 + ". Rule skipped." 15 ≈ 320 worst case). The assertion's purpose is catching unbounded bloat (a 10_000-char selector reaching the message verbatim would explode past 10_000), not pinning an exact figure — comment now reflects that intent.

Comment on lines +98 to +99
// rule whose selector is hostile + no rules to avoid a separate
// failure.

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.

Addressed in 47612a5 — comment now reads "hostile selector + empty declarations" which matches what the test actually builds (a stylesheet with a single rule, hostile selector, empty Declarations array).

raroche and others added 3 commits May 7, 2026 17:08
Three inline comments from Copilot, all valid:

#1 (line 223) — ParseDoc blocked on `.Result` which can deadlock
under hostile sync contexts + diverged from the async pattern used
in other AngleSharp-based tests in this repo (Phase2EndToEndTests,
CssParserAdapterPreprocessTests). Renamed to ParseDocAsync, returns
Task<IDocument>; both C-2 test methods converted to `async Task`.

#2 (line 155) — Message-length assertion (< 300) was tighter than
the actual sanitizer policy (selector max 80 + reason max 200 +
~30 char overhead = up to ~320 worst case). A long pseudo-class
name in the parser's reason field could legitimately push the
message past 300 + cause a flaky failure. Relaxed bound to < 400
with a comment documenting the worst-case math + the test's actual
intent (catch unbounded bloat from a 10k-char selector reaching
the message verbatim, NOT pin an exact figure).

#3 (line 99) — Comment said "hostile + no rules" but the test
creates a stylesheet WITH a single rule. Reworded to "hostile
selector + empty declarations" so the comment matches what's
actually built.

All 10 critical-fix Facts still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#1 (CascadeResolver.cs:512) — @supports value validation tightened from
   blacklist (state != Invalid) to whitelist (state == Resolved ||
   state == Deferred). The blacklist incorrectly accepted
   UnsupportedUnvalidated, which is the cycle-2-backlog state for the
   9 missing property resolvers (FontWeight, FontSize, LineHeight,
   LineWidth, FlexBasis, FontFamilyList, VerticalAlign, Content,
   MaxSize). Returning true for those values says "yes, NetPdf supports
   this" when in fact NetPdf hasn't validated the value at all — a
   guarded `@supports (font-weight: -5) { ... }` block would apply.
   The new whitelist correctly rejects UnsupportedUnvalidated; only
   resolver-validated outcomes count as "supported."

#2 + #3 (Phase2Result.cs + Phase2DeepReviewHardeningTests.cs) —
   Removed the "idempotent" Dispose contract + the test pinning it.
   Phase2Result is a value-type (record struct) with reference fields
   (Box tree); copying the struct shares the box tree by reference, so
   disposing both the original AND a copy releases the same pooled
   ComputedStyle instances twice. Under pool churn (any other
   Phase2Pipeline call between the two disposes), the second release
   returns now-foreign styles to the bag + corrupts the other run.
   Sharpened the type-level XML doc with the "single-call contract"
   warning + dropped the
   Rec7_phase2_result_dispose_is_idempotent fact (the test created a
   false guarantee under the pool-churn scenario reviewers raised).

Knock-on test fix: two tests in CascadeCopilotReviewTests
(Copilot5_Supports_with_paren_inside_quoted_value_does_not_truncate +
single-quoted variant) used `content: "a)b"` and `font-family: 'a)b'`
as @supports test values. Both properties are in the
UnsupportedUnvalidated set, so under the new whitelist they correctly
evaluate false instead of true. The tests' intent is paren-tracking,
not value-validation; the fix wraps the unsupported-property check in
`not(...)` so `not(false) = true`, the AND-conjunction succeeds, and
the inner rule applies — proving the parser correctly tracked the
quoted `)` (a truncation bug would have parsed something different
and the rule wouldn't have applied).

Tests: 3228 unit / 3354 solution-wide (-1 from removed idempotency
test). AOT/JIT byte-parity gate stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@raroche raroche changed the base branch from phase-2-deep-review-hardening to main May 7, 2026 21:16
@raroche raroche merged commit d4bcd6d into main May 7, 2026
@raroche raroche deleted the phase-2-deep-review-followup branch May 8, 2026 22:49
raroche added a commit that referenced this pull request May 9, 2026
…32)

* Phase 3 Task 9 cycle 1: LineBuilder scaffolding + bidi + itemization

Per the Phase 3 plan §"InlineLayouter + LineBuilder" — the keystone
task for inline content rendering. Task 9 has 6 algorithm steps
(bidi → itemize → shape → break-opp → measure → wrap); cycle 1
ships steps 1+2 as MVP scaffolding for the rest of the cycles to
build on.

New types in `src/NetPdf.Layout/Inline/`:
  - `TextRun` — readonly record struct (Text + ComputedStyle).
    The line builder's input view, decoupled from the box-tree
    `BoxKind.TextRun` so tests can drive the builder without a full
    box tree, and future generated content
    (`::before` with `content: counter()`) can flow through the same
    type without owning a box.
  - `ItemizedRun` — readonly record struct (Utf16Start, Utf16Length,
    BidiLevel, SourceTextRunIndex). One homogenous chunk after
    itemization — single bidi level + single source TextRun.
    `IsRtl` derives from the level (odd = RTL).
  - `LineBuilder` — static entry point with
    `Itemize(textRuns, paragraphDirection)` method.

Algorithm:
  1. Concatenate text from all TextRuns into a single buffer +
     build a char-index → source-run-index map.
  2. Resolve bidi: `ParagraphLevelResolver.Resolve` (handles
     LeftToRight / RightToLeft / Auto P2/P3) →
     `BidiPipeline.BuildCharInfos` →
     `BidiPipeline.ResolveLevelsForUtf16` returns one byte per UTF-16
     code unit.
  3. Walk the levels + the source-run map; emit a new
     `ItemizedRun` whenever EITHER the bidi level OR the source-run
     index changes.

Cycle 1 deferrals (subsequent cycles):
  - **Cycle 2.** Script-change boundaries per UAX #24 (multi-script
    docs need separate runs per script — HarfBuzz can't share a
    shaping pass across Latin / Hebrew / CJK / etc.). Cycle 1 produces
    correct output for single-script docs (most documents); mixed-
    script will see fewer boundaries than a real engine creates,
    which the cycle 2 shaper integration will catch.
  - **Cycle 2.** Shaping pass via `HbShaper.Shape` — turns each
    ItemizedRun into a `ShapedGlyph[]`.
  - **Cycle 3.** UAX #14 line-break opportunities + line wrapping
    with `text-align` / `white-space` / `overflow-wrap` / `word-break` /
    `vertical-align`. Output: `LineFragment[]` with absolute glyph
    positions in CSS px.
  - **Phase 3 Task 10.** Integration with `InlineLayouter` — the
    block-level layouter dispatches inline content via the line
    builder.
  - **Phase 3 cycle 4.** Inline content flowing around floats —
    completes the float story from Task 8 (line boxes shrink to fit
    the available inline range at each line's block-Y).

Tests: 16 new in `LineBuilderTests`:
  - Empty input + only-empty-runs return empty.
  - Single LTR run → 1 itemized run, level=0.
  - Single RTL run → 1 itemized run, level=1, IsRtl=true.
  - Mixed LTR + RTL in LTR paragraph → at least 1 LTR + 1 RTL run.
  - Two runs same direction different style → 2 itemized runs at
    style boundary.
  - Text with embedded LF → kept in run (cycle 3 wrapper handles
    it as Mandatory line-break).
  - Coverage invariant — emitted runs cover the full text in order
    with no gaps or overlaps.
  - Auto direction with LTR-first-strong → level 0.
  - Auto direction with RTL-first-strong → level 1.
  - Surrogate pair (😀) kept as 2 code units in a single run.
  - IsRtl theory: 0,2 → false; 1,3 → true.
  - Null-arg validation throws.

Solution-wide: **3848 unit / 3978 solution-wide passing** (+16 unit /
+16 solution-wide over PR #31's 3832; 4 skipped). AOT/JIT byte-parity
gate stays green.

Active-task pointer advances to **Phase 3 Task 9 cycle 2** —
shaping integration via `HbShaper` + per-glyph measurement. Cycle 3
will add line-break + wrapping; Task 10 (`InlineLayouter`) integrates
with the BlockLayouter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR #32 review pass: paragraph-aware bidi + style-boundary test fix

Per the post-PR-32 Copilot review (2 inline comments). Note: the
user's message also included a long block of recommendations from
the post-Task-7 review (CssResourceExtractor, SafeHttpResourceLoader,
optimizer, etc.) — those were ALL addressed in PR #27 + PR #29 and
are already on main. This pass only addresses the 2 NEW PR #32
issues.

Solution-wide: 3852 unit / 3982 solution-wide passing (+4 over
PR #32's 3848). AOT/JIT byte-parity green.

Copilot #1 — paragraph-aware bidi resolution
========================================================================
src/NetPdf.Layout/Inline/LineBuilder.cs

Pre-fix: called `BidiPipeline.ResolveLevelsForUtf16` directly with a
single paragraph-level for the entire concatenated buffer. UAX #9
§3.3.1 P1 requires per-paragraph resolution — embeddings + isolates
don't cross paragraph boundaries, AND each paragraph resolves its
own level under Auto. `ParagraphLevelResolver.Resolve`'s Auto scan
stops at the first B/paragraph separator, so multi-paragraph Auto
input would inherit the FIRST paragraph's level for ALL paragraphs.

Real bug: for `"Hello\nمرحبا"` with Auto direction:
  - Pre-fix: paragraph 1 first-strong = 'H' = LTR → both paragraphs
    resolve as LTR → Arabic text gets level 0 (wrong).
  - Post-fix: each paragraph resolves independently → Hello at level
    0, Arabic at level 1.

Fix: switch to `BidiAlgorithm.ResolveLevels` which already segments
on UCD class-B characters (LF, CR, NEL, PARAGRAPH SEPARATOR, etc.)
+ resolves each paragraph independently with its own P2/P3-resolved
level. Output is byte-deterministic for byte-equal input.

Tests: 3 new multi-paragraph regression tests:
  - PostPr32_multi_paragraph_LTR_RTL_each_resolves_independently_under_Auto
  - PostPr32_multi_paragraph_with_explicit_LTR_does_not_drop_subsequent_paragraphs
  - PostPr32_multi_paragraph_RTL_then_LTR_produces_two_distinct_levels

Copilot #2 — Style-boundary test uses distinguishing style
========================================================================
tests/NetPdf.UnitTests/Phase3/LineBuilderTests.cs

Pre-fix: the "different styles" test constructed both runs with
`MakeStyle()` and set no properties — semantically identical
ComputedStyle instances. The test still asserted the right outcome
(SourceTextRunIndex boundary) but the test name claimed style
differentiation that wasn't actually present.

Fix:
  - Updated `Itemize_two_runs_same_direction_different_style_creates_run_boundary`
    to set FontSize=24 on the second style — now genuinely
    distinguishing.
  - Added new `Itemize_two_runs_same_direction_identical_style_still_creates_source_run_boundary`
    to pin the source-run-boundary contract regardless of style
    equality.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
raroche added a commit that referenced this pull request May 9, 2026
…33)

* Phase 3 Task 9 cycle 2: LineBuilder shaping integration via HbShaper

Continues Task 9 from cycle 1 (PR #32). Cycle 1 shipped steps 1+2
of the 6-step inline pipeline (bidi → itemize → shape → break-opp →
measure → wrap); cycle 2 ships **step 3 — shape — plus the per-run
advance measurement** half of step 5.

New types in `src/NetPdf.Layout/Inline/`:
  - `ShapedRun` — readonly record struct (Source ItemizedRun +
    ShapedGlyph[] + TotalAdvance). Carries HarfBuzz output + the
    run's summed inline-axis advance, computed once at shape time
    so cycle 3's wrap pass doesn't re-sum on every wrap iteration.
  - `IShaperResolver` — `IDisposable` interface that resolves an
    `HbShaper` for a given `ComputedStyle`. The line builder no
    longer constructs shapers itself; resolution + caching is an
    injected concern so production can ship a font-registry-backed
    resolver while tests can drive the line builder with a synthetic-
    font resolver. Resolvers OWN returned shapers — callers must
    not dispose.

New static method on `LineBuilder`:
  - `Shape(sourceTextRuns, itemizedRuns, resolver,
          scriptIso15924="Latn", language="en")` → `ShapedRun[]`
    parallel to `itemizedRuns`.

Algorithm:
  1. Validate 5 args (all non-null).
  2. Rebuild concat text from sourceTextRuns. Cycle 2 trade-off:
     O(N) extra string copy avoids changing `Itemize`'s signature
     and breaking PR #32's tests; cycle 3 may pool a buffer.
  3. For each ItemizedRun: resolve a shaper via
     `IShaperResolver.Resolve(style)`, slice the concat text via
     `Utf16Start`+`Utf16Length`, derive `ShapingDirection` from
     `IsRtl`, call `HbShaper.Shape(slice, direction, script, lang)`,
     sum each glyph's `XAdvance` into `TotalAdvance`.

Cycle 2 deferrals (subsequent cycles):
  - UAX #24 per-run script detection — cycle 2 passes
    `scriptIso15924` + `language` uniformly to every run; cycle 3
    will detect script per-run + add a script-change boundary in
    `Itemize`.
  - UAX #14 line-break opportunities (cycle 3).
  - Line wrapping with `text-align`/`white-space`/`overflow-wrap`/
    `word-break`/`vertical-align` (cycle 3).
  - `LineFragment[]` output type (cycle 3).
  - Integration with `InlineLayouter` (Task 10).
  - Inline content flowing around floats — completes the float
    story from Task 8 (Task 10 + cycle 4).

New tests: `LineBuilderShapingTests` — 13 tests covering empty
input; 5 null-arg validations (sourceTextRuns/itemizedRuns/resolver/
scriptIso15924/language); single-LTR-run roundtrip with positive
XAdvance per glyph; total-advance equals sum-of-glyph-advances
invariant; source-itemized-run preserved on output; multi-run
inputs (one ShapedRun per ItemizedRun + glyph counts match);
`Source.Utf16Start` offsets are concat-text-relative; resolver
invoked once per itemized run; resolver receives correct source-
style per run. Tests use a synthetic-font resolver wrapping
`SyntheticFont.Build()` — no system-font dependency.

(Note: `HbShaper` is sealed so the test can't intercept its `Shape`
call to pin script/language/direction passthrough at the resolver
layer; those invariants are deferred to integration tests in cycle
3 / Task 10 once the full pipeline can be exercised end-to-end with
real glyph outputs.)

Tests: 3865 unit / 3995 solution-wide passing (+17 unit / +17
solution-wide over PR #32's 3848; 4 skipped). AOT/JIT byte-parity
gate stays green. Active-task pointer advances to **Phase 3 Task 9
cycle 3** — UAX #14 line-break opportunities + wrapping pass +
`LineFragment[]` output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR #33 review pass: 6 user/Copilot fixes (P1+P2)

Addresses the user's consolidated review + 4 Copilot inline comments
on PR #33. Six fixes ship together as a single review-pass commit.

1. [P1] LineBuilder/HbShaper now shape from the FULL concatenated
   buffer with itemOffset/itemLength so:
   - Glyph cluster indices stay concat-buffer relative across source/
     style boundaries (cycle 3's painter can build a ToUnicodeCMap
     directly from clusters without a per-run offset adjustment).
   - Cross-source-boundary contextual shaping works (Arabic joining
     across TextRuns, complex-script reordering past style edges).
   New `HbShaper.Shape(span, itemOffset, itemLength, dir, script,
   lang, ct)` overload uses HarfBuzzSharp's
   `AddUtf16(span, itemOffset, itemLength)` which maps to the native
   `hb_buffer_add_utf16(text, len, item_offset, item_length)` —
   chars before/after the item are visible to HarfBuzz as left/right
   shaping context but produce no glyphs. Existing single-span
   overload delegates with offset=0,length=span.Length.

2. [P1] Removed default `scriptIso15924="Latn"` + `language="en"`
   on `LineBuilder.Shape`. The earlier cycle-2 ship silently mis-
   shaped Arabic / Hebrew / Indic / CJK / Thai runs as Latin —
   plausible-looking glyphs would ship to PDF and pass a Latin-only
   reviewer's eye test. Now both args are required; until per-run
   UAX #24 script detection lands (cycle 3), callers must pass
   explicit metadata so the failure mode is "compile error", not
   "wrong glyphs ship to a PDF".

3. [P2] Added `CancellationToken cancellationToken = default` on
   both `LineBuilder.Shape` + the new `HbShaper.Shape` overload.
   LineBuilder checks cancellation between ItemizedRuns (most useful
   for documents with thousands of runs); HbShaper checks once before
   the native shape call. Per-run native call is microseconds, so
   instrumenting deeper isn't worth the call overhead.

4. [P2] LineBuilder.Shape now validates input coherence with clear
   ArgumentException failures (Copilot inline #2 + #3):
   - `ItemizedRun.SourceTextRunIndex` must be in range for
     `sourceTextRuns` (mismatched lists no longer surface as
     `IndexOutOfRangeException` from the array index).
   - `ItemizedRun.Utf16Start` ≥ 0 + `Utf16Start+Utf16Length` ≤
     concat length (mismatched lists no longer surface as
     `ArgumentOutOfRangeException` from the span slice).
   Both messages include the offending run index + numeric values
   so caller diagnostics can pinpoint the bad input.

5. [P1] `HtmlParsingHost` wide-DOM child-truncation loop fix. The
   prior `while (element.Children.Length > remainingBudget)` loop
   hangs on the 300k-child regression because AngleSharp's
   ChildElements (the live HtmlCollection backing `.Children`) re-
   walks the child node list on each `.Length` read — making the
   truncation loop O(N²). Replaced with a single forward sibling
   traversal that walks to the budget boundary once + removes the
   tail in O(doomed). The same pattern replaces the
   `remainingBudget <= 0` over-cap path. Effect on `B1Followup_
   wide_body_with_excess_children_truncated` (300k <p/> children):
   was hanging past the suite's blame-hang timeout; now completes
   in ~36s. Full unit suite drops from 19m 43s → 40s.

6. Doc nits:
   - PROGRESS.md: replaced "[PR #TBD]" placeholder with [PR #33]
     (Copilot inline #4).
   - ShapedRun.cs: relaxed the "TotalAdvance always non-negative"
     claim — HarfBuzz GPOS can in principle emit negative XAdvance
     (kerning, marks); well-formed fonts produce non-negative
     cumulative advance per cluster, but the wrapper itself does
     not enforce a sign invariant (Copilot inline #1).
   - LineBuilder.cs + ItemizedRun.cs: stale doc strings claimed
     UAX #24 script-change boundaries land in cycle 2; updated to
     cycle 3 (cycle 2 wired up the shaper but kept itemization at
     cycle-1 granularity).

Tests:
  - Updated 13 existing `LineBuilderShapingTests` to pass explicit
    "Latn" + "en" args (no more reliance on removed defaults).
  - Added 5 new tests:
    * `Shape_glyph_cluster_indices_are_concat_buffer_relative_for_multi_run`
      — verifies run-1's clusters land in [2,4) (concat coords)
      not [0,2) (run-local) per the full-buffer shaping fix.
    * `Shape_throws_when_SourceTextRunIndex_out_of_range` — pins
      ArgumentException with descriptive message.
    * `Shape_throws_when_Utf16Start_negative` — same.
    * `Shape_throws_when_Utf16_range_extends_past_concat_length` —
      same.
    * `Shape_observes_cancelled_token_before_first_run` — pre-
      cancelled CTS yields OperationCanceledException.
  - 18 LineBuilderShapingTests now (was 13).

Tests: 3870 unit / 4000 solution-wide passing (+5 unit / +5
solution-wide over PR #33's 3865; 4 skipped). AOT/JIT byte-parity
gate stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
raroche added a commit that referenced this pull request May 9, 2026
* Phase 3 Task 9 cycle 3a: LineBuilder.Wrap UAX #14 + greedy wrap

Continues Task 9 from cycle 2 (PR #33). Cycle 2 wired up shaping
(step 3 of the 6-step inline pipeline: bidi → itemize → shape →
break-opp → measure → wrap); cycle 3a ships **steps 4 + 6 — UAX #14
break-opportunity scan + greedy line wrapping** as MVP.

New types in `src/NetPdf.Layout/Inline/`:
  - `ShapedRunSlice` — readonly record struct (ShapedRunIndex,
    GlyphStart, GlyphLength, SliceAdvance). One contiguous glyph
    slice from a single ShapedRun that fits on a single line. The
    wrapping pass splits a ShapedRun at line-break boundaries; each
    line is composed of one or more slices (one per run that
    contributes glyphs).
  - `LineFragment` — readonly record struct (Slices[], TotalAdvance,
    EndsWithMandatoryBreak). One wrapped line ready for the painter.
    EndsWithMandatoryBreak distinguishes paragraph-end from soft-wrap
    so the painter can decide CRLF vs baseline-only behavior.

New static method on `LineBuilder`:
  - `Wrap(sourceTextRuns, shapedRuns, availableInlineSize, ct)` →
    `LineFragment[]`.

Algorithm (cycle 3a — naive greedy):
  1. Rebuild concat text from sourceTextRuns (same O(N) pattern as
     Shape).
  2. Run UAX #14 `LineBreakAlgorithm.FindBreaks` on the concat
     text — produces per-UTF-16-codeunit Prohibited / Allowed /
     Mandatory opportunities.
  3. Flatten glyphs across all shaped runs into a global array
     `(runIdx, glyphIdxInRun, advance, opp)` indexed by global glyph
     position. Cycle 2's full-buffer shaping makes glyph.Cluster
     concat-buffer relative, so the per-glyph break opportunity is
     `breaks[glyph.Cluster]`.
  4. Walk with two cursors (`lineStart`, `cursor`) tracking the
     most recent Allowed opportunity in `[lineStart, cursor]`.
  5. On overflow snap back to candidate + emit; on Mandatory force
     a fragment boundary; when no candidate exists overflow is
     allowed (cycle 3b adds `overflow-wrap`/`word-break`).
  6. Each emit groups consecutive same-run glyphs into one
     ShapedRunSlice — multi-shaped-run lines emit one slice per run.

Cycle 3a deferrals (cycle 3b/c):
  - White-space variants: `pre`/`pre-wrap`/`pre-line`/`nowrap`
    (cycle 3b).
  - `overflow-wrap: anywhere` + `word-break: break-all` (cycle 3b).
  - Hyphenation via Liang patterns — primitives already exist in
    `NetPdf.Text.Hyphenation` (cycle 3b).
  - `text-align` (start/end/center/justify) — cycle 3a emits left-
    aligned fragments only (cycle 3c).
  - `vertical-align` baseline shifts (cycle 3c).
  - RTL fragment-level reversal — cycle 2 already produces RTL
    glyph arrays in HarfBuzz visual order; cycle 3c reverses
    fragment-level slice order for RTL paragraphs.
  - UAX #24 per-run script detection (re-scoped from cycle 2;
    deferred to cycle 4).
  - Integration with `InlineLayouter` (Task 10).

New tests: `LineBuilderWrapTests` — 14 tests:
  - Empty input → empty result.
  - 7 arg validations: null sourceTextRuns + null shapedRuns + 5
    inline-size cases (zero / negative / NaN / +∞ / -∞ all reject).
  - Single short run fits in one line — one slice, full glyph count.
  - Soft-wrap on space separator (UAX #14 LB18) — two lines, first
    not mandatory-terminated.
  - Mandatory break at LF (LB5) — two lines, first marked
    EndsWithMandatoryBreak=true.
  - Long unbreakable run overflows without snap-back — cycle 3a
    fallback (cycle 3b adds overflow-wrap).
  - Multi-shaped-run line emits one slice per run with correct
    GlyphLengths.
  - TotalAdvance equals sum-of-slice-advances invariant.
  - Cancellation token observed at line emission.

Tests use the synthetic Latin font (no system-font dependency).

Tests: 3884 unit / 4014 solution-wide passing (+14 unit / +14
solution-wide over the cycle 2 review pass's 3870; 4 skipped).
AOT/JIT byte-parity gate stays green. Active-task pointer advances
to **Phase 3 Task 9 cycle 3b** — white-space variants +
`overflow-wrap` + hyphenation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PROGRESS.md: link cycle 3a entry to PR #34

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR #34 review pass: 7 user/Copilot fixes + 13 regression tests

Hardens cycle 3a's `LineBuilder.Wrap` before cycle 3b lands.
Addresses the user's 5-item review (cluster-end break mapping,
mandatory-control glyph trim, white-space doc downgrade, entry-
level cancellation, coherence validation) + 7 Copilot inline
comments on PR #34.

1. [P1] Cluster-END break-opportunity mapping. The prior
   `breaks[glyph.Cluster]` lookup used the FIRST UTF-16 code unit
   of each cluster, but `LineBreakAlgorithm.FindBreaks` writes the
   opportunity AFTER each unit — so multi-codeunit clusters
   (surrogate-pair emoji, combining marks, ligatures) had their
   Allowed/Mandatory breaks shifted or missed. Wrap now computes
   `clusterEnd` per glyph (= next glyph's cluster, or the run's
   UTF-16 end for the last glyph) + looks up
   `breaks[clusterEnd-1]`. RTL runs use `cluster+1` as a fallback —
   correct for single-codeunit clusters; multi-codeunit RTL cluster
   refinement is pinned to cycle 3c.

2. [P1] Mandatory-break control glyphs are now trimmed off drawable
   slices. The prior emit included the LF/CR/NEL/etc. glyph in the
   line, so the painter would have emitted .notdef for control
   characters in the glyph stream + ToUnicode mapping. Wrap now
   tags each glyph with `IsMandatoryControl` (LF, CR, VT, FF, NEL,
   LS, PS) + walks back from the mandatory-break cursor to drop
   any trailing control glyphs from the drawable slice. CRLF
   strips both CR + LF since both tag as mandatory-controls.

3. [P2] CancellationToken plumbing tightened. The prior code only
   checked the token after emit branches inside the wrap loop, so
   pre-cancelled tokens on single-line unbreakable inputs would
   return normally. Wrap now checks at method entry, before the
   concat rebuild, before FindBreaks, after each shaped-run during
   coherence validation, after each shaped-run during flat-glyph
   build, before the wrap loop's tail emission, and at every emit
   inside the wrap loop.

4. [P2] Coherence validation. Wrap now validates each shaped run
   up-front:
   - `Source.SourceTextRunIndex` is in range for sourceTextRuns
   - `Source.Utf16Start` ≥ 0 + `Source.Utf16Start + Source.Utf16Length`
     ≤ concat length
   - Each glyph's `Cluster` ≥ 0 + < concat length
   - Each glyph's `XAdvance` is finite + non-negative
   Mismatched lists throw ArgumentException with descriptive
   messages — fail early instead of `IndexOutOfRangeException` /
   `ArgumentOutOfRangeException` deep in the inner loop.

5. [P2] White-space doc downgrade. The prior LineFragment XML
   claimed "white-space is treated as normal (collapse + wrap)"
   but Wrap doesn't preprocess CSS white-space at all. Doc updated
   to clarify cycle 3a wraps input AS-IS; cycle 3b adds true
   white-space:normal collapse + trim + the
   pre/pre-wrap/pre-line/nowrap variants. New
   Wrap_collapsed_spaces_pinned_for_cycle_3b_white_space_normal
   regression test pins the AS-IS behavior so cycle 3b's
   preprocessor change is detected.

6. [P3] Doc + test comment nits (Copilot inline #5/#6/#7):
   - `ShapedRunSlice.GlyphLength` doc said zero-length slices may
     be emitted; actually empty drawable lines surface as
     `Slices = Array.Empty<ShapedRunSlice>()`, never as zero-length
     slices. Doc corrected.
   - LineBuilderWrapTests "AA\nBB" comment fixed to match actual
     "AA\nAA" data (synthetic font has no 'B' glyph).
   - Same for "AA + BB" multi-run comment → "AA + AA" with style
     distinction note.

7. [TESTS] 13 new regression tests in LineBuilderWrapTests:
   - Wrap_LF_glyph_excluded_from_drawable_slice
   - Wrap_CRLF_strips_both_CR_and_LF_from_drawable_slice
   - Wrap_trailing_LF_emits_one_drawable_line_then_empty_terminator
   - Wrap_lone_LF_emits_empty_drawable_fragment
   - Wrap_surrogate_pair_glyph_does_not_crash_break_lookup (emoji)
   - Wrap_collapsed_spaces_pinned_for_cycle_3b_white_space_normal
   - Wrap_combining_mark_does_not_split_cluster_advance
   - Wrap_precancelled_token_throws_at_method_entry_for_unbreakable_input
   - Wrap_throws_when_shapedRun_SourceTextRunIndex_out_of_range
   - Wrap_throws_when_shapedRun_Source_range_overflows_concat
   - Wrap_throws_when_glyph_Cluster_out_of_range
   - Wrap_throws_when_glyph_XAdvance_is_NaN
   - Wrap_throws_when_glyph_XAdvance_is_negative
   27 LineBuilderWrapTests now (was 14).

Tests: 3897 unit / 4027 solution-wide passing (+13 unit / +13
solution-wide over cycle 3a's 3884; 4 skipped). AOT/JIT byte-
parity gate stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
raroche added a commit that referenced this pull request May 9, 2026
Hardens cycle 3b sub-cycle 1 before sub-cycle 2 (overflow-wrap).
Addresses the user's 5-item review + 2 Copilot inline comments on
PR #35.

1. [User #1] Soft-wrap break-space trim. When the wrap loop snaps
   back to a UAX #14 Allowed break created by collapsible
   whitespace (SP/TAB) in collapsible modes (Normal/NoWrap/PreLine),
   the break-space glyph is now trimmed off the emitted drawable
   slice + its advance is excluded from TotalAdvance per CSS Text
   L3 §4.1.2 ("remove end-of-line spaces"). Pre/PreWrap preserve
   the break-space glyph (preserve modes — spaces are part of the
   rendered output). Implemented via a new IsBreakSpace flag on
   FlatGlyph, tagged during flat-build when the cluster's first
   codepoint is SP/TAB AND the white-space mode collapses spaces.

2. [User #2] Stateful inline-context preprocessor. New
   PreprocessTextRuns(IReadOnlyList<TextRun>, WhiteSpace) static
   method that carries the inWs collapse state across TextRun
   boundaries — "Hello " + "world" now collapses correctly to
   "Hello " + "world" (preserving the inter-run SP) rather than
   "Helloworld" if both runs strip independently. Document-leading
   + trailing strip applied at the boundaries of the multi-run
   sequence, not per-run. Pre/PreWrap path applies §4.1.1 segment-
   break normalization per run with no cross-run state.

3. [User #3] Per-run policy stopgap doc. Wrap's WhiteSpace
   parameter doc now explicitly notes the single-arg-for-the-whole-
   pass model is a stopgap; per-source-TextRun white-space
   (mixed-mode descendants like
   <span style="white-space:nowrap"> inside white-space:normal
   text) is scheduled for Task 10's InlineLayouter integration.

4. [User #4] CSS Text L3 §4.1.1 segment-break normalization.
   PreprocessWhitespace + PreprocessTextRuns now apply the spec's
   §4.1.1 transformation pass: any CR followed by LF → single LF;
   remaining lone CR → LF. Applies to Pre/PreWrap (where segment
   breaks are preserved) AND PreLine (collapse-spaces-but-preserve-
   breaks). Without this pass, callers feeding raw CRLF to Pre or
   PreLine would have seen TWO segment breaks where they should
   have one. New helper NormalizeSegmentBreaks with fast-path
   (returns input unchanged when no CRs).

5. [User #5 + Copilot #1] Doc refresh. LineFragment.cs replaced
   the stale cycle-3a "AS-IS whitespace" claim with the cycle-3b
   two-stage pipeline description (Preprocess + Wrap-time mode).
   PreprocessWhitespace doc dropped the misleading "(cycle 3b
   sub-cycle 2)" reference for §4.1.1 transformation rules
   (they're applied here in sub-cycle 1 — sub-cycle 2 covers
   overflow-wrap/word-break instead).

6. [Copilot #2] Fast-paths for CollapseAllWhitespace +
   CollapseSpacesPreserveBreaks. New scan helpers
   NeedsCollapseAllWhitespace + NeedsCollapseSpacesPreserveBreaks
   walk the input once; if there's nothing to collapse / strip /
   normalize, the helpers return the input string reference
   unchanged — no StringBuilder allocation, no copy. Also added
   to NormalizeSegmentBreaks (fast-path: no CRs → return input).

Tests: 12 new tests in LineBuilderWhitespaceTests covering:
  - PreprocessTextRuns null/empty/undefined-mode arg validation.
  - Inline-context "Hello " + "world" collapse boundary.
  - "Hello " + " world" double-boundary collapses to single SP.
  - Document leading/trailing strip across multi-run.
  - "Hello" + " world" leading-space-of-run-2 path.
  - Pre per-run segment-break normalization preserves no cross-
    run state.
  - PreLine + Pre CRLF→LF normalization.
  - Wrap_Normal_soft_wrap_trims_break_space_glyph_from_drawable
    (synthetic font's .notdef advance 600 fontUnits = 7.2px gives
    a non-zero space advance; line 1 advance < 20px after trim,
    not 25.2px without trim).
  - Wrap_PreLine_soft_wrap_trims_break_space_glyph (collapsible
    mode trims).
  - Wrap_PreWrap_soft_wrap_does_NOT_trim_break_space (preserve
    mode keeps the SP glyph).

32 LineBuilderWhitespaceTests now (was 20).

Tests: 3929 unit / 4059 solution-wide passing (+12 unit / +12
solution-wide over cycle 3b sub-cycle 1 ship at 3917; 4 skipped).
AOT/JIT byte-parity gate stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
raroche added a commit that referenced this pull request May 9, 2026
…NoWrap (#35)

* Phase 3 Task 9 cycle 3b sub-cycle 1: white-space preprocessing + Pre/NoWrap

Continues Task 9 from cycle 3a (PR #34). Cycle 3a wrapped raw input
AS-IS (no white-space preprocessing); cycle 3b sub-cycle 1 ships
**CSS Text L3 §4.1 white-space processing** + wrap-time `Pre`/`NoWrap`
honoring as the foundation for cycle 3b sub-cycles 2-3 (overflow-wrap,
hyphenation).

New types in `src/NetPdf.Layout/Inline/`:
  - `WhiteSpace` enum (byte): Normal=0, Pre=1, NoWrap=2, PreWrap=3,
    PreLine=4. Covers the 5 most-common CSS Text L3 §3 values;
    `break-spaces` deferred (rarely needed for v1 invoice/report
    content).

New static method on `LineBuilder`:
  - `PreprocessWhitespace(text, mode)` → `string`. Applies CSS Text
    L3 §4.1 collapse / preserve rules per mode.

Algorithm per mode:
  - Normal/NoWrap: collapse any run of {SP, TAB, LF, CR, FF} into a
    single SP + strip leading/trailing whitespace.
  - Pre/PreWrap: pass through unchanged.
  - PreLine: collapse SP+TAB runs to single SP but preserve LF+CR
    (segment breaks); strip end-of-line trailing SP per §4.1.2
    "remove end-of-line spaces".

Wrap signature update:
  - New 4th positional parameter `WhiteSpace whiteSpace =
    WhiteSpace.Normal` (cancellationToken now 5th).
  - When mode is Pre or NoWrap the wrap loop suppresses Allowed-
    break wrapping; only Mandatory breaks split lines.
  - WhiteSpace value validated up-front with descriptive
    ArgumentOutOfRangeException.

Cycle 3b sub-cycle 1 deferrals (sub-cycles 2/3):
  - `overflow-wrap: anywhere` + `word-break: break-all` (sub-cycle
    2 — forced break at any glyph when no candidate exists).
  - Hyphenation via Liang patterns (sub-cycle 3 — primitives
    already exist in `NetPdf.Text.Hyphenation`).
  - CSS Text L3 §4.1.1 segment-break-transformation rules
    (CR-LF→LF) — belongs in HTML preprocessor, not inline pass.
  - Preserved-tab tab-size handling for Pre mode.
  - Per-source-TextRun white-space mode — cycle 3b currently
    applies one mode for the whole inline pass; per-run
    white-space lands when InlineLayouter integrates in Task 10.
  - `break-spaces` mode (deferred).

New tests: `LineBuilderWhitespaceTests` — 20 tests:
  - 3 PreprocessWhitespace arg-validation (null text, undefined
    mode, empty text returns empty for all 5 modes).
  - 4 Normal cases: collapse consecutive spaces incl. tabs/CRLF;
    strip leading/trailing; pure-whitespace collapses to empty.
  - Pre + PreWrap pass-through invariants.
  - NoWrap collapse-like-Normal.
  - PreLine collapse-spaces-preserve-LF + end-of-line-SP-strip +
    CRLF-preserved.
  - Wrap-time NoWrap suppresses Allowed wrap (one overflowing
    line) but still honors Mandatory.
  - Wrap-time Pre suppresses Allowed wrap.
  - Wrap-time PreWrap + PreLine wrap at Allowed.
  - Wrap-time undefined-WhiteSpace throws.

Existing LineBuilderWrapTests updated for new Wrap signature
(positional cts.Token → named cancellationToken: cts.Token at 2
test sites).

Tests: 3917 unit / 4047 solution-wide passing (+20 unit / +20
solution-wide over cycle 3a's review pass at 3897; 4 skipped).
AOT/JIT byte-parity gate stays green. Active-task pointer advances
to **Phase 3 Task 9 cycle 3b sub-cycle 2** — `overflow-wrap` +
`word-break` (forced break at any glyph when no candidate exists).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PROGRESS.md: link cycle 3b sub-cycle 1 entry to PR #35

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR #35 review pass: 5 user recs + 2 Copilot fixes + 12 regression tests

Hardens cycle 3b sub-cycle 1 before sub-cycle 2 (overflow-wrap).
Addresses the user's 5-item review + 2 Copilot inline comments on
PR #35.

1. [User #1] Soft-wrap break-space trim. When the wrap loop snaps
   back to a UAX #14 Allowed break created by collapsible
   whitespace (SP/TAB) in collapsible modes (Normal/NoWrap/PreLine),
   the break-space glyph is now trimmed off the emitted drawable
   slice + its advance is excluded from TotalAdvance per CSS Text
   L3 §4.1.2 ("remove end-of-line spaces"). Pre/PreWrap preserve
   the break-space glyph (preserve modes — spaces are part of the
   rendered output). Implemented via a new IsBreakSpace flag on
   FlatGlyph, tagged during flat-build when the cluster's first
   codepoint is SP/TAB AND the white-space mode collapses spaces.

2. [User #2] Stateful inline-context preprocessor. New
   PreprocessTextRuns(IReadOnlyList<TextRun>, WhiteSpace) static
   method that carries the inWs collapse state across TextRun
   boundaries — "Hello " + "world" now collapses correctly to
   "Hello " + "world" (preserving the inter-run SP) rather than
   "Helloworld" if both runs strip independently. Document-leading
   + trailing strip applied at the boundaries of the multi-run
   sequence, not per-run. Pre/PreWrap path applies §4.1.1 segment-
   break normalization per run with no cross-run state.

3. [User #3] Per-run policy stopgap doc. Wrap's WhiteSpace
   parameter doc now explicitly notes the single-arg-for-the-whole-
   pass model is a stopgap; per-source-TextRun white-space
   (mixed-mode descendants like
   <span style="white-space:nowrap"> inside white-space:normal
   text) is scheduled for Task 10's InlineLayouter integration.

4. [User #4] CSS Text L3 §4.1.1 segment-break normalization.
   PreprocessWhitespace + PreprocessTextRuns now apply the spec's
   §4.1.1 transformation pass: any CR followed by LF → single LF;
   remaining lone CR → LF. Applies to Pre/PreWrap (where segment
   breaks are preserved) AND PreLine (collapse-spaces-but-preserve-
   breaks). Without this pass, callers feeding raw CRLF to Pre or
   PreLine would have seen TWO segment breaks where they should
   have one. New helper NormalizeSegmentBreaks with fast-path
   (returns input unchanged when no CRs).

5. [User #5 + Copilot #1] Doc refresh. LineFragment.cs replaced
   the stale cycle-3a "AS-IS whitespace" claim with the cycle-3b
   two-stage pipeline description (Preprocess + Wrap-time mode).
   PreprocessWhitespace doc dropped the misleading "(cycle 3b
   sub-cycle 2)" reference for §4.1.1 transformation rules
   (they're applied here in sub-cycle 1 — sub-cycle 2 covers
   overflow-wrap/word-break instead).

6. [Copilot #2] Fast-paths for CollapseAllWhitespace +
   CollapseSpacesPreserveBreaks. New scan helpers
   NeedsCollapseAllWhitespace + NeedsCollapseSpacesPreserveBreaks
   walk the input once; if there's nothing to collapse / strip /
   normalize, the helpers return the input string reference
   unchanged — no StringBuilder allocation, no copy. Also added
   to NormalizeSegmentBreaks (fast-path: no CRs → return input).

Tests: 12 new tests in LineBuilderWhitespaceTests covering:
  - PreprocessTextRuns null/empty/undefined-mode arg validation.
  - Inline-context "Hello " + "world" collapse boundary.
  - "Hello " + " world" double-boundary collapses to single SP.
  - Document leading/trailing strip across multi-run.
  - "Hello" + " world" leading-space-of-run-2 path.
  - Pre per-run segment-break normalization preserves no cross-
    run state.
  - PreLine + Pre CRLF→LF normalization.
  - Wrap_Normal_soft_wrap_trims_break_space_glyph_from_drawable
    (synthetic font's .notdef advance 600 fontUnits = 7.2px gives
    a non-zero space advance; line 1 advance < 20px after trim,
    not 25.2px without trim).
  - Wrap_PreLine_soft_wrap_trims_break_space_glyph (collapsible
    mode trims).
  - Wrap_PreWrap_soft_wrap_does_NOT_trim_break_space (preserve
    mode keeps the SP glyph).

32 LineBuilderWhitespaceTests now (was 20).

Tests: 3929 unit / 4059 solution-wide passing (+12 unit / +12
solution-wide over cycle 3b sub-cycle 1 ship at 3917; 4 skipped).
AOT/JIT byte-parity gate stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
raroche added a commit that referenced this pull request May 10, 2026
Hardens cycle 3b sub-cycle 2 before sub-cycle 3 (hyphenation).
Addresses the user's 5-item review + 4 Copilot inline comments
on PR #36.

1. [User #1] Fix duplicate-file build infrastructure. The earlier
   `<Compile Remove>` in Directory.Build.props fired BEFORE the SDK
   expanded its default Compile items, so the excludes had no
   effect when dup files appeared after Sdk import. Replaced with
   `<DefaultItemExcludes>$(DefaultItemExcludes);**/* [2-9].cs;**/*
   [1-9][0-9].cs</DefaultItemExcludes>` which the SDK consults
   DURING its default-item expansion. Patterns aligned with the
   existing .gitignore convention (added matching entries for
   *.cs duplicates). Deleted 2 existing dup files:
   `src/NetPdf.Layout/Inline/WhiteSpace 2.cs` and
   `tests/NetPdf.UnitTests/Phase3/LineBuilderWhitespaceTests 2.cs`.

2. [User #2 + Copilot #3 + #4] Gate OverflowWrap.Anywhere by
   white-space wrapping permission. Anywhere now only fires when
   `wrapsAtAllowed` is true (i.e., NOT under WhiteSpace.Pre or
   WhiteSpace.NoWrap). Pre/NoWrap's strict no-wrap policy wins
   over Anywhere's overflow-prevention. Tests
   `Wrap_NoWrap_with_Anywhere_does_NOT_force_breaks` and
   `Wrap_Pre_with_Anywhere_does_NOT_force_breaks` pin this.
   Renamed two prior tests with misleading names per Copilot:
   - `Wrap_OverflowWrap_Anywhere_with_NoWrap_only_breaks_at_Mandatory`
     → renamed to reflect the gating; assertion flipped from
     "still wraps" to "does NOT force breaks".
   - `Wrap_BreakAll_overrides_Pre_NoWrap_Allowed_suppression`
     → `Wrap_Pre_BreakAll_combo_does_NOT_wrap_pin_pre_no_wrap_policy`
     so the name matches the assertion (Pre's no-wrap wins).

3. [Copilot #1] OverflowWrap.Anywhere now handles the
   cursor==lineStart single-glyph-wider-than-line case explicitly:
   emit just that glyph as its own line + advance lineStart. This
   guarantees progress + limits overflow to at most one glyph per
   line. Without this, a 1px-budget input would have hung at the
   cursor>lineStart guard.

4. [User #3 + #4] WordBreak.BreakAll respects grapheme cluster
   boundaries (UAX #29 §3.1) and protected codepoint adjacencies.
   Instead of upgrading EVERY Prohibited boundary to Allowed, the
   upgrade is now conditional on:
     a. The cluster end is at a UAX #29 grapheme cluster boundary
        (computed once via `GraphemeClusterBreaker.FindBoundaries`).
        Combining marks, ZWJ-joined emoji sequences, regional-
        indicator flag pairs all stay atomic.
     b. The boundary is NOT adjacent to ZWJ (U+200D), WJ (U+2060),
        or NBSP (U+00A0). UAX #14 LB8a/LB11/LB12 protection rules
        are not overridden by BreakAll.
   New helper `IsBreakAllProtected(text, i)` performs the
   adjacency check.

5. [User #5] Documented LineBuilder-only scope of overflow-wrap +
   word-break. The Wrap method's XML doc now explicitly notes that
   per-element CSS resolution / materialization from a
   ComputedStyle is deferred to Task 10's InlineLayouter
   integration; until then the single-arg API is a stopgap.

6. [Copilot #2] PROGRESS.md PR #35 link cleaned up — dropped the
   spurious "/2025/" path segment so the link points to the
   correct GitHub URL.

Tests: 5 new regression tests in LineBuilderOverflowWrapTests:
  - `Wrap_NoWrap_with_Anywhere_does_NOT_force_breaks`
  - `Wrap_Pre_with_Anywhere_does_NOT_force_breaks`
  - `Wrap_OverflowWrap_Anywhere_single_glyph_wider_than_line_emits_one_per_line`
  - `Wrap_WordBreak_BreakAll_does_not_break_adjacent_to_NBSP`
  - `Wrap_WordBreak_BreakAll_does_not_break_adjacent_to_ZWJ`
  - `Wrap_WordBreak_BreakAll_does_not_break_adjacent_to_WJ`
15 LineBuilderOverflowWrapTests now (was 10).

Tests: 3944 unit / 4074 solution-wide passing (+5 unit / +5
solution-wide over sub-cycle 2 ship at 3939; 4 skipped). AOT/JIT
byte-parity gate stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
raroche added a commit that referenced this pull request May 10, 2026
* Phase 3 Task 9 cycle 3b sub-cycle 2: overflow-wrap + word-break

Continues Task 9 from cycle 3b sub-cycle 1 (PR #35). Sub-cycle 1
shipped white-space preprocessing + Pre/NoWrap wrap-time mode;
sub-cycle 2 ships **overflow-wrap + word-break** for forcing
wraps when no UAX #14 candidate exists.

New types in `src/NetPdf.Layout/Inline/`:
  - `OverflowWrap` enum (byte): Normal=0, Anywhere=1. Per CSS Text
    L3 §5.1.
  - `WordBreak` enum (byte): Normal=0, BreakAll=1, KeepAll=2. Per
    CSS Text L3 §5.2.

`LineBuilder.Wrap` updates:
  - New 5th + 6th positional parameters
    `OverflowWrap overflowWrap = OverflowWrap.Normal` and
    `WordBreak wordBreak = WordBreak.Normal`.
  - When wordBreak == BreakAll, flat-build upgrades every
    Prohibited per-glyph opportunity to Allowed (forces aggressive
    glyph-level wrapping).
  - When overflowWrap == Anywhere AND the line overflows AND no
    Allowed candidate exists in [lineStart, cursor), a forced
    break fires at cursor-1.
  - Both args validated up-front with descriptive
    ArgumentOutOfRangeException.

Composition rules:
  - OverflowWrap.Anywhere only fires under overflow + no candidate
    (preserves UAX #14 candidates as preferred — UAX #14 SP is
    preferred over per-glyph forced break).
  - WordBreak.BreakAll respects Pre/NoWrap's wrapsAtAllowed=false
    rule (Pre's strict no-wrap policy wins).
  - KeepAll has no observable effect for Latin/Cyrillic/Greek
    content (CJK semantics activate when UAX #24 lands in cycle 4).

Cycle 3b sub-cycle 2 deferrals (sub-cycle 3):
  - Hyphenation via Liang patterns (primitives already exist in
    `NetPdf.Text.Hyphenation`).
  - `overflow-wrap: break-word` (deprecated alias for `anywhere`
    with subtle min-content-size differences — most callers use
    `anywhere`).
  - Per-source-TextRun policy (Task 10's InlineLayouter).

New tests: `LineBuilderOverflowWrapTests` — 10 tests:
  - 2 arg-validation (undefined OverflowWrap + WordBreak).
  - OverflowWrap.Normal cycle-3a-fallback overflow.
  - OverflowWrap.Anywhere unbreakable-run forces per-glyph break
    (no line exceeds budget).
  - OverflowWrap.Anywhere prefers UAX #14 candidate when available
    (snap-back to SP, not per-glyph).
  - WordBreak.BreakAll glyph-level wrap.
  - WordBreak.KeepAll no-effect for Latin (matches Normal).
  - NoWrap + Anywhere combo (Anywhere fires when NoWrap leaves no
    candidate).
  - Pre + BreakAll (Pre's no-wrap policy wins).
  - Default Normal matches cycle-3a behavior.

Tests: 3939 unit / 4069 solution-wide passing (+10 unit / +10
solution-wide over sub-cycle 1's 3929; 4 skipped). AOT/JIT
byte-parity gate stays green. Active-task pointer advances to
**Phase 3 Task 9 cycle 3b sub-cycle 3** — hyphenation via Liang
patterns + soft-hyphen support.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PROGRESS.md: link cycle 3b sub-cycle 2 entry to PR #36

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR #36 review pass: 5 user recs + 4 Copilot fixes + 5 regression tests

Hardens cycle 3b sub-cycle 2 before sub-cycle 3 (hyphenation).
Addresses the user's 5-item review + 4 Copilot inline comments
on PR #36.

1. [User #1] Fix duplicate-file build infrastructure. The earlier
   `<Compile Remove>` in Directory.Build.props fired BEFORE the SDK
   expanded its default Compile items, so the excludes had no
   effect when dup files appeared after Sdk import. Replaced with
   `<DefaultItemExcludes>$(DefaultItemExcludes);**/* [2-9].cs;**/*
   [1-9][0-9].cs</DefaultItemExcludes>` which the SDK consults
   DURING its default-item expansion. Patterns aligned with the
   existing .gitignore convention (added matching entries for
   *.cs duplicates). Deleted 2 existing dup files:
   `src/NetPdf.Layout/Inline/WhiteSpace 2.cs` and
   `tests/NetPdf.UnitTests/Phase3/LineBuilderWhitespaceTests 2.cs`.

2. [User #2 + Copilot #3 + #4] Gate OverflowWrap.Anywhere by
   white-space wrapping permission. Anywhere now only fires when
   `wrapsAtAllowed` is true (i.e., NOT under WhiteSpace.Pre or
   WhiteSpace.NoWrap). Pre/NoWrap's strict no-wrap policy wins
   over Anywhere's overflow-prevention. Tests
   `Wrap_NoWrap_with_Anywhere_does_NOT_force_breaks` and
   `Wrap_Pre_with_Anywhere_does_NOT_force_breaks` pin this.
   Renamed two prior tests with misleading names per Copilot:
   - `Wrap_OverflowWrap_Anywhere_with_NoWrap_only_breaks_at_Mandatory`
     → renamed to reflect the gating; assertion flipped from
     "still wraps" to "does NOT force breaks".
   - `Wrap_BreakAll_overrides_Pre_NoWrap_Allowed_suppression`
     → `Wrap_Pre_BreakAll_combo_does_NOT_wrap_pin_pre_no_wrap_policy`
     so the name matches the assertion (Pre's no-wrap wins).

3. [Copilot #1] OverflowWrap.Anywhere now handles the
   cursor==lineStart single-glyph-wider-than-line case explicitly:
   emit just that glyph as its own line + advance lineStart. This
   guarantees progress + limits overflow to at most one glyph per
   line. Without this, a 1px-budget input would have hung at the
   cursor>lineStart guard.

4. [User #3 + #4] WordBreak.BreakAll respects grapheme cluster
   boundaries (UAX #29 §3.1) and protected codepoint adjacencies.
   Instead of upgrading EVERY Prohibited boundary to Allowed, the
   upgrade is now conditional on:
     a. The cluster end is at a UAX #29 grapheme cluster boundary
        (computed once via `GraphemeClusterBreaker.FindBoundaries`).
        Combining marks, ZWJ-joined emoji sequences, regional-
        indicator flag pairs all stay atomic.
     b. The boundary is NOT adjacent to ZWJ (U+200D), WJ (U+2060),
        or NBSP (U+00A0). UAX #14 LB8a/LB11/LB12 protection rules
        are not overridden by BreakAll.
   New helper `IsBreakAllProtected(text, i)` performs the
   adjacency check.

5. [User #5] Documented LineBuilder-only scope of overflow-wrap +
   word-break. The Wrap method's XML doc now explicitly notes that
   per-element CSS resolution / materialization from a
   ComputedStyle is deferred to Task 10's InlineLayouter
   integration; until then the single-arg API is a stopgap.

6. [Copilot #2] PROGRESS.md PR #35 link cleaned up — dropped the
   spurious "/2025/" path segment so the link points to the
   correct GitHub URL.

Tests: 5 new regression tests in LineBuilderOverflowWrapTests:
  - `Wrap_NoWrap_with_Anywhere_does_NOT_force_breaks`
  - `Wrap_Pre_with_Anywhere_does_NOT_force_breaks`
  - `Wrap_OverflowWrap_Anywhere_single_glyph_wider_than_line_emits_one_per_line`
  - `Wrap_WordBreak_BreakAll_does_not_break_adjacent_to_NBSP`
  - `Wrap_WordBreak_BreakAll_does_not_break_adjacent_to_ZWJ`
  - `Wrap_WordBreak_BreakAll_does_not_break_adjacent_to_WJ`
15 LineBuilderOverflowWrapTests now (was 10).

Tests: 3944 unit / 4074 solution-wide passing (+5 unit / +5
solution-wide over sub-cycle 2 ship at 3939; 4 skipped). AOT/JIT
byte-parity gate stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
raroche added a commit that referenced this pull request May 10, 2026
Hardens cycle 3b sub-cycle 3 before Task 10 InlineLayouter
integration. Addresses the user's 6-item review + 2 Copilot
inline comments on PR #37.

1. [User #1] Fix duplicate-file build infrastructure. The earlier
   DefaultItemExcludes range glob (`**/* [2-9].cs`) proved
   unreliable across SDK versions. Moved to a new
   Directory.Build.targets file with explicit per-suffix
   `<Compile Remove>` patterns (covers Finder dups 1-9 + 10-19,
   the typical range users encounter). Targets fire AFTER SDK
   default-item expansion so the removes work deterministically.
   Deleted the existing dup file `LineBuilderOverflowWrapTests
   2.cs` that had been compiling silently under the old broken
   pattern.

2. [User #2] Added EndsWithHyphenationBreak metadata to
   LineFragment. Phase 4 painter uses this to render a visible
   hyphen at the line end per CSS Text L3 §6.1.1: "When a line is
   broken at a hyphenation opportunity that does not include a
   visible hyphen character, the UA must insert one." Set true
   when soft-wrap fires at a soft-hyphen U+00AD or a Liang-pattern
   position. Always false when EndsWithMandatoryBreak is true.

3. [User #3 + Copilot #1] Suppress soft-hyphen U+00AD glyphs from
   normal drawing. New IsSoftHyphen flag on FlatGlyph. The wrap
   loop:
   - Zeroes Advance for fit decisions (SH doesn't push lineAdvance
     beyond budget, so the snap-back fires at the correct
     position).
   - Trims trailing IsSoftHyphen glyphs from the drawable slice
     (always — even under Pre/PreWrap modes where regular spaces
     are preserved). The glyph is invisible until Phase 4 renders
     it as a visible hyphen at break.

4. [User #4] Cancellation + word-size limits in
   ApplyLiangPatterns. Added per-word
   ThrowIfCancellationRequested + a MaxLiangWordLength=64
   threshold; words longer than this are skipped (real natural-
   language words rarely exceed 32 chars; long ASCII tokens like
   URLs, base64 strings, or hashes aren't meaningful hyphenation
   candidates and would otherwise drive large allocations + CPU
   inside the Liang engine).

5. [User #5] Manual-mode fast path. When `hyphens == Hyphens.Manual`
   AND the concat text contains no U+00AD (the only break source
   under Manual), skip the hyphenationAfter[concatTotal]
   allocation entirely. Saves an allocation + a per-codepoint
   scan for the common case of paragraphs without explicit soft-
   hyphens.

6. [User #6] Documented the CSS-property pipeline gap. Updated
   the `hyphens` parameter doc on LineBuilder.Wrap to explicitly
   note the deferral: this single-arg API is a stopgap; the
   properties.json + KeywordResolver + ComputedStyle
   materialization + per-source-TextRun plumbing all land in
   Task 10's InlineLayouter integration. Same deferral applies
   to overflow-wrap + word-break + white-space (already
   documented).

7. [Copilot #2] Updated the misleading test comment about UAX #14
   classification of U+00AD. The original comment said "UAX #14
   doesn't classify it as Allowed" which is incorrect — U+00AD
   IS BA (Break After) in UAX #14. The actual no-wrap behavior
   under Hyphens.None comes from the Wrap-pass active demotion
   logic. Comment now reflects this.

Tests: 6 new regression tests in LineBuilderHyphenationTests:
  - Wrap_soft_hyphen_glyph_trimmed_from_drawable_at_break — drawable
    glyph count = 3 (AAA), not 4 (AAA + SH).
  - Wrap_soft_hyphen_advance_zero_for_fit_decisions — line 1
    TotalAdvance == 18 (3 A's), excludes SH .notdef advance.
  - Wrap_LineFragment_EndsWithHyphenationBreak_set_on_soft_hyphen —
    Line 1 EndsWithHyphenationBreak=true; Line 2 false.
  - Wrap_LineFragment_EndsWithHyphenationBreak_false_for_non_hyphenation_wrap
    — soft-wrap at regular SP doesn't set the flag.
  - Wrap_Auto_pathologically_long_token_skipped — 200-char ASCII
    "word" doesn't crash or hang under Auto.
  - Wrap_Manual_no_soft_hyphen_fast_path_no_alloc — fast path
    behavior matches slow path for non-SH input.

16 LineBuilderHyphenationTests now (was 10).

Tests: 3960 unit / 4090 solution-wide passing (+6 unit / +6
solution-wide over sub-cycle 3 ship at 3954; 4 skipped). AOT/JIT
byte-parity gate stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
raroche added a commit that referenced this pull request May 10, 2026
…#37)

* Phase 3 Task 9 cycle 3b sub-cycle 3: hyphens + Liang auto-hyphenation

Continues Task 9 from cycle 3b sub-cycle 2 (PR #36 merged at
1414f21). Sub-cycle 2 shipped overflow-wrap + word-break;
sub-cycle 3 ships **CSS hyphens property + en-US Liang-pattern
auto-hyphenation** as the final piece of cycle 3b.

New types in `src/NetPdf.Layout/Inline/`:
  - `Hyphens` enum (byte): None=0, Manual=1, Auto=2. Per CSS Text
    L3 §6.1.

`LineBuilder.Wrap` updates:
  - New 7th positional parameter `Hyphens hyphens = Hyphens.Manual`
    (CSS default).
  - New 8th positional parameter `Hyphenator? hyphenator = null`
    — when null + Auto mode, falls back to
    `EnUsHyphenation.Default`.
  - Both args validated with descriptive ArgumentOutOfRangeException.

Algorithm:
  - When `hyphens != Hyphens.None`, compute a `hyphenationAfter[
    concatTotal]` boolean array:
    * Soft-hyphens (U+00AD) at any position → break opportunity
      (Manual + Auto).
    * Liang-pattern positions per ASCII-letter word →
      `Hyphenator.FindHyphenationPoints(wordSpan)` → mapped back
      to concat-text indices (Auto only).
  - The flat-build pass upgrades Prohibited boundaries to Allowed
    at hyphenation positions. The existing wrap loop's snap-back
    naturally honors them.
  - When `hyphens == Hyphens.None`, the wrap pass actively demotes
    UAX #14's BA classification of U+00AD to Prohibited per CSS
    Text L3 §6.1: "words are not hyphenated, even if soft-hyphens
    explicitly suggest opportunities".

Cycle 3b sub-cycle 3 simplifications:
  - Visible-hyphen-on-break rendering (CSS Text L3 §6.1.1) —
    deferred to Phase 4 painter cycle (display-list IR).
  - ASCII-letter-only word tokenizer for Liang patterns — UAX #29
    word-segmentation deferred.
  - en-US patterns regardless of language — per-language pattern
    routing scheduled for Task 10 InlineLayouter integration.
  - `hyphenate-character`, `hyphenate-limit-chars`,
    `hyphenate-limit-lines`, `hyphenate-limit-zone` (CSS Text L4)
    deferred.

**Cycle 3b complete with this ship — sub-cycles 1+2+3 cover the
wrap-pass CSS-Text-L3 §3-§6 surface.**

New tests: `LineBuilderHyphenationTests` — 10 tests:
  - Arg validation (undefined Hyphens throws).
  - Manual mode soft-hyphen creates break opportunity.
  - None ignores soft-hyphen (active demotion).
  - Auto soft-hyphen still works.
  - Auto null-hyphenator falls back to EnUs default.
  - Manual does NOT apply Liang patterns.
  - Auto applies Liang patterns to split long word ("hyphenation").
  - Auto with explicit hyphenator override.
  - Auto short word below leftMin/rightMin no hyphenation.
  - Default Wrap mode is Manual.

Tests use the synthetic Latin font; Liang pattern logic is
exercised via the production `EnUsHyphenation.Default` so the
pattern set + word tokenizer integration is end-to-end.

Tests: 3954 unit / 4084 solution-wide passing (+10 unit / +10
solution-wide over sub-cycle 2 hardening pass at 3944; 4 skipped).
AOT/JIT byte-parity gate stays green. Active-task pointer advances
to **Phase 3 Task 10** — InlineLayouter integration (per-source-
TextRun policy, UAX #24 script detection, RTL fragment-level
reversal, LineFragment[] → BoxFragment per Phase 3 plan).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PROGRESS.md: link cycle 3b sub-cycle 3 entry to PR #37

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR #37 review pass: 6 user recs + 2 Copilot fixes + 6 regression tests

Hardens cycle 3b sub-cycle 3 before Task 10 InlineLayouter
integration. Addresses the user's 6-item review + 2 Copilot
inline comments on PR #37.

1. [User #1] Fix duplicate-file build infrastructure. The earlier
   DefaultItemExcludes range glob (`**/* [2-9].cs`) proved
   unreliable across SDK versions. Moved to a new
   Directory.Build.targets file with explicit per-suffix
   `<Compile Remove>` patterns (covers Finder dups 1-9 + 10-19,
   the typical range users encounter). Targets fire AFTER SDK
   default-item expansion so the removes work deterministically.
   Deleted the existing dup file `LineBuilderOverflowWrapTests
   2.cs` that had been compiling silently under the old broken
   pattern.

2. [User #2] Added EndsWithHyphenationBreak metadata to
   LineFragment. Phase 4 painter uses this to render a visible
   hyphen at the line end per CSS Text L3 §6.1.1: "When a line is
   broken at a hyphenation opportunity that does not include a
   visible hyphen character, the UA must insert one." Set true
   when soft-wrap fires at a soft-hyphen U+00AD or a Liang-pattern
   position. Always false when EndsWithMandatoryBreak is true.

3. [User #3 + Copilot #1] Suppress soft-hyphen U+00AD glyphs from
   normal drawing. New IsSoftHyphen flag on FlatGlyph. The wrap
   loop:
   - Zeroes Advance for fit decisions (SH doesn't push lineAdvance
     beyond budget, so the snap-back fires at the correct
     position).
   - Trims trailing IsSoftHyphen glyphs from the drawable slice
     (always — even under Pre/PreWrap modes where regular spaces
     are preserved). The glyph is invisible until Phase 4 renders
     it as a visible hyphen at break.

4. [User #4] Cancellation + word-size limits in
   ApplyLiangPatterns. Added per-word
   ThrowIfCancellationRequested + a MaxLiangWordLength=64
   threshold; words longer than this are skipped (real natural-
   language words rarely exceed 32 chars; long ASCII tokens like
   URLs, base64 strings, or hashes aren't meaningful hyphenation
   candidates and would otherwise drive large allocations + CPU
   inside the Liang engine).

5. [User #5] Manual-mode fast path. When `hyphens == Hyphens.Manual`
   AND the concat text contains no U+00AD (the only break source
   under Manual), skip the hyphenationAfter[concatTotal]
   allocation entirely. Saves an allocation + a per-codepoint
   scan for the common case of paragraphs without explicit soft-
   hyphens.

6. [User #6] Documented the CSS-property pipeline gap. Updated
   the `hyphens` parameter doc on LineBuilder.Wrap to explicitly
   note the deferral: this single-arg API is a stopgap; the
   properties.json + KeywordResolver + ComputedStyle
   materialization + per-source-TextRun plumbing all land in
   Task 10's InlineLayouter integration. Same deferral applies
   to overflow-wrap + word-break + white-space (already
   documented).

7. [Copilot #2] Updated the misleading test comment about UAX #14
   classification of U+00AD. The original comment said "UAX #14
   doesn't classify it as Allowed" which is incorrect — U+00AD
   IS BA (Break After) in UAX #14. The actual no-wrap behavior
   under Hyphens.None comes from the Wrap-pass active demotion
   logic. Comment now reflects this.

Tests: 6 new regression tests in LineBuilderHyphenationTests:
  - Wrap_soft_hyphen_glyph_trimmed_from_drawable_at_break — drawable
    glyph count = 3 (AAA), not 4 (AAA + SH).
  - Wrap_soft_hyphen_advance_zero_for_fit_decisions — line 1
    TotalAdvance == 18 (3 A's), excludes SH .notdef advance.
  - Wrap_LineFragment_EndsWithHyphenationBreak_set_on_soft_hyphen —
    Line 1 EndsWithHyphenationBreak=true; Line 2 false.
  - Wrap_LineFragment_EndsWithHyphenationBreak_false_for_non_hyphenation_wrap
    — soft-wrap at regular SP doesn't set the flag.
  - Wrap_Auto_pathologically_long_token_skipped — 200-char ASCII
    "word" doesn't crash or hang under Auto.
  - Wrap_Manual_no_soft_hyphen_fast_path_no_alloc — fast path
    behavior matches slow path for non-SH input.

16 LineBuilderHyphenationTests now (was 10).

Tests: 3960 unit / 4090 solution-wide passing (+6 unit / +6
solution-wide over sub-cycle 3 ship at 3954; 4 skipped). AOT/JIT
byte-parity gate stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
raroche added a commit that referenced this pull request May 10, 2026
Hardens Task 10 cycle 3 by closing the AngleSharp.Css gap for
the new CSS Text L3 properties, adding word-wrap legacy alias
support, and replacing the silent break-spaces → Normal fold
with a real WhiteSpace.BreakSpaces enum value. Addresses the
user's 5-item review + 2 Copilot inline comments on PR #40.

1. [User #1, P1] Extended CssPreprocessor.ScanDeclarations with a
   KnownDroppedProperties recovery set (overflow-wrap, hyphens,
   white-space, word-wrap) so authored declarations that
   AngleSharp.Css 1.0.0-beta.144 silently drops are recovered
   from raw rule bodies. The InlineTextPolicyEndToEndTests'
   previously-pinned "AngleSharp drops" tests are now flipped to
   verify the values RESOLVE correctly:
   - p { overflow-wrap: anywhere } → OverflowWrap.Anywhere ✓
   - p { overflow-wrap: break-word } → OverflowWrap.Anywhere ✓
   - p { hyphens: auto } → Hyphens.Auto ✓
   - p { white-space: break-spaces } → WhiteSpace.BreakSpaces ✓
   The end-to-end test helper now calls CssPreprocessor.Process
   before CssParserAdapter.Adapt (matching production
   Phase2Pipeline) so the recovery path is actually exercised.

2. [User #2, P1] Added a central LegacyPropertyAliases dictionary
   in CssPreprocessor + a public NormalizePropertyName(name)
   helper. ScanDeclarations applies the normalization at recovery
   emission time so `word-wrap: break-word` lands at the cascade
   as `overflow-wrap: break-word`. New end-to-end test
   `EndToEnd_word_wrap_legacy_alias_normalizes_to_overflow_wrap`
   pins the production-path resolution of the legacy alias.

3. [User #3, P2] Added WhiteSpace.BreakSpaces enum value (= 5).
   The InlineTextPolicy materializer no longer silently folds
   keyword id 4 (break-spaces) to Normal — it now maps to
   BreakSpaces. PreprocessTextRuns + Wrap validation accept the
   new value. Cycle 3 simplification: BreakSpaces behaves like
   PreWrap (preserve + wrap at UAX #14 Allowed); the "wrap at
   every preserved space" detail per CSS Text L3 §6.4 lands in a
   subsequent cycle with forced wrap candidates at every SP
   glyph. The simplification preserves authored whitespace
   correctly (which is the user-visible guarantee) — strictly
   better than collapse-to-Normal which would silently lose
   spaces.

4. [User #4, P2] Updated cycle 3 InlineLayouter.Layout XML doc to
   prominently warn "UNIFORM-POLICY only — does NOT support
   per-source-TextRun" with explicit caller contract notes. Added
   pinned regression test
   `Layout_with_mixed_run_styles_silently_uniform_pinned` that
   documents the limit: the containing-block policy applies to
   the entire pass even when individual TextRuns have different
   styles. Cycle 3b will flip this when per-glyph metadata is
   added.

5. [User #5, P2] Added real-CSS pipeline tests to
   InlineTextPolicyEndToEndTests covering each previously-pinned
   gap + the new alias + break-spaces paths. Flipped 3 previously-
   pinned-as-failing tests to verify the working production path.

6. [Copilot #1+#2] Removed the unused `using NetPdf.Text.Bidi;`
   import from InlineLayouterCycle3Tests.cs (CS8019).

Tests added/changed: 4 new end-to-end (overflow-wrap recovery +
overflow-wrap break-word fold + word-wrap alias + break-spaces
real-CSS) + 1 mixed-mode pinned (Cycle3Tests) + 1 updated
materializer test (break-spaces no longer folds to Normal).

Tests: 4047 unit / 4183 solution-wide passing (+4 unit / +4
solution-wide over PR #40 ship at 4043; 4 skipped). AOT/JIT
byte-parity gate stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
raroche added a commit that referenced this pull request May 10, 2026
…ComputedStyle (#40)

* Phase 3 Task 10 cycle 3: Layout overload reads InlineTextPolicy from containing-block style

Continues from Task 10 cycle 2 (PR #39 merged at eadc9f5). Cycle 2
shipped the CSS-property pipeline + InlineTextPolicy materializer;
cycle 3 ships **the convenience `Layout` overload that reads the
wrap-time policy from a containing-block ComputedStyle**, closing
the cycle-1/2 gap where callers manually passed 4 wrap-policy
enums.

New `InlineLayouter.Layout(...)` overload signature:
```
public static LineFragment[] Layout(
    IReadOnlyList<TextRun> sourceTextRuns,
    double availableInlineSize,
    IShaperResolver resolver,
    ComputedStyle containingBlockStyle,        // NEW
    string scriptIso15924,
    string language,
    ParagraphDirection paragraphDirection = LeftToRight,
    Hyphenator? hyphenator = null,
    CancellationToken cancellationToken = default);
```

Algorithm:
  1. Validate args.
  2. Read InlineTextPolicy via the cycle-2 materializer
     `containingBlockStyle.ReadInlineTextPolicy()`.
  3. Delegate to the cycle-1+2 explicit-args Layout overload.

The cycle-2 materializer's cross-property folds
(`word-break:break-word` → bumps overflow-wrap to Anywhere;
`overflow-wrap:break-word` → folds to Anywhere;
`white-space:break-spaces` → folds to Normal) all flow through the
new convenience path automatically.

Cycle 3 simplification: policy is read from ONE containing-block
style + applied uniformly to all source TextRuns. Mixed-mode
descendants (`<span style="white-space:nowrap">` inside
`white-space:normal` text) require per-glyph policy metadata
flowing through Wrap — scheduled for cycle 3b. The cycle 3a path
covers the 95% case for invoice/report content where all
descendants inherit the paragraph's settings.

Cycle 3 deferrals (cycle 3b/4):
  - Per-source-TextRun policy with per-glyph metadata.
  - UAX #24 script detection.
  - RTL fragment-level reversal.
  - LineFragment[] → BoxFragment conversion (cycle 4).
  - Bidi-aware glyph painting via Phase 4 painter integration.

New tests: 7 in `InlineLayouterCycle3Tests`:
  - Null containingBlockStyle throws.
  - Default ComputedStyle returns default policy behavior.
  - NoWrap (keyword id 2) suppresses soft-wrap.
  - OverflowWrap.Anywhere (keyword id 1) splits unbreakable.
  - WordBreak.BreakAll (keyword id 1) splits unbreakable.
  - Hyphens.Auto (keyword id 2) + Liang patterns split.
  - word-break:break-word (keyword id 3) cross-property fold via
    materializer (folds to overflow-wrap:anywhere).

Tests: 4043 unit / 4179 solution-wide passing (+7 unit / +7
solution-wide over PR #39 hardening at 4036; 4 skipped). AOT/JIT
byte-parity gate stays green. Active-task pointer advances to
**Phase 3 Task 10 cycle 3b** — per-source-TextRun policy plumbing
with per-glyph metadata for mixed inline descendants, OR Task 11
(Phase 4 painter cycle) depending on review priority.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PROGRESS.md: link Task 10 cycle 3 entry to PR #40

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR #40 review pass: 5 user recs + 2 Copilot fixes + 4 regression tests

Hardens Task 10 cycle 3 by closing the AngleSharp.Css gap for
the new CSS Text L3 properties, adding word-wrap legacy alias
support, and replacing the silent break-spaces → Normal fold
with a real WhiteSpace.BreakSpaces enum value. Addresses the
user's 5-item review + 2 Copilot inline comments on PR #40.

1. [User #1, P1] Extended CssPreprocessor.ScanDeclarations with a
   KnownDroppedProperties recovery set (overflow-wrap, hyphens,
   white-space, word-wrap) so authored declarations that
   AngleSharp.Css 1.0.0-beta.144 silently drops are recovered
   from raw rule bodies. The InlineTextPolicyEndToEndTests'
   previously-pinned "AngleSharp drops" tests are now flipped to
   verify the values RESOLVE correctly:
   - p { overflow-wrap: anywhere } → OverflowWrap.Anywhere ✓
   - p { overflow-wrap: break-word } → OverflowWrap.Anywhere ✓
   - p { hyphens: auto } → Hyphens.Auto ✓
   - p { white-space: break-spaces } → WhiteSpace.BreakSpaces ✓
   The end-to-end test helper now calls CssPreprocessor.Process
   before CssParserAdapter.Adapt (matching production
   Phase2Pipeline) so the recovery path is actually exercised.

2. [User #2, P1] Added a central LegacyPropertyAliases dictionary
   in CssPreprocessor + a public NormalizePropertyName(name)
   helper. ScanDeclarations applies the normalization at recovery
   emission time so `word-wrap: break-word` lands at the cascade
   as `overflow-wrap: break-word`. New end-to-end test
   `EndToEnd_word_wrap_legacy_alias_normalizes_to_overflow_wrap`
   pins the production-path resolution of the legacy alias.

3. [User #3, P2] Added WhiteSpace.BreakSpaces enum value (= 5).
   The InlineTextPolicy materializer no longer silently folds
   keyword id 4 (break-spaces) to Normal — it now maps to
   BreakSpaces. PreprocessTextRuns + Wrap validation accept the
   new value. Cycle 3 simplification: BreakSpaces behaves like
   PreWrap (preserve + wrap at UAX #14 Allowed); the "wrap at
   every preserved space" detail per CSS Text L3 §6.4 lands in a
   subsequent cycle with forced wrap candidates at every SP
   glyph. The simplification preserves authored whitespace
   correctly (which is the user-visible guarantee) — strictly
   better than collapse-to-Normal which would silently lose
   spaces.

4. [User #4, P2] Updated cycle 3 InlineLayouter.Layout XML doc to
   prominently warn "UNIFORM-POLICY only — does NOT support
   per-source-TextRun" with explicit caller contract notes. Added
   pinned regression test
   `Layout_with_mixed_run_styles_silently_uniform_pinned` that
   documents the limit: the containing-block policy applies to
   the entire pass even when individual TextRuns have different
   styles. Cycle 3b will flip this when per-glyph metadata is
   added.

5. [User #5, P2] Added real-CSS pipeline tests to
   InlineTextPolicyEndToEndTests covering each previously-pinned
   gap + the new alias + break-spaces paths. Flipped 3 previously-
   pinned-as-failing tests to verify the working production path.

6. [Copilot #1+#2] Removed the unused `using NetPdf.Text.Bidi;`
   import from InlineLayouterCycle3Tests.cs (CS8019).

Tests added/changed: 4 new end-to-end (overflow-wrap recovery +
overflow-wrap break-word fold + word-wrap alias + break-spaces
real-CSS) + 1 mixed-mode pinned (Cycle3Tests) + 1 updated
materializer test (break-spaces no longer folds to Normal).

Tests: 4047 unit / 4183 solution-wide passing (+4 unit / +4
solution-wide over PR #40 ship at 4043; 4 skipped). AOT/JIT
byte-parity gate stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
raroche added a commit that referenced this pull request May 10, 2026
Hardens Task 10 cycle 3b before cycle 3c lands per-glyph metadata.
Addresses the user's 6-item review + 2 Copilot inline comments
on PR #41.

1. [User #1, P1] CssParserAdapter.FindRecovery now returns the LAST
   matching recovery in source order instead of the first. Fixes
   CSS last-decl-wins for repeated declarations like
   `white-space: normal; white-space: break-spaces` (intent: the
   second wins) and alias pairs like `word-wrap: normal;
   overflow-wrap: anywhere`. Adds 5 end-to-end tests for repeated
   white-space, overflow-wrap, hyphens, and word-wrap-then-
   overflow-wrap (both orders).

2. [User #2 + Copilot #1] LayoutPerRun now front-loads ALL argument
   validation BEFORE the per-run policy scan. Null resolver/
   script/language, invalid availableInlineSize/paragraphDirection,
   and pre-cancelled tokens all throw their proper exceptions
   before mixed-mode NotSupportedException can mask them. Adds 4
   regression tests pinning the validation order.

3. [User #3] LayoutPerRun now skips empty TextRuns when picking +
   comparing the effective policy. Empty generated-content/span
   runs with different styles no longer falsely trigger the
   mixed-mode guard (they contribute no glyphs and no wrap
   decisions). All-empty input returns empty result with default
   policy. Adds 3 regression tests pinning empty-run handling.

4. [User #4] Documented mixed-mode behavior in
   InlineLayouter.LayoutPerRun XML doc; the UNIFORM-POLICY-ONLY
   doc + cycle-3 mixed-mode pinned regression test from prior
   cycle stay relevant. Block/inline integration tests for
   mixed-descendants are deferred to cycle 3c when per-glyph
   metadata lands.

5. [User #5] WhiteSpace.BreakSpaces still behaves like PreWrap
   (preserve + wrap at UAX #14 Allowed); the "wrap at every
   preserved space" + end-of-line trailing-space behavior per CSS
   Text L3 §6.4 is documented as a deferred refinement. The
   approximation preserves authored whitespace correctly (which
   is the user-visible guarantee); aggressive wrap-candidate
   placement at every preserved SP requires a forced-candidate
   refactor in LineBuilder.Wrap — scheduled with the per-glyph
   metadata work in cycle 3c.

6. [User #6 + Copilot #2] Refreshed stale docs:
   - InlineTextPolicy.cs XML comment: replaced "break-spaces folds
     to Normal" with the correct "maps to WhiteSpace.BreakSpaces"
     description.
   - PROGRESS.md PR #40 link: corrected casing (raroche/NetPdf
     instead of raroche/netpdf) for consistency with other links.

New tests (15 total):
  - 5 end-to-end CSS pipeline tests (repeated declarations,
    alias-pair last-wins) in InlineTextPolicyEndToEndTests.
  - 4 LayoutPerRun arg-validation order tests
    (null-resolver-before-policy-scan, invalid-inlineSize-before-
    policy-scan, undefined-paragraphDirection-throws, pre-cancelled-
    token-throws-before-policy-scan) in InlineLayouterCycle3bTests.
  - 3 LayoutPerRun empty-run handling tests in
    InlineLayouterCycle3bTests.

Tests: 4067 unit / 4203 solution-wide passing (+12 unit / +12
solution-wide over PR #41 ship at 4055; 4 skipped). AOT/JIT
byte-parity gate stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
raroche added a commit that referenced this pull request May 10, 2026
* Phase 3 Task 10 cycle 3b: LayoutPerRun per-source-TextRun policy

Continues from Task 10 cycle 3 (PR #40 merged at 2b575ea). Cycle 3
shipped the containing-block-style `Layout` overload that applies
a uniform policy; cycle 3b adds **`LayoutPerRun(...)` — reads each
source-TextRun's policy + verifies they all match**, throwing
`NotSupportedException` on mixed-mode descendants.

Why this shape:
  - CSS Text L3 §3-§6 properties all inherit, so for the 95% case
    all source TextRuns share the same policy (inherited from the
    containing block) + the new overload silently delegates to the
    cycle-3a uniform path.
  - For mixed-mode descendants (e.g.,
    `<span style="white-space:nowrap">` inside
    `white-space:normal` text), the overload throws loudly with a
    descriptive message — the failure mode is loud + deterministic
    instead of silently using the wrong policy.
  - Full per-glyph metadata for mixed-mode is scheduled for cycle
    3c (substantial refactor threading per-glyph policy through
    every wrap-pass decision).

Algorithm:
  1. Read `InlineTextPolicy` from each TextRun's
     `Style.ReadInlineTextPolicy()`.
  2. Verify all runs share the same policy via record-struct
     equality.
  3. If all match, delegate to the cycle-3a explicit-args path.
  4. If any differs, throw NotSupportedException with run-index +
     both policies in the message.

Cycle 3b deferrals (cycle 3c+):
  - Per-glyph metadata stream threaded through LineBuilder.Wrap so
    mixed-mode descendants work without throwing.
  - UAX #24 script detection.
  - RTL fragment-level reversal.
  - LineFragment[] → BoxFragment conversion (cycle 4).
  - Bidi-aware glyph painting via Phase 4 painter integration.

New tests: 8 in `InlineLayouterCycle3bTests`:
  - Null sourceTextRuns throws.
  - Empty input returns empty.
  - Uniform default policy delegates correctly.
  - Uniform NoWrap suppresses wrap end-to-end.
  - Mixed white-space throws NotSupportedException.
  - Mixed overflow-wrap throws.
  - Mixed hyphens throws.
  - Singleton run (trivially uniform) works.

Tests: 4055 unit / 4191 solution-wide passing (+8 unit / +8
solution-wide over PR #40 hardening at 4047; 4 skipped). AOT/JIT
byte-parity gate stays green.

Active-task pointer advances to **Phase 3 Task 10 cycle 3c** —
per-glyph metadata stream for mixed-mode descendants, OR
**Task 11** (Phase 4 painter cycle) depending on review priority.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PROGRESS.md: link Task 10 cycle 3b entry to PR #41

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR #41 review pass: 6 user recs + 2 Copilot fixes + 15 regression tests

Hardens Task 10 cycle 3b before cycle 3c lands per-glyph metadata.
Addresses the user's 6-item review + 2 Copilot inline comments
on PR #41.

1. [User #1, P1] CssParserAdapter.FindRecovery now returns the LAST
   matching recovery in source order instead of the first. Fixes
   CSS last-decl-wins for repeated declarations like
   `white-space: normal; white-space: break-spaces` (intent: the
   second wins) and alias pairs like `word-wrap: normal;
   overflow-wrap: anywhere`. Adds 5 end-to-end tests for repeated
   white-space, overflow-wrap, hyphens, and word-wrap-then-
   overflow-wrap (both orders).

2. [User #2 + Copilot #1] LayoutPerRun now front-loads ALL argument
   validation BEFORE the per-run policy scan. Null resolver/
   script/language, invalid availableInlineSize/paragraphDirection,
   and pre-cancelled tokens all throw their proper exceptions
   before mixed-mode NotSupportedException can mask them. Adds 4
   regression tests pinning the validation order.

3. [User #3] LayoutPerRun now skips empty TextRuns when picking +
   comparing the effective policy. Empty generated-content/span
   runs with different styles no longer falsely trigger the
   mixed-mode guard (they contribute no glyphs and no wrap
   decisions). All-empty input returns empty result with default
   policy. Adds 3 regression tests pinning empty-run handling.

4. [User #4] Documented mixed-mode behavior in
   InlineLayouter.LayoutPerRun XML doc; the UNIFORM-POLICY-ONLY
   doc + cycle-3 mixed-mode pinned regression test from prior
   cycle stay relevant. Block/inline integration tests for
   mixed-descendants are deferred to cycle 3c when per-glyph
   metadata lands.

5. [User #5] WhiteSpace.BreakSpaces still behaves like PreWrap
   (preserve + wrap at UAX #14 Allowed); the "wrap at every
   preserved space" + end-of-line trailing-space behavior per CSS
   Text L3 §6.4 is documented as a deferred refinement. The
   approximation preserves authored whitespace correctly (which
   is the user-visible guarantee); aggressive wrap-candidate
   placement at every preserved SP requires a forced-candidate
   refactor in LineBuilder.Wrap — scheduled with the per-glyph
   metadata work in cycle 3c.

6. [User #6 + Copilot #2] Refreshed stale docs:
   - InlineTextPolicy.cs XML comment: replaced "break-spaces folds
     to Normal" with the correct "maps to WhiteSpace.BreakSpaces"
     description.
   - PROGRESS.md PR #40 link: corrected casing (raroche/NetPdf
     instead of raroche/netpdf) for consistency with other links.

New tests (15 total):
  - 5 end-to-end CSS pipeline tests (repeated declarations,
    alias-pair last-wins) in InlineTextPolicyEndToEndTests.
  - 4 LayoutPerRun arg-validation order tests
    (null-resolver-before-policy-scan, invalid-inlineSize-before-
    policy-scan, undefined-paragraphDirection-throws, pre-cancelled-
    token-throws-before-policy-scan) in InlineLayouterCycle3bTests.
  - 3 LayoutPerRun empty-run handling tests in
    InlineLayouterCycle3bTests.

Tests: 4067 unit / 4203 solution-wide passing (+12 unit / +12
solution-wide over PR #41 ship at 4055; 4 skipped). AOT/JIT
byte-parity gate stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
raroche added a commit that referenced this pull request May 10, 2026
…eview (#42)

Cycle 3c relaxes mixed-mode WhiteSpace by building a per-source-run WhiteSpace array + plumbing through LineBuilder.Wrap. Glyphs in NoWrap/Pre source runs get UAX #14 Allowed opportunities downgraded to Prohibited at flat-build time.

Result: <span style="white-space:nowrap"> inside white-space:normal text now works without throwing.

Post-PR review hardening (7 fixes):
1. [P1] Narrowed accepted matrix to {Normal, NoWrap}-only (CSS Text L3 §4.1 shared collapse semantics); Pre/PreWrap/PreLine/BreakSpaces mixes throw NotSupportedException pending cycle 3d.
2. [P1] Per-glyph wrapsAtAllowed when whiteSpacePerRun supplied — fixes Normal-after-NoWrap order bug.
3. [P1] Preprocess uses WhiteSpace.Normal (not PreWrap) for mixed mode.
4. [P2] Compute preprocessed ONCE + pass to both ShapeForLayout + Wrap.
5. [P2] Tests strengthened from Assert.NotEmpty to exact line/slice/glyph assertions.
6. [P2] whiteSpacePerRun enum validation.
7. [P3] Docs refresh in WhiteSpace/InlineTextPolicy/LineFragment.

Plus [Copilot] fixed misleading 'only LAST glyph' comment.

Tests: 4080 unit / 4208 solution-wide passing. AOT/JIT byte-parity green.
raroche added a commit that referenced this pull request Jun 16, 2026
…sizing (#186)

* Task 1 — inline <img> atomics (first cut)

An inline <img> in a line of text is no longer skipped
(LAYOUT-INLINE-ATOMIC-NOT-SUPPORTED-001). It now participates in line
layout as an atomic inline box and paints.

Pipeline:
- New InlineAtomic primitive (box + used border-box width/height), carried
  as an optional payload on TextRun and ShapedRun.
- BlockLayouter.CollectInlineTextRuns converts a sized InlineReplacedElement
  into a one-char U+FFFC atomic TextRun (used size read from the slots the
  pre-layout ReplacedSizeResolver wrote).
- LineBuilder.Shape emits a synthetic single-glyph ShapedRun (advance = used
  width, NOT HarfBuzz-shaped) for an atomic run; the white-space
  preprocessors pass atomic runs through verbatim (a new-TextRun rebuild
  would have dropped the payload — the bug fixed here); Wrap treats the
  1-glyph run as a non-breakable unit (U+FFFC carries UAX #14 breaks around
  it).
- BlockLayouter.ComputeInlineAtomicLayout grows each line to max(text
  line-height, tallest atomic) and places each atomic with its bottom on the
  text baseline; EmitInlineOnlyBlockFragment emits the atomic's own
  positioned BoxFragment (so ImagePainter paints it) and sets PerLineHeightsPx.
- TextPainter skips the synthetic glyph (the box paints from its fragment).

First-cut approximations (deferrals.md#inline-atomic-boxes): vertical-align
baseline only, approximate font ascent/descent, start-relative inline offset
(no centred/RTL shift), and inline-block/-flex/-grid/-table still skip +
diagnose.

Tests: 3 integration (HtmlPdfConvertTests — painted at used size, advances
with preceding text, a tall img grows its line + pushes following content
down) + 2 unit (LineBuilderShapingTests — Shape synthetic glyph + payload,
preprocessing preserves the atomic). Gates: 6934 unit / 5 skip, 30
LayoutSnapshots, 97 RealDocuments, 0-warning Release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Task 2 — border-radius: Rx / Ry elliptical slash (body + margin-box)

The explicit two-radii-per-corner `border-radius: <h-list> / <v-list>` form
now renders elliptically (it previously dropped to square — AngleSharp drops
the slash form).

Storage: four new INTERNAL vertical-radius properties
(-netpdf-border-{corner}-radius-y in properties.json, vendor-prefixed so real
@supports queries for the standard radius properties are unaffected).

Recovery + expansion: BorderRadiusShorthandExpander.TryExpand now handles a
top-level slash — it splits the value and expands BOTH sides by the 1-4-value
box distribution, emitting the horizontal radii onto the border-{corner}-radius
longhands and the vertical radii onto the -y longhands. The body
CssPreprocessor.ScanDeclarations recovers the dropped slash form (gated to a
well-formed top-level slash via the new HasTopLevelSlash; a circular form
AngleSharp already expands; a `/` inside calc() is a division). The margin box
shares the expander (CssParserAdapter), with the four -y ids joined to
MarginBoxStyle.CascadedStyleIds; a malformed slash still diagnoses.

Painter: FragmentPainter.ReadCornerRadii reads each corner's vertical radius
from the -y slot, falling back to the horizontal slot resolved against the box
HEIGHT when unset (the circular / %-ellipse pre-cycle behavior). The elliptical
CornerRadii rendering already existed.

Removed the now-obsolete IsDeferredElliptical (the slash no longer defers).

Tests: expander unit tests (slash expands h+v, two-value-per-side distribution,
calc-slash) + 2 integration (body slash rounds elliptically and differs from
circular; margin-box slash rounds with no diagnostic). Gates: 6936 unit / 5
skip, 30 LayoutSnapshots, 97 RealDocuments, corpus, 0-warning Release.

Still deferred: font-/viewport-relative margin-box radii; rounded NON-uniform
borders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Task 5 — grid box-sizing: border-box item contributions

GridSizing.ItemOuterContribution now honors box-sizing (CSS Basic UI 4 §10)
when an item contributes to intrinsic track sizing. A box-sizing: border-box
item's declared width/height IS its border box (border + padding inside), so
the chrome is no longer added on top (the pre-cycle code always added it,
double-counting for border-box items — the modern reset-stylesheet norm).

The two branches are unified into the new ItemBorderBoxExtent helper, which
takes the MAX of: the chrome floor; the declared size (border box under
border-box, content box + chrome under content-box); the content-measured
size + chrome (an auto size is always content-box); and the min-* floor
(border- or content-box per box-sizing). For the initial content-box this is
byte-identical to the old max(declared, content, min) + chrome.

Tests: 1 unit (GridLayouterTests — border-box column = declared 100, content-box
= 100 + 20 padding) + 1 production (GridLayouterProductionTests — a real
box-sizing:border-box width:100;padding;border item sizes its auto column to
100, not 130). Gates: 6938 unit / 5 skip, 30 LayoutSnapshots, 97 RealDocuments,
0-warning Release.

The broader cross-cutting box-sizing audit (BlockLayouter / FlexLayouter /
TableLayouter + a shared used-size helper) remains deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: PROGRESS.md active pointer — batch PR (3 of 5 tasks)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR #186 review: P0 deferrals status + P1 border-radius cascade reset + P1 inline-img chrome

P0 — docs/deferrals.md grid-box-sizing Status used "partially resolved",
outside the approximated|throws|not-started vocabulary DeferralsParityTests
enforces. Changed to "approximated" (fixes the one failing unit test).

P1 — border-radius cascade reset. The internal -netpdf-*-radius-y slots were
sticky: an Rx/Ry slash set them, but a later circular / CSS-wide / corner
longhand border-radius only wrote the horizontal, leaving a stale vertical
(border-radius: 10px / 30px; border-radius: 5px rendered 5px / 30px).
BorderRadiusShorthandExpander.TryExpand now ALWAYS emits the -y longhands
(circular -> -y = horizontal); CssPreprocessor recovers EVERY border-radius
shorthand (and the four corner longhands via the new IsCornerRadiusLonghand /
TryExpandCornerVertical), emitting BOTH axes so a later value resets the prior
elliptical's vertical by source order. The margin-box shorthand resets for
free (the expander now emits -y).

P1 — inline <img> ignored its own box-model chrome. The atomic used the
content size as the fragment, but ImagePainter treats the fragment as a BORDER
box and subtracts the img's padding/border -> too-small advance or no content.
InlineAtomic now carries the full box model (margin/border/padding/content):
the line reserves the MARGIN-box advance, the emitted fragment is the BORDER
box (after margin-start, baseline-aligned by the margin-box bottom), and the
line box grows to the margin-box height. A plain inline <img> (no chrome) is
byte-identical to the first cut.

Tests: corner-longhand expander unit; body shorthand + corner-longhand RESET
integration (== plain circular); inline-img-with-padding advance + content
paints. Gates: 6942 unit / 5 skip, 30 LayoutSnapshots, 97 RealDocuments,
1 corpus, 0-warning Release, AOT/JIT parity verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR #186 Copilot review: properties.json sort + negative vertical radius

(1) Moved the four internal -netpdf-border-*-radius-y entries to the TOP of
properties.json's `properties` array (a leading `-` sorts before `a`), restoring
the documented alphabetical-by-name ordering.

(2) Added the four Border*RadiusY ids to NonNegativeProperties so a negative
vertical radius (border-radius: 10px / -5px) invalidates the whole shorthand
(CSS B&B §6.1) instead of clamping the vertical to 0, matching the horizontal
corner behavior. Test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
raroche added a commit that referenced this pull request Jun 23, 2026
…212)

* margin-box-line-height: @page margin boxes honor a declared line-height

Literal @page margin-box content (e.g. @bottom-center { content: "…";
line-height: 2 }) used font-size × 1.2 for its line pitch regardless of a
declared line-height, because line-height was not in MarginBoxStyle's
inherited longhand whitelist and PageMarginBoxPainter read only the font-size.

- MarginBoxStyle.SupportedStyleIds: add PropertyId.LineHeight (an inherited
  CSS property → flows root → @page context → margin box).
- PageMarginBoxPainter: compute the content line pitch via ReadLineHeightPx
  (full <number> / <length> / <percentage> grammar), falling back to
  font-size × 1.2 only for normal / unset.

A margin box with no declared line-height is byte-identical (ReadLineHeightPx
returns null → same font-size × 1.2). Closes the margin-box-line-height
deferral (removed from deferrals.md + ExpectedDeferralIds).

Tests: MarginBoxStyleTests (declared number/length/percentage, normal-unset
fallback, parent inheritance) + a facade integration test asserting a 3×
line-height triples the inter-line span of a multi-line margin box.

Gates: full unit suite (7375/3 skip) + LayoutSnapshots/PaginationGolden/
RealDocuments/RenderingCorpus + W3cConformance + PdfValidation + AOT smoke
(sha256 2942DD1E…, byte-identical) all green; 0-warning Release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* text-align-match-parent: resolve against the parent at computed-value time

`text-align: match-parent` (CSS Text 3 §7.1) was approximated as a fixed
physical left (line-align factor 0), direction-insensitive. Spec-correct
match-parent takes the PARENT's text-align, resolving the parent's start/end
to physical left/right against the PARENT's direction, and inherits that
matched value — which needs the parent computed style, unavailable to the
layout-time ReadInlineAlignFactor reader.

- BoxBuilder.ResolveMatchParentTextAlign: in the top-down walk (after
  defaults + inheritance + declarations, parent fully computed), replace a
  match-parent keyword with the parent's resolved physical text-align. A
  descendant then inherits the RESOLVED value, not match-parent.
- ReadInlineAlignFactor's `6 => 0.0` branch is now a defensive fallback
  (doc updated).

No-op unless the element declared match-parent, so non-match-parent rendering
is byte-identical. Closes the text-align-match-parent deferral.

Tests: BoxBuilder unit theory (7 parent direction × align combos incl. the
direction-aware rtl/start → right case) + a facade integration test (a
direction:ltr child of a direction:rtl;text-align:start parent right-aligns,
isolating match-parent from plain inheritance and the old fixed-left approx).

Gates: full unit suite (7383/3 skip) + LayoutSnapshots/PaginationGolden/
RealDocuments/RenderingCorpus (byte-identical) + W3cConformance + PdfValidation;
0-warning Release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* white-space-break-spaces: break after every preserved space

`white-space: break-spaces` (CSS Text L3 §6.4) was treated identically to
pre-wrap (wrap only at UAX #14 Allowed positions). Per spec it must allow a
line break AFTER every preserved space — including between consecutive
spaces — and trailing spaces take up width (they do not hang).

- LineBuilder flat-build: for a BreakSpaces source run, upgrade each preserved
  SP/TAB glyph's break-AFTER opportunity to Allowed (mirrors the per-run
  glyphCollapses read; the per-run policy supplies the mode since LayoutPerRun
  passes the global whiteSpace as Normal). Preserve-mode spaces are never
  trimmed (IsBreakSpace stays false), so trailing break-spaces spaces keep
  their advance and wrap rather than hang.
- Doc refresh: WhiteSpace / InlineTextPolicy / LineFragment no longer call
  break-spaces a pre-wrap approximation.

Gated to BreakSpaces runs → every other white-space mode is byte-identical.
Closes the white-space-break-spaces deferral.

Tests: LineBuilder.Wrap unit test (a space run wraps across more lines under
break-spaces than pre-wrap) + a facade integration test (same, end-to-end
through the real shaper with real space metrics).

Gates: full unit suite (7385/3 skip) + LayoutSnapshots/PaginationGolden/
RealDocuments/RenderingCorpus (byte-identical) + W3cConformance + PdfValidation
+ AOT smoke (sha256 2942DD1E…); 0-warning Release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* line-height-percentage-inheritance: resolve % to a length at the declaring element

A `<percentage>` line-height resolved to a Percentage slot that inherited as a
percentage and was read at the READING element's font-size. Per CSS Inline 3
§4.2 the computed value is a LENGTH (% × the DECLARING element's font-size),
and that length inherits — so a child with a different font-size must keep the
parent's computed length, not recompute the percentage against its own.

- BoxBuilder.ResolveDeclaredPercentLineHeightInPlace: in the top-down walk
  (after the element's font-size is folded), convert a declared Percentage
  line-height slot to LengthPx using this element's font-size, BEFORE it
  inherits. A Percentage slot reaching this point can only be one this element
  declared (an inherited % was already converted on the parent), so the
  conversion is unambiguous. <number> (inherits as the number) and em/rem
  (folded by DeferredLengthResolver) are untouched.

The declaring element + same-font-size descendants are byte-identical (same
used px, resolved earlier); only inheritance across a font-size change now
differs (correctly). Closes the line-height-percentage-inheritance deferral.

Tests: BoxBuilder unit tests (different-font-size child inherits 30px not
150%×40; same-font-size control unchanged) + a facade integration test
(varying ONLY the parent font-size changes the child's inter-line span,
isolating inheritance-as-length from the old re-resolve-against-child bug).

Gates: full unit suite (7391/3 skip) + LayoutSnapshots/PaginationGolden/
RealDocuments/RenderingCorpus (byte-identical) + W3cConformance + PdfValidation
+ AOT smoke (sha256 2942DD1E…); 0-warning Release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* deferrals: strike 3 grid pagination deferrals already resolved end-to-end

grid-wrapper-rollback-for-pre-dispatch-deferral, grid-fragment-extent-emitted-
rows-only-deferral, and grid-explicit-height-paginate-deferral each documented
their removal condition as "RESOLVED end-to-end" with named regression tests,
but were still listed in deferrals.md + ExpectedDeferralIds.

Verified the F1/F2/F3 mechanisms ship at both the outer and recursive grid
dispatch sites and the named suites pass (Cycle5c2a_F1_*, Cycle5c2b_F2_*,
Cycle5c2d_post_PR_102_*, Cycle5c3_explicit_height_*) — 25 tests green. The only
residuals these entries reference (the ancestor cursor-advance approximation
and the §11 per-attempt plan consolidation) are already tracked under
recursive-block-continuation-consumed-extent-accounting-deferral and
grid-fragment-plan-shared-sizing-deferral respectively, which remain open.

Removed the three sections + their ExpectedDeferralIds entries. Docs-only — no
source change, so rendering is byte-identical by construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PROGRESS: roll in the Phase-3 residual long-tail status

Add a compact lead note for the in-progress long-tail PR (branch
phase3-residuals-longtail): 7 deferrals closed (inline-text/CSS-value tier +
3 verified-and-struck grid pagination deferrals), with the deep remainder
(RTL/script/hyphenation, fragmentation, table/multicol/float/flex, grid §11,
positioned) enumerated for continuation. Docs-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* grid-reverse-auto-placement: correct the premise; drop the spurious diagnostic

The deferral assumed `grid-row/column: auto / <line>` could carry a span > 1
and so need the CSS Grid L1 §8.5 backward-search ("reverse auto-placement"),
and emitted LAYOUT-GRID-PLACEMENT-APPROXIMATED-001 to flag it. That premise is
inaccurate: a span > 1 requires a `span` keyword, which routes to the auto/span
branch — the auto-start-definite-end branch is ALWAYS span 1, so the item
correctly occupies the single cell ending at the line (start = end - 1). The
placement was already spec-correct; only the warning was wrong.

- GridSizing.ReadPlacement: remove the EmitPlacementApproximatedDiagnostic call
  from both the integer-end and (resolved) named-line-end auto-start branches;
  keep the (correct) PlacementSpec(Definite, end - 1, 1). The diagnostic still
  fires for genuinely-approximated placements (unresolved named lines,
  occurrence syntax).
- F7 test renamed + flipped to assert the placement is correct AND no
  placement-approximated diagnostic fires.

Rendering is byte-identical (placement unchanged; only a Warning was removed).
Closes grid-reverse-auto-placement-deferral.

Gates: full unit suite (7388/3 skip) + LayoutSnapshots/PaginationGolden/
RealDocuments/RenderingCorpus (byte-identical) + W3cConformance + PdfValidation
+ AOT smoke (sha256 2942DD1E…); 0-warning Release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PROGRESS: note the 8th closed deferral (grid-reverse-auto-placement)

Roll the long-tail status: grid-reverse-auto-placement-deferral closed
(premise corrected — auto-start/definite-end is always span 1, so the
single-cell placement is spec-correct; spurious diagnostic dropped). Docs-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR #212 review: margin-box relative/calc/% line-height, pseudo match-parent, stale docs/tests

Addresses Roland's P1/P2/P2/P3 findings.

[P1] margin-box-line-height now closed for relative/calc + percentage inheritance:
- MarginBoxStyle.Build admits `line-height: calc(…)` as a deferred raw (the leaf
  resolver has no calc machinery) and converts a declared `%` line-height to a
  length at THIS context's font-size before it inherits (so `@page { font-size:20px;
  line-height:200% }` gives a 40px length to a smaller-font margin box).
- PageMarginBoxPainter.ResolveDeferredLineHeightInPlace resolves a deferred
  font-/viewport-relative or calc() line-height (2em/1.5rem/5vw/calc) to LengthPx
  IN PLACE after font-size — so the inline-content line metrics AND the painter
  pitch both see the used px (the per-line metrics come from the inline layout, not
  the painter's lineHeightPx, which is why an in-place resolve was needed).
- Tests: relative/calc theory (2em/1.5rem-uses-root/calc) + the @page 200% → box
  inheritance case.

[P2] text-align: match-parent resolved for generated boxes, not just elements:
- New BoxBuilder.ApplyComputedStyleFixups (match-parent + declared-% line-height)
  called from element, <br>, ::before/::after, ::first-line/::first-letter, and
  ::marker style materialization. Added an end-to-end ::before pseudo test.

[P2] Stale closeout docs/tests reconciled:
- PROGRESS.md: line-height-% + margin-box-line-height noted CLOSED (not residual).
- DirectionResolverTests: match-parent test reframed as the layout reader's
  defensive fallback (resolution is at box-build now).
- LineHeightReaderTests: the %-re-resolve "deferral pin" reframed as the reader's
  Percentage arithmetic (declared % is converted to a length at box-build).

[P3] break-spaces tests strengthened: trailing preserved spaces keep their advance
at a line end (vs normal trimming), and break-spaces is honored PER source run
(inlineTextPolicyPerRun), not globally.

Gates: full unit suite (7395/3 skip) + LayoutSnapshots/PaginationGolden/
RealDocuments/RenderingCorpus (byte-identical) + W3cConformance + PdfValidation +
AOT smoke (sha256 2942DD1E…); 0-warning Release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR #212 Copilot review: harden %-line-height font-size guard + tab literal

- ResolveDeclaredPercentLineHeightInPlace (BoxBuilder + MarginBoxStyle): only
  convert a declared `%` line-height when the font-size is a RESOLVED LengthPx
  (including an explicit 0 → a 0 line-height), multiplying by it directly. A
  still-deferred rem/viewport/calc font-size leaves the percentage in place for
  the read-time resolve, instead of converting against a hard-coded 16px guess.
  Fixes the two Copilot points: no 16px fallback for a deferred font-size, and a
  resolved `font-size: 0` gives 0 (not 16-based). Test pins font-size:0 → 0.
- LineBuilder break-spaces: use the '\t' escape instead of a literal tab char.

Gates: full unit suite (7396/3 skip) + LayoutSnapshots/PaginationGolden/
RealDocuments/RenderingCorpus (byte-identical) + W3cConformance + PdfValidation
+ AOT smoke (sha256 2942DD1E…); 0-warning Release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
raroche added a commit that referenced this pull request Jun 23, 2026
…shaping + CJK keep-all + grid auto-fit collapse (#214)

* rtl: close rtl-fragment-reversal — UAX-9 L2 per-run slice reversal + margin-box base direction

A right-to-left paragraph base direction had correct bidi levels + right-alignment but still
walked the line's per-run slices in document (logical) order, so a multi-run RTL line (embedded
LTR words/numbers, or font-fallback runs) painted left-to-right at the fragment level.

- `LineBuilder.Wrap` now threads the paragraph base direction into the slice-emission site
  (`EmitDrawableRange`). For an RTL base it reverses the per-run slice ORDER: each slice is
  level-homogeneous, so a flat reverse is UAX #9 L2 reordering at run granularity (first logical
  run → rightmost). Glyph order WITHIN each slice is left as HarfBuzz shaped it; `TotalAdvance`
  (the order-independent sum) is unchanged, so wrap/fit decisions are identical. The painter
  walks slices left-to-right accumulating advance + the line is right-aligned, so the reversed
  order paints the correct visual x. Threaded from `InlineLayouter.Layout`/`LayoutPerRun`.
- `PageMarginBoxPainter` reads its content's own `direction` (was hardcoded LTR) at both inline
  layout sites, so an RTL `@page` margin box / running element lays out right-to-left.
- `LeftToRight` / `Auto` keep document slice order → LTR output byte-identical.

Residual: deeper-than-single-embedding bidi nesting (a flat run-level reverse approximates true
nested L2). Updated the inline-atomic-boxes note (an atomic is a slice, so its run-level visual
order now reverses too).

Tests: LineBuilderWrapTests (slice order reverses for RTL, document order kept for LTR/Auto,
concrete painted-x shift, TotalAdvance invariant) + InlineLayouterTests (the Layout seam threads
the base direction end-to-end). Closed the deferral (deferrals.md section + DeferralsParityTests
entry) + CHANGELOG.

Gates: 7414 unit / 3 skip (+3) · 30 LayoutSnapshots · 1 PaginationGolden · 97 RealDocuments ·
1 RenderingCorpus (byte-identical) · 4 W3cConformance · 1 PdfValidation · AOT sha256 2942DD1E…
(exit 0) · 0-warning Release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n: UAX-24 script-change itemization + word-break:keep-all CJK suppression

Closes two coupled i18n deferrals.

uax-24-script-detection — Itemize split runs only by bidi level + source style, so a
same-direction script change (Latin + Han, both LTR) shared one HarfBuzz pass with the
caller's uniform script (BlockLayouter hardcodes "Latn") and mis-shaped the non-Latin part.
- New UAX #24 table NetPdf.Text.Bidi.UnicodeScripts: a sorted range lookup over the ~30
  major scripts (Latin/Greek/Cyrillic/Arabic/Hebrew/Indic/Thai/CJK/Hangul/…) → script enum
  + ISO 15924 tag. Clean-room: derived from the UCD script/block data, not another engine.
- Itemize now opens a run boundary on a script change too; each ItemizedRun carries its
  ScriptIso15924, and Shape shapes with run.ScriptIso15924 ?? uniform. Common/Inherited
  (digits, punctuation, spaces, marks) extend the surrounding run; a leading Common prefix
  adopts the following script (UAX #24 §5.1). Single-script content matching the uniform
  script is byte-identical.

word-break-keep-all-cjk — keep-all behaved like normal, and a per-run keep-all↔normal/
break-all mismatch threw NotSupportedException from LayoutPerRun.
- LineBuilder.Wrap.SuppressKeepAllCjkBreaks demotes the implicit inter-character soft-wrap
  opportunities between two East-Asian letter units (UAX #14 line-break classes
  ID/CJ/H2/H3/JL/JV/JT, via the existing LineBreakClassTable) from Allowed to Prohibited,
  uniformly or per source run. CJK then breaks only at spaces / explicit opportunities.
- Removed the LayoutPerRun keep-all mismatch throw (now supported per-run); converted the
  two throw-asserting tests into positive regression tests.

Tests: UnicodeScriptsTests (table + ISO tags + supplementary plane); LineBuilderTests
(script-change boundary, Common-extends, leading-Common-adopts, all-Common→null);
LineBuilderWrapTests (keep-all suppresses inter-CJK, still breaks at a space);
InlineLayouterCycle3dTests + BlockInlineIntegrationTests (mismatch no longer throws / lays
out without a diagnostic). Closed both deferrals (sections + parity entries) + CHANGELOG.

Gates: 7420 unit / 3 skip · 30 LayoutSnapshots · 1 PaginationGolden · 97 RealDocuments ·
1 RenderingCorpus (byte-identical — corpora are single-script Latin) · 4 W3cConformance ·
1 PdfValidation · AOT sha256 2942DD1E… (exit 0) · 0-warning Release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: roll PROGRESS.md for batch-3 i18n closures (rtl/uax-24/keep-all)

Update the "In progress" pointer — batch 3 closed 3 i18n deferrals (rtl-fragment-reversal,
uax-24-script-detection, word-break-keep-all-cjk); ExpectedDeferralIds 27 → 24. Lists the
remaining deep deferrals (hyphenation legal dep, fragmentation internals, table/multicol/
flex/float, the grid §11 cluster, positioned) as focused follow-up batches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* grid: close grid-auto-fit-collapse — repeat(auto-fit, …) collapses empty tracks (§7.2.3.1)

auto-fit previously expanded IDENTICALLY to auto-fill (empty-track collapse approximated +
flagged by LAYOUT-GRID-AUTO-FIT-APPROXIMATED-001). It now collapses empty tracks for real, so
auto-fit compresses where auto-fill keeps trailing empty space — the canonical responsive case.

Pipeline (gated to auto-fit-derived tracks → non-auto-fit grids byte-identical):
- ExpandTrackList tags items unrolled from a repeat(auto-fit, …) (Count == -1) via an optional
  parallel autoFitFlagsOut list; ResolveTrackSizes sets TrackSizingInfo.IsAutoFitDerived.
- After placement, GridSizing.Resolve runs CollapseEmptyAutoFitTracks: a track with no placed
  item (occupancy from placedItems' Row/Col + span) is marked IsCollapsed — base/growth-limit 0,
  leaves the fr pool — then fr is re-resolved so the survivors absorb the freed space.
- The fr free-space gutter count (ResolveFrTracks + MaximizeTracks) and ComputeTrackPositions are
  collapse-aware: a gutter sits only BETWEEN two visible tracks, so collapsed tracks + their
  gutters vanish. VisibleGutterCount == (count-1) and the position pass is bit-identical when
  nothing collapses, so existing grids are unchanged.
- Retired the now-false auto-fit-approximated diagnostic (emit + one-shot guard removed; constant
  kept for the regression test; doc + diagnostics-codes.md marked RETIRED).

So repeat(auto-fit, minmax(100px,1fr)) with 2 items in a 4-track-deriving 400px grid → two
200px columns (vs auto-fill's four 100px tracks). repeat(auto-fit, 100px) with one item at col 3
→ the two empty leading tracks collapse, item lands at x=0 (vs auto-fill's x=200).

Tests (GridLayouterTests): flipped the 3 tests that encoded the old approximation (collapse to
x=0; gutter-aware count via a full-fill case; no approximation diagnostic) + added the fr-fill
case. Closed the deferral (section + parity entry) + CHANGELOG.

Gates: 7421 unit / 3 skip · 30 LayoutSnapshots · 1 PaginationGolden · 97 RealDocuments ·
1 RenderingCorpus (byte-identical) · 4 W3cConformance · 1 PdfValidation · AOT sha256 2942DD1E…
(exit 0) · 0-warning Release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: roll PROGRESS.md — batch-3 4th closure (grid-auto-fit-collapse)

ExpectedDeferralIds 27 → 23. Records that the tractable standalone deferral tier is now
exhausted; the remaining 20 actionable deferrals are deep/multi-day and several are blocked on
shared deferred infrastructure (L19 intrinsic measurement, transform-in-layout, z-index,
overflow clipping) or an external legal-reviewed dependency (hyphenation pattern pack).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rtl/i18n: address PR #214 Copilot review — Auto-RTL reversal, margin-box direction, script-table accuracy

Three valid Copilot findings on the batch-3 i18n work:

- [LineBuilder] Auto base direction now reverses slices when it resolves to RTL. The slice
  reversal flag is resolved once in Wrap via ParagraphLevelResolver.Resolve(concatText, Auto)
  (the UAX #9 P2/P3 first-strong rule), so an Auto paragraph whose first strong char is RTL
  reverses like an explicit RTL base — previously only ParagraphDirection.RightToLeft did.
  EmitDrawableRange now takes the resolved `bool reverseSlices` instead of the raw direction.
  (Production never passes Auto — `direction` resolves to ltr/rtl upstream; this fixes the
  public API contract. Multi-paragraph mixed-direction Auto stays a documented approximation.)
- [PageMarginBoxPainter] The re-wrap / min-content measure paths now use the MARGIN BOX's own
  paragraph base direction (its content style), threaded into TryLayoutContent /
  TryMeasureMinContentWidthPx, instead of the first segment run's style (which can differ under
  per-segment styling) — consistent with the initial layout path.
- [UnicodeScripts] Documented honestly as a BLOCK-BASED APPROXIMATION of the UAX #24 Script
  property (the ranges are Unicode blocks, which mix scripts at a few edges); carved the Coptic
  letters (U+03E2..U+03EF) out of the Greek-and-Coptic block so they resolve to Common
  (surrounding/uniform script) rather than being mislabeled Greek and fed the wrong HarfBuzz tag.

Tests: +Auto-base-resolves-to-RTL reversal (Hebrew first-strong) in LineBuilderWrapTests; the
LTR/Auto-LTR document-order guard renamed accordingly.

Gates: 7422 unit / 3 skip · 30 LayoutSnapshots · 97 RealDocuments (byte-identical) ·
4 W3cConformance · 1 PdfValidation · 1 PaginationGolden · 1 RenderingCorpus · AOT sha256
2942DD1E… (exit 0) · 0-warning Release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rtl/i18n: PR #214 review — proper UAX-9 L2 level-aware reordering, narrow script deferral, doc refresh

Addresses the three review findings on the batch-3 i18n work:

[P1] RTL reorder was paragraph-base-only (a flat reverse for RTL base), so embedded RTL inside
an LTR paragraph (the common "English with a Hebrew name", numbers in RTL text) painted in
logical order. Replaced with exact UAX #9 rule L2 at run granularity: `LineBuilder.Wrap` now
reorders each line's slices by their shaped run's embedding LEVEL (`ReorderSlicesByLevelL2`) —
from the highest level down to the lowest odd level, reverse every contiguous run of slices at
that level or higher. This handles RTL base (whole line reverses, embedded LTR double-reverses
back), embedded RTL in LTR (only the RTL spans reverse), AND Latin-in-RTL (level 2 → stays in
document order — the case the flat reverse got wrong). The level comes from
`ShapedRun.Source.BidiLevel`, which `Itemize` already resolved (base direction + Auto
first-strong), so `paragraphDirection` is dropped from `Wrap` (and the separate Auto resolution
removed — the levels carry it). Pure-LTR lines (no odd level) untouched → byte-identical.

[P1/P2] UAX-24 script detection: the MECHANISM (Itemize script boundaries + Shape per-run
script) shipped, but the table is a block-based APPROXIMATION, so calling the deferral fully
closed overclaimed. Re-opened `uax-24-script-detection` NARROWED to the table-completeness
residual (exact UCD Scripts.txt table; uncovered assigned ranges → Common today) + a test
pinning the uncovered-range behavior (CJK Ext C, Arabic Extended-A, Coptic, Cherokee → Common).

[P3] Refreshed stale docs that still described KeepAll / per-run word-break/hyphens as
unsupported-or-throwing (LineBuilder + InlineLayouter class/method XML) to reflect that they
ship; removed the now-false NotSupportedException `<exception>` from LayoutPerRun.

Tests: rewrote the RTL tests with real RTL content — Hebrew RTL reverses, Latin-in-RTL keeps
document order, an embedded-RTL span in LTR reverses only that span, all-LTR untouched, Auto
resolves to RTL via levels; integration test through InlineLayouter.Layout.

Gates: 7425 unit / 3 skip · 30 LayoutSnapshots · 97 RealDocuments (byte-identical) ·
4 W3cConformance · 1 PdfValidation · 1 PaginationGolden · 1 RenderingCorpus · AOT sha256
2942DD1E… (exit 0) · 0-warning Release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* grid: PR #214 review round 2 — auto-fit row-extent collapse + lazy flag allocation

Two valid Copilot findings on the grid-auto-fit-collapse change:

- [thread 2, real bug] GridSizing.Result.RowExtentSum still added (RowSizes.Count - 1) * RowGap,
  so an auto-fit ROW template that collapses empty rows over-measured the block extent (extra
  row-gap that should have merged), which could make BlockLayouter's auto-height grid wrapper
  measurement disagree with the collapse-aware track positions. Added a collapse-aware
  RowVisibleGutterCount (= visible rows - 1) on Result, set from VisibleGutterCount(rowInfos);
  RowExtentSum now uses it. Equals RowSizes.Count - 1 when nothing collapsed → byte-identical for
  every non-auto-fit-row grid. +a row-axis collapse geometry test.
- [thread 1, perf] ResolveTrackSizes always allocated the autoFitFlags list even with no
  auto-fit repeat (the common case). Now allocates it only when ContainsAutoFitRepeat(trackList),
  restoring the prior zero-extra-allocation path for ordinary grids.

(Thread 3 — the paragraphDirection XML-doc/Auto inconsistency — was already resolved by the L2
rewrite, which removed paragraphDirection from Wrap entirely.)

Gates: 7426 unit / 3 skip · 30 LayoutSnapshots · 97 RealDocuments (byte-identical) ·
4 W3cConformance · 1 PdfValidation · 1 PaginationGolden · 1 RenderingCorpus · AOT sha256
2942DD1E… (exit 0) · 0-warning Release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: roll PROGRESS.md — batch-3 = 3 closed + uax-24 narrowed, #214 review rounds done

ExpectedDeferralIds 27 → 24. rtl-fragment-reversal (proper UAX-9 L2), word-break-keep-all-cjk,
and grid-auto-fit-collapse CLOSED; uax-24-script-detection NARROWED (detection mechanism shipped,
exact-table residual kept open). Two Copilot review rounds + Roland's [P1]/[P1-P2]/[P3] addressed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
raroche added a commit that referenced this pull request Jul 5, 2026
…view #270 P2/P3)

P2 — the language-pack table overstated coverage. State what each pack registers
TODAY: European ships real Liang hyphenation for de/fr only (not es/it/nl/pt);
Cjk (zh/ja/ko) and Arabic (ar/fa/ur) register explicit no-hyphenation, not
hyphenation; Indic (hi/bn/ta/te/…) is a placeholder routing registration with an
empty hyphenator pending vendored pattern data. Clarify that shaping + UAX #14
line breaking are always in core; the packs only add hyphenation dictionaries.

P3 — install/version snippets were launch-facing (1.0.0) but the repo packages
0.9.0-rc1. Install with --prerelease and pin 0.9.0-rc1; note dropping --prerelease
at the 1.0.0 launch. Fix the HtmlPdf.Version example string to the RC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
raroche added a commit that referenced this pull request Jul 5, 2026
)

* docs(README): add Installation + Quick start, refresh API usage, document language packs

Make the packaged README consumer-ready:

- New "Installation" section — dotnet add package NetPdf, .NET 10 requirement,
  cross-platform + auto-restored native assets, AOT-compatible, prerelease note.
- New "Quick start" — a complete HTML→PDF hello-world.
- Refreshed "Using the API" (was "Public API surface") — drop the stale
  0.7.0-beta / "Phase 4 remaining" framing (visual parity shipped at 0.9.0-rc1),
  annotate every overload, add the typed-exception pattern, and a paged-media
  (@page / margin boxes / counter(page) / break-*) example.
- New "Language packs" section — install + Register() usage + the pack table.
- Fixed an inaccuracy: PrintBackgrounds defaults to true (verified in source),
  not "off by default".

Docs only — no code change.

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

* docs(README): add paged-media recipes (pagination, repeating headers, page numbers)

New "Recipes" section with simple, verified copy-paste examples:
- automatic pagination (no config)
- repeat table column headers per page (thead/tfoot table-*-group)
- repeat a document banner per page (position:fixed OR @page margin boxes +
  running elements)
- one page per invoice (break-before:page) and one PDF file per invoice
- break-inside/break-after avoid guidance
- a complete invoice stylesheet

All CSS was verified against the live engine (thead repeat, break-before,
position:fixed banner all render as expected). Docs only.

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

* docs(README): honest language-pack coverage + RC-accurate install (review #270 P2/P3)

P2 — the language-pack table overstated coverage. State what each pack registers
TODAY: European ships real Liang hyphenation for de/fr only (not es/it/nl/pt);
Cjk (zh/ja/ko) and Arabic (ar/fa/ur) register explicit no-hyphenation, not
hyphenation; Indic (hi/bn/ta/te/…) is a placeholder routing registration with an
empty hyphenator pending vendored pattern data. Clarify that shaping + UAX #14
line breaking are always in core; the packs only add hyphenation dictionaries.

P3 — install/version snippets were launch-facing (1.0.0) but the repo packages
0.9.0-rc1. Install with --prerelease and pin 0.9.0-rc1; note dropping --prerelease
at the 1.0.0 launch. Fix the HtmlPdf.Version example string to the RC.

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

* docs(README): make API snippet copy/paste-compilable + en subtag (Copilot #270)

- Define `string html` once so the API example compiles standalone; use it for
  the one-liner and every subsequent call.
- Drop the undefined `cancellationToken` argument from the ConvertAsync example
  (it is optional) and note the token overload in a comment instead.
- Core hyphenation registers under the primary subtag `en` (en/en-GB/en-US all
  normalize to it), not specifically `en-US` — reword to say so.

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

---------

Co-authored-by: Claude Opus 4.8 <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