diff --git a/CHANGELOG.md b/CHANGELOG.md index c926c3f..e2090b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +# 0.7.0 + +Released on Monday, October 19 2026. + +- Improved regression test coverage for 3D CSS transform functions (`rotateX`/`rotateY`/`rotate3d`/`translate3d`/`scale3d`/`matrix3d`) (#16) +- Fixed `repeat(auto-fill/auto-fit)` fill-count resolution to use available space, gap, and track minimum size (#20) +- Fixed a pre-existing inline layout bug where plain, unstyled semantic inline elements (``, ``, ``, ``, ...) were misidentified as block-level, preventing them from sharing a line with sibling text (#23) +- Added support for `animation-play-state: paused` (#17) +- Added `aspect-ratio` width-from-height derivation for boxes with an auto width and definite height (#14) +- Added `object-fit` (`fill`/`contain`/`cover`/`none`/`scale-down`) and `object-position` support for replaced elements (#13) +- Added rendering support for `input[type=range]` (track + thumb) and `input[type=file]` (#21) +- Added `clip-path` support for ordinary HTML boxes (`circle()`, `ellipse()`, `inset()`, `polygon()`) (#12) +- Added `"""); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 100, + FontSize = 16f, + }); + + Assert.DoesNotContain(displayList.Commands, c => c is FillRectCommand f && f.Rect.Width == 1.5f); + } + + [Fact] + public async Task BuildDisplayList_FocusedTextAreaPaintsACaretOnTheLastWrappedLine() + { + var document = await ParseAsync(""""""); + var target = (IHtmlElement)document.GetElementById("target")!; + target.DoFocus(); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 100, + FontSize = 16f, + }); + + var caret = Assert.Single(displayList.Commands.OfType().Where(f => f.Rect.Width == 1.5f)); + var textCommands = displayList.Commands.OfType().OrderBy(t => t.Y).ToList(); + + Assert.True(textCommands.Count > 1); + + // The caret sits on the last wrapped line, below where the first line was painted - not + // pinned to the first line's own baseline. + Assert.True(caret.Rect.Y > textCommands[0].Y); + } + + [Fact] + public async Task BuildDisplayList_FocusedEmptyTextAreaPaintsACaretAtItsContentEdge() + { + var document = await ParseAsync(""""""); + var target = (IHtmlElement)document.GetElementById("target")!; + target.DoFocus(); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 100, + FontSize = 16f, + }); + + Assert.Single(displayList.Commands.OfType().Where(f => f.Rect.Width == 1.5f)); + } + + [Fact] + public async Task BuildDisplayList_FocusedTextAreaWithTrailingNewlineMovesCaretToANewEmptyLine() + { + var document = await ParseAsync(""" + + """); + var target = (IHtmlElement)document.GetElementById("target")!; + target.DoFocus(); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 100, + FontSize = 16f, + }); + + var caret = Assert.Single(displayList.Commands.OfType().Where(f => f.Rect.Width == 1.5f)); + var hiText = Assert.Single(displayList.Commands.OfType().Where(t => t.Text == "Hi")); + + // The trailing newline pushes the caret down onto its own, empty second line rather than + // leaving it right after "Hi" on the first line. + Assert.True(caret.Rect.Y > hiText.Y); + } + [Fact] public async Task BuildDisplayList_FocusedEmptyTextInputPaintsCaretAtTheContentEdge() { @@ -4417,6 +5127,76 @@ int CurrentCaretAlpha() Assert.Equal(255, CurrentCaretAlpha()); } + [Fact] + public async Task BuildDisplayList_RangeInputPaintsATrackAndAThumbPositionedByValue() + { + var document = await ParseAsync(""""""); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 100, + FontSize = 16f, + }); + + // The track: a thin, rounded gray bar spanning the control's own width. + var track = Assert.Single(displayList.Commands.OfType() + .Where(command => command.Rect.Width == 100f)); + Assert.False(track.Radii.IsZero); + + // The thumb: a small rounded square, its center at 25% along the track for value=25 out + // of a 0-100 range. + var thumb = Assert.Single(displayList.Commands.OfType() + .Where(command => command.Rect.Width < 100f && !command.Rect.Width.Equals(track.Rect.Width))); + var thumbCenterX = thumb.Rect.X + (thumb.Rect.Width / 2f); + Assert.Equal(25f, thumbCenterX, precision: 1); + } + + [Fact] + public async Task BuildDisplayList_RangeInputDefaultsToTheMidpointWhenValueIsUnset() + { + var document = await ParseAsync(""""""); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 100, + FontSize = 16f, + }); + + var track = Assert.Single(displayList.Commands.OfType().Where(c => c.Rect.Width == 100f)); + var thumb = Assert.Single(displayList.Commands.OfType().Where(c => c.Rect.Width < 100f && !c.Rect.Width.Equals(track.Rect.Width))); + var thumbCenterX = thumb.Rect.X + (thumb.Rect.Width / 2f); + + // No `value` authored - defaults to the (min+max)/2 midpoint (0-100 default range), so + // the thumb sits at 50% along the track. + Assert.Equal(50f, thumbCenterX, precision: 1); + } + + [Fact] + public async Task BuildDisplayList_FileInputGetsButtonLikeChromeAndAFixedLabel() + { + var document = await ParseAsync(""""""); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 100, + FontSize = 16f, + }); + + var label = Assert.Single(displayList.Commands.OfType().Where(t => t.Text == "Choose File")); + Assert.NotNull(label); + + // Button-like default chrome: a gray background box, matching FormControlKind.Button. + var background = Assert.Single(displayList.Commands.OfType() + .Where(command => command.Color.Equals(new RenderColor(0xe8, 0xe8, 0xe8)))); + Assert.True(background.Rect.Width > 0f); + } + [Fact] public async Task BuildDisplayList_UncheckedCheckboxPaintsOnlyItsBoxNoFill() { @@ -4908,28 +5688,119 @@ public async Task BuildDisplayList_ScaleTransformExpandsSymmetricallyAroundDefau } [Fact] - public async Task BuildDisplayList_MatrixFunctionPreservesAllSixComponents() + public async Task BuildDisplayList_RotateYProducesAnOrthographicHorizontalSquash() { - // The plain 6-value 2D matrix() form was previously confirmed to compute *wrong* values in - // AngleSharp.Css itself (a column-major-vs-row-major mismatch in how it padded the array - // passed to TransformMatrix's constructor) - now fixed upstream, with a reproducing test in - // AngleSharp.Css's own suite (TransformFunctionsTests.PlainSixValueMatrixFunctionPreservesAllSixComponents) - // confirming it. This renderer never guarded against that bug locally to begin with - per - // its own policy (see ParseCssTransform/ConvertToRenderTransform's remarks), it always - // trusts whatever AngleSharp.Css computes - so once AngleSharp.Css's own bug was fixed, - // matrix() started working correctly here automatically, with no change needed in this - // renderer at all. This test asserts the actual, now-correct values directly, using the - // same non-symmetric coefficients as AngleSharp.Css's own regression test specifically so a - // symmetric input could never mask a b/c transposition or an e/f loss the way it did before. + // Without a `perspective` in effect, this renderer flattens a 3D transform to 2D by + // evaluating its already-general matrix at z=0 and discarding the z-output - which for + // rotateY(theta) (rotation around the vertical axis) works out to a pure horizontal scale + // by cos(theta), with no foreshortening (matching how a browser renders 3D transforms with + // no perspective ancestor: an orthographic, not a perspective, projection). var document = await ParseAsync(""" -
+
"""); var renderer = new HtmlRenderer(); var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 200, ViewPortHeight = 200 }); - var push = Assert.Single(displayList.Commands.OfType()); - var t = push.Transform; + var t = Assert.Single(displayList.Commands.OfType()).Transform; + + Assert.Equal(0.5f, t.A, precision: 2); + Assert.Equal(0f, t.B, precision: 2); + Assert.Equal(0f, t.C, precision: 2); + Assert.Equal(1f, t.D, precision: 2); + } + + [Fact] + public async Task BuildDisplayList_RotateXProducesAnOrthographicVerticalSquash() + { + var document = await ParseAsync(""" + +
+ + """); + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 200, ViewPortHeight = 200 }); + var t = Assert.Single(displayList.Commands.OfType()).Transform; + + Assert.Equal(1f, t.A, precision: 2); + Assert.Equal(0f, t.B, precision: 2); + Assert.Equal(0f, t.C, precision: 2); + Assert.Equal(0.5f, t.D, precision: 2); + } + + [Fact] + public async Task BuildDisplayList_TranslateZHasNoVisibleEffectWithoutPerspective() + { + // translateZ alone moves an element only along the axis perpendicular to the screen - + // with no perspective in effect to project that depth back onto the 2D plane, it has + // (correctly, matching a real browser with no `perspective` ancestor) no visible effect. + var document = await ParseAsync(""" + +
+ + """); + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 200, ViewPortHeight = 200 }); + + Assert.Empty(displayList.Commands.OfType()); + } + + [Fact] + public async Task BuildDisplayList_Translate3DAppliesItsXAndYComponents() + { + var document = await ParseAsync(""" + +
+ + """); + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 200, ViewPortHeight = 200 }); + var t = Assert.Single(displayList.Commands.OfType()).Transform; + + Assert.Equal(new RenderTransform2D(1f, 0f, 0f, 1f, 12f, 6f), t); + } + + [Fact] + public async Task BuildDisplayList_Scale3DAppliesItsXAndYComponents() + { + var document = await ParseAsync(""" + +
+ + """); + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 200, ViewPortHeight = 200 }); + var t = Assert.Single(displayList.Commands.OfType()).Transform; + + Assert.Equal(2f, t.A, precision: 2); + Assert.Equal(0f, t.B, precision: 2); + Assert.Equal(0f, t.C, precision: 2); + Assert.Equal(3f, t.D, precision: 2); + } + + [Fact] + public async Task BuildDisplayList_MatrixFunctionPreservesAllSixComponents() + { + // The plain 6-value 2D matrix() form was previously confirmed to compute *wrong* values in + // AngleSharp.Css itself (a column-major-vs-row-major mismatch in how it padded the array + // passed to TransformMatrix's constructor) - now fixed upstream, with a reproducing test in + // AngleSharp.Css's own suite (TransformFunctionsTests.PlainSixValueMatrixFunctionPreservesAllSixComponents) + // confirming it. This renderer never guarded against that bug locally to begin with - per + // its own policy (see ParseCssTransform/ConvertToRenderTransform's remarks), it always + // trusts whatever AngleSharp.Css computes - so once AngleSharp.Css's own bug was fixed, + // matrix() started working correctly here automatically, with no change needed in this + // renderer at all. This test asserts the actual, now-correct values directly, using the + // same non-symmetric coefficients as AngleSharp.Css's own regression test specifically so a + // symmetric input could never mask a b/c transposition or an e/f loss the way it did before. + var document = await ParseAsync(""" + +
+ + """); + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 200, ViewPortHeight = 200 }); + var push = Assert.Single(displayList.Commands.OfType()); + var t = push.Transform; Assert.Equal(2f, t.A, precision: 2); Assert.Equal(3f, t.B, precision: 2); @@ -5012,6 +5883,125 @@ public async Task BuildDisplayList_NoFilterProducesNoPushOrPopFilterCommands() Assert.Empty(displayList.Commands.OfType()); } + [Fact] + public async Task BuildDisplayList_ClipPathCircleClipsAnOrdinaryDivToACircleShape() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 200, ViewPortHeight = 200 }); + + var push = Assert.Single(displayList.Commands.OfType()); + var circle = Assert.IsType(push.Shape); + + Assert.Equal(50f, circle.CenterX, precision: 1); + Assert.Equal(30f, circle.CenterY, precision: 1); + Assert.Equal(20f, circle.Radius, precision: 1); + Assert.Single(displayList.Commands.OfType()); + } + + [Fact] + public async Task BuildDisplayList_ClipPathCircleDefaultsToCenteredClosestSideRadius() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 200, ViewPortHeight = 200 }); + + var push = Assert.Single(displayList.Commands.OfType()); + var circle = Assert.IsType(push.Shape); + + // No "at" clause -> centered (50,30); no explicit radius -> closest-side, which for a + // 100x60 box centered at (50,30) is the vertical distance to the nearest edge (30px). + Assert.Equal(50f, circle.CenterX, precision: 1); + Assert.Equal(30f, circle.CenterY, precision: 1); + Assert.Equal(30f, circle.Radius, precision: 1); + } + + [Fact] + public async Task BuildDisplayList_ClipPathEllipseResolvesIndependentXAndYRadii() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 200, ViewPortHeight = 200 }); + + var push = Assert.Single(displayList.Commands.OfType()); + var ellipse = Assert.IsType(push.Shape); + + Assert.Equal(50f, ellipse.CenterX, precision: 1); + Assert.Equal(30f, ellipse.CenterY, precision: 1); + Assert.Equal(40f, ellipse.RadiusX, precision: 1); + Assert.Equal(20f, ellipse.RadiusY, precision: 1); + } + + [Fact] + public async Task BuildDisplayList_ClipPathInsetShrinksTheClipRectFromEachEdge() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 200, ViewPortHeight = 200 }); + + var push = Assert.Single(displayList.Commands.OfType()); + var inset = Assert.IsType(push.Shape); + + Assert.Equal(15f, inset.Rect.X, precision: 1); + Assert.Equal(10f, inset.Rect.Y, precision: 1); + Assert.Equal(65f, inset.Rect.Width, precision: 1); + Assert.Equal(45f, inset.Rect.Height, precision: 1); + } + + [Fact] + public async Task BuildDisplayList_ClipPathPolygonProducesVerticesResolvedAgainstTheBox() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 200, ViewPortHeight = 200 }); + + var push = Assert.Single(displayList.Commands.OfType()); + var polygon = Assert.IsType(push.Shape); + + Assert.Equal(3, polygon.Points.Count); + Assert.Equal((0f, 0f), polygon.Points[0]); + Assert.Equal((100f, 0f), polygon.Points[1]); + Assert.Equal((50f, 60f), polygon.Points[2]); + } + + [Fact] + public async Task BuildDisplayList_NoClipPathProducesNoClipShapeCommands() + { + var document = await ParseAsync(""" + +
+ + """); + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 200, ViewPortHeight = 200 }); + Assert.Empty(displayList.Commands.OfType()); + } + [Fact] public async Task BuildDisplayList_FilterNoneProducesNoPushOrPopFilterCommands() { @@ -5289,6 +6279,98 @@ public async Task BuildDisplayList_TextOverflowEllipsisHasNoEffectWithoutClippin Assert.Equal("This is a long line of text", text.Text); } + [Fact] + public async Task BuildDisplayList_WebkitLineClampTruncatesToNLinesWithEllipsisOnTheLast() + { + var document = await ParseAsync(""" + +
one two three four five six seven eight
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 120, + FontSize = 16f, + }); + + var textCommands = displayList.Commands.OfType().OrderBy(t => t.Y).ToList(); + + // Clamped to exactly 2 lines - the remaining wrapped lines are dropped entirely, not just + // clipped/hidden. + Assert.Equal(2, textCommands.Count); + Assert.EndsWith("…", textCommands[1].Text); + } + + [Fact] + public async Task BuildDisplayList_WebkitLineClampHasNoEffectWhenTextFitsWithinTheLimit() + { + var document = await ParseAsync(""" + +
Short text
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 120, + FontSize = 16f, + }); + + var text = Assert.Single(displayList.Commands.OfType()); + Assert.Equal("Short text", text.Text); + Assert.DoesNotContain("…", text.Text); + } + + [Fact] + public async Task BuildDisplayList_WebkitLineClampHasNoEffectWithoutOverflowClipping() + { + var document = await ParseAsync(""" + +
one two three four five six seven eight
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 120, + FontSize = 16f, + }); + + // No `overflow: hidden` - the clamp must not apply, matching the same "text-overflow has no + // effect without clipping" precedent already established for the single-line case. + var textCommands = displayList.Commands.OfType().ToList(); + Assert.True(textCommands.Count > 2); + Assert.DoesNotContain(textCommands, t => t.Text.Contains('…')); + } + + [Fact] + public async Task BuildDisplayList_WebkitLineClampWorksAcrossDifferentWidthsAndLineHeights() + { + var document = await ParseAsync(""" + +
one two three four five six
+ + """); + + 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); + } + [Fact] public async Task BuildDisplayList_WordBreakAllWrapsAnOverlongWordAcrossMultipleLines() { @@ -5462,6 +6544,113 @@ public async Task BuildDisplayList_PseudoElementContentAttrResolvesHostAttribute Assert.Equal("Hello", texts[0].Text); } + [Fact] + public async Task BuildDisplayList_CounterIncrementNumbersSiblingElementsSequentially() + { + var document = await ParseAsync(""" + +
Alpha
+
Bravo
+
Charlie
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var texts = displayList.Commands.OfType().OrderBy(t => t.Y).Select(t => t.Text).ToArray(); + + // The trailing space in `". "` is trimmed away by the default `white-space: normal` + // rendering (the same trimming any other painted text gets), leaving just "N.". + Assert.Equal(["1.", "Alpha", "2.", "Bravo", "3.", "Charlie"], texts); + } + + [Fact] + public async Task BuildDisplayList_NestedCounterResetRestartsAndRestoresTheOuterCounter() + { + var document = await ParseAsync(""" + +
    +
  1. One +
      +
    1. One-A
    2. +
    3. One-B
    4. +
    +
  2. +
  3. Two
  4. +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 300, + FontSize = 16f, + }); + + var markerTexts = displayList.Commands.OfType() + .Where(t => t.Text.EndsWith('.')) + .OrderBy(t => t.Y) + .Select(t => t.Text) + .ToArray(); + + // The nested
    restarts its own "item" counter at 1 independently of the outer list, + // and the outer list's own counter correctly resumes at 2 for its second
  1. once the + // nested list's subtree has ended - not left at whatever value the nested list reached. + Assert.Equal(["1.", "1.", "2.", "2."], markerTexts); + } + + [Fact] + public async Task BuildDisplayList_CountersFunctionJoinsNestedCounterValuesWithASeparator() + { + var document = await ParseAsync(""" + +
      +
    1. One +
        +
      1. One-A
      2. +
      +
    2. +
    + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 300, + FontSize = 16f, + }); + + var markerTexts = displayList.Commands.OfType() + .Where(t => t.Text is not ("One" or "One-A")) + .OrderBy(t => t.Y) + .Select(t => t.Text) + .ToArray(); + + Assert.Equal(["1", "1.1"], markerTexts); + } + [Fact] public async Task BuildDisplayList_PseudoElementInheritsColorFromHostWhenNotItselfSet() { diff --git a/src/AngleSharp.Renderer.Tests/rendered-cases/test02-typography-inline.png b/src/AngleSharp.Renderer.Tests/rendered-cases/test02-typography-inline.png index 0f8954e..a9f1f2c 100644 Binary files a/src/AngleSharp.Renderer.Tests/rendered-cases/test02-typography-inline.png and b/src/AngleSharp.Renderer.Tests/rendered-cases/test02-typography-inline.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.linux.png index f961687..16aeb14 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.linux.png and b/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.macos.png index 3a0c86e..9a1339a 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.macos.png and b/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.windows.png index d7c3b10..00c9871 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.windows.png and b/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.windows.png differ diff --git a/src/AngleSharp.Renderer/CssAnimationTracker.cs b/src/AngleSharp.Renderer/CssAnimationTracker.cs index 70274bd..e952d5f 100644 --- a/src/AngleSharp.Renderer/CssAnimationTracker.cs +++ b/src/AngleSharp.Renderer/CssAnimationTracker.cs @@ -33,7 +33,8 @@ internal readonly record struct CssAnimationSpec( CssTimingFunction TimingFunction, double IterationCount, CssAnimationDirection Direction, - CssAnimationFillMode FillMode); + CssAnimationFillMode FillMode, + bool IsPaused); internal sealed record CssKeyframeStop(double Position, IReadOnlyDictionary PropertyValues); @@ -52,6 +53,14 @@ internal sealed class CssAnimationTracker { private readonly Dictionary<(IElement Element, string Name), double> _startTimesMs = []; + // Total virtual-clock time this (element, animation-name) pair has spent paused since it + // started, so it can be subtracted back out of the raw clock to get an "effective" clock that + // stays frozen for as long as the animation is paused and resumes in sync with the real clock + // once it isn't - the same amount of clock time always elapsing while paused as gets added + // here is what keeps the effective clock frozen across any number of paused observations. + private readonly Dictionary<(IElement Element, string Name), double> _pausedAccumulatedMs = []; + private readonly Dictionary<(IElement Element, string Name), double> _lastObservedClockMs = []; + /// /// Gets the animated value currently in effect for on /// , or null if no running `animation` on this element currently @@ -109,7 +118,8 @@ internal sealed class CssAnimationTracker } var startMs = _startTimesMs[(element, spec.Name)]; - var elapsedMs = clockMs - startMs - spec.DelayMs; + var effectiveClockMs = ResolveEffectiveClockMs(element, spec, clockMs); + var elapsedMs = effectiveClockMs - startMs - spec.DelayMs; var totalDurationMs = spec.DurationMs * spec.IterationCount; if (elapsedMs < 0d) @@ -150,6 +160,39 @@ internal sealed class CssAnimationTracker return InterpolateAtKeyframes(keyframes, property, eased); } + /// + /// `animation-play-state: paused` freezes an animation exactly where it currently is - not by + /// stopping the shared virtual clock (other animations/transitions on the same or other + /// elements must keep advancing normally), but by tracking how much clock time this specific + /// (element, animation-name) pair has spent paused and subtracting that back out of the raw + /// clock before the ordinary elapsed-time math runs. Whether the gap since the last observation + /// counts as paused time is decided by *this* call's play-state (not the previous one) - the + /// only state this tracker ever samples is at call time, so a pause taking effect anywhere + /// between two observations is attributed to the whole gap once it is next observed as paused; + /// once next observed as running again, no further time is added and the effective clock + /// continues advancing in lockstep with the real one from wherever it was left off. + /// + private double ResolveEffectiveClockMs(IElement element, CssAnimationSpec spec, double clockMs) + { + var key = (element, spec.Name); + + if (_lastObservedClockMs.TryGetValue(key, out var lastClockMs)) + { + if (spec.IsPaused) + { + _pausedAccumulatedMs[key] = _pausedAccumulatedMs.GetValueOrDefault(key) + (clockMs - lastClockMs); + } + } + else + { + _pausedAccumulatedMs[key] = 0d; + } + + _lastObservedClockMs[key] = clockMs; + + return clockMs - _pausedAccumulatedMs.GetValueOrDefault(key); + } + private static string? InterpolateAtKeyframes(IReadOnlyList stops, string property, double position) { if (position <= stops[0].Position) @@ -275,6 +318,7 @@ private static List ParseAnimationSpecs(ICssStyleDeclaration s var iterationList = HtmlRenderer.SplitTopLevelCommaList(style.GetPropertyValue("animation-iteration-count")); var directionList = HtmlRenderer.SplitTopLevelCommaList(style.GetPropertyValue("animation-direction")); var fillModeList = HtmlRenderer.SplitTopLevelCommaList(style.GetPropertyValue("animation-fill-mode")); + var playStateList = HtmlRenderer.SplitTopLevelCommaList(style.GetPropertyValue("animation-play-state")); var result = new List(nameList.Length); @@ -287,8 +331,9 @@ private static List ParseAnimationSpecs(ICssStyleDeclaration s var iterationCount = ParseIterationCount(ResolveListValue(iterationList, i, "1")); var direction = ParseDirection(ResolveListValue(directionList, i, "normal")); var fillMode = ParseFillMode(ResolveListValue(fillModeList, i, "none")); + var isPaused = string.Equals(ResolveListValue(playStateList, i, "running")?.Trim(), "paused", StringComparison.OrdinalIgnoreCase); - result.Add(new CssAnimationSpec(name, duration, delay, timingFunction, iterationCount, direction, fillMode)); + result.Add(new CssAnimationSpec(name, duration, delay, timingFunction, iterationCount, direction, fillMode, isPaused)); } return result; diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 82823a1..08bbe39 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -72,7 +72,8 @@ private readonly record struct LayoutContext( ITextMeasurer TextMeasurer, FontFaceSet Fonts, Dictionary> StyleMapCache, - IStyleCollection? StyleCollection); + IStyleCollection? StyleCollection, + Dictionary> Counters); private sealed class LayoutCapture { @@ -236,7 +237,13 @@ private static LayoutContext CreateLayoutContext(IDocument document, IRenderDevi // must never be answered from a stale map computed on a previous call. See // GetOrCreateStyleMap's own remarks for why caching within a single call is safe. StyleMapCache: new Dictionary>(ReferenceEqualityComparer.Instance), - StyleCollection: styleCollection); + StyleCollection: styleCollection, + // CSS counters (`counter-reset`/`counter-increment`/`counter-set`, read by `counter()`/ + // `counters()` in `content`) are scoped to a single render exactly like StyleMapCache + // above - a fresh, empty stack set every call, never persisted across renders, so a + // counter's value can never leak between two separate BuildDisplayList calls for the + // same document (interactive re-renders included). + Counters: new Dictionary>()); } /// @@ -339,6 +346,16 @@ private static DisplayList BuildDisplayList(IDocument document, RenderViewport v PrepareDocumentForRendering(document); var renderTree = window.Render(renderDevice); + + // CSS counters (`counter-reset`/`counter-increment`/`counter-set`, read back by + // `counter()`/`counters()` in generated `content`) are resolved in a dedicated pre-pass + // over the already-built render tree, entirely independent of the actual layout walk below + // - plain recursive function calls give correct, automatic push/pop scoping for free (a + // counter-reset's own new instance is naturally "popped" back to whatever the enclosing + // scope had once this call returns), which the layout pass itself cannot offer cheaply + // given how many early-return paths LayoutElement has. See ProcessCounters' own remarks. + ProcessCounters(renderTree, context); + var body = document.Body; var root = body is null ? renderTree : renderTree.Find(body) ?? renderTree; @@ -356,7 +373,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, WordBreakMode.Normal, OverflowWrapMode.Normal, TextOverflowMode.Clip); + 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, 0, TextDirection.Ltr); var cursorY = contentY; var previousBlockMarginBottom = 0f; var suppressNextBlockTopMargin = false; @@ -665,6 +682,34 @@ private static void LayoutElement( flexMainSize, flexCrossSize, propertyName: "width"); + + if (float.IsNaN(specifiedContentWidth) && !isFlexItem) + { + // A definite height plus `aspect-ratio` overrides a block-level box's normal + // "auto width fills the container" default - matching real browsers, and the same + // used-width/used-height "transferred size" relationship `aspect-ratio` already + // establishes in the other direction (see ResolveAspectRatioContentHeight, called + // further down once `height` is auto instead). Only applies when `width` is genuinely + // unauthored (not merely resolved to NaN through a flex/grid override, which already + // has its own, more specific sizing story this must not second-guess). + var heightReferenceForAspectRatio = float.IsNaN(containingHeight) ? flowContainingWidth : containingHeight; + var explicitContentHeight = ResolveAuthoredDimension( + styleMap, + "height", + heightReferenceForAspectRatio, + float.NaN, + borderTop + borderBottom + paddingTop + paddingBottom); + + if (!float.IsNaN(explicitContentHeight)) + { + specifiedContentWidth = ResolveAspectRatioContentWidth( + styleMap, + explicitContentHeight, + borderLeft + borderRight + paddingLeft + paddingRight, + borderTop + borderBottom + paddingTop + paddingBottom); + } + } + ResolveHorizontalMetrics( flowContainingWidth, specifiedContentWidth, @@ -1251,7 +1296,9 @@ private static void LayoutElement( var hasFilter = filterFunctions.Count > 0; var opacity = ParseCssOpacity(styleMap); var hasOpacity = opacity < 1f; - var createsStackingContext = HasExplicitZIndex(styleMap) || hasOpacity || hasTransform || hasFilter; + var clipPathShape = ParseClipPath(styleMap, borderBoxWidth, borderBoxHeight); + var hasClipPath = clipPathShape is not null; + var createsStackingContext = HasExplicitZIndex(styleMap) || hasOpacity || hasTransform || hasFilter || hasClipPath; var boxPaintBuffer = new DisplayList(); @@ -1282,6 +1329,18 @@ private static void LayoutElement( boxPaintBuffer.PushFilter(filterFunctions); } + // `clip-path` wraps the whole element too - background, border, outline, and every + // descendant are all clipped to the shape (unlike the overflow clip below, which excludes + // the border/outline it is nested inside) - nested innermost of the four whole-element + // scopes above, since its shape is expressed in the element's own already-transformed + // local space and should clip the filtered/opacity-composited result, not the other way + // around. + if (hasClipPath) + { + boxPaintBuffer.PushClipShape(clipPathShape!); + } + + PaintBackground(boxPaintBuffer, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); PaintBoxShadows(boxPaintBuffer, box.BoxShadows, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); PaintBorder(boxPaintBuffer, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth, box.BorderRadius, box.BorderStyle); @@ -1310,9 +1369,19 @@ private static void LayoutElement( displayList.InsertRange(paintInsertionIndex, boxPaintBuffer.Commands); - if (TryResolveReplacedElementImage(node, styleMap, flowContainingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect)) + if (TryResolveReplacedElementImage(node, styleMap, flowContainingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect, out var objectFitClipRect)) { + if (objectFitClipRect is { } objectFitClip) + { + displayList.PushClip(objectFitClip, RenderCornerRadii.Zero); + } + displayList.DrawImage(imageRect, image!); + + if (objectFitClipRect is not null) + { + displayList.PopClip(); + } } if (clipsOverflow) @@ -1320,6 +1389,14 @@ private static void LayoutElement( displayList.PopClip(); } + // Closes the clip-path scope opened above, once children (and, for a replaced element, + // its image) have all been emitted - the innermost of the four whole-element scopes, so it + // closes before filter/opacity/transform do. + if (hasClipPath) + { + displayList.PopClip(); + } + // Closes the filter scope opened above, once children (and, for a replaced element, its // image) have all been emitted - nested inside the opacity scope, so it has to close // before that one does. @@ -1852,9 +1929,19 @@ private static void LayoutFlexContainer( displayList.InsertRange(boxPaintInsertIndex, boxPaintBuffer.Commands); - if (TryResolveReplacedElementImage(node, styleMap, containingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect)) + if (TryResolveReplacedElementImage(node, styleMap, containingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect, out var objectFitClipRect)) { + if (objectFitClipRect is { } objectFitClip) + { + displayList.PushClip(objectFitClip, RenderCornerRadii.Zero); + } + displayList.DrawImage(imageRect, image!); + + if (objectFitClipRect is not null) + { + displayList.PopClip(); + } } if (clipsOverflow) @@ -2415,14 +2502,6 @@ private static void LayoutGridContainer( // (and appended), so their paint commands are spliced in before this index instead. var boxPaintInsertIndex = displayList.Commands.Count; - var explicitGridTemplateColumns = ResolveExplicitPropertyValue(node.Ref, node.ComputedStyle, "grid-template-columns"); - var columns = ParseGridTrackListStructured(explicitGridTemplateColumns, containingWidth); - - if (columns.Count == 0) - { - columns.Add(GridTrackSize.Fixed(containingWidth)); - } - var columnGap = ParseGridGap(styleMap, "column-gap", containingWidth, 0) ?? ParseGridGap(styleMap, "gap", containingWidth, 0); var rowGap = ParseGridGap(styleMap, "row-gap", containingWidth, 0) @@ -2432,6 +2511,15 @@ private static void LayoutGridContainer( var gridItems = node.Children .Where(child => child is ElementRenderNode || (child is TextRenderNode textNode && NormalizeWhitespace(textNode.Ref.Data).Length > 0)) .ToList(); + + var explicitGridTemplateColumns = ResolveExplicitPropertyValue(node.Ref, node.ComputedStyle, "grid-template-columns"); + var columns = ParseGridTrackListStructured(explicitGridTemplateColumns, containingWidth, resolvedColumnGap, gridItems.Count); + + if (columns.Count == 0) + { + columns.Add(GridTrackSize.Fixed(containingWidth)); + } + var gridVerticalBorderAndPadding = borderTop + borderBottom + paddingTop + paddingBottom; var containerHeight = ResolveAuthoredDimension(styleMap, "height", containingWidth, float.NaN, gridVerticalBorderAndPadding); var explicitGridTemplateRows = ResolveExplicitPropertyValue(node.Ref, node.ComputedStyle, "grid-template-rows"); @@ -2867,27 +2955,128 @@ private static float GetGridTrackSpanSize(IReadOnlyList tracks, int index /// value - the caller falls back to a single implicit track, the same default a real browser /// gives an unstyled grid container. /// - private static List ParseGridTrackListStructured(string rawValue, float relativeTo) + private static List ParseGridTrackListStructured(string rawValue, float relativeTo, float gap = 0f, int itemCount = 0) { var tracks = new List(); + var trimmed = rawValue?.Trim() ?? string.Empty; - if (string.IsNullOrWhiteSpace(rawValue) || string.Equals(rawValue.Trim(), "none", StringComparison.OrdinalIgnoreCase)) + if (trimmed.Length == 0 || string.Equals(trimmed, "none", StringComparison.OrdinalIgnoreCase)) { return tracks; } - var source = new StringSource(rawValue.Trim()); + var source = new StringSource(trimmed); var parsed = source.ParseTrackList(); if (parsed is not null) { - AppendGridTracks(parsed, relativeTo, tracks); + AppendGridTracks(parsed, relativeTo, tracks, gap, itemCount); + return tracks; + } + + // AngleSharp.Css's own GridParser does not currently recognize the `auto-fill`/`auto-fit` + // repeat-count keywords at all (confirmed empirically: ParseTrackList returns null for the + // whole value, not just that one token) - a genuine upstream gap, worth reporting there, + // but one this renderer still needs to work around locally to support the single most + // common real-world use of `repeat()` (`repeat(auto-fill/auto-fit, ...)` as the entire + // track list). TryParseAutoRepeatTrackList hand-parses just that one shape; anything else + // that also failed to parse (a genuinely malformed value) still falls through to the empty + // list the caller already treats as "no explicit tracks". + if (TryParseAutoRepeatTrackList(trimmed, relativeTo, gap, itemCount, tracks)) + { + return tracks; } return tracks; } - private static void AppendGridTracks(ICssValue value, float relativeTo, List tracks) + /// + /// Hand-parses `repeat(auto-fill, <track-list>)`/`repeat(auto-fit, <track-list>)` as + /// the entire `grid-template-columns`/`-rows` value - locating the keyword by splitting on the + /// first top-level comma (one that is not itself nested inside the repeated track list's own + /// parentheses, e.g. `minmax(100px, 1fr)`'s comma), then delegating the repeated track list's + /// own structural parsing back to AngleSharp.Css's `GridParser` exactly as the normal path does - + /// only the outer auto-fill/auto-fit keyword recognition itself is hand-rolled. + /// + private static bool TryParseAutoRepeatTrackList(string trimmed, float relativeTo, float gap, int itemCount, List tracks) + { + if (!trimmed.StartsWith("repeat(", StringComparison.OrdinalIgnoreCase) || !trimmed.EndsWith(")", StringComparison.Ordinal)) + { + return false; + } + + var inner = trimmed[7..^1]; + var depth = 0; + var commaIndex = -1; + + for (var i = 0; i < inner.Length; i++) + { + switch (inner[i]) + { + case '(': + depth++; + break; + case ')': + depth--; + break; + case ',' when depth == 0: + commaIndex = i; + break; + } + + if (commaIndex >= 0) + { + break; + } + } + + if (commaIndex < 0) + { + return false; + } + + var keyword = inner[..commaIndex].Trim(); + var isAutoFill = string.Equals(keyword, "auto-fill", StringComparison.OrdinalIgnoreCase); + var isAutoFit = string.Equals(keyword, "auto-fit", StringComparison.OrdinalIgnoreCase); + + if (!isAutoFill && !isAutoFit) + { + return false; + } + + var trackListText = inner[(commaIndex + 1)..].Trim(); + var trackSource = new StringSource(trackListText); + var trackDefinition = trackSource.ParseTrackList(); + + if (trackDefinition is null) + { + return false; + } + + var groupMinSize = ResolveGridRepeatGroupMinSize(trackDefinition, relativeTo); + + if (groupMinSize <= 0f || float.IsNaN(relativeTo) || relativeTo <= 0f) + { + AppendGridTracks(trackDefinition, relativeTo, tracks, gap, itemCount); + return true; + } + + var fillCount = Math.Max(1, (int)Math.Floor((relativeTo + gap) / (groupMinSize + gap))); + + if (isAutoFit && itemCount > 0) + { + fillCount = Math.Max(1, Math.Min(fillCount, itemCount)); + } + + for (var i = 0; i < fillCount; i++) + { + AppendGridTracks(trackDefinition, relativeTo, tracks, gap, itemCount); + } + + return true; + } + + private static void AppendGridTracks(ICssValue value, float relativeTo, List tracks, float gap, int itemCount) { switch (value) { @@ -2901,11 +3090,11 @@ private static void AppendGridTracks(ICssValue value, float relativeTo, List - /// `repeat(auto-fill, ...)`/`repeat(auto-fit, ...)` need the container's own available space to - /// compute how many repetitions fit - a genuinely different, container-size-dependent algorithm - /// this renderer does not implement. Falls back to a single repetition (the count `1` never - /// causes a dropped track or a NaN/absurd count), a deliberate, documented scope cut. + /// `repeat(auto-fill, ...)`/`repeat(auto-fit, ...)` resolve their true fill count from the + /// container's own available space, the axis gap, and the repeated track group's own minimum + /// size - the same formula browsers use: floor((available + gap) / (groupMinSize + gap)), + /// clamped to at least one repetition. `auto-fit` additionally collapses empty trailing tracks: + /// the full spec algorithm does this after item placement (an empty track becomes a literal + /// zero-width, gap-less track), which this renderer approximates by capping the fill count at + /// the actual item count up front - close enough for the common single-row/auto-placement case, + /// though it does not reclaim space for an item that itself spans multiple tracks. `auto-fill` + /// keeps every computed track (empty or not), which is the whole difference between the two. /// - private static int ResolveGridRepeatCount(ICssValue countValue) + private static int ResolveGridRepeatCount(ICssValue countValue, ICssValue trackDefinition, float availableSize, float gap, int itemCount) { - var text = countValue.CssText; + var text = countValue.CssText.Trim(); + var isAutoFill = string.Equals(text, "auto-fill", StringComparison.OrdinalIgnoreCase); + var isAutoFit = string.Equals(text, "auto-fit", StringComparison.OrdinalIgnoreCase); + + if (isAutoFill || isAutoFit) + { + if (float.IsNaN(availableSize) || availableSize <= 0f) + { + return 1; + } + + var groupMinSize = ResolveGridRepeatGroupMinSize(trackDefinition, availableSize); + + if (groupMinSize <= 0f) + { + return 1; + } + + var fillCount = Math.Max(1, (int)Math.Floor((availableSize + gap) / (groupMinSize + gap))); + + if (isAutoFit && itemCount > 0) + { + fillCount = Math.Max(1, Math.Min(fillCount, itemCount)); + } + + return fillCount; + } if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count)) { @@ -2945,6 +3165,27 @@ private static int ResolveGridRepeatCount(ICssValue countValue) return 1; } + /// + /// The minimum pixel footprint of one repetition of a `repeat(auto-fill/auto-fit, ...)`'s own + /// track list - a fixed length contributes its own size, `minmax(min, max)` contributes `min` + /// when it is itself a plain length (0 otherwise), and an `fr`/`auto` track contributes 0 (it + /// has no definite minimum to anchor the fill-count formula to) - a deliberate approximation of + /// the spec's own (considerably more involved) automatic-repeat-count algorithm, matching the + /// overwhelmingly common real-world pattern of `repeat(auto-fill/auto-fit, minmax(<length>, ...))`. + /// + private static float ResolveGridRepeatGroupMinSize(ICssValue trackDefinition, float relativeTo) => trackDefinition switch + { + CssTupleValue tuple => tuple.Items.Where(item => item is not null).Sum(item => ResolveGridTrackMinContribution(item!, relativeTo)), + _ => ResolveGridTrackMinContribution(trackDefinition, relativeTo), + }; + + private static float ResolveGridTrackMinContribution(ICssValue value, float relativeTo) => value switch + { + CssMinMaxValue minMax => minMax.Minimum is CssLengthValue ? ResolveGridTrackLength(minMax.Minimum, relativeTo) : 0f, + CssLengthValue => ResolveGridTrackLength(value, relativeTo), + _ => 0f, + }; + private static GridTrackSize ConvertGridTrackSize(ICssValue value, float relativeTo) { switch (value) @@ -3327,6 +3568,19 @@ private static void LayoutWrappedText( var lineHeight = textStyle.FontSize * textStyle.LineHeightMultiplier; var lines = WrapTextRespectingWhiteSpace(context, text, maxWidth, textStyle); + // `-webkit-line-clamp: N` truncates to at most N lines, with an ellipsis appended to the + // last kept line whenever there was more text than fit - the multi-line counterpart to + // `text-overflow: ellipsis`'s single-line truncation just above. The clamped-away lines + // are dropped entirely (not laid out at all, not just clipped/hidden), matching how a real + // browser's `-webkit-line-clamp` removes them from the box's own content height too. + var isClampedLastLine = false; + + if (textStyle.LineClamp > 0 && lines.Count > textStyle.LineClamp) + { + lines = [.. lines.Take(textStyle.LineClamp)]; + isClampedLastLine = true; + } + for (var index = 0; index < lines.Count; index++) { var line = lines[index]; @@ -3338,21 +3592,35 @@ private static void LayoutWrappedText( } // An empty line (a blank `pre`/`pre-wrap`/`pre-line` row from a run of consecutive - // forced breaks) still needs to advance cursorY above, but has nothing to measure/paint. - if (line.Length == 0) + // forced breaks) still needs to advance cursorY above, but has nothing to measure/paint + // - unless it is itself the clamped-to line, which still needs its own ellipsis. + if (line.Length == 0 && !(isClampedLastLine && index == lines.Count - 1)) { continue; } + // `direction: rtl`'s own visual-order reversal (see ParseTextDirection's remarks for + // this renderer's bidi scope) - reversing an already-wrapped line's own word sequence + // so the first logical word ends up rightmost, matching how a real browser lays out a + // pure-RTL paragraph. Deliberately word-level, not full UAX#9 character reordering: + // each word's own internal character order is left untouched, which is correct for a + // non-cursive script and a reasonable approximation for any other. + if (textStyle.Direction == TextDirection.Rtl) + { + line = ReverseWordOrder(line); + } + var lineWidth = MeasureTextWidth(context, line, textStyle); var lineMaxWidth = index == 0 ? Math.Max(0f, maxWidth - firstLineIndent) : maxWidth; + var isLastLine = index == lines.Count - 1; // `text-overflow: ellipsis` is scoped to the single-line case - 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`); a genuinely - // multi-line result here (`lines.Count > 1`) is left as-is, matching that scope cut. - if (textStyle.TextOverflow == TextOverflowMode.Ellipsis && lines.Count == 1 && lineWidth > lineMaxWidth) + // does not define without the non-standard `-webkit-line-clamp` extension handled + // separately above; a genuinely multi-line, non-clamped result here is left as-is. + if ((isClampedLastLine && isLastLine) || + (textStyle.TextOverflow == TextOverflowMode.Ellipsis && lines.Count == 1 && lineWidth > lineMaxWidth)) { line = TruncateWithEllipsis(context, line, lineMaxWidth, textStyle); lineWidth = MeasureTextWidth(context, line, textStyle); @@ -3379,6 +3647,24 @@ private static void LayoutWrappedText( } } + /// + /// Reverses a single already-wrapped line's own word sequence, for `direction: rtl`'s + /// word-level visual reordering - see 's own call site remarks + /// for the scope this covers (and does not). + /// + private static string ReverseWordOrder(string line) + { + var words = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (words.Length <= 1) + { + return line; + } + + Array.Reverse(words); + return string.Join(' ', words); + } + /// /// Splits into display lines, honoring white-space's two layout- /// relevant effects itself cannot: an @@ -3808,7 +4094,7 @@ private static float EstimateFlexItemMainSize(IRenderNode node, float crossSize, var styleMap = GetOrCreateStyleMap(context, element.Ref, element.ComputedStyle); var box = ResolveBoxStyle(styleMap, element.Ref); var contentWidth = Math.Max(1f, crossSize - box.BorderWidth.Left - box.BorderWidth.Right - box.Padding.Left - box.Padding.Right); - var textStyle = ResolveTextStyle(styleMap, new RenderTextStyle(16f, RenderColor.Black, "sans-serif", 1.2f, 400f, false, false, false, RenderColor.Black, global::AngleSharp.Renderer.Rendering.RenderTextDecorationStyle.Solid, TextAlign.Left, 0f, 0f, 0f, [], WhiteSpaceMode.Normal, WordBreakMode.Normal, OverflowWrapMode.Normal, TextOverflowMode.Clip)); + var textStyle = ResolveTextStyle(styleMap, new RenderTextStyle(16f, RenderColor.Black, "sans-serif", 1.2f, 400f, false, false, false, RenderColor.Black, global::AngleSharp.Renderer.Rendering.RenderTextDecorationStyle.Solid, TextAlign.Left, 0f, 0f, 0f, [], WhiteSpaceMode.Normal, WordBreakMode.Normal, OverflowWrapMode.Normal, TextOverflowMode.Clip, 0, TextDirection.Ltr)); var text = NormalizeWhitespace(element.Ref.TextContent ?? string.Empty, textStyle.WhiteSpace); var lineCount = text.Length == 0 ? 0 : WrapTextRespectingWhiteSpace(context, text, contentWidth, textStyle).Count; return lineCount * textStyle.FontSize * textStyle.LineHeightMultiplier; @@ -3822,7 +4108,7 @@ private static float EstimateFlexItemContentWidth(ElementRenderNode element, flo if (text.Length > 0) { - var textStyle = ResolveTextStyle(styleMap, new RenderTextStyle(16f, RenderColor.Black, "sans-serif", 1.2f, 400f, false, false, false, RenderColor.Black, global::AngleSharp.Renderer.Rendering.RenderTextDecorationStyle.Solid, TextAlign.Left, 0f, 0f, 0f, [], WhiteSpaceMode.Normal, WordBreakMode.Normal, OverflowWrapMode.Normal, TextOverflowMode.Clip)); + var textStyle = ResolveTextStyle(styleMap, new RenderTextStyle(16f, RenderColor.Black, "sans-serif", 1.2f, 400f, false, false, false, RenderColor.Black, global::AngleSharp.Renderer.Rendering.RenderTextDecorationStyle.Solid, TextAlign.Left, 0f, 0f, 0f, [], WhiteSpaceMode.Normal, WordBreakMode.Normal, OverflowWrapMode.Normal, TextOverflowMode.Clip, 0, TextDirection.Ltr)); contentWidth = MeasureTextWidth(context, text, textStyle); var maxWidth = ResolveAuthoredDimension(styleMap, "max-width", relativeTo, float.NaN, 0f); if (!float.IsNaN(maxWidth)) @@ -3877,7 +4163,23 @@ private static bool ShouldRenderAsBlock(ICssStyleDeclaration computedStyle, stri }; } - return !string.Equals(tagName, "code", StringComparison.OrdinalIgnoreCase); + // AngleSharp.Css's own UA stylesheet does not give every semantic inline tag (``, + // ``, ``, ``, ...) an explicit `display: inline` rule - `inline` is + // simply CSS's own initial value, so nothing ever needs to set it - which means + // `GetDisplay()` reports an empty string for a plain, unstyled one of these (the same + // "never serialized when nothing in the cascade set it explicitly" pattern already + // documented elsewhere in this file for `list-style-type`/`white-space`/etc.), not the + // literal word "inline". A real, confirmed bug (not hypothetical) fell out of that: this + // fallback used to treat every tag except `` as block-level whenever `display` came + // back empty, so a plain, zero-CSS ``/``/``/... was misidentified as + // block, which in turn made a parent's `hasInlineRun` merge-onto-shared-lines check (see + // `LayoutElement`) never trigger for it - `

    Hello World

    ` rendered "World" on + // its own separate line instead of beside "Hello". `IsKnownInlineElement` already + // enumerates every standard semantic inline tag (including ``, so that tag's own + // prior special case is now fully subsumed) for exactly this "no explicit display, still + // inline by default" situation - reusing it here, rather than only ever excluding ``, + // fixes every one of them at once. + return !(tagName is not null && IsKnownInlineElement(tagName)); } private static bool IsInlineBlock(ICssStyleDeclaration computedStyle) @@ -3925,7 +4227,8 @@ private static RenderTextStyle ResolveTextStyle(Dictionary style var (underline, strikeThrough) = ParseTextDecoration(styleMap, inherited.Underline, inherited.StrikeThrough); var decorationColor = ParseColor(styleMap.TryGetValue("text-decoration-color", out var decorationColorValue) ? decorationColorValue : null, color); var decorationStyle = ParseTextDecorationStyle(styleMap, inherited.DecorationStyle); - var textAlign = ParseTextAlign(styleMap, inherited.TextAlign); + var direction = ParseTextDirection(styleMap, inherited.Direction); + var textAlign = ParseTextAlign(styleMap, inherited.TextAlign, direction); var letterSpacing = ParseLength(styleMap, "letter-spacing", inherited.FontSize, inherited.LetterSpacing, allowAuto: false); var textIndent = ParseLength(styleMap, "text-indent", inherited.FontSize, 0f, allowAuto: false); var verticalAlignOffset = ParseVerticalAlign(styleMap, fontSize); @@ -3934,8 +4237,28 @@ private static RenderTextStyle ResolveTextStyle(Dictionary style var wordBreak = ParseWordBreak(styleMap, inherited.WordBreak); var overflowWrap = ParseOverflowWrap(styleMap, inherited.OverflowWrap); var textOverflow = ParseTextOverflow(styleMap); + var lineClamp = ParseLineClamp(styleMap); + + return new RenderTextStyle(fontSize, color, fontFamily, lineHeight, fontWeight, isItalic, underline, strikeThrough, decorationColor, decorationStyle, textAlign, letterSpacing, textIndent, verticalAlignOffset, textShadows, whiteSpace, wordBreak, overflowWrap, textOverflow, lineClamp, direction); + } + + /// + /// `direction` (`ltr`/`rtl`) is inherited, the same as `white-space`/`word-break` above. + /// `unicode-bidi` (which controls whether `direction` establishes a new embedding level versus + /// only affecting the current one) is not separately modeled - this renderer's own bidi support + /// is a single, document-wide-per-element `direction` flag driving word-order reversal and the + /// `start`/`end` text-align resolution below, not a full Unicode Bidirectional Algorithm (UAX#9) + /// implementation with embedding levels/overrides - a deliberate, documented scope cut matching + /// "supported by current architecture" rather than a from-scratch bidi engine. + /// + private static TextDirection ParseTextDirection(Dictionary styleMap, TextDirection inherited) + { + if (!styleMap.TryGetValue("direction", out var value) || string.IsNullOrWhiteSpace(value)) + { + return inherited; + } - return new RenderTextStyle(fontSize, color, fontFamily, lineHeight, fontWeight, isItalic, underline, strikeThrough, decorationColor, decorationStyle, textAlign, letterSpacing, textIndent, verticalAlignOffset, textShadows, whiteSpace, wordBreak, overflowWrap, textOverflow); + return string.Equals(value.Trim(), "rtl", StringComparison.OrdinalIgnoreCase) ? TextDirection.Rtl : TextDirection.Ltr; } /// @@ -4000,6 +4323,36 @@ private static TextOverflowMode ParseTextOverflow(Dictionary sty : TextOverflowMode.Clip; } + /// + /// `-webkit-line-clamp: <N>` (a long-standing, still-`-webkit-`-prefixed-only de facto + /// standard for multi-line truncation with an ellipsis on the final visible line - there is no + /// unprefixed equivalent in wide use, and it is what `text-overflow: ellipsis` itself has no + /// standard multi-line behavior without) - like `text-overflow`, never inherited (re-derived + /// fresh per element) and only meaningful on a box that itself clips overflow; unlike + /// `text-overflow`, this renderer does not additionally require `display: -webkit-box`/ + /// `-webkit-box-orient: vertical` (the other two properties real browsers also require) - a + /// deliberate, documented simplification, since this renderer has no multi-column/flex-like + /// `-webkit-box` layout mode of its own for those to meaningfully interact with; `overflow: + /// hidden` plus a positive integer is treated as sufficient signal on its own. Returns 0 + /// (unclamped) when unset, non-positive, or not a plain integer. + /// + private static int ParseLineClamp(Dictionary styleMap) + { + if (!ShouldClipOverflow(styleMap)) + { + return 0; + } + + if (!styleMap.TryGetValue("-webkit-line-clamp", out var value) || string.IsNullOrWhiteSpace(value)) + { + return 0; + } + + return int.TryParse(value.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var lineClamp) && lineClamp > 0 + ? lineClamp + : 0; + } + /// /// white-space is inherited (confirmed empirically - an unset value on a child reports /// empty, not the CSS initial normal, the same "never serialized when nothing in the @@ -4084,11 +4437,19 @@ _ when ParseLengthValue(normalized, float.NaN, allowAuto: false) is var offset & }; } - private static TextAlign ParseTextAlign(Dictionary styleMap, TextAlign defaultValue) + private static TextAlign ParseTextAlign(Dictionary styleMap, TextAlign defaultValue, TextDirection direction) { + var startAlign = direction == TextDirection.Rtl ? TextAlign.Right : TextAlign.Left; + var endAlign = direction == TextDirection.Rtl ? TextAlign.Left : TextAlign.Right; + if (!styleMap.TryGetValue("text-align", out var value) || string.IsNullOrWhiteSpace(value)) { - return defaultValue; + // `text-align`'s own CSS initial value is `start`, which - unlike this map's other + // properties - genuinely depends on `direction` rather than always meaning "left": + // `defaultValue` here is really only ever `TextAlign.Left`, the hardcoded assumption + // every call site made before `direction` was resolved at all, so it is superseded by + // `startAlign` whenever this element's own resolved direction is `rtl`. + return direction == TextDirection.Rtl ? startAlign : defaultValue; } var normalized = value.Trim().ToLowerInvariant(); @@ -4097,9 +4458,9 @@ private static TextAlign ParseTextAlign(Dictionary styleMap, Tex { "center" => TextAlign.Center, "right" => TextAlign.Right, - "end" => TextAlign.Right, + "end" => endAlign, "left" => TextAlign.Left, - "start" => TextAlign.Left, + "start" => startAlign, _ => defaultValue, }; } @@ -4518,6 +4879,9 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddLengthProperty(map, style, "min-height", explicitDeclarations); AddLengthProperty(map, style, "max-height", explicitDeclarations); AddIfPresent(map, "box-sizing", style.GetBoxSizing()); + AddIfPresent(map, "object-fit", style.GetPropertyValue("object-fit")); + AddIfPresent(map, "object-position", style.GetPropertyValue("object-position")); + AddIfPresent(map, "clip-path", style.GetPropertyValue("clip-path")); var explicitAspectRatio = explicitDeclarations?.GetPropertyValue("aspect-ratio"); AddIfPresent(map, "aspect-ratio", string.IsNullOrWhiteSpace(explicitAspectRatio) ? style.GetPropertyValue("aspect-ratio") @@ -4566,6 +4930,9 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "list-style-type", style.GetPropertyValue("list-style-type")); AddIfPresent(map, "list-style-position", style.GetPropertyValue("list-style-position")); + AddIfPresent(map, "counter-reset", style.GetPropertyValue("counter-reset")); + AddIfPresent(map, "counter-increment", style.GetPropertyValue("counter-increment")); + AddIfPresent(map, "counter-set", style.GetPropertyValue("counter-set")); AddIfPresent(map, "overflow-x", style.GetPropertyValue("overflow-x")); AddIfPresent(map, "overflow-y", style.GetPropertyValue("overflow-y")); @@ -4683,13 +5050,31 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "text-decoration-color", style.GetPropertyValue("text-decoration-color")); AddIfPresent(map, "text-decoration-style", style.GetPropertyValue("text-decoration-style")); AddIfPresent(map, "text-align", style.GetPropertyValue("text-align")); + AddIfPresent(map, "direction", style.GetPropertyValue("direction")); AddLengthProperty(map, style, "text-indent"); AddIfPresent(map, "vertical-align", style.GetVerticalAlign()); AddLengthProperty(map, style, "letter-spacing"); AddIfPresent(map, "line-height", style.GetLineHeight()); AddIfPresent(map, "color", style.GetColor()); - AddIfPresent(map, "white-space", style.GetPropertyValue("white-space")); + // A real