diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..6a8c8a1 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "fallout.cli": { + "version": "11.0.18", + "commands": [ + "fallout" + ] + } + } +} diff --git a/.fallout/build.schema.json b/.fallout/build.schema.json new file mode 100644 index 0000000..82690d1 --- /dev/null +++ b/.fallout/build.schema.json @@ -0,0 +1,145 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": { + "Host": { + "type": "string", + "enum": [ + "AppVeyor", + "AzurePipelines", + "Bamboo", + "Bitbucket", + "Bitrise", + "GitHubActions", + "GitLab", + "Jenkins", + "Rider", + "SpaceAutomation", + "TeamCity", + "Terminal", + "TravisCI", + "VisualStudio", + "VSCode" + ] + }, + "ExecutableTarget": { + "type": "string", + "enum": [ + "Clean", + "Compile", + "CreatePackage", + "Default", + "Package", + "PrePublish", + "Publish", + "PublishPackage", + "PublishPreRelease", + "PublishRelease", + "Restore", + "RunUnitTests" + ] + }, + "Verbosity": { + "type": "string", + "description": "", + "enum": [ + "Verbose", + "Normal", + "Minimal", + "Quiet" + ] + }, + "FalloutBuild": { + "properties": { + "Continue": { + "type": "boolean", + "description": "Indicates to continue a previously failed build attempt" + }, + "Help": { + "type": "boolean", + "description": "Shows the help text for this build assembly" + }, + "Host": { + "description": "Host for execution. Default is 'automatic'", + "$ref": "#/definitions/Host" + }, + "NoLogo": { + "type": "boolean", + "description": "Disables displaying the NUKE logo" + }, + "Partition": { + "type": "string", + "description": "Partition to use on CI" + }, + "Plan": { + "type": "boolean", + "description": "Shows the execution plan (HTML)" + }, + "Profile": { + "type": "array", + "description": "Defines the profiles to load", + "items": { + "type": "string" + } + }, + "Root": { + "type": "string", + "description": "Root directory during build execution" + }, + "Skip": { + "type": "array", + "description": "List of targets to be skipped. Empty list skips all dependencies", + "items": { + "$ref": "#/definitions/ExecutableTarget" + } + }, + "Target": { + "type": "array", + "description": "List of targets to be invoked. Default is '{default_target}'", + "items": { + "$ref": "#/definitions/ExecutableTarget" + } + }, + "Verbosity": { + "description": "Logging verbosity during build execution. Default is 'Normal'", + "$ref": "#/definitions/Verbosity" + }, + "BuildProjectFile": { + "type": [ + "null", + "string" + ], + "description": "Path to the build project (.csproj) relative to the repository root. Defaults to 'build/_build.csproj' when unset. Read by the Fallout global tool's in-tool runner." + } + } + } + }, + "allOf": [ + { + "properties": { + "AngleSharpVersion": { + "type": "string", + "description": "AngleSharp package version override (e.g. 1.0.0 for compatibility checks)" + }, + "Configuration": { + "type": "string", + "enum": [ + "Debug", + "Release" + ], + "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)" + }, + "ReleaseNotesFilePath": { + "type": "string", + "description": "ReleaseNotesFilePath - To determine the SemanticVersion" + }, + "Solution": { + "type": "string", + "description": "Path to a solution file that is automatically loaded" + } + } + }, + { + "$ref": "#/definitions/FalloutBuild" + } + ] +} diff --git a/.fallout/parameters.json b/.fallout/parameters.json new file mode 100644 index 0000000..cc1ecf1 --- /dev/null +++ b/.fallout/parameters.json @@ -0,0 +1,4 @@ +{ + "$schema": "./build.schema.json", + "Solution": "src/AngleSharp.Renderer.sln" +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61e9edf..b3f20f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,10 +45,22 @@ jobs: npx pilet publish --fresh --url https://feed.piral.cloud/api/v1/pilet/anglesharp --api-key ${{ secrets.PIRAL_FEED_KEY }} linux: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 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 @@ -57,8 +69,20 @@ jobs: 10.0.x - name: Build + env: + ANGLESHARP_SNAPSHOT_STRICT: 1 run: ./build.sh -AngleSharpVersion 1.5.0 + - name: Upload visual assets + if: always() + uses: actions/upload-artifact@v4 + with: + name: linux-visual-assets + path: | + src/AngleSharp.Renderer.Tests/verification-assets/** + src/AngleSharp.Renderer.Tests/failure-assets/** + if-no-files-found: ignore + windows: runs-on: windows-latest @@ -80,3 +104,13 @@ jobs: } else { .\build.ps1 -AngleSharpVersion 1.5.0 } + + - 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/.gitignore b/.gitignore index de3ed79..32b162d 100644 --- a/.gitignore +++ b/.gitignore @@ -59,7 +59,8 @@ local.properties [Dd]ebug/ [Rr]elease/ [Bb]in/ -[Bb]uild/ +build/bin/ +build/obj/ [Oo]bj/ # Visual Studio 2015 cache/options directory @@ -171,7 +172,8 @@ Desktop.ini *.egg *.egg-info dist -build +build/bin +build/obj eggs parts var @@ -195,5 +197,5 @@ pip-log.txt # Mac crap .DS_Store -# Nuke build tool -.nuke/temp +# Fallout build tool +.fallout/temp diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..428c9a0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,74 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. Keep this file as the quick-start reference for the repo's commands, architecture, conventions, and test workflow. + +## What This Repository Is + +AngleSharp.Renderer adds rendering capabilities on top of AngleSharp and AngleSharp.Css. The current implementation is a display-list renderer with a SkiaSharp backend that rasterizes HTML/CSS content into PNG images. + +## Most Important Commands + +Use the solution-level test command for normal validation: + +```bash +dotnet test src/AngleSharp.Renderer.sln +``` + +Use the test project directly when iterating on renderer behavior: + +```bash +dotnet test src/AngleSharp.Renderer.Tests/AngleSharp.Renderer.Tests.csproj +``` + +The repo also includes build scripts that drive the Fallout bootstrapper: + +```bash +./build.sh +./build.ps1 +./build.cmd +``` + +The main renderer project targets `net8.0` and `net10.0`. The test project targets `net8.0`. + +## Architecture + +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. +- `HtmlRenderOptions` holds viewport and text defaults. +- `AngleSharp.Css` provides the render tree and computed-style data used by the renderer. + +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. + +## Code Conventions + +Follow the repository's existing C# style, which is defined by `.editorconfig` and the existing source files: + +- 4 spaces for code files, 2 spaces for `.csproj` files. +- LF line endings and UTF-8. +- Trim trailing whitespace. +- Use `var` where the type is obvious from the right-hand side. +- Keep changes narrow and consistent with nearby code. +- Preserve the existing file style instead of reformatting whole files. + +## Tests And Snapshots + +Tests are split between structural assertions and visual conformance checks. + +- Structural tests live in `src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs` and verify the display list directly. +- Visual tests live in `src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs` and compare PNG output against baselines in `verification-assets/`. +- Failed visual comparisons write the actual image and a diff image into `failure-assets/`. +- Missing baselines are auto-created unless `ANGLESHARP_SNAPSHOT_STRICT=1` or `true` is set. +- CI runs visual tests on both Linux and Windows. To reduce drift, the renderer uses bundled deterministic font files for generic font-family resolution. + +When changing renderer behavior, update or add tests first, then run the focused test file or the full test project. + +## Repository Notes + +- The docs live under `docs/general/` and `docs/tutorials/`; they are the best place to document user-facing renderer behavior. +- `AGENTS.md` should remain the primary agent note for this repo. +- The repository already carries a snapshot-based workflow, so visual changes usually require updating the baseline PNGs together with the code change. +- The Linux Skia setup uses native assets from the main project package references. +- Keep an eye on `failure-assets/` after test runs; they are useful diagnostics, not source of truth. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0617092..7ab77c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ +# 0.2.0 + +Released on Friday, July 31 2026. + +- Added more drawing modes such as tables, floats, ... +- Added `WithRendering` extension to register rendering service + # 0.1.0 -Released on ?. +Released on Tuesday, July 28 2026. - Initial release diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9f02784 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +The guidance is shared with every AI agent working here, so it lives in AGENTS.md and is +imported below. Record new guidance there rather than in this file. + +@AGENTS.md diff --git a/README.md b/README.md index 10b5680..5bab395 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ The project now contains a first draft implementation with: - A backend-agnostic rendering core and display-list model - A SkiaSharp backend that renders PNG output - A basic DOM-driven text layout pass (block flow, heading scaling, word wrapping) +- Basic support for HTML canvas via a bitmap-backed 2D rendering context This is an initial vertical slice and not a full browser-grade layout engine yet. @@ -26,7 +27,7 @@ This is an initial vertical slice and not a full browser-grade layout engine yet using AngleSharp; using AngleSharp.Renderer; -var context = BrowsingContext.New(Configuration.Default); +var context = BrowsingContext.New(Configuration.Default.WithCss().WithRendering()); var document = await context.OpenAsync(req => req.Content(@" @@ -45,6 +46,8 @@ var image = renderer.RenderToPng(document, new HtmlRenderOptions await File.WriteAllBytesAsync("render.png", image.Data); ``` +Registering the rendering service with `WithRendering()` is also what enables support for `` elements and their 2D drawing context. The renderer currently provides a lightweight bitmap-backed implementation for common drawing operations such as rectangles, paths, text, clear operations, and simple state management. + ## Design Direction The current architecture is intentionally split into two layers: @@ -60,4 +63,4 @@ This project is supported by the [.NET Foundation](https://dotnetfoundation.org) ## License -AngleSharp.Css is released using the MIT license. For more information see the [license file](./LICENSE). +AngleSharp.Renderer is released using the MIT license. For more information see the [license file](./LICENSE). diff --git a/build.ps1 b/build.ps1 index 3cca4ef..61c6628 100644 --- a/build.ps1 +++ b/build.ps1 @@ -13,10 +13,9 @@ $PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent # CONFIGURATION ########################################################################### -$BuildProjectFile = "$PSScriptRoot\nuke\_build.csproj" -$TempDirectory = "$PSScriptRoot\\.nuke\temp" +$TempDirectory = "$PSScriptRoot\.fallout\temp" -$DotNetGlobalFile = "$PSScriptRoot\\global.json" +$DotNetGlobalFile = "$PSScriptRoot\global.json" $DotNetInstallUrl = "https://dot.net/v1/dotnet-install.ps1" $DotNetChannel = "STS" @@ -32,7 +31,7 @@ function ExecSafe([scriptblock] $cmd) { if ($LASTEXITCODE) { exit $LASTEXITCODE } } -# If dotnet CLI is installed globally and it matches requested version, use for execution +# If dotnet CLI is installed globally, use it; otherwise provision a local copy under .fallout\temp. if ($null -ne (Get-Command "dotnet" -ErrorAction SilentlyContinue) -and ` $(dotnet --version) -and $LASTEXITCODE -eq 0) { $env:DOTNET_EXE = (Get-Command "dotnet").Path @@ -65,10 +64,5 @@ else { Write-Output "Microsoft (R) .NET SDK version $(& $env:DOTNET_EXE --version)" -if (Test-Path env:NUKE_ENTERPRISE_TOKEN) { - & $env:DOTNET_EXE nuget remove source "nuke-enterprise" > $null - & $env:DOTNET_EXE nuget add source "https://f.feedz.io/nuke/enterprise/nuget" --name "nuke-enterprise" --username "PAT" --password $env:NUKE_ENTERPRISE_TOKEN > $null -} - -ExecSafe { & $env:DOTNET_EXE build $BuildProjectFile /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet } -ExecSafe { & $env:DOTNET_EXE run --project $BuildProjectFile --no-build -- $BuildArguments } +ExecSafe { & $env:DOTNET_EXE tool restore } +ExecSafe { & $env:DOTNET_EXE fallout $BuildArguments } diff --git a/build.sh b/build.sh index a226b96..fc76bf8 100755 --- a/build.sh +++ b/build.sh @@ -9,10 +9,9 @@ SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd) # CONFIGURATION ########################################################################### -BUILD_PROJECT_FILE="$SCRIPT_DIR/nuke/_build.csproj" -TEMP_DIRECTORY="$SCRIPT_DIR//.nuke/temp" +TEMP_DIRECTORY="$SCRIPT_DIR/.fallout/temp" -DOTNET_GLOBAL_FILE="$SCRIPT_DIR//global.json" +DOTNET_GLOBAL_FILE="$SCRIPT_DIR/global.json" DOTNET_INSTALL_URL="https://dot.net/v1/dotnet-install.sh" DOTNET_CHANNEL="STS" @@ -27,7 +26,7 @@ function FirstJsonValue { perl -nle 'print $1 if m{"'"$1"'": "([^"]+)",?}' <<< "${@:2}" } -# If dotnet CLI is installed globally and it matches requested version, use for execution +# If dotnet CLI is installed globally, use it; otherwise provision a local copy under .fallout/temp. if [ -x "$(command -v dotnet)" ] && dotnet --version &>/dev/null; then export DOTNET_EXE="$(command -v dotnet)" else @@ -58,10 +57,5 @@ fi echo "Microsoft (R) .NET SDK version $("$DOTNET_EXE" --version)" -if [[ ! -z ${NUKE_ENTERPRISE_TOKEN+x} && "$NUKE_ENTERPRISE_TOKEN" != "" ]]; then - "$DOTNET_EXE" nuget remove source "nuke-enterprise" &>/dev/null || true - "$DOTNET_EXE" nuget add source "https://f.feedz.io/nuke/enterprise/nuget" --name "nuke-enterprise" --username "PAT" --password "$NUKE_ENTERPRISE_TOKEN" --store-password-in-clear-text &>/dev/null || true -fi - -"$DOTNET_EXE" build "$BUILD_PROJECT_FILE" /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet -"$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" --no-build -- "$@" +"$DOTNET_EXE" tool restore +exec "$DOTNET_EXE" fallout "$@" diff --git a/nuke/.editorconfig b/build/.editorconfig similarity index 100% rename from nuke/.editorconfig rename to build/.editorconfig diff --git a/nuke/Build.cs b/build/Build.cs similarity index 73% rename from nuke/Build.cs rename to build/Build.cs index a1ffbde..ed9d51c 100644 --- a/nuke/Build.cs +++ b/build/Build.cs @@ -1,12 +1,11 @@ +using Fallout.Common; +using Fallout.Common.CI.GitHubActions; +using Fallout.Common.IO; +using Fallout.Common.Tools.DotNet; +using Fallout.Common.Tools.GitHub; +using Fallout.Common.Utilities.Collections; +using Fallout.Solutions; using Microsoft.Build.Exceptions; -using Nuke.Common; -using Nuke.Common.CI.GitHubActions; -using Nuke.Common.IO; -using Nuke.Common.ProjectModel; -using Nuke.Common.Tools.DotNet; -using Nuke.Common.Tools.GitHub; -using Nuke.Common.Tools.NuGet; -using Nuke.Common.Utilities.Collections; using Octokit; using Octokit.Internal; using Serilog; @@ -14,27 +13,20 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using static Nuke.Common.Tools.DotNet.DotNetTasks; -using static Nuke.Common.Tools.NuGet.NuGetTasks; -using Project = Nuke.Common.ProjectModel.Project; +using static Fallout.Common.Tools.DotNet.DotNetTasks; +using Project = Fallout.Solutions.Project; -class Build : NukeBuild +class Build : FalloutBuild { - /// Support plugins are available for: - /// - JetBrains ReSharper https://nuke.build/resharper - /// - JetBrains Rider https://nuke.build/rider - /// - Microsoft VisualStudio https://nuke.build/visualstudio - /// - Microsoft VSCode https://nuke.build/vscode - public static int Main () => Execute(x => x.RunUnitTests); - [Nuke.Common.Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] + [Fallout.Common.Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release; - [Nuke.Common.Parameter("ReleaseNotesFilePath - To determine the SemanticVersion")] + [Fallout.Common.Parameter("ReleaseNotesFilePath - To determine the SemanticVersion")] readonly AbsolutePath ReleaseNotesFilePath = RootDirectory / "CHANGELOG.md"; - [Nuke.Common.Parameter("AngleSharp package version override (e.g. 1.0.0 for compatibility checks)")] + [Fallout.Common.Parameter("AngleSharp package version override (e.g. 1.0.0 for compatibility checks)")] readonly string AngleSharpVersion; [Solution] @@ -46,8 +38,6 @@ class Build : NukeBuild AbsolutePath SourceDirectory => RootDirectory / "src"; - AbsolutePath BuildDirectory => SourceDirectory / TargetProjectName / "bin" / Configuration; - AbsolutePath ResultDirectory => RootDirectory / "bin" / Version; AbsolutePath NugetDirectory => ResultDirectory / "nuget"; @@ -56,7 +46,7 @@ class Build : NukeBuild Project TargetProject { get; set; } - // Note: The ChangeLogTasks from Nuke itself look buggy. So using the Cake source code. + // Note: The built-in ChangeLogTasks (inherited from NUKE) look buggy. So using the Cake source code. IReadOnlyList ChangeLog { get; set; } ReleaseNotes LatestReleaseNotes { get; set; } @@ -141,6 +131,8 @@ protected override void OnBuildInitialized() var settings = s .SetProjectFile(Solution) .SetConfiguration(Configuration) + .SetVersion(Version) + .SetContinuousIntegrationBuild(IsServerBuild) .EnableNoRestore(); if (!String.IsNullOrEmpty(AngleSharpVersion)) @@ -161,6 +153,7 @@ protected override void OnBuildInitialized() var settings = s .SetProjectFile(Solution) .SetConfiguration(Configuration) + .SetProperty("Version", Version) .EnableNoRestore() .EnableNoBuild(); @@ -173,39 +166,30 @@ protected override void OnBuildInitialized() }); }); - Target CopyFiles => _ => _ + // The package is produced by `dotnet pack` straight from the project, so the dependency + // groups follow the actual TargetFrameworks instead of a hand-maintained nuspec. + Target CreatePackage => _ => _ .DependsOn(Compile) .Executes(() => { - foreach (var item in TargetFrameworks) + DotNetPack(s => { - var targetDir = NugetDirectory / "lib" / item; - var srcDir = BuildDirectory / item; - - (srcDir / $"{TargetProjectName}.dll").Copy(targetDir / $"{TargetProjectName}.dll", ExistsPolicy.FileOverwriteIfNewer); - (srcDir / $"{TargetProjectName}.pdb").Copy(targetDir / $"{TargetProjectName}.pdb", ExistsPolicy.FileOverwriteIfNewer); - (srcDir / $"{TargetProjectName}.xml").Copy(targetDir / $"{TargetProjectName}.xml", ExistsPolicy.FileOverwriteIfNewer); - } + var settings = s + .SetProject(TargetProject) + .SetConfiguration(Configuration) + .SetVersion(Version) + .SetOutputDirectory(NugetDirectory) + .SetContinuousIntegrationBuild(IsServerBuild) + .EnableNoRestore() + .EnableNoBuild(); - (SourceDirectory / $"{TargetProjectName}.nuspec").Copy(NugetDirectory / $"{TargetProjectName}.nuspec", ExistsPolicy.FileOverwriteIfNewer); - (RootDirectory / "logo.png").Copy(NugetDirectory / "logo.png", ExistsPolicy.FileOverwriteIfNewer); - (RootDirectory / "README.md").Copy(NugetDirectory / "README.md", ExistsPolicy.FileOverwriteIfNewer); - }); + if (!String.IsNullOrEmpty(AngleSharpVersion)) + { + settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); + } - Target CreatePackage => _ => _ - .DependsOn(CopyFiles) - .Executes(() => - { - var nuspec = NugetDirectory / $"{TargetProjectName}.nuspec"; - - NuGetPack(_ => _ - .SetTargetPath(nuspec) - .SetVersion(Version) - .SetOutputDirectory(NugetDirectory) - .SetSymbols(true) - .SetSymbolPackageFormat("snupkg") - .AddProperty("Configuration", Configuration) - ); + return settings; + }); }); Target PublishPackage => _ => _ @@ -221,9 +205,10 @@ protected override void OnBuildInitialized() throw new BuildAbortedException("Could not resolve the NuGet API key."); } + // Pushing the .nupkg also uploads the matching .snupkg next to it. foreach (var nupkg in NugetDirectory.GlobFiles("*.nupkg")) { - NuGetPush(s => s + DotNetNuGetPush(s => s .SetTargetPath(nupkg) .SetSource("https://api.nuget.org/v3/index.json") .SetApiKey(apiKey)); @@ -253,7 +238,7 @@ protected override void OnBuildInitialized() var credentials = new Credentials(gitHubToken); GitHubTasks.GitHubClient = new GitHubClient( - new ProductHeaderValue(nameof(NukeBuild)), + new ProductHeaderValue(nameof(FalloutBuild)), new InMemoryCredentialStore(credentials)); GitHubTasks.GitHubClient.Repository.Release @@ -289,7 +274,7 @@ protected override void OnBuildInitialized() var credentials = new Credentials(gitHubToken); GitHubTasks.GitHubClient = new GitHubClient( - new ProductHeaderValue(nameof(NukeBuild)), + new ProductHeaderValue(nameof(FalloutBuild)), new InMemoryCredentialStore(credentials)); GitHubTasks.GitHubClient.Repository.Release diff --git a/nuke/Configuration.cs b/build/Configuration.cs similarity index 93% rename from nuke/Configuration.cs rename to build/Configuration.cs index 9c08b1a..75c63ea 100644 --- a/nuke/Configuration.cs +++ b/build/Configuration.cs @@ -1,7 +1,7 @@ using System; using System.ComponentModel; using System.Linq; -using Nuke.Common.Tooling; +using Fallout.Common.Tooling; [TypeConverter(typeof(TypeConverter))] public class Configuration : Enumeration diff --git a/nuke/Directory.Build.props b/build/Directory.Build.props similarity index 100% rename from nuke/Directory.Build.props rename to build/Directory.Build.props diff --git a/nuke/Directory.Build.targets b/build/Directory.Build.targets similarity index 100% rename from nuke/Directory.Build.targets rename to build/Directory.Build.targets diff --git a/nuke/Extensions/StringExtensions.cs b/build/Extensions/StringExtensions.cs similarity index 100% rename from nuke/Extensions/StringExtensions.cs rename to build/Extensions/StringExtensions.cs diff --git a/nuke/ReleaseNotes.cs b/build/ReleaseNotes.cs similarity index 100% rename from nuke/ReleaseNotes.cs rename to build/ReleaseNotes.cs diff --git a/nuke/ReleaseNotesParser.cs b/build/ReleaseNotesParser.cs similarity index 100% rename from nuke/ReleaseNotesParser.cs rename to build/ReleaseNotesParser.cs diff --git a/nuke/SemVersion.cs b/build/SemVersion.cs similarity index 100% rename from nuke/SemVersion.cs rename to build/SemVersion.cs diff --git a/build/_build.csproj b/build/_build.csproj new file mode 100644 index 0000000..647ac26 --- /dev/null +++ b/build/_build.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + CS0649;CS0169 + .. + .. + 1 + + + + + + + + + diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index 154e573..bbb69e6 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -26,13 +26,33 @@ The renderer works in three steps: using AngleSharp; using AngleSharp.Renderer; -var context = BrowsingContext.New(Configuration.Default.WithCss()); +var context = BrowsingContext.New(Configuration.Default.WithCss().WithRendering()); var document = await context.OpenAsync(request => request.Content("Hello")); var renderer = new HtmlRenderer(); var image = renderer.RenderToPng(document); ``` +## Canvas Support + +The renderer can also be configured to expose a basic 2D drawing context for `` elements. Register the rendering service once on the browsing context with `Configuration.Default.WithCss().WithRendering()` and then resolve the context through `canvas.GetContext("2d")`. + +```cs +using AngleSharp; +using AngleSharp.Renderer; + +var context = BrowsingContext.New(Configuration.Default.WithCss().WithRendering()); +var document = await context.OpenAsync(request => request.Content("")); + +var canvas = document.QuerySelector("canvas") as IHtmlCanvasElement; +var canvasContext = canvas?.GetContext("2d") as Canvas2DRenderingContext; + +canvasContext?.SetFillStyle("#ff0000"); +canvasContext?.FillRect(10f, 10f, 80f, 40f); +``` + +This implementation is intentionally lightweight and bitmap-backed, but it supports common operations such as rectangles, paths, text, clear operations, and save/restore state. + ## Text And Fonts The renderer supports basic text styling such as font size, weight, italic, alignment, spacing, and decoration. diff --git a/docs/tutorials/01-API.md b/docs/tutorials/01-API.md index 4357d29..1701972 100644 --- a/docs/tutorials/01-API.md +++ b/docs/tutorials/01-API.md @@ -14,6 +14,15 @@ var displayList = renderer.BuildDisplayList(document); var image = renderer.RenderToPng(document); ``` +## Rendering Configuration + +Use `Configuration.Default.WithCss().WithRendering()` when you want to enable the renderer’s canvas-aware services. The `WithRendering()` extension registers the canvas rendering service that powers the `2d` context for `` elements. + +```cs +var config = Configuration.Default.WithCss().WithRendering(); +var context = BrowsingContext.New(config); +``` + ## HtmlRenderOptions `HtmlRenderOptions` controls the viewport and default rendering behavior. @@ -32,6 +41,18 @@ The list currently includes: - filled rectangles for backgrounds, borders, and outlines, - text commands with font, alignment, spacing, and decoration metadata. +## Canvas Rendering Context + +The renderer package also ships with a basic 2D canvas implementation backed by Skia. Once the rendering service is registered, `` elements can be drawn with methods such as `FillRect`, `StrokeRect`, `ClearRect`, `BeginPath`, `MoveTo`, `LineTo`, `ClosePath`, `Fill`, `Stroke`, `FillText`, and `Save`/`Restore`. + +```cs +var canvas = document.QuerySelector("canvas") as IHtmlCanvasElement; +var canvasContext = canvas?.GetContext("2d") as Canvas2DRenderingContext; + +canvasContext?.SetFillStyle("#00ff00"); +canvasContext?.FillRect(10f, 10f, 80f, 40f); +``` + ## RenderedImage `RenderedImage` wraps the raster result and exposes the PNG bytes together with width, height, and MIME type. diff --git a/docs/tutorials/02-Examples.md b/docs/tutorials/02-Examples.md index 6500e34..2515db1 100644 --- a/docs/tutorials/02-Examples.md +++ b/docs/tutorials/02-Examples.md @@ -30,6 +30,36 @@ var image = renderer.RenderToPng(document, new HtmlRenderOptions File.WriteAllBytes("render.png", image.Data); ``` +## Draw To A Canvas + +```cs +using AngleSharp; +using AngleSharp.Renderer; +using AngleSharp.Html.Dom; + +var context = BrowsingContext.New(Configuration.Default.WithCss().WithRendering()); +var document = await context.OpenAsync(request => request.Content(""" + + + + + +""")); + +var canvas = document.QuerySelector("canvas") as IHtmlCanvasElement; +var canvasContext = canvas?.GetContext("2d") as Canvas2DRenderingContext; + +canvasContext?.SetFillStyle("#ff0000"); +canvasContext?.FillRect(10f, 10f, 100f, 50f); +canvasContext?.SetFillStyle("#00ff00"); +canvasContext?.FillRect(30f, 20f, 50f, 30f); + +var png = canvasContext?.ToImage("image/png"); +File.WriteAllBytes("canvas.png", png ?? Array.Empty()); +``` + +This example highlights the new canvas path and shows how the renderer can produce PNG output from 2D drawing operations. + ## Compare Different Font Families ```html diff --git a/nuke/_build.csproj b/nuke/_build.csproj deleted file mode 100644 index 4c5a47a..0000000 --- a/nuke/_build.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - Exe - net10.0 - - CS0649;CS0169 - .. - .. - 1 - - - - - - - - - - - diff --git a/src/AngleSharp.Renderer.Docs/package.json b/src/AngleSharp.Renderer.Docs/package.json index ba4d8fd..d7e6cee 100644 --- a/src/AngleSharp.Renderer.Docs/package.json +++ b/src/AngleSharp.Renderer.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/renderer", - "version": "0.1.0", + "version": "0.2.0", "preview": true, "description": "The doclet for the AngleSharp.Renderer documentation.", "keywords": [ diff --git a/src/AngleSharp.Renderer.Tests/Canvas2DRenderingContextTests.cs b/src/AngleSharp.Renderer.Tests/Canvas2DRenderingContextTests.cs new file mode 100644 index 0000000..ec398e6 --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/Canvas2DRenderingContextTests.cs @@ -0,0 +1,162 @@ +namespace AngleSharp.Renderer.Tests; + +using AngleSharp; +using AngleSharp.Html.Dom; +using SkiaSharp; + +public sealed class Canvas2DRenderingContextTests +{ + [Fact] + public async Task FillRectAndClearRect_ChangePixelContent() + { + var context = await CreateContextAsync(); + + context.SetFillStyle("#ff0000"); + context.FillRect(0f, 0f, 100f, 100f); + + var filledPixels = CountNonTransparentPixels(context.ToImage("image/png")); + Assert.True(filledPixels > 100, $"Expected fill to produce visible pixels, but found {filledPixels}."); + + context.ClearRect(0f, 0f, 100f, 100f); + + var clearedPixels = CountNonTransparentPixels(context.ToImage("image/png")); + Assert.True(clearedPixels < filledPixels, $"Expected clearRect to reduce visible pixels from {filledPixels} to {clearedPixels}."); + } + + [Fact] + public async Task TextAndPathDrawing_LeaveVisiblePixels() + { + var context = await CreateContextAsync(); + + context.SetFillStyle("#00ff00"); + context.SetStrokeStyle("#0000ff"); + context.SetLineWidth(2f); + context.SetFont("20px sans-serif"); + + context.BeginPath(); + context.MoveTo(10f, 10f); + context.LineTo(90f, 10f); + context.LineTo(90f, 90f); + context.ClosePath(); + context.Fill(); + context.Stroke(); + context.FillText("Canvas", 12f, 70f); + + var pixelCount = CountNonTransparentPixels(context.ToImage("image/png")); + Assert.True(pixelCount > 100, $"Expected text and path drawing to produce visible pixels, but found {pixelCount}."); + } + + [Fact] + public async Task SaveAndRestore_PreserveAndRestoreDrawingState() + { + var context = await CreateContextAsync(); + + context.SetFillStyle("#ff0000"); + context.FillRect(0f, 0f, 50f, 50f); + + context.Save(); + context.SetFillStyle("#0000ff"); + context.FillRect(50f, 0f, 50f, 50f); + context.Restore(); + context.FillRect(25f, 50f, 50f, 50f); + + var image = context.ToImage("image/png"); + var bitmap = SKBitmap.Decode(image); + Assert.NotNull(bitmap); + + Assert.True(ContainsColor(bitmap, new SKColor(255, 0, 0, 255))); + Assert.True(ContainsColor(bitmap, new SKColor(0, 0, 255, 255))); + } + + [Fact] + public async Task CanvasContext2D_InterfaceSupportsStateAndDimensions() + { + var context = await CreateContextAsync(); + var canvasContext = Assert.IsAssignableFrom(context); + + Assert.Same(context.Host, canvasContext.Canvas); + Assert.Equal(context.Host.Width, canvasContext.Width); + Assert.Equal(context.Host.Height, canvasContext.Height); + + canvasContext.SaveState(); + canvasContext.Width = 120; + canvasContext.Height = 80; + Assert.Equal(120, canvasContext.Width); + Assert.Equal(80, canvasContext.Height); + canvasContext.RestoreState(); + + Assert.Equal(100, canvasContext.Width); + Assert.Equal(100, canvasContext.Height); + } + + [Fact] + public async Task Translate_AffectsSubsequentDrawingOperations() + { + var context = await CreateContextAsync(); + + context.SetFillStyle("#00ff00"); + context.Translate(10f, 10f); + context.FillRect(0f, 0f, 20f, 20f); + + var image = context.ToImage("image/png"); + var bitmap = SKBitmap.Decode(image); + Assert.NotNull(bitmap); + + Assert.True(ContainsColor(bitmap, new SKColor(0, 255, 0, 255))); + } + + private static async Task CreateContextAsync() + { + var document = await ParseAsync(""); + var canvas = document.QuerySelector("canvas") as IHtmlCanvasElement; + Assert.NotNull(canvas); + return new Canvas2DRenderingContext(canvas!); + } + + private static async Task ParseAsync(string html) + { + var context = BrowsingContext.New(Configuration.Default.WithCss().WithRendering()); + return await context.OpenAsync(request => request.Content(html)); + } + + private static int CountNonTransparentPixels(byte[] imageData) + { + using var bitmap = SKBitmap.Decode(imageData); + var count = 0; + + for (var y = 0; y < bitmap.Height; y++) + { + for (var x = 0; x < bitmap.Width; x++) + { + var pixel = bitmap.GetPixel(x, y); + if (pixel.Alpha > 0) + { + count++; + } + } + } + + return count; + } + + private static bool ContainsColor(SKBitmap bitmap, SKColor targetColor) + { + for (var y = 0; y < bitmap.Height; y++) + { + for (var x = 0; x < bitmap.Width; x++) + { + var pixel = bitmap.GetPixel(x, y); + + if (pixel.Red == targetColor.Red && + pixel.Green == targetColor.Green && + pixel.Blue == targetColor.Blue && + pixel.Alpha == targetColor.Alpha) + { + return true; + } + } + } + + return false; + } +} diff --git a/src/AngleSharp.Renderer.Tests/CanvasRenderingServiceTests.cs b/src/AngleSharp.Renderer.Tests/CanvasRenderingServiceTests.cs new file mode 100644 index 0000000..276db1a --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/CanvasRenderingServiceTests.cs @@ -0,0 +1,25 @@ +namespace AngleSharp.Renderer.Tests; + +using AngleSharp; +using AngleSharp.Html.Dom; + +public sealed class CanvasRenderingServiceTests +{ + [Fact] + public async Task GetContext_ProvidesA2DRenderingContextForCanvasElements() + { + var context = BrowsingContext.New(Configuration.Default.WithCss().WithRendering()); + var document = await context.OpenAsync(request => request.Content("")); + + var canvas = document.QuerySelector("canvas") as IHtmlCanvasElement; + + Assert.NotNull(canvas); + + var renderingContext = canvas!.GetContext("2d"); + + Assert.NotNull(renderingContext); + Assert.Equal("2d", renderingContext.ContextId); + Assert.False(renderingContext.IsFixed); + Assert.NotEmpty(renderingContext.ToImage("image/png")); + } +} diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index 1f3b84b..1f2c771 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -88,6 +88,97 @@ one two three four five six seven eight nine ten eleven twelve Assert.True(textCommands[1].Y - textCommands[0].Y >= 20f); } + [Fact] + public async Task BuildDisplayList_AppliesColspanToCellGeometry() + { + var document = await ParseAsync(""" + + + + + + + + + +
Header
AB
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 240, + Height = 160, + FontSize = 16f, + }); + + var headerBackground = displayList.Commands + .OfType() + .FirstOrDefault(command => command.Rect.Width > 100f); + + Assert.NotNull(headerBackground); + Assert.True(headerBackground!.Rect.Width > 100f); + } + + [Fact] + public async Task BuildDisplayList_CollapsesAdjacentCellBordersWhenRequested() + { + var document = await ParseAsync(""" + + + + +
AB
CD
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 240, + Height = 160, + FontSize = 16f, + }); + + var borderCommands = displayList.Commands + .OfType() + .Count(command => command.Color == RenderColor.Black); + + Assert.True(borderCommands < 10, $"Expected collapsed borders to reduce border commands, but found {borderCommands}."); + } + + [Fact] + public async Task BuildDisplayList_UsesColumnWidthsFromColgroup() + { + var document = await ParseAsync(""" + + + + + + + + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 240, + Height = 160, + FontSize = 16f, + }); + + var fills = displayList.Commands.OfType().ToArray(); + var cellBackground = fills.FirstOrDefault(command => command.Color == new RenderColor(255, 0, 0)); + + Assert.NotNull(cellBackground); + 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_AppliesLetterSpacingToTextCommands() { diff --git a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs index 4a45688..0a6bf36 100644 --- a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs +++ b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs @@ -1,8 +1,9 @@ -using AngleSharp; -using AngleSharp.Css; - namespace AngleSharp.Renderer.Tests; +using AngleSharp; +using AngleSharp.Html.Dom; + +[Trait("Category", "Visual")] public sealed class VisualConformanceTests { [Fact] @@ -31,7 +32,7 @@ public async Task RenderToPng_PaintsBoxBackgroundAndBorderAtExpectedPixels() VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "paints-box-background-and-border.png", actualPng: image.Data, - perChannelTolerance: 2, + perChannelTolerance: 0, maxDifferentPixels: 0); } @@ -61,7 +62,7 @@ public async Task RenderToPng_CentersAutoMarginBlock() VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "centers-auto-margin-block.png", actualPng: image.Data, - perChannelTolerance: 2, + perChannelTolerance: 0, maxDifferentPixels: 0); } @@ -92,10 +93,204 @@ public async Task RenderToPng_ShowsCollapsedVerticalMarginGap() VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "shows-collapsed-vertical-margin-gap.png", actualPng: image.Data, - perChannelTolerance: 2, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersSimpleTableLayout() + { + var document = await ParseAsync(""" + + + + + + + + +
AB
CD
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new HtmlRenderOptions + { + Width = 180, + Height = 120, + Padding = 0f, + ParagraphSpacing = 0f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-simple-table-layout.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersTableCellBordersAndWidths() + { + var document = await ParseAsync(""" + + + + + + + + + + +
LeftRight
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new HtmlRenderOptions + { + Width = 220, + Height = 120, + Padding = 0f, + ParagraphSpacing = 0f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-table-cell-borders-and-widths.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersTableWithColspanAndRowspan() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + +
Header
LeftRight
Bottom
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new HtmlRenderOptions + { + Width = 220, + Height = 140, + Padding = 0f, + ParagraphSpacing = 0f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-table-with-colspan-and-rowspan.png", + actualPng: image.Data, + perChannelTolerance: 0, maxDifferentPixels: 0); } + [Fact] + public async Task RenderToPng_RendersCanvasRectanglesAndClearRect() + { + var image = await RenderCanvasSnapshotAsync(""" + + + + + + """, context => + { + context.SetFillStyle("#ff0000"); + context.FillRect(10f, 10f, 70f, 40f); + context.SetFillStyle("#00ff00"); + context.FillRect(40f, 30f, 45f, 30f); + context.ClearRect(25f, 20f, 45f, 25f); + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-canvas-rectangles-and-clear-rect.png", + actualPng: image, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersCanvasPathAndStroke() + { + var image = await RenderCanvasSnapshotAsync(""" + + + + + + """, context => + { + context.SetFillStyle("#00ff00"); + context.SetStrokeStyle("#0000ff"); + context.SetLineWidth(2f); + context.SetFont("20px sans-serif"); + context.BeginPath(); + context.MoveTo(10f, 10f); + context.LineTo(110f, 10f); + context.LineTo(110f, 90f); + context.ClosePath(); + context.Fill(); + context.Stroke(); + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-canvas-path-and-text.png", + actualPng: image, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersCanvasTranslationAndState() + { + var image = await RenderCanvasSnapshotAsync(""" + + + + + + """, context => + { + context.SetFillStyle("#ff0000"); + context.FillRect(10f, 10f, 30f, 30f); + context.Save(); + context.Translate(20f, 0f); + context.SetFillStyle("#0000ff"); + context.FillRect(10f, 10f, 30f, 30f); + context.Restore(); + context.SetFillStyle("#00ff00"); + context.FillRect(50f, 50f, 30f, 30f); + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-canvas-translation-and-state.png", + actualPng: image, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + [Fact] public async Task RenderToPng_PaintsAbsolutePositionedElementOutOfFlow() { @@ -125,7 +320,7 @@ public async Task RenderToPng_PaintsAbsolutePositionedElementOutOfFlow() VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "absolute-positioned-out-of-flow.png", actualPng: image.Data, - perChannelTolerance: 2, + perChannelTolerance: 0, maxDifferentPixels: 0); } @@ -158,7 +353,7 @@ public async Task RenderToPng_PaintsHigherZIndexAboveLowerZIndex() VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "higher-z-index-over-lower-z-index.png", actualPng: image.Data, - perChannelTolerance: 2, + perChannelTolerance: 0, maxDifferentPixels: 0); } @@ -190,7 +385,7 @@ public async Task RenderToPng_PaintsNegativeZIndexBehindInFlowContent() VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "negative-z-index-behind-in-flow.png", actualPng: image.Data, - perChannelTolerance: 2, + perChannelTolerance: 0, maxDifferentPixels: 0); } @@ -227,7 +422,7 @@ public async Task RenderToPng_RendersMixedTextSizesStylesAndDecorations() VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "mixed-text-sizes-styles-decorations.png", actualPng: image.Data, - perChannelTolerance: 3, + perChannelTolerance: 0, maxDifferentPixels: 0); } @@ -263,7 +458,7 @@ one two three four five six seven eight nine ten eleven twelve thirteen fourteen VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "aligned-wrapped-text-with-line-height.png", actualPng: image.Data, - perChannelTolerance: 3, + perChannelTolerance: 0, maxDifferentPixels: 0); } @@ -299,7 +494,7 @@ Decoration test VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "decoration-color-and-style.png", actualPng: image.Data, - perChannelTolerance: 3, + perChannelTolerance: 0, maxDifferentPixels: 0); } @@ -338,7 +533,7 @@ Indented text that wraps to a second line. VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "text-indent-and-vertical-align.png", actualPng: image.Data, - perChannelTolerance: 3, + perChannelTolerance: 0, maxDifferentPixels: 0); } @@ -368,10 +563,25 @@ public async Task RenderToPng_RendersWebSafeFontFamiliesDifferently() VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "web-safe-font-families.png", actualPng: image.Data, - perChannelTolerance: 3, + perChannelTolerance: 0, maxDifferentPixels: 0); } + private static async Task RenderCanvasSnapshotAsync(string html, Action draw) + { + var context = BrowsingContext.New(Configuration.Default.WithCss().WithRendering()); + var document = await context.OpenAsync(request => request.Content(html)); + var canvas = document.QuerySelector("canvas") as IHtmlCanvasElement; + + Assert.NotNull(canvas); + + var renderingContext = canvas!.GetContext("2d"); + var canvasContext = Assert.IsType(renderingContext); + draw(canvasContext); + + return canvasContext.ToImage("image/png"); + } + private static async Task ParseAsync(string html) { var context = BrowsingContext.New(Configuration.Default.WithCss()); diff --git a/src/AngleSharp.Renderer.Tests/VisualSnapshotVerifier.cs b/src/AngleSharp.Renderer.Tests/VisualSnapshotVerifier.cs index 9fbaecc..cb2f85b 100644 --- a/src/AngleSharp.Renderer.Tests/VisualSnapshotVerifier.cs +++ b/src/AngleSharp.Renderer.Tests/VisualSnapshotVerifier.cs @@ -21,9 +21,10 @@ public static void VerifyOrCreate( Directory.CreateDirectory(verificationAssetsPath); Directory.CreateDirectory(failureAssetsPath); - var baselinePath = Path.Combine(verificationAssetsPath, snapshotName); - var failurePath = Path.Combine(failureAssetsPath, snapshotName); - var diffPath = Path.Combine(failureAssetsPath, Path.GetFileNameWithoutExtension(snapshotName) + ".diff.png"); + var platformSnapshotName = GetPlatformSnapshotName(snapshotName); + var baselinePath = Path.Combine(verificationAssetsPath, platformSnapshotName); + var failurePath = Path.Combine(failureAssetsPath, platformSnapshotName); + var diffPath = Path.Combine(failureAssetsPath, Path.GetFileNameWithoutExtension(platformSnapshotName) + ".diff.png"); if (!File.Exists(baselinePath)) { @@ -32,7 +33,7 @@ public static void VerifyOrCreate( File.WriteAllBytes(failurePath, actualPng); throw new XunitException( - $"Missing baseline snapshot '{snapshotName}' while strict mode is enabled ({StrictModeEnvironmentVariable}=1). " + + $"Missing baseline snapshot '{platformSnapshotName}' while strict mode is enabled ({StrictModeEnvironmentVariable}=1). " + $"Create baseline at: {baselinePath}. Actual output written to: {failurePath}."); } @@ -62,7 +63,7 @@ public static void VerifyOrCreate( File.WriteAllBytes(diffPath, comparison.DiffPng); throw new XunitException( - $"Visual snapshot mismatch for '{snapshotName}'. " + + $"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}), " + @@ -70,6 +71,41 @@ public static void VerifyOrCreate( $"Baseline: {baselinePath}. Failure output: {failurePath}. Diff output: {diffPath}."); } + private static string GetPlatformSnapshotName(string snapshotName) + { + var platformSuffix = GetPlatformSuffix(); + var directory = Path.GetDirectoryName(snapshotName); + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(snapshotName); + var extension = Path.GetExtension(snapshotName); + var platformFileName = string.IsNullOrEmpty(extension) + ? $"{fileNameWithoutExtension}.{platformSuffix}" + : $"{fileNameWithoutExtension}.{platformSuffix}{extension}"; + + return string.IsNullOrEmpty(directory) + ? platformFileName + : Path.Combine(directory, platformFileName); + } + + private static string GetPlatformSuffix() + { + if (OperatingSystem.IsWindows()) + { + return "windows"; + } + + if (OperatingSystem.IsLinux()) + { + return "linux"; + } + + if (OperatingSystem.IsMacOS()) + { + return "macos"; + } + + return Environment.OSVersion.Platform.ToString().ToLowerInvariant(); + } + private static bool IsStrictModeEnabled() { var strictModeValue = Environment.GetEnvironmentVariable(StrictModeEnvironmentVariable); diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/absolute-positioned-out-of-flow.png b/src/AngleSharp.Renderer.Tests/verification-assets/absolute-positioned-out-of-flow.linux.png similarity index 100% rename from src/AngleSharp.Renderer.Tests/verification-assets/absolute-positioned-out-of-flow.png rename to src/AngleSharp.Renderer.Tests/verification-assets/absolute-positioned-out-of-flow.linux.png diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/absolute-positioned-out-of-flow.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/absolute-positioned-out-of-flow.macos.png new file mode 100644 index 0000000..9e1558d Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/absolute-positioned-out-of-flow.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/absolute-positioned-out-of-flow.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/absolute-positioned-out-of-flow.windows.png new file mode 100644 index 0000000..9e1558d Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/absolute-positioned-out-of-flow.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.png b/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.linux.png similarity index 100% rename from src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.png rename to src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.linux.png 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 new file mode 100644 index 0000000..6a4e80b Binary files /dev/null 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 new file mode 100644 index 0000000..6ca3451 Binary files /dev/null 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/centers-auto-margin-block.png b/src/AngleSharp.Renderer.Tests/verification-assets/centers-auto-margin-block.linux.png similarity index 100% rename from src/AngleSharp.Renderer.Tests/verification-assets/centers-auto-margin-block.png rename to src/AngleSharp.Renderer.Tests/verification-assets/centers-auto-margin-block.linux.png diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/centers-auto-margin-block.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/centers-auto-margin-block.macos.png new file mode 100644 index 0000000..399024e Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/centers-auto-margin-block.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/centers-auto-margin-block.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/centers-auto-margin-block.windows.png new file mode 100644 index 0000000..399024e Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/centers-auto-margin-block.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/decoration-color-and-style.png b/src/AngleSharp.Renderer.Tests/verification-assets/decoration-color-and-style.linux.png similarity index 100% rename from src/AngleSharp.Renderer.Tests/verification-assets/decoration-color-and-style.png rename to src/AngleSharp.Renderer.Tests/verification-assets/decoration-color-and-style.linux.png diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/decoration-color-and-style.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/decoration-color-and-style.macos.png new file mode 100644 index 0000000..35e05f1 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/decoration-color-and-style.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/decoration-color-and-style.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/decoration-color-and-style.windows.png new file mode 100644 index 0000000..7089df6 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/decoration-color-and-style.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/higher-z-index-over-lower-z-index.png b/src/AngleSharp.Renderer.Tests/verification-assets/higher-z-index-over-lower-z-index.linux.png similarity index 100% rename from src/AngleSharp.Renderer.Tests/verification-assets/higher-z-index-over-lower-z-index.png rename to src/AngleSharp.Renderer.Tests/verification-assets/higher-z-index-over-lower-z-index.linux.png diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/higher-z-index-over-lower-z-index.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/higher-z-index-over-lower-z-index.macos.png new file mode 100644 index 0000000..e1eab6f Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/higher-z-index-over-lower-z-index.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/higher-z-index-over-lower-z-index.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/higher-z-index-over-lower-z-index.windows.png new file mode 100644 index 0000000..e1eab6f Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/higher-z-index-over-lower-z-index.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.linux.png new file mode 100644 index 0000000..7a89794 Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.macos.png new file mode 100644 index 0000000..f254598 Binary files /dev/null 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.png b/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.png deleted file mode 100644 index 83f657d..0000000 Binary files a/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.png and /dev/null 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 new file mode 100644 index 0000000..cbf7a90 Binary files /dev/null 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/negative-z-index-behind-in-flow.png b/src/AngleSharp.Renderer.Tests/verification-assets/negative-z-index-behind-in-flow.linux.png similarity index 100% rename from src/AngleSharp.Renderer.Tests/verification-assets/negative-z-index-behind-in-flow.png rename to src/AngleSharp.Renderer.Tests/verification-assets/negative-z-index-behind-in-flow.linux.png diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/negative-z-index-behind-in-flow.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/negative-z-index-behind-in-flow.macos.png new file mode 100644 index 0000000..9831acc Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/negative-z-index-behind-in-flow.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/negative-z-index-behind-in-flow.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/negative-z-index-behind-in-flow.windows.png new file mode 100644 index 0000000..9831acc Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/negative-z-index-behind-in-flow.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/paints-box-background-and-border.png b/src/AngleSharp.Renderer.Tests/verification-assets/paints-box-background-and-border.linux.png similarity index 100% rename from src/AngleSharp.Renderer.Tests/verification-assets/paints-box-background-and-border.png rename to src/AngleSharp.Renderer.Tests/verification-assets/paints-box-background-and-border.linux.png diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/paints-box-background-and-border.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/paints-box-background-and-border.macos.png new file mode 100644 index 0000000..d650efe Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/paints-box-background-and-border.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/paints-box-background-and-border.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/paints-box-background-and-border.windows.png new file mode 100644 index 0000000..d650efe Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/paints-box-background-and-border.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-path-and-text.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-path-and-text.linux.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.linux.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 new file mode 100644 index 0000000..c361fce Binary files /dev/null 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-rectangles-and-clear-rect.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-rectangles-and-clear-rect.linux.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.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-rectangles-and-clear-rect.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-rectangles-and-clear-rect.macos.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.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-translation-and-state.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-translation-and-state.linux.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.linux.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-translation-and-state.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-translation-and-state.macos.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.macos.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 new file mode 100644 index 0000000..0de1fc9 Binary files /dev/null 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 new file mode 100644 index 0000000..291fb49 Binary files /dev/null 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-table-cell-borders-and-widths.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.linux.png new file mode 100644 index 0000000..7239418 Binary files /dev/null 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 new file mode 100644 index 0000000..5af5c73 Binary files /dev/null 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-with-colspan-and-rowspan.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.linux.png new file mode 100644 index 0000000..5809662 Binary files /dev/null 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 new file mode 100644 index 0000000..2cac1c1 Binary files /dev/null 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/shows-collapsed-vertical-margin-gap.png b/src/AngleSharp.Renderer.Tests/verification-assets/shows-collapsed-vertical-margin-gap.linux.png similarity index 100% rename from src/AngleSharp.Renderer.Tests/verification-assets/shows-collapsed-vertical-margin-gap.png rename to src/AngleSharp.Renderer.Tests/verification-assets/shows-collapsed-vertical-margin-gap.linux.png diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/shows-collapsed-vertical-margin-gap.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/shows-collapsed-vertical-margin-gap.macos.png new file mode 100644 index 0000000..a33392f Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/shows-collapsed-vertical-margin-gap.macos.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/shows-collapsed-vertical-margin-gap.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/shows-collapsed-vertical-margin-gap.windows.png new file mode 100644 index 0000000..a33392f Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/shows-collapsed-vertical-margin-gap.windows.png differ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.png b/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.linux.png similarity index 100% rename from src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.png rename to src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.linux.png 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 new file mode 100644 index 0000000..1990c77 Binary files /dev/null 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 new file mode 100644 index 0000000..b79e191 Binary files /dev/null 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.png b/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.linux.png similarity index 100% rename from src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.png rename to src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.linux.png 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 new file mode 100644 index 0000000..75f12a3 Binary files /dev/null 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 new file mode 100644 index 0000000..4074e5e Binary files /dev/null and b/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.windows.png differ diff --git a/src/AngleSharp.Renderer.nuspec b/src/AngleSharp.Renderer.nuspec deleted file mode 100644 index 28f083b..0000000 --- a/src/AngleSharp.Renderer.nuspec +++ /dev/null @@ -1,23 +0,0 @@ - - - - AngleSharp.Renderer - $version$ - AngleSharp - Florian Rappl - MIT - - https://anglesharp.github.io - logo.png - README.md - false - Adds rendering functionality to the core AngleSharp library. - https://github.com/AngleSharp/AngleSharp.Renderer/blob/main/CHANGELOG.md - Copyright 2016-2026, AngleSharp - html html5 css css3 dom rendering canvas library anglesharp angle - - - - - - diff --git a/src/AngleSharp.Renderer.sln b/src/AngleSharp.Renderer.sln index 2d17b76..fcbbe8a 100644 --- a/src/AngleSharp.Renderer.sln +++ b/src/AngleSharp.Renderer.sln @@ -7,7 +7,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AngleSharp.Renderer", "Angl EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{988B2C40-782B-4128-9551-7AE65B60F903}" ProjectSection(SolutionItems) = preProject - AngleSharp.Css.nuspec = AngleSharp.Css.nuspec Directory.Build.props = Directory.Build.props EndProjectSection EndProject diff --git a/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj b/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj index 277c829..030e078 100644 --- a/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj +++ b/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj @@ -5,13 +5,22 @@ net8.0;net10.0 true enable + + + + https://anglesharp.github.io + MIT + logo.png + README.md + html;html5;css;css3;dom;rendering;canvas;library;anglesharp;angle + https://github.com/AngleSharp/AngleSharp.Renderer/blob/main/CHANGELOG.md https://github.com/AngleSharp/AngleSharp.Renderer git true true true snupkg - 1.* + 1.5.0 @@ -19,12 +28,27 @@ - - + + + + + + + + + + + + + + <_Parameter1>false + + + false diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 1408f8b..5c037f6 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -204,6 +204,14 @@ private static void LayoutElement( return; } + var display = GetDisplay(styleMap); + + 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); + return; + } + var renderAsBlock = ShouldRenderAsBlock(computedStyle); var isInlineBlock = IsInlineBlock(computedStyle); var currentTextStyle = ResolveTextStyle(styleMap, inheritedTextStyle); @@ -536,6 +544,355 @@ private static void LayoutElement( previousBlockMarginBottom = effectiveMarginBottom + options.ParagraphSpacing; } + private static IEnumerable CollectTableRows(ElementRenderNode tableNode) + { + foreach (var child in tableNode.Children) + { + if (child is ElementRenderNode childElement) + { + if (string.Equals(childElement.Ref.LocalName, "tr", StringComparison.OrdinalIgnoreCase)) + { + yield return childElement; + } + + foreach (var descendant in CollectTableRows(childElement)) + { + yield return descendant; + } + } + } + } + + private static void LayoutTable( + ElementRenderNode tableNode, + 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, + HtmlRenderOptions options, + DisplayList displayList, + float maxY) + { + var tableStyle = CreateStyleMap(tableNode.ComputedStyle); + var specifiedWidth = ParseLength(tableStyle, "width", containingWidth, float.NaN, allowAuto: true); + var availableWidth = float.IsNaN(specifiedWidth) ? containingWidth : specifiedWidth; + var borderCollapse = string.Equals(tableStyle.TryGetValue("border-collapse", out var borderCollapseValue) ? borderCollapseValue : null, "collapse", StringComparison.OrdinalIgnoreCase); + + var rows = CollectTableRows(tableNode).ToList(); + var colgroup = tableNode.Children + .Where(child => child is ElementRenderNode { Ref.LocalName: var localName } && string.Equals(localName, "colgroup", StringComparison.OrdinalIgnoreCase)) + .Cast() + .FirstOrDefault(); + var columnSpecs = new List<(int ColumnIndex, float Width)>(); + + if (colgroup is not null) + { + var columnNodes = colgroup.Children + .Where(child => child is ElementRenderNode { Ref.LocalName: var localName } && string.Equals(localName, "col", StringComparison.OrdinalIgnoreCase)) + .Cast() + .ToList(); + + for (var index = 0; index < columnNodes.Count; index++) + { + var columnStyle = CreateStyleMap(columnNodes[index].ComputedStyle); + var specifiedColumnWidth = ParseLength(columnStyle, "width", availableWidth, float.NaN, allowAuto: true); + if (!float.IsNaN(specifiedColumnWidth)) + { + columnSpecs.Add((index, specifiedColumnWidth)); + } + } + } + + if (rows.Count == 0) + { + cursorY += inheritedTextStyle.FontSize * inheritedTextStyle.LineHeightMultiplier; + return; + } + + var rowCellLists = rows + .Select(row => row.Children + .Where(child => child is ElementRenderNode cellNode && (string.Equals(cellNode.Ref.LocalName, "td", StringComparison.OrdinalIgnoreCase) || string.Equals(cellNode.Ref.LocalName, "th", StringComparison.OrdinalIgnoreCase))) + .Cast() + .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 rowSpanOccupancy = new List(); + var columnCount = 0; + + for (var rowIndex = 0; rowIndex < rowCellLists.Count; rowIndex++) + { + var cells = rowCellLists[rowIndex]; + var currentRowOccupied = new List(); + var nextRowSpanOccupancy = new List(); + var currentColumnIndex = 0; + + foreach (var cellNode in cells) + { + while (true) + { + while (currentColumnIndex >= currentRowOccupied.Count) + { + currentRowOccupied.Add(0); + } + + while (currentColumnIndex >= rowSpanOccupancy.Count) + { + rowSpanOccupancy.Add(0); + } + + if (rowSpanOccupancy[currentColumnIndex] == 0 && currentRowOccupied[currentColumnIndex] == 0) + { + break; + } + + currentColumnIndex++; + } + + var cellStyle = CreateStyleMap(cellNode.ComputedStyle); + var cellTextStyle = ResolveTextStyle(cellStyle, inheritedTextStyle); + var text = NormalizeWhitespace(cellNode.Ref.TextContent ?? string.Empty); + var colspan = 1; + var rowspan = 1; + + if (int.TryParse(cellNode.Ref.GetAttribute("colspan"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedColspan) && parsedColspan > 0) + { + colspan = parsedColspan; + } + + if (int.TryParse(cellNode.Ref.GetAttribute("rowspan"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedRowspan) && parsedRowspan > 0) + { + rowspan = parsedRowspan; + } + + for (var spanOffset = 0; spanOffset < colspan; spanOffset++) + { + while (currentColumnIndex + spanOffset >= currentRowOccupied.Count) + { + currentRowOccupied.Add(0); + } + + currentRowOccupied[currentColumnIndex + spanOffset] = 1; + } + + for (var spanOffset = 0; spanOffset < colspan; spanOffset++) + { + while (currentColumnIndex + spanOffset >= nextRowSpanOccupancy.Count) + { + nextRowSpanOccupancy.Add(0); + } + + nextRowSpanOccupancy[currentColumnIndex + spanOffset] = Math.Max(nextRowSpanOccupancy[currentColumnIndex + spanOffset], Math.Max(0, rowspan - 1)); + } + + tableCells.Add((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), + ParseLength(cellStyle, "padding-bottom", containingWidth, 4f, allowAuto: false), + ParseLength(cellStyle, "border-left-width", containingWidth, 1f, allowAuto: false), + 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))); + + columnCount = Math.Max(columnCount, currentColumnIndex + colspan); + currentColumnIndex += colspan; + } + + foreach (var index in Enumerable.Range(0, rowSpanOccupancy.Count)) + { + if (rowSpanOccupancy[index] > 0) + { + nextRowSpanOccupancy[index] = Math.Max(nextRowSpanOccupancy[index], rowSpanOccupancy[index] - 1); + } + } + + rowSpanOccupancy = nextRowSpanOccupancy; + } + + if (columnCount <= 0) + { + cursorY += inheritedTextStyle.FontSize * inheritedTextStyle.LineHeightMultiplier; + return; + } + + var columnMinWidths = new float[columnCount]; + foreach (var placement in tableCells) + { + var specifiedCellWidth = ParseLength(placement.CellStyle, "width", availableWidth, float.NaN, allowAuto: true); + var paddingLeft = placement.PaddingLeft; + 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 minCellWidth = textWidth + paddingLeft + paddingRight + borderLeftWidth + borderRightWidth + 8f; + var widthPerColumn = float.IsNaN(specifiedCellWidth) ? minCellWidth / Math.Max(1, placement.ColumnSpan) : specifiedCellWidth / Math.Max(1, placement.ColumnSpan); + + for (var spanOffset = 0; spanOffset < placement.ColumnSpan; spanOffset++) + { + var columnIndex = placement.ColumnIndex + spanOffset; + columnMinWidths[columnIndex] = Math.Max(columnMinWidths[columnIndex], widthPerColumn); + } + } + + foreach (var (columnIndex, columnWidth) in columnSpecs) + { + if (columnIndex < columnMinWidths.Length) + { + columnMinWidths[columnIndex] = Math.Max(columnMinWidths[columnIndex], columnWidth); + } + } + + var totalMinWidth = columnMinWidths.Sum(); + var tableWidth = Math.Max(availableWidth, totalMinWidth); + var columnWidths = new float[columnCount]; + + if (availableWidth > totalMinWidth) + { + var extraWidth = availableWidth - totalMinWidth; + var extraPerColumn = extraWidth / Math.Max(1, columnCount); + for (var columnIndex = 0; columnIndex < columnCount; columnIndex++) + { + columnWidths[columnIndex] = columnMinWidths[columnIndex] + extraPerColumn; + } + } + else if (availableWidth > 0f && totalMinWidth > availableWidth) + { + var scale = availableWidth / totalMinWidth; + for (var columnIndex = 0; columnIndex < columnCount; columnIndex++) + { + columnWidths[columnIndex] = columnMinWidths[columnIndex] * scale; + } + } + else + { + columnWidths = columnMinWidths.ToArray(); + } + + var tableX = containingX; + var tableY = cursorY; + var rowTopOffsets = new float[rowCellLists.Count]; + var rowHeights = new float[rowCellLists.Count]; + + foreach (var placement in tableCells) + { + 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; + + 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; + } + + 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++) + { + rowHeights[rowIndex] = Math.Max(rowHeights[rowIndex], effectiveHeight); + } + } + + var currentRowTop = 0f; + for (var rowIndex = 0; rowIndex < rowCellLists.Count; rowIndex++) + { + rowTopOffsets[rowIndex] = currentRowTop; + currentRowTop += rowHeights[rowIndex]; + } + + var columnLefts = new float[columnCount]; + var currentColumnLeft = 0f; + for (var columnIndex = 0; columnIndex < columnCount; columnIndex++) + { + columnLefts[columnIndex] = currentColumnLeft; + currentColumnLeft += columnWidths[columnIndex]; + } + + var tableHeight = currentRowTop; + foreach (var placement in tableCells) + { + var contentWidth = Math.Max(0f, columnWidths.Skip(placement.ColumnIndex).Take(placement.ColumnSpan).Sum() - placement.PaddingLeft - placement.PaddingRight - placement.BorderLeftWidth - placement.BorderRightWidth); + var cellX = tableX + columnLefts[placement.ColumnIndex]; + var cellY = tableY + rowTopOffsets[placement.RowIndex]; + var cellWidth = columnWidths.Skip(placement.ColumnIndex).Take(placement.ColumnSpan).Sum(); + var cellHeight = 0f; + for (var rowIndex = placement.RowIndex; rowIndex < placement.RowIndex + placement.RowSpan; rowIndex++) + { + cellHeight += rowHeights[rowIndex]; + } + + var contentHeight = 0f; + if (contentWidth > 0f && placement.Text.Length > 0) + { + 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; + } + + 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) + { + 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); + } + + if (contentWidth > 0f && placement.Text.Length > 0) + { + var wrappedLines = WrapText(placement.Text, contentWidth, placement.CellTextStyle.FontSize, options.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing); + var lineHeight = placement.CellTextStyle.FontSize * placement.CellTextStyle.LineHeightMultiplier; + var lineX = cellX + placement.PaddingLeft + placement.BorderLeftWidth; + var lineY = cellY + placement.PaddingTop + placement.BorderTopWidth + lineHeight; + + for (var lineIndex = 0; lineIndex < wrappedLines.Count; lineIndex++) + { + var line = wrappedLines[lineIndex]; + displayList.DrawText(line, lineX, lineY + (lineIndex * lineHeight), placement.CellTextStyle.Color, placement.CellTextStyle.FontSize, placement.CellTextStyle.FontFamily, placement.CellTextStyle.FontWeight, placement.CellTextStyle.IsItalic, placement.CellTextStyle.Underline, placement.CellTextStyle.StrikeThrough, placement.CellTextStyle.DecorationColor, placement.CellTextStyle.DecorationStyle, placement.CellTextStyle.LetterSpacing); + } + } + } + + if (borderCollapse) + { + var collapsedBorderWidth = 1f; + + 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); + + var currentVerticalX = tableX; + for (var columnIndex = 1; columnIndex < columnCount; columnIndex++) + { + currentVerticalX += columnWidths[columnIndex - 1]; + displayList.FillRect(new RenderRect(currentVerticalX, tableY, collapsedBorderWidth, tableHeight), RenderColor.Black); + } + + var currentHorizontalY = tableY; + for (var rowIndex = 1; rowIndex < rowCellLists.Count; rowIndex++) + { + currentHorizontalY += rowHeights[rowIndex - 1]; + displayList.FillRect(new RenderRect(tableX, currentHorizontalY, tableWidth, collapsedBorderWidth), RenderColor.Black); + } + } + + displayList.FillRect(new RenderRect(tableX, tableY, tableWidth, tableHeight), RenderColor.Transparent); + cursorY = tableY + tableHeight + 4f; + previousBlockMarginBottom = 0f; + suppressNextBlockTopMargin = false; + } + private static void LayoutTextNode( IText textNode, float containingX, @@ -671,6 +1028,11 @@ private static void LayoutInlineTextRun( } } + private static string? GetDisplay(Dictionary styleMap) + { + return styleMap.TryGetValue("display", out var display) ? display : null; + } + private static bool ShouldRenderAsBlock(ICssStyleDeclaration computedStyle) { var display = computedStyle.GetDisplay(); @@ -897,6 +1259,7 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "border-right-width", style.GetBorderRightWidth()); AddIfPresent(map, "border-bottom-width", style.GetBorderBottomWidth()); AddIfPresent(map, "border-left-width", style.GetBorderLeftWidth()); + AddIfPresent(map, "border-collapse", style.GetPropertyValue("border-collapse")); AddIfPresent(map, "border-top-style", style.GetBorderTopStyle()); AddIfPresent(map, "border-right-style", style.GetBorderRightStyle()); diff --git a/src/AngleSharp.Renderer/Rendering/Canvas2DRenderingContext.cs b/src/AngleSharp.Renderer/Rendering/Canvas2DRenderingContext.cs new file mode 100644 index 0000000..57cc976 --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/Canvas2DRenderingContext.cs @@ -0,0 +1,397 @@ +namespace AngleSharp.Renderer; + +using System.Linq; +using AngleSharp.Html.Dom; +using AngleSharp.Media.Dom; +using SkiaSharp; + +/// +/// A minimal 2D canvas context backed by a Skia bitmap. +/// +public sealed class Canvas2DRenderingContext : ICanvasRenderingContext2D +{ + private SKBitmap _bitmap; + private SKCanvas _canvas; + private SKColor _fillColor = SKColors.Black; + private SKColor _strokeColor = SKColors.Black; + private float _lineWidth = 1f; + private string _font = "10px sans-serif"; + private float _translateX; + private float _translateY; + private readonly List<(float X, float Y)> _pathPoints = new(); + private readonly Stack<(SKColor FillColor, SKColor StrokeColor, float LineWidth, string Font, float TranslateX, float TranslateY, int Width, int Height)> _stateStack = new(); + + /// + /// Creates a new bitmap-backed 2D canvas rendering context. + /// + /// The canvas element hosting the context. + public Canvas2DRenderingContext(IHtmlCanvasElement host) + { + Host = host ?? throw new ArgumentNullException(nameof(host)); + var width = Math.Max(1, host.Width); + var height = Math.Max(1, host.Height); + _bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul); + _bitmap.Erase(SKColors.Transparent); + _canvas = new SKCanvas(_bitmap); + } + + /// + public string ContextId => "2d"; + + /// + public bool IsFixed => false; + + /// + public IHtmlCanvasElement Host { get; } + + /// + public IHtmlCanvasElement Canvas => Host; + + /// + public int Width + { + get => Host.Width; + set + { + Host.Width = value; + ResizeBitmap(); + } + } + + /// + public int Height + { + get => Host.Height; + set + { + Host.Height = value; + ResizeBitmap(); + } + } + + /// + /// Fills a rectangle with the current fill style. + /// + public void FillRect(float x, float y, float width, float height) + { + using var paint = new SKPaint + { + Color = _fillColor, + Style = SKPaintStyle.Fill, + }; + + _canvas.DrawRect(new SKRect(x + _translateX, y + _translateY, x + _translateX + width, y + _translateY + height), paint); + } + + /// + /// Strokes the outline of a rectangle with the current stroke style. + /// + public void StrokeRect(float x, float y, float width, float height) + { + using var paint = new SKPaint + { + Color = _strokeColor, + Style = SKPaintStyle.Stroke, + StrokeWidth = _lineWidth, + }; + + _canvas.DrawRect(new SKRect(x + _translateX, y + _translateY, x + _translateX + width, y + _translateY + height), paint); + } + + /// + /// Clears a rectangular area of the backing bitmap. + /// + public void ClearRect(float x, float y, float width, float height) + { + using var paint = new SKPaint + { + Style = SKPaintStyle.Fill, + BlendMode = SKBlendMode.Clear, + }; + + _canvas.DrawRect(new SKRect(x + _translateX, y + _translateY, x + _translateX + width, y + _translateY + height), paint); + } + + /// + /// Sets the current fill style. + /// + public void SetFillStyle(string? color) + { + _fillColor = ParseColor(color); + } + + /// + /// Sets the current stroke style. + /// + public void SetStrokeStyle(string? color) + { + _strokeColor = ParseColor(color); + } + + /// + /// Sets the current line width. + /// + public void SetLineWidth(float width) + { + _lineWidth = Math.Max(0f, width); + } + + /// + /// Sets the current font string. + /// + public void SetFont(string? font) + { + _font = string.IsNullOrWhiteSpace(font) ? "10px sans-serif" : font; + } + + /// + /// Translates subsequent drawing operations by the specified amount. + /// + public void Translate(float x, float y) + { + _translateX += x; + _translateY += y; + } + + /// + /// Begins a new path. + /// + public void BeginPath() + { + _pathPoints.Clear(); + } + + /// + /// Adds a point to the current path. + /// + public void MoveTo(float x, float y) + { + _pathPoints.Clear(); + _pathPoints.Add((x, y)); + } + + /// + /// Adds a line segment to the current path. + /// + public void LineTo(float x, float y) + { + if (_pathPoints.Count == 0) + { + _pathPoints.Add((x, y)); + return; + } + + _pathPoints.Add((x, y)); + } + + /// + /// Closes the current path. + /// + public void ClosePath() + { + if (_pathPoints.Count > 1) + { + _pathPoints.Add(_pathPoints[0]); + } + + } + + /// + /// Fills the current path. + /// + public void Fill() + { + if (_pathPoints.Count < 2) + { + return; + } + + using var paint = CreatePaint(_fillColor, SKPaintStyle.Fill); + var points = _pathPoints.Select(point => new SKPoint(point.X + _translateX, point.Y + _translateY)).ToArray(); + _canvas.DrawPoints(SKPointMode.Polygon, points, paint); + } + + /// + /// Strokes the current path. + /// + public void Stroke() + { + if (_pathPoints.Count < 2) + { + return; + } + + using var paint = CreatePaint(_strokeColor, SKPaintStyle.Stroke); + paint.StrokeWidth = _lineWidth; + var points = _pathPoints.Select(point => new SKPoint(point.X + _translateX, point.Y + _translateY)).ToArray(); + _canvas.DrawPoints(SKPointMode.Polygon, points, paint); + } + + /// + /// Draws filled text at the requested position. + /// + public void FillText(string? text, float x, float y) + { + if (string.IsNullOrWhiteSpace(text)) + { + return; + } + + using var paint = CreatePaint(_fillColor, SKPaintStyle.Fill); + using var typeface = SKTypeface.Default; + using var font = new SKFont(typeface, ParseFontSize(_font)); + _canvas.DrawText(text, x + _translateX, y + _translateY, font, paint); + } + + /// + /// Saves the current canvas state. + /// + public void Save() + { + _stateStack.Push((_fillColor, _strokeColor, _lineWidth, _font, _translateX, _translateY, Host.Width, Host.Height)); + _canvas.Save(); + } + + /// + /// Restores the most recently saved canvas state. + /// + public void Restore() + { + if (_stateStack.Count == 0) + { + return; + } + + var (fillColor, strokeColor, lineWidth, font, translateX, translateY, width, height) = _stateStack.Pop(); + _fillColor = fillColor; + _strokeColor = strokeColor; + _lineWidth = lineWidth; + _font = font; + _translateX = translateX; + _translateY = translateY; + Host.Width = width; + Host.Height = height; + _canvas.Restore(); + } + + /// + public void SaveState() + { + Save(); + } + + /// + public void RestoreState() + { + Restore(); + } + + /// + public byte[] ToImage(string type) + { + _canvas.Flush(); + + using var image = SKImage.FromBitmap(_bitmap); + using var data = image.Encode(type.Equals("image/png", StringComparison.OrdinalIgnoreCase) ? SKEncodedImageFormat.Png : SKEncodedImageFormat.Png, 100); + + return data is null ? Array.Empty() : data.ToArray(); + } + + private void ResizeBitmap() + { + if (_bitmap.Width == Host.Width && _bitmap.Height == Host.Height) + { + return; + } + + var previous = _bitmap; + var resized = new SKBitmap(Math.Max(1, Host.Width), Math.Max(1, Host.Height), SKColorType.Rgba8888, SKAlphaType.Premul); + resized.Erase(SKColors.Transparent); + + using var canvas = new SKCanvas(resized); + canvas.DrawBitmap(previous, new SKRect(0, 0, previous.Width, previous.Height), new SKRect(0, 0, resized.Width, resized.Height)); + canvas.Flush(); + + _bitmap.Dispose(); + _canvas.Dispose(); + _bitmap = resized; + _canvas = new SKCanvas(_bitmap); + } + + private SKPaint CreatePaint(SKColor color, SKPaintStyle style) + { + return new SKPaint + { + Color = color, + Style = style, + StrokeWidth = _lineWidth, + }; + } + + private static float ParseFontSize(string font) + { + if (string.IsNullOrWhiteSpace(font)) + { + return 10f; + } + + var token = font.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); + if (token is null) + { + return 10f; + } + + if (token.EndsWith("px", StringComparison.OrdinalIgnoreCase) && + float.TryParse(token[..^2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var px)) + { + return px; + } + + return 10f; + } + + private static SKColor ParseColor(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return SKColors.Black; + } + + var color = value.Trim(); + + if (color.StartsWith("#", StringComparison.Ordinal) && color.Length == 7) + { + if (byte.TryParse(color[1..3], System.Globalization.NumberStyles.HexNumber, null, out var r) && + byte.TryParse(color[3..5], System.Globalization.NumberStyles.HexNumber, null, out var g) && + byte.TryParse(color[5..7], System.Globalization.NumberStyles.HexNumber, null, out var b)) + { + return new SKColor(r, g, b); + } + } + + if (color.StartsWith("rgb(", StringComparison.OrdinalIgnoreCase) && color.EndsWith(")", StringComparison.Ordinal)) + { + var content = color[4..^1].Trim(); + var components = content.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + if (components.Length >= 3 && + byte.TryParse(components[0], out var r) && + byte.TryParse(components[1], out var g) && + byte.TryParse(components[2], out var b)) + { + return new SKColor(r, g, b); + } + } + + return color.ToLowerInvariant() switch + { + "transparent" => SKColors.Transparent, + "black" => SKColors.Black, + "white" => SKColors.White, + "red" => SKColors.Red, + "green" => SKColors.Green, + "blue" => SKColors.Blue, + _ => SKColors.Black, + }; + } +} diff --git a/src/AngleSharp.Renderer/Rendering/CanvasRenderingService.cs b/src/AngleSharp.Renderer/Rendering/CanvasRenderingService.cs new file mode 100644 index 0000000..0b22923 --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/CanvasRenderingService.cs @@ -0,0 +1,24 @@ +namespace AngleSharp.Renderer; + +using AngleSharp.Html.Dom; +using AngleSharp.Media.Dom; + +/// +/// Registers a simple bitmap-backed 2D rendering context for canvas elements. +/// +public sealed class CanvasRenderingService : IRenderingService +{ + /// + public bool IsSupportingContext(string contextId) => string.Equals(contextId, "2d", StringComparison.OrdinalIgnoreCase); + + /// + public IRenderingContext CreateContext(IHtmlCanvasElement host, string contextId) + { + if (!IsSupportingContext(contextId)) + { + throw new InvalidOperationException($"Unsupported rendering context '{contextId}'."); + } + + return new Canvas2DRenderingContext(host); + } +} diff --git a/src/AngleSharp.Renderer/Rendering/RenderingConfigurationExtensions.cs b/src/AngleSharp.Renderer/Rendering/RenderingConfigurationExtensions.cs new file mode 100644 index 0000000..8bee4dd --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/RenderingConfigurationExtensions.cs @@ -0,0 +1,28 @@ +namespace AngleSharp; + +using AngleSharp.Media.Dom; + +/// +/// Configuration extensions for registering canvas rendering services. +/// +public static class RenderingConfigurationExtensions +{ + /// + /// Registers a canvas rendering service with the configuration. + /// + public static IConfiguration WithRendering(this IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + return configuration.WithRendering(context => new Renderer.CanvasRenderingService()); + } + + /// + /// Registers a custom rendering service with the configuration. + /// + public static IConfiguration WithRendering(this IConfiguration configuration, Func factory) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(factory); + return configuration.WithOnly(factory); + } +} diff --git a/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSans-Bold.ttf b/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSans-Bold.ttf new file mode 100644 index 0000000..06db62c Binary files /dev/null and b/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSans-Bold.ttf differ diff --git a/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSans.ttf b/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSans.ttf new file mode 100644 index 0000000..2fbbe69 Binary files /dev/null and b/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSans.ttf differ diff --git a/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSansMono-Bold.ttf b/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSansMono-Bold.ttf new file mode 100644 index 0000000..b210eb5 Binary files /dev/null and b/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSansMono-Bold.ttf differ diff --git a/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSansMono.ttf b/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSansMono.ttf new file mode 100644 index 0000000..041cffc Binary files /dev/null and b/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSansMono.ttf differ diff --git a/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSerif-Bold.ttf b/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSerif-Bold.ttf new file mode 100644 index 0000000..8162112 Binary files /dev/null and b/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSerif-Bold.ttf differ diff --git a/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSerif.ttf b/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSerif.ttf new file mode 100644 index 0000000..1b62977 Binary files /dev/null and b/src/AngleSharp.Renderer/Resources/Fonts/DejaVuSerif.ttf differ diff --git a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs index 8991a1c..3f0bcc0 100644 --- a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs +++ b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs @@ -1,4 +1,6 @@ using AngleSharp.Renderer.Rendering; +using System.Reflection; +using System.Threading; using SkiaSharp; @@ -9,6 +11,24 @@ namespace AngleSharp.Renderer.Skia; /// public sealed class SkiaRenderBackend : IRenderBackend { + 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", + }; + /// public RenderedImage RenderToPng(DisplayList displayList, RenderViewport viewport) { @@ -85,8 +105,12 @@ private static void DrawText(SKCanvas canvas, DrawTextCommand command) { 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, }; DrawTextWithLetterSpacing(canvas, paint, command.Text, command.X, command.Y, command.LetterSpacing); @@ -148,6 +172,8 @@ 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) @@ -159,17 +185,11 @@ private static SKTypeface CreateTypeface(string fontFamily, SKFontStyle fontStyl continue; } - normalized = normalized.ToLowerInvariant(); - - normalized = normalized switch + if (GenericFontMappings.TryGetValue(normalized, out var bundledFamilyKey) && + BundledFonts.Value.TryGetValue(bundledFamilyKey, out var bundledFamily)) { - "serif" => "DejaVu Serif", - "sans-serif" => "DejaVu Sans", - "monospace" => "DejaVu Sans Mono", - "cursive" => "DejaVu Sans", - "fantasy" => "DejaVu Serif", - _ => normalized, - }; + return isBold ? bundledFamily.Bold : bundledFamily.Regular; + } var typeface = SKTypeface.FromFamilyName(normalized, fontStyle); @@ -179,9 +199,45 @@ private static SKTypeface CreateTypeface(string fontFamily, SKFontStyle fontStyl } } + 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) @@ -218,4 +274,6 @@ private static float MeasureTextWidth(SKPaint paint, string text, float letterSp } 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/Directory.Build.props b/src/Directory.Build.props index ee8f0d9..b2aac2c 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.1.0 + 0.2.0 enable latest true