diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f180e0c..7e7e782 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ env: # Snapshot baselines are per-platform and committed; a missing one is a gap, never a pass. ANGLESHARP_SNAPSHOT_STRICT: 1 ANGLESHARP_VERSION: 1.8.1 - ANGLESHARP_CSS_VERSION: 1.1.1 + ANGLESHARP_CSS_VERSION: 1.1.2 jobs: can_document: diff --git a/.github/workflows/update-snapshots.yml b/.github/workflows/update-snapshots.yml index 985c05d..e2eb2d5 100644 --- a/.github/workflows/update-snapshots.yml +++ b/.github/workflows/update-snapshots.yml @@ -16,7 +16,7 @@ permissions: env: ANGLESHARP_VERSION: 1.8.1 - ANGLESHARP_CSS_VERSION: 1.1.1 + ANGLESHARP_CSS_VERSION: 1.1.2 jobs: # Keep this matrix identical to the `test` matrix in ci.yml, images included - a baseline diff --git a/AGENTS.md b/AGENTS.md index eee098a..a79a281 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,7 @@ Wiring is currently scoped to `LayoutElement` only - `LayoutFlexContainer` and ` 3D transform functions (`rotate3d`, `perspective`, `matrix3d`, ...) are a separate, permanent scope cut, unrelated to any of the bugs above - `TransformParser` is AngleSharp.Css's own general-purpose parser and does recognize them (this renderer does not filter them out before parsing - `translateZ` happens to work today since its 2D projection is trivially identity), but this renderer's flat, backend-agnostic display list has no camera/projection concept to give a function that genuinely depends on a Z axis any real meaning. -This renderer builds against `AngleSharp.Css` 1.1.1+ (`AngleSharpCssVersion` in `AngleSharp.Renderer.csproj`), which carries every upstream fix this project's own development surfaced and reported: the three `transform`-function bugs, general pseudo-class forcing (`:hover` etc.), `animation-*`/`list-style-type`/`list-style-position` initial-value resolution, `overflow` shorthand decomposition (including the shorthand/explicit-longhand-override ordering fix) and `overflow: clip` recognition, per-axis percentage `border-radius` resolution, and full structured `filter` parsing (`FilterParser`/`ICssFilterFunctionValue`). Earlier in this project's history the `.csproj` temporarily pointed at a sibling `../../../AngleSharp.Css` checkout via `ProjectReference` to pick these fixes up ahead of release - that workaround is gone now that they have shipped in a published package; if a similar situation arises again, the same "reproduce in AngleSharp.Css's own test suite, fix upstream, verify locally via a temporary `ProjectReference`, then revert to `PackageReference` once released" workflow is the one to repeat. +This renderer builds against `AngleSharp.Css` 1.1.2+ (`AngleSharpCssVersion` in `AngleSharp.Renderer.csproj`), which carries every upstream fix this project's own development has surfaced and reported so far, across two rounds. Shipped in 1.1.1: the three `transform`-function bugs, general pseudo-class forcing (`:hover` etc.), `animation-*`/`list-style-type`/`list-style-position` initial-value resolution, `overflow` shorthand decomposition (including the shorthand/explicit-longhand-override ordering fix) and `overflow: clip` recognition, per-axis percentage `border-radius` resolution, and full structured `filter` parsing (`FilterParser`/`ICssFilterFunctionValue`). Shipped in 1.1.2: the four gradient-value bugs found while delegating gradient parsing (`CssConicGradientValue`'s wrong default angle, `GradientParser`'s missing angle-based stop-position parsing, the null-conditional `.Compute()` fix shared by all three gradient value types, and `CssPoint2D`'s `x`/`y` copy-paste typo), the two grid-related bugs found while delegating track-list parsing (`CssTupleValue`'s null-computed crash on an omitted `grid-column`/`grid-row` end line, and the `Gap`/`GridGap` `Longhands` ordering fix), the `::before`/`::after` render-tree support plus its UA `display: inline` default, and the brand-new `text-overflow` declaration (previously unregistered entirely). The `.csproj` twice temporarily pointed at a sibling `../../../AngleSharp.Css` checkout via `ProjectReference` to pick up each round of fixes ahead of its own release - both workarounds are gone now that everything has shipped in the published 1.1.2 package; if a similar situation arises again, the same "reproduce in AngleSharp.Css's own test suite, fix upstream, verify locally via a temporary `ProjectReference`, then revert to `PackageReference` once released" workflow is the one to repeat. This renderer builds against `AngleSharp` 1.8.1+ (`AngleSharpVersion` in `AngleSharp.Renderer.csproj`), which carries the focus-support fixes the caret-animation feature above surfaced and reported: `HtmlInputElement`/`HtmlSelectElement`/`HtmlTextAreaElement`/`HtmlButtonElement` now really implement `DoFocus()`/`DoBlur()` (previously a no-op inherited from `HtmlElement` for every element except `HtmlAnchorElement`), and `Document.SetFocus` now transitions cleanly between elements - firing `blur` on whichever element previously held focus (and `focus` on the new one) instead of silently dropping the outgoing element with no event and nothing analogous to `DoBlur()` ever running on it, a real, confirmed gap caught by moving focus between two plain ``s and observing zero `blur` events on the first. Both were reported and fixed directly in AngleSharp.Core's own suite (`AngleSharp.Core.Tests/Html/FormControlFocus.cs`) before this renderer built the caret on top of them. Earlier in this project's history `AngleSharp.Renderer.csproj` (and, cascading one level further, `AngleSharp.Css.csproj`'s own `AngleSharp` reference) temporarily pointed at a sibling `../../../AngleSharp.Core` checkout via `ProjectReference` to pick these fixes up ahead of release - that workaround is gone now that they have shipped in a published package; if a similar situation arises again, the same "reproduce in the upstream project's own test suite, fix there, verify locally via a temporary `ProjectReference`, then revert to `PackageReference` once released" workflow (already used twice for AngleSharp.Css, and now once for AngleSharp.Core itself) is the one to repeat. @@ -144,7 +144,21 @@ CSS `white-space` (`normal`/`nowrap`/`pre`/`pre-wrap`/`pre-line`/`break-spaces`) Collapsing/preserving whitespace is only half the feature; the other half is what a preserved `\n` and a suppressed wrap actually *do* to layout, and the two text-layout functions handle it differently because of how differently they are already structured. `LayoutWrappedText` (a block element's own direct text content, or a lone all-inline leaf element with only text - the common case `
`/`

`/etc. actually appear in) gained `WrapTextRespectingWhiteSpace`: it splits the already-normalized text on `\n` into paragraphs first (a no-op split for `normal`/`nowrap`, which never have a literal `\n` surviving `NormalizeWhitespace` in the first place), then either treats each paragraph as one unwrapped line (`nowrap`/`pre`) or still word-wraps it against `maxWidth` via the existing `WrapText` (every other mode) - an empty paragraph (a blank line from consecutive forced breaks) still becomes its own empty line entry rather than being dropped, so blank lines in preserved text still occupy the vertical space a browser would give them, while `LayoutWrappedText`'s own per-line loop skips measuring/painting a `DrawText` command for a line with nothing in it (cursorY still advances by a full line-height first, so the blank row's height is still reserved). `LayoutInlineTextRun` (mixed inline content sharing a line with sibling text/elements - a `` interleaved with plain text and other inline elements) only gets the narrower half of this: `nowrap`/`pre` still suppress its own width-driven wrap-to-new-line check (`IsNoWrapWhiteSpace`, the same predicate `LayoutWrappedText` uses), but multi-space preservation and explicit forced breaks are a deliberate, documented scope cut here specifically - this function's word-by-word model (`Split(' ', RemoveEmptyEntries)`, one `DrawText` command per word, a fixed single-space-width gap between them) has no way to represent either even if the text handed to it preserved them, so `NormalizeWhitespaceForInlineRun` (used only at this function's own two call sites) still collapses runs of horizontal whitespace exactly like `normal` would and always neutralizes any literal `\n` down to a plain space before the text ever reaches this function, rather than handing it a character it cannot lay out. `

