diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 6a8c8a1..a8483d3 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -2,8 +2,8 @@ "version": 1, "isRoot": true, "tools": { - "fallout.cli": { - "version": "11.0.18", + "fallout.globaltool": { + "version": "10.4.0", "commands": [ "fallout" ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3f20f7..199c766 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,10 @@ env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} DOCS_PATH: ${{ secrets.DOCS_PATH }} DOCS_BRANCH: ${{ secrets.DOCS_BRANCH }} + # Snapshot baselines are per-platform and committed; a missing one is a gap, never a pass. + ANGLESHARP_SNAPSHOT_STRICT: 1 + ANGLESHARP_VERSION: 1.8.0 + ANGLESHARP_CSS_VERSION: 1.1.0 jobs: can_document: @@ -44,73 +48,74 @@ jobs: cd $DOCS_PATH npx pilet publish --fresh --url https://feed.piral.cloud/api/v1/pilet/anglesharp --api-key ${{ secrets.PIRAL_FEED_KEY }} - linux: - runs-on: ubuntu-22.04 + # Every platform that has committed snapshot baselines has to run them. Skia rasterizes + # glyphs through a different scaler per platform, so a baseline is only ever exercised by + # the platform it was recorded on. The runner images are pinned on purpose: bumping one + # changes the rasterization and requires regenerating the baselines via `update-snapshots`. + test: + name: Test (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - name: linux + os: ubuntu-22.04 + - name: windows + os: windows-2022 + - name: macos + os: macos-14 steps: - uses: actions/checkout@v5 - - name: Install deterministic fonts for snapshots - run: | - sudo apt-get update - sudo apt-get install -y fonts-dejavu-core fontconfig - fc-cache -f - fc-match "DejaVu Serif" - fc-match "DejaVu Sans" - fc-match "DejaVu Sans Mono" - fc-list | grep -q "DejaVu Serif" - fc-list | grep -q "DejaVu Sans" - fc-list | grep -q "DejaVu Sans Mono" - - name: Setup dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: | + dotnet-version: | + 8.0.x 10.0.x - - name: Build - env: - ANGLESHARP_SNAPSHOT_STRICT: 1 - run: ./build.sh -AngleSharpVersion 1.5.0 + - name: Test + if: runner.os != 'Windows' + run: ./build.sh -Target RunUnitTests -AngleSharpVersion $ANGLESHARP_VERSION -AngleSharpCssVersion $ANGLESHARP_CSS_VERSION + + - name: Test + if: runner.os == 'Windows' + run: .\build.ps1 -Target RunUnitTests -AngleSharpVersion $env:ANGLESHARP_VERSION -AngleSharpCssVersion $env:ANGLESHARP_CSS_VERSION - name: Upload visual assets if: always() uses: actions/upload-artifact@v4 with: - name: linux-visual-assets + name: visual-assets-${{ matrix.name }} path: | src/AngleSharp.Renderer.Tests/verification-assets/** src/AngleSharp.Renderer.Tests/failure-assets/** if-no-files-found: ignore - windows: - runs-on: windows-latest + # Packaging and publishing stay on Windows, but only once every platform is green. + package: + needs: [test] + runs-on: windows-2022 steps: - uses: actions/checkout@v5 - + - name: Setup dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: | + dotnet-version: | + 8.0.x 10.0.x - name: Build run: | if ($env:GITHUB_REF -eq "refs/heads/main") { - .\build.ps1 -Target Publish -AngleSharpVersion 1.5.0 + .\build.ps1 -Target Publish -AngleSharpVersion $env:ANGLESHARP_VERSION -AngleSharpCssVersion $env:ANGLESHARP_CSS_VERSION } elseif ($env:GITHUB_REF -eq "refs/heads/devel") { - .\build.ps1 -Target PrePublish -AngleSharpVersion 1.5.0 + .\build.ps1 -Target PrePublish -AngleSharpVersion $env:ANGLESHARP_VERSION -AngleSharpCssVersion $env:ANGLESHARP_CSS_VERSION } else { - .\build.ps1 -AngleSharpVersion 1.5.0 + .\build.ps1 -AngleSharpVersion $env:ANGLESHARP_VERSION -AngleSharpCssVersion $env:ANGLESHARP_CSS_VERSION } - - - name: Upload visual assets - if: always() - uses: actions/upload-artifact@v4 - with: - name: windows-visual-assets - path: | - src/AngleSharp.Renderer.Tests/verification-assets/** - src/AngleSharp.Renderer.Tests/failure-assets/** - if-no-files-found: ignore diff --git a/.github/workflows/update-snapshots.yml b/.github/workflows/update-snapshots.yml new file mode 100644 index 0000000..5bce9e1 --- /dev/null +++ b/.github/workflows/update-snapshots.yml @@ -0,0 +1,137 @@ +name: Update Snapshots + +# Visual baselines are platform specific, so a contributor can only ever regenerate the one +# for the machine they are sitting at. This workflow renders them on all supported platforms +# and pushes the complete set back, which is the only way to keep the matrix in sync. +on: + workflow_dispatch: + inputs: + commit: + description: "Commit the regenerated baselines back to the branch" + type: boolean + default: true + +permissions: + contents: write + +env: + ANGLESHARP_VERSION: 1.8.0 + ANGLESHARP_CSS_VERSION: 1.1.0 + +jobs: + # Keep this matrix identical to the `test` matrix in ci.yml, images included - a baseline + # recorded on one image is not valid for another. + render: + name: Render (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - name: linux + os: ubuntu-22.04 + - name: windows + os: windows-2022 + - name: macos + os: macos-14 + + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.ref }} + + - name: Setup dotnet + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Render baselines + env: + ANGLESHARP_SNAPSHOT_UPDATE: 1 + run: > + dotnet test src/AngleSharp.Renderer.Tests/AngleSharp.Renderer.Tests.csproj + --framework net8.0 + --filter "Category=Visual" + -p:AngleSharpVersion=${{ env.ANGLESHARP_VERSION }} + -p:AngleSharpCssVersion=${{ env.ANGLESHARP_CSS_VERSION }} + + - name: Upload baselines + uses: actions/upload-artifact@v4 + with: + name: baselines-${{ matrix.name }} + path: src/AngleSharp.Renderer.Tests/verification-assets/*.${{ matrix.name }}.png + if-no-files-found: error + + collect: + name: Collect and commit + needs: [render] + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.ref }} + + - name: Setup dotnet + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Download baselines + uses: actions/download-artifact@v4 + with: + pattern: baselines-* + merge-multiple: true + path: src/AngleSharp.Renderer.Tests/verification-assets + + - name: Verify the platform matrix is complete + run: > + dotnet test src/AngleSharp.Renderer.Tests/AngleSharp.Renderer.Tests.csproj + --framework net8.0 + --filter "Category=SnapshotCoverage" + -p:AngleSharpVersion=${{ env.ANGLESHARP_VERSION }} + -p:AngleSharpCssVersion=${{ env.ANGLESHARP_CSS_VERSION }} + + - name: Summarize changes + run: | + { + echo "### Regenerated baselines" + echo + if git diff --quiet --exit-code -- src/AngleSharp.Renderer.Tests/verification-assets; then + echo "No baseline changed." + else + echo '```' + git diff --stat -- src/AngleSharp.Renderer.Tests/verification-assets + echo '```' + fi + echo + echo "Untracked:" + echo '```' + git ls-files --others --exclude-standard -- src/AngleSharp.Renderer.Tests/verification-assets + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Commit baselines + if: inputs.commit + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -- src/AngleSharp.Renderer.Tests/verification-assets + if git diff --cached --quiet --exit-code; then + echo "Baselines are already up to date." + exit 0 + fi + git commit -m "Regenerate visual snapshot baselines for all platforms" + git push origin HEAD:${{ github.ref_name }} + + - name: Upload combined baselines + if: ${{ !inputs.commit }} + uses: actions/upload-artifact@v4 + with: + name: baselines-all + path: src/AngleSharp.Renderer.Tests/verification-assets/*.png diff --git a/AGENTS.md b/AGENTS.md index 428c9a0..94cae8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ The repo also includes build scripts that drive the Fallout bootstrapper: ./build.cmd ``` -The main renderer project targets `net8.0` and `net10.0`. The test project targets `net8.0`. +The main renderer project targets `net8.0` and `net10.0`; the test project targets both as well, so both runtimes are exercised. ## Architecture @@ -36,12 +36,40 @@ The renderer is intentionally split into a small number of layers: - `HtmlRenderer` builds a display list from the AngleSharp render tree and computed styles. - `DisplayList` is the backend-agnostic command model. -- `SkiaRenderBackend` turns the display list into a PNG using SkiaSharp. +- `SkiaRenderBackend` turns the display list into a PNG using SkiaSharp, and implements `ITextMeasurer` so layout measures with the very typefaces it paints with. Both paths resolve fonts through `SkiaTextShaping`, which exists to keep them from drifting apart. +- Font resolution lives in `SkiaTextShaping.CreateTypeface` and walks the CSS family list in order: generic families map to the bundled fonts, named families resolve only when actually installed, and an exhausted list falls back to the bundled sans-serif. Do not resolve named families with `SKTypeface.FromFamilyName` - it substitutes the host's default for an unknown family instead of returning null, which swallows the rest of the fallback list and makes output depend on the machine. Availability goes through a case-insensitive index of the installed families, because Skia's own lookup is case sensitive on Linux but not on Windows. +- `ITextMeasurer` is the seam between layout and rasterization. Line breaking, text alignment and table column widths all go through it; a renderer built with a custom measurer lays out against that measurer. Never reintroduce a font-independent width heuristic here - it silently decouples layout from what is drawn. - `HtmlRenderOptions` holds viewport and text defaults. - `AngleSharp.Css` provides the render tree and computed-style data used by the renderer. +- `SvgRasterizer` (in `Skia/`) turns SVG content into PNG bytes without a second SVG parser: it walks the DOM AngleSharp already produced and paints directly with SkiaSharp (`Skia/Svg/`: `SvgElementRenderer` walks the tree, `SvgPathDataParser`/`SvgTransformParser`/`SvgColorParsing` decode the `d`/`transform`/paint attributes, `SvgPaintState` carries inherited fill/stroke/opacity down the tree). The PNG it produces flows into an ordinary `RenderedImage` afterward, so `DrawImageCommand` and `SkiaRenderBackend.DrawImage` need no SVG-specific handling. + +Font handling resolves each entry of a `font-family` list in order, and the first usable one wins: + +1. Generic families (`serif`, `sans-serif`, `monospace`, plus `cursive`, `fantasy`, `system-ui` and the `ui-*` aliases) always come from the fonts bundled in `Resources/Fonts`. They are keywords, so an `@font-face` rule cannot take them over, and they are what keeps snapshots reproducible. +2. `@font-face` declarations, collected per document by `FontFaceLoader` and carried on `DisplayList.Fonts`. Sources are tried in declaration order; `local()` resolves against the installed fonts and `url()` against `data:` URIs, or the network when - and only when - the browsing context has an `IDocumentLoader` configured, mirroring how images are handled. WOFF and WOFF2 are rejected up front because Skia cannot decode them. +3. Installed families, which depend on the host and are therefore not safe to assert in snapshots. +4. The bundled sans-serif, as the last resort. + +Table spans: a cell covers the columns and rows it spans, and a spanning cell's height is shared across the rows it covers rather than imposed on each of them. With `border-collapse: collapse` each cell paints only its top and left edge and the table adds the frame, so shared edges are drawn once and no rule is painted across a spanning cell. Cell content honours `vertical-align` (`top`, `middle`, `bottom`), defaulting to the `middle` that AngleSharp.Css resolves for cells; `baseline` is treated as `top`, since baselines are not aligned across a row. Note that on a cell `vertical-align` positions the content box, which is a different meaning from the inline shift `ParseVerticalAlign` applies to `super`, `sub` and friends. 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, 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. + +Supported elements: `rect`/`circle`/`ellipse`/`line`/`polyline`/`polygon`/`path` (full path-data grammar including elliptical arcs, via `SKPath.ArcTo`'s SVG-shaped overload; every geometry attribute - `x`/`y`/`width`/`height`/`cx`/`cy`/`r`/`rx`/`ry`/`stroke-width` - accepts percentages, resolved against the current `SvgViewport` via `SvgLength`, per axis for axis-specific properties and against the viewport diagonal for the rest), `g`/`a` grouping with `transform`, `use` (cycle-guarded in `SvgRenderContext.ActiveUseReferences`; referencing a `symbol` or nested `svg` establishes a new viewport sized from the `use`'s own `width`/`height`, not just a translated copy), a nested `` establishing its own sub-viewport (`x`/`y`/`width`/`height`/`viewBox`/`preserveAspectRatio`, clipped to its own box), `text`/`tspan` (via `SkiaTextShaping.CreateTypeface`, the same font-resolution path HTML text uses), `clipPath` (shape-union clipping via `SKCanvas.ClipPath`), `mask` (luminance masking via `SKCanvas.SaveLayer` + `SKColorFilter.CreateLumaColor()` + `SKBlendMode.DstIn`, honouring its own `maskUnits`/`x`/`y`/`width`/`height` region - default `-10%/-10%/120%/120%` of the masked element's bounding box, computed by `SvgGeometry.ComputeBounds` - and `maskContentUnits="objectBoundingBox"`), `linearGradient`/`radialGradient`/`pattern` as `fill`/`stroke` paint servers (`SvgGradientBuilder`/`SvgPatternBuilder`; `objectBoundingBox`/`userSpaceOnUse`, `gradientTransform`/`patternTransform`, `spreadMethod`, `href`/`xlink:href` inheritance chains; a pattern's own content is rendered once into a tile bitmap via the same `SvgElementRenderer.Render` entry point the root `` uses, then repeated with `SKShader.CreateImage`), `filter` (`SvgFilterBuilder`: a primitive-chain interpreter for `feGaussianBlur`/`feOffset`/`feMerge`/`feColorMatrix`/`feDropShadow`, applied via `SKCanvas.SaveLayer` with an `SKImageFilter`; an unsupported primitive passes its input through unchanged instead of breaking the chain), `currentColor` (resolves against the CSS `color` property, itself inherited like any other paint property - including when set directly on the root ``, which `SvgElementRenderer.Render` resolves before walking children, since nothing else ever visits the root itself), `preserveAspectRatio` (`meet`/`slice` and all nine alignment keywords - `SvgViewBoxMapping`, shared by the root ``, nested ``, and `symbol`), and SVG-internal ` + +
+ + + """); + + var element = document.QuerySelector("#box"); + Assert.NotNull(element); + + var rect = element!.GetBoundingClientRect(); + var rects = element.GetClientRects(); + + Assert.Equal(54d, rect.Width); + Assert.Equal(34d, rect.Height); + Assert.Equal(1, rects.Length); + Assert.Equal(2, element.GetClientLeft()); + Assert.Equal(2, element.GetClientTop()); + Assert.Equal(50, element.GetClientWidth()); + Assert.Equal(30, element.GetClientHeight()); + Assert.Equal(54, element.GetOffsetWidth()); + Assert.Equal(34, element.GetOffsetHeight()); + Assert.Equal(50, element.GetScrollWidth()); + Assert.Equal(30, element.GetScrollHeight()); + } + + [Fact] + public async Task GetClientRects_ReturnsEmpty_WhenElementHasNoLayoutBox() + { + var document = await ParseAsync(""" + + + + + + + """); + + var element = document.QuerySelector("#hidden"); + Assert.NotNull(element); + + var rect = element!.GetBoundingClientRect(); + var rects = element.GetClientRects(); + + Assert.Equal(0, rects.Length); + Assert.Equal(0d, rect.Width); + Assert.Equal(0d, rect.Height); + Assert.Equal(0, element.GetOffsetWidth()); + Assert.Equal(0, element.GetOffsetHeight()); + } + + [Fact] + public async Task OffsetParent_AndOffsets_AreResolvedFromPositionedAncestor() + { + var document = await ParseAsync(""" + + + +
+
+
+ + + """); + + var parent = document.QuerySelector("#parent"); + var child = document.QuerySelector("#child"); + + Assert.NotNull(parent); + Assert.NotNull(child); + + Assert.Same(parent, child!.GetOffsetParent()); + Assert.Equal(10, child.GetOffsetLeft()); + Assert.Equal(10, child.GetOffsetTop()); + } + + [Fact] + public async Task OffsetParent_IsNull_ForFixedPositionedElements() + { + var document = await ParseAsync(""" + + + +
+ + + """); + + var child = document.QuerySelector("#child"); + Assert.NotNull(child); + + Assert.Null(child!.GetOffsetParent()); + } + + [Fact] + public async Task ScrollMetrics_IncludeOverflowingDescendants() + { + var document = await ParseAsync(""" + + + +
+
+
+ + + """); + + var viewport = document.QuerySelector("#viewport"); + Assert.NotNull(viewport); + + Assert.Equal(40, viewport!.GetClientWidth()); + Assert.Equal(20, viewport.GetClientHeight()); + Assert.True(viewport.GetScrollWidth() > viewport.GetClientWidth()); + Assert.True(viewport.GetScrollHeight() > viewport.GetClientHeight()); + } + + [Fact] + public async Task ScrollPositions_AreMutable_AndClamped() + { + var document = await ParseAsync(""" + + + +
+
+
+ + + """); + + var viewport = document.QuerySelector("#viewport"); + Assert.NotNull(viewport); + + var maxLeft = viewport!.GetScrollWidth() - viewport.GetClientWidth(); + var maxTop = viewport.GetScrollHeight() - viewport.GetClientHeight(); + Assert.True(maxLeft > 0); + Assert.True(maxTop > 0); + + viewport.SetScrollLeft(500); + viewport.SetScrollTop(500); + Assert.Equal(maxLeft, viewport.GetScrollLeft()); + Assert.Equal(maxTop, viewport.GetScrollTop()); + + viewport.ScrollTo(-10, -5); + Assert.Equal(0d, viewport.GetScrollLeft()); + Assert.Equal(0d, viewport.GetScrollTop()); + + viewport.ScrollBy(maxLeft + 20, maxTop + 20); + Assert.Equal(maxLeft, viewport.GetScrollLeft()); + Assert.Equal(maxTop, viewport.GetScrollTop()); + + viewport.Scroll(new ScrollToOptions { Left = 5, Top = 7 }); + Assert.Equal(5d, viewport.GetScrollLeft()); + Assert.Equal(7d, viewport.GetScrollTop()); + + viewport.ScrollBy(new ScrollToOptions { Left = 3, Top = 4 }); + Assert.Equal(8d, viewport.GetScrollLeft()); + Assert.Equal(11d, viewport.GetScrollTop()); + + viewport.Scroll(2, 3); + Assert.Equal(2d, viewport.GetScrollLeft()); + Assert.Equal(3d, viewport.GetScrollTop()); + } + + [Fact] + public async Task ScrollIntoView_ScrollsScrollableAncestor() + { + var document = await ParseAsync(""" + + + +
+
+
+ + + """); + + var viewport = document.QuerySelector("#viewport"); + var target = document.QuerySelector("#target"); + + Assert.NotNull(viewport); + Assert.NotNull(target); + + target!.ScrollIntoView(); + + Assert.True(viewport!.GetScrollLeft() > 0d); + Assert.True(viewport.GetScrollTop() > 0d); + } + + [Fact] + public async Task ScrollIntoView_RespectsBooleanAndOptionsVariants() + { + var document = await ParseAsync(""" + + + +
+
+
+ + + """); + + var viewport = document.QuerySelector("#viewport"); + var target = document.QuerySelector("#target"); + + Assert.NotNull(viewport); + Assert.NotNull(target); + + target!.ScrollIntoView(false); + var bottomAlignedTop = viewport!.GetScrollTop(); + + viewport.ScrollTo(0, 0); + target.ScrollIntoView(new ScrollIntoViewOptions + { + Block = ScrollLogicalPosition.Center, + Inline = ScrollLogicalPosition.Center, + }); + + Assert.True(bottomAlignedTop >= viewport.GetScrollTop()); + Assert.True(viewport.GetScrollLeft() > 0d); + Assert.True(viewport.GetScrollTop() > 0d); + } + + [Fact] + public async Task ScrollPositions_AreIsolatedPerBrowsingContext() + { + const string html = """ + + + +
+
+
+ + + """; + + var contextA = BrowsingContext.New(CreateConfiguration(120, 80)); + var contextB = BrowsingContext.New(CreateConfiguration(120, 80)); + + var documentA = await contextA.OpenAsync(request => request.Content(html)); + var documentB = await contextB.OpenAsync(request => request.Content(html)); + + var viewportA = documentA.QuerySelector("#viewport"); + var viewportB = documentB.QuerySelector("#viewport"); + + Assert.NotNull(viewportA); + Assert.NotNull(viewportB); + + viewportA!.SetScrollLeft(25); + viewportA.SetScrollTop(15); + + Assert.True(viewportA.GetScrollLeft() > 0d); + Assert.True(viewportA.GetScrollTop() > 0d); + Assert.Equal(0d, viewportB!.GetScrollLeft()); + Assert.Equal(0d, viewportB.GetScrollTop()); + } + + [Fact] + public async Task DomHarness_RaisesPaintInvalidated_OnInteractionChanges() + { + var context = BrowsingContext.New(CreateConfiguration(160, 100)); + var document = await context.OpenAsync(request => request.Content(""" + + + +
+
+
+ + + """)); + + var harness = context.GetDomHarness(); + var viewport = document.QuerySelector("#viewport"); + Assert.NotNull(viewport); + + var invalidatedCount = 0; + harness.PaintInvalidated += (_, _) => invalidatedCount++; + + viewport!.SetScrollLeft(20); + viewport.SetScrollTop(10); + harness.MousePosition = (20d, 20d); + + Assert.Same(viewport, harness.HoveredElement); + + Assert.True(invalidatedCount >= 3); + } + + [Fact] + public async Task DomHarness_PaintsOnAssignedRenderDevice() + { + var context = BrowsingContext.New(CreateConfiguration(210, 130)); + _ = await context.OpenAsync(request => request.Content("
")); + + var harness = context.GetDomHarness(); + var image = harness.PaintToPng(); + + Assert.Equal(210, image.Width); + Assert.Equal(130, image.Height); + } + + [Fact] + public async Task CaretPositionFromPoint_ReturnsCaretInTextNode() + { + var document = await ParseAsync(""" + + + +

hello

+ + + """); + + var caret = document.CaretPositionFromPoint(22d, 10d); + + Assert.NotNull(caret); + Assert.IsAssignableFrom(caret!.OffsetNode); + Assert.InRange(caret.Offset, 1, 3); + + var rect = caret.GetClientRect(); + Assert.True(rect.Height > 0d); + } + + [Fact] + public async Task CaretPositionFromPoint_ReturnsNull_OutsideRenderedContent() + { + var document = await ParseAsync(""" + + + +

hello

+ + + """); + + var caret = document.CaretPositionFromPoint(-100d, -100d); + + Assert.Null(caret); + } + + private static async Task ParseAsync(string html) + { + var context = BrowsingContext.New(CreateConfiguration(240, 160)); + return await context.OpenAsync(request => request.Content(html)); + } + + private static IConfiguration CreateConfiguration(int width, int height) + { + return Configuration.Default + .WithCss() + .WithRenderDevice(new DefaultRenderDevice + { + ViewPortWidth = width, + ViewPortHeight = height, + DeviceWidth = width, + DeviceHeight = height, + FontSize = 16, + }); + } +} diff --git a/src/AngleSharp.Renderer.Tests/FontFaceTests.cs b/src/AngleSharp.Renderer.Tests/FontFaceTests.cs new file mode 100644 index 0000000..00efac0 --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/FontFaceTests.cs @@ -0,0 +1,206 @@ +namespace AngleSharp.Renderer.Tests; + +using AngleSharp; +using AngleSharp.Css; +using AngleSharp.Renderer.Rendering; +using AngleSharp.Renderer.Skia; + +using SkiaSharp; + +/// +/// Covers @font-face handling. The declared faces are built from the fonts bundled with the +/// renderer, so the expected result can be stated as "this has to measure like the bundled serif", +/// which holds on every platform. +/// +public sealed class FontFaceTests +{ + private const string Sample = "Handgloves quick brown fox"; + + private static readonly string SerifDataUri = BuildDataUri("DejaVuSerif.ttf"); + private static readonly string MonoDataUri = BuildDataUri("DejaVuSansMono.ttf"); + + [Fact] + public async Task EmbeddedFace_IsUsedForTheDeclaredFamily() + { + var width = await MeasureAsync( + $"@font-face {{ font-family: 'Embedded'; src: url({SerifDataUri}) format('truetype'); }}", + "Embedded"); + + Assert.Equal(await MeasureAsync(string.Empty, "serif"), width, tolerance: 0.01f); + Assert.True(Math.Abs(await MeasureAsync(string.Empty, "sans-serif") - width) > 0.01f, + "The embedded face measured like the default font, so it was not used."); + } + + [Fact] + public async Task EmbeddedFace_TakesPrecedenceOverTheFallbackEntry() + { + var width = await MeasureAsync( + $"@font-face {{ font-family: 'Embedded'; src: url({SerifDataUri}) format('truetype'); }}", + "'Embedded', monospace"); + + Assert.Equal(await MeasureAsync(string.Empty, "serif"), width, tolerance: 0.01f); + } + + [Fact] + public async Task UnsupportedFormat_FallsThroughToTheNextSource() + { + // Skia cannot decode the compressed wrappers, so a woff2 source has to be skipped rather + // than claim the family and leave it unrenderable. + var width = await MeasureAsync( + "@font-face { font-family: 'Embedded'; " + + $"src: url(data:font/woff2;base64,{Convert.ToBase64String("wOF2padding"u8.ToArray())}) format('woff2'), " + + $"url({SerifDataUri}) format('truetype'); }}", + "Embedded"); + + Assert.Equal(await MeasureAsync(string.Empty, "serif"), width, tolerance: 0.01f); + } + + [Fact] + public async Task UnsupportedFormat_WithoutAlternative_FallsBackToTheNextFamily() + { + var width = await MeasureAsync( + "@font-face { font-family: 'Embedded'; " + + $"src: url(data:font/woff2;base64,{Convert.ToBase64String("wOF2padding"u8.ToArray())}) format('woff2'); }}", + "'Embedded', monospace"); + + Assert.Equal(await MeasureAsync(string.Empty, "monospace"), width, tolerance: 0.01f); + } + + [Fact] + public async Task MissingLocalSource_FallsThroughToTheUrlSource() + { + // local() is only usable when the family is installed, which is why the choice cannot be + // made while loading the rule. + var width = await MeasureAsync( + $"@font-face {{ font-family: 'Embedded'; src: local('DefinitelyMissing'), url({SerifDataUri}) format('truetype'); }}", + "Embedded"); + + Assert.Equal(await MeasureAsync(string.Empty, "serif"), width, tolerance: 0.01f); + } + + [Fact] + public async Task NetworkUrl_IsIgnoredWithoutALoader() + { + // Nothing is fetched unless the browsing context was configured to load resources, so the + // family stays unresolved and the declared fallback takes over. + var width = await MeasureAsync( + "@font-face { font-family: 'Web'; src: url(https://example.com/font.ttf) format('truetype'); }", + "'Web', monospace"); + + Assert.Equal(await MeasureAsync(string.Empty, "monospace"), width, tolerance: 0.01f); + } + + [Fact] + public async Task Weight_SelectsTheMatchingFace() + { + // Two faces under one family, deliberately different files so the choice is observable. + var css = + $"@font-face {{ font-family: 'Duo'; font-weight: 400; src: url({SerifDataUri}) format('truetype'); }}" + + $"@font-face {{ font-family: 'Duo'; font-weight: 700; src: url({MonoDataUri}) format('truetype'); }}"; + + Assert.Equal(await MeasureAsync(string.Empty, "serif"), await MeasureAsync(css, "Duo", weight: 400), tolerance: 0.01f); + Assert.Equal(await MeasureAsync(string.Empty, "monospace"), await MeasureAsync(css, "Duo", weight: 700), tolerance: 0.01f); + } + + [Fact] + public async Task GenericFamilies_CannotBeOverridden() + { + // serif is a keyword, not a family name, so a face may not take it over. + var width = await MeasureAsync( + $"@font-face {{ font-family: 'serif'; src: url({MonoDataUri}) format('truetype'); }}", + "serif"); + + Assert.Equal(await MeasureAsync(string.Empty, "serif"), width, tolerance: 0.01f); + } + + [Fact] + public async Task DeclaredFace_IsExposedOnTheDisplayList() + { + var document = await ParseAsync( + $"@font-face {{ font-family: 'Embedded'; src: local('DefinitelyMissing'), url({SerifDataUri}) format('truetype'); }}", + "Embedded", + 400f); + + var displayList = new HtmlRenderer().BuildDisplayList(document, Device()); + + var face = Assert.Single(displayList.Fonts.Faces); + Assert.Equal("Embedded", face.Family); + Assert.Equal(400f, face.Weight); + Assert.False(face.IsItalic); + Assert.Collection(face.Sources, + source => Assert.Equal("DefinitelyMissing", source.LocalFamily), + source => Assert.NotNull(source.Data)); + } + + [Fact] + public async Task EmbeddedFace_RendersTheSamePixelsAsTheEquivalentGeneric() + { + var embedded = await RenderAsync( + $"@font-face {{ font-family: 'Embedded'; src: url({SerifDataUri}) format('truetype'); }}", + "Embedded"); + var serif = await RenderAsync(string.Empty, "serif"); + + Assert.Equal(serif, embedded); + } + + [Fact] + public void FontFaceSet_Empty_MatchesNothing() + { + Assert.True(FontFaceSet.Empty.IsEmpty); + Assert.False(FontFaceSet.Empty.TryMatch("anything", 400f, false, out _)); + } + + [Fact] + public void FontFaceSet_PrefersAMatchingSlantOverACloserWeight() + { + var upright = new FontFace("F", 400f, false, [FontFaceSource.FromLocal("A")]); + var italic = new FontFace("F", 900f, true, [FontFaceSource.FromLocal("B")]); + var set = new FontFaceSet([upright, italic]); + + Assert.True(set.TryMatch("F", 400f, isItalic: true, out var matched)); + Assert.Same(italic, matched); + + Assert.True(set.TryMatch("f", 400f, isItalic: false, out var uprightMatch)); + Assert.Same(upright, uprightMatch); + } + + private static DefaultRenderDevice Device() => + new() { ViewPortWidth = 480, ViewPortHeight = 140, FontSize = 20f }; + + private static async Task MeasureAsync(string css, string fontFamily, float weight = 400f) + { + var document = await ParseAsync(css, fontFamily, weight); + var displayList = new HtmlRenderer().BuildDisplayList(document, Device()); + var command = displayList.Commands.OfType().First(); + + return new SkiaTextMeasurer().MeasureWidth( + command.Text, + new RenderFont(command.FontFamily, command.FontSize, command.FontWeight, command.IsItalic, command.LetterSpacing, displayList.Fonts)); + } + + private static async Task RenderAsync(string css, string fontFamily) => + new HtmlRenderer().RenderToPng(await ParseAsync(css, fontFamily, 400f), Device()).Data; + + private static async Task ParseAsync(string css, string fontFamily, float weight) + { + var context = BrowsingContext.New(Configuration.Default.WithCss()); + + return await context.OpenAsync(request => request.Content($$""" + + +

{{Sample}}

+ + """)); + } + + private static string BuildDataUri(string fontFileName) + { + var assembly = typeof(HtmlRenderer).Assembly; + using var stream = assembly.GetManifestResourceStream($"AngleSharp.Renderer.Resources.Fonts.{fontFileName}") + ?? throw new InvalidOperationException($"Bundled font not found: {fontFileName}"); + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + + return $"data:font/ttf;base64,{Convert.ToBase64String(buffer.ToArray())}"; + } +} diff --git a/src/AngleSharp.Renderer.Tests/FontFallbackTests.cs b/src/AngleSharp.Renderer.Tests/FontFallbackTests.cs new file mode 100644 index 0000000..19b1642 --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/FontFallbackTests.cs @@ -0,0 +1,137 @@ +namespace AngleSharp.Renderer.Tests; + +using AngleSharp; +using AngleSharp.Css; +using AngleSharp.Renderer.Rendering; +using AngleSharp.Renderer.Skia; + +using SkiaSharp; + +/// +/// CSS resolves a font-family list left to right and skips entries that are not available. These +/// assertions are expressed through measured advance widths, which is observable on every +/// platform, rather than through the typeface identity, which is not. +/// +public sealed class FontFallbackTests +{ + private const string Sample = "Handgloves quick brown fox"; + + private static readonly SkiaTextMeasurer Measurer = new(); + + [Theory] + [InlineData("DefinitelyMissing, serif")] + [InlineData("'DefinitelyMissing', serif")] + [InlineData("\"DefinitelyMissing\", serif")] + [InlineData("DefinitelyMissing, AlsoMissing, serif")] + public void MissingFamilies_FallThroughToTheNextEntry(string fontFamily) + { + // The unavailable leading entries must be skipped, leaving the declared generic in charge. + Assert.Equal(Width("serif"), Width(fontFamily), tolerance: 0.01f); + Assert.True(Math.Abs(Width("sans-serif") - Width(fontFamily)) > 0.01f, + "Expected the serif fallback to differ from sans-serif."); + } + + [Fact] + public void MissingFamily_OnItsOwn_FallsBackToTheDefault() + { + // Nothing in the list is available, so the renderer's own default takes over. It has to be + // that default rather than whatever the host happens to prefer. + Assert.Equal(Width("sans-serif"), Width("DefinitelyMissing"), tolerance: 0.01f); + } + + [Fact] + public void GenericFamilies_AreCaseInsensitive() + { + Assert.Equal(Width("serif"), Width("SERIF"), tolerance: 0.01f); + Assert.Equal(Width("sans-serif"), Width("Sans-Serif"), tolerance: 0.01f); + Assert.Equal(Width("monospace"), Width("MonoSpace"), tolerance: 0.01f); + } + + [Fact] + public void InstalledFamilies_ResolveRegardlessOfCase() + { + var family = FindDistinguishableInstalledFamily(); + + if (family is null) + { + // No installed family renders differently from the bundled default, so there is + // nothing here that could tell a successful match from a fallback. + return; + } + + Assert.Equal(Width(family), Width(family.ToUpperInvariant()), tolerance: 0.01f); + Assert.Equal(Width(family), Width(family.ToLowerInvariant()), tolerance: 0.01f); + } + + [Fact] + public void FallbackOrder_PrefersTheFirstAvailableEntry() + { + Assert.Equal(Width("monospace"), Width("monospace, serif"), tolerance: 0.01f); + Assert.Equal(Width("serif"), Width("serif, monospace"), tolerance: 0.01f); + } + + [Fact] + public async Task MissingFamily_RendersIdenticallyToItsFallback() + { + // Measuring and painting resolve fonts through the same path, so the pixels have to agree + // as well - this is what a stale first entry would break. + var withMissing = await RenderAsync("'DefinitelyMissing', serif"); + var serifOnly = await RenderAsync("serif"); + var sansOnly = await RenderAsync("sans-serif"); + + Assert.Equal(serifOnly, withMissing); + Assert.NotEqual(sansOnly, withMissing); + } + + private static string? FindDistinguishableInstalledFamily() + { + var defaultWidth = Width("sans-serif"); + + // Whatever an unknown name resolves to is useless for this test: if the candidate happens + // to be the host's own substitute font, a failed match is indistinguishable from a hit. + var substituteWidth = Width("DefinitelyMissing"); + + foreach (var family in SKFontManager.Default.FontFamilies) + { + if (string.IsNullOrWhiteSpace(family) || family.Contains(',', StringComparison.Ordinal)) + { + continue; + } + + // Mixed case only proves something when the family name actually has letters to case. + if (!family.Any(char.IsLetter)) + { + continue; + } + + var width = Width(family); + + if (Math.Abs(width - defaultWidth) < 1f || Math.Abs(width - substituteWidth) < 1f) + { + continue; + } + + return family; + } + + return null; + } + + private static float Width(string fontFamily) => + Measurer.MeasureWidth(Sample, new RenderFont(fontFamily, 20f, 400f, false, 0f)); + + private static async Task RenderAsync(string fontFamily) + { + var context = BrowsingContext.New(Configuration.Default.WithCss()); + var document = await context.OpenAsync(request => request.Content($$""" + + +

{{Sample}}

+ + """)); + + return new HtmlRenderer() + .RenderToPng(document, new DefaultRenderDevice { ViewPortWidth = 400, ViewPortHeight = 120, FontSize = 20f }) + .Data; + } +} diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index 1f2c771..7773406 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -1,7 +1,12 @@ using AngleSharp; using AngleSharp.Css; +using AngleSharp.Dom; +using AngleSharp.Io; using AngleSharp.Renderer.Rendering; +using System.Net; +using System.Threading; + namespace AngleSharp.Renderer.Tests; public sealed class HtmlRendererTests @@ -12,10 +17,10 @@ public async Task BuildDisplayList_IncludesBackgroundAndTextCommands() var document = await ParseAsync("

Title

Hello renderer world from AngleSharp.

"); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 360, - Height = 240, + ViewPortWidth = 360, + ViewPortHeight = 240, FontSize = 16f, }); @@ -24,6 +29,229 @@ public async Task BuildDisplayList_IncludesBackgroundAndTextCommands() Assert.Contains(displayList.Commands, command => command is DrawTextCommand); } + [Fact] + public async Task BuildDisplayList_PaintsImageElementsFromCurrentDownload() + { + var document = await ParseAsync(""" + + + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + var imageCommand = Assert.Single(displayList.Commands.OfType()); + Assert.Equal(40f, imageCommand.Rect.Width); + Assert.Equal(20f, imageCommand.Rect.Height); + Assert.NotEmpty(imageCommand.Image.Data); + } + + [Fact] + public async Task BuildDisplayList_CachesHttpImagePayloadPerDocument() + { + var requester = new SingleResponseImageRequester(Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQABAA4A4cQTmwAAAABJRU5ErkJggg==")); + var configuration = Configuration.Default + .WithCss() + .With(requester) + .WithDefaultLoader(new LoaderOptions + { + IsResourceLoadingEnabled = true, + }); + + var document = await ParseAsync(""" + + + + """, configuration, "http://example.test/"); + + var renderer = new HtmlRenderer(); + + var first = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + var second = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + Assert.Single(first.Commands.OfType()); + Assert.Single(second.Commands.OfType()); + Assert.Equal(1, requester.ContentReadSessionCount); + } + + [Fact] + public async Task BuildDisplayList_PaintsSvgImageElementFromDataUri() + { + var document = await ParseAsync(""" + + + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + var imageCommand = Assert.Single(displayList.Commands.OfType()); + Assert.Equal(40f, imageCommand.Rect.Width); + Assert.Equal(40f, imageCommand.Rect.Height); + Assert.NotEmpty(imageCommand.Image.Data); + Assert.Equal("image/png", imageCommand.Image.MimeType); + } + + [Fact] + public async Task BuildDisplayList_PaintsInlineSvgAsReplacedElement() + { + var document = await ParseAsync(""" + + + + + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + var imageCommand = Assert.Single(displayList.Commands.OfType()); + Assert.Equal(10f, imageCommand.Rect.Width); + Assert.Equal(10f, imageCommand.Rect.Height); + Assert.NotEmpty(imageCommand.Image.Data); + } + + [Fact] + public async Task BuildDisplayList_IgnoresInlineSvgTitleTextContent() + { + var document = await ParseAsync(""" + + + This must not be painted as page text + + + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + Assert.Single(displayList.Commands.OfType()); + Assert.DoesNotContain(displayList.Commands.OfType(), command => command.Text.Contains("must not be painted")); + } + + [Fact] + public async Task BuildDisplayList_ParsesLinearGradientBackgrounds() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + var gradientBackground = displayList.Commands + .OfType() + .Single(command => command.Paint is RenderGradientPaint); + + var gradient = Assert.IsType(gradientBackground.Paint).Gradient; + + Assert.Equal(RenderGradientKind.Linear, gradient.Kind); + Assert.Equal(2, gradient.Stops.Count); + Assert.Equal(new RenderColor(255, 0, 0), gradient.Stops[0].Color); + Assert.Equal(new RenderColor(0, 0, 255), gradient.Stops[1].Color); + } + + [Fact] + public async Task BuildDisplayList_ParsesRadialGradientBackgrounds() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + var gradientBackground = displayList.Commands + .OfType() + .Single(command => command.Paint is RenderGradientPaint); + + var gradient = Assert.IsType(gradientBackground.Paint).Gradient; + + Assert.Equal(RenderGradientKind.Radial, gradient.Kind); + Assert.Equal(2, gradient.Stops.Count); + Assert.Equal(new RenderColor(255, 0, 0), gradient.Stops[0].Color); + Assert.Equal(new RenderColor(0, 0, 255), gradient.Stops[1].Color); + } + + [Fact] + public async Task BuildDisplayList_ParsesConicGradientBackgrounds() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + var gradientBackground = displayList.Commands + .OfType() + .Single(command => command.Paint is RenderGradientPaint); + + var gradient = Assert.IsType(gradientBackground.Paint).Gradient; + + Assert.Equal(RenderGradientKind.Conic, gradient.Kind); + Assert.Equal(3, gradient.Stops.Count); + Assert.Equal(new RenderColor(255, 0, 0), gradient.Stops[0].Color); + Assert.Equal(new RenderColor(0, 255, 0), gradient.Stops[1].Color); + Assert.Equal(new RenderColor(0, 0, 255), gradient.Stops[2].Color); + } + [Fact] public async Task BuildDisplayList_PropagatesFontSizeStyleAndDecorationToTextCommands() { @@ -37,10 +265,10 @@ public async Task BuildDisplayList_PropagatesFontSizeStyleAndDecorationToTextCom """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 360, - Height = 240, + ViewPortWidth = 360, + ViewPortHeight = 240, FontSize = 16f, }); @@ -74,10 +302,10 @@ one two three four five six seven eight nine ten eleven twelve """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 220, - Height = 180, + ViewPortWidth = 220, + ViewPortHeight = 180, FontSize = 10f, }); @@ -106,10 +334,10 @@ public async Task BuildDisplayList_AppliesColspanToCellGeometry() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 160, + ViewPortWidth = 240, + ViewPortHeight = 160, FontSize = 16f, }); @@ -124,9 +352,23 @@ public async Task BuildDisplayList_AppliesColspanToCellGeometry() [Fact] public async Task BuildDisplayList_CollapsesAdjacentCellBordersWhenRequested() { - var document = await ParseAsync(""" + var collapsed = await CountCellBorderCommandsAsync("collapse"); + var separate = await CountCellBorderCommandsAsync("separate"); + + Assert.True(collapsed.Total < separate.Total, + $"Expected collapsing to draw fewer borders than separate ones, got {collapsed.Total} against {separate.Total}."); + + // The point of collapsing is that neighbours share an edge, so the rule between the two + // columns has to be painted exactly once. + Assert.Equal(1, collapsed.InteriorVerticalRules); + Assert.Equal(2, separate.InteriorVerticalRules); + } + + private static async Task<(int Total, int InteriorVerticalRules)> CountCellBorderCommandsAsync(string borderCollapse) + { + var document = await ParseAsync($$""" - +
AB
CD
@@ -134,18 +376,30 @@ public async Task BuildDisplayList_CollapsesAdjacentCellBordersWhenRequested() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 160, + ViewPortWidth = 240, + ViewPortHeight = 160, FontSize = 16f, }); - var borderCommands = displayList.Commands + var borders = displayList.Commands .OfType() - .Count(command => command.Color == RenderColor.Black); + .Where(command => command.Color == RenderColor.Black) + .ToArray(); - Assert.True(borderCommands < 10, $"Expected collapsed borders to reduce border commands, but found {borderCommands}."); + // Vertical rules that sit strictly inside the table, counted on the first row only. + var tableLeft = borders.Min(command => command.Rect.X); + var tableRight = borders.Max(command => command.Rect.X + command.Rect.Width); + var firstRowY = borders.Min(command => command.Rect.Y); + + var interior = borders + .Where(command => command.Rect.Width <= 2f) + .Where(command => command.Rect.X > tableLeft + 0.5f && command.Rect.X + command.Rect.Width < tableRight - 0.5f) + .Where(command => command.Rect.Y <= firstRowY + 1f) + .Count(); + + return (borders.Length, interior); } [Fact] @@ -165,10 +419,10 @@ public async Task BuildDisplayList_UsesColumnWidthsFromColgroup() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 160, + ViewPortWidth = 240, + ViewPortHeight = 160, FontSize = 16f, }); @@ -179,6 +433,504 @@ public async Task BuildDisplayList_UsesColumnWidthsFromColgroup() Assert.True(cellBackground!.Rect.Width >= 100f, $"Expected the colgroup width to expand the cell geometry, but got {cellBackground.Rect.Width}."); } + [Fact] + public async Task BuildDisplayList_LaysOutFlexItemsInCenteredRow() + { + var document = await ParseAsync(""" + +
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 120, + FontSize = 16f, + }); + + var containerBackground = displayList.Commands + .OfType() + .First(command => command.Rect.Width == 100f && command.Rect.Height == 40f); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(containerBackground.Rect.X + 30f, childBackgrounds[0].Rect.X); + Assert.Equal(containerBackground.Rect.Y + 15f, childBackgrounds[0].Rect.Y); + Assert.Equal(containerBackground.Rect.X + 50f, childBackgrounds[1].Rect.X); + Assert.Equal(containerBackground.Rect.Y + 15f, childBackgrounds[1].Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_LaysOutFlexItemsInColumnDirection() + { + var document = await ParseAsync(""" + +
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 140, + FontSize = 16f, + }); + + var containerBackground = displayList.Commands + .OfType() + .First(command => command.Rect.Width == 100f && command.Rect.Height == 60f); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.Y) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(containerBackground.Rect.X + 40f, childBackgrounds[0].Rect.X); + Assert.Equal(containerBackground.Rect.Y, childBackgrounds[0].Rect.Y); + Assert.Equal(containerBackground.Rect.X + 40f, childBackgrounds[1].Rect.X); + Assert.Equal(containerBackground.Rect.Y + 10f, childBackgrounds[1].Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_AppliesFlexGrowToItems() + { + 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.Width == 50f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(childBackgrounds[0].Rect.X, childBackgrounds[0].Rect.X); + Assert.Equal(childBackgrounds[0].Rect.X + 50f, childBackgrounds[1].Rect.X); + } + + [Fact] + public async Task BuildDisplayList_WrapsItemsToNewLinesWhenNeeded() + { + 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.Width == 40f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.Y) + .ThenBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.True(childBackgrounds[1].Rect.Y > childBackgrounds[0].Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_UsesAlignSelfForIndividualItems() + { + 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.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.True(childBackgrounds[0].Rect.Y < childBackgrounds[1].Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_UsesFlexEndJustification() + { + 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.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(60f, childBackgrounds[0].Rect.X); + Assert.Equal(80f, childBackgrounds[1].Rect.X); + } + + [Fact] + public async Task BuildDisplayList_UsesSpaceBetweenJustification() + { + 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.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(15f, childBackgrounds[0].Rect.X); + Assert.Equal(65f, childBackgrounds[1].Rect.X); + } + + [Fact] + public async Task BuildDisplayList_UsesRowReverseDirection() + { + var document = await ParseAsync(""" + +
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 120, + FontSize = 16f, + }); + + var containerBackground = displayList.Commands + .OfType() + .First(command => command.Rect.Width == 100f && command.Rect.Height == 40f); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(containerBackground.Rect.X + 60f, childBackgrounds[0].Rect.X); + Assert.Equal(containerBackground.Rect.X + 80f, childBackgrounds[1].Rect.X); + } + + [Fact] + public async Task BuildDisplayList_UsesColumnReverseDirection() + { + var document = await ParseAsync(""" + +
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 140, + FontSize = 16f, + }); + + var containerBackground = displayList.Commands + .OfType() + .First(command => command.Rect.Width == 100f && command.Rect.Height == 60f); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.Y) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(containerBackground.Rect.Y + 40f, childBackgrounds[0].Rect.Y); + Assert.Equal(containerBackground.Rect.Y + 50f, childBackgrounds[1].Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_UsesFlexBasisForMainSize() + { + 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.Width == 40f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Single(childBackgrounds); + } + + [Fact] + public async Task BuildDisplayList_LaysOutGridItemsInRowsAndColumns() + { + 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.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.Y) + .ThenBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(4, 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(50f, childBackgrounds[3].Rect.X); + Assert.Equal(20f, childBackgrounds[3].Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_AppliesGridGapsToTrackPlacement() + { + 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.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.Y) + .ThenBy(command => command.Rect.X) + .ToArray(); + + 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(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(30f, childBackgrounds[3].Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_AppliesExplicitGridItemPlacement() + { + var document = await ParseAsync(""" + +
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 120, + FontSize = 16f, + }); + + var childBackground = displayList.Commands + .OfType() + .Single(command => command.Rect.Width == 20f && command.Rect.Height == 10f); + + Assert.Equal(50f, childBackground.Rect.X); + Assert.Equal(20f, childBackground.Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_AppliesSpanBasedGridItemPlacement() + { + var document = await ParseAsync(""" + +
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 120, + FontSize = 16f, + }); + + var childBackground = displayList.Commands + .OfType() + .Single(command => command.Rect.Width == 20f && command.Rect.Height == 10f); + + Assert.Equal(0f, childBackground.Rect.X); + Assert.Equal(0f, childBackground.Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_AppliesAutoPlacementAcrossImplicitTracks() + { + 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.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.Y) + .ThenBy(command => command.Rect.X) + .ToArray(); + + 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); + } + [Fact] public async Task BuildDisplayList_AppliesLetterSpacingToTextCommands() { @@ -189,10 +941,10 @@ public async Task BuildDisplayList_AppliesLetterSpacingToTextCommands() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, + ViewPortWidth = 200, + ViewPortHeight = 120, FontSize = 16f, }); @@ -213,10 +965,10 @@ Decorated text """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 120, + ViewPortWidth = 240, + ViewPortHeight = 120, FontSize = 16f, }); @@ -237,10 +989,10 @@ public async Task BuildDisplayList_IndentsFirstLineOfBlockText() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 160, + ViewPortWidth = 240, + ViewPortHeight = 160, FontSize = 16f, }); @@ -263,10 +1015,10 @@ public async Task BuildDisplayList_ShiftsInlineTextWithVerticalAlign() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 160, + ViewPortWidth = 240, + ViewPortHeight = 160, FontSize = 16f, }); @@ -288,10 +1040,10 @@ public async Task RenderToPng_UsesFontFamilyFallbackList() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 240, - Height = 120, + ViewPortWidth = 240, + ViewPortHeight = 120, FontSize = 18f, }); @@ -305,10 +1057,10 @@ public async Task RenderToPng_ReturnsPngPayload() var document = await ParseAsync("

PNG smoke test output.

"); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 320, - Height = 180, + ViewPortWidth = 320, + ViewPortHeight = 180, }); Assert.Equal("image/png", image.MimeType); @@ -329,12 +1081,10 @@ public async Task BuildDisplayList_RendersBoxBackgroundFromPaddingAndMargins() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 300, - Height = 200, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 300, + ViewPortHeight = 200, }); var backgrounds = displayList.Commands.OfType().ToArray(); @@ -358,12 +1108,10 @@ public async Task BuildDisplayList_RendersPerSideBorderWidths() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 300, - Height = 200, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 300, + ViewPortHeight = 200, }); var fills = displayList.Commands.OfType().ToArray(); @@ -395,12 +1143,10 @@ public async Task BuildDisplayList_ResolvesPercentageWidthAgainstContainingBlock """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 300, - Height = 150, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 300, + ViewPortHeight = 150, }); var fills = displayList.Commands.OfType().ToArray(); @@ -421,12 +1167,10 @@ public async Task BuildDisplayList_CentersBlockWithAutoHorizontalMargins() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 300, - Height = 150, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 300, + ViewPortHeight = 150, }); var fills = displayList.Commands.OfType().ToArray(); @@ -448,12 +1192,10 @@ public async Task BuildDisplayList_CollapsesAdjacentVerticalMargins() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 300, - Height = 200, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 300, + ViewPortHeight = 200, }); var fills = displayList.Commands.OfType().ToArray(); @@ -478,12 +1220,10 @@ public async Task BuildDisplayList_CollapsesParentAndFirstChildTopMargins() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 320, - Height = 240, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 320, + ViewPortHeight = 240, }); var fills = displayList.Commands.OfType().ToArray(); @@ -508,12 +1248,10 @@ public async Task BuildDisplayList_DoesNotCollapseParentAndFirstChildTopMarginsW """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 320, - Height = 240, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 320, + ViewPortHeight = 240, }); var fills = displayList.Commands.OfType().ToArray(); @@ -539,12 +1277,10 @@ public async Task BuildDisplayList_CollapsesParentAndLastChildBottomMargins() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 320, - Height = 260, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 320, + ViewPortHeight = 260, }); var fills = displayList.Commands.OfType().ToArray(); @@ -563,12 +1299,10 @@ public async Task BuildDisplayList_DoesNotPaintBorderWhenStyleIsNone() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 300, - Height = 200, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 300, + ViewPortHeight = 200, }); var fills = displayList.Commands.OfType().ToArray(); @@ -586,12 +1320,10 @@ public async Task BuildDisplayList_DoesNotPaintDisplayNoneElement() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 200, + ViewPortHeight = 120, }); var fills = displayList.Commands.OfType().ToArray(); @@ -608,12 +1340,10 @@ public async Task BuildDisplayList_DoesNotPaintVisibilityHiddenElement() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 200, + ViewPortHeight = 120, }); var fills = displayList.Commands.OfType().ToArray(); @@ -630,12 +1360,10 @@ public async Task BuildDisplayList_RendersInlineBlockBox() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 200, + ViewPortHeight = 120, }); var redFill = displayList.Commands @@ -657,12 +1385,10 @@ public async Task BuildDisplayList_RespectsDisplayBlock() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 200, + ViewPortHeight = 120, }); var greenFill = displayList.Commands @@ -683,12 +1409,10 @@ public async Task BuildDisplayList_TreatsInvalidDisplayFixedAsDefaultBlock() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 180, - Height = 80, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 180, + ViewPortHeight = 80, }); var redFill = displayList.Commands @@ -708,12 +1432,10 @@ public async Task BuildDisplayList_TreatsInvalidDisplayRelativeAsDefaultBlock() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 180, - Height = 80, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 180, + ViewPortHeight = 80, }); var blueFill = displayList.Commands @@ -734,12 +1456,10 @@ public async Task BuildDisplayList_FloatsLeftAndWrapsFollowingBlock() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 180, - Height = 100, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 180, + ViewPortHeight = 100, }); var fills = displayList.Commands.OfType().ToArray(); @@ -767,12 +1487,10 @@ public async Task BuildDisplayList_AppliesRelativePositionOffsetWithoutChangingF """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 200, + ViewPortHeight = 120, }); var fills = displayList.Commands.OfType().ToArray(); @@ -795,12 +1513,10 @@ public async Task BuildDisplayList_RendersFixedPositionRelativeToViewportAndExcl """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 200, + ViewPortHeight = 120, }); var fills = displayList.Commands.OfType().ToArray(); @@ -822,12 +1538,10 @@ public async Task BuildDisplayList_DistinguishesPaddingFromMargin() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 220, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 220, + ViewPortHeight = 120, }); var redFill = displayList.Commands @@ -851,12 +1565,10 @@ public async Task BuildDisplayList_PaintsOutlineOutsideBorderWithoutChangingFlow """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 200, + ViewPortHeight = 120, }); var fills = displayList.Commands.OfType().ToArray(); @@ -885,12 +1597,10 @@ public async Task BuildDisplayList_RendersAbsolutePositionRelativeToContainingBl """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 140, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 240, + ViewPortHeight = 140, }); var fills = displayList.Commands.OfType().ToArray(); @@ -915,12 +1625,10 @@ public async Task BuildDisplayList_PaintsHigherZIndexAfterLowerForPositionedOver """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 220, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 220, + ViewPortHeight = 120, }); var fills = displayList.Commands.OfType().ToArray(); @@ -944,12 +1652,10 @@ public async Task BuildDisplayList_PaintsNegativeZIndexBeforeInFlowBackground() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 220, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 220, + ViewPortHeight = 120, }); var fills = displayList.Commands.OfType().ToArray(); @@ -974,12 +1680,10 @@ public async Task BuildDisplayList_UsesZIndexOverSourceOrderForPositionedSibling """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 220, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 220, + ViewPortHeight = 120, }); var fills = displayList.Commands.OfType().ToArray(); @@ -991,9 +1695,87 @@ public async Task BuildDisplayList_UsesZIndexOverSourceOrderForPositionedSibling Assert.True(blueIndex > redIndex); } - private static async Task ParseAsync(string html) + private static async Task ParseAsync(string html, IConfiguration? configuration = null, string? address = null) { - var context = BrowsingContext.New(Configuration.Default.WithCss()); - return await context.OpenAsync(request => request.Content(html)); + var context = BrowsingContext.New(configuration ?? Configuration.Default.WithCss()); + + return await context.OpenAsync(request => + { + if (!string.IsNullOrWhiteSpace(address)) + { + request.Address(address); + } + + request.Content(html); + }); + } + + private sealed class SingleResponseImageRequester : BaseRequester + { + private readonly ReadTrackingMemoryStream _stream; + + public SingleResponseImageRequester(byte[] imageData) + { + _stream = new ReadTrackingMemoryStream(imageData); + } + + public int ContentReadSessionCount => _stream.ReadSessionCount; + + public override bool SupportsProtocol(string protocol) + { + return string.Equals(protocol, "http", StringComparison.OrdinalIgnoreCase) || + string.Equals(protocol, "https", StringComparison.OrdinalIgnoreCase); + } + + protected override Task PerformRequestAsync(Request request, CancellationToken cancel) + { + var response = new DefaultResponse + { + Address = request.Address, + StatusCode = HttpStatusCode.OK, + Headers = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Content-Type"] = "image/png", + }, + Content = _stream, + }; + + return Task.FromResult(response); + } + + private sealed class ReadTrackingMemoryStream : MemoryStream + { + public ReadTrackingMemoryStream(byte[] buffer) + : base(buffer) + { + } + + public int ReadSessionCount { get; private set; } + + public override int Read(byte[] buffer, int offset, int count) + { + if (Position == 0) + { + ReadSessionCount++; + } + + return base.Read(buffer, offset, count); + } + + public override int Read(Span buffer) + { + if (Position == 0) + { + ReadSessionCount++; + } + + return base.Read(buffer); + } + + protected override void Dispose(bool disposing) + { + // Keep the backing stream alive for deterministic test behavior. + } + } } } \ No newline at end of file diff --git a/src/AngleSharp.Renderer.Tests/SnapshotBaselineCoverageTests.cs b/src/AngleSharp.Renderer.Tests/SnapshotBaselineCoverageTests.cs new file mode 100644 index 0000000..ab07433 --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/SnapshotBaselineCoverageTests.cs @@ -0,0 +1,97 @@ +namespace AngleSharp.Renderer.Tests; + +/// +/// Guards the snapshot baselines themselves. Visual tests can only compare against the +/// baseline of the platform they happen to run on, so a missing baseline for another +/// platform stays invisible until that platform's CI leg runs - or, when a whole platform +/// has no CI leg, forever. These tests make the gap fail on every platform instead. +/// +[Trait("Category", "SnapshotCoverage")] +public sealed class SnapshotBaselineCoverageTests +{ + [Fact] + public void Baselines_ExistForEverySupportedPlatform() + { + var missing = new List(); + + foreach (var (snapshot, platforms) in EnumerateBaselines()) + { + foreach (var platform in VisualSnapshotVerifier.SupportedPlatformSuffixes) + { + if (!platforms.Contains(platform)) + { + missing.Add($"{snapshot}.{platform}.png"); + } + } + } + + Assert.True(missing.Count == 0, + $"Missing baseline snapshots for {missing.Count} platform variant(s):{Environment.NewLine}" + + string.Join(Environment.NewLine, missing.Order(StringComparer.Ordinal).Select(name => $" - {name}")) + + $"{Environment.NewLine}Run the 'Update Snapshots' GitHub workflow to regenerate the baselines " + + "for every platform, then commit the updated verification-assets."); + } + + [Fact] + public void Baselines_UseAKnownPlatformSuffix() + { + var unknown = Directory + .EnumerateFiles(VisualSnapshotVerifier.VerificationAssetsPath, "*.png") + .Select(path => Path.GetFileName(path)) + .Where(name => !IsKnownBaselineName(name)) + .Order(StringComparer.Ordinal) + .ToList(); + + Assert.True(unknown.Count == 0, + $"Baseline snapshots without a known platform suffix ({string.Join(", ", VisualSnapshotVerifier.SupportedPlatformSuffixes)}):" + + $"{Environment.NewLine}" + string.Join(Environment.NewLine, unknown.Select(name => $" - {name}"))); + } + + private static bool IsKnownBaselineName(string fileName) => + TrySplit(fileName, out _, out var platform) && + VisualSnapshotVerifier.SupportedPlatformSuffixes.Contains(platform); + + private static IEnumerable<(string Snapshot, IReadOnlyCollection Platforms)> EnumerateBaselines() + { + var baselines = new Dictionary>(StringComparer.Ordinal); + + foreach (var path in Directory.EnumerateFiles(VisualSnapshotVerifier.VerificationAssetsPath, "*.png")) + { + var fileName = Path.GetFileName(path); + + if (!IsKnownBaselineName(fileName)) + { + continue; + } + + TrySplit(fileName, out var snapshot, out var platform); + + if (!baselines.TryGetValue(snapshot, out var platforms)) + { + platforms = new HashSet(StringComparer.Ordinal); + baselines[snapshot] = platforms; + } + + platforms.Add(platform); + } + + return baselines.Select(entry => (entry.Key, (IReadOnlyCollection)entry.Value)); + } + + private static bool TrySplit(string fileName, out string snapshot, out string platform) + { + var withoutExtension = Path.GetFileNameWithoutExtension(fileName); + var separator = withoutExtension.LastIndexOf('.'); + + if (separator <= 0 || separator == withoutExtension.Length - 1) + { + snapshot = withoutExtension; + platform = string.Empty; + return false; + } + + snapshot = withoutExtension[..separator]; + platform = withoutExtension[(separator + 1)..]; + return true; + } +} diff --git a/src/AngleSharp.Renderer.Tests/TableSpanTests.cs b/src/AngleSharp.Renderer.Tests/TableSpanTests.cs new file mode 100644 index 0000000..6770737 --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/TableSpanTests.cs @@ -0,0 +1,284 @@ +namespace AngleSharp.Renderer.Tests; + +using AngleSharp; +using AngleSharp.Css; +using AngleSharp.Renderer.Rendering; + +/// +/// Geometry of colspan and rowspan, asserted on the display list so the checks do +/// not depend on how any particular platform rasterizes the result. +/// +public sealed class TableSpanTests +{ + private const string TableCss = + "html, body { margin: 0; padding: 0; } table { border-collapse: collapse; width: 180px; } " + + "td { padding: 6px; border: 1px solid #222; background-color: #eef7ff; }"; + + [Fact] + public async Task ColspanCell_CoversEveryColumnItSpans() + { + var cells = await LayoutCellsAsync(""" + + + +
Header
LeftRight
+ """); + + var header = cells["Header"]; + var left = cells["Left"]; + var right = cells["Right"]; + + Assert.Equal(left.Width + right.Width, header.Width, tolerance: 0.5f); + Assert.Equal(left.X, header.X, tolerance: 0.5f); + Assert.Equal(right.X + right.Width, header.X + header.Width, tolerance: 0.5f); + } + + [Fact] + public async Task RowspanCell_CoversEveryRowItSpans() + { + var cells = await LayoutCellsAsync(""" + + + +
LeftRight
Bottom
+ """); + + var left = cells["Left"]; + var right = cells["Right"]; + var bottom = cells["Bottom"]; + + Assert.Equal(right.Height + bottom.Height, left.Height, tolerance: 0.5f); + Assert.Equal(right.Y, left.Y, tolerance: 0.5f); + Assert.Equal(bottom.Y + bottom.Height, left.Y + left.Height, tolerance: 0.5f); + } + + [Fact] + public async Task TallRowspanCell_IsSharedAcrossItsRowsRatherThanRepeated() + { + // The cell is taller than either row needs on its own. Its height has to be satisfied by + // the spanned rows together; giving each row the full height would double the table. + const string Long = "one two three four five six seven eight nine ten eleven twelve"; + + // The text wraps, so it arrives as several draw commands; the cell is identified by being + // the tallest background rather than by its text. + var reference = await TallestCellAsync($""" + + +
{Long}a
+ """); + + var spanned = await TallestCellAsync($""" + + + +
{Long}a
b
+ """); + + Assert.True(spanned > 40f, "The sample text was expected to wrap to several lines."); + Assert.True(spanned <= reference + 0.5f, + $"The spanning cell grew to {spanned:F1}px against a required {reference:F1}px, " + + "so its height was applied to every spanned row instead of being shared between them."); + } + + [Fact] + public async Task SpannedSlot_IsNotPaintedAsItsOwnCell() + { + var document = await ParseAsync(""" + + + + +
Header
LeftRight
Bottom
+ """); + + // Four declared cells, so exactly four cell backgrounds. A phantom cell in a covered slot + // would show up as a fifth. + Assert.Equal(4, CellBackgrounds(new HtmlRenderer().BuildDisplayList(document, Device())).Count()); + } + + [Fact] + public async Task SpannedSlot_HoldsNoSeparateCell() + { + var cells = await LayoutCellsAsync(""" + + + + +
Header
LeftRight
Bottom
+ """); + + Assert.Equal(["Bottom", "Header", "Left", "Right"], cells.Keys.Order(StringComparer.Ordinal)); + + // Nothing is painted in the slot the rowspan covers. + var left = cells["Left"]; + var bottom = cells["Bottom"]; + + Assert.True(bottom.X >= left.X + left.Width - 0.5f, + "The cell after a rowspan must start beyond the spanning cell, not inside it."); + } + + [Fact] + public async Task EmptyRow_UnderARowspan_DoesNotThrow() + { + // The occupancy carried into the next row used to index past the end of a shorter row. + var cells = await LayoutCellsAsync(""" + + + + +
TallSide
Last
+ """); + + Assert.Contains("Tall", cells.Keys); + Assert.Contains("Last", cells.Keys); + } + + [Fact] + public async Task CollapsedBorders_DoNotCrossASpanningCell() + { + var document = await ParseAsync(""" + + + +
Header
LeftRight
+ """); + + var displayList = new HtmlRenderer().BuildDisplayList(document, Device()); + var cells = CellRects(displayList); + var header = cells["Header"]; + + // A vertical rule inside the header would be a border painted across the span. + var crossing = displayList.Commands + .OfType() + .Where(command => IsBorderPaint(command)) + .Where(command => command.Rect.Width <= 2f) + .Where(command => command.Rect.X > header.X + 0.5f && command.Rect.X < header.X + header.Width - 1.5f) + .Where(command => command.Rect.Y < header.Y + header.Height - 0.5f && command.Rect.Y + command.Rect.Height > header.Y + 0.5f) + .ToArray(); + + Assert.True(crossing.Length == 0, + $"{crossing.Length} vertical border(s) painted across the spanning header cell."); + } + + private static bool IsBorderPaint(FillRectCommand command) => + command.Paint is RenderColorPaint { Color: { R: 0, G: 0, B: 0, A: 255 } }; + + private static DefaultRenderDevice Device() => + new() { ViewPortWidth = 220, ViewPortHeight = 200 }; + + private static async Task TallestCellAsync(string tableHtml) => + CellBackgrounds(new HtmlRenderer().BuildDisplayList(await ParseAsync(tableHtml), Device())) + .Max(rect => rect.Height); + + private static async Task> LayoutCellsAsync(string tableHtml) => + CellRects(new HtmlRenderer().BuildDisplayList(await ParseAsync(tableHtml), Device())); + + [Fact] + public async Task CellContent_IsCenteredByDefault() + { + // The UA stylesheet gives cells vertical-align: middle, so the default has to match an + // explicit middle rather than pin the text to the top. + Assert.Equal( + await TextBaselineAsync(AlignedTable("middle"), "Left"), + await TextBaselineAsync(AlignedTable(null), "Left"), + tolerance: 0.01f); + } + + [Fact] + public async Task VerticalAlign_PutsMiddleHalfwayBetweenTopAndBottom() + { + // Stated as a relation between the three keywords, so it holds whatever the line height + // works out to. The reported Y is a baseline, which is not the centre of the line box. + var top = await TextBaselineAsync(AlignedTable("top"), "Left"); + var middle = await TextBaselineAsync(AlignedTable("middle"), "Left"); + var bottom = await TextBaselineAsync(AlignedTable("bottom"), "Left"); + + Assert.True(top < middle && middle < bottom, + $"Expected top ({top:F1}) above middle ({middle:F1}) above bottom ({bottom:F1})."); + Assert.Equal(bottom - middle, middle - top, tolerance: 0.5f); + } + + [Fact] + public async Task VerticalAlign_Top_MatchesANonSpanningCell() + { + // Aligned to the top, a cell spanning two rows has to offset its text exactly like any + // other top-aligned cell: by its border and padding alone. + var cells = await LayoutCellsAsync(AlignedTable("top", alignEveryCell: true)); + var left = await TextBaselineAsync(AlignedTable("top", alignEveryCell: true), "Left"); + var right = await TextBaselineAsync(AlignedTable("top", alignEveryCell: true), "Right"); + + Assert.Equal(right - cells["Right"].Y, left - cells["Left"].Y, tolerance: 0.01f); + } + + [Fact] + public async Task VerticalAlign_Bottom_MatchesANonSpanningCell() + { + var cells = await LayoutCellsAsync(AlignedTable("bottom", alignEveryCell: true)); + var left = await TextBaselineAsync(AlignedTable("bottom", alignEveryCell: true), "Left"); + var right = await TextBaselineAsync(AlignedTable("bottom", alignEveryCell: true), "Right"); + + var leftCell = cells["Left"]; + var rightCell = cells["Right"]; + + Assert.Equal(rightCell.Y + rightCell.Height - right, leftCell.Y + leftCell.Height - left, tolerance: 0.01f); + } + + private static string AlignedTable(string? verticalAlign, bool alignEveryCell = false) + { + var style = verticalAlign is null ? string.Empty : $" style=\"vertical-align:{verticalAlign}\""; + var otherStyle = alignEveryCell ? style : string.Empty; + + return $""" + + Right + Bottom +
Left
+ """; + } + + private static async Task TextBaselineAsync(string tableHtml, string text) + { + var displayList = new HtmlRenderer().BuildDisplayList(await ParseAsync(tableHtml), Device()); + + return displayList.Commands.OfType().First(command => command.Text == text).Y; + } + + private static IEnumerable CellBackgrounds(DisplayList displayList) => + displayList.Commands + .OfType() + .Where(command => command.Paint is RenderColorPaint { Color: { R: 238, G: 247, B: 255, A: 255 } }) + .Select(command => command.Rect); + + private static Dictionary CellRects(DisplayList displayList) + { + var backgrounds = CellBackgrounds(displayList).ToList(); + var cells = new Dictionary(StringComparer.Ordinal); + + foreach (var text in displayList.Commands.OfType()) + { + // The page background contains every cell, so the enclosing rectangle of least area is + // the one that actually belongs to this text. + var match = backgrounds + .Where(rect => text.X >= rect.X && text.X <= rect.X + rect.Width && + text.Y >= rect.Y && text.Y <= rect.Y + rect.Height) + .OrderBy(rect => rect.Width * rect.Height) + .ToArray(); + + if (match.Length > 0) + { + cells[text.Text] = match[0]; + } + } + + return cells; + } + + private static async Task ParseAsync(string tableHtml) + { + var context = BrowsingContext.New(Configuration.Default.WithCss()); + + return await context.OpenAsync(request => request.Content($$""" + {{tableHtml}} + """)); + } +} diff --git a/src/AngleSharp.Renderer.Tests/TextMeasurementTests.cs b/src/AngleSharp.Renderer.Tests/TextMeasurementTests.cs new file mode 100644 index 0000000..984ef77 --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/TextMeasurementTests.cs @@ -0,0 +1,174 @@ +namespace AngleSharp.Renderer.Tests; + +using AngleSharp; +using AngleSharp.Css; +using AngleSharp.Renderer.Rendering; +using AngleSharp.Renderer.Skia; + +/// +/// Layout has to break lines against the advance widths the backend will actually paint with. +/// These assertions are structural on purpose - they hold on every platform, unlike the +/// snapshots, which can only ever check the platform they were recorded on. +/// +public sealed class TextMeasurementTests +{ + private const string Sentence = "The quick brown fox jumps over the lazy dog and keeps running"; + + [Theory] + [InlineData("serif")] + [InlineData("sans-serif")] + [InlineData("monospace")] + public async Task WrappedLines_StayWithinTheContainer(string fontFamily) + { + var lines = await LayoutLinesAsync(fontFamily, containerWidth: 180, fontSize: 14f); + var measurer = new SkiaTextMeasurer(); + + Assert.NotEmpty(lines); + + foreach (var line in lines) + { + var width = measurer.MeasureWidth(line.Text, ToFont(line)); + + Assert.True(width <= 180f, + $"Line \"{line.Text}\" measures {width:F2}px in {fontFamily}, which overflows the 180px container."); + } + } + + [Fact] + public async Task WrappedLines_DependOnTheFontFamily() + { + // Monospace is markedly wider than the proportional families at the same size, so it has + // to break earlier. Identical break points would mean layout is ignoring the font. + var sans = await LayoutLinesAsync("sans-serif", containerWidth: 180, fontSize: 14f); + var mono = await LayoutLinesAsync("monospace", containerWidth: 180, fontSize: 14f); + + var sansTexts = sans.Select(line => line.Text).ToArray(); + var monoTexts = mono.Select(line => line.Text).ToArray(); + + Assert.NotEqual(sansTexts, monoTexts); + Assert.True(mono.Count >= sans.Count, + $"Expected monospace to need at least as many lines as sans-serif, got {mono.Count} vs {sans.Count}."); + + // The break points differ because the advance widths do. + var measurer = new SkiaTextMeasurer(); + var sansWidth = measurer.MeasureWidth(Sentence, new RenderFont("sans-serif", 14f, 400f, false, 0f)); + var monoWidth = measurer.MeasureWidth(Sentence, new RenderFont("monospace", 14f, 400f, false, 0f)); + + Assert.True(monoWidth > sansWidth, + $"Expected monospace to measure wider than sans-serif, got {monoWidth:F2} vs {sansWidth:F2}."); + } + + [Fact] + public async Task CenteredLines_AreOffsetByTheMeasuredWidth() + { + const float containerWidth = 180f; + var lines = await LayoutLinesAsync("serif", containerWidth, fontSize: 14f, textAlign: "center"); + var measurer = new SkiaTextMeasurer(); + + Assert.NotEmpty(lines); + + foreach (var line in lines) + { + var width = measurer.MeasureWidth(line.Text, ToFont(line)); + + Assert.Equal((containerWidth - width) / 2f, line.X, tolerance: 0.5f); + } + } + + [Fact] + public async Task Layout_UsesTheInjectedMeasurer() + { + // A deliberately wrong measurer must still drive the line breaking - that is the proof + // layout goes through the measurer rather than a built-in heuristic. + var measurer = new FixedWidthTextMeasurer(widthPerCharacter: 20f); + var document = await ParseAsync("sans-serif", containerWidth: 100, fontSize: 14f, textAlign: "left"); + + var renderer = new HtmlRenderer(new SkiaRenderBackend(), measurer); + var lines = renderer + .BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 400, ViewPortHeight = 600, FontSize = 14f }) + .Commands.OfType() + .ToArray(); + + Assert.True(measurer.CallCount > 0, "Layout never consulted the injected measurer."); + Assert.NotEmpty(lines); + + // At 20px per character not even the two shortest words fit together into 100px, so every + // line has to come out as a single word. A word wider than the container still overflows, + // because nothing here opts into breaking inside a word. + foreach (var line in lines) + { + Assert.DoesNotContain(' ', line.Text); + } + + // The same document measured with the real font puts several words on a line, so the + // difference can only come from the injected measurer. + var realLines = await LayoutLinesAsync("sans-serif", containerWidth: 100, fontSize: 14f); + + Assert.Contains(realLines, line => line.Text.Contains(' ')); + } + + [Fact] + public void Measurer_AccountsForLetterSpacing() + { + var measurer = new SkiaTextMeasurer(); + var plain = new RenderFont("sans-serif", 16f, 400f, false, 0f); + var spaced = plain with { LetterSpacing = 4f }; + + // "Handgloves" has ten characters, so nine gaps of four pixels each. + Assert.Equal(measurer.MeasureWidth("Handgloves", plain) + 36f, measurer.MeasureWidth("Handgloves", spaced), tolerance: 0.01f); + } + + [Fact] + public void Measurer_ReturnsZeroForEmptyText() + { + Assert.Equal(0f, new SkiaTextMeasurer().MeasureWidth(string.Empty, new RenderFont("serif", 16f, 400f, false, 0f))); + } + + private static RenderFont ToFont(DrawTextCommand command) => + new(command.FontFamily, command.FontSize, command.FontWeight, command.IsItalic, command.LetterSpacing); + + private static async Task> LayoutLinesAsync( + string fontFamily, + float containerWidth, + float fontSize, + string textAlign = "left") + { + var document = await ParseAsync(fontFamily, containerWidth, fontSize, textAlign); + + return new HtmlRenderer() + .BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 400, ViewPortHeight = 600, FontSize = fontSize }) + .Commands.OfType() + .ToArray(); + } + + private static async Task ParseAsync( + string fontFamily, + float containerWidth, + float fontSize, + string textAlign) + { + var context = BrowsingContext.New(Configuration.Default.WithCss()); + + return await context.OpenAsync(request => request.Content($$""" + + + +
+ {{Sentence}} +
+ + + """)); + } + + private sealed class FixedWidthTextMeasurer(float widthPerCharacter) : ITextMeasurer + { + public int CallCount { get; private set; } + + public float MeasureWidth(string text, RenderFont font) + { + CallCount++; + return text.Length * widthPerCharacter; + } + } +} diff --git a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs index 0a6bf36..4d13e25 100644 --- a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs +++ b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs @@ -1,11 +1,27 @@ namespace AngleSharp.Renderer.Tests; using AngleSharp; +using AngleSharp.Css; using AngleSharp.Html.Dom; [Trait("Category", "Visual")] public sealed class VisualConformanceTests { + // Text glyph rasterization is delegated to the OS's own font engine (CoreText on macOS, + // DirectWrite on Windows, FreeType on Linux - see AGENTS.md), and that engine's hinting and + // anti-aliasing can differ across OS versions even on the *same* platform: CI is pinned to a + // specific runner image (macos-14 et al.), but a developer's local machine runs whatever OS + // version they have, which can be materially newer. That produces a handful of glyph/border + // edge pixels differing by a few intensity levels - not a rendering regression, since the same + // input consistently produces the same *content*, just very slightly different anti-aliasing. + // These tolerances (measured: real CI-vs-local drift topped out at a per-channel delta of 6 + // across 9 pixels; doubled here for headroom) apply only to tests whose content is dominated + // by text. Every shape/gradient/SVG test keeps an exact 0/0 tolerance - that geometry is + // rendered by Skia's own rasterizer with no OS dependency, and has proven bit-for-bit + // reproducible across OS versions, so loosening it here would hide real regressions there. + private const byte TextRenderingToleranceChannel = 12; + private const int TextRenderingToleranceMaxPixels = 20; + [Fact] public async Task RenderToPng_PaintsBoxBackgroundAndBorderAtExpectedPixels() { @@ -21,12 +37,10 @@ public async Task RenderToPng_PaintsBoxBackgroundAndBorderAtExpectedPixels() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 120, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 120, + ViewPortHeight = 120, }); VisualSnapshotVerifier.VerifyOrCreate( @@ -51,12 +65,10 @@ public async Task RenderToPng_CentersAutoMarginBlock() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 120, - Height = 80, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 120, + ViewPortHeight = 80, }); VisualSnapshotVerifier.VerifyOrCreate( @@ -82,12 +94,10 @@ public async Task RenderToPng_ShowsCollapsedVerticalMarginGap() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 120, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 120, + ViewPortHeight = 120, }); VisualSnapshotVerifier.VerifyOrCreate( @@ -97,6 +107,202 @@ public async Task RenderToPng_ShowsCollapsedVerticalMarginGap() maxDifferentPixels: 0); } + [Fact] + public async Task RenderToPng_RendersDefaultEllipticalRadialGradient() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-default-elliptical-radial-gradient.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersPositionedCircleRadialGradient() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 120, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-positioned-circle-radial-gradient.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersClosestSideRadialGradient() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 120, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-closest-side-radial-gradient.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersConicGradientWithDegreeStops() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 120, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-conic-gradient-with-degree-stops.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersRepeatingLinearGradient() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 120, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-repeating-linear-gradient.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersRepeatingRadialGradient() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 120, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-repeating-radial-gradient.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersRepeatingConicGradient() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 120, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-repeating-conic-gradient.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + [Fact] public async Task RenderToPng_RendersSimpleTableLayout() { @@ -115,19 +321,17 @@ public async Task RenderToPng_RendersSimpleTableLayout() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 180, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 180, + ViewPortHeight = 120, }); VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "renders-simple-table-layout.png", actualPng: image.Data, - perChannelTolerance: 0, - maxDifferentPixels: 0); + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); } [Fact] @@ -150,12 +354,10 @@ public async Task RenderToPng_RendersTableCellBordersAndWidths() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 220, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 220, + ViewPortHeight = 120, }); VisualSnapshotVerifier.VerifyOrCreate( @@ -191,19 +393,17 @@ public async Task RenderToPng_RendersTableWithColspanAndRowspan() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 220, - Height = 140, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 220, + ViewPortHeight = 140, }); VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "renders-table-with-colspan-and-rowspan.png", actualPng: image.Data, - perChannelTolerance: 0, - maxDifferentPixels: 0); + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); } [Fact] @@ -309,12 +509,10 @@ public async Task RenderToPng_PaintsAbsolutePositionedElementOutOfFlow() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 160, - Height = 100, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 160, + ViewPortHeight = 100, }); VisualSnapshotVerifier.VerifyOrCreate( @@ -342,12 +540,10 @@ public async Task RenderToPng_PaintsHigherZIndexAboveLowerZIndex() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 160, - Height = 100, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 160, + ViewPortHeight = 100, }); VisualSnapshotVerifier.VerifyOrCreate( @@ -374,12 +570,10 @@ public async Task RenderToPng_PaintsNegativeZIndexBehindInFlowContent() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 160, - Height = 100, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 160, + ViewPortHeight = 100, }); VisualSnapshotVerifier.VerifyOrCreate( @@ -410,20 +604,18 @@ public async Task RenderToPng_RendersMixedTextSizesStylesAndDecorations() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 260, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 260, + ViewPortHeight = 120, FontSize = 16f, }); VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "mixed-text-sizes-styles-decorations.png", actualPng: image.Data, - perChannelTolerance: 0, - maxDifferentPixels: 0); + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); } [Fact] @@ -446,20 +638,18 @@ one two three four five six seven eight nine ten eleven twelve thirteen fourteen """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 220, - Height = 180, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 220, + ViewPortHeight = 180, FontSize = 12f, }); VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "aligned-wrapped-text-with-line-height.png", actualPng: image.Data, - perChannelTolerance: 0, - maxDifferentPixels: 0); + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); } [Fact] @@ -482,12 +672,10 @@ Decoration test """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 240, - Height = 100, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 240, + ViewPortHeight = 100, FontSize = 18f, }); @@ -521,50 +709,686 @@ Indented text that wraps to a second line. """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 260, - Height = 160, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 260, + ViewPortHeight = 160, FontSize = 16f, }); VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "text-indent-and-vertical-align.png", actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); + } + + [Fact] + public async Task RenderToPng_RendersSvgImageSource() + { + var document = await ParseAsync(""" + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-svg-image-source.png", + actualPng: image.Data, perChannelTolerance: 0, maxDifferentPixels: 0); } - [Fact] - public async Task RenderToPng_RendersWebSafeFontFamiliesDifferently() - { - var document = await ParseAsync(""" - - -

Serif sample

-

Sans sample

-

Mono sample

- - - """); + [Fact] + public async Task RenderToPng_RendersInlineSvgShapes() + { + var document = await ParseAsync(""" + + + + + + + + + + + + """); - var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions - { - Width = 320, - Height = 200, - Padding = 0f, - ParagraphSpacing = 4f, - FontSize = 16f, - }); + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); - VisualSnapshotVerifier.VerifyOrCreate( - snapshotName: "web-safe-font-families.png", - actualPng: image.Data, - perChannelTolerance: 0, - maxDifferentPixels: 0); + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-shapes.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgPathAndTransform() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-path-and-transform.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgLinearGradient() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-linear-gradient.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgRadialGradient() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-radial-gradient.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgUseElement() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 50, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-use-element.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgTextWithTspanAndAnchor() + { + var document = await ParseAsync(""" + + + + + + + Hi! + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 160, + ViewPortHeight = 60, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-text.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgClipPath() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-clip-path.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgMask() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-mask.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgPreserveAspectRatioSlice() + { + var document = await ParseAsync(""" + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-preserve-aspect-ratio-slice.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgInternalStyleSheet() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-internal-stylesheet.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgPercentageLengths() + { + var document = await ParseAsync(""" + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-percentage-lengths.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersNestedSvgViewport() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-nested-svg-viewport.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersSymbolViaUse() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 50, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-symbol-via-use.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgCurrentColor() + { + var document = await ParseAsync(""" + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-current-color.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgExplicitMaskRegion() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-explicit-mask-region.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgPatternFill() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-pattern-fill.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgGaussianBlurFilter() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-gaussian-blur-filter.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_IgnoresMediaRuleInsideInlineSvgStyle() + { + var document = await ParseAsync(""" + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "ignores-media-rule-inside-inline-svg-style.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersWebSafeFontFamiliesDifferently() + { + var document = await ParseAsync(""" + + +

Serif sample

+

Sans sample

+

Mono sample

+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 320, + ViewPortHeight = 200, + FontSize = 16f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "web-safe-font-families.png", + actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); } private static async Task RenderCanvasSnapshotAsync(string html, Action draw) diff --git a/src/AngleSharp.Renderer.Tests/VisualSnapshotVerifier.cs b/src/AngleSharp.Renderer.Tests/VisualSnapshotVerifier.cs index cb2f85b..5129c42 100644 --- a/src/AngleSharp.Renderer.Tests/VisualSnapshotVerifier.cs +++ b/src/AngleSharp.Renderer.Tests/VisualSnapshotVerifier.cs @@ -7,6 +7,19 @@ namespace AngleSharp.Renderer.Tests; internal static class VisualSnapshotVerifier { private const string StrictModeEnvironmentVariable = "ANGLESHARP_SNAPSHOT_STRICT"; + private const string UpdateModeEnvironmentVariable = "ANGLESHARP_SNAPSHOT_UPDATE"; + + /// + /// The platforms a baseline has to exist for. Skia rasterizes glyphs through a different + /// scaler per platform (FreeType on Linux, DirectWrite on Windows, CoreText on macOS), so + /// the very same font file produces different anti-aliasing and the baselines cannot be shared. + /// + public static readonly string[] SupportedPlatformSuffixes = ["linux", "macos", "windows"]; + + /// + /// Gets the directory holding the committed baseline images. + /// + public static string VerificationAssetsPath => Path.Combine(GetProjectRoot(), "verification-assets"); public static void VerifyOrCreate( string snapshotName, @@ -26,15 +39,24 @@ public static void VerifyOrCreate( var failurePath = Path.Combine(failureAssetsPath, platformSnapshotName); var diffPath = Path.Combine(failureAssetsPath, Path.GetFileNameWithoutExtension(platformSnapshotName) + ".diff.png"); + if (IsEnabled(UpdateModeEnvironmentVariable)) + { + File.WriteAllBytes(baselinePath, actualPng); + DeleteIfExists(failurePath); + DeleteIfExists(diffPath); + return; + } + if (!File.Exists(baselinePath)) { - if (IsStrictModeEnabled()) + if (IsEnabled(StrictModeEnvironmentVariable)) { File.WriteAllBytes(failurePath, actualPng); throw new XunitException( $"Missing baseline snapshot '{platformSnapshotName}' while strict mode is enabled ({StrictModeEnvironmentVariable}=1). " + - $"Create baseline at: {baselinePath}. Actual output written to: {failurePath}."); + $"Create baseline at: {baselinePath}. Actual output written to: {failurePath}. " + + "Baselines for all platforms are produced by the 'Update Snapshots' GitHub workflow."); } File.WriteAllBytes(baselinePath, actualPng); @@ -46,16 +68,8 @@ public static void VerifyOrCreate( if (comparison.IsMatch(maxDifferentPixels)) { - if (File.Exists(failurePath)) - { - File.Delete(failurePath); - } - - if (File.Exists(diffPath)) - { - File.Delete(diffPath); - } - + DeleteIfExists(failurePath); + DeleteIfExists(diffPath); return; } @@ -66,7 +80,7 @@ public static void VerifyOrCreate( $"Visual snapshot mismatch for '{platformSnapshotName}'. " + $"Expected size {comparison.ExpectedWidth}x{comparison.ExpectedHeight}, " + $"actual size {comparison.ActualWidth}x{comparison.ActualHeight}, " + - $"different pixels: {comparison.DifferentPixels} (allowed: {maxDifferentPixels}), " + + $"different pixels: {comparison.DifferentPixels} ({comparison.DifferentPixelRatio:P2}, allowed: {maxDifferentPixels}), " + $"channel tolerance: {perChannelTolerance}. " + $"Baseline: {baselinePath}. Failure output: {failurePath}. Diff output: {diffPath}."); } @@ -106,12 +120,20 @@ private static string GetPlatformSuffix() return Environment.OSVersion.Platform.ToString().ToLowerInvariant(); } - private static bool IsStrictModeEnabled() + private static bool IsEnabled(string environmentVariable) { - var strictModeValue = Environment.GetEnvironmentVariable(StrictModeEnvironmentVariable); + var value = Environment.GetEnvironmentVariable(environmentVariable); + + return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } - return string.Equals(strictModeValue, "1", StringComparison.OrdinalIgnoreCase) || - string.Equals(strictModeValue, "true", StringComparison.OrdinalIgnoreCase); + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } } private static string GetProjectRoot() @@ -228,5 +250,14 @@ private readonly record struct ImageComparison( byte[] DiffPng) { public bool IsMatch(int maxDifferentPixels) => DifferentPixels <= maxDifferentPixels; + + public double DifferentPixelRatio + { + get + { + var total = Math.Max(ExpectedWidth * ExpectedHeight, ActualWidth * ActualHeight); + return total > 0 ? (double)DifferentPixels / total : 0d; + } + } } } \ No newline at end of file diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.linux.png index 7fecebe..58322b1 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.linux.png and b/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.macos.png index 6a4e80b..cf76e2a 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.macos.png and b/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.windows.png index 6ca3451..70ba070 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.windows.png and b/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/ignores-media-rule-inside-inline-svg-style.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/ignores-media-rule-inside-inline-svg-style.linux.png new file mode 100644 index 0000000..03b7ed8 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/ignores-media-rule-inside-inline-svg-style.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/ignores-media-rule-inside-inline-svg-style.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/ignores-media-rule-inside-inline-svg-style.macos.png new file mode 100644 index 0000000..03b7ed8 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/ignores-media-rule-inside-inline-svg-style.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/ignores-media-rule-inside-inline-svg-style.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/ignores-media-rule-inside-inline-svg-style.windows.png new file mode 100644 index 0000000..03b7ed8 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/ignores-media-rule-inside-inline-svg-style.windows.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 f254598..bef354c 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 cbf7a90..2487282 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.Tests/verification-assets/renders-canvas-path-and-text.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-path-and-text.macos.png index c361fce..fe392e3 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-path-and-text.macos.png and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-path-and-text.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-path-and-text.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-path-and-text.windows.png new file mode 100644 index 0000000..fe392e3 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-path-and-text.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-rectangles-and-clear-rect.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-rectangles-and-clear-rect.windows.png new file mode 100644 index 0000000..3a3937e Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-rectangles-and-clear-rect.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-translation-and-state.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-translation-and-state.windows.png new file mode 100644 index 0000000..759a6e9 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-translation-and-state.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-closest-side-radial-gradient.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-closest-side-radial-gradient.linux.png new file mode 100644 index 0000000..8790995 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-closest-side-radial-gradient.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-closest-side-radial-gradient.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-closest-side-radial-gradient.macos.png new file mode 100644 index 0000000..8790995 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-closest-side-radial-gradient.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-closest-side-radial-gradient.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-closest-side-radial-gradient.windows.png new file mode 100644 index 0000000..8790995 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-closest-side-radial-gradient.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-conic-gradient-with-degree-stops.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-conic-gradient-with-degree-stops.linux.png new file mode 100644 index 0000000..84ba709 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-conic-gradient-with-degree-stops.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-conic-gradient-with-degree-stops.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-conic-gradient-with-degree-stops.macos.png new file mode 100644 index 0000000..84ba709 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-conic-gradient-with-degree-stops.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-conic-gradient-with-degree-stops.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-conic-gradient-with-degree-stops.windows.png new file mode 100644 index 0000000..84ba709 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-conic-gradient-with-degree-stops.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-default-elliptical-radial-gradient.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-default-elliptical-radial-gradient.linux.png new file mode 100644 index 0000000..b4840a7 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-default-elliptical-radial-gradient.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-default-elliptical-radial-gradient.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-default-elliptical-radial-gradient.macos.png new file mode 100644 index 0000000..b4840a7 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-default-elliptical-radial-gradient.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-default-elliptical-radial-gradient.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-default-elliptical-radial-gradient.windows.png new file mode 100644 index 0000000..b4840a7 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-default-elliptical-radial-gradient.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-clip-path.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-clip-path.linux.png new file mode 100644 index 0000000..ab1938e Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-clip-path.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-clip-path.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-clip-path.macos.png new file mode 100644 index 0000000..e1f3830 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-clip-path.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-clip-path.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-clip-path.windows.png new file mode 100644 index 0000000..ab1938e Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-clip-path.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.linux.png new file mode 100644 index 0000000..03b7ed8 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.macos.png new file mode 100644 index 0000000..03b7ed8 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.windows.png new file mode 100644 index 0000000..03b7ed8 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-explicit-mask-region.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-explicit-mask-region.linux.png new file mode 100644 index 0000000..fffc12c Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-explicit-mask-region.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-explicit-mask-region.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-explicit-mask-region.macos.png new file mode 100644 index 0000000..fffc12c Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-explicit-mask-region.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-explicit-mask-region.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-explicit-mask-region.windows.png new file mode 100644 index 0000000..fffc12c Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-explicit-mask-region.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-gaussian-blur-filter.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-gaussian-blur-filter.linux.png new file mode 100644 index 0000000..9b77ad0 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-gaussian-blur-filter.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-gaussian-blur-filter.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-gaussian-blur-filter.macos.png new file mode 100644 index 0000000..9b77ad0 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-gaussian-blur-filter.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-gaussian-blur-filter.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-gaussian-blur-filter.windows.png new file mode 100644 index 0000000..9b77ad0 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-gaussian-blur-filter.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-internal-stylesheet.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-internal-stylesheet.linux.png new file mode 100644 index 0000000..8b35693 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-internal-stylesheet.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-internal-stylesheet.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-internal-stylesheet.macos.png new file mode 100644 index 0000000..8b35693 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-internal-stylesheet.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-internal-stylesheet.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-internal-stylesheet.windows.png new file mode 100644 index 0000000..8b35693 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-internal-stylesheet.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-linear-gradient.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-linear-gradient.linux.png new file mode 100644 index 0000000..54cd61f Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-linear-gradient.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-linear-gradient.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-linear-gradient.macos.png new file mode 100644 index 0000000..54cd61f Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-linear-gradient.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-linear-gradient.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-linear-gradient.windows.png new file mode 100644 index 0000000..54cd61f Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-linear-gradient.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-mask.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-mask.linux.png new file mode 100644 index 0000000..cec9c93 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-mask.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-mask.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-mask.macos.png new file mode 100644 index 0000000..cec9c93 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-mask.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-mask.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-mask.windows.png new file mode 100644 index 0000000..cec9c93 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-mask.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-path-and-transform.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-path-and-transform.linux.png new file mode 100644 index 0000000..4152a68 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-path-and-transform.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-path-and-transform.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-path-and-transform.macos.png new file mode 100644 index 0000000..d11d7a6 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-path-and-transform.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-path-and-transform.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-path-and-transform.windows.png new file mode 100644 index 0000000..4152a68 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-path-and-transform.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-pattern-fill.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-pattern-fill.linux.png new file mode 100644 index 0000000..caf4f1d Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-pattern-fill.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-pattern-fill.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-pattern-fill.macos.png new file mode 100644 index 0000000..caf4f1d Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-pattern-fill.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-pattern-fill.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-pattern-fill.windows.png new file mode 100644 index 0000000..caf4f1d Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-pattern-fill.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-percentage-lengths.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-percentage-lengths.linux.png new file mode 100644 index 0000000..5161a1f Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-percentage-lengths.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-percentage-lengths.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-percentage-lengths.macos.png new file mode 100644 index 0000000..8c7ab51 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-percentage-lengths.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-percentage-lengths.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-percentage-lengths.windows.png new file mode 100644 index 0000000..5161a1f Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-percentage-lengths.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-preserve-aspect-ratio-slice.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-preserve-aspect-ratio-slice.linux.png new file mode 100644 index 0000000..fb617d8 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-preserve-aspect-ratio-slice.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-preserve-aspect-ratio-slice.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-preserve-aspect-ratio-slice.macos.png new file mode 100644 index 0000000..fb617d8 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-preserve-aspect-ratio-slice.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-preserve-aspect-ratio-slice.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-preserve-aspect-ratio-slice.windows.png new file mode 100644 index 0000000..fb617d8 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-preserve-aspect-ratio-slice.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.linux.png new file mode 100644 index 0000000..e7480ac Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.macos.png new file mode 100644 index 0000000..e7480ac Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.windows.png new file mode 100644 index 0000000..e7480ac Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.linux.png new file mode 100644 index 0000000..65175fd Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.macos.png new file mode 100644 index 0000000..4bbbfa8 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.windows.png new file mode 100644 index 0000000..65175fd Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.linux.png new file mode 100644 index 0000000..22a65b1 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.macos.png new file mode 100644 index 0000000..93ae08d Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.windows.png new file mode 100644 index 0000000..2a7d497 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.linux.png new file mode 100644 index 0000000..0a808cd Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.macos.png new file mode 100644 index 0000000..fc477dc Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.windows.png new file mode 100644 index 0000000..0a808cd Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-svg-viewport.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-svg-viewport.linux.png new file mode 100644 index 0000000..2374224 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-svg-viewport.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-svg-viewport.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-svg-viewport.macos.png new file mode 100644 index 0000000..afa9f1c Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-svg-viewport.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-svg-viewport.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-svg-viewport.windows.png new file mode 100644 index 0000000..2374224 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-svg-viewport.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-positioned-circle-radial-gradient.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-positioned-circle-radial-gradient.linux.png new file mode 100644 index 0000000..f5b5ef0 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-positioned-circle-radial-gradient.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-positioned-circle-radial-gradient.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-positioned-circle-radial-gradient.macos.png new file mode 100644 index 0000000..f5b5ef0 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-positioned-circle-radial-gradient.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-positioned-circle-radial-gradient.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-positioned-circle-radial-gradient.windows.png new file mode 100644 index 0000000..f5b5ef0 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-positioned-circle-radial-gradient.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-conic-gradient.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-conic-gradient.linux.png new file mode 100644 index 0000000..155850b Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-conic-gradient.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-conic-gradient.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-conic-gradient.macos.png new file mode 100644 index 0000000..155850b Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-conic-gradient.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-conic-gradient.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-conic-gradient.windows.png new file mode 100644 index 0000000..155850b Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-conic-gradient.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-linear-gradient.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-linear-gradient.linux.png new file mode 100644 index 0000000..452bde0 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-linear-gradient.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-linear-gradient.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-linear-gradient.macos.png new file mode 100644 index 0000000..452bde0 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-linear-gradient.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-linear-gradient.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-linear-gradient.windows.png new file mode 100644 index 0000000..452bde0 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-linear-gradient.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-radial-gradient.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-radial-gradient.linux.png new file mode 100644 index 0000000..6f1b27a Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-radial-gradient.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-radial-gradient.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-radial-gradient.macos.png new file mode 100644 index 0000000..6f1b27a Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-radial-gradient.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-radial-gradient.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-radial-gradient.windows.png new file mode 100644 index 0000000..6f1b27a Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-radial-gradient.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.linux.png index 0de1fc9..4d367f2 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.linux.png and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.macos.png index 291fb49..0a4b0e7 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.macos.png and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.windows.png new file mode 100644 index 0000000..62863e3 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-svg-image-source.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-svg-image-source.linux.png new file mode 100644 index 0000000..aa9cef9 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-svg-image-source.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-svg-image-source.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-svg-image-source.macos.png new file mode 100644 index 0000000..aa9cef9 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-svg-image-source.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-svg-image-source.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-svg-image-source.windows.png new file mode 100644 index 0000000..aa9cef9 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-svg-image-source.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-symbol-via-use.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-symbol-via-use.linux.png new file mode 100644 index 0000000..d6b8df2 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-symbol-via-use.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-symbol-via-use.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-symbol-via-use.macos.png new file mode 100644 index 0000000..d6b8df2 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-symbol-via-use.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-symbol-via-use.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-symbol-via-use.windows.png new file mode 100644 index 0000000..d6b8df2 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-symbol-via-use.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.linux.png index 7239418..a358fe6 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.linux.png and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.macos.png index 5af5c73..2bffbca 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.macos.png and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.windows.png new file mode 100644 index 0000000..224514d Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.linux.png index 5809662..b31bce3 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.linux.png and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.macos.png index 2cac1c1..57c46c4 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.macos.png and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.windows.png new file mode 100644 index 0000000..6d47069 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.macos.png index 1990c77..c1309a5 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.macos.png and b/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.windows.png index b79e191..cd27229 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.windows.png and b/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.linux.png index d1c4c81..2122eaa 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.linux.png and b/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.macos.png index 75f12a3..32d6f10 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.macos.png and b/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.windows.png index 4074e5e..fc443a9 100644 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.windows.png and b/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.windows.png differ diff --git a/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj b/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj index 030e078..1f67325 100644 --- a/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj +++ b/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj @@ -20,7 +20,8 @@ true true snupkg - 1.5.0 + 1.8.0 + 1.1.0 @@ -29,7 +30,7 @@ - + diff --git a/src/AngleSharp.Renderer/CaretPosition.cs b/src/AngleSharp.Renderer/CaretPosition.cs new file mode 100644 index 0000000..24f0dc5 --- /dev/null +++ b/src/AngleSharp.Renderer/CaretPosition.cs @@ -0,0 +1,24 @@ +namespace AngleSharp.Dom; + +using AngleSharp.Dom.Geometry; + +internal sealed class CaretPosition : ICaretPosition +{ + private readonly IDomRect _clientRect; + + public CaretPosition(INode offsetNode, int offset, IDomRect clientRect) + { + ArgumentNullException.ThrowIfNull(offsetNode); + ArgumentNullException.ThrowIfNull(clientRect); + + OffsetNode = offsetNode; + Offset = Math.Max(0, offset); + _clientRect = clientRect; + } + + public INode OffsetNode { get; } + + public int Offset { get; } + + public IDomRect GetClientRect() => _clientRect; +} diff --git a/src/AngleSharp.Renderer/DocumentCssomViewExtensions.cs b/src/AngleSharp.Renderer/DocumentCssomViewExtensions.cs new file mode 100644 index 0000000..738dfd9 --- /dev/null +++ b/src/AngleSharp.Renderer/DocumentCssomViewExtensions.cs @@ -0,0 +1,173 @@ +namespace AngleSharp.Dom; + +using AngleSharp.Attributes; +using AngleSharp.Dom.Geometry; +using AngleSharp.Renderer; +using System.Linq; + +/// +/// Provides CSSOM View-style helpers for documents. +/// +public static class DocumentCssomViewExtensions +{ + /// + /// Returns a caret position for the given viewport coordinates. + /// + [DomName("caretPositionFromPoint")] + public static ICaretPosition? CaretPositionFromPoint(this IDocument document, double x, double y) + { + ArgumentNullException.ThrowIfNull(document); + + var harness = document.Context.GetDomHarness(); + var metricsMap = HtmlRenderer.CaptureLayoutMetrics(document, harness.RenderDevice); + var targetElement = FindTopMostElementAt(metricsMap, x, y); + + if (targetElement is null) + { + return null; + } + + if (!metricsMap.TryGetValue(targetElement, out var metrics)) + { + return null; + } + + var textNode = GetFirstTextNode(targetElement); + if (textNode is null) + { + var rect = new DomRect(metrics.BorderBoxX, metrics.BorderBoxY, 0d, metrics.BorderBoxHeight); + return new CaretPosition(targetElement, 0, rect); + } + + var style = targetElement.ComputeCurrentStyle(); + var fontSize = ParseLengthOrDefault(style?.GetPropertyValue("font-size"), (float)harness.RenderDevice.FontSize); + var lineHeightFactor = ParseLineHeightFactor(style?.GetPropertyValue("line-height"), 1.35f); + var caretHeight = Math.Max(1d, fontSize * lineHeightFactor); + var contentLeft = metrics.BorderBoxX + metrics.BorderLeft + metrics.PaddingLeft; + var contentTop = metrics.BorderBoxY + metrics.BorderTop + metrics.PaddingTop; + + var text = textNode.Data ?? string.Empty; + var averageCharWidth = Math.Max(1d, fontSize * 0.55d); + var maxX = contentLeft + (text.Length * averageCharWidth); + var clampedX = Math.Max(contentLeft, Math.Min(x, maxX)); + var offset = (int)Math.Round((clampedX - contentLeft) / averageCharWidth, MidpointRounding.AwayFromZero); + offset = Math.Clamp(offset, 0, text.Length); + + var caretX = contentLeft + (offset * averageCharWidth); + var rectAtCaret = new DomRect(caretX, contentTop, 0d, caretHeight); + + return new CaretPosition(textNode, offset, rectAtCaret); + } + + private static IText? GetFirstTextNode(IElement element) + { + foreach (var child in element.ChildNodes) + { + if (child is IText text && !string.IsNullOrEmpty(text.Data)) + { + return text; + } + + if (child is IElement childElement) + { + var nested = GetFirstTextNode(childElement); + if (nested is not null) + { + return nested; + } + } + } + + return null; + } + + private static IElement? FindTopMostElementAt(IReadOnlyDictionary metrics, double x, double y) + { + return metrics + .Where(pair => Contains(pair.Value, x, y)) + .OrderByDescending(pair => GetDepth(pair.Key)) + .ThenBy(pair => Math.Max(0f, pair.Value.BorderBoxWidth) * Math.Max(0f, pair.Value.BorderBoxHeight)) + .Select(pair => pair.Key) + .FirstOrDefault(); + } + + private static bool Contains(HtmlRenderer.ElementLayoutMetrics metrics, double x, double y) + { + var left = metrics.BorderBoxX; + var top = metrics.BorderBoxY; + var right = metrics.BorderBoxX + metrics.BorderBoxWidth; + var bottom = metrics.BorderBoxY + metrics.BorderBoxHeight; + + return x >= left && x <= right && y >= top && y <= bottom; + } + + private static int GetDepth(IElement element) + { + var depth = 0; + var current = element.ParentElement; + + while (current is not null) + { + depth++; + current = current.ParentElement; + } + + return depth; + } + + private static float ParseLengthOrDefault(string? value, float fallback) + { + if (string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + var normalized = value.Trim(); + + if (normalized.EndsWith("px", StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized[..^2].Trim(); + } + + if (float.TryParse(normalized, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + { + return Math.Max(1f, parsed); + } + + return fallback; + } + + private static float ParseLineHeightFactor(string? value, float fallback) + { + if (string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + var normalized = value.Trim().ToLowerInvariant(); + + if (normalized == "normal") + { + return fallback; + } + + if (normalized.EndsWith("%", StringComparison.Ordinal) && + float.TryParse(normalized[..^1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var percent)) + { + return Math.Max(0.1f, percent / 100f); + } + + if (normalized.EndsWith("px", StringComparison.Ordinal)) + { + normalized = normalized[..^2].Trim(); + } + + if (float.TryParse(normalized, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + { + return Math.Max(0.1f, parsed); + } + + return fallback; + } +} + diff --git a/src/AngleSharp.Renderer/DocumentRenderingExtensions.cs b/src/AngleSharp.Renderer/DocumentRenderingExtensions.cs index 218160c..d357f1a 100644 --- a/src/AngleSharp.Renderer/DocumentRenderingExtensions.cs +++ b/src/AngleSharp.Renderer/DocumentRenderingExtensions.cs @@ -1,7 +1,8 @@ -using AngleSharp.Dom; -using AngleSharp.Renderer.Rendering; +namespace AngleSharp.Dom; -namespace AngleSharp.Renderer; +using AngleSharp.Css; +using AngleSharp.Renderer; +using AngleSharp.Renderer.Rendering; /// /// Provides convenience extension methods for document rendering. @@ -12,11 +13,24 @@ public static class DocumentRenderingExtensions /// Renders a document to PNG bytes with the default renderer. /// /// The source document. - /// Optional rendering settings. /// The rendered PNG image. - public static RenderedImage RenderToPng(this IDocument document, HtmlRenderOptions? options = null) + public static RenderedImage RenderToPng(this IDocument document) { + var renderDevice = document.Context.GetService(); + return document.RenderToPng(renderDevice!); + } + + /// + /// Renders a document to PNG bytes with the default renderer. + /// + /// The source document. + /// The render device used for rendering. + /// The rendered PNG image. + public static RenderedImage RenderToPng(this IDocument document, IRenderDevice renderDevice) + { + ArgumentNullException.ThrowIfNull(renderDevice); + var renderer = new HtmlRenderer(); - return renderer.RenderToPng(document, options); + return renderer.RenderToPng(document, renderDevice); } } \ No newline at end of file diff --git a/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs b/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs new file mode 100644 index 0000000..8a8098e --- /dev/null +++ b/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs @@ -0,0 +1,565 @@ +namespace AngleSharp.Dom; + +using AngleSharp; +using AngleSharp.Attributes; +using AngleSharp.Dom.Geometry; +using AngleSharp.Renderer; + +/// +/// Provides CSSOM View-style geometry helpers for elements. +/// +public static class ElementCssomViewExtensions +{ + /// + /// Returns the element's border-box rectangle in viewport coordinates. + /// + [DomName("getBoundingClientRect")] + public static IDomRect GetBoundingClientRect(this IElement element) + { + ArgumentNullException.ThrowIfNull(element); + + var metricsMap = GetMetricsMap(element); + if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) + { + return new DomRect(); + } + + return new DomRect(metrics.BorderBoxX, metrics.BorderBoxY, metrics.BorderBoxWidth, metrics.BorderBoxHeight); + } + + /// + /// Returns the list of border-box fragments for the element. + /// + [DomName("getClientRects")] + public static IDomRectList GetClientRects(this IElement element) + { + ArgumentNullException.ThrowIfNull(element); + + var metricsMap = GetMetricsMap(element); + if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) + { + return new DomRectList(); + } + + if (metrics.BorderBoxWidth <= 0f && metrics.BorderBoxHeight <= 0f) + { + return new DomRectList(); + } + + return new DomRectList(new IDomRect[] + { + new DomRect(metrics.BorderBoxX, metrics.BorderBoxY, metrics.BorderBoxWidth, metrics.BorderBoxHeight), + }); + } + + /// + /// Returns the inner width including padding, excluding borders. + /// + [DomName("clientWidth")] + public static int GetClientWidth(this IElement element) + { + return GetRoundedDimension(element, static metrics => metrics.BorderBoxWidth - metrics.BorderLeft - metrics.BorderRight); + } + + /// + /// Returns the left border width. + /// + [DomName("clientLeft")] + public static int GetClientLeft(this IElement element) + { + return GetRoundedDimension(element, static metrics => metrics.BorderLeft); + } + + /// + /// Returns the inner height including padding, excluding borders. + /// + [DomName("clientHeight")] + public static int GetClientHeight(this IElement element) + { + return GetRoundedDimension(element, static metrics => metrics.BorderBoxHeight - metrics.BorderTop - metrics.BorderBottom); + } + + /// + /// Returns the top border width. + /// + [DomName("clientTop")] + public static int GetClientTop(this IElement element) + { + return GetRoundedDimension(element, static metrics => metrics.BorderTop); + } + + /// + /// Returns the width of the element's scrolling area. + /// + [DomName("scrollWidth")] + public static int GetScrollWidth(this IElement element) + { + return GetScrollExtents(element).Width; + } + + /// + /// Returns the height of the element's scrolling area. + /// + [DomName("scrollHeight")] + public static int GetScrollHeight(this IElement element) + { + return GetScrollExtents(element).Height; + } + + /// + /// Returns the current horizontal scroll position. + /// + [DomName("scrollLeft")] + public static double GetScrollLeft(this IElement element) + { + ArgumentNullException.ThrowIfNull(element); + + var maxLeft = GetMaxScrollLeft(element); + var state = GetInteractiveState(element); + return state is null ? 0d : state.GetScrollLeft(element, maxLeft); + } + + /// + /// Sets the horizontal scroll position. + /// + [DomName("scrollLeft")] + public static void SetScrollLeft(this IElement element, double value) + { + ArgumentNullException.ThrowIfNull(element); + + var state = GetInteractiveState(element); + if (state is null) + { + return; + } + + state.SetScrollLeft(element, value, GetMaxScrollLeft(element)); + } + + /// + /// Returns the current vertical scroll position. + /// + [DomName("scrollTop")] + public static double GetScrollTop(this IElement element) + { + ArgumentNullException.ThrowIfNull(element); + + var maxTop = GetMaxScrollTop(element); + var state = GetInteractiveState(element); + return state is null ? 0d : state.GetScrollTop(element, maxTop); + } + + /// + /// Sets the vertical scroll position. + /// + [DomName("scrollTop")] + public static void SetScrollTop(this IElement element, double value) + { + ArgumentNullException.ThrowIfNull(element); + + var state = GetInteractiveState(element); + if (state is null) + { + return; + } + + state.SetScrollTop(element, value, GetMaxScrollTop(element)); + } + + /// + /// Sets scroll positions to absolute coordinates. + /// + [DomName("scrollTo")] + public static void ScrollTo(this IElement element, double x, double y) + { + ArgumentNullException.ThrowIfNull(element); + + SetScrollLeft(element, x); + SetScrollTop(element, y); + } + + /// + /// Sets scroll positions from options. + /// + [DomName("scrollTo")] + public static void ScrollTo(this IElement element, ScrollToOptions options) + { + ArgumentNullException.ThrowIfNull(element); + ArgumentNullException.ThrowIfNull(options); + + var targetX = options.Left ?? GetScrollLeft(element); + var targetY = options.Top ?? GetScrollTop(element); + ScrollTo(element, targetX, targetY); + } + + /// + /// Scroll alias for setting absolute coordinates. + /// + [DomName("scroll")] + public static void Scroll(this IElement element, double x, double y) + { + ScrollTo(element, x, y); + } + + /// + /// Scroll alias for setting absolute coordinates from options. + /// + [DomName("scroll")] + public static void Scroll(this IElement element, ScrollToOptions options) + { + ScrollTo(element, options); + } + + /// + /// Adjusts scroll positions by the supplied deltas. + /// + [DomName("scrollBy")] + public static void ScrollBy(this IElement element, double x, double y) + { + ArgumentNullException.ThrowIfNull(element); + + ScrollTo(element, GetScrollLeft(element) + x, GetScrollTop(element) + y); + } + + /// + /// Adjusts scroll positions by the supplied option deltas. + /// + [DomName("scrollBy")] + public static void ScrollBy(this IElement element, ScrollToOptions options) + { + ArgumentNullException.ThrowIfNull(element); + ArgumentNullException.ThrowIfNull(options); + + var deltaX = options.Left ?? 0d; + var deltaY = options.Top ?? 0d; + ScrollBy(element, deltaX, deltaY); + } + + /// + /// Scrolls ancestor containers to reveal the element. + /// + [DomName("scrollIntoView")] + public static void ScrollIntoView(this IElement element) + { + ScrollIntoView(element, true); + } + + /// + /// Scrolls ancestor containers to reveal the element using legacy align-to-top behavior. + /// + [DomName("scrollIntoView")] + public static void ScrollIntoView(this IElement element, bool alignToTop) + { + ArgumentNullException.ThrowIfNull(element); + + var block = alignToTop ? ScrollLogicalPosition.Start : ScrollLogicalPosition.End; + ScrollElementIntoView(element, block, ScrollLogicalPosition.Nearest); + } + + /// + /// Scrolls ancestor containers to reveal the element using options. + /// + [DomName("scrollIntoView")] + public static void ScrollIntoView(this IElement element, ScrollIntoViewOptions options) + { + ArgumentNullException.ThrowIfNull(element); + ArgumentNullException.ThrowIfNull(options); + + ScrollElementIntoView(element, options.Block, options.Inline); + } + + /// + /// Returns the border-box width. + /// + [DomName("offsetWidth")] + public static int GetOffsetWidth(this IElement element) + { + return GetRoundedDimension(element, static metrics => metrics.BorderBoxWidth); + } + + /// + /// Returns the border-box height. + /// + [DomName("offsetHeight")] + public static int GetOffsetHeight(this IElement element) + { + return GetRoundedDimension(element, static metrics => metrics.BorderBoxHeight); + } + + /// + /// Returns the left offset relative to the offset parent's padding edge. + /// + [DomName("offsetLeft")] + public static int GetOffsetLeft(this IElement element) + { + ArgumentNullException.ThrowIfNull(element); + + var metricsMap = GetMetricsMap(element); + if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) + { + return 0; + } + + var offsetParent = GetOffsetParent(element); + if (offsetParent is null || !metricsMap.TryGetValue(offsetParent, out var parentMetrics)) + { + return (int)Math.Round(metrics.BorderBoxX); + } + + var relativeLeft = metrics.BorderBoxX - (parentMetrics.BorderBoxX + parentMetrics.BorderLeft); + return (int)Math.Round(relativeLeft); + } + + /// + /// Returns the top offset relative to the offset parent's padding edge. + /// + [DomName("offsetTop")] + public static int GetOffsetTop(this IElement element) + { + ArgumentNullException.ThrowIfNull(element); + + var metricsMap = GetMetricsMap(element); + if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) + { + return 0; + } + + var offsetParent = GetOffsetParent(element); + if (offsetParent is null || !metricsMap.TryGetValue(offsetParent, out var parentMetrics)) + { + return (int)Math.Round(metrics.BorderBoxY); + } + + var relativeTop = metrics.BorderBoxY - (parentMetrics.BorderBoxY + parentMetrics.BorderTop); + return (int)Math.Round(relativeTop); + } + + /// + /// Returns the nearest offset parent. + /// + [DomName("offsetParent")] + public static IElement? GetOffsetParent(this IElement element) + { + ArgumentNullException.ThrowIfNull(element); + + if (IsPositionFixed(element)) + { + return null; + } + + var ancestor = element.ParentElement; + + while (ancestor is not null) + { + if (!IsPositionStatic(ancestor)) + { + return ancestor; + } + + ancestor = ancestor.ParentElement; + } + + return element.Owner?.Body; + } + + private static int GetRoundedDimension(IElement element, Func selector) + { + ArgumentNullException.ThrowIfNull(element); + + var metricsMap = GetMetricsMap(element); + if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) + { + return 0; + } + + var value = Math.Max(0f, selector(metrics)); + return (int)Math.Round(value); + } + + private static IReadOnlyDictionary? GetMetricsMap(IElement element) + { + var owner = element.Owner; + if (owner is null) + { + return null; + } + + var harness = owner.Context.GetDomHarness(); + return HtmlRenderer.CaptureLayoutMetrics(owner, harness.RenderDevice); + } + + private static IDomHarness? GetInteractiveState(IElement element) + { + var owner = element.Owner; + return owner?.Context.GetDomHarness(); + } + + private static (int Width, int Height) GetScrollExtents(IElement element) + { + ArgumentNullException.ThrowIfNull(element); + + var metricsMap = GetMetricsMap(element); + if (metricsMap is null || !metricsMap.TryGetValue(element, out var elementMetrics)) + { + return (0, 0); + } + + var clientWidth = Math.Max(0d, elementMetrics.BorderBoxWidth - elementMetrics.BorderLeft - elementMetrics.BorderRight); + var clientHeight = Math.Max(0d, elementMetrics.BorderBoxHeight - elementMetrics.BorderTop - elementMetrics.BorderBottom); + var paddingBoxLeft = elementMetrics.BorderBoxX + elementMetrics.BorderLeft; + var paddingBoxTop = elementMetrics.BorderBoxY + elementMetrics.BorderTop; + var rightExtent = paddingBoxLeft + clientWidth; + var bottomExtent = paddingBoxTop + clientHeight; + + foreach (var (candidate, candidateMetrics) in metricsMap) + { + if (ReferenceEquals(candidate, element) || !IsDescendantOf(candidate, element)) + { + continue; + } + + rightExtent = Math.Max(rightExtent, candidateMetrics.BorderBoxX + candidateMetrics.BorderBoxWidth); + bottomExtent = Math.Max(bottomExtent, candidateMetrics.BorderBoxY + candidateMetrics.BorderBoxHeight); + } + + var scrollWidth = (int)Math.Round(Math.Max(clientWidth, rightExtent - paddingBoxLeft)); + var scrollHeight = (int)Math.Round(Math.Max(clientHeight, bottomExtent - paddingBoxTop)); + + return (Math.Max(0, scrollWidth), Math.Max(0, scrollHeight)); + } + + private static double GetMaxScrollLeft(IElement element) + { + var extents = GetScrollExtents(element); + var maxLeft = extents.Width - GetClientWidth(element); + return Math.Max(0d, maxLeft); + } + + private static double GetMaxScrollTop(IElement element) + { + var extents = GetScrollExtents(element); + var maxTop = extents.Height - GetClientHeight(element); + return Math.Max(0d, maxTop); + } + + private static bool IsDescendantOf(IElement candidate, IElement ancestor) + { + var current = candidate.ParentElement; + + while (current is not null) + { + if (ReferenceEquals(current, ancestor)) + { + return true; + } + + current = current.ParentElement; + } + + return false; + } + + private static bool IsPositionFixed(IElement element) + { + var style = element.ComputeCurrentStyle(); + var position = style?.GetPropertyValue("position"); + return string.Equals(position?.Trim(), "fixed", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsPositionStatic(IElement element) + { + var style = element.ComputeCurrentStyle(); + var position = style?.GetPropertyValue("position"); + + if (string.IsNullOrWhiteSpace(position)) + { + return true; + } + + return string.Equals(position.Trim(), "static", StringComparison.OrdinalIgnoreCase); + } + + private static void ScrollElementIntoView(IElement element, ScrollLogicalPosition block, ScrollLogicalPosition inline) + { + var metricsMap = GetMetricsMap(element); + if (metricsMap is null || !metricsMap.TryGetValue(element, out var elementMetrics)) + { + return; + } + + var ancestor = element.ParentElement; + + while (ancestor is not null) + { + if (!metricsMap.TryGetValue(ancestor, out var ancestorMetrics) || !IsScrollable(ancestor, ancestorMetrics)) + { + ancestor = ancestor.ParentElement; + continue; + } + + var clientWidth = Math.Max(0d, ancestorMetrics.BorderBoxWidth - ancestorMetrics.BorderLeft - ancestorMetrics.BorderRight); + var clientHeight = Math.Max(0d, ancestorMetrics.BorderBoxHeight - ancestorMetrics.BorderTop - ancestorMetrics.BorderBottom); + var paddingLeft = ancestorMetrics.BorderBoxX + ancestorMetrics.BorderLeft; + var paddingTop = ancestorMetrics.BorderBoxY + ancestorMetrics.BorderTop; + var elementLeft = elementMetrics.BorderBoxX - paddingLeft; + var elementTop = elementMetrics.BorderBoxY - paddingTop; + var elementRight = elementLeft + elementMetrics.BorderBoxWidth; + var elementBottom = elementTop + elementMetrics.BorderBoxHeight; + + var currentLeft = ancestor.GetScrollLeft(); + var currentTop = ancestor.GetScrollTop(); + var targetLeft = ResolveScrollOffset(currentLeft, elementLeft, elementRight, clientWidth, inline); + var targetTop = ResolveScrollOffset(currentTop, elementTop, elementBottom, clientHeight, block); + + ancestor.SetScrollLeft(targetLeft); + ancestor.SetScrollTop(targetTop); + ancestor = ancestor.ParentElement; + } + } + + private static double ResolveScrollOffset(double current, double start, double end, double viewportSize, ScrollLogicalPosition position) + { + if (viewportSize <= 0d) + { + return current; + } + + return position switch + { + ScrollLogicalPosition.Start => start, + ScrollLogicalPosition.End => end - viewportSize, + ScrollLogicalPosition.Center => start - ((viewportSize - (end - start)) / 2d), + _ => ResolveNearest(current, start, end, viewportSize), + }; + } + + private static double ResolveNearest(double current, double start, double end, double viewportSize) + { + var viewportEnd = current + viewportSize; + var size = end - start; + + if (size > viewportSize) + { + return start; + } + + if (start < current) + { + return start; + } + + if (end > viewportEnd) + { + return end - viewportSize; + } + + return current; + } + + private static bool IsScrollable(IElement element, HtmlRenderer.ElementLayoutMetrics metrics) + { + var clientWidth = Math.Max(0d, metrics.BorderBoxWidth - metrics.BorderLeft - metrics.BorderRight); + var clientHeight = Math.Max(0d, metrics.BorderBoxHeight - metrics.BorderTop - metrics.BorderBottom); + return element.GetScrollWidth() > clientWidth || element.GetScrollHeight() > clientHeight; + } +} diff --git a/src/AngleSharp.Renderer/FontFaceLoader.cs b/src/AngleSharp.Renderer/FontFaceLoader.cs new file mode 100644 index 0000000..72155e0 --- /dev/null +++ b/src/AngleSharp.Renderer/FontFaceLoader.cs @@ -0,0 +1,319 @@ +namespace AngleSharp.Renderer; + +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +using AngleSharp.Css.Dom; +using AngleSharp.Dom; +using AngleSharp.Io; +using AngleSharp.Renderer.Rendering; + +/// +/// Turns the @font-face rules of a document into a . +/// +internal static class FontFaceLoader +{ + // Skia reads raw TrueType/OpenType only. The compressed web wrappers would need a decoder + // this library does not carry, so those sources are skipped and the next one is tried. + private static readonly HashSet UnsupportedFormats = + new(StringComparer.OrdinalIgnoreCase) { "woff", "woff2", "svg", "embedded-opentype" }; + + public static FontFaceSet Load(IDocument document) + { + var faces = new List(); + + foreach (var sheet in document.StyleSheets.OfType()) + { + foreach (var rule in sheet.Rules.OfType()) + { + if (TryCreateFace(document, rule, out var face)) + { + faces.Add(face); + } + } + } + + return faces.Count == 0 ? FontFaceSet.Empty : new FontFaceSet(faces); + } + + private static bool TryCreateFace(IDocument document, ICssFontFaceRule rule, out FontFace face) + { + face = null!; + + var family = Unquote(rule.Family); + + if (string.IsNullOrWhiteSpace(family) || string.IsNullOrWhiteSpace(rule.Source)) + { + return false; + } + + var weight = ParseWeight(rule.Weight); + var isItalic = IsItalic(rule.Style); + var sources = new List(); + + // The declaration order is preserved. Whether a local() source exists is a property of the + // machine, so that decision belongs to the backend rather than to this loader. + foreach (var source in SplitSources(rule.Source)) + { + if (TryParseLocal(source, out var localFamily)) + { + sources.Add(FontFaceSource.FromLocal(localFamily)); + } + else if (TryParseUrl(source, out var url, out var format)) + { + if (format is not null && UnsupportedFormats.Contains(format)) + { + continue; + } + + if (TryLoadFontData(document, url, out var data)) + { + sources.Add(FontFaceSource.FromData(data)); + } + } + } + + if (sources.Count == 0) + { + return false; + } + + face = new FontFace(family, weight, isItalic, sources); + return true; + } + + private static bool TryLoadFontData(IDocument document, string url, out byte[] data) + { + data = []; + + if (TryDecodeDataUri(url, out var inlineData)) + { + data = inlineData; + return IsSupportedFontData(data); + } + + // Matching how images are handled, nothing is fetched unless the browsing context was + // configured with a loader. A renderer should not silently reach out to the network. + var loader = document.Context.GetService(); + + if (loader is null) + { + return false; + } + + try + { + var target = new Url(document.BaseUrl, url); + var download = loader.FetchAsync(DocumentRequest.Get(target, source: document, referer: document.BaseUri)); + var response = download.Task.GetAwaiter().GetResult(); + + if (response?.Content is null) + { + return false; + } + + using var content = response.Content; + using var buffer = new MemoryStream(); + content.CopyTo(buffer); + data = buffer.ToArray(); + + return IsSupportedFontData(data); + } + catch + { + return false; + } + } + + /// + /// Rejects the compressed wrappers up front, so a face that could never be rasterized does not + /// shadow the next source or the next family in the fallback list. + /// + private static bool IsSupportedFontData(byte[] data) + { + if (data.Length < 4) + { + return false; + } + + var tag = Encoding.ASCII.GetString(data, 0, 4); + + return tag is not ("wOFF" or "wOF2"); + } + + private static IEnumerable SplitSources(string source) + { + var depth = 0; + var quote = '\0'; + var start = 0; + + for (var index = 0; index < source.Length; index++) + { + var current = source[index]; + + if (quote != '\0') + { + if (current == quote) + { + quote = '\0'; + } + } + else if (current is '\'' or '"') + { + quote = current; + } + else if (current == '(') + { + depth++; + } + else if (current == ')') + { + depth--; + } + else if (current == ',' && depth == 0) + { + yield return source[start..index]; + start = index + 1; + } + } + + if (start < source.Length) + { + yield return source[start..]; + } + } + + private static bool TryParseLocal(string source, out string localFamily) + { + localFamily = string.Empty; + + var value = ExtractFunctionArgument(source.Trim(), "local"); + + if (value is null) + { + return false; + } + + localFamily = Unquote(value); + return localFamily.Length > 0; + } + + private static bool TryParseUrl(string source, out string url, out string? format) + { + url = string.Empty; + format = null; + + var trimmed = source.Trim(); + var value = ExtractFunctionArgument(trimmed, "url"); + + if (value is null) + { + return false; + } + + url = Unquote(value); + + var formatIndex = trimmed.IndexOf("format", StringComparison.OrdinalIgnoreCase); + + if (formatIndex >= 0) + { + var formatValue = ExtractFunctionArgument(trimmed[formatIndex..], "format"); + + if (formatValue is not null) + { + format = Unquote(formatValue); + } + } + + return url.Length > 0; + } + + private static string? ExtractFunctionArgument(string source, string functionName) + { + if (!source.StartsWith(functionName, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var open = source.IndexOf('(', functionName.Length); + + if (open < 0 || source[functionName.Length..open].Trim().Length > 0) + { + return null; + } + + var close = source.IndexOf(')', open); + + return close < 0 ? null : source[(open + 1)..close].Trim(); + } + + private static bool TryDecodeDataUri(string url, out byte[] data) + { + data = []; + + if (!url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var separator = url.IndexOf(','); + + if (separator < 0) + { + return false; + } + + var metadata = url[5..separator]; + var payload = url[(separator + 1)..]; + + try + { + data = metadata.Contains("base64", StringComparison.OrdinalIgnoreCase) + ? Convert.FromBase64String(payload.Trim()) + : Encoding.ASCII.GetBytes(Uri.UnescapeDataString(payload)); + + return data.Length > 0; + } + catch (FormatException) + { + return false; + } + } + + private static float ParseWeight(string? weight) + { + if (string.IsNullOrWhiteSpace(weight)) + { + return 400f; + } + + var trimmed = weight.Trim(); + + if (string.Equals(trimmed, "bold", StringComparison.OrdinalIgnoreCase)) + { + return 700f; + } + + if (string.Equals(trimmed, "normal", StringComparison.OrdinalIgnoreCase)) + { + return 400f; + } + + // A weight range such as "400 700" contributes its lower bound. + var first = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + + return float.TryParse(first, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : 400f; + } + + private static bool IsItalic(string? style) => + style is not null && + (style.Contains("italic", StringComparison.OrdinalIgnoreCase) || + style.Contains("oblique", StringComparison.OrdinalIgnoreCase)); + + private static string Unquote(string? value) => + value is null ? string.Empty : value.Trim().Trim('\'', '"').Trim(); +} diff --git a/src/AngleSharp.Renderer/HtmlRenderOptions.cs b/src/AngleSharp.Renderer/HtmlRenderOptions.cs deleted file mode 100644 index 7f5e14f..0000000 --- a/src/AngleSharp.Renderer/HtmlRenderOptions.cs +++ /dev/null @@ -1,59 +0,0 @@ -using AngleSharp.Renderer.Rendering; - -namespace AngleSharp.Renderer; - -/// -/// Defines options for HTML-to-image rendering. -/// -public sealed class HtmlRenderOptions -{ - /// - /// Gets or sets the viewport width. - /// - public int Width { get; set; } = 1024; - - /// - /// Gets or sets the viewport height. - /// - public int Height { get; set; } = 768; - - /// - /// Gets or sets the background color of the output image. - /// - public RenderColor BackgroundColor { get; set; } = RenderColor.White; - - /// - /// Gets or sets the foreground text color. - /// - public RenderColor TextColor { get; set; } = RenderColor.Black; - - /// - /// Gets or sets the content padding in pixels. - /// - public float Padding { get; set; } = 16f; - - /// - /// Gets or sets the fallback font family used by the first draft renderer. - /// - public string FontFamily { get; set; } = "sans-serif"; - - /// - /// Gets or sets the fallback font size in pixels. - /// - public float FontSize { get; set; } = 16f; - - /// - /// Gets or sets the line-height multiplier used during layout. - /// - public float LineHeightMultiplier { get; set; } = 1.35f; - - /// - /// Gets or sets the additional spacing inserted between block paragraphs. - /// - public float ParagraphSpacing { get; set; } = 8f; - - /// - /// Gets or sets the average character width factor used by draft text measurement. - /// - public float AverageCharacterWidthFactor { get; set; } = 0.55f; -} \ No newline at end of file diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 5c037f6..05268a4 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -1,22 +1,145 @@ +namespace AngleSharp.Renderer; + using System.Globalization; +using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; +using System.Threading; using AngleSharp.Css; using AngleSharp.Css.Dom; using AngleSharp.Css.RenderTree; using AngleSharp.Dom; +using AngleSharp.Io; using AngleSharp.Renderer.Rendering; using AngleSharp.Renderer.Skia; -namespace AngleSharp.Renderer; +using SkiaSharp; /// /// Renders HTML documents into image output. /// public sealed class HtmlRenderer { + // Per-document cache keeps image payloads stable across repeated renders of the same DOM instance. + private static readonly ConditionalWeakTable s_imageCacheByDocument = new(); + // Inline markup has no URL to key a per-document cache on, so it is cached per element instead. + private static readonly ConditionalWeakTable s_inlineSvgCacheByElement = new(); + private static readonly AsyncLocal s_layoutCapture = new(); + private static readonly ITextMeasurer s_defaultTextMeasurer = new SkiaTextMeasurer(); + + private const float CollapsedBorderWidth = 1f; + private readonly IRenderBackend _backend; + private readonly ITextMeasurer _textMeasurer; + + private sealed class DocumentImageCache + { + public Dictionary Resources { get; } = new(StringComparer.Ordinal); + } + + private sealed record CachedImageResource(byte[] Bytes, string MimeType, int NaturalWidth, int NaturalHeight); + + internal readonly record struct ElementLayoutMetrics( + float BorderBoxX, + float BorderBoxY, + float BorderBoxWidth, + float BorderBoxHeight, + float BorderLeft, + float BorderRight, + float BorderTop, + float BorderBottom, + float PaddingLeft, + float PaddingRight, + float PaddingTop, + float PaddingBottom); + + private readonly record struct LayoutContext( + int Width, + int Height, + RenderColor BackgroundColor, + RenderColor TextColor, + float Padding, + string FontFamily, + float FontSize, + float LineHeightMultiplier, + float ParagraphSpacing, + ITextMeasurer TextMeasurer, + FontFaceSet Fonts); + + private sealed class LayoutCapture + { + private readonly Dictionary _metricsByElement = new(ReferenceEqualityComparer.Instance); + + public void Record(IElement element, ElementLayoutMetrics metrics) + { + _metricsByElement[element] = metrics; + } + + public IReadOnlyDictionary Snapshot() + { + return _metricsByElement; + } + } + + internal static IReadOnlyDictionary CaptureLayoutMetrics(IDocument document, IRenderDevice renderDevice) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(renderDevice); + + var context = CreateLayoutContext(document, renderDevice, s_defaultTextMeasurer); + var viewport = new RenderViewport(context.Width, context.Height); + var capture = new LayoutCapture(); + var previous = s_layoutCapture.Value; + s_layoutCapture.Value = capture; + + try + { + _ = BuildDisplayList(document, viewport, context, renderDevice); + return capture.Snapshot(); + } + finally + { + s_layoutCapture.Value = previous; + } + } + + private static void RecordLayoutMetrics( + IElement element, + float borderBoxX, + float borderBoxY, + float borderBoxWidth, + float borderBoxHeight, + float borderLeft, + float borderRight, + float borderTop, + float borderBottom, + float paddingLeft, + float paddingRight, + float paddingTop, + float paddingBottom) + { + var capture = s_layoutCapture.Value; + if (capture is null) + { + return; + } + + capture.Record(element, new ElementLayoutMetrics( + borderBoxX, + borderBoxY, + borderBoxWidth, + borderBoxHeight, + borderLeft, + borderRight, + borderTop, + borderBottom, + paddingLeft, + paddingRight, + paddingTop, + paddingBottom)); + } /// /// Creates a new renderer with a default Skia backend. @@ -27,28 +150,91 @@ public HtmlRenderer() } /// - /// Creates a new renderer with a specific backend. + /// Creates a new renderer with a specific backend. A backend that measures text itself is + /// used for layout as well, so that layout and rasterization agree on advance widths. /// /// The backend used for rasterization. public HtmlRenderer(IRenderBackend backend) + : this(backend, (backend as ITextMeasurer) ?? s_defaultTextMeasurer) + { + } + + /// + /// Creates a new renderer with a specific backend and text measurer. + /// + /// The backend used for rasterization. + /// The measurer used to compute advance widths during layout. + public HtmlRenderer(IRenderBackend backend, ITextMeasurer textMeasurer) { ArgumentNullException.ThrowIfNull(backend); + ArgumentNullException.ThrowIfNull(textMeasurer); _backend = backend; + _textMeasurer = textMeasurer; + } + + private static LayoutContext CreateLayoutContext(IDocument document, IRenderDevice renderDevice, ITextMeasurer textMeasurer) + { + var width = Math.Max(1, (int)Math.Round(Convert.ToDouble(renderDevice.ViewPortWidth))); + var height = Math.Max(1, (int)Math.Round(Convert.ToDouble(renderDevice.ViewPortHeight))); + var defaultFontSize = Math.Max(1f, (float)renderDevice.FontSize); + var defaultFontFamily = "sans-serif"; + var defaultLineHeight = 1.35f; + var defaultTextColor = RenderColor.Black; + var defaultBackgroundColor = RenderColor.White; + + var rootElement = document.Body ?? document.DocumentElement; + if (rootElement is not null) + { + var styleMap = CreateStyleMap(rootElement.ComputeCurrentStyle(), rootElement); + defaultFontSize = ParseLength(styleMap, "font-size", defaultFontSize, defaultFontSize, allowAuto: false); + defaultFontFamily = styleMap.TryGetValue("font-family", out var family) && !string.IsNullOrWhiteSpace(family) + ? family.Trim('\'', '"', ' ') + : defaultFontFamily; + defaultLineHeight = ParseLineHeight(styleMap, defaultLineHeight); + defaultTextColor = ParseColor(styleMap.TryGetValue("color", out var colorValue) ? colorValue : null, defaultTextColor); + defaultBackgroundColor = ParseColor(styleMap.TryGetValue("background-color", out var backgroundValue) ? backgroundValue : null, defaultBackgroundColor); + } + + return new LayoutContext( + Width: width, + Height: height, + BackgroundColor: defaultBackgroundColor, + TextColor: defaultTextColor, + Padding: 0f, + FontFamily: defaultFontFamily, + FontSize: defaultFontSize, + LineHeightMultiplier: defaultLineHeight, + ParagraphSpacing: 0f, + TextMeasurer: textMeasurer, + Fonts: FontFaceLoader.Load(document)); + } + + /// + /// Renders the given document to a PNG image. + /// + /// The source document. + /// The rendered PNG image. + public RenderedImage RenderToPng(IDocument document) + { + ArgumentNullException.ThrowIfNull(document); + var renderDevice = document.Context.GetService(); + return RenderToPng(document, renderDevice!); } /// /// Renders the given document to a PNG image. /// /// The source document. - /// Optional rendering settings. + /// The render device used for viewport and typography defaults. /// The rendered PNG image. - public RenderedImage RenderToPng(IDocument document, HtmlRenderOptions? options = null) + public RenderedImage RenderToPng(IDocument document, IRenderDevice renderDevice) { ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(renderDevice); - var effectiveOptions = options ?? new HtmlRenderOptions(); - var viewport = new RenderViewport(effectiveOptions.Width, effectiveOptions.Height); - var displayList = BuildDisplayList(document, viewport, effectiveOptions); + var context = CreateLayoutContext(document, renderDevice, _textMeasurer); + var viewport = new RenderViewport(context.Width, context.Height); + var displayList = BuildDisplayList(document, viewport, context, renderDevice); return _backend.RenderToPng(displayList, viewport); } @@ -57,21 +243,34 @@ public RenderedImage RenderToPng(IDocument document, HtmlRenderOptions? options /// Builds a display list from the given document. /// /// The source document. - /// Optional rendering settings. /// The generated display list. - public DisplayList BuildDisplayList(IDocument document, HtmlRenderOptions? options = null) + public DisplayList BuildDisplayList(IDocument document) + { + ArgumentNullException.ThrowIfNull(document); + var renderDevice = document.Context.GetService(); + return BuildDisplayList(document, renderDevice!); + } + + /// + /// Builds a display list from the given document. + /// + /// The source document. + /// The render device used for viewport and typography defaults. + /// The generated display list. + public DisplayList BuildDisplayList(IDocument document, IRenderDevice renderDevice) { ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(renderDevice); - var effectiveOptions = options ?? new HtmlRenderOptions(); - var viewport = new RenderViewport(effectiveOptions.Width, effectiveOptions.Height); - return BuildDisplayList(document, viewport, effectiveOptions); + var context = CreateLayoutContext(document, renderDevice, _textMeasurer); + var viewport = new RenderViewport(context.Width, context.Height); + return BuildDisplayList(document, viewport, context, renderDevice); } - private static DisplayList BuildDisplayList(IDocument document, RenderViewport viewport, HtmlRenderOptions options) + private static DisplayList BuildDisplayList(IDocument document, RenderViewport viewport, LayoutContext context, IRenderDevice renderDevice) { - var displayList = new DisplayList(); - displayList.FillRect(new RenderRect(0f, 0f, viewport.Width, viewport.Height), options.BackgroundColor); + var displayList = new DisplayList { Fonts = context.Fonts }; + displayList.FillRect(new RenderRect(0f, 0f, viewport.Width, viewport.Height), context.BackgroundColor); var window = document.DefaultView; if (window is null) @@ -79,29 +278,22 @@ private static DisplayList BuildDisplayList(IDocument document, RenderViewport v return displayList; } - var renderDevice = new DefaultRenderDevice - { - ViewPortWidth = viewport.Width, - ViewPortHeight = viewport.Height, - DeviceWidth = viewport.Width, - DeviceHeight = viewport.Height, - FontSize = options.FontSize, - }; + PrepareDocumentForRendering(document); var renderTree = window.Render(renderDevice); var body = document.Body; var root = body is null ? renderTree : renderTree.Find(body) ?? renderTree; - var contentX = options.Padding; - var contentY = options.Padding; - var contentWidth = viewport.Width - (2f * options.Padding); + var contentX = context.Padding; + var contentY = context.Padding; + var contentWidth = viewport.Width - (2f * context.Padding); if (contentWidth <= 0f) { return displayList; } - var textStyle = new RenderTextStyle(options.FontSize, options.TextColor, options.FontFamily, options.LineHeightMultiplier, 400f, false, false, false, options.TextColor, global::AngleSharp.Renderer.Rendering.RenderTextDecorationStyle.Solid, TextAlign.Left, 0f, 0f, 0f); + 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); var cursorY = contentY; var previousBlockMarginBottom = 0f; var suppressNextBlockTopMargin = false; @@ -123,11 +315,11 @@ private static DisplayList BuildDisplayList(IDocument document, RenderViewport v activeFloatBottom: ref activeFloatBottom, textIndentConsumed: ref textIndentConsumed, textStyle: textStyle, - options: options, + context: context, displayList: displayList, - maxY: viewport.Height - options.Padding); + maxY: viewport.Height - context.Padding); - if (cursorY > viewport.Height - options.Padding) + if (cursorY > viewport.Height - context.Padding) { break; } @@ -148,17 +340,21 @@ private static void LayoutNode( ref float activeFloatBottom, ref bool textIndentConsumed, RenderTextStyle textStyle, - HtmlRenderOptions options, + LayoutContext context, DisplayList displayList, - float maxY) + float maxY, + bool isFlexItem = false, + bool isRowDirection = true, + float? flexMainSize = null, + float? flexCrossSize = null) { switch (node) { case TextRenderNode textNode: - LayoutTextNode(textNode.Ref, containingX, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, textStyle, options, displayList, maxY); + LayoutTextNode(textNode.Ref, containingX, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, textStyle, context, displayList, maxY); return; case ElementRenderNode element: - LayoutElement(element, containingX, containingY, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, textStyle, options, displayList, maxY); + LayoutElement(element, containingX, containingY, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, textStyle, context, displayList, maxY, isFlexItem, isRowDirection, flexMainSize, flexCrossSize); return; default: return; @@ -177,13 +373,17 @@ private static void LayoutElement( ref float activeFloatBottom, ref bool textIndentConsumed, RenderTextStyle inheritedTextStyle, - HtmlRenderOptions options, + LayoutContext context, DisplayList displayList, - float maxY) + float maxY, + bool isFlexItem = false, + bool isRowDirection = true, + float? flexMainSize = null, + float? flexCrossSize = null) { var element = node.Ref; var computedStyle = node.ComputedStyle; - var styleMap = CreateStyleMap(node.ComputedStyle); + var styleMap = CreateStyleMap(node.ComputedStyle, node.Ref); if (!node.IsVisible()) { @@ -208,11 +408,11 @@ private static void LayoutElement( if (string.Equals(display, "table", StringComparison.OrdinalIgnoreCase)) { - LayoutTable(node, containingX, containingY, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, inheritedTextStyle, options, displayList, maxY); + LayoutTable(node, containingX, containingY, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, inheritedTextStyle, context, displayList, maxY); return; } - var renderAsBlock = ShouldRenderAsBlock(computedStyle); + var renderAsBlock = ShouldRenderAsBlock(computedStyle) || IsReplacedElementTag(tagName); var isInlineBlock = IsInlineBlock(computedStyle); var currentTextStyle = ResolveTextStyle(styleMap, inheritedTextStyle); @@ -237,7 +437,7 @@ private static void LayoutElement( var inlineText = NormalizeWhitespace(element.TextContent ?? string.Empty); if (inlineText.Length > 0) { - LayoutWrappedText(inlineText, flowContainingX, flowContainingWidth, ref cursorY, currentTextStyle, options, displayList, maxY, textIndentConsumed ? 0f : currentTextStyle.TextIndent); + LayoutWrappedText(inlineText, flowContainingX, flowContainingWidth, ref cursorY, currentTextStyle, context, displayList, maxY, textIndentConsumed ? 0f : currentTextStyle.TextIndent); textIndentConsumed = true; } @@ -298,7 +498,15 @@ private static void LayoutElement( effectiveMarginTop = CollapseMargins(marginTop, firstChildTopMargin); } - var specifiedContentWidth = ParseLength(styleMap, "width", flowContainingWidth, float.NaN, allowAuto: true); + var specifiedContentWidth = ResolveFlexibleContentDimension( + styleMap, + flowContainingWidth, + float.NaN, + isFlexItem, + isRowDirection, + flexMainSize, + flexCrossSize, + propertyName: "width"); ResolveHorizontalMetrics( flowContainingWidth, specifiedContentWidth, @@ -329,12 +537,12 @@ private static void LayoutElement( } var borderBoxX = isFixed - ? options.Padding + leftOffset + ? context.Padding + leftOffset : isAbsolute ? containingX + leftOffset : flowBorderBoxX + (isRelative ? leftOffset : 0f); var borderBoxY = isFixed - ? options.Padding + topOffset + ? context.Padding + topOffset : isAbsolute ? containingY + topOffset : flowBorderBoxY + (isRelative ? topOffset : 0f); @@ -352,13 +560,86 @@ private static void LayoutElement( var inlineLineHeight = currentTextStyle.FontSize * currentTextStyle.LineHeightMultiplier; var inlineCursorX = flowContainingX + (textIndentConsumed ? 0f : currentTextStyle.TextIndent); - var orderedChildren = OrderChildrenForPainting(node.Children).ToList(); + // An root's children are foreign-namespaced SVG elements (circle, text, title, ...), + // not HTML flow content; it is rasterized as a single replaced element below, so its + // subtree must never be walked as if it were normal inline/block content. + var orderedChildren = string.Equals(tagName, "svg", StringComparison.OrdinalIgnoreCase) + ? [] + : OrderChildrenForPainting(node.Children).ToList(); var hasInlineRun = orderedChildren.Any(child => (child is ElementRenderNode childElement && !ShouldRenderAsBlock(childElement.ComputedStyle) && !IsInlineBlock(childElement.ComputedStyle)) || (child is ElementRenderNode childElementWithBr && string.Equals(childElementWithBr.Ref.LocalName, "br", StringComparison.OrdinalIgnoreCase))); + if (IsFlexContainer(styleMap)) + { + LayoutFlexContainer( + node, + contentX, + contentY, + contentWidth, + ref cursorY, + ref previousBlockMarginBottom, + ref suppressNextBlockTopMargin, + ref activeFloatLeftOffset, + ref activeFloatBottom, + ref textIndentConsumed, + currentTextStyle, + context, + displayList, + maxY, + styleMap, + borderLeft, + borderTop, + borderRight, + borderBottom, + paddingLeft, + paddingRight, + paddingTop, + paddingBottom, + box, + flowBorderBoxX, + flowBorderBoxY, + borderBoxX, + borderBoxY); + return; + } + + if (IsGridContainer(styleMap)) + { + LayoutGridContainer( + node, + contentX, + contentY, + contentWidth, + ref cursorY, + ref previousBlockMarginBottom, + ref suppressNextBlockTopMargin, + ref activeFloatLeftOffset, + ref activeFloatBottom, + ref textIndentConsumed, + currentTextStyle, + context, + displayList, + maxY, + styleMap, + borderLeft, + borderTop, + borderRight, + borderBottom, + paddingLeft, + paddingRight, + paddingTop, + paddingBottom, + box, + flowBorderBoxX, + flowBorderBoxY, + borderBoxX, + borderBoxY); + return; + } + if (!hasInlineRun) { foreach (var child in orderedChildren) @@ -376,7 +657,7 @@ private static void LayoutElement( ref childActiveFloatBottom, ref textIndentConsumed, currentTextStyle, - options, + context, displayList, maxY); } @@ -394,7 +675,7 @@ private static void LayoutElement( activeFloatBottom: ref childActiveFloatBottom, textIndentConsumed: ref childTextIndentConsumed, textStyle: currentTextStyle, - options: options, + context: context, displayList: displayList, maxY: maxY); } @@ -433,7 +714,7 @@ private static void LayoutElement( activeFloatBottom: ref childActiveFloatBottom, textIndentConsumed: ref childTextIndentConsumed, textStyle: currentTextStyle, - options: options, + context: context, displayList: displayList, maxY: maxY); } @@ -453,7 +734,7 @@ private static void LayoutElement( currentTextStyle, flowContainingX, flowContainingWidth, - options.AverageCharacterWidthFactor, + context, ref inlineCursorX, ref inlineLineTop, ref inlineLineHeight, @@ -484,7 +765,7 @@ private static void LayoutElement( childTextStyle, flowContainingX, flowContainingWidth, - options.AverageCharacterWidthFactor, + context, ref inlineCursorX, ref inlineLineTop, ref inlineLineHeight, @@ -502,7 +783,15 @@ private static void LayoutElement( } var autoContentHeight = Math.Max(0f, childCursorY - contentY); - var specifiedContentHeight = ParseLength(styleMap, "height", flowContainingWidth, float.NaN, allowAuto: true); + var specifiedContentHeight = ResolveFlexibleContentDimension( + styleMap, + flowContainingWidth, + float.NaN, + isFlexItem, + isRowDirection, + flexMainSize, + flexCrossSize, + propertyName: "height"); var contentHeight = float.IsNaN(specifiedContentHeight) ? autoContentHeight : Math.Max(specifiedContentHeight, autoContentHeight); var borderBoxWidth = borderLeft + paddingLeft + contentWidth + paddingRight + borderRight; @@ -519,10 +808,30 @@ private static void LayoutElement( effectiveMarginBottom = CollapseMargins(marginBottom, childPreviousBlockMarginBottom); } - PaintBackground(displayList, box.BackgroundColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + RecordLayoutMetrics( + node.Ref, + borderBoxX, + borderBoxY, + borderBoxWidth, + borderBoxHeight, + borderLeft, + borderRight, + borderTop, + borderBottom, + paddingLeft, + paddingRight, + paddingTop, + paddingBottom); + + PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth); PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + if (TryResolveReplacedElementImage(node, styleMap, flowContainingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect)) + { + displayList.DrawImage(imageRect, image!); + } + if (isFloatLeft) { var floatRightEdge = (flowBorderBoxX + borderBoxWidth) - containingX; @@ -541,7 +850,380 @@ private static void LayoutElement( } cursorY = flowBorderBoxY + borderBoxHeight; - previousBlockMarginBottom = effectiveMarginBottom + options.ParagraphSpacing; + previousBlockMarginBottom = effectiveMarginBottom + context.ParagraphSpacing; + } + + private readonly record struct FlexItemLayoutInfo( + IRenderNode Node, + Dictionary Style, + float Order, + float FlexGrow, + float FlexShrink, + float BaseMainSize, + float CrossSize, + string AlignSelf); + + private readonly record struct GridPlacement(int LineIndex, int Span); + + private static void LayoutFlexContainer( + ElementRenderNode node, + float containingX, + float containingY, + float containingWidth, + ref float cursorY, + ref float previousBlockMarginBottom, + ref bool suppressNextBlockTopMargin, + ref float activeFloatLeftOffset, + ref float activeFloatBottom, + ref bool textIndentConsumed, + RenderTextStyle inheritedTextStyle, + LayoutContext context, + DisplayList displayList, + float maxY, + Dictionary styleMap, + float borderLeft, + float borderTop, + float borderRight, + float borderBottom, + float paddingLeft, + float paddingRight, + float paddingTop, + float paddingBottom, + BoxStyle box, + float flowBorderBoxX, + float flowBorderBoxY, + float borderBoxX, + float borderBoxY) + { + var flexDirection = GetFlexDirection(styleMap); + var isRowDirection = !string.Equals(flexDirection, "column", StringComparison.OrdinalIgnoreCase) && !string.Equals(flexDirection, "column-reverse", StringComparison.OrdinalIgnoreCase); + var isReverseDirection = string.Equals(flexDirection, "row-reverse", StringComparison.OrdinalIgnoreCase) || string.Equals(flexDirection, "column-reverse", StringComparison.OrdinalIgnoreCase); + var justifyContent = GetJustifyContent(styleMap); + var alignItems = GetAlignItems(styleMap); + var flexWrap = GetFlexWrap(styleMap); + var alignContent = GetAlignContent(styleMap); + var flexItems = OrderChildrenForPainting(node.Children) + .Where(child => child is ElementRenderNode || child is TextRenderNode) + .Select(child => CreateFlexItemLayoutInfo(child, isRowDirection, containingWidth)) + .OrderBy(item => item.Order) + .ToList(); + + if (flexItems.Count == 0) + { + cursorY = flowBorderBoxY + borderTop + paddingTop + borderBottom + paddingBottom; + previousBlockMarginBottom = 0f; + suppressNextBlockTopMargin = false; + return; + } + + var containerMainSize = isRowDirection + ? ParseLength(styleMap, "width", containingWidth, containingWidth, allowAuto: true) + : ParseLength(styleMap, "height", containingWidth, containingWidth, allowAuto: true); + + if (float.IsNaN(containerMainSize) || containerMainSize <= 0f) + { + containerMainSize = isRowDirection ? containingWidth : containingWidth; + } + + var specifiedCrossSize = isRowDirection + ? ParseLength(styleMap, "height", containingWidth, float.NaN, allowAuto: true) + : ParseLength(styleMap, "width", containingWidth, float.NaN, allowAuto: true); + var containerCrossSize = float.IsNaN(specifiedCrossSize) ? 0f : specifiedCrossSize; + + var contentWidth = containingWidth; + var contentHeight = containerCrossSize; + + var lines = new List>(); + var currentLine = new List(); + var currentLineMainSize = 0f; + + foreach (var item in flexItems) + { + if (string.Equals(flexWrap, "wrap", StringComparison.OrdinalIgnoreCase) && currentLine.Count > 0 && currentLineMainSize + item.BaseMainSize > containerMainSize && containerMainSize > 0f) + { + lines.Add(currentLine); + currentLine = new List(); + currentLineMainSize = 0f; + } + + currentLine.Add(item); + currentLineMainSize += item.BaseMainSize; + } + + if (currentLine.Count > 0) + { + lines.Add(currentLine); + } + + var lineCrossSizes = lines.Select(line => line.Count > 0 ? line.Max(item => item.CrossSize) : 0f).ToList(); + var totalCrossSize = lineCrossSizes.Sum(); + var remainingCrossSize = Math.Max(0f, containerCrossSize - totalCrossSize); + var crossSpacing = 0f; + var currentCrossOffset = 0f; + + switch (alignContent) + { + case "center": + currentCrossOffset = remainingCrossSize / 2f; + break; + case "flex-end": + currentCrossOffset = remainingCrossSize; + break; + case "space-between": + crossSpacing = lines.Count > 1 ? remainingCrossSize / Math.Max(1, lines.Count - 1) : 0f; + break; + case "space-around": + crossSpacing = lines.Count > 0 ? remainingCrossSize / Math.Max(1, lines.Count) : 0f; + currentCrossOffset = crossSpacing / 2f; + break; + case "space-evenly": + crossSpacing = lines.Count > 0 ? remainingCrossSize / Math.Max(1, lines.Count + 1) : 0f; + currentCrossOffset = crossSpacing; + break; + default: + currentCrossOffset = 0f; + break; + } + + var childCursorY = containingY; + var childPreviousBlockMarginBottom = 0f; + var childSuppressNextBlockTopMargin = false; + var childActiveFloatLeftOffset = 0f; + var childActiveFloatBottom = 0f; + var childTextIndentConsumed = false; + var totalLineMainSize = 0f; + + for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) + { + var line = lines[lineIndex]; + var lineBaseSize = line.Sum(item => item.BaseMainSize); + var lineGrowSum = line.Sum(item => item.FlexGrow); + var lineShrinkSum = line.Sum(item => item.FlexShrink); + var lineItems = new List<(FlexItemLayoutInfo Item, float FinalMainSize)>(line.Count); + var availableMainSize = Math.Max(0f, containerMainSize - lineBaseSize); + var lineMainSize = 0f; + + foreach (var item in line) + { + var finalMainSize = item.BaseMainSize; + + if (availableMainSize > 0f && lineGrowSum > 0f) + { + finalMainSize = item.BaseMainSize + (availableMainSize * item.FlexGrow / lineGrowSum); + } + else if (availableMainSize < 0f && lineShrinkSum > 0f) + { + finalMainSize = Math.Max(0f, item.BaseMainSize + (availableMainSize * item.FlexShrink / lineShrinkSum)); + } + + lineItems.Add((item, finalMainSize)); + lineMainSize += finalMainSize; + } + + var spacerCount = Math.Max(0, lineItems.Count - 1); + var lineMainSpacing = 0f; + var lineMainStart = 0f; + + switch (justifyContent) + { + case "center": + lineMainStart = Math.Max(0f, containerMainSize - lineMainSize) / 2f; + break; + case "flex-end": + lineMainStart = Math.Max(0f, containerMainSize - lineMainSize); + break; + case "space-between": + lineMainSpacing = lineItems.Count > 1 ? Math.Max(0f, containerMainSize - lineMainSize) / spacerCount : 0f; + break; + case "space-around": + lineMainSpacing = lineItems.Count > 0 ? Math.Max(0f, containerMainSize - lineMainSize) / lineItems.Count : 0f; + lineMainStart = lineMainSpacing / 2f; + break; + case "space-evenly": + lineMainSpacing = lineItems.Count > 0 ? Math.Max(0f, containerMainSize - lineMainSize) / (lineItems.Count + 1) : 0f; + lineMainStart = lineMainSpacing; + break; + default: + lineMainStart = 0f; + break; + } + + var lineCrossSize = lineItems.Count > 0 ? lineItems.Max(entry => entry.Item.CrossSize) : 0f; + var lineCrossPosition = currentCrossOffset; + var lineCrossStart = 0f; + + if (string.Equals(alignItems, "center", StringComparison.OrdinalIgnoreCase)) + { + lineCrossStart = containerCrossSize > 0f && lineCrossSize < containerCrossSize ? (containerCrossSize - lineCrossSize) / 2f : 0f; + } + else if (string.Equals(alignItems, "flex-end", StringComparison.OrdinalIgnoreCase)) + { + lineCrossStart = containerCrossSize > 0f && lineCrossSize < containerCrossSize ? containerCrossSize - lineCrossSize : 0f; + } + else if (string.Equals(alignItems, "stretch", StringComparison.OrdinalIgnoreCase)) + { + lineCrossStart = 0f; + } + + var mainOffset = isReverseDirection ? containerMainSize - lineMainStart - lineMainSize : lineMainStart; + var currentMainOffset = 0f; + + foreach (var (item, finalMainSize) in lineItems) + { + var resolvedCrossSize = item.CrossSize; + if (string.Equals(alignItems, "stretch", StringComparison.OrdinalIgnoreCase) && resolvedCrossSize <= 0f && containerCrossSize > 0f) + { + resolvedCrossSize = containerCrossSize; + } + + var itemCrossPosition = lineCrossPosition + lineCrossStart; + var alignSelf = item.AlignSelf; + + if (string.Equals(alignSelf, "center", StringComparison.OrdinalIgnoreCase)) + { + itemCrossPosition = containerCrossSize > 0f && resolvedCrossSize < containerCrossSize ? (containerCrossSize - resolvedCrossSize) / 2f : 0f; + } + else if (string.Equals(alignSelf, "flex-end", StringComparison.OrdinalIgnoreCase)) + { + itemCrossPosition = containerCrossSize > 0f && resolvedCrossSize < containerCrossSize ? containerCrossSize - resolvedCrossSize : 0f; + } + else if (string.Equals(alignSelf, "stretch", StringComparison.OrdinalIgnoreCase) && resolvedCrossSize <= 0f && containerCrossSize > 0f) + { + resolvedCrossSize = containerCrossSize; + itemCrossPosition = 0f; + } + else if (!string.Equals(alignSelf, "auto", StringComparison.OrdinalIgnoreCase)) + { + itemCrossPosition = 0f; + } + + var itemOffset = isReverseDirection + ? mainOffset + currentMainOffset + : lineMainStart + currentMainOffset; + var childX = isRowDirection ? containingX + itemOffset : containingX + itemCrossPosition; + var childY = isRowDirection ? containingY + itemCrossPosition + lineCrossPosition : containingY + itemOffset; + var childWidth = isRowDirection ? finalMainSize : resolvedCrossSize; + var childHeight = isRowDirection ? resolvedCrossSize : finalMainSize; + + if (item.Node is TextRenderNode textNode) + { + LayoutTextNode(textNode.Ref, childX, containingWidth, ref childCursorY, ref childPreviousBlockMarginBottom, ref childSuppressNextBlockTopMargin, ref childActiveFloatLeftOffset, ref childActiveFloatBottom, ref childTextIndentConsumed, inheritedTextStyle, context, displayList, maxY); + } + else if (item.Node is ElementRenderNode elementChild) + { + var childContainingWidth = Math.Max(0f, childWidth); + var childContainingHeight = Math.Max(0f, childHeight); + var childCursor = isRowDirection ? containingY + itemCrossPosition + lineCrossPosition : containingY + itemOffset; + var childBlockCursor = childCursor; + var childPreviousBottom = 0f; + var childSuppressMargin = false; + var childTextIndent = false; + var childActiveFloatLeft = 0f; + var childActiveFloatBottomOffset = 0f; + + LayoutNode( + node: elementChild, + containingX: childX, + containingY: childY, + containingWidth: childContainingWidth, + cursorY: ref childBlockCursor, + previousBlockMarginBottom: ref childPreviousBottom, + suppressNextBlockTopMargin: ref childSuppressMargin, + activeFloatLeftOffset: ref childActiveFloatLeft, + activeFloatBottom: ref childActiveFloatBottomOffset, + textIndentConsumed: ref childTextIndent, + textStyle: inheritedTextStyle, + context: context, + displayList: displayList, + maxY: maxY, + isFlexItem: true, + isRowDirection: isRowDirection, + flexMainSize: finalMainSize, + flexCrossSize: resolvedCrossSize); + } + + currentMainOffset += finalMainSize + lineMainSpacing; + } + + currentCrossOffset += lineCrossSize + crossSpacing; + totalLineMainSize = Math.Max(totalLineMainSize, lineMainSize); + } + + var autoContentHeight = Math.Max(0f, (isRowDirection ? containerCrossSize : containerMainSize) - 0f); + var specifiedContentHeight = ParseLength(styleMap, "height", containingWidth, float.NaN, allowAuto: true); + contentHeight = float.IsNaN(specifiedContentHeight) ? Math.Max(autoContentHeight, totalLineMainSize) : Math.Max(specifiedContentHeight, autoContentHeight); + var borderBoxWidth = borderLeft + paddingLeft + containingWidth + paddingRight + borderRight; + var borderBoxHeight = borderTop + paddingTop + contentHeight + paddingBottom + borderBottom; + var canCollapseWithLastChild = borderBottom <= 0f && paddingBottom <= 0f && float.IsNaN(specifiedContentHeight); + var effectiveMarginBottom = ParseLength(styleMap, "margin-bottom", containingWidth, box.Margin.Bottom, allowAuto: false); + + if (canCollapseWithLastChild) + { + effectiveMarginBottom = CollapseMargins(effectiveMarginBottom, childPreviousBlockMarginBottom); + } + + if (box.BackgroundPaint is RenderColorPaint colorPaint && colorPaint.Color.A == 0) + { + displayList.FillRect(new RenderRect(borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight), RenderColor.Transparent); + } + else + { + PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + } + + RecordLayoutMetrics( + node.Ref, + borderBoxX, + borderBoxY, + borderBoxWidth, + borderBoxHeight, + borderLeft, + borderRight, + borderTop, + borderBottom, + paddingLeft, + paddingRight, + paddingTop, + paddingBottom); + + PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth); + PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + + if (TryResolveReplacedElementImage(node, styleMap, containingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect)) + { + displayList.DrawImage(imageRect, image!); + } + + cursorY = flowBorderBoxY + borderBoxHeight; + previousBlockMarginBottom = effectiveMarginBottom + context.ParagraphSpacing; + suppressNextBlockTopMargin = false; + } + + private static float ResolveFlexibleContentDimension( + Dictionary styleMap, + float relativeTo, + float defaultValue, + bool isFlexItem, + bool isRowDirection, + float? flexMainSize, + float? flexCrossSize, + string propertyName) + { + if (!isFlexItem) + { + return ParseLength(styleMap, propertyName, relativeTo, defaultValue, allowAuto: true); + } + + if (string.Equals(propertyName, "width", StringComparison.OrdinalIgnoreCase)) + { + return isRowDirection + ? (flexMainSize.HasValue ? flexMainSize.Value : ParseLength(styleMap, propertyName, relativeTo, defaultValue, allowAuto: true)) + : (flexCrossSize.HasValue ? flexCrossSize.Value : ParseLength(styleMap, propertyName, relativeTo, defaultValue, allowAuto: true)); + } + + return isRowDirection + ? (flexCrossSize.HasValue ? flexCrossSize.Value : ParseLength(styleMap, propertyName, relativeTo, defaultValue, allowAuto: true)) + : (flexMainSize.HasValue ? flexMainSize.Value : ParseLength(styleMap, propertyName, relativeTo, defaultValue, allowAuto: true)); } private static IEnumerable CollectTableRows(ElementRenderNode tableNode) @@ -575,7 +1257,7 @@ private static void LayoutTable( ref float activeFloatBottom, ref bool textIndentConsumed, RenderTextStyle inheritedTextStyle, - HtmlRenderOptions options, + LayoutContext context, DisplayList displayList, float maxY) { @@ -622,7 +1304,7 @@ private static void LayoutTable( .ToList()) .ToList(); - var tableCells = new List<(int RowIndex, int ColumnIndex, int ColumnSpan, int RowSpan, ElementRenderNode CellNode, Dictionary CellStyle, RenderTextStyle CellTextStyle, string Text, float PaddingLeft, float PaddingRight, float PaddingTop, float PaddingBottom, float BorderLeftWidth, float BorderRightWidth, float BorderTopWidth, float BorderBottomWidth, RenderColor BackgroundColor)>(); + var tableCells = new List(); var rowSpanOccupancy = new List(); var columnCount = 0; @@ -691,7 +1373,7 @@ private static void LayoutTable( nextRowSpanOccupancy[currentColumnIndex + spanOffset] = Math.Max(nextRowSpanOccupancy[currentColumnIndex + spanOffset], Math.Max(0, rowspan - 1)); } - tableCells.Add((rowIndex, currentColumnIndex, colspan, rowspan, cellNode, cellStyle, cellTextStyle, text, + tableCells.Add(new TableCellPlacement(rowIndex, currentColumnIndex, colspan, rowspan, cellNode, cellStyle, cellTextStyle, text, ParseLength(cellStyle, "padding-left", containingWidth, 4f, allowAuto: false), ParseLength(cellStyle, "padding-right", containingWidth, 4f, allowAuto: false), ParseLength(cellStyle, "padding-top", containingWidth, 4f, allowAuto: false), @@ -700,7 +1382,8 @@ private static void LayoutTable( ParseLength(cellStyle, "border-right-width", containingWidth, 1f, allowAuto: false), ParseLength(cellStyle, "border-top-width", containingWidth, 1f, allowAuto: false), ParseLength(cellStyle, "border-bottom-width", containingWidth, 1f, allowAuto: false), - ParseColor(cellStyle.TryGetValue("background-color", out var backgroundColor) ? backgroundColor : null, RenderColor.Transparent))); + ParseColor(cellStyle.TryGetValue("background-color", out var backgroundColor) ? backgroundColor : null, RenderColor.Transparent), + ParseCellVerticalAlign(cellStyle))); columnCount = Math.Max(columnCount, currentColumnIndex + colspan); currentColumnIndex += colspan; @@ -710,9 +1393,15 @@ private static void LayoutTable( { if (rowSpanOccupancy[index] > 0) { - nextRowSpanOccupancy[index] = Math.Max(nextRowSpanOccupancy[index], rowSpanOccupancy[index] - 1); - } - } + // A row with fewer cells than the span reaches over leaves the new list short. + while (index >= nextRowSpanOccupancy.Count) + { + nextRowSpanOccupancy.Add(0); + } + + nextRowSpanOccupancy[index] = Math.Max(nextRowSpanOccupancy[index], rowSpanOccupancy[index] - 1); + } + } rowSpanOccupancy = nextRowSpanOccupancy; } @@ -731,7 +1420,7 @@ private static void LayoutTable( var paddingRight = placement.PaddingRight; var borderLeftWidth = placement.BorderLeftWidth; var borderRightWidth = placement.BorderRightWidth; - var textWidth = placement.Text.Length > 0 ? EstimateTextWidth(placement.Text, placement.CellTextStyle.FontSize, options.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing) : 0f; + var textWidth = placement.Text.Length > 0 ? MeasureTextWidth(context, placement.Text, placement.CellTextStyle) : 0f; var minCellWidth = textWidth + paddingLeft + paddingRight + borderLeftWidth + borderRightWidth + 8f; var widthPerColumn = float.IsNaN(specifiedCellWidth) ? minCellWidth / Math.Max(1, placement.ColumnSpan) : specifiedCellWidth / Math.Max(1, placement.ColumnSpan); @@ -781,23 +1470,33 @@ private static void LayoutTable( var rowTopOffsets = new float[rowCellLists.Count]; var rowHeights = new float[rowCellLists.Count]; - foreach (var placement in tableCells) + // Cells confined to a single row establish the row heights on their own. + foreach (var placement in tableCells.Where(placement => placement.RowSpan <= 1)) { - var contentWidth = Math.Max(0f, columnWidths.Skip(placement.ColumnIndex).Take(placement.ColumnSpan).Sum() - placement.PaddingLeft - placement.PaddingRight - placement.BorderLeftWidth - placement.BorderRightWidth); - var contentHeight = 0f; - var text = placement.Text; + rowHeights[placement.RowIndex] = Math.Max(rowHeights[placement.RowIndex], MeasureCellHeight(context, placement, columnWidths)); + } - if (contentWidth > 0f && text.Length > 0) - { - var wrappedLines = WrapText(text, contentWidth, placement.CellTextStyle.FontSize, options.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing); - var lineHeight = placement.CellTextStyle.FontSize * placement.CellTextStyle.LineHeightMultiplier; - contentHeight = wrappedLines.Count * lineHeight; - } + for (var rowIndex = 0; rowIndex < rowHeights.Length; rowIndex++) + { + rowHeights[rowIndex] = Math.Max(rowHeights[rowIndex], 20f); + } - var effectiveHeight = Math.Max(20f, contentHeight + placement.PaddingTop + placement.PaddingBottom + placement.BorderTopWidth + placement.BorderBottomWidth); - for (var rowIndex = placement.RowIndex; rowIndex < placement.RowIndex + placement.RowSpan; rowIndex++) + // A spanning cell only has to fit across the rows it covers taken together, so it grows + // them by whatever is still missing rather than imposing its full height on each one. + foreach (var placement in tableCells.Where(placement => placement.RowSpan > 1)) + { + var spannedRows = Enumerable.Range(placement.RowIndex, placement.RowSpan).ToArray(); + var available = spannedRows.Sum(rowIndex => rowHeights[rowIndex]); + var required = MeasureCellHeight(context, placement, columnWidths); + + if (required > available) { - rowHeights[rowIndex] = Math.Max(rowHeights[rowIndex], effectiveHeight); + var deficitPerRow = (required - available) / placement.RowSpan; + + foreach (var rowIndex in spannedRows) + { + rowHeights[rowIndex] += deficitPerRow; + } } } @@ -829,31 +1528,59 @@ private static void LayoutTable( cellHeight += rowHeights[rowIndex]; } - var contentHeight = 0f; - if (contentWidth > 0f && placement.Text.Length > 0) + RecordLayoutMetrics( + placement.CellNode.Ref, + cellX, + cellY, + cellWidth, + cellHeight, + placement.BorderLeftWidth, + placement.BorderRightWidth, + placement.BorderTopWidth, + placement.BorderBottomWidth, + placement.PaddingLeft, + placement.PaddingRight, + placement.PaddingTop, + placement.PaddingBottom); + + displayList.FillRect(new RenderRect(cellX, cellY, cellWidth, cellHeight), placement.BackgroundColor); + + if (borderCollapse) { - var wrappedLines = WrapText(placement.Text, contentWidth, placement.CellTextStyle.FontSize, options.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing); - var lineHeight = placement.CellTextStyle.FontSize * placement.CellTextStyle.LineHeightMultiplier; - contentHeight = wrappedLines.Count * lineHeight; + // Collapsed borders are shared, so each cell contributes only its top and left + // edge and the table frame closes the far sides. Drawing full outlines instead + // would lay two lines over every shared edge, and a grid spanning the whole table + // would cut straight through the cells that span rows or columns. + displayList.FillRect(new RenderRect(cellX, cellY, cellWidth, CollapsedBorderWidth), RenderColor.Black); + displayList.FillRect(new RenderRect(cellX, cellY, CollapsedBorderWidth, cellHeight), RenderColor.Black); } - - var effectiveHeight = Math.Max(20f, contentHeight + placement.PaddingTop + placement.PaddingBottom + placement.BorderTopWidth + placement.BorderBottomWidth); - displayList.FillRect(new RenderRect(cellX, cellY, cellWidth, effectiveHeight), placement.BackgroundColor); - - if (!borderCollapse) + else { displayList.FillRect(new RenderRect(cellX, cellY, cellWidth, placement.BorderTopWidth), RenderColor.Black); - displayList.FillRect(new RenderRect(cellX + cellWidth - placement.BorderRightWidth, cellY, placement.BorderRightWidth, effectiveHeight), RenderColor.Black); - displayList.FillRect(new RenderRect(cellX, cellY + effectiveHeight - placement.BorderBottomWidth, cellWidth, placement.BorderBottomWidth), RenderColor.Black); - displayList.FillRect(new RenderRect(cellX, cellY, placement.BorderLeftWidth, effectiveHeight), RenderColor.Black); + displayList.FillRect(new RenderRect(cellX + cellWidth - placement.BorderRightWidth, cellY, placement.BorderRightWidth, cellHeight), RenderColor.Black); + displayList.FillRect(new RenderRect(cellX, cellY + cellHeight - placement.BorderBottomWidth, cellWidth, placement.BorderBottomWidth), RenderColor.Black); + displayList.FillRect(new RenderRect(cellX, cellY, placement.BorderLeftWidth, cellHeight), RenderColor.Black); } if (contentWidth > 0f && placement.Text.Length > 0) { - var wrappedLines = WrapText(placement.Text, contentWidth, placement.CellTextStyle.FontSize, options.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing); + var wrappedLines = WrapText(context, placement.Text, contentWidth, placement.CellTextStyle); var lineHeight = placement.CellTextStyle.FontSize * placement.CellTextStyle.LineHeightMultiplier; var lineX = cellX + placement.PaddingLeft + placement.BorderLeftWidth; - var lineY = cellY + placement.PaddingTop + placement.BorderTopWidth + lineHeight; + + // The content box can be taller than the text, most visibly in a cell that spans + // rows, so the block of lines is placed according to the cell's vertical-align. + var contentBoxHeight = cellHeight - placement.PaddingTop - placement.PaddingBottom + - placement.BorderTopWidth - placement.BorderBottomWidth; + var slack = Math.Max(0f, contentBoxHeight - (wrappedLines.Count * lineHeight)); + var verticalOffset = placement.VerticalAlign switch + { + CellVerticalAlign.Middle => slack / 2f, + CellVerticalAlign.Bottom => slack, + _ => 0f, + }; + + var lineY = cellY + placement.PaddingTop + placement.BorderTopWidth + verticalOffset + lineHeight; for (var lineIndex = 0; lineIndex < wrappedLines.Count; lineIndex++) { @@ -865,34 +1592,385 @@ private static void LayoutTable( if (borderCollapse) { - var collapsedBorderWidth = 1f; + // The interior lines come from the cell outlines above; only the frame is left, which + // also closes the edge of rows that hold fewer cells than the table has columns. + displayList.FillRect(new RenderRect(tableX, tableY, tableWidth, CollapsedBorderWidth), RenderColor.Black); + displayList.FillRect(new RenderRect(tableX, tableY + tableHeight - CollapsedBorderWidth, tableWidth, CollapsedBorderWidth), RenderColor.Black); + displayList.FillRect(new RenderRect(tableX, tableY, CollapsedBorderWidth, tableHeight), RenderColor.Black); + displayList.FillRect(new RenderRect(tableX + tableWidth - CollapsedBorderWidth, tableY, CollapsedBorderWidth, tableHeight), RenderColor.Black); + } + + RecordLayoutMetrics( + tableNode.Ref, + tableX, + tableY, + tableWidth, + tableHeight, + borderLeft: 0f, + borderRight: 0f, + borderTop: 0f, + borderBottom: 0f, + paddingLeft: 0f, + paddingRight: 0f, + paddingTop: 0f, + paddingBottom: 0f); + + displayList.FillRect(new RenderRect(tableX, tableY, tableWidth, tableHeight), RenderColor.Transparent); + cursorY = tableY + tableHeight + 4f; + previousBlockMarginBottom = 0f; + suppressNextBlockTopMargin = false; + } + + private static void LayoutGridContainer( + ElementRenderNode node, + float containingX, + float containingY, + float containingWidth, + ref float cursorY, + ref float previousBlockMarginBottom, + ref bool suppressNextBlockTopMargin, + ref float activeFloatLeftOffset, + ref float activeFloatBottom, + ref bool textIndentConsumed, + RenderTextStyle inheritedTextStyle, + LayoutContext context, + DisplayList displayList, + float maxY, + Dictionary styleMap, + float borderLeft, + float borderTop, + float borderRight, + float borderBottom, + float paddingLeft, + float paddingRight, + float paddingTop, + float paddingBottom, + BoxStyle box, + float flowBorderBoxX, + float flowBorderBoxY, + float borderBoxX, + float borderBoxY) + { + var columns = ParseGridTrackList(styleMap, "grid-template-columns", containingWidth, 1); + var columnGap = ParseGridGap(styleMap, "column-gap", containingWidth, 0) + ?? ParseGridGap(styleMap, "gap", containingWidth, 0); + var rowGap = ParseGridGap(styleMap, "row-gap", containingWidth, 0) + ?? ParseGridGap(styleMap, "gap", containingWidth, 0); + var resolvedColumnGap = columnGap ?? 0f; + var resolvedRowGap = rowGap ?? 0f; + var gridItems = node.Children + .Where(child => child is ElementRenderNode || (child is TextRenderNode textNode && NormalizeWhitespace(textNode.Ref.Data).Length > 0)) + .ToList(); + var hasExplicitRowTracks = styleMap.TryGetValue("grid-template-rows", out var rowTemplateValue) && !string.IsNullOrWhiteSpace(rowTemplateValue); + var containerHeight = ParseLength(styleMap, "height", containingWidth, containingWidth, allowAuto: true); + var rows = hasExplicitRowTracks + ? ParseGridTrackList(styleMap, "grid-template-rows", containerHeight, 1) + : CreateAutoRows(gridItems.Count, columns.Count, containerHeight); + + var currentColumn = 0; + var currentRow = 0; + + foreach (var child in gridItems) + { + if (child is TextRenderNode textNode) + { + LayoutTextNode(textNode.Ref, containingX, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, inheritedTextStyle, context, displayList, maxY); + continue; + } - displayList.FillRect(new RenderRect(tableX, tableY, tableWidth, collapsedBorderWidth), RenderColor.Black); - displayList.FillRect(new RenderRect(tableX, tableY + tableHeight - collapsedBorderWidth, tableWidth, collapsedBorderWidth), RenderColor.Black); - displayList.FillRect(new RenderRect(tableX, tableY, collapsedBorderWidth, tableHeight), RenderColor.Black); - displayList.FillRect(new RenderRect(tableX + tableWidth - collapsedBorderWidth, tableY, collapsedBorderWidth, tableHeight), RenderColor.Black); + if (child is not ElementRenderNode elementChild) + { + continue; + } + + var placementColumn = ResolveGridPlacement(styleMap, elementChild, "grid-column", currentColumn); + var placementRow = ResolveGridPlacement(styleMap, elementChild, "grid-row", currentRow); + var effectivePlacementColumn = placementColumn; + var effectivePlacementRow = placementRow; + + var hasExplicitColumnPlacement = elementChild.Ref.GetAttribute("data-render-grid-column") is not null; + var hasExplicitRowPlacement = elementChild.Ref.GetAttribute("data-render-grid-row") is not null; + + if (hasExplicitColumnPlacement || hasExplicitRowPlacement) + { + effectivePlacementColumn = new GridPlacement(Math.Max(0, placementColumn.LineIndex), placementColumn.Span); + effectivePlacementRow = new GridPlacement(Math.Max(0, placementRow.LineIndex), placementRow.Span); + } + else + { + effectivePlacementColumn = new GridPlacement(Math.Max(0, currentColumn), placementColumn.Span); + effectivePlacementRow = new GridPlacement(Math.Max(0, currentRow), placementRow.Span); + } + var estimatedItemWidth = ResolveGridItemEstimatedSize(elementChild, styleMap, containingWidth, "width"); + var estimatedItemHeight = ResolveGridItemEstimatedSize(elementChild, styleMap, containingWidth, "height"); + var effectiveColumnCount = Math.Max(columns.Count, effectivePlacementColumn.LineIndex + effectivePlacementColumn.Span); + var effectiveRowCount = Math.Max(rows.Count, effectivePlacementRow.LineIndex + effectivePlacementRow.Span); + + if (effectiveColumnCount > columns.Count) + { + columns.AddRange(Enumerable.Repeat(containingWidth, effectiveColumnCount - columns.Count)); + } - var currentVerticalX = tableX; - for (var columnIndex = 1; columnIndex < columnCount; columnIndex++) + if (effectiveRowCount > rows.Count) { - currentVerticalX += columnWidths[columnIndex - 1]; - displayList.FillRect(new RenderRect(currentVerticalX, tableY, collapsedBorderWidth, tableHeight), RenderColor.Black); + rows.AddRange(Enumerable.Repeat(0f, effectiveRowCount - rows.Count)); } - var currentHorizontalY = tableY; - for (var rowIndex = 1; rowIndex < rowCellLists.Count; rowIndex++) + EnsureGridTrackSize(columns, effectivePlacementColumn.LineIndex, estimatedItemWidth, containingWidth); + EnsureGridTrackSize(rows, effectivePlacementRow.LineIndex, estimatedItemHeight, 0f); + + var contentX = borderBoxX + borderLeft + paddingLeft; + var contentY = borderBoxY + borderTop + paddingTop; + var cellX = contentX + GetGridTrackOffset(columns, effectivePlacementColumn.LineIndex, resolvedColumnGap); + var cellY = contentY + GetGridTrackOffset(rows, effectivePlacementRow.LineIndex, resolvedRowGap); + var cellWidth = GetGridTrackSpanSize(columns, effectivePlacementColumn.LineIndex, effectivePlacementColumn.Span, resolvedColumnGap, containingWidth); + var cellHeight = GetGridTrackSpanSize(rows, effectivePlacementRow.LineIndex, effectivePlacementRow.Span, resolvedRowGap, containingWidth); + + var childCursor = cellY; + var childPreviousBlockMarginBottom = 0f; + var childSuppressNextBlockTopMargin = false; + var childActiveFloatLeftOffset = 0f; + var childActiveFloatBottom = 0f; + var childTextIndentConsumed = false; + + LayoutNode( + node: elementChild, + containingX: cellX, + containingY: cellY, + containingWidth: Math.Max(0f, cellWidth), + cursorY: ref childCursor, + previousBlockMarginBottom: ref childPreviousBlockMarginBottom, + suppressNextBlockTopMargin: ref childSuppressNextBlockTopMargin, + activeFloatLeftOffset: ref childActiveFloatLeftOffset, + activeFloatBottom: ref childActiveFloatBottom, + textIndentConsumed: ref childTextIndentConsumed, + textStyle: inheritedTextStyle, + context: context, + displayList: displayList, + maxY: maxY, + isFlexItem: false, + isRowDirection: true, + flexMainSize: null, + flexCrossSize: null); + + currentColumn++; + if (currentColumn >= columns.Count) { - currentHorizontalY += rowHeights[rowIndex - 1]; - displayList.FillRect(new RenderRect(tableX, currentHorizontalY, tableWidth, collapsedBorderWidth), RenderColor.Black); + currentColumn = 0; + currentRow++; } } - displayList.FillRect(new RenderRect(tableX, tableY, tableWidth, tableHeight), RenderColor.Transparent); - cursorY = tableY + tableHeight + 4f; - previousBlockMarginBottom = 0f; + var gridContentWidth = GetGridContentSize(columns, resolvedColumnGap, containingWidth); + var specifiedHeight = ParseLength(styleMap, "height", containingWidth, containingWidth, allowAuto: true); + var gridContentHeight = GetGridContentSize(rows, resolvedRowGap, specifiedHeight); + var borderBoxWidth = borderLeft + paddingLeft + Math.Max(containingWidth, gridContentWidth) + paddingRight + borderRight; + var borderBoxHeight = borderTop + paddingTop + Math.Max(ParseLength(styleMap, "height", containingWidth, containingWidth, allowAuto: true), gridContentHeight) + paddingBottom + borderBottom; + var canCollapseWithLastChild = borderBottom <= 0f && paddingBottom <= 0f; + var effectiveMarginBottom = ParseLength(styleMap, "margin-bottom", containingWidth, box.Margin.Bottom, allowAuto: false); + + if (canCollapseWithLastChild) + { + effectiveMarginBottom = CollapseMargins(effectiveMarginBottom, previousBlockMarginBottom); + } + + if (box.BackgroundPaint is RenderColorPaint colorPaint && colorPaint.Color.A == 0) + { + displayList.FillRect(new RenderRect(borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight), RenderColor.Transparent); + } + else + { + PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + } + + RecordLayoutMetrics( + node.Ref, + borderBoxX, + borderBoxY, + borderBoxWidth, + borderBoxHeight, + borderLeft, + borderRight, + borderTop, + borderBottom, + paddingLeft, + paddingRight, + paddingTop, + paddingBottom); + + PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth); + PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + + cursorY = flowBorderBoxY + borderBoxHeight; + previousBlockMarginBottom = effectiveMarginBottom + context.ParagraphSpacing; suppressNextBlockTopMargin = false; } + private static float GetGridTrackOffset(IReadOnlyList tracks, int index, float gap) + { + if (index <= 0) + { + return 0f; + } + + var offset = 0f; + for (var current = 0; current < index && current < tracks.Count; current++) + { + offset += tracks[current]; + offset += gap; + } + + return offset; + } + + private static float GetGridTrackSpanSize(IReadOnlyList tracks, int index, int span, float gap, float fallback) + { + var totalSize = 0f; + var spanCount = Math.Max(1, span); + + for (var current = 0; current < spanCount; current++) + { + var trackIndex = index + current; + totalSize += GetGridTrackSize(tracks, trackIndex, fallback); + + if (current < spanCount - 1) + { + totalSize += gap; + } + } + + return totalSize; + } + + private static List ParseGridTrackList(Dictionary styleMap, string propertyName, float fallbackSize, int minimumCount) + { + if (!styleMap.TryGetValue(propertyName, out var rawValue) || string.IsNullOrWhiteSpace(rawValue)) + { + var fallbackTracks = new List(Math.Max(1, minimumCount)); + var fallbackTrackSize = Math.Max(0f, fallbackSize / Math.Max(1, minimumCount)); + for (var index = 0; index < Math.Max(1, minimumCount); index++) + { + fallbackTracks.Add(fallbackTrackSize); + } + + return fallbackTracks; + } + + var tracks = new List(); + foreach (var token in rawValue.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var normalized = token.Trim().ToLowerInvariant(); + var trackSize = normalized switch + { + "auto" => Math.Max(0f, fallbackSize), + _ => ParseLengthValue(normalized, fallbackSize, allowAuto: false) + }; + + tracks.Add(float.IsNaN(trackSize) ? Math.Max(0f, fallbackSize) : Math.Max(0f, trackSize)); + } + + return tracks.Count > 0 ? tracks : new List { Math.Max(0f, fallbackSize) }; + } + + private static List CreateAutoRows(int itemCount, int columnCount, float containerHeight) + { + var rowCount = Math.Max(1, (int)Math.Ceiling((double)itemCount / Math.Max(1, columnCount))); + var fallbackRowSize = containerHeight > 0f ? containerHeight / rowCount : 0f; + return Enumerable.Range(0, rowCount).Select(_ => fallbackRowSize).ToList(); + } + + private static float? ParseGridGap(Dictionary styleMap, string propertyName, float relativeTo, int tokenIndex) + { + if (!styleMap.TryGetValue(propertyName, out var rawValue) || string.IsNullOrWhiteSpace(rawValue)) + { + return null; + } + + var tokens = rawValue.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (tokens.Length == 0) + { + return null; + } + + var token = tokenIndex >= 0 && tokenIndex < tokens.Length ? tokens[tokenIndex] : tokens[^1]; + var parsed = ParseLengthValue(token, float.NaN, allowAuto: false); + return float.IsNaN(parsed) ? null : parsed; + } + + private static GridPlacement ResolveGridPlacement(Dictionary styleMap, ElementRenderNode elementChild, string propertyName, int fallbackIndex) + { + var childStyleMap = CreateStyleMap(elementChild.ComputedStyle, elementChild.Ref); + if (!childStyleMap.TryGetValue(propertyName, out var rawValue) || string.IsNullOrWhiteSpace(rawValue)) + { + return new GridPlacement(fallbackIndex, 1); + } + + var tokens = rawValue.Split(new[] { ' ', '/', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var startToken = tokens.FirstOrDefault(token => int.TryParse(token, out _)); + + if (startToken is not null && int.TryParse(startToken, out var explicitIndex)) + { + return new GridPlacement(Math.Max(0, explicitIndex - 1), 1); + } + + if (tokens.Length >= 3 && string.Equals(tokens[1], "span", StringComparison.OrdinalIgnoreCase) && int.TryParse(tokens[2], out var spanCount)) + { + return new GridPlacement(Math.Max(0, fallbackIndex), Math.Max(1, spanCount)); + } + + return new GridPlacement(Math.Max(0, fallbackIndex), 1); + } + + private static float ResolveGridItemEstimatedSize(ElementRenderNode elementChild, Dictionary styleMap, float fallbackSize, string propertyName) + { + var rawValue = styleMap.TryGetValue(propertyName, out var value) && !string.IsNullOrWhiteSpace(value) + ? value + : null; + + if (string.IsNullOrWhiteSpace(rawValue)) + { + return fallbackSize; + } + + var parsed = ParseLengthValue(rawValue, fallbackSize, allowAuto: false); + return float.IsNaN(parsed) ? fallbackSize : Math.Max(0f, parsed); + } + + private static void EnsureGridTrackSize(List tracks, int index, float size, float fallbackSize) + { + while (tracks.Count <= index) + { + tracks.Add(Math.Max(0f, fallbackSize)); + } + + if (tracks[index] <= 0f) + { + tracks[index] = Math.Max(0f, Math.Max(size, fallbackSize)); + } + } + + private static float GetGridTrackSize(IReadOnlyList tracks, int index, float fallback) + { + if (index >= 0 && index < tracks.Count) + { + return tracks[index]; + } + + return fallback; + } + + private static float GetGridContentSize(IReadOnlyList tracks, float gap, float fallbackSize) + { + if (tracks.Count <= 1) + { + return Math.Max(fallbackSize, tracks.Sum()); + } + + var trackSize = tracks.Sum(); + var gapSize = (tracks.Count - 1) * gap; + return Math.Max(fallbackSize, trackSize + gapSize); + } + private static void LayoutTextNode( IText textNode, float containingX, @@ -904,7 +1982,7 @@ private static void LayoutTextNode( ref float activeFloatBottom, ref bool textIndentConsumed, RenderTextStyle textStyle, - HtmlRenderOptions options, + LayoutContext context, DisplayList displayList, float maxY) { @@ -925,7 +2003,7 @@ private static void LayoutTextNode( } var localFloatLeftOffset = cursorY < activeFloatBottom ? activeFloatLeftOffset : 0f; - LayoutWrappedText(text, containingX + localFloatLeftOffset, containingWidth - localFloatLeftOffset, ref cursorY, textStyle, options, displayList, maxY, textIndentConsumed ? 0f : textStyle.TextIndent); + LayoutWrappedText(text, containingX + localFloatLeftOffset, containingWidth - localFloatLeftOffset, ref cursorY, textStyle, context, displayList, maxY, textIndentConsumed ? 0f : textStyle.TextIndent); textIndentConsumed = true; } @@ -935,13 +2013,13 @@ private static void LayoutWrappedText( float maxWidth, ref float cursorY, RenderTextStyle textStyle, - HtmlRenderOptions options, + LayoutContext context, DisplayList displayList, float maxY, float firstLineIndent) { var lineHeight = textStyle.FontSize * textStyle.LineHeightMultiplier; - var lines = WrapText(text, maxWidth, textStyle.FontSize, options.AverageCharacterWidthFactor, textStyle.LetterSpacing); + var lines = WrapText(context, text, maxWidth, textStyle); for (var index = 0; index < lines.Count; index++) { @@ -953,7 +2031,7 @@ private static void LayoutWrappedText( return; } - var lineWidth = EstimateTextWidth(line, textStyle.FontSize, options.AverageCharacterWidthFactor, textStyle.LetterSpacing); + var lineWidth = MeasureTextWidth(context, line, textStyle); var lineMaxWidth = index == 0 ? Math.Max(0f, maxWidth - firstLineIndent) : maxWidth; var lineX = x + (index == 0 ? firstLineIndent : 0f) + ResolveTextAlignmentOffset(textStyle.TextAlign, lineMaxWidth, lineWidth); var baselineY = cursorY + textStyle.VerticalAlignOffset; @@ -981,7 +2059,7 @@ private static void LayoutInlineTextRun( RenderTextStyle textStyle, float flowX, float flowWidth, - float averageCharacterWidthFactor, + LayoutContext context, ref float inlineCursorX, ref float inlineLineTop, ref float inlineLineHeight, @@ -989,11 +2067,11 @@ private static void LayoutInlineTextRun( { var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); var rightEdge = flowX + flowWidth; - var spaceWidth = EstimateTextWidth(" ", textStyle.FontSize, averageCharacterWidthFactor, textStyle.LetterSpacing); + var spaceWidth = MeasureTextWidth(context, " ", textStyle); foreach (var word in words) { - var wordWidth = EstimateTextWidth(word, textStyle.FontSize, averageCharacterWidthFactor, textStyle.LetterSpacing); + var wordWidth = MeasureTextWidth(context, word, textStyle); if (inlineCursorX > flowX && inlineCursorX + spaceWidth + wordWidth > rightEdge) { @@ -1033,6 +2111,130 @@ private static void LayoutInlineTextRun( return styleMap.TryGetValue("display", out var display) ? display : null; } + private static bool IsFlexContainer(Dictionary styleMap) + { + var display = GetDisplay(styleMap); + return string.Equals(display, "flex", StringComparison.OrdinalIgnoreCase) || string.Equals(display, "inline-flex", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsGridContainer(Dictionary styleMap) + { + var display = GetDisplay(styleMap); + return string.Equals(display, "grid", StringComparison.OrdinalIgnoreCase) || string.Equals(display, "inline-grid", StringComparison.OrdinalIgnoreCase); + } + + private static string GetFlexDirection(Dictionary styleMap) + { + return styleMap.TryGetValue("flex-direction", out var direction) && !string.IsNullOrWhiteSpace(direction) + ? direction.Trim().ToLowerInvariant() + : "row"; + } + + private static string GetJustifyContent(Dictionary styleMap) + { + return styleMap.TryGetValue("justify-content", out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim().ToLowerInvariant() + : "flex-start"; + } + + private static string GetAlignItems(Dictionary styleMap) + { + return styleMap.TryGetValue("align-items", out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim().ToLowerInvariant() + : "stretch"; + } + + private static string GetFlexWrap(Dictionary styleMap) + { + return styleMap.TryGetValue("flex-wrap", out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim().ToLowerInvariant() + : "nowrap"; + } + + private static string GetAlignContent(Dictionary styleMap) + { + return styleMap.TryGetValue("align-content", out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim().ToLowerInvariant() + : "stretch"; + } + + private static float GetFlexGrow(Dictionary styleMap) + { + return styleMap.TryGetValue("flex-grow", out var value) && !string.IsNullOrWhiteSpace(value) + ? ParseLengthValue(value.Trim(), 0f, allowAuto: false) + : 0f; + } + + private static float GetFlexShrink(Dictionary styleMap) + { + return styleMap.TryGetValue("flex-shrink", out var value) && !string.IsNullOrWhiteSpace(value) + ? ParseLengthValue(value.Trim(), 1f, allowAuto: false) + : 1f; + } + + private static float GetFlexOrder(Dictionary styleMap) + { + return styleMap.TryGetValue("order", out var value) && !string.IsNullOrWhiteSpace(value) + ? ParseLengthValue(value.Trim(), 0f, allowAuto: false) + : 0f; + } + + private static string GetAlignSelf(Dictionary styleMap, string fallback) + { + if (!styleMap.TryGetValue("align-self", out var value) || string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + var normalized = value.Trim().ToLowerInvariant(); + return normalized == "auto" ? fallback : normalized; + } + + private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bool isRowDirection, float relativeTo) + { + if (child is not ElementRenderNode elementChild) + { + return new FlexItemLayoutInfo(child, new Dictionary(StringComparer.OrdinalIgnoreCase), 0f, 0f, 1f, 0f, 0f, "auto"); + } + + var childStyle = CreateStyleMap(elementChild.ComputedStyle, elementChild.Ref); + var baseMainSize = ResolveFlexBaseSize(childStyle, isRowDirection, relativeTo); + var crossSize = ResolveFlexCrossSize(childStyle, isRowDirection, relativeTo); + return new FlexItemLayoutInfo( + child, + childStyle, + GetFlexOrder(childStyle), + GetFlexGrow(childStyle), + GetFlexShrink(childStyle), + baseMainSize, + crossSize, + GetAlignSelf(childStyle, "auto")); + } + + private static float ResolveFlexBaseSize(Dictionary styleMap, bool isRowDirection, float relativeTo) + { + var flexBasis = ParseLength(styleMap, "flex-basis", relativeTo, float.NaN, allowAuto: true); + if (!float.IsNaN(flexBasis)) + { + return flexBasis; + } + + var mainSize = isRowDirection + ? ParseLength(styleMap, "width", relativeTo, float.NaN, allowAuto: true) + : ParseLength(styleMap, "height", relativeTo, float.NaN, allowAuto: true); + + return float.IsNaN(mainSize) ? 0f : mainSize; + } + + private static float ResolveFlexCrossSize(Dictionary styleMap, bool isRowDirection, float relativeTo) + { + var crossSize = isRowDirection + ? ParseLength(styleMap, "height", relativeTo, float.NaN, allowAuto: true) + : ParseLength(styleMap, "width", relativeTo, float.NaN, allowAuto: true); + + return float.IsNaN(crossSize) ? 0f : crossSize; + } + private static bool ShouldRenderAsBlock(ICssStyleDeclaration computedStyle) { var display = computedStyle.GetDisplay(); @@ -1089,7 +2291,28 @@ private static RenderTextStyle ResolveTextStyle(Dictionary style return new RenderTextStyle(fontSize, color, fontFamily, lineHeight, fontWeight, isItalic, underline, strikeThrough, decorationColor, decorationStyle, textAlign, letterSpacing, textIndent, verticalAlignOffset); } - private static float ParseVerticalAlign(Dictionary styleMap, float fontSize) + /// + /// Reads the box-level meaning of vertical-align, which is what the property means on a + /// table cell. On inline content the same property shifts the text instead, which is what + /// handles. + /// + private static CellVerticalAlign ParseCellVerticalAlign(Dictionary styleMap) + { + if (!styleMap.TryGetValue("vertical-align", out var value) || string.IsNullOrWhiteSpace(value)) + { + return CellVerticalAlign.Middle; + } + + return value.Trim().ToLowerInvariant() switch + { + "top" or "text-top" => CellVerticalAlign.Top, + "bottom" or "text-bottom" => CellVerticalAlign.Bottom, + "middle" => CellVerticalAlign.Middle, + _ => CellVerticalAlign.Top, + }; + } + + private static float ParseVerticalAlign(Dictionary styleMap, float fontSize) { if (!styleMap.TryGetValue("vertical-align", out var value) || string.IsNullOrWhiteSpace(value)) { @@ -1231,11 +2454,200 @@ private static float ParseLineHeight(Dictionary styleMap, float return defaultValue; } - private static Dictionary CreateStyleMap(ICssStyleDeclaration style) + private static void PrepareDocumentForRendering(IDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + foreach (var element in document.All.OfType()) + { + var styleAttribute = element.GetAttribute("style"); + + if (string.IsNullOrWhiteSpace(styleAttribute)) + { + continue; + } + + var currentStyle = styleAttribute; + var changed = false; + + if (TryExtractGradientBackground(currentStyle, out var gradientValue, out var updatedStyle)) + { + currentStyle = updatedStyle; + changed = true; + element.SetAttribute("data-render-gradient", gradientValue); + } + + if (TryExtractGridDeclarations(currentStyle, out var gridValues, out updatedStyle)) + { + currentStyle = updatedStyle; + changed = true; + + foreach (var entry in gridValues) + { + element.SetAttribute($"data-render-{entry.Key}", entry.Value); + } + } + + if (changed) + { + element.SetAttribute("style", currentStyle); + } + } + } + + private static bool TryExtractGradientBackground(string styleAttribute, out string gradientValue, out string updatedStyle) + { + gradientValue = string.Empty; + updatedStyle = styleAttribute; + + if (!styleAttribute.Contains("background-image", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var declarations = styleAttribute.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var remaining = new List(); + + foreach (var declaration in declarations) + { + var separator = declaration.IndexOf(':'); + if (separator <= 0) + { + continue; + } + + var property = declaration[..separator].Trim(); + var value = declaration[(separator + 1)..].Trim(); + + if (string.Equals(property, "background-image", StringComparison.OrdinalIgnoreCase) && + (value.StartsWith("linear-gradient", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("radial-gradient", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("conic-gradient", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("repeating-linear-gradient", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("repeating-radial-gradient", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("repeating-conic-gradient", StringComparison.OrdinalIgnoreCase))) + { + gradientValue = value; + continue; + } + + remaining.Add(declaration); + } + + if (string.IsNullOrWhiteSpace(gradientValue)) + { + return false; + } + + updatedStyle = string.Join(";", remaining); + return true; + } + + private static bool TryExtractGridDeclarations(string styleAttribute, out Dictionary values, out string updatedStyle) + { + values = new Dictionary(StringComparer.OrdinalIgnoreCase); + updatedStyle = styleAttribute; + + if (string.IsNullOrWhiteSpace(styleAttribute)) + { + return false; + } + + var declarations = styleAttribute.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var remaining = new List(); + var strippedAny = false; + + foreach (var declaration in declarations) + { + var separator = declaration.IndexOf(':'); + if (separator <= 0) + { + continue; + } + + var property = declaration[..separator].Trim(); + var value = declaration[(separator + 1)..].Trim(); + + if (string.Equals(property, "grid-column", StringComparison.OrdinalIgnoreCase)) + { + values["grid-column"] = value; + strippedAny = true; + continue; + } + + if (string.Equals(property, "grid-row", StringComparison.OrdinalIgnoreCase)) + { + values["grid-row"] = value; + strippedAny = true; + continue; + } + + if (string.Equals(property, "grid-template-columns", StringComparison.OrdinalIgnoreCase)) + { + values["grid-template-columns"] = value; + strippedAny = true; + continue; + } + + if (string.Equals(property, "grid-template-rows", StringComparison.OrdinalIgnoreCase)) + { + values["grid-template-rows"] = value; + strippedAny = true; + continue; + } + + if (string.Equals(property, "column-gap", StringComparison.OrdinalIgnoreCase)) + { + values["column-gap"] = value; + strippedAny = true; + continue; + } + + if (string.Equals(property, "row-gap", StringComparison.OrdinalIgnoreCase)) + { + values["row-gap"] = value; + strippedAny = true; + continue; + } + + if (string.Equals(property, "gap", StringComparison.OrdinalIgnoreCase)) + { + values["gap"] = value; + strippedAny = true; + continue; + } + + remaining.Add(declaration); + } + + if (!strippedAny) + { + return false; + } + + updatedStyle = string.Join(";", remaining); + return true; + } + + private static Dictionary CreateStyleMap(ICssStyleDeclaration style, IElement? element = null) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + var inlineStyle = element?.GetAttribute("style"); + var gridColumnValue = element?.GetAttribute("data-render-grid-column"); + var gridRowValue = element?.GetAttribute("data-render-grid-row"); + var gridTemplateColumnsValue = element?.GetAttribute("data-render-grid-template-columns"); + var gridTemplateRowsValue = element?.GetAttribute("data-render-grid-template-rows"); + var columnGapValue = element?.GetAttribute("data-render-column-gap"); + var rowGapValue = element?.GetAttribute("data-render-row-gap"); + var gapValue = element?.GetAttribute("data-render-gap"); + + var displayValue = style.GetDisplay(); + if (string.IsNullOrWhiteSpace(displayValue)) + { + displayValue = ParseStyleAttributeValue(inlineStyle, "display"); + } - AddIfPresent(map, "display", style.GetDisplay()); + AddIfPresent(map, "display", displayValue); AddIfPresent(map, "visibility", style.GetVisibility()); AddIfPresent(map, "width", style.GetWidth()); AddIfPresent(map, "height", style.GetHeight()); @@ -1276,6 +2688,36 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "outline-color", style.GetPropertyValue("outline-color")); AddIfPresent(map, "background-color", style.GetBackgroundColor()); + AddIfPresent(map, "grid-template-columns", !string.IsNullOrWhiteSpace(gridTemplateColumnsValue) ? gridTemplateColumnsValue : (string.IsNullOrWhiteSpace(style.GetPropertyValue("grid-template-columns")) ? ParseStyleAttributeValue(inlineStyle, "grid-template-columns") : style.GetPropertyValue("grid-template-columns"))); + AddIfPresent(map, "grid-template-rows", !string.IsNullOrWhiteSpace(gridTemplateRowsValue) ? gridTemplateRowsValue : (string.IsNullOrWhiteSpace(style.GetPropertyValue("grid-template-rows")) ? ParseStyleAttributeValue(inlineStyle, "grid-template-rows") : style.GetPropertyValue("grid-template-rows"))); + AddIfPresent(map, "column-gap", !string.IsNullOrWhiteSpace(columnGapValue) ? columnGapValue : (string.IsNullOrWhiteSpace(style.GetPropertyValue("column-gap")) ? ParseStyleAttributeValue(inlineStyle, "column-gap") : style.GetPropertyValue("column-gap"))); + AddIfPresent(map, "row-gap", !string.IsNullOrWhiteSpace(rowGapValue) ? rowGapValue : (string.IsNullOrWhiteSpace(style.GetPropertyValue("row-gap")) ? ParseStyleAttributeValue(inlineStyle, "row-gap") : style.GetPropertyValue("row-gap"))); + AddIfPresent(map, "gap", !string.IsNullOrWhiteSpace(gapValue) ? gapValue : (string.IsNullOrWhiteSpace(style.GetPropertyValue("gap")) ? ParseStyleAttributeValue(inlineStyle, "gap") : style.GetPropertyValue("gap"))); + AddIfPresent(map, "grid-column", !string.IsNullOrWhiteSpace(gridColumnValue) ? gridColumnValue : ParseStyleAttributeValue(inlineStyle, "grid-column")); + AddIfPresent(map, "grid-row", !string.IsNullOrWhiteSpace(gridRowValue) ? gridRowValue : ParseStyleAttributeValue(inlineStyle, "grid-row")); + AddIfPresent(map, "flex-direction", string.IsNullOrWhiteSpace(style.GetPropertyValue("flex-direction")) ? ParseStyleAttributeValue(inlineStyle, "flex-direction") : style.GetPropertyValue("flex-direction")); + AddIfPresent(map, "justify-content", string.IsNullOrWhiteSpace(style.GetPropertyValue("justify-content")) ? ParseStyleAttributeValue(inlineStyle, "justify-content") : style.GetPropertyValue("justify-content")); + AddIfPresent(map, "align-items", string.IsNullOrWhiteSpace(style.GetPropertyValue("align-items")) ? ParseStyleAttributeValue(inlineStyle, "align-items") : style.GetPropertyValue("align-items")); + AddIfPresent(map, "align-self", string.IsNullOrWhiteSpace(style.GetPropertyValue("align-self")) ? ParseStyleAttributeValue(inlineStyle, "align-self") : style.GetPropertyValue("align-self")); + AddIfPresent(map, "flex-wrap", string.IsNullOrWhiteSpace(style.GetPropertyValue("flex-wrap")) ? ParseStyleAttributeValue(inlineStyle, "flex-wrap") : style.GetPropertyValue("flex-wrap")); + AddIfPresent(map, "flex-grow", string.IsNullOrWhiteSpace(style.GetPropertyValue("flex-grow")) ? ParseStyleAttributeValue(inlineStyle, "flex-grow") : style.GetPropertyValue("flex-grow")); + AddIfPresent(map, "flex-shrink", string.IsNullOrWhiteSpace(style.GetPropertyValue("flex-shrink")) ? ParseStyleAttributeValue(inlineStyle, "flex-shrink") : style.GetPropertyValue("flex-shrink")); + AddIfPresent(map, "flex-basis", string.IsNullOrWhiteSpace(style.GetPropertyValue("flex-basis")) ? ParseStyleAttributeValue(inlineStyle, "flex-basis") : style.GetPropertyValue("flex-basis")); + AddIfPresent(map, "order", string.IsNullOrWhiteSpace(style.GetPropertyValue("order")) ? ParseStyleAttributeValue(inlineStyle, "order") : style.GetPropertyValue("order")); + AddIfPresent(map, "align-content", string.IsNullOrWhiteSpace(style.GetPropertyValue("align-content")) ? ParseStyleAttributeValue(inlineStyle, "align-content") : style.GetPropertyValue("align-content")); + + var backgroundImageValue = element is not null + ? element.GetAttribute("data-render-gradient") + : null; + + if (!string.IsNullOrWhiteSpace(backgroundImageValue)) + { + AddIfPresent(map, "background-image", backgroundImageValue); + } + else + { + AddIfPresent(map, "background-image", style.GetPropertyValue("background-image")); + } AddIfPresent(map, "font-size", style.GetFontSize()); AddIfPresent(map, "font-family", style.GetFontFamily()); AddIfPresent(map, "font-weight", style.GetPropertyValue("font-weight")); @@ -1302,6 +2744,31 @@ private static void AddIfPresent(Dictionary map, string property } } + private static string? ParseStyleAttributeValue(string? styleAttribute, string propertyName) + { + if (string.IsNullOrWhiteSpace(styleAttribute)) + { + return null; + } + + foreach (var declaration in styleAttribute.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var separatorIndex = declaration.IndexOf(':'); + if (separatorIndex <= 0) + { + continue; + } + + var candidateProperty = declaration[..separatorIndex].Trim(); + if (string.Equals(candidateProperty, propertyName, StringComparison.OrdinalIgnoreCase)) + { + return declaration[(separatorIndex + 1)..].Trim(); + } + } + + return null; + } + private static bool TryGetFirstCollapsibleChildTopMargin(ElementRenderNode node, float containingWidth, out float marginTop) { foreach (var child in node.Children) @@ -1341,7 +2808,7 @@ private static bool TryGetFirstCollapsibleChildTopMargin(ElementRenderNode node, return false; } - var childStyle = CreateStyleMap(childElement.ComputedStyle); + var childStyle = CreateStyleMap(childElement.ComputedStyle, childElement.Ref); marginTop = ParseLength(childStyle, "margin-top", containingWidth, 0f, allowAuto: false); return true; } @@ -1362,7 +2829,7 @@ private static IEnumerable OrderChildrenForPainting(IEnumerable styleMap) borderWidth = ApplyBorderStyleToWidths(borderWidth, borderStyle); var backgroundColor = ParseColor(styleMap.TryGetValue("background-color", out var background) ? background : null, RenderColor.Transparent); + var backgroundPaint = ParseBackgroundPaint(styleMap, backgroundColor); var borderColor = ParseColor( styleMap.TryGetValue("border-top-color", out var topColor) ? topColor : styleMap.TryGetValue("border-right-color", out var rightColor) ? rightColor : @@ -1461,7 +2929,7 @@ private static BoxStyle ResolveBoxStyle(Dictionary styleMap) null, RenderColor.Black); - return new BoxStyle(margin, padding, borderWidth, backgroundColor, borderColor); + return new BoxStyle(margin, padding, borderWidth, backgroundPaint, borderColor); } private static EdgeBorderStyle ResolveBorderStyles(Dictionary styleMap) @@ -1528,216 +2996,1007 @@ private static string GetFloat(Dictionary styleMap) : string.Empty; } - private static void PaintOutline(DisplayList displayList, Dictionary styleMap, float x, float y, float width, float height) + private static void PaintOutline(DisplayList displayList, Dictionary styleMap, float x, float y, float width, float height) + { + if (!styleMap.TryGetValue("outline-width", out var outlineWidthRaw) || + !styleMap.TryGetValue("outline-style", out var outlineStyleRaw)) + { + return; + } + + var style = ParseBorderStyleToken(outlineStyleRaw); + + if (!IsPaintedBorderStyle(style)) + { + return; + } + + var outlineWidth = ParseLengthValue(outlineWidthRaw, 0f, allowAuto: false); + + if (outlineWidth <= 0f) + { + return; + } + + var color = ParseColor( + styleMap.TryGetValue("outline-color", out var outlineColor) ? outlineColor : null, + RenderColor.Black); + + PaintBorder( + displayList, + color, + x - outlineWidth, + y - outlineWidth, + width + (2f * outlineWidth), + height + (2f * outlineWidth), + new EdgeSizes(outlineWidth, outlineWidth, outlineWidth, outlineWidth)); + } + + private static bool IsReplacedElementTag(string tagName) => + string.Equals(tagName, "img", StringComparison.OrdinalIgnoreCase) || + string.Equals(tagName, "svg", StringComparison.OrdinalIgnoreCase); + + private static bool TryResolveReplacedElementImage(ElementRenderNode node, Dictionary styleMap, float containingWidth, float x, float y, out RenderedImage? image, out RenderRect rect) => + TryResolveImage(node, styleMap, containingWidth, x, y, out image, out rect) || + TryResolveInlineSvg(node, styleMap, containingWidth, x, y, out image, out rect); + + private static bool TryResolveImage(ElementRenderNode node, Dictionary styleMap, float containingWidth, float x, float y, out RenderedImage? image, out RenderRect rect) + { + image = null; + rect = default; + + if (!string.Equals(node.Ref.LocalName, "img", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var source = node.Ref.GetAttribute("src"); + if (!TryGetOrLoadImageResource(node.Ref, source, out var imageResource) || imageResource is null) + { + return false; + } + + return TryResolveReplacedElementRect(imageResource, styleMap, containingWidth, x, y, out image, out rect); + } + + private static bool TryResolveInlineSvg(ElementRenderNode node, Dictionary styleMap, float containingWidth, float x, float y, out RenderedImage? image, out RenderRect rect) + { + image = null; + rect = default; + + if (!string.Equals(node.Ref.LocalName, "svg", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!TryGetOrLoadInlineSvgResource(node.Ref, out var imageResource) || imageResource is null) + { + return false; + } + + return TryResolveReplacedElementRect(imageResource, styleMap, containingWidth, x, y, out image, out rect); + } + + private static bool TryResolveReplacedElementRect(CachedImageResource imageResource, Dictionary styleMap, float containingWidth, float x, float y, out RenderedImage? image, out RenderRect rect) + { + image = null; + rect = default; + + var width = ParseLength(styleMap, "width", containingWidth, float.NaN, allowAuto: true); + var height = ParseLength(styleMap, "height", containingWidth, float.NaN, allowAuto: true); + + var naturalWidth = imageResource.NaturalWidth; + var naturalHeight = imageResource.NaturalHeight; + + if (float.IsNaN(width) && float.IsNaN(height)) + { + width = naturalWidth; + height = naturalHeight; + } + else if (float.IsNaN(width) && !float.IsNaN(height) && naturalWidth > 0f) + { + width = (height / naturalHeight) * naturalWidth; + } + else if (!float.IsNaN(width) && float.IsNaN(height) && naturalHeight > 0f) + { + height = (width / naturalWidth) * naturalHeight; + } + + if (float.IsNaN(width) || float.IsNaN(height) || width <= 0f || height <= 0f) + { + width = naturalWidth; + height = naturalHeight; + } + + image = new RenderedImage(imageResource.Bytes, (int)Math.Max(1, Math.Round(width)), (int)Math.Max(1, Math.Round(height)), imageResource.MimeType); + rect = new RenderRect(x, y, width, height); + return true; + } + + private static bool TryGetOrLoadInlineSvgResource(IElement element, out CachedImageResource? imageResource) + { + if (s_inlineSvgCacheByElement.TryGetValue(element, out var cached)) + { + imageResource = cached; + return true; + } + + if (!TryRasterizeInlineSvgElement(element, out imageResource) || imageResource is null) + { + return false; + } + + s_inlineSvgCacheByElement.AddOrUpdate(element, imageResource); + return true; + } + + private static bool TryRasterizeInlineSvgElement(IElement element, out CachedImageResource? imageResource) + { + imageResource = null; + + // The already-parsed element is walked directly - AngleSharp parsed this SVG once, as + // part of the host document, and it is never serialized back to text and re-parsed. Only + // the SVG's own presentation attributes/style apply - page CSS never cascades into it. + if (!SvgRasterizer.TryRasterizeElement(element, out var pngBytes, out var naturalWidth, out var naturalHeight)) + { + return false; + } + + imageResource = new CachedImageResource(pngBytes, "image/png", naturalWidth, naturalHeight); + return true; + } + + private static bool TryGetOrLoadImageResource(IElement element, string? source, out CachedImageResource? imageResource) + { + imageResource = null; + if (string.IsNullOrWhiteSpace(source)) + { + return false; + } + + var cacheKey = source.Trim(); + var cache = GetImageCache(element); + + if (cache is not null) + { + lock (cache.Resources) + { + if (cache.Resources.TryGetValue(cacheKey, out imageResource)) + { + return imageResource is not null; + } + } + } + + if (!TryLoadImageResource(element, source, out imageResource)) + { + if (cache is not null) + { + lock (cache.Resources) + { + cache.Resources[cacheKey] = null; + } + } + + return false; + } + + if (cache is not null) + { + lock (cache.Resources) + { + cache.Resources[cacheKey] = imageResource; + } + } + + return true; + } + + private static DocumentImageCache? GetImageCache(IElement element) + { + var owner = element.Owner; + return owner is null ? null : s_imageCacheByDocument.GetValue(owner, static _ => new DocumentImageCache()); + } + + private static bool TryLoadImageResource(IElement element, string? source, out CachedImageResource? imageResource) + { + imageResource = null; + + byte[]? bytes = null; + string? mimeType = null; + + if (element is ILoadableElement loadableElement && loadableElement.CurrentDownload is { Task: not null } download) + { + IResponse? response; + + try + { + response = download.Task.GetAwaiter().GetResult(); + } + catch + { + response = null; + } + + if (response?.Content is not null) + { + using var sourceStream = response.Content; + using var memoryStream = new MemoryStream(); + sourceStream.CopyTo(memoryStream); + bytes = memoryStream.ToArray(); + mimeType = response.Headers?.TryGetValue("Content-Type", out var contentType) == true && !string.IsNullOrWhiteSpace(contentType) + ? contentType + : "image/unknown"; + } + } + + if (bytes is null && TryParseDataUri(source, out var dataUriBytes, out var dataUriMimeType)) + { + bytes = dataUriBytes; + mimeType = dataUriMimeType; + } + + if (bytes is null || bytes.Length == 0) + { + return false; + } + + if (SvgRasterizer.IsSvg(bytes)) + { + if (!SvgRasterizer.TryRasterizeMarkup(bytes, out var rasterizedBytes, out var svgNaturalWidth, out var svgNaturalHeight)) + { + return false; + } + + imageResource = new CachedImageResource(rasterizedBytes, "image/png", svgNaturalWidth, svgNaturalHeight); + return true; + } + + using var skImage = SKImage.FromEncodedData(bytes); + if (skImage is null) + { + return false; + } + + var naturalWidth = skImage.Width; + var naturalHeight = skImage.Height; + + imageResource = new CachedImageResource(bytes, mimeType ?? "image/unknown", naturalWidth, naturalHeight); + return true; + } + + private static bool TryParseDataUri(string? source, out byte[]? bytes, out string? mimeType) + { + bytes = null; + mimeType = null; + + if (string.IsNullOrWhiteSpace(source) || !source.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var commaIndex = source.IndexOf(','); + if (commaIndex <= 5) + { + return false; + } + + var header = source[5..commaIndex]; + var isBase64 = header.EndsWith(";base64", StringComparison.OrdinalIgnoreCase); + var mediaType = isBase64 ? header[..^7] : header; + var payload = source[(commaIndex + 1)..]; + + if (string.IsNullOrEmpty(mediaType)) + { + mimeType = "image/unknown"; + } + else if (mediaType.StartsWith(";", StringComparison.Ordinal)) + { + mimeType = "image/unknown"; + } + else + { + mimeType = mediaType; + } + + try + { + bytes = isBase64 + ? Convert.FromBase64String(payload) + : Encoding.UTF8.GetBytes(Uri.UnescapeDataString(payload)); + } + catch + { + bytes = null; + return false; + } + + return bytes.Length > 0; + } + + private static void PaintBackground(DisplayList displayList, RenderPaint paint, float x, float y, float width, float height) + { + if (width <= 0f || height <= 0f) + { + return; + } + + if (paint is RenderColorPaint colorPaint) + { + if (colorPaint.Color.A == 0) + { + return; + } + + displayList.FillRect(new RenderRect(x, y, width, height), colorPaint.Color); + return; + } + + if (paint is RenderGradientPaint) + { + displayList.FillRect(new RenderRect(x, y, width, height), paint); + } + } + + private static void PaintBorder(DisplayList displayList, RenderColor color, float x, float y, float width, float height, EdgeSizes border) + { + if (color.A == 0 || width <= 0f || height <= 0f) + { + return; + } + + if (border.Top > 0f) + { + displayList.FillRect(new RenderRect(x, y, width, border.Top), color); + } + + if (border.Right > 0f) + { + displayList.FillRect(new RenderRect(x + width - border.Right, y, border.Right, height), color); + } + + if (border.Bottom > 0f) + { + displayList.FillRect(new RenderRect(x, y + height - border.Bottom, width, border.Bottom), color); + } + + if (border.Left > 0f) + { + displayList.FillRect(new RenderRect(x, y, border.Left, height), color); + } + } + + private static float CollapseMargins(float previousMarginBottom, float currentMarginTop) + { + var positivePart = Math.Max(0f, previousMarginBottom) + Math.Max(0f, currentMarginTop); + var negativePart = Math.Min(0f, previousMarginBottom) + Math.Min(0f, currentMarginTop); + + if (positivePart > 0f && negativePart < 0f) + { + return positivePart + negativePart; + } + + if (positivePart > 0f) + { + return Math.Max(previousMarginBottom, currentMarginTop); + } + + return Math.Min(previousMarginBottom, currentMarginTop); + } + + private static void ResolveHorizontalMetrics( + float containingWidth, + float specifiedContentWidth, + float borderLeft, + float borderRight, + float paddingLeft, + float paddingRight, + ref float marginLeft, + ref float marginRight, + out float contentWidth) + { + var hasAutoWidth = float.IsNaN(specifiedContentWidth); + var hasAutoLeft = float.IsNaN(marginLeft); + var hasAutoRight = float.IsNaN(marginRight); + + var usedMarginLeft = hasAutoLeft ? 0f : marginLeft; + var usedMarginRight = hasAutoRight ? 0f : marginRight; + var horizontalExtras = borderLeft + borderRight + paddingLeft + paddingRight; + + if (hasAutoWidth) + { + contentWidth = containingWidth - horizontalExtras - usedMarginLeft - usedMarginRight; + + if (contentWidth < 0f) + { + contentWidth = 0f; + } + + marginLeft = usedMarginLeft; + marginRight = usedMarginRight; + return; + } + + contentWidth = Math.Max(0f, specifiedContentWidth); + var underflow = containingWidth - horizontalExtras - contentWidth - usedMarginLeft - usedMarginRight; + + if (hasAutoLeft && hasAutoRight) + { + var half = underflow / 2f; + marginLeft = half; + marginRight = half; + return; + } + + if (hasAutoLeft) + { + marginLeft = underflow; + marginRight = usedMarginRight; + return; + } + + if (hasAutoRight) + { + marginLeft = usedMarginLeft; + marginRight = underflow; + return; + } + + marginLeft = usedMarginLeft; + marginRight = usedMarginRight + underflow; + } + + private static float ParseLength(Dictionary styleMap, string propertyName, float relativeTo, float defaultValue, bool allowAuto) + { + if (!styleMap.TryGetValue(propertyName, out var value) || string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + var parsed = value.Trim().ToLowerInvariant(); + + if (allowAuto && parsed == "auto") + { + return float.NaN; + } + + if (parsed.EndsWith("%", StringComparison.Ordinal) && + float.TryParse(parsed[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var pct)) + { + return (pct / 100f) * relativeTo; + } + + return ParseLengthValue(parsed, defaultValue, allowAuto: false); + } + + private static float ParseLengthValue(string value, float defaultValue, bool allowAuto = true) + { + if (allowAuto && string.Equals(value.Trim(), "auto", StringComparison.OrdinalIgnoreCase)) + { + return float.NaN; + } + + if (TryParsePixelValue(value, out var pixels)) + { + return pixels; + } + + if (float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var unitless)) + { + return unitless; + } + + return defaultValue; + } + + private static bool TryParsePixelValue(string value, out float pixels) + { + var trimmed = value.Trim().ToLowerInvariant(); + + if (trimmed.EndsWith("px", StringComparison.Ordinal)) + { + return float.TryParse(trimmed[..^2], NumberStyles.Float, CultureInfo.InvariantCulture, out pixels); + } + + pixels = 0f; + return false; + } + + private static RenderPaint ParseBackgroundPaint(Dictionary styleMap, RenderColor fallbackColor) + { + if (!styleMap.TryGetValue("background-image", out var backgroundImage) || string.IsNullOrWhiteSpace(backgroundImage)) + { + return new RenderColorPaint(fallbackColor); + } + + return ParseGradientPaint(backgroundImage, fallbackColor); + } + + private static RenderPaint ParseGradientPaint(string rawValue, RenderColor fallbackColor) + { + var value = rawValue.Trim(); + + if (value.StartsWith("repeating-linear-gradient", StringComparison.OrdinalIgnoreCase)) + { + return new RenderGradientPaint(ParseLinearGradient(value, "repeating-linear-gradient", repeating: true, fallbackColor)); + } + + if (value.StartsWith("linear-gradient", StringComparison.OrdinalIgnoreCase)) + { + return new RenderGradientPaint(ParseLinearGradient(value, "linear-gradient", repeating: false, fallbackColor)); + } + + if (value.StartsWith("repeating-radial-gradient", StringComparison.OrdinalIgnoreCase)) + { + return new RenderGradientPaint(ParseRadialGradient(value, "repeating-radial-gradient", repeating: true, fallbackColor)); + } + + if (value.StartsWith("radial-gradient", StringComparison.OrdinalIgnoreCase)) + { + return new RenderGradientPaint(ParseRadialGradient(value, "radial-gradient", repeating: false, fallbackColor)); + } + + if (value.StartsWith("repeating-conic-gradient", StringComparison.OrdinalIgnoreCase)) + { + return new RenderGradientPaint(ParseConicGradient(value, "repeating-conic-gradient", repeating: true, fallbackColor)); + } + + if (value.StartsWith("conic-gradient", StringComparison.OrdinalIgnoreCase)) + { + return new RenderGradientPaint(ParseConicGradient(value, "conic-gradient", repeating: false, fallbackColor)); + } + + return new RenderColorPaint(fallbackColor); + } + + private static RenderGradient ParseLinearGradient(string rawValue, string functionName, bool repeating, RenderColor fallbackColor) + { + var inner = ExtractGradientInnerExpression(rawValue, functionName); + var parts = SplitGradientArguments(inner); + var startIndex = 0; + var angleDegrees = 90f; + + if (parts.Length > 0) + { + var first = parts[0].Trim(); + + if (TryParseDirection(first, out var parsedAngle)) + { + angleDegrees = parsedAngle; + startIndex = 1; + } + } + + var stops = ParseGradientStops(parts.Skip(startIndex).ToArray(), fallbackColor); + return new RenderGradient(RenderGradientKind.Linear, stops, AngleDegrees: angleDegrees, Repeating: repeating); + } + + private static RenderGradient ParseRadialGradient(string rawValue, string functionName, bool repeating, RenderColor fallbackColor) { - if (!styleMap.TryGetValue("outline-width", out var outlineWidthRaw) || - !styleMap.TryGetValue("outline-style", out var outlineStyleRaw)) + var inner = ExtractGradientInnerExpression(rawValue, functionName); + var parts = SplitGradientArguments(inner); + var startIndex = 0; + + var isCircle = false; + var sizeKind = RenderGradientSizeKind.FarthestCorner; + float? explicitRadiusX = null; + float? explicitRadiusY = null; + var centerX = 0.5f; + var centerY = 0.5f; + + if (parts.Length > 0 && LooksLikeRadialConfiguration(parts[0])) { - return; - } + var configText = parts[0].Trim(); + startIndex = 1; - var style = ParseBorderStyleToken(outlineStyleRaw); + var atIndex = configText.IndexOf(" at ", StringComparison.OrdinalIgnoreCase); + var shapeSizeText = atIndex >= 0 ? configText[..atIndex].Trim() : configText; + var positionText = atIndex >= 0 ? configText[(atIndex + 4)..].Trim() : null; - if (!IsPaintedBorderStyle(style)) - { - return; + ParseRadialShapeAndSize(shapeSizeText, out isCircle, out sizeKind, out explicitRadiusX, out explicitRadiusY); + + if (positionText is not null) + { + (centerX, centerY) = ParsePosition(positionText); + } } - var outlineWidth = ParseLengthValue(outlineWidthRaw, 0f, allowAuto: false); + var stops = ParseGradientStops(parts.Skip(startIndex).ToArray(), fallbackColor); + return new RenderGradient( + RenderGradientKind.Radial, + stops, + CenterX: centerX, + CenterY: centerY, + IsCircle: isCircle, + Repeating: repeating, + SizeKind: sizeKind, + ExplicitRadiusX: explicitRadiusX, + ExplicitRadiusY: explicitRadiusY); + } - if (outlineWidth <= 0f) + private static RenderGradient ParseConicGradient(string rawValue, string functionName, bool repeating, RenderColor fallbackColor) + { + var inner = ExtractGradientInnerExpression(rawValue, functionName); + var parts = SplitGradientArguments(inner); + var startIndex = 0; + var angleDegrees = 0f; + var centerX = 0.5f; + var centerY = 0.5f; + + if (parts.Length > 0) { - return; + var first = parts[0].Trim(); + + if (first.StartsWith("from", StringComparison.OrdinalIgnoreCase) || first.StartsWith("at", StringComparison.OrdinalIgnoreCase)) + { + var atIndex = first.IndexOf(" at ", StringComparison.OrdinalIgnoreCase); + var fromText = atIndex >= 0 ? first[..atIndex].Trim() : first; + var positionText = atIndex >= 0 + ? first[(atIndex + 4)..].Trim() + : (first.StartsWith("at", StringComparison.OrdinalIgnoreCase) ? first[2..].Trim() : null); + + if (fromText.StartsWith("from", StringComparison.OrdinalIgnoreCase)) + { + angleDegrees = ParseAngle(fromText[4..].Trim()); + } + + if (positionText is not null) + { + (centerX, centerY) = ParsePosition(positionText); + } + + startIndex = 1; + } } - var color = ParseColor( - styleMap.TryGetValue("outline-color", out var outlineColor) ? outlineColor : null, - RenderColor.Black); + var stops = ParseGradientStops(parts.Skip(startIndex).ToArray(), fallbackColor, isConic: true); + return new RenderGradient(RenderGradientKind.Conic, stops, AngleDegrees: angleDegrees, CenterX: centerX, CenterY: centerY, Repeating: repeating); + } - PaintBorder( - displayList, - color, - x - outlineWidth, - y - outlineWidth, - width + (2f * outlineWidth), - height + (2f * outlineWidth), - new EdgeSizes(outlineWidth, outlineWidth, outlineWidth, outlineWidth)); + /// + /// Distinguishes a radial-gradient's leading `<ending-shape> || <size> [at + /// <position>]` configuration clause from what is actually just its first color stop - + /// a color stop never starts with a shape/size keyword, "at", or a bare length. + /// + private static bool LooksLikeRadialConfiguration(string part) + { + var lower = part.Trim().ToLowerInvariant(); + + return lower.StartsWith("circle", StringComparison.Ordinal) || + lower.StartsWith("ellipse", StringComparison.Ordinal) || + lower.StartsWith("closest-", StringComparison.Ordinal) || + lower.StartsWith("farthest-", StringComparison.Ordinal) || + lower.StartsWith("at ", StringComparison.Ordinal) || + lower.Contains(" at ", StringComparison.Ordinal) || + (TryParsePixelValue(lower.Split(' ')[0], out _) && !lower.Contains(',')); } - private static void PaintBackground(DisplayList displayList, RenderColor color, float x, float y, float width, float height) + private static void ParseRadialShapeAndSize(string text, out bool isCircle, out RenderGradientSizeKind sizeKind, out float? explicitRadiusX, out float? explicitRadiusY) { - if (color.A == 0 || width <= 0f || height <= 0f) + isCircle = false; + sizeKind = RenderGradientSizeKind.FarthestCorner; + explicitRadiusX = null; + explicitRadiusY = null; + + if (string.IsNullOrWhiteSpace(text)) { return; } - displayList.FillRect(new RenderRect(x, y, width, height), color); - } + var tokens = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var lengths = new List(); - private static void PaintBorder(DisplayList displayList, RenderColor color, float x, float y, float width, float height, EdgeSizes border) - { - if (color.A == 0 || width <= 0f || height <= 0f) + foreach (var token in tokens) { - return; + switch (token.ToLowerInvariant()) + { + case "circle": + isCircle = true; + break; + case "ellipse": + isCircle = false; + break; + case "closest-side": + sizeKind = RenderGradientSizeKind.ClosestSide; + break; + case "farthest-side": + sizeKind = RenderGradientSizeKind.FarthestSide; + break; + case "closest-corner": + sizeKind = RenderGradientSizeKind.ClosestCorner; + break; + case "farthest-corner": + sizeKind = RenderGradientSizeKind.FarthestCorner; + break; + default: + if (TryParsePixelValue(token, out var pixels)) + { + lengths.Add(pixels); + } + + break; + } } - if (border.Top > 0f) + if (lengths.Count > 0) { - displayList.FillRect(new RenderRect(x, y, width, border.Top), color); + sizeKind = RenderGradientSizeKind.Explicit; + explicitRadiusX = lengths[0]; + explicitRadiusY = lengths.Count > 1 ? lengths[1] : lengths[0]; + + if (lengths.Count == 1) + { + // A single explicit length implies a circle - CSS grammar doesn't allow one + // length with an explicit "ellipse" keyword (that needs two lengths). + isCircle = true; + } } + } - if (border.Right > 0f) + private static readonly string[] PositionKeywords = ["left", "right", "top", "bottom", "center"]; + + /// + /// Parses a CSS `<position>` value (1-2 tokens, keywords and/or percentages, in either + /// order for keywords) into fractional (0-1) X/Y coordinates. Absolute lengths (`at 20px + /// 10px`) and the 4-value edge-offset syntax are not supported and fall back to center. + /// + private static (float X, float Y) ParsePosition(string text) + { + var tokens = text.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (tokens.Length == 0) { - displayList.FillRect(new RenderRect(x + width - border.Right, y, border.Right, height), color); + return (0.5f, 0.5f); } - if (border.Bottom > 0f) + float? x = null; + float? y = null; + + foreach (var token in tokens) { - displayList.FillRect(new RenderRect(x, y + height - border.Bottom, width, border.Bottom), color); + switch (token.ToLowerInvariant()) + { + case "left": + x = 0f; + break; + case "right": + x = 1f; + break; + case "top": + y = 0f; + break; + case "bottom": + y = 1f; + break; + } } - if (border.Left > 0f) + var positionalTokens = tokens.Where(token => !PositionKeywords.Contains(token.ToLowerInvariant())).ToArray(); + var assignedX = x is not null; + + foreach (var token in positionalTokens) { - displayList.FillRect(new RenderRect(x, y, border.Left, height), color); + var value = ParseStopPosition(token); + + if (!assignedX) + { + x = value; + assignedX = true; + } + else if (y is null) + { + y = value; + } } + + return (x ?? 0.5f, y ?? 0.5f); } - private static float CollapseMargins(float previousMarginBottom, float currentMarginTop) + private static string ExtractGradientInnerExpression(string rawValue, string functionName) { - var positivePart = Math.Max(0f, previousMarginBottom) + Math.Max(0f, currentMarginTop); - var negativePart = Math.Min(0f, previousMarginBottom) + Math.Min(0f, currentMarginTop); - - if (positivePart > 0f && negativePart < 0f) + if (!rawValue.StartsWith(functionName, StringComparison.OrdinalIgnoreCase)) { - return positivePart + negativePart; + return string.Empty; } - if (positivePart > 0f) + var opening = rawValue.IndexOf('('); + var closing = rawValue.LastIndexOf(')'); + + if (opening < 0 || closing <= opening) { - return Math.Max(previousMarginBottom, currentMarginTop); + return string.Empty; } - return Math.Min(previousMarginBottom, currentMarginTop); + return rawValue[(opening + 1)..closing].Trim(); } - private static void ResolveHorizontalMetrics( - float containingWidth, - float specifiedContentWidth, - float borderLeft, - float borderRight, - float paddingLeft, - float paddingRight, - ref float marginLeft, - ref float marginRight, - out float contentWidth) + private static string[] SplitGradientArguments(string value) { - var hasAutoWidth = float.IsNaN(specifiedContentWidth); - var hasAutoLeft = float.IsNaN(marginLeft); - var hasAutoRight = float.IsNaN(marginRight); + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } - var usedMarginLeft = hasAutoLeft ? 0f : marginLeft; - var usedMarginRight = hasAutoRight ? 0f : marginRight; - var horizontalExtras = borderLeft + borderRight + paddingLeft + paddingRight; + var parts = new List(); + var current = new StringBuilder(); + var depth = 0; - if (hasAutoWidth) + foreach (var character in value) { - contentWidth = containingWidth - horizontalExtras - usedMarginLeft - usedMarginRight; - - if (contentWidth < 0f) + if (character == '(') { - contentWidth = 0f; + depth++; + } + else if (character == ')') + { + depth = Math.Max(0, depth - 1); } + else if (character == ',' && depth == 0) + { + var part = current.ToString().Trim(); + if (part.Length > 0) + { + parts.Add(part); + } - marginLeft = usedMarginLeft; - marginRight = usedMarginRight; - return; - } + current.Clear(); + continue; + } - contentWidth = Math.Max(0f, specifiedContentWidth); - var underflow = containingWidth - horizontalExtras - contentWidth - usedMarginLeft - usedMarginRight; + current.Append(character); + } - if (hasAutoLeft && hasAutoRight) + var last = current.ToString().Trim(); + if (last.Length > 0) { - var half = underflow / 2f; - marginLeft = half; - marginRight = half; - return; + parts.Add(last); } - if (hasAutoLeft) + return parts.ToArray(); + } + + private static IReadOnlyList ParseGradientStops(string[] parts, RenderColor fallbackColor, bool isConic = false) + { + if (parts.Length == 0) { - marginLeft = underflow; - marginRight = usedMarginRight; - return; + return [new RenderGradientStop(0f, fallbackColor)]; } - if (hasAutoRight) + var stops = new List(parts.Length); + + for (var index = 0; index < parts.Length; index++) { - marginLeft = usedMarginLeft; - marginRight = underflow; - return; + var part = parts[index].Trim(); + if (part.Length == 0) + { + continue; + } + + var separatorIndex = part.IndexOfAny([ ' ', '\t', '\n', '\r' ]); + var colorToken = separatorIndex >= 0 ? part[..separatorIndex].Trim() : part; + var positionToken = separatorIndex >= 0 ? part[(separatorIndex + 1)..].Trim() : string.Empty; + + var color = ParseColor(colorToken, fallbackColor); + var autoPosition = parts.Length == 1 ? 0f : (index / (float)Math.Max(1, parts.Length - 1)); + + if (string.IsNullOrWhiteSpace(positionToken)) + { + stops.Add(new RenderGradientStop(autoPosition, color)); + } + else if (!isConic && TryParsePixelValue(positionToken, out var pixels)) + { + // An absolute-length stop position ("red 10px") cannot become a fraction until + // the gradient's own rendered geometry (line length/radius) is known, so the raw + // pixel value is carried through and resolved by the backend at paint time. + stops.Add(new RenderGradientStop(autoPosition, color, pixels)); + } + else + { + stops.Add(new RenderGradientStop(ParseStopPosition(positionToken, isConic), color)); + } } - marginLeft = usedMarginLeft; - marginRight = usedMarginRight + underflow; + return stops; } - private static float ParseLength(Dictionary styleMap, string propertyName, float relativeTo, float defaultValue, bool allowAuto) + private static float ParseStopPosition(string rawPosition, bool isConic = false) { - if (!styleMap.TryGetValue(propertyName, out var value) || string.IsNullOrWhiteSpace(value)) + var value = rawPosition.Trim(); + if (string.IsNullOrWhiteSpace(value)) { - return defaultValue; + return 0f; } - var parsed = value.Trim().ToLowerInvariant(); + // conic-gradient stops are naturally written as angles ("90deg", "0.25turn"), which are a + // fraction of the full circle rather than of a linear 0-100% run - resolve those first. + if (isConic && TryParseAngle(value, out var angleDegrees)) + { + var normalizedDegrees = ((angleDegrees % 360f) + 360f) % 360f; + return Math.Clamp(normalizedDegrees / 360f, 0f, 1f); + } - if (allowAuto && parsed == "auto") + if (value.EndsWith("%", StringComparison.Ordinal) && + float.TryParse(value[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent)) { - return float.NaN; + return Math.Clamp(percent / 100f, 0f, 1f); } - if (parsed.EndsWith("%", StringComparison.Ordinal) && - float.TryParse(parsed[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var pct)) + if (float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var numeric)) { - return (pct / 100f) * relativeTo; + return Math.Clamp(numeric, 0f, 1f); } - return ParseLengthValue(parsed, defaultValue, allowAuto: false); + return 0f; } - private static float ParseLengthValue(string value, float defaultValue, bool allowAuto = true) + private static bool TryParseDirection(string value, out float angleDegrees) { - if (allowAuto && string.Equals(value.Trim(), "auto", StringComparison.OrdinalIgnoreCase)) + angleDegrees = 90f; + var normalized = value.Trim().ToLowerInvariant(); + + if (normalized.StartsWith("to ", StringComparison.Ordinal)) { - return float.NaN; + var direction = normalized[3..].Trim(); + angleDegrees = direction switch + { + "top" => 270f, + "right" => 0f, + "bottom" => 90f, + "left" => 180f, + "top right" or "right top" => 315f, + "top left" or "left top" => 225f, + "bottom right" or "right bottom" => 45f, + "bottom left" or "left bottom" => 135f, + _ => 90f, + }; + return true; } - if (TryParsePixelValue(value, out var pixels)) + return TryParseAngle(normalized, out angleDegrees); + } + + private static bool TryParseAngle(string value, out float angleDegrees) + { + angleDegrees = 90f; + var trimmed = value.Trim(); + + if (trimmed.EndsWith("deg", StringComparison.Ordinal) && + float.TryParse(trimmed[..^3].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var degrees)) { - return pixels; + angleDegrees = degrees; + return true; } - if (float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var unitless)) + if (trimmed.EndsWith("grad", StringComparison.Ordinal) && + float.TryParse(trimmed[..^4].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var gradians)) { - return unitless; + angleDegrees = gradians * 0.9f; + return true; } - return defaultValue; - } - - private static bool TryParsePixelValue(string value, out float pixels) - { - var trimmed = value.Trim().ToLowerInvariant(); + if (trimmed.EndsWith("turn", StringComparison.Ordinal) && + float.TryParse(trimmed[..^4].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var turns)) + { + angleDegrees = turns * 360f; + return true; + } - if (trimmed.EndsWith("px", StringComparison.Ordinal)) + // Checked after "grad" - "grad" also ends with "rad" and would otherwise be misread here. + if (trimmed.EndsWith("rad", StringComparison.Ordinal) && + float.TryParse(trimmed[..^3].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var radians)) { - return float.TryParse(trimmed[..^2], NumberStyles.Float, CultureInfo.InvariantCulture, out pixels); + angleDegrees = radians * 180f / (float)Math.PI; + return true; } - pixels = 0f; return false; } + private static float ParseAngle(string value) + { + return TryParseAngle(value, out var angle) ? angle : 0f; + } + private static RenderColor ParseColor(string? rawColor, RenderColor fallback) { if (string.IsNullOrWhiteSpace(rawColor)) @@ -1803,9 +4062,173 @@ private static RenderColor ParseColor(string? rawColor, RenderColor fallback) return RenderColor.Transparent; } + // Regular box/text colors are normalized by AngleSharp.Css's own computed-style engine + // before they ever reach this method (it resolves "red" to an rgb()/hex form itself), so + // this named-color table only matters for values this renderer parses from raw CSS text + // itself - chiefly gradient stop colors, since a `background-image: linear-gradient(...)` + // function's internals are opaque to AngleSharp.Css and are hand-parsed here instead. + // Without it, every named gradient stop color silently fell back to the same color, + // producing an invisible (fallback-to-fallback) "gradient". + if (NamedColors.TryGetValue(color, out var named)) + { + return named; + } + return fallback; } + private static readonly IReadOnlyDictionary NamedColors = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["aliceblue"] = new RenderColor(240, 248, 255), + ["antiquewhite"] = new RenderColor(250, 235, 215), + ["aqua"] = new RenderColor(0, 255, 255), + ["aquamarine"] = new RenderColor(127, 255, 212), + ["azure"] = new RenderColor(240, 255, 255), + ["beige"] = new RenderColor(245, 245, 220), + ["bisque"] = new RenderColor(255, 228, 196), + ["black"] = new RenderColor(0, 0, 0), + ["blanchedalmond"] = new RenderColor(255, 235, 205), + ["blue"] = new RenderColor(0, 0, 255), + ["blueviolet"] = new RenderColor(138, 43, 226), + ["brown"] = new RenderColor(165, 42, 42), + ["burlywood"] = new RenderColor(222, 184, 135), + ["cadetblue"] = new RenderColor(95, 158, 160), + ["chartreuse"] = new RenderColor(127, 255, 0), + ["chocolate"] = new RenderColor(210, 105, 30), + ["coral"] = new RenderColor(255, 127, 80), + ["cornflowerblue"] = new RenderColor(100, 149, 237), + ["cornsilk"] = new RenderColor(255, 248, 220), + ["crimson"] = new RenderColor(220, 20, 60), + ["cyan"] = new RenderColor(0, 255, 255), + ["darkblue"] = new RenderColor(0, 0, 139), + ["darkcyan"] = new RenderColor(0, 139, 139), + ["darkgoldenrod"] = new RenderColor(184, 134, 11), + ["darkgray"] = new RenderColor(169, 169, 169), + ["darkgreen"] = new RenderColor(0, 100, 0), + ["darkgrey"] = new RenderColor(169, 169, 169), + ["darkkhaki"] = new RenderColor(189, 183, 107), + ["darkmagenta"] = new RenderColor(139, 0, 139), + ["darkolivegreen"] = new RenderColor(85, 107, 47), + ["darkorange"] = new RenderColor(255, 140, 0), + ["darkorchid"] = new RenderColor(153, 50, 204), + ["darkred"] = new RenderColor(139, 0, 0), + ["darksalmon"] = new RenderColor(233, 150, 122), + ["darkseagreen"] = new RenderColor(143, 188, 143), + ["darkslateblue"] = new RenderColor(72, 61, 139), + ["darkslategray"] = new RenderColor(47, 79, 79), + ["darkslategrey"] = new RenderColor(47, 79, 79), + ["darkturquoise"] = new RenderColor(0, 206, 209), + ["darkviolet"] = new RenderColor(148, 0, 211), + ["deeppink"] = new RenderColor(255, 20, 147), + ["deepskyblue"] = new RenderColor(0, 191, 255), + ["dimgray"] = new RenderColor(105, 105, 105), + ["dimgrey"] = new RenderColor(105, 105, 105), + ["dodgerblue"] = new RenderColor(30, 144, 255), + ["firebrick"] = new RenderColor(178, 34, 34), + ["floralwhite"] = new RenderColor(255, 250, 240), + ["forestgreen"] = new RenderColor(34, 139, 34), + ["fuchsia"] = new RenderColor(255, 0, 255), + ["gainsboro"] = new RenderColor(220, 220, 220), + ["ghostwhite"] = new RenderColor(248, 248, 255), + ["gold"] = new RenderColor(255, 215, 0), + ["goldenrod"] = new RenderColor(218, 165, 32), + ["gray"] = new RenderColor(128, 128, 128), + ["green"] = new RenderColor(0, 128, 0), + ["greenyellow"] = new RenderColor(173, 255, 47), + ["grey"] = new RenderColor(128, 128, 128), + ["honeydew"] = new RenderColor(240, 255, 240), + ["hotpink"] = new RenderColor(255, 105, 180), + ["indianred"] = new RenderColor(205, 92, 92), + ["indigo"] = new RenderColor(75, 0, 130), + ["ivory"] = new RenderColor(255, 255, 240), + ["khaki"] = new RenderColor(240, 230, 140), + ["lavender"] = new RenderColor(230, 230, 250), + ["lavenderblush"] = new RenderColor(255, 240, 245), + ["lawngreen"] = new RenderColor(124, 252, 0), + ["lemonchiffon"] = new RenderColor(255, 250, 205), + ["lightblue"] = new RenderColor(173, 216, 230), + ["lightcoral"] = new RenderColor(240, 128, 128), + ["lightcyan"] = new RenderColor(224, 255, 255), + ["lightgoldenrodyellow"] = new RenderColor(250, 250, 210), + ["lightgray"] = new RenderColor(211, 211, 211), + ["lightgreen"] = new RenderColor(144, 238, 144), + ["lightgrey"] = new RenderColor(211, 211, 211), + ["lightpink"] = new RenderColor(255, 182, 193), + ["lightsalmon"] = new RenderColor(255, 160, 122), + ["lightseagreen"] = new RenderColor(32, 178, 170), + ["lightskyblue"] = new RenderColor(135, 206, 250), + ["lightslategray"] = new RenderColor(119, 136, 153), + ["lightslategrey"] = new RenderColor(119, 136, 153), + ["lightsteelblue"] = new RenderColor(176, 196, 222), + ["lightyellow"] = new RenderColor(255, 255, 224), + ["lime"] = new RenderColor(0, 255, 0), + ["limegreen"] = new RenderColor(50, 205, 50), + ["linen"] = new RenderColor(250, 240, 230), + ["magenta"] = new RenderColor(255, 0, 255), + ["maroon"] = new RenderColor(128, 0, 0), + ["mediumaquamarine"] = new RenderColor(102, 205, 170), + ["mediumblue"] = new RenderColor(0, 0, 205), + ["mediumorchid"] = new RenderColor(186, 85, 211), + ["mediumpurple"] = new RenderColor(147, 112, 219), + ["mediumseagreen"] = new RenderColor(60, 179, 113), + ["mediumslateblue"] = new RenderColor(123, 104, 238), + ["mediumspringgreen"] = new RenderColor(0, 250, 154), + ["mediumturquoise"] = new RenderColor(72, 209, 204), + ["mediumvioletred"] = new RenderColor(199, 21, 133), + ["midnightblue"] = new RenderColor(25, 25, 112), + ["mintcream"] = new RenderColor(245, 255, 250), + ["mistyrose"] = new RenderColor(255, 228, 225), + ["moccasin"] = new RenderColor(255, 228, 181), + ["navajowhite"] = new RenderColor(255, 222, 173), + ["navy"] = new RenderColor(0, 0, 128), + ["oldlace"] = new RenderColor(253, 245, 230), + ["olive"] = new RenderColor(128, 128, 0), + ["olivedrab"] = new RenderColor(107, 142, 35), + ["orange"] = new RenderColor(255, 165, 0), + ["orangered"] = new RenderColor(255, 69, 0), + ["orchid"] = new RenderColor(218, 112, 214), + ["palegoldenrod"] = new RenderColor(238, 232, 170), + ["palegreen"] = new RenderColor(152, 251, 152), + ["paleturquoise"] = new RenderColor(175, 238, 238), + ["palevioletred"] = new RenderColor(219, 112, 147), + ["papayawhip"] = new RenderColor(255, 239, 213), + ["peachpuff"] = new RenderColor(255, 218, 185), + ["peru"] = new RenderColor(205, 133, 63), + ["pink"] = new RenderColor(255, 192, 203), + ["plum"] = new RenderColor(221, 160, 221), + ["powderblue"] = new RenderColor(176, 224, 230), + ["purple"] = new RenderColor(128, 0, 128), + ["rebeccapurple"] = new RenderColor(102, 51, 153), + ["red"] = new RenderColor(255, 0, 0), + ["rosybrown"] = new RenderColor(188, 143, 143), + ["royalblue"] = new RenderColor(65, 105, 225), + ["saddlebrown"] = new RenderColor(139, 69, 19), + ["salmon"] = new RenderColor(250, 128, 114), + ["sandybrown"] = new RenderColor(244, 164, 96), + ["seagreen"] = new RenderColor(46, 139, 87), + ["seashell"] = new RenderColor(255, 245, 238), + ["sienna"] = new RenderColor(160, 82, 45), + ["silver"] = new RenderColor(192, 192, 192), + ["skyblue"] = new RenderColor(135, 206, 235), + ["slateblue"] = new RenderColor(106, 90, 205), + ["slategray"] = new RenderColor(112, 128, 144), + ["slategrey"] = new RenderColor(112, 128, 144), + ["snow"] = new RenderColor(255, 250, 250), + ["springgreen"] = new RenderColor(0, 255, 127), + ["steelblue"] = new RenderColor(70, 130, 180), + ["tan"] = new RenderColor(210, 180, 140), + ["teal"] = new RenderColor(0, 128, 128), + ["thistle"] = new RenderColor(216, 191, 216), + ["tomato"] = new RenderColor(255, 99, 71), + ["turquoise"] = new RenderColor(64, 224, 208), + ["violet"] = new RenderColor(238, 130, 238), + ["wheat"] = new RenderColor(245, 222, 179), + ["white"] = new RenderColor(255, 255, 255), + ["whitesmoke"] = new RenderColor(245, 245, 245), + ["yellow"] = new RenderColor(255, 255, 0), + ["yellowgreen"] = new RenderColor(154, 205, 50), + }; + private static bool TryParseColorChannel(string raw, out byte value) { var token = raw.Trim(); @@ -1884,7 +4307,28 @@ private static RenderColor ParseHexColor(string color, RenderColor fallback) return fallback; } - private static IReadOnlyList WrapText(string text, float maxWidth, float fontSize, float averageCharacterWidthFactor, float letterSpacing) + /// + /// The height a cell needs for its own content, independent of the rows it spans. + /// + private static float MeasureCellHeight( + LayoutContext context, + TableCellPlacement placement, + float[] columnWidths) + { + var contentWidth = Math.Max(0f, columnWidths.Skip(placement.ColumnIndex).Take(placement.ColumnSpan).Sum() + - placement.PaddingLeft - placement.PaddingRight - placement.BorderLeftWidth - placement.BorderRightWidth); + var contentHeight = 0f; + + if (contentWidth > 0f && placement.Text.Length > 0) + { + var wrappedLines = WrapText(context, placement.Text, contentWidth, placement.CellTextStyle); + contentHeight = wrappedLines.Count * placement.CellTextStyle.FontSize * placement.CellTextStyle.LineHeightMultiplier; + } + + return Math.Max(20f, contentHeight + placement.PaddingTop + placement.PaddingBottom + placement.BorderTopWidth + placement.BorderBottomWidth); + } + + private static IReadOnlyList WrapText(LayoutContext context, string text, float maxWidth, RenderTextStyle textStyle) { var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); @@ -1899,8 +4343,8 @@ private static IReadOnlyList WrapText(string text, float maxWidth, float foreach (var word in words) { - var wordWidth = EstimateTextWidth(word, fontSize, averageCharacterWidthFactor, letterSpacing); - var separatorWidth = current.Length == 0 ? 0f : EstimateTextWidth(" ", fontSize, averageCharacterWidthFactor, letterSpacing); + var wordWidth = MeasureTextWidth(context, word, textStyle); + var separatorWidth = current.Length == 0 ? 0f : MeasureTextWidth(context, " ", textStyle); if (current.Length > 0 && currentWidth + separatorWidth + wordWidth > maxWidth) { @@ -1927,30 +4371,16 @@ private static IReadOnlyList WrapText(string text, float maxWidth, float return lines; } - private static float EstimateTextWidth(string text, float fontSize, float averageCharacterWidthFactor, float letterSpacing) - { - var width = 0f; - var characterCount = 0; - - foreach (var c in text) - { - characterCount++; - width += c switch - { - 'i' or 'l' or '!' or '|' => fontSize * 0.35f, - 'm' or 'w' or 'M' or 'W' => fontSize * 0.9f, - ' ' => fontSize * 0.33f, - _ => fontSize * averageCharacterWidthFactor, - }; - } + private static RenderFont ToRenderFont(RenderTextStyle textStyle, FontFaceSet fonts) => new( + textStyle.FontFamily, + textStyle.FontSize, + textStyle.FontWeight, + textStyle.IsItalic, + textStyle.LetterSpacing, + fonts); - if (characterCount > 1) - { - width += (characterCount - 1) * letterSpacing; - } - - return width; - } + private static float MeasureTextWidth(LayoutContext context, string text, RenderTextStyle textStyle) => + context.TextMeasurer.MeasureWidth(text, ToRenderFont(textStyle, context.Fonts)); private static float ResolveTextAlignmentOffset(TextAlign align, float availableWidth, float textWidth) { @@ -1997,6 +4427,40 @@ private static string NormalizeWhitespace(string value) return sb.ToString().Trim(); } + /// + /// Where a cell's content sits within the box the cell occupies. + /// + /// + /// baseline is treated as top: aligning the first line boxes of every cell in a + /// row against a shared baseline is not implemented. + /// + private enum CellVerticalAlign + { + Top, + Middle, + Bottom, + } + + private readonly record struct TableCellPlacement( + int RowIndex, + int ColumnIndex, + int ColumnSpan, + int RowSpan, + ElementRenderNode CellNode, + Dictionary CellStyle, + RenderTextStyle CellTextStyle, + string Text, + float PaddingLeft, + float PaddingRight, + float PaddingTop, + float PaddingBottom, + float BorderLeftWidth, + float BorderRightWidth, + float BorderTopWidth, + float BorderBottomWidth, + RenderColor BackgroundColor, + CellVerticalAlign VerticalAlign); + private readonly record struct RenderTextStyle( float FontSize, RenderColor Color, @@ -2035,6 +4499,6 @@ private readonly record struct BoxStyle( EdgeSizes Margin, EdgeSizes Padding, EdgeSizes BorderWidth, - RenderColor BackgroundColor, + RenderPaint BackgroundPaint, RenderColor BorderColor); } diff --git a/src/AngleSharp.Renderer/ICaretPosition.cs b/src/AngleSharp.Renderer/ICaretPosition.cs new file mode 100644 index 0000000..35a92c1 --- /dev/null +++ b/src/AngleSharp.Renderer/ICaretPosition.cs @@ -0,0 +1,30 @@ +namespace AngleSharp.Dom; + +using AngleSharp.Attributes; +using AngleSharp.Dom.Geometry; + +/// +/// Represents a caret position in the document. +/// +[DomName("CaretPosition")] +[DomExposed("Window")] +public interface ICaretPosition +{ + /// + /// Gets the node that contains the caret. + /// + [DomName("offsetNode")] + INode OffsetNode { get; } + + /// + /// Gets the UTF-16 code unit offset within . + /// + [DomName("offset")] + int Offset { get; } + + /// + /// Gets the caret client rectangle. + /// + [DomName("getClientRect")] + IDomRect GetClientRect(); +} diff --git a/src/AngleSharp.Renderer/IDomHarness.cs b/src/AngleSharp.Renderer/IDomHarness.cs new file mode 100644 index 0000000..7b6a415 --- /dev/null +++ b/src/AngleSharp.Renderer/IDomHarness.cs @@ -0,0 +1,61 @@ +using AngleSharp.Css; +using AngleSharp.Dom; +using AngleSharp.Renderer.Rendering; + +namespace AngleSharp.Renderer; + +/// +/// Represents an interactive harness bound to a browsing context. +/// +public interface IDomHarness +{ + /// + /// Raised whenever interaction state changes require repainting. + /// + event EventHandler? PaintInvalidated; + + /// + /// Gets the browsing context associated with this harness. + /// + IBrowsingContext Context { get; } + + /// + /// Gets the render device associated with this harness. + /// + IRenderDevice RenderDevice { get; } + + /// + /// Gets the currently hovered element (derived from the mouse cursor position). + /// + IElement? HoveredElement { get; } + + /// + /// Gets or sets the current mouse cursor position in viewport coordinates. + /// + (double X, double Y) MousePosition { get; set; } + + /// + /// Gets the horizontal scroll offset for the given element. + /// + double GetScrollLeft(IElement element, double maxLeft); + + /// + /// Sets the horizontal scroll offset for the given element. + /// + void SetScrollLeft(IElement element, double value, double maxLeft); + + /// + /// Gets the vertical scroll offset for the given element. + /// + double GetScrollTop(IElement element, double maxTop); + + /// + /// Sets the vertical scroll offset for the given element. + /// + void SetScrollTop(IElement element, double value, double maxTop); + + /// + /// Renders the active document to PNG using the harness-bound render device. + /// + RenderedImage PaintToPng(); +} diff --git a/src/AngleSharp.Renderer/InteractiveHtmlRendererState.cs b/src/AngleSharp.Renderer/InteractiveHtmlRendererState.cs new file mode 100644 index 0000000..ac846bd --- /dev/null +++ b/src/AngleSharp.Renderer/InteractiveHtmlRendererState.cs @@ -0,0 +1,214 @@ +using AngleSharp.Css; +using AngleSharp.Dom; +using AngleSharp.Renderer.Rendering; +using System.Linq; +using System.Runtime.CompilerServices; + +namespace AngleSharp.Renderer; + +/// +/// Stores interactive renderer state (scroll positions, hover state, and render device) per browsing context. +/// +internal sealed class InteractiveHtmlRendererState : IDomHarness +{ + private readonly ConditionalWeakTable _elementStates = new(); + private readonly HtmlRenderer _renderer; + private IElement? _hoveredElement; + private (double X, double Y) _mousePosition; + + private sealed class ElementInteractionState + { + public double ScrollLeft { get; set; } + + public double ScrollTop { get; set; } + } + + public InteractiveHtmlRendererState(IBrowsingContext context, IRenderDevice renderDevice) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(renderDevice); + + Context = context; + RenderDevice = renderDevice; + _renderer = new HtmlRenderer(); + } + + public event EventHandler? PaintInvalidated; + + public IBrowsingContext Context { get; } + + /// + /// Gets the render device used for interactive measurements. + /// + public IRenderDevice RenderDevice { get; } + + /// + /// Gets the currently hovered element, if any. + /// + public IElement? HoveredElement => _hoveredElement; + + /// + /// Gets or sets the current mouse cursor position. + /// + public (double X, double Y) MousePosition + { + get => _mousePosition; + set + { + if (Math.Abs(_mousePosition.X - value.X) < double.Epsilon && + Math.Abs(_mousePosition.Y - value.Y) < double.Epsilon) + { + return; + } + + _mousePosition = value; + var hoverChanged = UpdateHoveredElementFromMousePosition(); + + if (!hoverChanged) + { + PaintInvalidated?.Invoke(this, EventArgs.Empty); + } + } + } + + /// + /// Gets the horizontal scroll position for an element, clamped to the supplied maximum. + /// + public double GetScrollLeft(IElement element, double maxLeft) + { + ArgumentNullException.ThrowIfNull(element); + + var state = _elementStates.GetValue(element, static _ => new ElementInteractionState()); + state.ScrollLeft = Clamp(state.ScrollLeft, 0d, maxLeft); + return state.ScrollLeft; + } + + /// + /// Sets the horizontal scroll position for an element with clamping. + /// + public void SetScrollLeft(IElement element, double value, double maxLeft) + { + ArgumentNullException.ThrowIfNull(element); + + var state = _elementStates.GetValue(element, static _ => new ElementInteractionState()); + var next = Clamp(value, 0d, maxLeft); + if (Math.Abs(state.ScrollLeft - next) < double.Epsilon) + { + return; + } + + state.ScrollLeft = next; + PaintInvalidated?.Invoke(this, EventArgs.Empty); + } + + /// + /// Gets the vertical scroll position for an element, clamped to the supplied maximum. + /// + public double GetScrollTop(IElement element, double maxTop) + { + ArgumentNullException.ThrowIfNull(element); + + var state = _elementStates.GetValue(element, static _ => new ElementInteractionState()); + state.ScrollTop = Clamp(state.ScrollTop, 0d, maxTop); + return state.ScrollTop; + } + + /// + /// Sets the vertical scroll position for an element with clamping. + /// + public void SetScrollTop(IElement element, double value, double maxTop) + { + ArgumentNullException.ThrowIfNull(element); + + var state = _elementStates.GetValue(element, static _ => new ElementInteractionState()); + var next = Clamp(value, 0d, maxTop); + if (Math.Abs(state.ScrollTop - next) < double.Epsilon) + { + return; + } + + state.ScrollTop = next; + PaintInvalidated?.Invoke(this, EventArgs.Empty); + } + + private bool UpdateHoveredElementFromMousePosition() + { + var targetDocument = Context.Active; + IElement? nextHovered = null; + + if (targetDocument is not null) + { + var metrics = HtmlRenderer.CaptureLayoutMetrics(targetDocument, RenderDevice); + nextHovered = FindTopMostElementAt(metrics, _mousePosition.X, _mousePosition.Y); + } + + if (ReferenceEquals(_hoveredElement, nextHovered)) + { + return false; + } + + _hoveredElement = nextHovered; + PaintInvalidated?.Invoke(this, EventArgs.Empty); + return true; + } + + private static IElement? FindTopMostElementAt(IReadOnlyDictionary metrics, double x, double y) + { + return metrics + .Where(pair => Contains(pair.Value, x, y)) + .OrderByDescending(pair => GetDepth(pair.Key)) + .ThenBy(pair => Math.Max(0f, pair.Value.BorderBoxWidth) * Math.Max(0f, pair.Value.BorderBoxHeight)) + .Select(pair => pair.Key) + .FirstOrDefault(); + } + + private static bool Contains(HtmlRenderer.ElementLayoutMetrics metrics, double x, double y) + { + var left = metrics.BorderBoxX; + var top = metrics.BorderBoxY; + var right = metrics.BorderBoxX + metrics.BorderBoxWidth; + var bottom = metrics.BorderBoxY + metrics.BorderBoxHeight; + + return x >= left && x <= right && y >= top && y <= bottom; + } + + private static int GetDepth(IElement element) + { + var depth = 0; + var current = element.ParentElement; + + while (current is not null) + { + depth++; + current = current.ParentElement; + } + + return depth; + } + + public RenderedImage PaintToPng() + { + var targetDocument = Context.Active; + if (targetDocument is null) + { + throw new InvalidOperationException("No active document is available for painting."); + } + + return _renderer.RenderToPng(targetDocument, RenderDevice); + } + + private static double Clamp(double value, double min, double max) + { + if (value < min) + { + return min; + } + + if (value > max) + { + return max; + } + + return value; + } +} diff --git a/src/AngleSharp.Renderer/InteractiveHtmlRendererStateExtensions.cs b/src/AngleSharp.Renderer/InteractiveHtmlRendererStateExtensions.cs new file mode 100644 index 0000000..89e2b8c --- /dev/null +++ b/src/AngleSharp.Renderer/InteractiveHtmlRendererStateExtensions.cs @@ -0,0 +1,34 @@ +namespace AngleSharp; + +using AngleSharp.Css; +using AngleSharp.Renderer; +using System.Linq; +using System.Runtime.CompilerServices; + +/// +/// Extension methods for retrieving interactive renderer state from a browsing context. +/// +public static class InteractiveHtmlRendererStateExtensions +{ + private static readonly ConditionalWeakTable s_harnesses = new(); + + /// + /// Gets the interactive DOM harness for the browsing context. + /// + public static IDomHarness GetDomHarness(this IBrowsingContext context) + { + ArgumentNullException.ThrowIfNull(context); + + return s_harnesses.GetValue(context, static browsingContext => + { + var renderDevice = browsingContext.GetServices().FirstOrDefault(); + + if (renderDevice is null) + { + throw new InvalidOperationException("No IRenderDevice service is registered in the browsing context. Register a render device service in IConfiguration before creating the context."); + } + + return new InteractiveHtmlRendererState(browsingContext, renderDevice); + }); + } +} diff --git a/src/AngleSharp.Renderer/Rendering/DisplayList.cs b/src/AngleSharp.Renderer/Rendering/DisplayList.cs index 789d008..d81d059 100644 --- a/src/AngleSharp.Renderer/Rendering/DisplayList.cs +++ b/src/AngleSharp.Renderer/Rendering/DisplayList.cs @@ -1,8 +1,7 @@ -using System.Collections.Generic; -using System.Collections.ObjectModel; - namespace AngleSharp.Renderer.Rendering; +using System.Collections.ObjectModel; + /// /// Represents an ordered sequence of draw commands. /// @@ -15,6 +14,11 @@ public sealed class DisplayList /// public ReadOnlyCollection Commands => _commands.AsReadOnly(); + /// + /// Gets or sets the @font-face declarations the text commands resolve against. + /// + public FontFaceSet Fonts { get; set; } = FontFaceSet.Empty; + /// /// Adds a command to the list. /// @@ -28,7 +32,25 @@ public void Add(RenderCommand command) /// /// Adds a filled rectangle command. /// - public void FillRect(RenderRect rect, RenderColor color) => Add(new FillRectCommand(rect, color)); + public void FillRect(RenderRect rect, RenderColor color) => Add(new FillRectCommand(rect, new RenderColorPaint(color))); + + /// + /// Adds a filled rectangle command using a custom paint. + /// + public void FillRect(RenderRect rect, RenderPaint paint) + { + ArgumentNullException.ThrowIfNull(paint); + Add(new FillRectCommand(rect, paint)); + } + + /// + /// Adds an image draw command. + /// + public void DrawImage(RenderRect rect, RenderedImage image) + { + ArgumentNullException.ThrowIfNull(image); + Add(new DrawImageCommand(rect, image)); + } /// /// Adds a text draw command. @@ -63,7 +85,13 @@ public abstract record RenderCommand; /// /// Draws a filled rectangle. /// -public sealed record FillRectCommand(RenderRect Rect, RenderColor Color) : RenderCommand; +public sealed record FillRectCommand(RenderRect Rect, RenderPaint Paint) : RenderCommand +{ + /// + /// Gets the solid color for this command when it uses a simple color paint. + /// + public RenderColor Color => Paint is RenderColorPaint colorPaint ? colorPaint.Color : RenderColor.Transparent; +} /// /// Draws a single line of text at a baseline position. diff --git a/src/AngleSharp.Renderer/Rendering/DrawImageCommand.cs b/src/AngleSharp.Renderer/Rendering/DrawImageCommand.cs new file mode 100644 index 0000000..ea6fa42 --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/DrawImageCommand.cs @@ -0,0 +1,6 @@ +namespace AngleSharp.Renderer.Rendering; + +/// +/// Draws an image at a given rectangle. +/// +public sealed record DrawImageCommand(RenderRect Rect, RenderedImage Image) : RenderCommand; diff --git a/src/AngleSharp.Renderer/Rendering/FontFace.cs b/src/AngleSharp.Renderer/Rendering/FontFace.cs new file mode 100644 index 0000000..53e24f6 --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/FontFace.cs @@ -0,0 +1,76 @@ +namespace AngleSharp.Renderer.Rendering; + +/// +/// One source listed in the src descriptor of an @font-face rule. +/// +/// The raw font file, when the source was a url(). +/// The installed family, when the source was a local(). +public readonly record struct FontFaceSource(byte[]? Data, string? LocalFamily) +{ + /// + /// Creates a source backed by an embedded font file. + /// + public static FontFaceSource FromData(byte[] data) + { + ArgumentNullException.ThrowIfNull(data); + return new FontFaceSource(data, null); + } + + /// + /// Creates a source that refers to an installed family. + /// + public static FontFaceSource FromLocal(string localFamily) + { + ArgumentNullException.ThrowIfNull(localFamily); + return new FontFaceSource(null, localFamily); + } +} + +/// +/// Represents a single @font-face declaration. +/// +public sealed class FontFace +{ + /// + /// Creates a face. + /// + /// The family name the face is registered under. + /// The numeric weight the face provides. + /// Whether the face is italic or oblique. + /// The sources to try, in declaration order. + public FontFace(string family, float weight, bool isItalic, IEnumerable sources) + { + ArgumentNullException.ThrowIfNull(family); + ArgumentNullException.ThrowIfNull(sources); + + Family = family; + Weight = weight; + IsItalic = isItalic; + Sources = [.. sources]; + } + + /// + /// Gets the family name the face is registered under. + /// + public string Family { get; } + + /// + /// Gets the numeric weight the face provides. + /// + public float Weight { get; } + + /// + /// Gets whether the face is italic or oblique. + /// + public bool IsItalic { get; } + + /// + /// Gets the sources to try, in declaration order. + /// + /// + /// The order matters and resolution is deliberately deferred: whether a local() source + /// is usable depends on the fonts installed on the machine, which only the backend knows. A + /// source that cannot be used falls through to the next one. + /// + public IReadOnlyList Sources { get; } +} diff --git a/src/AngleSharp.Renderer/Rendering/FontFaceSet.cs b/src/AngleSharp.Renderer/Rendering/FontFaceSet.cs new file mode 100644 index 0000000..ead0bd7 --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/FontFaceSet.cs @@ -0,0 +1,72 @@ +namespace AngleSharp.Renderer.Rendering; + +using System.Linq; + +/// +/// The @font-face declarations that apply to a document. +/// +public sealed class FontFaceSet +{ + /// + /// An empty set, used for documents that declare no custom fonts. + /// + public static readonly FontFaceSet Empty = new([]); + + private readonly Dictionary> _facesByFamily; + + /// + /// Creates a set from the given faces. + /// + /// The faces to include. + public FontFaceSet(IEnumerable faces) + { + ArgumentNullException.ThrowIfNull(faces); + + Faces = faces.ToArray(); + _facesByFamily = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var face in Faces) + { + if (!_facesByFamily.TryGetValue(face.Family, out var group)) + { + group = []; + _facesByFamily[face.Family] = group; + } + + group.Add(face); + } + } + + /// + /// Gets the declared faces. + /// + public IReadOnlyList Faces { get; } + + /// + /// Gets whether the set declares no faces at all. + /// + public bool IsEmpty => Faces.Count == 0; + + /// + /// Finds the face that best matches the requested family, weight and style. + /// + /// + /// This is a reduced form of the CSS font matching algorithm: a matching slant wins first, + /// then the nearest weight. Stretch and unicode ranges are not considered. + /// + public bool TryMatch(string family, float weight, bool isItalic, out FontFace face) + { + if (!_facesByFamily.TryGetValue(family, out var candidates)) + { + face = null!; + return false; + } + + face = candidates + .OrderBy(candidate => candidate.IsItalic == isItalic ? 0 : 1) + .ThenBy(candidate => Math.Abs(candidate.Weight - weight)) + .First(); + + return true; + } +} diff --git a/src/AngleSharp.Renderer/Rendering/IRenderBackend.cs b/src/AngleSharp.Renderer/Rendering/IRenderBackend.cs index f973d06..80b3e39 100644 --- a/src/AngleSharp.Renderer/Rendering/IRenderBackend.cs +++ b/src/AngleSharp.Renderer/Rendering/IRenderBackend.cs @@ -12,4 +12,4 @@ public interface IRenderBackend /// The target viewport. /// The resulting image bytes and metadata. RenderedImage RenderToPng(DisplayList displayList, RenderViewport viewport); -} \ No newline at end of file +} diff --git a/src/AngleSharp.Renderer/Rendering/ITextMeasurer.cs b/src/AngleSharp.Renderer/Rendering/ITextMeasurer.cs new file mode 100644 index 0000000..d08c43a --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/ITextMeasurer.cs @@ -0,0 +1,19 @@ +namespace AngleSharp.Renderer.Rendering; + +/// +/// Measures text the way the backend will eventually paint it. +/// +/// +/// Layout and rasterization have to agree on advance widths. If they do not, line breaking, +/// text alignment and table column widths are computed against a font that is never drawn. +/// +public interface ITextMeasurer +{ + /// + /// Measures the advance width of the given text in pixels. + /// + /// The text to measure. + /// The font the text is rendered with. + /// The advance width in pixels. + float MeasureWidth(string text, RenderFont font); +} diff --git a/src/AngleSharp.Renderer/Rendering/RenderFont.cs b/src/AngleSharp.Renderer/Rendering/RenderFont.cs new file mode 100644 index 0000000..45085dd --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/RenderFont.cs @@ -0,0 +1,18 @@ +namespace AngleSharp.Renderer.Rendering; + +/// +/// Describes the font a text run is laid out and painted with. +/// +/// The CSS font-family list, in declaration order. +/// The font size in pixels. +/// The numeric CSS font weight. +/// Whether the run is italic or oblique. +/// The additional spacing between characters in pixels. +/// The @font-face declarations in scope, if any. +public readonly record struct RenderFont( + string FontFamily, + float FontSize, + float FontWeight, + bool IsItalic, + float LetterSpacing, + FontFaceSet? Faces = null); diff --git a/src/AngleSharp.Renderer/Rendering/RenderPaint.cs b/src/AngleSharp.Renderer/Rendering/RenderPaint.cs new file mode 100644 index 0000000..5989cd6 --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/RenderPaint.cs @@ -0,0 +1,115 @@ +namespace AngleSharp.Renderer.Rendering; + +/// +/// Represents a paint that can fill a rectangle. +/// +public abstract record RenderPaint; + +/// +/// Represents a solid-color paint. +/// +public sealed record RenderColorPaint(RenderColor Color) : RenderPaint; + +/// +/// Represents a gradient paint. +/// +public sealed record RenderGradientPaint(RenderGradient Gradient) : RenderPaint; + +/// +/// Describes a gradient definition. +/// +/// The gradient's shape: linear, radial, or conic. +/// The ordered color stops along the gradient. +/// The gradient's angle in degrees, for linear and conic gradients. +/// Fractional center X (0-1) within the painted box, for radial/conic gradients. +/// Fractional center Y (0-1) within the painted box, for radial/conic gradients. +/// Unused; retained for source compatibility. Radial sizing is governed by . +/// Whether a radial gradient uses a circular (vs. the CSS-default elliptical) ending shape. +/// Whether the gradient repeats past its defined extent (`repeating-*-gradient`). +/// How a radial gradient's ending shape is sized when not given explicit radii. +/// An explicit radial gradient radius (or the X radius of an explicit ellipse), in pixels. +/// An explicit radial gradient's Y radius, in pixels, when it differs from . +public sealed record RenderGradient( + RenderGradientKind Kind, + IReadOnlyList Stops, + float AngleDegrees = 90f, + float CenterX = 0.5f, + float CenterY = 0.5f, + float Radius = 0.5f, + bool IsCircle = false, + bool Repeating = false, + RenderGradientSizeKind SizeKind = RenderGradientSizeKind.FarthestCorner, + float? ExplicitRadiusX = null, + float? ExplicitRadiusY = null); + +/// +/// Describes a single gradient stop. +/// +/// +/// The stop's position as a 0-1 fraction. When the stop was given in an absolute length (`10px`) +/// rather than a percentage, this holds the value that was in effect before +/// could be resolved (typically 0) and is not the position +/// actually painted with - the backend resolves against the +/// gradient's own geometry once that is known, since a stop's absolute-length position cannot be +/// turned into a fraction until the gradient's rendered size is. +/// +/// The stop's color. +/// +/// The stop's position as an absolute length in pixels (`red 10px`), if it was given as one +/// rather than a percentage/unitless fraction; otherwise . +/// +public sealed record RenderGradientStop(float Position, RenderColor Color, float? AbsolutePositionPixels = null); + +/// +/// Describes the available gradient kinds. +/// +public enum RenderGradientKind +{ + /// + /// Draws a linear gradient. + /// + Linear, + + /// + /// Draws a radial gradient. + /// + Radial, + + /// + /// Draws a conic gradient. + /// + Conic, +} + +/// +/// Describes how a radial gradient's ending shape is sized, matching the CSS `<extent-keyword>` +/// values (or an explicit radius/radii). +/// +public enum RenderGradientSizeKind +{ + /// + /// The ending shape meets the corner of the box farthest from its center. The CSS default. + /// + FarthestCorner, + + /// + /// The ending shape meets the side of the box closest to its center. + /// + ClosestSide, + + /// + /// The ending shape meets the side of the box farthest from its center. + /// + FarthestSide, + + /// + /// The ending shape meets the corner of the box closest to its center. + /// + ClosestCorner, + + /// + /// The ending shape uses an explicit radius ( / + /// ) rather than one derived from the box. + /// + Explicit, +} diff --git a/src/AngleSharp.Renderer/ScrollBehavior.cs b/src/AngleSharp.Renderer/ScrollBehavior.cs new file mode 100644 index 0000000..d649466 --- /dev/null +++ b/src/AngleSharp.Renderer/ScrollBehavior.cs @@ -0,0 +1,28 @@ +namespace AngleSharp.Dom; + +using AngleSharp.Attributes; + +/// +/// Defines how a scroll operation should be animated. +/// +[DomName("ScrollBehavior")] +public enum ScrollBehavior +{ + /// + /// Uses automatic behavior. + /// + [DomName("auto")] + Auto, + + /// + /// Scrolls instantly. + /// + [DomName("instant")] + Instant, + + /// + /// Scrolls smoothly. + /// + [DomName("smooth")] + Smooth, +} diff --git a/src/AngleSharp.Renderer/ScrollIntoViewOptions.cs b/src/AngleSharp.Renderer/ScrollIntoViewOptions.cs new file mode 100644 index 0000000..bff5e42 --- /dev/null +++ b/src/AngleSharp.Renderer/ScrollIntoViewOptions.cs @@ -0,0 +1,29 @@ +namespace AngleSharp.Dom; + +using AngleSharp.Attributes; + +/// +/// Options for scrolling an element into view. +/// +[DomName("ScrollIntoViewOptions")] +[DomExposed("Window")] +public sealed class ScrollIntoViewOptions +{ + /// + /// Gets or sets the scroll behavior. + /// + [DomName("behavior")] + public ScrollBehavior Behavior { get; set; } = ScrollBehavior.Auto; + + /// + /// Gets or sets vertical alignment mode. + /// + [DomName("block")] + public ScrollLogicalPosition Block { get; set; } = ScrollLogicalPosition.Start; + + /// + /// Gets or sets horizontal alignment mode. + /// + [DomName("inline")] + public ScrollLogicalPosition Inline { get; set; } = ScrollLogicalPosition.Nearest; +} diff --git a/src/AngleSharp.Renderer/ScrollLogicalPosition.cs b/src/AngleSharp.Renderer/ScrollLogicalPosition.cs new file mode 100644 index 0000000..60743c7 --- /dev/null +++ b/src/AngleSharp.Renderer/ScrollLogicalPosition.cs @@ -0,0 +1,34 @@ +namespace AngleSharp.Dom; + +using AngleSharp.Attributes; + +/// +/// Defines logical alignment positions for scrolling. +/// +[DomName("ScrollLogicalPosition")] +public enum ScrollLogicalPosition +{ + /// + /// Aligns the start edge. + /// + [DomName("start")] + Start, + + /// + /// Centers the target. + /// + [DomName("center")] + Center, + + /// + /// Aligns the end edge. + /// + [DomName("end")] + End, + + /// + /// Uses the nearest edge that requires minimal movement. + /// + [DomName("nearest")] + Nearest, +} diff --git a/src/AngleSharp.Renderer/ScrollToOptions.cs b/src/AngleSharp.Renderer/ScrollToOptions.cs new file mode 100644 index 0000000..588cde4 --- /dev/null +++ b/src/AngleSharp.Renderer/ScrollToOptions.cs @@ -0,0 +1,29 @@ +namespace AngleSharp.Dom; + +using AngleSharp.Attributes; + +/// +/// Options for absolute element scrolling. +/// +[DomName("ScrollToOptions")] +[DomExposed("Window")] +public sealed class ScrollToOptions +{ + /// + /// Gets or sets the horizontal destination. + /// + [DomName("left")] + public double? Left { get; set; } + + /// + /// Gets or sets the vertical destination. + /// + [DomName("top")] + public double? Top { get; set; } + + /// + /// Gets or sets the scroll behavior. + /// + [DomName("behavior")] + public ScrollBehavior Behavior { get; set; } = ScrollBehavior.Auto; +} diff --git a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs index 3f0bcc0..1ec32d4 100644 --- a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs +++ b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs @@ -1,6 +1,5 @@ using AngleSharp.Renderer.Rendering; -using System.Reflection; -using System.Threading; +using System.Linq; using SkiaSharp; @@ -9,25 +8,16 @@ namespace AngleSharp.Renderer.Skia; /// /// Uses SkiaSharp to render a display list. /// -public sealed class SkiaRenderBackend : IRenderBackend +/// +/// The backend also measures text, so a renderer using it lays out against the same advance +/// widths it paints with. +/// +public sealed class SkiaRenderBackend : IRenderBackend, ITextMeasurer { - private const string FontResourcePrefix = "AngleSharp.Renderer.Resources.Fonts."; + private readonly SkiaTextMeasurer _textMeasurer = new(); - private static readonly Lazy> BundledFonts = - new(CreateBundledFonts, LazyThreadSafetyMode.ExecutionAndPublication); - - private static readonly IReadOnlyDictionary GenericFontMappings = - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["serif"] = "serif", - ["sans-serif"] = "sans-serif", - ["monospace"] = "monospace", - ["cursive"] = "sans-serif", - ["fantasy"] = "serif", - ["dejavu serif"] = "serif", - ["dejavu sans"] = "sans-serif", - ["dejavu sans mono"] = "monospace", - }; + /// + public float MeasureWidth(string text, RenderFont font) => _textMeasurer.MeasureWidth(text, font); /// public RenderedImage RenderToPng(DisplayList displayList, RenderViewport viewport) @@ -48,7 +38,7 @@ public RenderedImage RenderToPng(DisplayList displayList, RenderViewport viewpor foreach (var command in displayList.Commands) { - DrawCommand(canvas, command); + DrawCommand(canvas, command, displayList.Fonts); } using var image = surface.Snapshot(); @@ -58,15 +48,18 @@ public RenderedImage RenderToPng(DisplayList displayList, RenderViewport viewpor return new RenderedImage(data.ToArray(), viewport.Width, viewport.Height, "image/png"); } - private static void DrawCommand(SKCanvas canvas, RenderCommand command) + private static void DrawCommand(SKCanvas canvas, RenderCommand command, FontFaceSet fonts) { switch (command) { case FillRectCommand fill: DrawFillRect(canvas, fill); break; + case DrawImageCommand image: + DrawImage(canvas, image); + break; case DrawTextCommand text: - DrawText(canvas, text); + DrawText(canvas, text, fonts); break; } } @@ -78,12 +71,7 @@ private static void DrawFillRect(SKCanvas canvas, FillRectCommand command) return; } - using var paint = new SKPaint - { - Color = ToSkColor(command.Color), - IsAntialias = true, - Style = SKPaintStyle.Fill, - }; + using var paint = CreateFillPaint(command.Paint, command.Rect); var rect = new SKRect( command.Rect.X, @@ -94,24 +82,234 @@ private static void DrawFillRect(SKCanvas canvas, FillRectCommand command) canvas.DrawRect(rect, paint); } - private static void DrawText(SKCanvas canvas, DrawTextCommand command) + private static SKPaint CreateFillPaint(RenderPaint paint, RenderRect rect) { - var fontStyle = new SKFontStyle( - command.FontWeight >= 600f ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal, - SKFontStyleWidth.Normal, - command.IsItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright); + return paint switch + { + RenderColorPaint colorPaint => new SKPaint + { + Color = ToSkColor(colorPaint.Color), + IsAntialias = true, + Style = SKPaintStyle.Fill, + }, + RenderGradientPaint gradientPaint => CreateGradientPaint(gradientPaint.Gradient, rect), + _ => throw new NotSupportedException($"Unsupported paint type: {paint.GetType().Name}"), + }; + } - using var paint = new SKPaint + private static SKPaint CreateGradientPaint(RenderGradient gradient, RenderRect rect) + { + var paint = new SKPaint { - Color = ToSkColor(command.Color), IsAntialias = true, - SubpixelText = false, - LcdRenderText = false, - HintingLevel = SKPaintHinting.Normal, - TextSize = command.FontSize, - Typeface = CreateTypeface(command.FontFamily, fontStyle), - TextSkewX = command.IsItalic ? -0.25f : 0f, + Style = SKPaintStyle.Fill, + }; + + var colors = gradient.Stops.Select(stop => new SKColor(stop.Color.R, stop.Color.G, stop.Color.B, stop.Color.A)).ToArray(); + + var centerX = rect.X + (gradient.CenterX * rect.Width); + var centerY = rect.Y + (gradient.CenterY * rect.Height); + var diagonal = (float)Math.Sqrt((rect.Width * rect.Width) + (rect.Height * rect.Height)); + var (unscaledRx, unscaledRy) = gradient.Kind == RenderGradientKind.Radial + ? ComputeRadialRadii(gradient, rect, centerX, centerY) + : (0f, 0f); + + // An absolute-length stop position ("red 10px") only becomes a fraction once the + // gradient's own rendered geometry is known - the gradient line's length for linear, the + // resolved (pre-repeat-scale) radius for radial. Conic stops use angles, never lengths. + var referenceLength = gradient.Kind switch + { + RenderGradientKind.Linear => diagonal, + RenderGradientKind.Radial => Math.Max(unscaledRx, unscaledRy), + _ => 0f, + }; + + var rawPositions = gradient.Stops + .Select(stop => stop.AbsolutePositionPixels is { } pixels && referenceLength > 0f + ? Math.Clamp(pixels / referenceLength, 0f, 1f) + : stop.Position) + .ToArray(); + + var tileMode = gradient.Repeating ? SKShaderTileMode.Repeat : SKShaderTileMode.Clamp; + + // A repeating-*-gradient's defined stops are one period of an infinitely repeated + // pattern. Rescaling every position by the last stop's position makes that period fill + // the shader's whole [0,1] domain; shrinking the shader's own geometric extent by the + // same factor (see each Create*Shader below) then gives Repeat room to tile the rest. + // This assumes the first stop is at/near 0%, the overwhelmingly common case - a repeating + // gradient whose first stop is well past 0% renders its period at the right cadence but + // not anchored at the exact original offset. + var repeatScale = 1f; + var positions = rawPositions; + + if (gradient.Repeating && rawPositions.Length > 0) + { + var maxPosition = rawPositions.Max(); + + if (maxPosition > 0f && maxPosition < 1f) + { + repeatScale = maxPosition; + positions = rawPositions.Select(position => position / maxPosition).ToArray(); + } + } + + paint.Shader = gradient.Kind switch + { + RenderGradientKind.Linear => CreateLinearGradientShader(gradient, centerX, centerY, diagonal, colors, positions, tileMode, repeatScale), + RenderGradientKind.Radial => CreateRadialGradientShader(centerX, centerY, unscaledRx, unscaledRy, colors, positions, tileMode, repeatScale), + RenderGradientKind.Conic => CreateConicGradientShader(gradient, centerX, centerY, colors, positions, tileMode, repeatScale), + _ => throw new NotSupportedException($"Unsupported gradient kind: {gradient.Kind}"), + }; + + return paint; + } + + private static SKShader CreateLinearGradientShader(RenderGradient gradient, float centerX, float centerY, float diagonal, SKColor[] colors, float[] positions, SKShaderTileMode tileMode, float repeatScale) + { + var halfDiagonal = diagonal / 2f; + var radians = (gradient.AngleDegrees % 360f + 360f) % 360f; + var angle = radians * (Math.PI / 180d); + var dx = (float)Math.Cos(angle); + var dy = (float)Math.Sin(angle); + + var start = new SKPoint(centerX - (dx * halfDiagonal), centerY - (dy * halfDiagonal)); + var fullEnd = new SKPoint(centerX + (dx * halfDiagonal), centerY + (dy * halfDiagonal)); + var end = new SKPoint(start.X + ((fullEnd.X - start.X) * repeatScale), start.Y + ((fullEnd.Y - start.Y) * repeatScale)); + + return SKShader.CreateLinearGradient(start, end, colors, positions, tileMode); + } + + private static SKShader CreateRadialGradientShader(float centerX, float centerY, float unscaledRx, float unscaledRy, SKColor[] colors, float[] positions, SKShaderTileMode tileMode, float repeatScale) + { + var rx = Math.Max(0.0001f, unscaledRx * repeatScale); + var ry = Math.Max(0.0001f, unscaledRy * repeatScale); + + if (Math.Abs(rx - ry) < 0.01f) + { + // A circle - no elliptical distortion needed, so the plain center+radius overload + // (unambiguous, no matrix semantics to get backwards) is enough. + return SKShader.CreateRadialGradient(new SKPoint(centerX, centerY), rx, colors, positions, tileMode); + } + + // An ellipse: build the gradient as a unit circle at the origin, then use the + // constructor-time local-matrix overload to map it onto an ellipse of the right size at + // the right position. Verified empirically (not just by API docs) that this matrix maps + // the shader's local space directly into world space - i.e. this scales/positions the + // visible gradient exactly as constructed, not its inverse. + var matrix = new SKMatrix(rx, 0f, centerX, 0f, ry, centerY, 0f, 0f, 1f); + return SKShader.CreateRadialGradient(new SKPoint(0f, 0f), 1f, colors, positions, tileMode, matrix); + } + + /// + /// Resolves a radial gradient's ending-shape radii from its , + /// matching the CSS `<size>` keyword definitions (farthest-corner is the CSS default). + /// + private static (float Rx, float Ry) ComputeRadialRadii(RenderGradient gradient, RenderRect rect, float centerX, float centerY) + { + if (gradient.SizeKind == RenderGradientSizeKind.Explicit && gradient.ExplicitRadiusX is { } explicitX) + { + var explicitY = gradient.ExplicitRadiusY ?? explicitX; + return gradient.IsCircle ? (explicitX, explicitX) : (explicitX, explicitY); + } + + var nearestX = Math.Min(Math.Abs(centerX - rect.X), Math.Abs((rect.X + rect.Width) - centerX)); + var farthestX = Math.Max(Math.Abs(centerX - rect.X), Math.Abs((rect.X + rect.Width) - centerX)); + var nearestY = Math.Min(Math.Abs(centerY - rect.Y), Math.Abs((rect.Y + rect.Height) - centerY)); + var farthestY = Math.Max(Math.Abs(centerY - rect.Y), Math.Abs((rect.Y + rect.Height) - centerY)); + + if (gradient.IsCircle) + { + var radius = gradient.SizeKind switch + { + RenderGradientSizeKind.ClosestSide => Math.Min(nearestX, nearestY), + RenderGradientSizeKind.FarthestSide => Math.Max(farthestX, farthestY), + RenderGradientSizeKind.ClosestCorner => (float)Math.Sqrt((nearestX * nearestX) + (nearestY * nearestY)), + _ => (float)Math.Sqrt((farthestX * farthestX) + (farthestY * farthestY)), + }; + + return (radius, radius); + } + + return gradient.SizeKind switch + { + RenderGradientSizeKind.ClosestSide => (nearestX, nearestY), + RenderGradientSizeKind.FarthestSide => (farthestX, farthestY), + RenderGradientSizeKind.ClosestCorner => EllipseThroughCorner(nearestX, nearestY, rect.Width, rect.Height), + _ => EllipseThroughCorner(farthestX, farthestY, rect.Width, rect.Height), }; + } + + /// + /// The semi-axes of an ellipse that shares the box's aspect ratio and passes through a corner + /// offset by (, ) from its center - the CSS + /// closest-corner/farthest-corner ellipse sizing algorithm. + /// + private static (float Rx, float Ry) EllipseThroughCorner(float dx, float dy, float boxWidth, float boxHeight) + { + if (boxWidth <= 0f || boxHeight <= 0f) + { + return (Math.Abs(dx), Math.Abs(dy)); + } + + var aspect = boxWidth / boxHeight; + var ry = (float)Math.Sqrt(((double)dx * dx / (aspect * aspect)) + ((double)dy * dy)); + var rx = aspect * ry; + + return (rx, ry); + } + + private static SKShader CreateConicGradientShader(RenderGradient gradient, float centerX, float centerY, SKColor[] colors, float[] positions, SKShaderTileMode tileMode, float repeatScale) + { + // Skia's sweep gradient silently degenerates to a zero-width (solid first-color) span + // once endAngle exceeds 360 - verified empirically, it does not treat e.g. [270,630] as + // "a full revolution starting at 270". So the shader's own angle span always stays a + // plain [0, 360*repeatScale], and CSS's "from " rotation - plus the fact that CSS + // conic-gradient's 0deg points up while Skia's sweep 0deg points right, both clockwise + // (also verified empirically) - is applied via the constructor-time rotation matrix + // instead, the same "shader-local-space maps directly into world-space" mechanism already + // used for elliptical radial gradients. + var rotation = SKMatrix.CreateRotationDegrees(gradient.AngleDegrees - 90f, centerX, centerY); + var endAngle = 360f * repeatScale; + + return SKShader.CreateSweepGradient(new SKPoint(centerX, centerY), colors, positions, tileMode, 0f, endAngle, rotation); + } + + private static void DrawImage(SKCanvas canvas, DrawImageCommand command) + { + if (command.Image.Data.Length == 0 || command.Rect.IsEmpty) + { + return; + } + + using var data = SKData.CreateCopy(command.Image.Data); + using var image = SKImage.FromEncodedData(data); + if (image is null) + { + return; + } + + var rect = new SKRect( + command.Rect.X, + command.Rect.Y, + command.Rect.X + command.Rect.Width, + command.Rect.Y + command.Rect.Height); + + using var paint = new SKPaint { IsAntialias = true, FilterQuality = SKFilterQuality.High }; + canvas.DrawImage(image, rect, paint); + } + + private static void DrawText(SKCanvas canvas, DrawTextCommand command, FontFaceSet fonts) + { + var font = new RenderFont( + command.FontFamily, + command.FontSize, + command.FontWeight, + command.IsItalic, + command.LetterSpacing, + fonts); + + using var paint = SkiaTextShaping.CreateTextPaint(font); + paint.Color = ToSkColor(command.Color); DrawTextWithLetterSpacing(canvas, paint, command.Text, command.X, command.Y, command.LetterSpacing); @@ -125,7 +323,7 @@ private static void DrawText(SKCanvas canvas, DrawTextCommand command) StrokeWidth = Math.Max(1f, command.FontSize / 14f), }; - var textWidth = MeasureTextWidth(paint, command.Text, command.LetterSpacing); + var textWidth = SkiaTextShaping.MeasureTextWidth(paint, command.Text, command.LetterSpacing); if (command.Underline) { @@ -170,74 +368,6 @@ private static void DrawPatternedLine(SKCanvas canvas, SKPaint paint, float x, f } } - private static SKTypeface CreateTypeface(string fontFamily, SKFontStyle fontStyle) - { - var isBold = fontStyle.Weight >= (int)SKFontStyleWeight.Bold; - - var families = fontFamily.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - foreach (var family in families) - { - var normalized = family.Trim('\'', '"', ' '); - - if (string.IsNullOrWhiteSpace(normalized)) - { - continue; - } - - if (GenericFontMappings.TryGetValue(normalized, out var bundledFamilyKey) && - BundledFonts.Value.TryGetValue(bundledFamilyKey, out var bundledFamily)) - { - return isBold ? bundledFamily.Bold : bundledFamily.Regular; - } - - var typeface = SKTypeface.FromFamilyName(normalized, fontStyle); - - if (typeface is not null) - { - return typeface; - } - } - - if (BundledFonts.Value.TryGetValue("sans-serif", out var defaultFamily)) - { - return isBold ? defaultFamily.Bold : defaultFamily.Regular; - } - - return SKTypeface.FromFamilyName(fontFamily, fontStyle) ?? SKTypeface.Default; - } - - private static IReadOnlyDictionary CreateBundledFonts() - { - var sansRegular = LoadBundledTypeface("DejaVuSans.ttf"); - var sansBold = LoadBundledTypeface("DejaVuSans-Bold.ttf"); - var serifRegular = LoadBundledTypeface("DejaVuSerif.ttf"); - var serifBold = LoadBundledTypeface("DejaVuSerif-Bold.ttf"); - var monoRegular = LoadBundledTypeface("DejaVuSansMono.ttf"); - var monoBold = LoadBundledTypeface("DejaVuSansMono-Bold.ttf"); - - return new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["sans-serif"] = new BundledFontFamily(sansRegular, sansBold), - ["serif"] = new BundledFontFamily(serifRegular, serifBold), - ["monospace"] = new BundledFontFamily(monoRegular, monoBold), - }; - } - - private static SKTypeface LoadBundledTypeface(string fileName) - { - var assembly = typeof(SkiaRenderBackend).Assembly; - var resourceName = string.Concat(FontResourcePrefix, fileName); - - using var stream = assembly.GetManifestResourceStream(resourceName) - ?? throw new InvalidOperationException($"Bundled font resource not found: {resourceName}"); - using var data = SKData.Create(stream) - ?? throw new InvalidOperationException($"Unable to read bundled font resource: {resourceName}"); - - return SKTypeface.FromData(data) - ?? throw new InvalidOperationException($"Unable to load bundled font resource: {resourceName}"); - } - private static void DrawTextWithLetterSpacing(SKCanvas canvas, SKPaint paint, string text, float x, float y, float letterSpacing) { if (letterSpacing <= 0f) @@ -256,24 +386,6 @@ private static void DrawTextWithLetterSpacing(SKCanvas canvas, SKPaint paint, st } } - private static float MeasureTextWidth(SKPaint paint, string text, float letterSpacing) - { - if (letterSpacing <= 0f) - { - return paint.MeasureText(text); - } - - var width = 0f; - - foreach (var character in text) - { - width += paint.MeasureText(character.ToString()) + letterSpacing; - } - - return width > 0f ? width - letterSpacing : 0f; - } - private static SKColor ToSkColor(RenderColor color) => new(color.R, color.G, color.B, color.A); - private readonly record struct BundledFontFamily(SKTypeface Regular, SKTypeface Bold); } \ No newline at end of file diff --git a/src/AngleSharp.Renderer/Skia/SkiaTextMeasurer.cs b/src/AngleSharp.Renderer/Skia/SkiaTextMeasurer.cs new file mode 100644 index 0000000..8688368 --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/SkiaTextMeasurer.cs @@ -0,0 +1,55 @@ +namespace AngleSharp.Renderer.Skia; + +using AngleSharp.Renderer.Rendering; + +using SkiaSharp; + +/// +/// Measures text with the same typefaces and paint settings paints with. +/// +public sealed class SkiaTextMeasurer : ITextMeasurer +{ + // Layout measures once per word, so the paints are cached. They are kept per thread because + // SKPaint is not safe to share across threads, and typeface resolution behind them is already + // process wide. + // A document only ever uses a handful of distinct fonts; the cap is there so a pathological + // document cannot grow the cache without bound. + private const int MaxCachedPaints = 256; + + [ThreadStatic] + private static Dictionary? t_paints; + + /// + public float MeasureWidth(string text, RenderFont font) + { + if (string.IsNullOrEmpty(text)) + { + return 0f; + } + + return SkiaTextShaping.MeasureTextWidth(GetPaint(font), text, font.LetterSpacing); + } + + private static SKPaint GetPaint(RenderFont font) + { + var paints = t_paints ??= []; + + if (!paints.TryGetValue(font, out var paint)) + { + if (paints.Count >= MaxCachedPaints) + { + foreach (var cached in paints.Values) + { + cached.Dispose(); + } + + paints.Clear(); + } + + paint = SkiaTextShaping.CreateTextPaint(font); + paints[font] = paint; + } + + return paint; + } +} diff --git a/src/AngleSharp.Renderer/Skia/SkiaTextShaping.cs b/src/AngleSharp.Renderer/Skia/SkiaTextShaping.cs new file mode 100644 index 0000000..e5f368c --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/SkiaTextShaping.cs @@ -0,0 +1,287 @@ +namespace AngleSharp.Renderer.Skia; + +using System.Collections.Concurrent; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; + +using AngleSharp.Renderer.Rendering; + +using SkiaSharp; + +/// +/// Shared text configuration for the Skia backend. +/// +/// +/// Measuring and painting have to resolve the same typeface and apply the same paint settings, +/// otherwise layout is computed against a font that never reaches the canvas. Both paths go +/// through this type so they cannot drift apart. +/// +internal static class SkiaTextShaping +{ + private const string FontResourcePrefix = "AngleSharp.Renderer.Resources.Fonts."; + + private static readonly Lazy> BundledFonts = + new(CreateBundledFonts, LazyThreadSafetyMode.ExecutionAndPublication); + + private static readonly IReadOnlyDictionary GenericFontMappings = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["serif"] = "serif", + ["sans-serif"] = "sans-serif", + ["monospace"] = "monospace", + ["cursive"] = "sans-serif", + ["fantasy"] = "serif", + ["dejavu serif"] = "serif", + ["dejavu sans"] = "sans-serif", + ["dejavu sans mono"] = "monospace", + ["system-ui"] = "sans-serif", + ["ui-sans-serif"] = "sans-serif", + ["ui-serif"] = "serif", + ["ui-monospace"] = "monospace", + }; + + // CSS family names are case insensitive, but Skia's family lookup is not on every platform: + // the Linux font manager matches case sensitively, so "arial" resolves on Windows and fails + // on Linux. Indexing the installed families once gives the same answer everywhere. + private static readonly Lazy> SystemFontFamilies = + new(CreateSystemFontFamilyIndex, LazyThreadSafetyMode.ExecutionAndPublication); + + private static readonly ConcurrentDictionary ResolvedSystemTypefaces = new(); + + // Decoding a font file is expensive and a face outlives a single render, so the typeface is + // kept alongside the face itself rather than rebuilt per paint. + private static readonly ConcurrentDictionary EmbeddedTypefaces = new(ReferenceEqualityComparer.Instance); + + public static SKFontStyle CreateFontStyle(float fontWeight, bool isItalic) => + new(fontWeight >= 600f ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal, + SKFontStyleWidth.Normal, + isItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright); + + /// + /// Creates the paint used for both measuring and drawing a text run. The caller assigns the + /// color; everything that influences advance widths is set here. + /// + public static SKPaint CreateTextPaint(RenderFont font) + { + var typeface = CreateTypeface( + font.FontFamily, + CreateFontStyle(font.FontWeight, font.IsItalic), + font.Faces ?? FontFaceSet.Empty, + font.FontWeight); + + return new SKPaint + { + IsAntialias = true, + SubpixelText = false, + LcdRenderText = false, + HintingLevel = SKPaintHinting.Normal, + TextSize = font.FontSize, + Typeface = typeface, + // Only slant synthetically when the resolved face is upright. A real italic face is + // already slanted, and skewing it again doubles the angle. + TextSkewX = font.IsItalic && typeface.FontSlant == SKFontStyleSlant.Upright ? -0.25f : 0f, + }; + } + + /// + /// Resolves a CSS font-family list to a typeface, honouring the declared fallback order. + /// + public static SKTypeface CreateTypeface(string fontFamily, SKFontStyle fontStyle) => + CreateTypeface(fontFamily, fontStyle, FontFaceSet.Empty, fontStyle.Weight); + + /// + /// Resolves a CSS font-family list to a typeface, honouring the declared fallback order and + /// any @font-face declarations in scope. + /// + public static SKTypeface CreateTypeface(string fontFamily, SKFontStyle fontStyle, FontFaceSet faces, float requestedWeight) + { + var isBold = fontStyle.Weight >= (int)SKFontStyleWeight.Bold; + var isItalic = fontStyle.Slant != SKFontStyleSlant.Upright; + + var families = fontFamily.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var family in families) + { + var normalized = family.Trim('\'', '"', ' '); + + if (string.IsNullOrWhiteSpace(normalized)) + { + continue; + } + + // Generic names are keywords, so they always mean the bundled fonts and cannot be + // taken over by an @font-face declaration. + if (GenericFontMappings.TryGetValue(normalized, out var bundledFamilyKey) && + BundledFonts.Value.TryGetValue(bundledFamilyKey, out var bundledFamily)) + { + return isBold ? bundledFamily.Bold : bundledFamily.Regular; + } + + // A declared face takes precedence over an installed font of the same name. + if (!faces.IsEmpty && + faces.TryMatch(normalized, requestedWeight, isItalic, out var face) && + TryResolveFaceTypeface(face, fontStyle, out var faceTypeface)) + { + return faceTypeface; + } + + if (TryResolveSystemTypeface(normalized, fontStyle, out var typeface)) + { + return typeface; + } + } + + return GetDefaultTypeface(isBold); + } + + /// + /// Walks the sources of a face in declaration order and returns the first usable one. + /// + private static bool TryResolveFaceTypeface(FontFace face, SKFontStyle fontStyle, out SKTypeface typeface) + { + foreach (var source in face.Sources) + { + if (source.LocalFamily is not null) + { + if (TryResolveSystemTypeface(source.LocalFamily, fontStyle, out typeface)) + { + return true; + } + + continue; + } + + if (source.Data is not null && TryDecodeTypeface(source.Data) is { } decoded) + { + typeface = decoded; + return true; + } + } + + typeface = null!; + return false; + } + + private static SKTypeface? TryDecodeTypeface(byte[] fontData) => + EmbeddedTypefaces.GetOrAdd(fontData, static bytes => + { + using var data = SKData.CreateCopy(bytes); + return SKTypeface.FromData(data); + }); + + /// + /// Resolves an installed family, or reports that it is unavailable so the caller can move on + /// to the next entry of the fallback list. + /// + /// + /// cannot be used for this: it + /// substitutes the platform default for an unknown family instead of returning + /// , which silently swallows the rest of the fallback list and makes the + /// result depend on whichever font the host happens to default to. + /// + private static bool TryResolveSystemTypeface(string family, SKFontStyle fontStyle, out SKTypeface typeface) + { + if (!SystemFontFamilies.Value.TryGetValue(family, out var canonicalFamily)) + { + typeface = null!; + return false; + } + + var key = new SystemTypefaceKey(canonicalFamily, fontStyle.Weight, fontStyle.Width, fontStyle.Slant); + var resolved = ResolvedSystemTypefaces.GetOrAdd(key, static k => + { + var match = SKFontManager.Default.MatchFamily(k.Family, new SKFontStyle(k.Weight, k.Width, k.Slant)); + + // Some platforms substitute rather than return null, so confirm what came back really + // is the requested family before accepting it. + return match is not null && string.Equals(match.FamilyName, k.Family, StringComparison.OrdinalIgnoreCase) + ? match + : null; + }); + + typeface = resolved!; + return resolved is not null; + } + + private static SKTypeface GetDefaultTypeface(bool isBold) + { + if (BundledFonts.Value.TryGetValue("sans-serif", out var defaultFamily)) + { + return isBold ? defaultFamily.Bold : defaultFamily.Regular; + } + + return SKTypeface.Default; + } + + private static IReadOnlyDictionary CreateSystemFontFamilyIndex() + { + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var family in SKFontManager.Default.FontFamilies) + { + if (!string.IsNullOrWhiteSpace(family)) + { + index[family] = family; + } + } + + return index; + } + + /// + /// Measures the advance width, mirroring how the backend lays glyphs out when letter + /// spacing is in play. + /// + public static float MeasureTextWidth(SKPaint paint, string text, float letterSpacing) + { + if (letterSpacing <= 0f) + { + return paint.MeasureText(text); + } + + var width = 0f; + + foreach (var character in text) + { + width += paint.MeasureText(character.ToString()) + letterSpacing; + } + + return width > 0f ? width - letterSpacing : 0f; + } + + private static IReadOnlyDictionary CreateBundledFonts() + { + var sansRegular = LoadBundledTypeface("DejaVuSans.ttf"); + var sansBold = LoadBundledTypeface("DejaVuSans-Bold.ttf"); + var serifRegular = LoadBundledTypeface("DejaVuSerif.ttf"); + var serifBold = LoadBundledTypeface("DejaVuSerif-Bold.ttf"); + var monoRegular = LoadBundledTypeface("DejaVuSansMono.ttf"); + var monoBold = LoadBundledTypeface("DejaVuSansMono-Bold.ttf"); + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["sans-serif"] = new BundledFontFamily(sansRegular, sansBold), + ["serif"] = new BundledFontFamily(serifRegular, serifBold), + ["monospace"] = new BundledFontFamily(monoRegular, monoBold), + }; + } + + private static SKTypeface LoadBundledTypeface(string fileName) + { + var assembly = typeof(SkiaTextShaping).Assembly; + var resourceName = string.Concat(FontResourcePrefix, fileName); + + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Bundled font resource not found: {resourceName}"); + using var data = SKData.Create(stream) + ?? throw new InvalidOperationException($"Unable to read bundled font resource: {resourceName}"); + + return SKTypeface.FromData(data) + ?? throw new InvalidOperationException($"Unable to load bundled font resource: {resourceName}"); + } + + private readonly record struct BundledFontFamily(SKTypeface Regular, SKTypeface Bold); + + private readonly record struct SystemTypefaceKey(string Family, int Weight, int Width, SKFontStyleSlant Slant); +} diff --git a/src/AngleSharp.Renderer/Skia/Svg/SvgColorParsing.cs b/src/AngleSharp.Renderer/Skia/Svg/SvgColorParsing.cs new file mode 100644 index 0000000..716ae6d --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/Svg/SvgColorParsing.cs @@ -0,0 +1,217 @@ +namespace AngleSharp.Renderer.Skia.Svg; + +using System.Globalization; + +using SkiaSharp; + +/// +/// Parses SVG/CSS paint values (colors, "none", "currentColor" and a common set of named colors) +/// into . Scoped to the presentation-attribute subset SVG rendering needs - +/// not a general CSS color parser. +/// +internal static class SvgColorParsing +{ + /// + /// Attempts to parse a paint value. Returns false for "none" (explicitly unpainted) or + /// an unrecognized value. + /// + public static bool TryParsePaint(string? value, out SKColor color) + { + color = SKColors.Black; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var trimmed = value.Trim(); + + if (string.Equals(trimmed, "none", StringComparison.OrdinalIgnoreCase) || + string.Equals(trimmed, "transparent", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // currentColor would need the CSS `color` property resolved from an ancestor; without a + // wider style cascade to resolve it against, black is a reasonable fallback. + if (string.Equals(trimmed, "currentColor", StringComparison.OrdinalIgnoreCase)) + { + color = SKColors.Black; + return true; + } + + if (trimmed.StartsWith('#')) + { + return TryParseHex(trimmed, out color); + } + + if (trimmed.StartsWith("rgb", StringComparison.OrdinalIgnoreCase)) + { + return TryParseRgbFunction(trimmed, out color); + } + + return TryParseNamedColor(trimmed, out color); + } + + private static bool TryParseHex(string value, out SKColor color) + { + color = SKColors.Black; + var hex = value[1..]; + + static bool TryHexByte(string s, out byte result) => + byte.TryParse(s, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out result); + + static byte Expand(char c) => (byte)(Uri.FromHex(c) * 16 + Uri.FromHex(c)); + + try + { + switch (hex.Length) + { + case 3: + color = new SKColor(Expand(hex[0]), Expand(hex[1]), Expand(hex[2])); + return true; + case 4: + color = new SKColor(Expand(hex[0]), Expand(hex[1]), Expand(hex[2]), Expand(hex[3])); + return true; + case 6 when TryHexByte(hex[..2], out var r) && TryHexByte(hex[2..4], out var g) && TryHexByte(hex[4..6], out var b): + color = new SKColor(r, g, b); + return true; + case 8 when TryHexByte(hex[..2], out var r2) && TryHexByte(hex[2..4], out var g2) && TryHexByte(hex[4..6], out var b2) && TryHexByte(hex[6..8], out var a2): + color = new SKColor(r2, g2, b2, a2); + return true; + default: + return false; + } + } + catch (FormatException) + { + return false; + } + } + + private static bool TryParseRgbFunction(string value, out SKColor color) + { + color = SKColors.Black; + + var openParen = value.IndexOf('('); + var closeParen = value.LastIndexOf(')'); + + if (openParen < 0 || closeParen <= openParen) + { + return false; + } + + var content = value[(openParen + 1)..closeParen]; + var separators = content.Contains(',') ? new[] { ',' } : new[] { ' ' }; + var parts = content.Split(separators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (parts.Length < 3) + { + return false; + } + + if (!TryParseColorChannel(parts[0], out var r) || + !TryParseColorChannel(parts[1], out var g) || + !TryParseColorChannel(parts[2], out var b)) + { + return false; + } + + byte a = 255; + + if (parts.Length >= 4 && float.TryParse(parts[3].TrimEnd('%'), NumberStyles.Float, CultureInfo.InvariantCulture, out var alpha)) + { + a = (byte)Math.Clamp(Math.Round(alpha * 255f), 0, 255); + } + + color = new SKColor(r, g, b, a); + return true; + } + + private static bool TryParseColorChannel(string token, out byte value) + { + value = 0; + + if (token.EndsWith('%')) + { + if (!float.TryParse(token[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent)) + { + return false; + } + + value = (byte)Math.Clamp(Math.Round(percent * 255f / 100f), 0, 255); + return true; + } + + if (!float.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out var raw)) + { + return false; + } + + value = (byte)Math.Clamp(Math.Round(raw), 0, 255); + return true; + } + + private static bool TryParseNamedColor(string name, out SKColor color) + { + var found = name.ToLowerInvariant() switch + { + "black" => SKColors.Black, + "white" => SKColors.White, + "red" => SKColors.Red, + "green" => new SKColor(0, 128, 0), + "blue" => SKColors.Blue, + "yellow" => SKColors.Yellow, + "orange" => new SKColor(255, 165, 0), + "purple" => new SKColor(128, 0, 128), + "gray" or "grey" => new SKColor(128, 128, 128), + "silver" => new SKColor(192, 192, 192), + "cyan" or "aqua" => SKColors.Cyan, + "magenta" or "fuchsia" => SKColors.Magenta, + "lime" => new SKColor(0, 255, 0), + "navy" => new SKColor(0, 0, 128), + "teal" => new SKColor(0, 128, 128), + "olive" => new SKColor(128, 128, 0), + "maroon" => new SKColor(128, 0, 0), + "pink" => new SKColor(255, 192, 203), + "brown" => new SKColor(165, 42, 42), + "gold" => new SKColor(255, 215, 0), + "indigo" => new SKColor(75, 0, 130), + "violet" => new SKColor(238, 130, 238), + "coral" => new SKColor(255, 127, 80), + "salmon" => new SKColor(250, 128, 114), + "khaki" => new SKColor(240, 230, 140), + "crimson" => new SKColor(220, 20, 60), + "turquoise" => new SKColor(64, 224, 208), + "beige" => new SKColor(245, 245, 220), + "ivory" => new SKColor(255, 255, 240), + "lavender" => new SKColor(230, 230, 250), + "chocolate" => new SKColor(210, 105, 30), + "tomato" => new SKColor(255, 99, 71), + "orchid" => new SKColor(218, 112, 214), + "plum" => new SKColor(221, 160, 221), + "skyblue" => new SKColor(135, 206, 235), + "steelblue" => new SKColor(70, 130, 180), + "royalblue" => new SKColor(65, 105, 225), + "forestgreen" => new SKColor(34, 139, 34), + "seagreen" => new SKColor(46, 139, 87), + "darkgreen" => new SKColor(0, 100, 0), + "darkred" => new SKColor(139, 0, 0), + "darkblue" => new SKColor(0, 0, 139), + "lightblue" => new SKColor(173, 216, 230), + "lightgreen" => new SKColor(144, 238, 144), + "lightgray" or "lightgrey" => new SKColor(211, 211, 211), + "darkgray" or "darkgrey" => new SKColor(169, 169, 169), + _ => (SKColor?)null, + }; + + if (found is null) + { + color = SKColors.Black; + return false; + } + + color = found.Value; + return true; + } +} diff --git a/src/AngleSharp.Renderer/Skia/Svg/SvgElementRenderer.cs b/src/AngleSharp.Renderer/Skia/Svg/SvgElementRenderer.cs new file mode 100644 index 0000000..ceda46d --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/Svg/SvgElementRenderer.cs @@ -0,0 +1,520 @@ +namespace AngleSharp.Renderer.Skia.Svg; + +using AngleSharp.Dom; + +using SkiaSharp; + +/// +/// Walks an SVG element tree that AngleSharp has already parsed (either as part of the host HTML +/// document, for inline <svg>, or as a standalone document for an SVG image source) and +/// paints its shapes directly onto an . No SVG markup is re-parsed here - +/// this only reads the attributes AngleSharp already exposes on the DOM. +/// +internal static class SvgElementRenderer +{ + private static readonly HashSet NonRenderingTags = new(StringComparer.OrdinalIgnoreCase) + { + "defs", "title", "desc", "metadata", "style", "symbol", "clipPath", "mask", "linearGradient", "radialGradient", "pattern", "filter", + }; + + public static void Render(SKCanvas canvas, IElement svgRoot, SvgPaintState initialState, SvgRenderContext context, SvgViewport viewport) + { + // The root itself is never visited by RenderElement (that only walks its children), so its + // own presentation attributes/style (e.g. a `color` establishing currentColor for the whole + // document) would otherwise be silently ignored. + var rootState = initialState.Resolve(svgRoot, context, viewport); + + foreach (var child in svgRoot.Children) + { + RenderElement(canvas, child, rootState, context, viewport); + } + } + + private static void RenderElement(SKCanvas canvas, IElement element, SvgPaintState inheritedState, SvgRenderContext context, SvgViewport viewport) + { + var tagName = element.LocalName; + + if (NonRenderingTags.Contains(tagName)) + { + return; + } + + var state = inheritedState.Resolve(element, context, viewport); + var transform = SvgTransformParser.Parse(element.GetAttribute("transform")); + var hasTransform = !transform.IsIdentity; + + var clipPathElement = ResolveReferencedElement(element, "clip-path", "clipPath", context); + var maskElement = ResolveReferencedElement(element, "mask", "mask", context); + var filterElement = ResolveReferencedElement(element, "filter", "filter", context); + var needsScope = hasTransform || clipPathElement is not null || maskElement is not null || filterElement is not null; + + if (needsScope) + { + canvas.Save(); + } + + if (hasTransform) + { + canvas.Concat(ref transform); + } + + if (clipPathElement is not null) + { + using var clipPath = BuildClipPath(clipPathElement, viewport); + canvas.ClipPath(clipPath, SKClipOperation.Intersect, antialias: true); + } + + var filterLayerCount = -1; + + if (filterElement is not null && SvgFilterBuilder.Build(filterElement) is { } imageFilter) + { + using var filterPaint = new SKPaint { ImageFilter = imageFilter }; + filterLayerCount = canvas.SaveLayer(filterPaint); + } + + if (maskElement is not null) + { + RenderMasked(canvas, element, maskElement, tagName, state, context, viewport); + } + else + { + RenderElementContent(canvas, element, tagName, state, context, viewport); + } + + if (filterLayerCount >= 0) + { + canvas.RestoreToCount(filterLayerCount); + } + + if (needsScope) + { + canvas.Restore(); + } + } + + private static void RenderMasked(SKCanvas canvas, IElement element, IElement maskElement, string tagName, SvgPaintState state, SvgRenderContext context, SvgViewport viewport) + { + var contentLayerCount = canvas.SaveLayer(); + + ApplyMaskRegionClip(canvas, maskElement, element, viewport, context); + RenderElementContent(canvas, element, tagName, state, context, viewport); + + using (var maskPaint = new SKPaint { ColorFilter = SKColorFilter.CreateLumaColor(), BlendMode = SKBlendMode.DstIn }) + { + canvas.SaveLayer(maskPaint); + + var contentUnitsIsObjectBoundingBox = string.Equals(maskElement.GetAttribute("maskContentUnits"), "objectBoundingBox", StringComparison.OrdinalIgnoreCase); + var maskViewport = viewport; + + if (contentUnitsIsObjectBoundingBox && SvgGeometry.ComputeBounds(element, viewport, context) is { } bbox) + { + canvas.Translate(bbox.Left, bbox.Top); + canvas.Scale(bbox.Width, bbox.Height); + maskViewport = new SvgViewport(1f, 1f); + } + + foreach (var maskChild in maskElement.Children) + { + RenderElement(canvas, maskChild, SvgPaintState.Initial, context, maskViewport); + } + } + + canvas.RestoreToCount(contentLayerCount); + } + + private static void ApplyMaskRegionClip(SKCanvas canvas, IElement maskElement, IElement maskedElement, SvgViewport viewport, SvgRenderContext context) + { + var isObjectBoundingBox = !string.Equals(maskElement.GetAttribute("maskUnits"), "userSpaceOnUse", StringComparison.OrdinalIgnoreCase); + + if (isObjectBoundingBox) + { + var bounds = SvgGeometry.ComputeBounds(maskedElement, viewport, context); + + if (bounds is not { } bbox) + { + // Can't determine a bounding box (e.g. a group of only text) - don't clip, so the + // mask still applies rather than silently painting nothing. + return; + } + + var x = ParseFraction(maskElement.GetAttribute("x"), -0.1f); + var y = ParseFraction(maskElement.GetAttribute("y"), -0.1f); + var w = ParseFraction(maskElement.GetAttribute("width"), 1.2f); + var h = ParseFraction(maskElement.GetAttribute("height"), 1.2f); + + canvas.ClipRect(new SKRect( + bbox.Left + (x * bbox.Width), + bbox.Top + (y * bbox.Height), + bbox.Left + ((x + w) * bbox.Width), + bbox.Top + ((y + h) * bbox.Height))); + } + else + { + var x = SvgLength.Parse(maskElement.GetAttribute("x"), viewport.Width, -0.1f * viewport.Width); + var y = SvgLength.Parse(maskElement.GetAttribute("y"), viewport.Height, -0.1f * viewport.Height); + var w = SvgLength.Parse(maskElement.GetAttribute("width"), viewport.Width, 1.2f * viewport.Width); + var h = SvgLength.Parse(maskElement.GetAttribute("height"), viewport.Height, 1.2f * viewport.Height); + + canvas.ClipRect(new SKRect(x, y, x + w, y + h)); + } + } + + private static float ParseFraction(string? raw, float defaultValue) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return defaultValue; + } + + var trimmed = raw.Trim(); + + if (trimmed.EndsWith('%') && float.TryParse(trimmed[..^1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var percent)) + { + return percent / 100f; + } + + return float.TryParse(trimmed, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var value) ? value : defaultValue; + } + + private static void RenderElementContent(SKCanvas canvas, IElement element, string tagName, SvgPaintState state, SvgRenderContext context, SvgViewport viewport) + { + switch (tagName.ToLowerInvariant()) + { + case "g": + case "a": + foreach (var child in element.Children) + { + RenderElement(canvas, child, state, context, viewport); + } + + break; + + case "svg": + RenderNestedViewport(canvas, element, element.Children, state, context, viewport, translate: true); + break; + + case "use": + RenderUse(canvas, element, state, context, viewport); + break; + + case "rect": + RenderPath(canvas, SvgShapeBuilder.BuildRect(element, viewport), state, context, viewport); + break; + + case "circle": + RenderPath(canvas, SvgShapeBuilder.BuildCircle(element, viewport), state, context, viewport); + break; + + case "ellipse": + RenderPath(canvas, SvgShapeBuilder.BuildEllipse(element, viewport), state, context, viewport); + break; + + case "line": + RenderPath(canvas, SvgShapeBuilder.BuildLine(element, viewport), state, context, viewport, strokeOnly: true); + break; + + case "polyline": + case "polygon": + case "path": + if (SvgShapeBuilder.Build(element, viewport) is { } shapePath) + { + RenderPath(canvas, shapePath, state, context, viewport); + } + + break; + + case "text": + var x = SvgShapeBuilder.ReadX(element, "x", viewport); + var y = SvgShapeBuilder.ReadY(element, "y", viewport); + RenderTextContent(canvas, element, state, context, viewport, ref x, y); + break; + + default: + // Unsupported element (image, ...): descend in case it groups further shapes, but + // paint nothing for the element itself. + foreach (var child in element.Children) + { + RenderElement(canvas, child, state, context, viewport); + } + + break; + } + } + + /// + /// Establishes a new viewport for a nested <svg> or a <symbol>/<svg> + /// referenced through <use>: translates to `x`/`y` (only meaningful for a direct nested + /// <svg>, not a <use>'s already-applied translate), clips to `width`x`height`, maps + /// the element's own `viewBox` onto that box, and renders its children in the resulting + /// coordinate system. + /// + private static void RenderNestedViewport(SKCanvas canvas, IElement viewportElement, IEnumerable content, SvgPaintState state, SvgRenderContext context, SvgViewport outerViewport, bool translate) + { + var width = SvgShapeBuilder.ReadOptionalX(viewportElement, "width", outerViewport) ?? outerViewport.Width; + var height = SvgShapeBuilder.ReadOptionalY(viewportElement, "height", outerViewport) ?? outerViewport.Height; + + if (width <= 0f || height <= 0f) + { + return; + } + + canvas.Save(); + + if (translate) + { + var x = SvgShapeBuilder.ReadX(viewportElement, "x", outerViewport); + var y = SvgShapeBuilder.ReadY(viewportElement, "y", outerViewport); + canvas.Translate(x, y); + } + + canvas.ClipRect(new SKRect(0f, 0f, width, height)); + + var viewBox = SvgViewBoxMapping.ParseViewBox(viewportElement.GetAttribute("viewBox")); + var (align, meet) = SvgViewBoxMapping.ParsePreserveAspectRatio(viewportElement.GetAttribute("preserveAspectRatio")); + var viewBoxMatrix = SvgViewBoxMapping.ComputeMatrix(viewBox, width, height, align, meet); + canvas.Concat(ref viewBoxMatrix); + + var innerViewport = viewBox is { } box ? new SvgViewport(box.Width, box.Height) : new SvgViewport(width, height); + + foreach (var child in content) + { + RenderElement(canvas, child, state, context, innerViewport); + } + + canvas.Restore(); + } + + private static void RenderUse(SKCanvas canvas, IElement element, SvgPaintState state, SvgRenderContext context, SvgViewport viewport) + { + var href = SvgUrlReference.GetHref(element); + + if (href is not { Length: > 1 } || !href.StartsWith('#') || + !context.ElementsById.TryGetValue(href[1..], out var referenced) || + !context.ActiveUseReferences.Add(referenced)) + { + return; + } + + try + { + var x = SvgShapeBuilder.ReadX(element, "x", viewport); + var y = SvgShapeBuilder.ReadY(element, "y", viewport); + + canvas.Save(); + canvas.Translate(x, y); + + var referencedTag = referenced.LocalName.ToLowerInvariant(); + + if (referencedTag is "symbol" or "svg") + { + // A referencing a / establishes a new viewport sized from the + // 's own width/height (falling back to the referenced element's, then to the + // outer viewport), per the SVG spec - it is not just a translated copy. + var useWidth = SvgShapeBuilder.ReadOptionalX(element, "width", viewport) + ?? SvgShapeBuilder.ReadOptionalX(referenced, "width", viewport) + ?? viewport.Width; + var useHeight = SvgShapeBuilder.ReadOptionalY(element, "height", viewport) + ?? SvgShapeBuilder.ReadOptionalY(referenced, "height", viewport) + ?? viewport.Height; + + if (useWidth > 0f && useHeight > 0f) + { + canvas.Save(); + canvas.ClipRect(new SKRect(0f, 0f, useWidth, useHeight)); + + var viewBox = SvgViewBoxMapping.ParseViewBox(referenced.GetAttribute("viewBox")); + var (align, meet) = SvgViewBoxMapping.ParsePreserveAspectRatio(referenced.GetAttribute("preserveAspectRatio")); + var viewBoxMatrix = SvgViewBoxMapping.ComputeMatrix(viewBox, useWidth, useHeight, align, meet); + canvas.Concat(ref viewBoxMatrix); + + var innerViewport = viewBox is { } box ? new SvgViewport(box.Width, box.Height) : new SvgViewport(useWidth, useHeight); + + foreach (var child in referenced.Children) + { + RenderElement(canvas, child, state, context, innerViewport); + } + + canvas.Restore(); + } + } + else + { + RenderElement(canvas, referenced, state, context, viewport); + } + + canvas.Restore(); + } + finally + { + context.ActiveUseReferences.Remove(referenced); + } + } + + private static IElement? ResolveReferencedElement(IElement element, string property, string expectedTag, SvgRenderContext context) + { + var raw = element.GetAttribute(property); + var style = element.GetAttribute("style"); + + if (!string.IsNullOrWhiteSpace(style)) + { + foreach (var declaration in style.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var colonIndex = declaration.IndexOf(':'); + + if (colonIndex > 0 && declaration[..colonIndex].Trim().Equals(property, StringComparison.OrdinalIgnoreCase)) + { + raw = declaration[(colonIndex + 1)..].Trim(); + } + } + } + + return SvgUrlReference.TryExtract(raw, out var id) && + context.ElementsById.TryGetValue(id, out var referenced) && + string.Equals(referenced.LocalName, expectedTag, StringComparison.OrdinalIgnoreCase) + ? referenced + : null; + } + + private static SKPath BuildClipPath(IElement clipPathElement, SvgViewport viewport) + { + var combined = new SKPath(); + + foreach (var child in clipPathElement.Children) + { + using var shapePath = SvgShapeBuilder.Build(child, viewport); + + if (shapePath is null) + { + continue; + } + + var transform = SvgTransformParser.Parse(child.GetAttribute("transform")); + + using var transformedPath = transform.IsIdentity ? null : new SKPath(); + + if (transformedPath is not null) + { + shapePath.Transform(transform, transformedPath); + combined.AddPath(transformedPath); + } + else + { + combined.AddPath(shapePath); + } + } + + return combined; + } + + private static void RenderPath(SKCanvas canvas, SKPath path, SvgPaintState state, SvgRenderContext context, SvgViewport viewport, bool strokeOnly = false) + { + path.FillType = state.FillRule; + var bounds = path.Bounds; + + if (!strokeOnly) + { + using var fillPaint = state.CreateFillPaint(bounds, context, viewport); + + if (fillPaint is not null) + { + canvas.DrawPath(path, fillPaint); + } + } + + using var strokePaint = state.CreateStrokePaint(bounds, context, viewport); + + if (strokePaint is not null) + { + canvas.DrawPath(path, strokePaint); + } + + path.Dispose(); + } + + private static void RenderTextContent(SKCanvas canvas, IElement element, SvgPaintState inheritedState, SvgRenderContext context, SvgViewport viewport, ref float cursorX, float baselineY) + { + var state = inheritedState.Resolve(element, context, viewport); + + var explicitX = SvgShapeBuilder.ReadOptionalX(element, "x", viewport); + var explicitY = SvgShapeBuilder.ReadOptionalY(element, "y", viewport); + + if (explicitX is { } x) + { + cursorX = x; + } + + var y = explicitY ?? baselineY; + + foreach (var node in element.ChildNodes) + { + if (node is IText textNode) + { + var text = NormalizeWhitespace(textNode.Data); + + if (text.Length > 0) + { + DrawTextRun(canvas, text, state, context, viewport, ref cursorX, y); + } + } + else if (node is IElement childElement && string.Equals(childElement.LocalName, "tspan", StringComparison.OrdinalIgnoreCase)) + { + RenderTextContent(canvas, childElement, state, context, viewport, ref cursorX, y); + } + } + } + + private static void DrawTextRun(SKCanvas canvas, string text, SvgPaintState state, SvgRenderContext context, SvgViewport viewport, ref float cursorX, float y) + { + using var measurePaint = new SKPaint + { + Typeface = SkiaTextShaping.CreateTypeface(state.FontFamily, SkiaTextShaping.CreateFontStyle(state.FontWeight, state.IsItalic)), + TextSize = state.FontSize, + }; + + var width = measurePaint.MeasureText(text); + var ascent = -measurePaint.FontMetrics.Ascent; + var descent = measurePaint.FontMetrics.Descent; + var bounds = new SKRect(cursorX, y - ascent, cursorX + width, y + descent); + + var anchorOffset = state.TextAnchor switch + { + SvgTextAnchor.Middle => -width / 2f, + SvgTextAnchor.End => -width, + _ => 0f, + }; + + var drawX = cursorX + anchorOffset; + + using var fillPaint = state.CreateFillPaint(bounds, context, viewport); + + if (fillPaint is not null) + { + fillPaint.Typeface = measurePaint.Typeface; + fillPaint.TextSize = state.FontSize; + canvas.DrawText(text, drawX, y, fillPaint); + } + + using var strokePaint = state.CreateStrokePaint(bounds, context, viewport); + + if (strokePaint is not null) + { + strokePaint.Typeface = measurePaint.Typeface; + strokePaint.TextSize = state.FontSize; + canvas.DrawText(text, drawX, y, strokePaint); + } + + cursorX += width; + } + + private static string NormalizeWhitespace(string text) + { + var normalized = text.Replace('\t', ' ').Replace('\n', ' ').Replace('\r', ' '); + + while (normalized.Contains(" ", StringComparison.Ordinal)) + { + normalized = normalized.Replace(" ", " ", StringComparison.Ordinal); + } + + return normalized.Trim(); + } +} diff --git a/src/AngleSharp.Renderer/Skia/Svg/SvgFilterBuilder.cs b/src/AngleSharp.Renderer/Skia/Svg/SvgFilterBuilder.cs new file mode 100644 index 0000000..3aef785 --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/Svg/SvgFilterBuilder.cs @@ -0,0 +1,213 @@ +namespace AngleSharp.Renderer.Skia.Svg; + +using System.Globalization; +using System.Linq; + +using AngleSharp.Dom; + +using SkiaSharp; + +/// +/// Builds an from a <filter> element's primitive chain. Supports +/// the common `feGaussianBlur`/`feOffset`/`feMerge`/`feColorMatrix`/`feDropShadow` primitives, +/// each composed via SkiaSharp's own filter graph ( is itself a DAG of +/// inputs, so this only has to translate primitives one at a time, not build a compositor). +/// An unsupported primitive (feFlood, feComposite, feTurbulence, feDisplacementMap, feTile, +/// feImage, feComponentTransfer, feConvolveMatrix, feDiffuseLighting, feSpecularLighting, +/// feMorphology) passes its resolved input through unchanged rather than being dropped, so later +/// primitives in the same chain still have something to work with. +/// +internal static class SvgFilterBuilder +{ + public static SKImageFilter? Build(IElement filterElement) + { + var results = new Dictionary(StringComparer.Ordinal); + SKImageFilter? last = null; + var hasPrimitive = false; + + foreach (var primitive in filterElement.Children) + { + var tag = primitive.LocalName.ToLowerInvariant(); + + if (tag == "femerge") + { + last = BuildMerge(primitive, results, last); + } + else + { + var input = ResolveInput(primitive.GetAttribute("in"), results, last); + + last = tag switch + { + "fegaussianblur" => BuildGaussianBlur(primitive, input), + "feoffset" => BuildOffset(primitive, input), + "fecolormatrix" => BuildColorMatrix(primitive, input), + "fedropshadow" => BuildDropShadow(primitive, input), + _ => input, + }; + } + + hasPrimitive = true; + + var resultName = primitive.GetAttribute("result"); + + if (!string.IsNullOrWhiteSpace(resultName)) + { + results[resultName] = last; + } + } + + return hasPrimitive ? last : null; + } + + private static SKImageFilter? ResolveInput(string? inName, Dictionary results, SKImageFilter? last) + { + if (string.IsNullOrWhiteSpace(inName)) + { + return last; + } + + // SourceAlpha (an alpha-only copy of the element's own content) is approximated as + // SourceGraphic - a documented simplification, not a distinction this builder makes. + if (inName is "SourceGraphic" or "SourceAlpha") + { + return null; + } + + return results.TryGetValue(inName, out var named) ? named : last; + } + + private static SKImageFilter BuildGaussianBlur(IElement primitive, SKImageFilter? input) + { + var (sigmaX, sigmaY) = ParseStdDeviation(primitive.GetAttribute("stdDeviation")); + return SKImageFilter.CreateBlur(sigmaX, sigmaY, input); + } + + private static SKImageFilter BuildOffset(IElement primitive, SKImageFilter? input) + { + var dx = ParseFloat(primitive.GetAttribute("dx"), 0f); + var dy = ParseFloat(primitive.GetAttribute("dy"), 0f); + return SKImageFilter.CreateOffset(dx, dy, input); + } + + private static SKImageFilter BuildMerge(IElement primitive, Dictionary results, SKImageFilter? last) + { + var nodes = primitive.Children + .Where(child => string.Equals(child.LocalName, "feMergeNode", StringComparison.OrdinalIgnoreCase)) + .Select(node => ResolveInput(node.GetAttribute("in"), results, last)) + .ToArray(); + + return nodes.Length > 0 ? SKImageFilter.CreateMerge(nodes) : (last ?? SKImageFilter.CreateOffset(0f, 0f)); + } + + private static SKImageFilter BuildColorMatrix(IElement primitive, SKImageFilter? input) + { + var type = primitive.GetAttribute("type"); + var values = primitive.GetAttribute("values"); + + var matrix = type?.ToLowerInvariant() switch + { + "saturate" => CreateSaturateMatrix(ParseFloat(values, 1f)), + "luminancetoalpha" => LuminanceToAlphaMatrix, + "matrix" when TryParseMatrixValues(values, out var explicitMatrix) => explicitMatrix, + _ => IdentityMatrix, + }; + + using var colorFilter = SKColorFilter.CreateColorMatrix(matrix); + return SKImageFilter.CreateColorFilter(colorFilter, input); + } + + private static SKImageFilter BuildDropShadow(IElement primitive, SKImageFilter? input) + { + var dx = ParseFloat(primitive.GetAttribute("dx"), 2f); + var dy = ParseFloat(primitive.GetAttribute("dy"), 2f); + var (sigmaX, sigmaY) = ParseStdDeviation(primitive.GetAttribute("stdDeviation"), defaultValue: 2f); + + var floodColorRaw = primitive.GetAttribute("flood-color"); + var color = SvgColorParsing.TryParsePaint(string.IsNullOrWhiteSpace(floodColorRaw) ? "black" : floodColorRaw, out var parsed) ? parsed : SKColors.Black; + + if (float.TryParse(primitive.GetAttribute("flood-opacity"), NumberStyles.Float, CultureInfo.InvariantCulture, out var floodOpacity)) + { + color = color.WithAlpha((byte)Math.Round(color.Alpha * Math.Clamp(floodOpacity, 0f, 1f))); + } + + return SKImageFilter.CreateDropShadow(dx, dy, sigmaX, sigmaY, color, input); + } + + private static (float SigmaX, float SigmaY) ParseStdDeviation(string? value, float defaultValue = 2f) + { + if (string.IsNullOrWhiteSpace(value)) + { + return (defaultValue, defaultValue); + } + + var parts = value.Trim().Split([' ', ','], StringSplitOptions.RemoveEmptyEntries); + var x = parts.Length > 0 && float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var px) ? px : defaultValue; + var y = parts.Length > 1 && float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var py) ? py : x; + + return (Math.Max(0f, x), Math.Max(0f, y)); + } + + private static float ParseFloat(string? value, float defaultValue) => + !string.IsNullOrWhiteSpace(value) && float.TryParse(value.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : defaultValue; + + private static bool TryParseMatrixValues(string? value, out float[] matrix) + { + matrix = IdentityMatrix; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var tokens = value.Trim().Split([' ', ',', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + + if (tokens.Length != 20) + { + return false; + } + + var parsed = new float[20]; + + for (var i = 0; i < 20; i++) + { + if (!float.TryParse(tokens[i], NumberStyles.Float, CultureInfo.InvariantCulture, out parsed[i])) + { + return false; + } + } + + matrix = parsed; + return true; + } + + private static float[] CreateSaturateMatrix(float s) + { + // The SVG spec's saturate color matrix (a standard luminance-preserving desaturation). + return + [ + 0.213f + (0.787f * s), 0.715f - (0.715f * s), 0.072f - (0.072f * s), 0f, 0f, + 0.213f - (0.213f * s), 0.715f + (0.285f * s), 0.072f - (0.072f * s), 0f, 0f, + 0.213f - (0.213f * s), 0.715f - (0.715f * s), 0.072f + (0.928f * s), 0f, 0f, + 0f, 0f, 0f, 1f, 0f, + ]; + } + + private static readonly float[] IdentityMatrix = + [ + 1f, 0f, 0f, 0f, 0f, + 0f, 1f, 0f, 0f, 0f, + 0f, 0f, 1f, 0f, 0f, + 0f, 0f, 0f, 1f, 0f, + ]; + + private static readonly float[] LuminanceToAlphaMatrix = + [ + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 0f, 0f, + 0.2125f, 0.7154f, 0.0721f, 0f, 0f, + ]; +} diff --git a/src/AngleSharp.Renderer/Skia/Svg/SvgGeometry.cs b/src/AngleSharp.Renderer/Skia/Svg/SvgGeometry.cs new file mode 100644 index 0000000..713d903 --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/Svg/SvgGeometry.cs @@ -0,0 +1,97 @@ +namespace AngleSharp.Renderer.Skia.Svg; + +using AngleSharp.Dom; + +using SkiaSharp; + +/// +/// Computes the (approximate) geometric bounding box of an element's rendered content, in its own +/// local user space - the "object bounding box" `mask`'s default region and gradient +/// `objectBoundingBox` units are defined against. Descends into `g`/`a`/`svg` and resolved `use` +/// targets; text content is not measured and contributes no bounds, a documented simplification. +/// +internal static class SvgGeometry +{ + public static SKRect? ComputeBounds(IElement element, SvgViewport viewport, SvgRenderContext context) => + ComputeBounds(element, viewport, context, []); + + private static SKRect? ComputeBounds(IElement element, SvgViewport viewport, SvgRenderContext context, HashSet visited) + { + if (!visited.Add(element)) + { + return null; + } + + try + { + var localBounds = ComputeLocalBounds(element, viewport, context, visited); + + if (localBounds is not { } bounds) + { + return null; + } + + var transform = SvgTransformParser.Parse(element.GetAttribute("transform")); + return transform.IsIdentity ? bounds : transform.MapRect(bounds); + } + finally + { + visited.Remove(element); + } + } + + private static SKRect? ComputeLocalBounds(IElement element, SvgViewport viewport, SvgRenderContext context, HashSet visited) + { + var tag = element.LocalName.ToLowerInvariant(); + + switch (tag) + { + case "g": + case "a": + case "svg": + case "symbol": + return UnionChildren(element.Children, viewport, context, visited); + + case "use": + { + var href = SvgUrlReference.GetHref(element); + + if (href is not { Length: > 1 } || !href.StartsWith('#') || !context.ElementsById.TryGetValue(href[1..], out var referenced)) + { + return null; + } + + var x = SvgShapeBuilder.ReadX(element, "x", viewport); + var y = SvgShapeBuilder.ReadY(element, "y", viewport); + var referencedBounds = ComputeBounds(referenced, viewport, context, visited); + + return referencedBounds is { } rb ? SKRect.Create(rb.Left + x, rb.Top + y, rb.Width, rb.Height) : null; + } + + default: + using (var path = SvgShapeBuilder.Build(element, viewport)) + { + return path is { IsEmpty: false } ? path.Bounds : null; + } + } + } + + private static SKRect? UnionChildren(IEnumerable children, SvgViewport viewport, SvgRenderContext context, HashSet visited) + { + SKRect? union = null; + + foreach (var child in children) + { + var childBounds = ComputeBounds(child, viewport, context, visited); + + if (childBounds is not { } bounds) + { + continue; + } + + union = union is { } existing ? SKRect.Union(existing, bounds) : bounds; + } + + return union; + } +} diff --git a/src/AngleSharp.Renderer/Skia/Svg/SvgGradientBuilder.cs b/src/AngleSharp.Renderer/Skia/Svg/SvgGradientBuilder.cs new file mode 100644 index 0000000..1dd2f12 --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/Svg/SvgGradientBuilder.cs @@ -0,0 +1,255 @@ +namespace AngleSharp.Renderer.Skia.Svg; + +using System.Globalization; + +using AngleSharp.Dom; + +using SkiaSharp; + +/// +/// Builds an from a <linearGradient>/<radialGradient> element, +/// resolving `href`/`xlink:href` chains for inherited stops and attributes the way SVG paint +/// servers do. +/// +internal static class SvgGradientBuilder +{ + public static SKShader? Build(IElement gradientElement, SKRect boundingBox, IReadOnlyDictionary elementsById) + { + var stops = ResolveStops(gradientElement, elementsById); + + if (stops.Count == 0) + { + return null; + } + + if (stops.Count == 1) + { + // A single-stop gradient paints as a solid color; SKShader.CreateLinearGradient needs + // at least two color/position pairs to be well-defined. + return SKShader.CreateColor(stops[0].Color); + } + + var colors = new SKColor[stops.Count]; + var positions = new float[stops.Count]; + + for (var i = 0; i < stops.Count; i++) + { + colors[i] = stops[i].Color; + positions[i] = stops[i].Offset; + } + + var isObjectBoundingBox = !string.Equals( + GetInheritedAttribute(gradientElement, "gradientUnits", elementsById), + "userSpaceOnUse", + StringComparison.OrdinalIgnoreCase); + + var tileMode = GetInheritedAttribute(gradientElement, "spreadMethod", elementsById)?.ToLowerInvariant() switch + { + "reflect" => SKShaderTileMode.Mirror, + "repeat" => SKShaderTileMode.Repeat, + _ => SKShaderTileMode.Clamp, + }; + + var gradientTransform = SvgTransformParser.Parse(GetInheritedAttribute(gradientElement, "gradientTransform", elementsById)); + + var isRadial = string.Equals(gradientElement.LocalName, "radialGradient", StringComparison.OrdinalIgnoreCase); + + return isRadial + ? BuildRadial(gradientElement, boundingBox, isObjectBoundingBox, gradientTransform, colors, positions, tileMode, elementsById) + : BuildLinear(gradientElement, boundingBox, isObjectBoundingBox, gradientTransform, colors, positions, tileMode, elementsById); + } + + private static SKShader BuildLinear( + IElement element, + SKRect boundingBox, + bool isObjectBoundingBox, + SKMatrix gradientTransform, + SKColor[] colors, + float[] positions, + SKShaderTileMode tileMode, + IReadOnlyDictionary elementsById) + { + var x1 = ResolveCoordinate(GetInheritedAttribute(element, "x1", elementsById), 0f, isObjectBoundingBox, boundingBox.Left, boundingBox.Width); + var y1 = ResolveCoordinate(GetInheritedAttribute(element, "y1", elementsById), 0f, isObjectBoundingBox, boundingBox.Top, boundingBox.Height); + var x2 = ResolveCoordinate(GetInheritedAttribute(element, "x2", elementsById), 1f, isObjectBoundingBox, boundingBox.Left, boundingBox.Width); + var y2 = ResolveCoordinate(GetInheritedAttribute(element, "y2", elementsById), 0f, isObjectBoundingBox, boundingBox.Top, boundingBox.Height); + + var p1 = gradientTransform.MapPoint(x1, y1); + var p2 = gradientTransform.MapPoint(x2, y2); + + return SKShader.CreateLinearGradient(p1, p2, colors, positions, tileMode); + } + + private static SKShader BuildRadial( + IElement element, + SKRect boundingBox, + bool isObjectBoundingBox, + SKMatrix gradientTransform, + SKColor[] colors, + float[] positions, + SKShaderTileMode tileMode, + IReadOnlyDictionary elementsById) + { + var cx = ResolveCoordinate(GetInheritedAttribute(element, "cx", elementsById), 0.5f, isObjectBoundingBox, boundingBox.Left, boundingBox.Width); + var cy = ResolveCoordinate(GetInheritedAttribute(element, "cy", elementsById), 0.5f, isObjectBoundingBox, boundingBox.Top, boundingBox.Height); + var r = ResolveCoordinate(GetInheritedAttribute(element, "r", elementsById), 0.5f, isObjectBoundingBox, 0f, Math.Max(boundingBox.Width, boundingBox.Height)); + + var fxRaw = GetInheritedAttribute(element, "fx", elementsById); + var fyRaw = GetInheritedAttribute(element, "fy", elementsById); + var fx = fxRaw is null ? cx : ResolveCoordinate(fxRaw, 0.5f, isObjectBoundingBox, boundingBox.Left, boundingBox.Width); + var fy = fyRaw is null ? cy : ResolveCoordinate(fyRaw, 0.5f, isObjectBoundingBox, boundingBox.Top, boundingBox.Height); + + var center = gradientTransform.MapPoint(cx, cy); + var focal = gradientTransform.MapPoint(fx, fy); + + // Scale a unit radius through the transform so a non-uniform gradientTransform (or an + // objectBoundingBox mapping onto a non-square box) still produces a roughly elliptical + // falloff rather than only ever a uniformly-scaled circle. + var edge = gradientTransform.MapPoint(cx + r, cy); + var radius = Distance(center, edge); + + if (radius <= 0f) + { + return SKShader.CreateColor(colors[^1]); + } + + return (focal.X == center.X && focal.Y == center.Y) + ? SKShader.CreateRadialGradient(center, radius, colors, positions, tileMode) + : SKShader.CreateTwoPointConicalGradient(focal, 0f, center, radius, colors, positions, tileMode); + } + + private static float Distance(SKPoint a, SKPoint b) => (float)Math.Sqrt(Math.Pow(b.X - a.X, 2) + Math.Pow(b.Y - a.Y, 2)); + + private static float ResolveCoordinate(string? raw, float defaultFraction, bool isObjectBoundingBox, float boxOrigin, float boxSize) + { + var fraction = ParseFractionOrNumber(raw, defaultFraction); + return isObjectBoundingBox ? boxOrigin + (fraction * boxSize) : fraction; + } + + private static float ParseFractionOrNumber(string? raw, float defaultValue) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return defaultValue; + } + + var trimmed = raw.Trim(); + + if (trimmed.EndsWith('%') && float.TryParse(trimmed[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent)) + { + return percent / 100f; + } + + return float.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : defaultValue; + } + + private static List<(float Offset, SKColor Color)> ResolveStops(IElement start, IReadOnlyDictionary elementsById) + { + var visited = new HashSet(); + var current = start; + + while (current is not null && visited.Add(current)) + { + var stops = ReadStops(current); + + if (stops.Count > 0) + { + return stops; + } + + current = SvgUrlReference.GetHref(current) is { } href && href.StartsWith('#') && elementsById.TryGetValue(href[1..], out var next) + ? next + : null; + } + + return []; + } + + private static List<(float Offset, SKColor Color)> ReadStops(IElement gradientElement) + { + var stops = new List<(float, SKColor)>(); + var previousOffset = 0f; + + foreach (var child in gradientElement.Children) + { + if (!string.Equals(child.LocalName, "stop", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var offset = Math.Clamp(ParseFractionOrNumber(child.GetAttribute("offset"), previousOffset), 0f, 1f); + offset = Math.Max(offset, previousOffset); + previousOffset = offset; + + var declarations = ReadStopDeclarations(child); + var colorValue = declarations.TryGetValue("stop-color", out var explicitColor) ? explicitColor : "black"; + SvgColorParsing.TryParsePaint(colorValue, out var color); + + var opacity = declarations.TryGetValue("stop-opacity", out var opacityValue) && float.TryParse(opacityValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedOpacity) + ? Math.Clamp(parsedOpacity, 0f, 1f) + : 1f; + + stops.Add((offset, color.WithAlpha((byte)Math.Round(color.Alpha * opacity)))); + } + + return stops; + } + + private static Dictionary ReadStopDeclarations(IElement stopElement) + { + var declarations = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (stopElement.GetAttribute("stop-color") is { Length: > 0 } stopColor) + { + declarations["stop-color"] = stopColor; + } + + if (stopElement.GetAttribute("stop-opacity") is { Length: > 0 } stopOpacity) + { + declarations["stop-opacity"] = stopOpacity; + } + + var style = stopElement.GetAttribute("style"); + + if (string.IsNullOrWhiteSpace(style)) + { + return declarations; + } + + foreach (var declaration in style.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var colonIndex = declaration.IndexOf(':'); + + if (colonIndex <= 0) + { + continue; + } + + declarations[declaration[..colonIndex].Trim()] = declaration[(colonIndex + 1)..].Trim(); + } + + return declarations; + } + + private static string? GetInheritedAttribute(IElement start, string attribute, IReadOnlyDictionary elementsById) + { + var visited = new HashSet(); + var current = start; + + while (current is not null && visited.Add(current)) + { + var value = current.GetAttribute(attribute); + + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + + current = SvgUrlReference.GetHref(current) is { } href && href.StartsWith('#') && elementsById.TryGetValue(href[1..], out var next) + ? next + : null; + } + + return null; + } +} diff --git a/src/AngleSharp.Renderer/Skia/Svg/SvgLength.cs b/src/AngleSharp.Renderer/Skia/Svg/SvgLength.cs new file mode 100644 index 0000000..d2ad036 --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/Svg/SvgLength.cs @@ -0,0 +1,51 @@ +namespace AngleSharp.Renderer.Skia.Svg; + +using System.Globalization; + +/// +/// The current SVG viewport (in user-space units) that percentage lengths resolve against. The +/// root <svg> establishes the first one; a nested <svg> or a <symbol> referenced +/// through <use> establishes a new one for its own subtree. +/// +internal readonly record struct SvgViewport(float Width, float Height) +{ + /// + /// The reference length percentages on non-axis-specific properties (`r`, `stroke-width`, ...) + /// resolve against - the length of the viewport diagonal divided by sqrt(2), per the SVG spec. + /// + public float DiagonalReference => (float)(Math.Sqrt((double)(Width * Width) + (Height * Height)) / Math.Sqrt(2)); +} + +/// +/// Resolves an SVG/CSS length that may be a plain number, a `px` value, or a percentage of some +/// reference dimension (a viewport axis, or the viewport diagonal for non-axis-specific lengths). +/// +internal static class SvgLength +{ + public static float? ParseOptional(string? value, float referenceDimension) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + + if (trimmed.EndsWith('%')) + { + return float.TryParse(trimmed[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent) + ? percent / 100f * referenceDimension + : null; + } + + if (trimmed.EndsWith("px", StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[..^2]; + } + + return float.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) ? number : null; + } + + public static float Parse(string? value, float referenceDimension, float fallback) => + ParseOptional(value, referenceDimension) ?? fallback; +} diff --git a/src/AngleSharp.Renderer/Skia/Svg/SvgPaintState.cs b/src/AngleSharp.Renderer/Skia/Svg/SvgPaintState.cs new file mode 100644 index 0000000..f7fa1e3 --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/Svg/SvgPaintState.cs @@ -0,0 +1,386 @@ +namespace AngleSharp.Renderer.Skia.Svg; + +using System.Globalization; +using System.Linq; + +using AngleSharp.Dom; + +using SkiaSharp; + +/// +/// Text alignment relative to the position an SVG <text>/<tspan> was given. +/// +internal enum SvgTextAnchor +{ + Start, + Middle, + End, +} + +/// +/// What a `fill`/`stroke` value resolved to: unpainted, a solid color, or a `url(#id)` reference +/// to a paint server (a gradient) that needs the element's own geometry to finish resolving. +/// +internal readonly record struct SvgPaintValue(SvgPaintKind Kind, SKColor Color, string? ReferenceId) +{ + public static SvgPaintValue None { get; } = new(SvgPaintKind.None, SKColors.Transparent, null); + + public static SvgPaintValue FromColor(SKColor color) => new(SvgPaintKind.Color, color, null); + + public static SvgPaintValue FromReference(string id) => new(SvgPaintKind.Reference, SKColors.Transparent, id); + + public static SvgPaintValue Parse(string raw, SvgPaintValue fallback, SKColor currentColor) + { + var trimmed = raw.Trim(); + + if (SvgUrlReference.TryExtract(trimmed, out var id)) + { + return FromReference(id); + } + + if (trimmed.Equals("currentColor", StringComparison.OrdinalIgnoreCase)) + { + return FromColor(currentColor); + } + + return SvgColorParsing.TryParsePaint(trimmed, out var color) ? FromColor(color) : None; + } +} + +internal enum SvgPaintKind +{ + None, + Color, + Reference, +} + +/// +/// The inherited paint and font state while walking an SVG element tree. Presentation attributes +/// inherit down the tree by default; an SVG-internal <style> rule overrides them, and an +/// inline `style=""` declaration on the element overrides both, matching the CSS cascade. +/// +internal readonly record struct SvgPaintState( + SvgPaintValue Fill, + float FillOpacity, + SvgPaintValue Stroke, + float StrokeOpacity, + float StrokeWidth, + float Opacity, + SKPathFillType FillRule, + string FontFamily, + float FontSize, + float FontWeight, + bool IsItalic, + SvgTextAnchor TextAnchor, + SKColor CurrentColor) +{ + public static SvgPaintState Initial { get; } = new( + Fill: SvgPaintValue.FromColor(SKColors.Black), + FillOpacity: 1f, + Stroke: SvgPaintValue.None, + StrokeOpacity: 1f, + StrokeWidth: 1f, + Opacity: 1f, + FillRule: SKPathFillType.Winding, + FontFamily: "sans-serif", + FontSize: 16f, + FontWeight: 400f, + IsItalic: false, + TextAnchor: SvgTextAnchor.Start, + CurrentColor: SKColors.Black); + + /// + /// Resolves the paint state for , inheriting from this instance and + /// layering in, from weakest to strongest: presentation attributes, matched + /// SVG-internal <style> rules, then an inline `style` attribute. + /// + public SvgPaintState Resolve(IElement element, SvgRenderContext context, SvgViewport viewport) + { + var declarations = ReadDeclarations(element, context); + + // `color` is resolved first so `currentColor` on this same element's fill/stroke picks up + // its own computed value, not the inherited one, matching the CSS `currentColor` keyword. + var currentColor = CurrentColor; + + if (TryGetDeclaration(declarations, "color", out var colorValue) && SvgColorParsing.TryParsePaint(colorValue, out var parsedColor)) + { + currentColor = parsedColor; + } + + var fill = TryGetDeclaration(declarations, "fill", out var fillValue) ? SvgPaintValue.Parse(fillValue, Fill, currentColor) : Fill; + var stroke = TryGetDeclaration(declarations, "stroke", out var strokeValue) ? SvgPaintValue.Parse(strokeValue, Stroke, currentColor) : Stroke; + + var fillOpacity = FillOpacity; + + if (TryGetDeclaration(declarations, "fill-opacity", out var fillOpacityValue) && + TryParseOpacity(fillOpacityValue, out var parsedFillOpacity)) + { + fillOpacity = parsedFillOpacity; + } + + var strokeOpacity = StrokeOpacity; + + if (TryGetDeclaration(declarations, "stroke-opacity", out var strokeOpacityValue) && + TryParseOpacity(strokeOpacityValue, out var parsedStrokeOpacity)) + { + strokeOpacity = parsedStrokeOpacity; + } + + var strokeWidth = StrokeWidth; + + if (TryGetDeclaration(declarations, "stroke-width", out var strokeWidthValue)) + { + strokeWidth = SvgLength.Parse(strokeWidthValue, viewport.DiagonalReference, strokeWidth); + } + + // Element opacity composites with a translucent group in a real SVG renderer (via an + // isolated layer); multiplying it directly into fill/stroke alpha is a simplification + // that is exact for opaque descendants and close enough for the common icon/logo case. + var opacity = Opacity; + + if (TryGetDeclaration(declarations, "opacity", out var opacityValue) && + TryParseOpacity(opacityValue, out var parsedOpacity)) + { + opacity *= parsedOpacity; + } + + var fillRule = FillRule; + + if (TryGetDeclaration(declarations, "fill-rule", out var fillRuleValue)) + { + fillRule = string.Equals(fillRuleValue.Trim(), "evenodd", StringComparison.OrdinalIgnoreCase) + ? SKPathFillType.EvenOdd + : SKPathFillType.Winding; + } + + var fontFamily = TryGetDeclaration(declarations, "font-family", out var fontFamilyValue) ? fontFamilyValue : FontFamily; + + var fontSize = FontSize; + + if (TryGetDeclaration(declarations, "font-size", out var fontSizeValue)) + { + fontSize = ParseFontSize(fontSizeValue, FontSize); + } + + var fontWeight = FontWeight; + + if (TryGetDeclaration(declarations, "font-weight", out var fontWeightValue)) + { + fontWeight = ParseFontWeight(fontWeightValue, FontWeight); + } + + var isItalic = IsItalic; + + if (TryGetDeclaration(declarations, "font-style", out var fontStyleValue)) + { + var normalized = fontStyleValue.Trim(); + isItalic = normalized.Equals("italic", StringComparison.OrdinalIgnoreCase) || normalized.Equals("oblique", StringComparison.OrdinalIgnoreCase); + } + + var textAnchor = TextAnchor; + + if (TryGetDeclaration(declarations, "text-anchor", out var textAnchorValue)) + { + textAnchor = textAnchorValue.Trim().ToLowerInvariant() switch + { + "middle" => SvgTextAnchor.Middle, + "end" => SvgTextAnchor.End, + _ => SvgTextAnchor.Start, + }; + } + + return new SvgPaintState(fill, fillOpacity, stroke, strokeOpacity, strokeWidth, opacity, fillRule, fontFamily, fontSize, fontWeight, isItalic, textAnchor, currentColor); + } + + public SKPaint? CreateFillPaint(SKRect bounds, SvgRenderContext context, SvgViewport viewport) => + CreatePaint(Fill, FillOpacity, SKPaintStyle.Fill, bounds, context, viewport); + + public SKPaint? CreateStrokePaint(SKRect bounds, SvgRenderContext context, SvgViewport viewport) + { + if (StrokeWidth <= 0f) + { + return null; + } + + var paint = CreatePaint(Stroke, StrokeOpacity, SKPaintStyle.Stroke, bounds, context, viewport); + + if (paint is not null) + { + paint.StrokeWidth = StrokeWidth; + } + + return paint; + } + + private SKPaint? CreatePaint(SvgPaintValue paintValue, float paintOpacity, SKPaintStyle style, SKRect bounds, SvgRenderContext context, SvgViewport viewport) + { + var alpha = Math.Clamp(paintOpacity * Opacity, 0f, 1f); + + switch (paintValue.Kind) + { + case SvgPaintKind.None: + return null; + + case SvgPaintKind.Color: + return new SKPaint + { + Style = style, + IsAntialias = true, + Color = paintValue.Color.WithAlpha((byte)Math.Round(paintValue.Color.Alpha * alpha)), + }; + + case SvgPaintKind.Reference when paintValue.ReferenceId is { } id && context.ElementsById.TryGetValue(id, out var paintServer): + var shader = string.Equals(paintServer.LocalName, "pattern", StringComparison.OrdinalIgnoreCase) + ? SvgPatternBuilder.Build(paintServer, bounds, context, viewport) + : SvgGradientBuilder.Build(paintServer, bounds, context.ElementsById); + + if (shader is null) + { + return null; + } + + // A shader ignores the paint's RGB, but its alpha still modulates every sample the + // shader produces - that is how fill-opacity/opacity reach a gradient or pattern fill. + return new SKPaint + { + Style = style, + IsAntialias = true, + Shader = shader, + Color = SKColors.White.WithAlpha((byte)Math.Round(255 * alpha)), + }; + + default: + // A url() reference to a nonexistent or unsupported paint server paints nothing, + // per the SVG spec, rather than falling back to some default color. + return null; + } + } + + private static float ParseFontSize(string value, float inherited) + { + var trimmed = value.Trim(); + + if (trimmed.EndsWith('%') && float.TryParse(trimmed[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent)) + { + return inherited * percent / 100f; + } + + if (trimmed.EndsWith("px", StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[..^2]; + } + + return float.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && number > 0f ? number : inherited; + } + + private static float ParseFontWeight(string value, float inherited) + { + var trimmed = value.Trim(); + + return trimmed.ToLowerInvariant() switch + { + "normal" => 400f, + "bold" => 700f, + "bolder" => Math.Min(900f, inherited + 300f), + "lighter" => Math.Max(100f, inherited - 300f), + _ when float.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out var numeric) => numeric, + _ => inherited, + }; + } + + private static bool TryGetDeclaration(Dictionary declarations, string name, out string value) + { + if (declarations.TryGetValue(name, out var found) && !string.IsNullOrWhiteSpace(found)) + { + value = found; + return true; + } + + value = string.Empty; + return false; + } + + private static bool TryParseOpacity(string value, out float opacity) + { + var trimmed = value.Trim(); + + if (trimmed.EndsWith('%') && float.TryParse(trimmed[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent)) + { + opacity = Math.Clamp(percent / 100f, 0f, 1f); + return true; + } + + if (float.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out var raw)) + { + opacity = Math.Clamp(raw, 0f, 1f); + return true; + } + + opacity = 1f; + return false; + } + + private static readonly string[] PresentationProperties = + [ + "fill", "stroke", "fill-opacity", "stroke-opacity", "stroke-width", "opacity", "fill-rule", + "font-family", "font-size", "font-weight", "font-style", "text-anchor", "color", + ]; + + private static Dictionary ReadDeclarations(IElement element, SvgRenderContext context) + { + var declarations = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // 1. Presentation attributes - weakest precedence. + foreach (var property in PresentationProperties) + { + AddIfPresent(declarations, property, element.GetAttribute(property)); + } + + // 2. SVG-internal + public static bool TryRasterizeMarkup(byte[] svgBytes, out byte[] pngBytes, out int naturalWidth, out int naturalHeight) + { + pngBytes = []; + naturalWidth = 0; + naturalHeight = 0; + + var text = Encoding.UTF8.GetString(svgBytes).TrimStart('', ' ', '\t', '\r', '\n'); + var xmlDeclarationEnd = text.IndexOf("{svgMarkup}"; + + try + { + var context = BrowsingContext.New(Configuration.Default); + var document = context.OpenAsync(request => request.Content(wrapped)).GetAwaiter().GetResult(); + var svgRoot = document.QuerySelector("svg"); + + if (svgRoot is null) + { + return false; + } + + return TryRasterizeElement(svgRoot, out pngBytes, out naturalWidth, out naturalHeight); + } + catch + { + return false; + } + } + + /// + /// Rasterizes an SVG element that was already parsed as part of a larger document (an inline + /// <svg> in the host HTML) - the DOM is walked directly, nothing is re-parsed. + /// + public static bool TryRasterizeElement(IElement svgRoot, out byte[] pngBytes, out int naturalWidth, out int naturalHeight) + { + pngBytes = []; + naturalWidth = 0; + naturalHeight = 0; + + try + { + var (viewBoxMatrix, width, height, contentViewport) = ResolveViewport(svgRoot); + + if (width <= 0f || height <= 0f) + { + return false; + } + + naturalWidth = (int)Math.Max(1, Math.Round(width)); + naturalHeight = (int)Math.Max(1, Math.Round(height)); + + var scale = OversampleFactor; + var maxNatural = Math.Max(width, height); + + if (maxNatural * scale > MaxRasterDimension) + { + scale = Math.Max(1f, MaxRasterDimension / maxNatural); + } + + var pixelWidth = Math.Max(1, (int)Math.Round(width * scale)); + var pixelHeight = Math.Max(1, (int)Math.Round(height * scale)); + + using var bitmap = new SKBitmap(pixelWidth, pixelHeight, SKColorType.Rgba8888, SKAlphaType.Premul); + bitmap.Erase(SKColors.Transparent); + + var renderContext = SvgRenderContext.Build(svgRoot); + + using (var canvas = new SKCanvas(bitmap)) + { + canvas.Scale(scale, scale); + canvas.Concat(ref viewBoxMatrix); + SvgElementRenderer.Render(canvas, svgRoot, SvgPaintState.Initial, renderContext, contentViewport); + canvas.Flush(); + } + + using var image = SKImage.FromBitmap(bitmap); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + + if (data is null) + { + return false; + } + + pngBytes = data.ToArray(); + return pngBytes.Length > 0; + } + catch + { + return false; + } + } + + private static (SKMatrix ViewBoxMatrix, float Width, float Height, SvgViewport ContentViewport) ResolveViewport(IElement svgRoot) + { + var viewBox = SvgViewBoxMapping.ParseViewBox(svgRoot.GetAttribute("viewBox")); + var explicitWidth = ParseLength(svgRoot.GetAttribute("width")); + var explicitHeight = ParseLength(svgRoot.GetAttribute("height")); + + float width; + float height; + + if (explicitWidth is { } specifiedWidth && explicitHeight is { } specifiedHeight) + { + width = specifiedWidth; + height = specifiedHeight; + } + else if (explicitWidth is { } widthOnly) + { + width = widthOnly; + height = viewBox is { } vbForHeight && vbForHeight.Width > 0f ? widthOnly * (vbForHeight.Height / vbForHeight.Width) : widthOnly; + } + else if (explicitHeight is { } heightOnly) + { + height = heightOnly; + width = viewBox is { } vbForWidth && vbForWidth.Height > 0f ? heightOnly * (vbForWidth.Width / vbForWidth.Height) : heightOnly; + } + else if (viewBox is { } vbOnly) + { + width = vbOnly.Width; + height = vbOnly.Height; + } + else + { + width = DefaultNaturalSize; + height = DefaultNaturalSize / 2f; + } + + var (align, meet) = SvgViewBoxMapping.ParsePreserveAspectRatio(svgRoot.GetAttribute("preserveAspectRatio")); + var matrix = SvgViewBoxMapping.ComputeMatrix(viewBox, width, height, align, meet); + var contentViewport = viewBox is { } box ? new SvgViewport(box.Width, box.Height) : new SvgViewport(width, height); + + return (matrix, width, height, contentViewport); + } + + private static float? ParseLength(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + + if (trimmed.EndsWith('%')) + { + return null; + } + + if (trimmed.EndsWith("px", StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[..^2]; + } + + return float.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && number > 0f + ? number + : null; + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index b2aac2c..6d96d9b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ Adds rendering functionality to the core AngleSharp library. AngleSharp.Renderer - 0.2.0 + 0.3.0 enable latest true