Phase 2 deep review C-1/C-2/C-3: critical security + correctness fixes#14
Conversation
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>
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
Addressed in 47612a5 — ParseDoc 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.
| // 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"); |
There was a problem hiding this comment.
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.
| // rule whose selector is hostile + no rules to avoid a separate | ||
| // failure. |
There was a problem hiding this comment.
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).
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>
…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>
…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>
* 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>
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>
…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>
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>
* 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>
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>
…#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>
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>
…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>
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>
* 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>
…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.
…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>
…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>
…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>
…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>
) * 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>
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] —
SelectorCompilerrecursion depth limitThe 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 withStackOverflowException(uncatchable, brings down the process). AddedMaxRecursionDepth = 64+ a_depthcounter tracked across theParseSelectorListchain — mirrors the existingVarSubstitution.MaxRecursionDepthdefense pattern. The guard fires as a catchableSelectorParseExceptionwhichCascadeResolverwraps inCSS-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-001emission inCompileSelectorWithDiagnosticsinterpolated the raw selector text + the exception reason verbatim into the message string: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 helperSanitizeForDiagnosticMessage(text, maxLength):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).maxLengthchars (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'sTryParseAlphaComponentwas clamping silently in both forms:rgb(255 0 0 / 2.0)→ resolved to opaque red instead of emittingCSS-PROPERTY-VALUE-INVALID-001.rgba(255, 0, 0, 1.5)→ same./ 200%→ same.Fix:
pct < 0 || pct > 100reject path for percentage alpha;n < 0 || n > 1reject 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::has()200-deep throws (strict context — guard manifests as exception);:not()200-deep throws; reasonable 5-level nesting still compiles.rgba(...)alpha > 1 rejected; boundary values 0/1/0%/100% all accepted.Test deltas
Test plan
dotnet test NetPdf.slnx -c Release— 3239 unit / 3365 solution-wide passing./scripts/aot-parity.sh— AOT/JIT byte-parity green🤖 Generated with Claude Code