` gets `white-space: pre` from AngleSharp.Css's own UA stylesheet already (the same targeted-rule mechanism that gives `
    `/`
      ` their `list-style-type` default), not something this renderer has to inject itself. -Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, `white-space` collapsing/preservation, border-radius, box-shadow, text-shadow, list-item markers, overflow clipping, page-level scroll offsets, image and gradient backgrounds, form controls (including a focused text input's animated caret), 2D `transform`/`transform-origin`, CSS `filter`, CSS `opacity`, real `:hover` matching and CSS `transition`/`animation`/`@keyframes` for interactive documents, and generic font-family handling. +`text-overflow: ellipsis`, `word-break: break-all`, and `overflow-wrap`/`word-wrap: break-word`/`anywhere` are read into the style map the same simple `AddIfPresent` way `white-space` is, and resolved onto three new `RenderTextStyle` fields (`TextOverflowMode`/`WordBreakMode`/`OverflowWrapMode`, `HtmlRenderer.cs`) alongside `WhiteSpaceMode`. `word-break`/`overflow-wrap` are inherited exactly like `white-space` (`ParseWordBreak`/`ParseOverflowWrap` fall back to `inherited` when unset, confirmed empirically the same way `white-space`'s own inheritance was); `text-overflow` is the deliberate exception - `ParseTextOverflow` never consults `inherited` at all, always re-deriving fresh from the current element's own style map, mirroring `RenderTextStyle.TextIndent`'s existing non-inheritance for the identical reason (it targets this element's own line box, not a descendant's). `ParseTextOverflow` also gates on `ShouldClipOverflow` (the same function `overflow` clipping already uses) - `ellipsis` on a box that does not clip is silently ignored, matching a real browser's own "text-overflow has no effect unless overflow is not visible" rule, and reusing `ShouldClipOverflow`'s existing "either axis, hidden/scroll/auto all count" simplification rather than a separate, narrower rule of its own. + +Discovered while wiring this up: AngleSharp.Css had **no registered declaration for `text-overflow` at all** - no `TextOverflowDeclaration.cs`, never wired into `DefaultDeclarationFactory` - so `ComputeCurrentStyle().GetPropertyValue("text-overflow")` always reported an empty string, even for an explicitly authored `text-overflow: ellipsis` (confirmed with a throwaway probe test before assuming anything); its own placeholder initial-value constant (`InitialValues.TextOverflowDecl`) also reused the wrong enum entirely (`OverflowMode`, whose keywords are `visible`/`hidden`/`scroll`/`auto`/`clip`) paired with the wrong keyword (`auto`) for a property whose real CSS initial value is the keyword `clip`. `word-break`/`overflow-wrap`/`word-wrap`, by contrast, were already fully implemented and correctly computed - confirmed empirically before writing any renderer code against them, so no upstream work was needed for those two. Fixed upstream: a new public `AngleSharp.Css.Dom.TextOverflow` enum (`Clip`/`Ellipsis`), a `Map.TextOverflows` string mapping and `ValueConverters.TextOverflowConverter` (mirroring `OverflowWrap`'s own pattern exactly), a real `TextOverflowDeclaration.cs`, and its registration in `DefaultDeclarationFactory` - locked in by `AngleSharp.Css.Tests/Styling/TextOverflowComputedStyleTests.cs`. + +`text-overflow: ellipsis` truncation itself (`TruncateWithEllipsis`, `HtmlRenderer.cs`) is scoped to the single-line case only - by far the dominant real-world usage (`overflow: hidden; white-space: nowrap; text-overflow: ellipsis`) - rather than truncating the last of several wrapped lines, which the CSS spec itself does not define without a non-standard extension (`-webkit-line-clamp`); `LayoutWrappedText` only invokes it when `WrapTextRespectingWhiteSpace` produced exactly one line and that line still overflows `lineMaxWidth`, leaving a genuinely multi-line wrapped result untouched. It finds the longest prefix (by *rune*, not raw UTF-16 `char`, so a truncation point never lands inside a surrogate pair) whose width plus the ellipsis character's own width still fits, via binary search over rune count rather than a linear character-by-character scan, since `MeasureTextWidth` goes through the backend's own font shaping and this runs on the hot layout path (on already-overflowing text specifically, so still rare in practice, but no reason to make it worse than necessary). + +`word-break: break-all` (`WrapTextCharacterWise`) and `overflow-wrap`/`word-wrap: break-word`/`anywhere` (`SplitOverlongWord`, called from inside `WrapText`) both extend the same greedy word-wrapping `WrapText` already implements, but at different granularities: `break-all` treats *every* character as a potential break point and abandons the word-based model entirely - each character becomes its own token with no separator width added around it, which reproduces ordinary space-based wrapping for free wherever a line happens to break at one, while still allowing a break mid-word wherever it does not (a deliberate, simpler approximation of the spec's own preference for word boundaries over arbitrary ones, defensible since a greedy per-character fill already tends to break near a word boundary when one is nearby). `overflow-wrap`/`word-wrap: break-word`/`anywhere` (folded into one `OverflowWrapMode.BreakWord` - the two keywords only differ in how they affect *intrinsic* (min-content) sizing, a concept this renderer's already-approximate, non-intrinsic text layout does not model) is narrower, matching the spec's own "last resort" framing precisely: `WrapText` only reaches for `SplitOverlongWord` when a *whole* word is wider than the entire line (not merely wider than what's left of the current line) - a word that simply doesn't fit the remainder of a line still wraps to a new line whole first, exactly like `overflow-wrap: normal`, and only a word that would overflow even a fresh, empty line gets broken mid-word. `word-break: break-all` takes precedence over `overflow-wrap` when both are set (matching spec) - `WrapText` checks `WordBreakMode.BreakAll` first and returns early via `WrapTextCharacterWise`, never consulting `OverflowWrapMode` at all in that case, since character-level breaking is already unrestricted. `LayoutInlineTextRun` (mixed inline content sharing a line with sibling text/elements) does not support either - the same documented scope cut already in place for `white-space`'s multi-space/forced-break handling there: that function's word-by-word model paints one whole word per `DrawText` command with no sub-word split point, unlike `WrapText`'s line-based model where a broken chunk can simply become its own line, so an overlong word in mixed inline content still overflows its line whole regardless of `word-break`/`overflow-wrap`. + +`box-sizing: border-box` (as opposed to `content-box`, the CSS - and this renderer's own long-standing implicit - default): this was a real, confirmed, previously-unflagged gap, not a hypothetical one - `box-sizing` was never even read into the style map at all, so every box's authored `width`/`height` was unconditionally treated as its *content* size and padding/border were always added on top, regardless of what `box-sizing` actually said. Since nearly every real-world stylesheet sets `box-sizing: border-box` globally (`* { box-sizing: border-box }` - Bootstrap's and Tailwind's own preflight both do this), this silently oversized a huge fraction of real content: any box with both an explicit `width`/`height` and non-zero padding/border rendered measurably larger than a browser would. `IsBorderBox` (`HtmlRenderer.cs`, next to `GetPosition`/`GetFloat`) reads the new `box-sizing` style-map entry; `ResolveAuthoredDimension` is the one place the actual conversion happens - it parses an authored `width`/`height`/`flex-basis` exactly like `ParseLength` already did, then - only when `box-sizing` computes to `border-box` on *this* element - subtracts that same axis's own border width plus padding (clamped to never go negative) before handing the result on as this renderer's pre-existing content-box convention. Every other downstream computation (`ResolveHorizontalMetrics`, `borderBoxWidth`/`borderBoxHeight`, the flex grow/shrink algorithm, grid track sizing, ...) stays completely unaware `box-sizing` exists at all - it always received a content size before, and it still does now; only the one conversion point changed. A declared border-box size smaller than its own border+padding is not itself overridden (an author's explicit number is never silently replaced) - content clamps to zero and the *painted* border box ends up exactly border+padding, matching how a real browser can't shrink a box smaller than its own chrome either. + +This conversion is applied at every site in `LayoutElement` that resolves an authored `width`/`height` into a content dimension (the ordinary block-box path, and the form-control specified-height fast path), in `TryMeasureInlineBlockBoxSize` (the inline-block line-flow prediction path - reordered to resolve `ResolveBoxStyle` first so its border/padding sums are available for the conversion), and in both flex and grid layout. For a flex *item*, `ResolveFlexBaseSize`/`ResolveFlexCrossSize` (called from `CreateFlexItemLayoutInfo`, which now also resolves the item's own `BoxStyle` up front) convert the item's own authored `width`/`height`/`flex-basis` using *its own* border/padding before flex-grow/shrink ever runs - the resulting `BaseMainSize`/`CrossSize`, and the `flexMainSize`/`flexCrossSize` ultimately threaded back into `LayoutElement` for that item, are therefore already content-box sizes by the time `ResolveFlexibleContentDimension`'s `isFlexItem` branch reads them, so that branch must not (and does not) apply the conversion a second time - only its "no container override" fallback path, which re-reads the raw authored value directly, does. For the flex *container* itself, `containerMainSize`/`specifiedCrossSize` (the algorithm's own available-main/cross-size inputs) and the container's own final `contentHeight` all convert using the container's own border/padding, already available as this function's own parameters. For a grid container, only its own `height` needed the same treatment (`containerHeight`, and the later `specifiedHeight` feeding `borderBoxHeight`) - a grid *item*'s own width/height already flows back through the ordinary `LayoutElement` path in pass 2 (`LayoutNode(..., isFlexItem: false, ...)`), so it gets the fix for free with no grid-specific code needed; `ResolveGridItemEstimatedSize` (used only in pass 1, to grow `Auto` tracks toward an item's own visual footprint before real layout runs) deliberately keeps reading the item's *raw* authored value unconverted - that estimate wants the item's outer, border-box footprint to size a track around, not its content size, so converting it there would have been wrong, not merely unnecessary. + +`box-sizing` on a `` or its cells is a deliberate, documented scope cut - table column-width resolution (`LayoutTable`) is already its own, separate, already-approximate sizing algorithm, and was left untouched by this pass rather than folding a second box model into it. + +Current behavior includes block layout, margins, padding, borders, `box-sizing: content-box`/`border-box`, floats, inline-block, relative/fixed/absolute/sticky positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, `white-space` collapsing/preservation, `text-overflow: ellipsis`, `word-break`/`overflow-wrap` line breaking, `::before`/`::after` generated content, border-radius, box-shadow, text-shadow, list-item markers, overflow clipping, page-level scroll offsets, image and gradient backgrounds, form controls (including a focused text input's animated caret), flexbox, CSS Grid (auto-placement plus `fr`/`repeat()`/`minmax()` track sizing), 2D `transform`/`transform-origin`, CSS `filter`, CSS `opacity`, real `:hover` matching and CSS `transition`/`animation`/`@keyframes` for interactive documents, and generic font-family handling. SVG support: `` (including `data:` URIs) and inline `` markup both render, through two loading paths that converge on the same DOM-walking rasterizer (`Skia/Svg/`) - no third-party SVG parser is involved anywhere. An `` SVG source is sniffed and rasterized inside `TryLoadImageResource`, exactly where a PNG/JPEG source is decoded: `SvgRasterizer.TryRasterizeMarkup` parses the bytes with AngleSharp's own HTML/foreign-content parser (wrapped in a throwaway `` shell) and is cached per-document by URL like any other image. Inline `` has no URL and, more importantly, is already sitting in the host document's DOM - `SvgRasterizer.TryRasterizeElement` walks that element directly and is cached per-element in `s_inlineSvgCacheByElement`; it is never serialized back to text and re-parsed. `LayoutElement` empties `orderedChildren` for an `` root so its foreign-namespaced children are never walked as HTML flow content; both `` and inline `` are treated as a single replaced element. Rasterization always happens at the SVG's own natural (`viewBox`/`width`/`height`) size oversampled by a fixed factor (`SvgRasterizer.OversampleFactor`), not at the resolved CSS box size - the loader runs before CSS sizing is known, so this is a deliberate blur-vs-memory tradeoff rather than a per-render-size cache. @@ -152,13 +166,23 @@ Supported elements: `rect`/`circle`/`ellipse`/`line`/`polyline`/`polygon`/`path` Not implemented, and silently skipped rather than approximated: `feFlood`/`feComposite`/`feTurbulence`/`feDisplacementMap`/`feTile`/`feImage`/`feComponentTransfer`/`feConvolveMatrix`/`feDiffuseLighting`/`feSpecularLighting`/`feMorphology` filter primitives (pass their input through unchanged), a `filter`'s own region clipping (`x`/`y`/`width`/`height`/`filterUnits` - the effect is not clipped to the nominal -10%/120% region), `SourceAlpha` as distinct from `SourceGraphic` in a filter chain, percentages in `points` (the SVG spec disallows them there too), text measurement contributing to `SvgGeometry.ComputeBounds` (a ``/gradient/pattern bounding box that depends on text extent falls back to "no bounds", which skips the mask-region clip rather than guessing), and CSS attribute selectors/pseudo-classes/child/sibling combinators in an SVG ``/``'s own generated-content pseudo would be mistaken for the replaced element itself and repaint the same image a second time. The `renderAsBlock` computation in `LayoutElement` (`IsReplacedElementTag(tagName)`) and the ``-root-empties-its-children check both got the same `element is not IPseudoElement`/`element is IPseudoElement` guard, for the same underlying reason - a generated-content pseudo should get its own independent `display` computation (inline, by default, as fixed above) and should still get to paint its own synthetic text child, entirely unrelated to whatever the host's own tag would otherwise force. +- Locked in by `BuildDisplayList_PseudoElementOnFormControlDoesNotDuplicateControlChrome`/`BuildDisplayList_PseudoElementOnImageDoesNotRepaintTheImage`. + +One more real, confirmed bug surfaced while verifying a pseudo-element could actually *paint*, not just exist in the tree: `LayoutElement`'s "generic plain inline element" fallback (mixed inline content sharing a line with siblings - not a `
      `, not inline-block, not plain text - the path a nested ``/``/etc. goes through) read `element.TextContent` directly to get that element's own contribution to the shared line. `PseudoElement.TextContent` is hardcoded to always be an empty string (`PseudoElement.cs` - it has no real DOM text content of its own to report), so this path silently produced no text at all for a pseudo-element - and, more subtly, a *host* element's own `.TextContent` also cannot see a pseudo *child's* generated text either way (pseudo-elements exist only in the render tree, never in a DOM `.TextContent` walk), so `` - empty DOM text, all its visible content coming from its own `::after` - vanished completely even though the render tree correctly carried it. Fixed by `ResolvePlainInlineElementText`, which walks the element's own render-tree children in order instead of reading `.TextContent` once - a `TextRenderNode`'s data, a pseudo child's own single synthetic text child, or (preserving the exact previous behavior for anything else, i.e. a real nested element) that child's own flat `.TextContent` - so the fallback keeps working exactly as before for ordinary nested elements while also correctly picking up pseudo-generated text. + +Two more inline-layout issues were found while building visual tests for this feature, both confirmed - via a git-stash sanity check against the unmodified, already-committed codebase, and reproduced with plain HTML carrying no pseudo-elements or custom CSS at all - to be pre-existing and unrelated to pseudo-elements specifically, not something this pass introduced or is in scope to fix (matching the standing "distinct, pre-existing bug, confirmed but deliberately left unfixed here to keep this change's blast radius contained" precedent already set for a similar inline-layout gap during the `inline-block` flow work): +- Multiple plain inline siblings sharing one line do not continue a shared `inlineCursorX` across separate parent-loop iterations in every circumstance - confirmed with three plain `` elements as *direct children of ``* each independently starting at `X=0` instead of continuing after the previous one, with no pseudo-elements involved at all. +- A block element whose entire own content triggers the "hasInlineRun" merge path (a nested inline element or a generated-content pseudo, with no direct text of its own) paints one line-height too high when it is not the very first element in the whole layout - confirmed with plain `

      Hello World

      ` (zero custom CSS, zero pseudo-elements) as the second element on a page rendering `World` on a separate, mispositioned line instead of beside "Hello" on the same one. This is why the generated-content visual test (`RenderToPng_RendersBeforeAndAfterGeneratedContent`) deliberately stays to a single first-and-only element on the page rather than demonstrating several stacked pseudo-bearing elements at once - not a limitation of pseudo-element support itself, but of this pre-existing surrounding machinery. + +`position: sticky` builds directly on the page-level scroll-offset machinery `position: fixed`/CSSOM-view scrolling already established (see the "Page-level scrolling" section above), rather than introducing a second scroll model: `BuildDisplayList` already lays out an entire scrolled page in one coordinate space where `Y = context.Padding` is the viewport's own top edge (the whole page's starting Y is shifted by `-scrollOffsetY` up front), so once inside `LayoutElement`, a sticky element's own natural (`flowBorderBoxY`) and the viewport's own top edge are already expressed in the *same* coordinates - "stick to `top: ` once scrolled past it" is therefore just `Math.Max(flowBorderBoxY, context.Padding + topOffset)` for the box's final paint Y, with no separate scroll lookup needed. Only `top` is supported (not `bottom`, which would need this element's own final height known *before* its Y can be decided - in conflict with the general auto-height-after-children flow every other box in this renderer follows - and not `left`/`right`, which this renderer's scroll model has no horizontal axis for at all, so a horizontally-scrolled position could never exist to stick against); both are a deliberate, documented scope cut. A sticky element with no `top` authored at all (`styleMap.ContainsKey("top")`) has nothing to stick to and behaves exactly like `static`, per spec - `ParseLength`'s own `allowAuto` fallback to `0` for an *unset* property is deliberately not treated as "top: 0px was authored" here, the same distinction grid track placement already had to make for its own optional properties. + +For every other purpose - margins, margin collapsing, cursor advancement (`cursorY = flowBorderBoxY + borderBoxHeight`, always the *natural*, not the possibly-stuck, position), painting order relative to `z-index`-bucketed `absolute`/`fixed` descendants - `position: sticky` is simply never matched by any of the existing `isAbsolute`/`isFixed` checks throughout `LayoutElement`, so it falls through to the same code every `static`/`relative` element already uses, with no changes needed there at all: it reserves its natural in-flow space regardless of whether it currently renders stuck, exactly matching spec. + +A real, confirmed bug surfaced immediately once a stuck sticky element could actually be seen next to real content: `DisplayList` is a flat, backend-agnostic command sequence emitted in the exact order elements are laid out, and every other positioning mode's final screen position stays close enough to its natural document-order slot that plain document-order painting already produces the right stacking - but a *stuck* sticky element's whole point is to end up at a screen position far from its natural one, routinely overlapping *later* siblings that would otherwise paint on top of it in plain document order. Caught by rendering the sticky-header visual test and seeing the header completely painted over by the content scrolling underneath it. The natural-seeming fix - folding `sticky` into `OrderChildrenForPainting`'s existing z-index bucketing, the same mechanism `absolute`/`fixed` already use to paint after `flow` content - turned out to be wrong: that function's output order is not only a *painting* order, it is also the *layout* order (`LayoutElement`'s own `orderedChildren`/the top-level page loop process children by iterating that exact sequence, threading `cursorY` through each call in turn), so reordering it to paint sticky last also *laid out* sticky last, corrupting `cursorY` for every sibling in between - confirmed by three previously-passing structural tests failing immediately. + +Fixed instead with `DisplayList.MoveRangeToEnd(startIndex, count)` (`Rendering/DisplayList.cs`), a new primitive alongside the existing `InsertRange`-based "splice a box's own background before its children" mechanism (see the box-painting-order section above) - but physically relocating an already-emitted range rather than inserting a new one. The top-level page loop (`BuildDisplayList`) records the command-index range each sticky child's own `LayoutNode` call adds (laid out in plain document order, exactly like every other page child, so its own layout stays correct) and moves each recorded range to the end, in original relative order, only *after* every page child has been laid out - each earlier move shifts every later range's true position left by however many commands it removed, so the loop subtracts that same cumulative shift from each subsequent range's originally-recorded index before moving it. This is deliberately scoped to sticky children of the *page* (direct children of ``/``, the loop `BuildDisplayList` itself owns) rather than plumbed into `LayoutElement`'s/`LayoutFlexContainer`'s/`LayoutGridContainer`'s own separate child-processing loops - covering the overwhelmingly common real-world case (a page-level sticky header/nav bar) without the largest, riskiest part of this change (the index-bookkeeping loop) needing to be duplicated into every container type that lays out children sequentially. A sticky element nested inside some other container (not a direct child of the page) still positions itself correctly per the `Math.Max` clamp above, but may still be visually painted-over by later siblings *within its own immediate parent* - a deliberate, documented scope cut, not a partial fix. + ## Code Conventions Follow the repository's existing C# style, which is defined by `.editorconfig` and the existing source files: diff --git a/CHANGELOG.md b/CHANGELOG.md index 848084e..a76d126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# 0.5.0 + +Released on Friday, September 11 2026. + +- Updated to use the AngleSharp.Css gradient model (#10) +- Updated minimum required AngleSharp.Css version to be 1.1.2 +- Improved rendering of overflow with ellipsis (#11) +- Improved rendering with non-default `box-sizing` value +- Added support for CSS grid track sizing (#7) +- Added support for pseudo elements and `content` declaration (#8) +- Added support for `position: sticky` (#9) + # 0.4.0 Released on Thursday, September 10 2026. diff --git a/build/Build.cs b/build/Build.cs index 9a1711f..44cfc56 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -29,7 +29,7 @@ class Build : FalloutBuild [Fallout.Common.Parameter("AngleSharp package version override (e.g. 1.8.1 for compatibility checks)")] readonly string AngleSharpVersion; - [Fallout.Common.Parameter("AngleSharp.Css package version override (e.g. 1.1.1 for compatibility checks)")] + [Fallout.Common.Parameter("AngleSharp.Css package version override (e.g. 1.1.2 for compatibility checks)")] readonly string AngleSharpCssVersion; [Solution] diff --git a/src/AngleSharp.Renderer.Docs/package.json b/src/AngleSharp.Renderer.Docs/package.json index fd7ef9b..feecb99 100644 --- a/src/AngleSharp.Renderer.Docs/package.json +++ b/src/AngleSharp.Renderer.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/renderer", - "version": "0.4.0", + "version": "0.5.0", "preview": true, "description": "The doclet for the AngleSharp.Renderer documentation.", "keywords": [ diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index 7de802f..b8561fe 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -956,17 +956,162 @@ public async Task BuildDisplayList_AppliesGridGapsToTrackPlacement() .ThenBy(command => command.Rect.X) .ToArray(); + // gap's two-value form is per spec, so "gap:10px 20px" is + // row-gap=10px (second row starts at 20+10=30) and column-gap=20px (second column + // starts at 50+20=70) - this used to assert the reverse, matching a real, now-fixed + // upstream bug in AngleSharp.Css's GapDeclaration ordering (see AGENTS.md/GapShorthandComputedStyleTests). Assert.Equal(4, childBackgrounds.Length); Assert.Equal(0f, childBackgrounds[0].Rect.X); Assert.Equal(0f, childBackgrounds[0].Rect.Y); - Assert.Equal(60f, childBackgrounds[1].Rect.X); + Assert.Equal(70f, childBackgrounds[1].Rect.X); Assert.Equal(0f, childBackgrounds[1].Rect.Y); Assert.Equal(0f, childBackgrounds[2].Rect.X); Assert.Equal(30f, childBackgrounds[2].Rect.Y); - Assert.Equal(60f, childBackgrounds[3].Rect.X); + Assert.Equal(70f, childBackgrounds[3].Rect.X); Assert.Equal(30f, childBackgrounds[3].Rect.Y); } + [Fact] + public async Task BuildDisplayList_SizesFractionalTracksProportionally() + { + var document = await ParseAsync(""" + +
      +
      +
      +
      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 120, + FontSize = 16f, + }); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Height == 20f && command.Rect.Width < 150f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(0f, childBackgrounds[0].Rect.X); + Assert.Equal(50f, childBackgrounds[0].Rect.Width); + Assert.Equal(50f, childBackgrounds[1].Rect.X); + Assert.Equal(100f, childBackgrounds[1].Rect.Width); + } + + [Fact] + public async Task BuildDisplayList_ExpandsRepeatFunctionIntoFixedTracks() + { + var document = await ParseAsync(""" + +
      +
      +
      +
      +
      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 120, + FontSize = 16f, + }); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Height == 20f && command.Rect.Width < 150f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(3, childBackgrounds.Length); + Assert.Equal(0f, childBackgrounds[0].Rect.X); + Assert.Equal(40f, childBackgrounds[0].Rect.Width); + Assert.Equal(45f, childBackgrounds[1].Rect.X); + Assert.Equal(40f, childBackgrounds[1].Rect.Width); + Assert.Equal(90f, childBackgrounds[2].Rect.X); + Assert.Equal(40f, childBackgrounds[2].Rect.Width); + } + + [Fact] + public async Task BuildDisplayList_ClampsMinMaxTrackToItsFractionalMaximum() + { + var document = await ParseAsync(""" + +
      +
      +
      +
      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 120, + FontSize = 16f, + }); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Height == 20f && command.Rect.Width < 150f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(0f, childBackgrounds[0].Rect.X); + Assert.Equal(90f, childBackgrounds[0].Rect.Width); + Assert.Equal(90f, childBackgrounds[1].Rect.X); + Assert.Equal(60f, childBackgrounds[1].Rect.Width); + } + + [Fact] + public async Task BuildDisplayList_ClampsAutoTrackToItsMinMaxLengthBounds() + { + var document = await ParseAsync(""" + +
      +
      +
      +
      +
      +
      +
      +
      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var secondColumnCells = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 30f) + .OrderBy(command => command.Rect.Y) + .ToArray(); + + Assert.Equal(2, secondColumnCells.Length); + // First grid: the item's estimated size (20px) is below the minmax() floor, so the + // Auto track clamps up to its 60px minimum. + Assert.Equal(60f, secondColumnCells[0].Rect.X); + // Second grid: the item's estimated size (150px) exceeds the minmax() ceiling, so the + // Auto track clamps down to its 100px maximum. + Assert.Equal(100f, secondColumnCells[1].Rect.X); + } + [Fact] public async Task BuildDisplayList_AppliesExplicitGridItemPlacement() { @@ -1049,13 +1194,17 @@ public async Task BuildDisplayList_AppliesAutoPlacementAcrossImplicitTracks() .ThenBy(command => command.Rect.X) .ToArray(); + // Implicit auto rows now size to their own content (10px, each item's actual height), + // the same way auto columns already did - this used to assert 20 (containerHeight/rowCount, + // 40/2), a coarse guess from before implicit rows grew to fit content like explicit Auto + // tracks do (see GrowGridTrackSize). Assert.Equal(3, childBackgrounds.Length); Assert.Equal(0f, childBackgrounds[0].Rect.X); Assert.Equal(0f, childBackgrounds[0].Rect.Y); Assert.Equal(50f, childBackgrounds[1].Rect.X); Assert.Equal(0f, childBackgrounds[1].Rect.Y); Assert.Equal(0f, childBackgrounds[2].Rect.X); - Assert.Equal(20f, childBackgrounds[2].Rect.Y); + Assert.Equal(10f, childBackgrounds[2].Rect.Y); } [Fact] @@ -2295,14 +2444,7 @@ public async Task BuildDisplayList_ResolvesPercentageBorderRadiusAgainstBox() // A border-radius percentage resolves per-axis against the element's own border box - the // horizontal component against its 200px width (10% = 20px), the vertical component - // against its 80px height (10% = 8px) - per spec. An older AngleSharp.Css version this - // test used to pin (1.1.1) resolved both components against the containing block's width - // instead (a confirmed upstream bug, reported with a reproducing test in AngleSharp.Css's - // own suite - see BorderRadiusPercentageResolutionTests.cs there); fixed upstream since, - // confirmed by this test flipping from the old (wrong) 30px/30px to the correct 20px/8px - // with no renderer-side code change of its own. Neither exceeds either edge, so no - // overlap-clamping kicks in here (that path is covered separately by the pixel-radius - // clamp test below). + // against its 80px height (10% = 8px) - per spec. Assert.Equal(20f, boxBackground.Radii.TopLeftX); Assert.Equal(8f, boxBackground.Radii.TopLeftY); } @@ -4705,6 +4847,567 @@ public async Task BuildDisplayList_TransformOpacityAndFilterNestInOpacityBetween // Mirrors the private HtmlRenderer.FormControlAccentColor constant (26, 115, 232) - kept as an // independent literal here rather than reflecting into the private field, so a test failure // reads as "the painted color changed" rather than needing reflection to even compile. + [Fact] + public async Task BuildDisplayList_TextOverflowEllipsisTruncatesOverflowingSingleLine() + { + var document = await ParseAsync(""" + +
      This is a long line of text
      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 120, + FontSize = 16f, + }); + + var text = Assert.Single(displayList.Commands.OfType()); + Assert.EndsWith("…", text.Text); + Assert.True(text.Text.Length < "This is a long line of text".Length); + } + + [Fact] + public async Task BuildDisplayList_TextOverflowEllipsisHasNoEffectWithoutClipping() + { + var document = await ParseAsync(""" + +
      This is a long line of text
      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 120, + FontSize = 16f, + }); + + var text = Assert.Single(displayList.Commands.OfType()); + Assert.Equal("This is a long line of text", text.Text); + } + + [Fact] + public async Task BuildDisplayList_WordBreakAllWrapsAnOverlongWordAcrossMultipleLines() + { + var document = await ParseAsync(""" + +
      aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var lines = displayList.Commands.OfType().ToArray(); + + Assert.True(lines.Length > 1); + Assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", string.Concat(lines.Select(line => line.Text))); + } + + [Fact] + public async Task BuildDisplayList_OverflowWrapBreakWordBreaksAnOverlongWordAsLastResort() + { + var document = await ParseAsync(""" + +
      aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var lines = displayList.Commands.OfType().ToArray(); + + Assert.True(lines.Length > 1); + Assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", string.Concat(lines.Select(line => line.Text))); + } + + [Fact] + public async Task BuildDisplayList_OverflowWrapNormalLeavesAnOverlongWordOnOneOverflowingLine() + { + var document = await ParseAsync(""" + +
      aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var text = Assert.Single(displayList.Commands.OfType()); + Assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", text.Text); + } + + [Fact] + public async Task BuildDisplayList_RendersBeforePseudoElementContentInlineBeforeRealContent() + { + var document = await ParseAsync(""" + +
      middle
      + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var texts = displayList.Commands.OfType().ToArray(); + + Assert.Equal(2, texts.Length); + Assert.Equal("PREFIX-", texts[0].Text); + Assert.Equal("middle", texts[1].Text); + Assert.Equal(0f, texts[0].X); + Assert.Equal(texts[0].Y, texts[1].Y); + Assert.True(texts[1].X > texts[0].X); + } + + [Fact] + public async Task BuildDisplayList_RendersAfterPseudoElementContentInlineAfterRealContent() + { + var document = await ParseAsync(""" + +
      middle
      + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var texts = displayList.Commands.OfType().ToArray(); + + Assert.Equal(2, texts.Length); + Assert.Equal("middle", texts[0].Text); + Assert.Equal("-SUFFIX", texts[1].Text); + Assert.True(texts[1].X > texts[0].X); + } + + [Fact] + public async Task BuildDisplayList_PseudoElementWithNoContentRendersNothingExtra() + { + var document = await ParseAsync(""" +
      middle
      + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var text = Assert.Single(displayList.Commands.OfType()); + Assert.Equal("middle", text.Text); + } + + [Fact] + public async Task BuildDisplayList_PseudoElementContentAttrResolvesHostAttribute() + { + var document = await ParseAsync(""" + +
      middle
      + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var texts = displayList.Commands.OfType().ToArray(); + + Assert.Equal(2, texts.Length); + Assert.Equal("Hello", texts[0].Text); + } + + [Fact] + public async Task BuildDisplayList_PseudoElementInheritsColorFromHostWhenNotItselfSet() + { + var document = await ParseAsync(""" + +
      middle
      + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var texts = displayList.Commands.OfType().ToArray(); + + Assert.Equal(2, texts.Length); + Assert.Equal(new RenderColor(10, 20, 30), texts[0].Color); + Assert.Equal(new RenderColor(10, 20, 30), texts[1].Color); + } + + [Fact] + public async Task BuildDisplayList_PseudoElementOnFormControlDoesNotDuplicateControlChrome() + { + var document = await ParseAsync(""" + + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + // Baseline (the identical markup without the ::before rule) paints exactly 6 + // FillRectCommands (the input's default chrome, plus page/body backgrounds) and 1 + // DrawTextCommand (its own "hi" value) - a pseudo-element misidentified as the same form + // control would duplicate that chrome and/or the value text. Only the pseudo's own "*" + // text should be added on top. + Assert.Equal(6, displayList.Commands.OfType().Count()); + var texts = displayList.Commands.OfType().ToArray(); + Assert.Equal(2, texts.Length); + Assert.Contains(texts, t => t.Text == "*"); + Assert.Contains(texts, t => t.Text == "hi"); + } + + [Fact] + public async Task BuildDisplayList_PseudoElementOnImageDoesNotRepaintTheImage() + { + var document = await ParseAsync(""" + + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var imageCommands = displayList.Commands.OfType().ToArray(); + Assert.Single(imageCommands); + } + + [Fact] + public async Task BuildDisplayList_StickyElementRendersAtItsNaturalPositionBeforeScrollingPastIt() + { + var renderDevice = new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 200, + FontSize = 16, + }; + var configuration = Configuration.Default.WithCss().WithRenderDevice(renderDevice); + var document = await ParseAsync(""" + + + +
      +
      +
      + + + """, configuration); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, renderDevice); + var fill = Assert.Single(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(0, 255, 0)))); + + Assert.Equal(50f, fill.Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_StickyElementPinsToTopOnceScrolledPastIt() + { + var renderDevice = new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 200, + FontSize = 16, + }; + var configuration = Configuration.Default.WithCss().WithRenderDevice(renderDevice); + var document = await ParseAsync(""" + + + +
      +
      +
      + + + """, configuration); + + document.Context.GetDomHarness(); + document.DocumentElement.SetScrollTop(250); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, renderDevice); + var fill = Assert.Single(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(0, 255, 0)))); + + // Natural position (210 - 250 = -40) has scrolled above the 10px sticky threshold, so the + // element clamps to top:10px instead of following its natural (now off-screen) position. + Assert.Equal(10f, fill.Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_StickyElementWithoutAnOffsetBehavesLikeStatic() + { + var renderDevice = new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 200, + FontSize = 16, + }; + var configuration = Configuration.Default.WithCss().WithRenderDevice(renderDevice); + var document = await ParseAsync(""" + + + +
      +
      +
      + + + """, configuration); + + document.Context.GetDomHarness(); + document.DocumentElement.SetScrollTop(250); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, renderDevice); + var fill = Assert.Single(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(0, 255, 0)))); + + // No offset property at all means nothing to stick to - stays at its natural (now + // off-screen, negative-Y) scrolled position, exactly like `position: static` would. + Assert.Equal(-40f, fill.Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_ContentBoxSizingRemainsTheDefaultAndAddsPaddingAndBorderOnTop() + { + var renderDevice = new DefaultRenderDevice { ViewPortWidth = 300, ViewPortHeight = 200, FontSize = 16 }; + var configuration = Configuration.Default.WithCss().WithRenderDevice(renderDevice); + var document = await ParseAsync(""" + + + +
      + + + """, configuration); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, renderDevice); + var fill = Assert.Single(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(0, 255, 0)))); + + // Unset box-sizing (content-box, the CSS default) is unaffected by this feature: the + // authored width/height already describe the content box, so padding and border are added + // on top of it, exactly as this renderer always assumed before box-sizing existed anywhere + // in its style map - 100 + 2*10 (padding) + 2*5 (border) = 130. + Assert.Equal(130f, fill.Rect.Width); + Assert.Equal(80f, fill.Rect.Height); + } + + [Fact] + public async Task BuildDisplayList_BorderBoxSizingMakesWidthAndHeightIncludePaddingAndBorder() + { + var renderDevice = new DefaultRenderDevice { ViewPortWidth = 300, ViewPortHeight = 200, FontSize = 16 }; + var configuration = Configuration.Default.WithCss().WithRenderDevice(renderDevice); + var document = await ParseAsync(""" + + + +
      + + + """, configuration); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, renderDevice); + var fill = Assert.Single(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(0, 255, 0)))); + + // box-sizing: border-box means the authored width/height already *are* the border-box + // size - padding and border eat into the content area instead of being added on top of it, + // so the painted border box stays exactly 100x50 regardless of padding/border. + Assert.Equal(100f, fill.Rect.Width); + Assert.Equal(50f, fill.Rect.Height); + } + + [Fact] + public async Task BuildDisplayList_BorderBoxSizingClampsContentToZeroWhenPaddingAndBorderExceedTheDeclaredSize() + { + var renderDevice = new DefaultRenderDevice { ViewPortWidth = 300, ViewPortHeight = 200, FontSize = 16 }; + var configuration = Configuration.Default.WithCss().WithRenderDevice(renderDevice); + var document = await ParseAsync(""" + + + +
      + + + """, configuration); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, renderDevice); + var fill = Assert.Single(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(0, 255, 0)))); + + // Padding (20px each side) plus border (5px each side) alone already exceed the declared + // 20px border box - ResolveAuthoredDimension clamps the resulting negative content size to + // 0 rather than letting it go negative, matching a real browser: the box still cannot be + // physically smaller than its own border+padding, so the rendered border box ends up + // border+padding (2*5 + 2*20 = 50) rather than the too-small declared 20px, exactly as + // borderBoxWidth/Height's own pre-existing bottom-up "border + padding + content" formula + // (unchanged by this feature) already produces once content is 0. + Assert.Equal(50f, fill.Rect.Width); + Assert.Equal(50f, fill.Rect.Height); + } + + [Fact] + public async Task BuildDisplayList_FlexItemBorderBoxWidthIncludesPaddingAndBorder() + { + var renderDevice = new DefaultRenderDevice { ViewPortWidth = 300, ViewPortHeight = 200, FontSize = 16 }; + var configuration = Configuration.Default.WithCss().WithRenderDevice(renderDevice); + var document = await ParseAsync(""" + + + +
      +
      +
      + + + """, configuration); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, renderDevice); + var fill = Assert.Single(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(0, 255, 0)))); + + // A flex item's own box-sizing follows the same rule as an ordinary block box - + // ResolveFlexBaseSize converts the item's authored border-box width into a content-box + // main size, using this item's own (not the container's) border/padding, before the + // flex-grow/shrink distribution ever runs. + Assert.Equal(100f, fill.Rect.Width); + } + + [Fact] + public async Task BuildDisplayList_FlexContainerBorderBoxWidthIncludesPaddingAndBorder() + { + var renderDevice = new DefaultRenderDevice { ViewPortWidth = 300, ViewPortHeight = 200, FontSize = 16 }; + var configuration = Configuration.Default.WithCss().WithRenderDevice(renderDevice); + var document = await ParseAsync(""" + + + +
      +
      +
      + + + """, configuration); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, renderDevice); + var fill = Assert.Single(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(255, 0, 255)))); + + // The flex container itself is an ordinary box for sizing purposes - its own declared + // border-box width/height (200x80) must stay exactly that regardless of its own padding + // (20px) and border (10px), with the reduced space left over distributed to items instead. + Assert.Equal(200f, fill.Rect.Width); + Assert.Equal(80f, fill.Rect.Height); + } + + [Fact] + public async Task BuildDisplayList_GridContainerBorderBoxHeightIncludesPaddingAndBorder() + { + var renderDevice = new DefaultRenderDevice { ViewPortWidth = 300, ViewPortHeight = 200, FontSize = 16 }; + var configuration = Configuration.Default.WithCss().WithRenderDevice(renderDevice); + var document = await ParseAsync(""" + + + +
      +
      +
      + + + """, configuration); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, renderDevice); + var fill = Assert.Single(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(255, 0, 255)))); + + // The child is given an explicit, small height so the implicit row's own auto-growth + // (ResolveGridItemEstimatedSize's pre-existing, unrelated fallback of the *container's* + // content width whenever an item's own height is unset) never dominates over the + // container's own specified height - isolating this assertion to the box-sizing + // conversion this test actually targets. + Assert.Equal(100f, fill.Rect.Height); + } + + [Fact] + public async Task BuildDisplayList_InlineBlockBorderBoxWidthIsUsedForLineWrapPrediction() + { + var renderDevice = new DefaultRenderDevice { ViewPortWidth = 300, ViewPortHeight = 200, FontSize = 16 }; + var configuration = Configuration.Default.WithCss().WithRenderDevice(renderDevice); + var document = await ParseAsync(""" + + + +
      + +
      + + + """, configuration); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, renderDevice); + var greenFill = Assert.Single(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(0, 255, 0)))); + var blueFill = Assert.Single(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(0, 0, 255)))); + + // TryMeasureInlineBlockBoxSize's own prediction must agree with box-sizing too: each + // border-box inline-block is exactly 50px wide (not 50+2*5+2*2=64, which its own padding + // and border would inflate it to under the old, box-sizing-unaware prediction), so both + // fit together in the 100px container on the very same line without wrapping. + Assert.Equal(50f, greenFill.Rect.Width); + Assert.Equal(greenFill.Rect.Y, blueFill.Rect.Y); + Assert.Equal(50f, blueFill.Rect.X); + } + private static readonly RenderColor FormControlAccentColorForTests = new(26, 115, 232); private static async Task ParseAsync(string html, IConfiguration? configuration = null, string? address = null) diff --git a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs index 0ce8013..84fbb16 100644 --- a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs +++ b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs @@ -2624,6 +2624,176 @@ public async Task RenderToPng_PreWhiteSpacePreservesIndentationAndLineBreaks() maxDifferentPixels: TextRenderingToleranceMaxPixels); } + [Fact] + public async Task RenderToPng_SizesGridTracksWithFrRepeatAndMinMax() + { + var document = await ParseAsync(""" + + + + + +
      +
      +
      +
      +
      + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "sizes-grid-tracks-with-fr-repeat-and-minmax.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_TruncatesAndBreaksOverflowingText() + { + var document = await ParseAsync(""" + + + + + +
      This text is much too long to fit
      +
      Supercalifragilisticexpialidocious
      + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 160, + ViewPortHeight = 160, + FontSize = 16f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "truncates-and-breaks-overflowing-text.png", + actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); + } + + [Fact] + public async Task RenderToPng_RendersBeforeAndAfterGeneratedContent() + { + var document = await ParseAsync(""" + + + + + +
      Widget
      + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 180, + ViewPortHeight = 50, + FontSize = 16f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-before-and-after-generated-content.png", + actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); + } + + [Fact] + public async Task RenderToPng_StickyHeaderStaysPinnedToTopWhileScrolledPastIt() + { + var renderDevice = new DefaultRenderDevice + { + ViewPortWidth = 80, + ViewPortHeight = 100, + DeviceWidth = 80, + DeviceHeight = 100, + FontSize = 16, + }; + var configuration = Configuration.Default.WithCss().WithRenderDevice(renderDevice); + var document = await ParseAsync(""" + + + +
      +
      +
      +
      +
      +
      + + + """, configuration); + + document.Context.GetDomHarness(); + document.DocumentElement.SetScrollTop(80); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, renderDevice); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "sticky-header-stays-pinned-to-top-while-scrolled-past-it.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_BorderBoxSizingKeepsTheDeclaredWidthInsteadOfAddingPaddingAndBorder() + { + var renderDevice = new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 80, + DeviceWidth = 200, + DeviceHeight = 80, + FontSize = 16, + }; + var configuration = Configuration.Default.WithCss().WithRenderDevice(renderDevice); + var document = await ParseAsync(""" + + + +
      +
      + + + """, configuration); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, renderDevice); + + // The green box (default content-box) grows to 60 + 2*10 (padding) + 2*5 (border) = 90px + // wide; the blue box (box-sizing: border-box), authored with the exact same width/padding/ + // border, stays exactly 60px wide instead - the padding and border eat into its content + // area rather than adding to the declared size. + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "border-box-sizing-keeps-the-declared-width.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + private static async Task ParseAsync(string html, IConfiguration? configuration = null) { var context = BrowsingContext.New(configuration ?? Configuration.Default.WithCss()); diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/border-box-sizing-keeps-the-declared-width.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/border-box-sizing-keeps-the-declared-width.linux.png new file mode 100644 index 0000000..fdb2bef Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/border-box-sizing-keeps-the-declared-width.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/border-box-sizing-keeps-the-declared-width.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/border-box-sizing-keeps-the-declared-width.macos.png new file mode 100644 index 0000000..fdb2bef Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/border-box-sizing-keeps-the-declared-width.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/border-box-sizing-keeps-the-declared-width.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/border-box-sizing-keeps-the-declared-width.windows.png new file mode 100644 index 0000000..fdb2bef Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/border-box-sizing-keeps-the-declared-width.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-before-and-after-generated-content.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-before-and-after-generated-content.linux.png new file mode 100644 index 0000000..d97fac9 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-before-and-after-generated-content.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-before-and-after-generated-content.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-before-and-after-generated-content.macos.png new file mode 100644 index 0000000..ea945f7 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-before-and-after-generated-content.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-before-and-after-generated-content.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-before-and-after-generated-content.windows.png new file mode 100644 index 0000000..b546dc2 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-before-and-after-generated-content.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/sizes-grid-tracks-with-fr-repeat-and-minmax.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/sizes-grid-tracks-with-fr-repeat-and-minmax.linux.png new file mode 100644 index 0000000..8e7c435 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/sizes-grid-tracks-with-fr-repeat-and-minmax.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/sizes-grid-tracks-with-fr-repeat-and-minmax.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/sizes-grid-tracks-with-fr-repeat-and-minmax.macos.png new file mode 100644 index 0000000..8e7c435 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/sizes-grid-tracks-with-fr-repeat-and-minmax.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/sizes-grid-tracks-with-fr-repeat-and-minmax.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/sizes-grid-tracks-with-fr-repeat-and-minmax.windows.png new file mode 100644 index 0000000..8e7c435 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/sizes-grid-tracks-with-fr-repeat-and-minmax.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/sticky-header-stays-pinned-to-top-while-scrolled-past-it.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/sticky-header-stays-pinned-to-top-while-scrolled-past-it.linux.png new file mode 100644 index 0000000..35c38b7 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/sticky-header-stays-pinned-to-top-while-scrolled-past-it.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/sticky-header-stays-pinned-to-top-while-scrolled-past-it.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/sticky-header-stays-pinned-to-top-while-scrolled-past-it.macos.png new file mode 100644 index 0000000..35c38b7 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/sticky-header-stays-pinned-to-top-while-scrolled-past-it.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/sticky-header-stays-pinned-to-top-while-scrolled-past-it.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/sticky-header-stays-pinned-to-top-while-scrolled-past-it.windows.png new file mode 100644 index 0000000..35c38b7 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/sticky-header-stays-pinned-to-top-while-scrolled-past-it.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/truncates-and-breaks-overflowing-text.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/truncates-and-breaks-overflowing-text.linux.png new file mode 100644 index 0000000..34f3449 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/truncates-and-breaks-overflowing-text.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/truncates-and-breaks-overflowing-text.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/truncates-and-breaks-overflowing-text.macos.png new file mode 100644 index 0000000..f623179 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/truncates-and-breaks-overflowing-text.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/truncates-and-breaks-overflowing-text.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/truncates-and-breaks-overflowing-text.windows.png new file mode 100644 index 0000000..1280e63 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/truncates-and-breaks-overflowing-text.windows.png differ diff --git a/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj b/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj index d3ea91c..444069a 100644 --- a/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj +++ b/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj @@ -21,7 +21,7 @@ true snupkg 1.8.1 - 1.1.1 + 1.1.2 diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 10d7e6a..35acca5 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -330,7 +330,7 @@ private static DisplayList BuildDisplayList(IDocument document, RenderViewport v return displayList; } - var textStyle = new RenderTextStyle(context.FontSize, context.TextColor, context.FontFamily, context.LineHeightMultiplier, 400f, false, false, false, context.TextColor, global::AngleSharp.Renderer.Rendering.RenderTextDecorationStyle.Solid, TextAlign.Left, 0f, 0f, 0f, [], WhiteSpaceMode.Normal); + var textStyle = new RenderTextStyle(context.FontSize, context.TextColor, context.FontFamily, context.LineHeightMultiplier, 400f, false, false, false, context.TextColor, global::AngleSharp.Renderer.Rendering.RenderTextDecorationStyle.Solid, TextAlign.Left, 0f, 0f, 0f, [], WhiteSpaceMode.Normal, WordBreakMode.Normal, OverflowWrapMode.Normal, TextOverflowMode.Clip); var cursorY = contentY; var previousBlockMarginBottom = 0f; var suppressNextBlockTopMargin = false; @@ -344,8 +344,23 @@ private static DisplayList BuildDisplayList(IDocument document, RenderViewport v // opposite: the full page, regardless of how tall it is relative to the viewport. var maxY = measureFullExtent ? float.MaxValue : viewport.Height - context.Padding; + // A `position: sticky` page child is laid out right here, in plain document order, like + // any other page child - its own cursorY/margin-collapse threading must stay in sequence + // for its layout to be correct. But once actually stuck, its final on-screen position + // routinely overlaps *later* siblings it would otherwise paint behind in that same document + // order - a real, confirmed bug caught by rendering the sticky-header visual test and + // seeing the header completely painted over by the content scrolling underneath it. Each + // sticky child's own command range is recorded here (by index, since DisplayList is a flat + // list) and relocated to the end, in original relative order, only after every page child + // has been laid out - see the loop below. + var stickyRanges = new List<(int StartIndex, int Count)>(); + foreach (var child in OrderChildrenForPainting(root.Children)) { + var isStickyChild = child is ElementRenderNode stickyCandidate && + IsStickyPositioned(CreateStyleMap(stickyCandidate.ComputedStyle, stickyCandidate.Ref)); + var stickyStartIndex = displayList.Commands.Count; + LayoutNode( node: child, containingX: contentX, @@ -362,12 +377,28 @@ private static DisplayList BuildDisplayList(IDocument document, RenderViewport v displayList: displayList, maxY: maxY); + if (isStickyChild) + { + stickyRanges.Add((stickyStartIndex, displayList.Commands.Count - stickyStartIndex)); + } + if (cursorY > maxY) { break; } } + // Each earlier move shifts every later index left by however many commands it removed, so + // later ranges (still expressed in their original, pre-move indices) need that same + // cumulative shift subtracted before they are moved themselves. + var cumulativeShift = 0; + + foreach (var (startIndex, count) in stickyRanges) + { + displayList.MoveRangeToEnd(startIndex - cumulativeShift, count); + cumulativeShift += count; + } + // The page's own scrolling elements (/) are never laid out as boxes of their // own - the loop above only ever lays out *their children* directly onto the page canvas - // so neither ever gets an ordinary RecordLayoutMetrics call the way a normal descendant @@ -491,7 +522,12 @@ private static void LayoutElement( return; } - var renderAsBlock = ShouldRenderAsBlock(computedStyle) || IsReplacedElementTag(tagName); + // IsReplacedElementTag reads the host's own tagName, which a ::before/::after pseudo-element + // shares (PseudoElement.LocalName/TagName proxy through) - a real browser gives a generated- + // content pseudo its own independent display computation (defaulting to inline, per spec), + // entirely unrelated to whatever the host's tag would otherwise force, so this renderer must + // not either. + var renderAsBlock = ShouldRenderAsBlock(computedStyle) || (element is not IPseudoElement && IsReplacedElementTag(tagName)); var isInlineBlock = IsInlineBlock(computedStyle); var currentTextStyle = ResolveTextStyle(styleMap, inheritedTextStyle); @@ -563,6 +599,7 @@ private static void LayoutElement( var isAbsolute = string.Equals(position, "absolute", StringComparison.OrdinalIgnoreCase); var isFixed = string.Equals(position, "fixed", StringComparison.OrdinalIgnoreCase); var isRelative = string.Equals(position, "relative", StringComparison.OrdinalIgnoreCase); + var isSticky = string.Equals(position, "sticky", StringComparison.OrdinalIgnoreCase); var isFloatLeft = string.Equals(GetFloat(styleMap), "left", StringComparison.OrdinalIgnoreCase); if (isAbsolute || isFixed) @@ -586,6 +623,7 @@ private static void LayoutElement( styleMap, flowContainingWidth, float.NaN, + borderLeft + borderRight + paddingLeft + paddingRight, isFlexItem, isRowDirection, flexMainSize, @@ -625,11 +663,25 @@ private static void LayoutElement( : isAbsolute ? containingX + leftOffset : flowBorderBoxX + (isRelative ? leftOffset : 0f); + // `position: sticky` stays in normal flow for every other purpose (margins, cursor + // advancement, painting order - see the isAbsolute/isFixed checks elsewhere in this + // method, none of which match "sticky") - only its own final paint Y is adjusted here. + // This renderer already lays out an entire scrolled page in one coordinate space where + // Y=context.Padding is the viewport's own top edge (BuildDisplayList shifts the whole + // page's starting Y by -scrollOffsetY up front, so flowBorderBoxY arrives already + // viewport-relative) - "stick to `top` once scrolled past it" is therefore just clamping + // the element's own natural Y to never go above that threshold, with no separate scroll + // lookup needed. Only triggers when `top` is actually authored (`styleMap.ContainsKey`, + // not just defaulted to 0 via ParseLength's own allowAuto fallback below) - an + // unconstrained sticky element (no offset property at all) has nothing to stick to and + // behaves exactly like `static`, per spec. var borderBoxY = isFixed ? context.Padding + topOffset : isAbsolute ? containingY + topOffset - : flowBorderBoxY + (isRelative ? topOffset : 0f); + : isSticky && styleMap.ContainsKey("top") + ? Math.Max(flowBorderBoxY, context.Padding + topOffset) + : flowBorderBoxY + (isRelative ? topOffset : 0f); var contentX = borderBoxX + borderLeft + paddingLeft; var contentY = borderBoxY + borderTop + paddingTop; @@ -657,6 +709,7 @@ private static void LayoutElement( styleMap, flowContainingWidth, float.NaN, + borderTop + borderBottom + paddingTop + paddingBottom, isFlexItem, isRowDirection, flexMainSize, @@ -690,8 +743,13 @@ private static void LayoutElement( // the ordinary block child-layout path below is exactly what a browser's own