diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e7e782..c92504b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ env: # Snapshot baselines are per-platform and committed; a missing one is a gap, never a pass. ANGLESHARP_SNAPSHOT_STRICT: 1 ANGLESHARP_VERSION: 1.8.1 - ANGLESHARP_CSS_VERSION: 1.1.2 + ANGLESHARP_CSS_VERSION: 1.2.0 jobs: can_document: diff --git a/.github/workflows/update-snapshots.yml b/.github/workflows/update-snapshots.yml index e2eb2d5..6425e69 100644 --- a/.github/workflows/update-snapshots.yml +++ b/.github/workflows/update-snapshots.yml @@ -16,7 +16,7 @@ permissions: env: ANGLESHARP_VERSION: 1.8.1 - ANGLESHARP_CSS_VERSION: 1.1.2 + ANGLESHARP_CSS_VERSION: 1.2.0 jobs: # Keep this matrix identical to the `test` matrix in ci.yml, images included - a baseline diff --git a/AGENTS.md b/AGENTS.md index e858c9c..d5174f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,414 +1,177 @@ # 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. +Guidance for AI coding agents working in this repository: commands, architecture, conventions, and the non-obvious decisions behind the code. Keep it short - record *why* and *gotchas*, not what the code already says. ## 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. +AngleSharp.Renderer adds rendering on top of AngleSharp and AngleSharp.Css: a display-list renderer with a SkiaSharp backend that rasterizes HTML/CSS (and SVG) into PNG images. -## Most Important Commands - -Use the solution-level test command for normal validation: +## Commands ```bash -dotnet test src/AngleSharp.Renderer.sln +dotnet test src/AngleSharp.Renderer.sln # full validation +dotnet test src/AngleSharp.Renderer.Tests/AngleSharp.Renderer.Tests.csproj # iterate on renderer behavior +./build.sh | ./build.ps1 | ./build.cmd # Fallout bootstrapper ``` -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 both as well, so both runtimes are exercised. +The renderer and test projects target `net8.0` and `net10.0`; both runtimes are exercised. ## 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, and implements `ITextMeasurer` so layout measures with the very typefaces it paints with. Both paths resolve fonts through `SkiaTextShaping`, which exists to keep them from drifting apart. -- Font resolution lives in `SkiaTextShaping.CreateTypeface` and walks the CSS family list in order: generic families map to the bundled fonts, named families resolve only when actually installed, and an exhausted list falls back to the bundled sans-serif. Do not resolve named families with `SKTypeface.FromFamilyName` - it substitutes the host's default for an unknown family instead of returning null, which swallows the rest of the fallback list and makes output depend on the machine. Availability goes through a case-insensitive index of the installed families, because Skia's own lookup is case sensitive on Linux but not on Windows. -- `ITextMeasurer` is the seam between layout and rasterization. Line breaking, text alignment and table column widths all go through it; a renderer built with a custom measurer lays out against that measurer. Never reintroduce a font-independent width heuristic here - it silently decouples layout from what is drawn. +- `HtmlRenderer` builds a `DisplayList` from the AngleSharp.Css render tree and computed styles. All layout lives in this partial class: `HtmlRenderer.cs` (block/flex/grid/table layout, style maps, painting), `HtmlRenderer.Inline.cs` (flow children, inline formatting context, bidi reordering, intrinsic sizing), `HtmlRenderer.Text.cs` (`hyphens`, `tab-size`, small caps, `font-stretch`, `DrawStyledText`), `HtmlRenderer.Floats.cs` (floats, `clear`, block formatting contexts), `HtmlRenderer.Columns.cs` (multi-column layout), `HtmlRenderer.Tables.cs` (tables), `HtmlRenderer.Sticky.cs` (`position: sticky`), `HtmlRenderer.WritingModes.cs` (`writing-mode`). +- `DisplayList` is the backend-agnostic, flat command model. Scoped effects are push/pop pairs (`PushClip`, `PushTransform`, `PushOpacity`, `PushFilter`, ...) that the backend maps to `Save`/`SaveLayer`/`Restore`. `InsertRange` and `MoveRangeToEnd` exist to reorder already-emitted commands. +- `SkiaRenderBackend` rasterizes the display list and implements `ITextMeasurer`, so layout measures with the very typefaces it paints with. +- `ITextMeasurer` is the seam between layout and rasterization (line breaking, alignment, table widths). Never reintroduce a font-independent width heuristic - it silently decouples layout from what is drawn. +- `SkiaTextShaping` is the single font-resolution path for both measuring and painting (and SVG text). Text is drawn glyph by glyph with `canvas.DrawText` - no HarfBuzz shaping (no Arabic joining, ligatures or `font-feature-settings`). +- `BidiAlgorithm` (UAX #9: levels, `unicode-bidi` embeddings/overrides/isolates, mirroring) and `WebFontDecoder` (WOFF via zlib, WOFF2 via Brotli plus the `glyf`/`loca`/`hmtx` transforms, back to plain sfnt) are self-contained internal helpers. - `HtmlRenderOptions` holds viewport and text defaults. -- `AngleSharp.Css` provides the render tree and computed-style data used by the renderer. -- `SvgRasterizer` (in `Skia/`) turns SVG content into PNG bytes without a second SVG parser: it walks the DOM AngleSharp already produced and paints directly with SkiaSharp (`Skia/Svg/`: `SvgElementRenderer` walks the tree, `SvgPathDataParser`/`SvgTransformParser`/`SvgColorParsing` decode the `d`/`transform`/paint attributes, `SvgPaintState` carries inherited fill/stroke/opacity down the tree). The PNG it produces flows into an ordinary `RenderedImage` afterward, so `DrawImageCommand` and `SkiaRenderBackend.DrawImage` need no SVG-specific handling. - -Font handling resolves each entry of a `font-family` list in order, and the first usable one wins: - -1. Generic families (`serif`, `sans-serif`, `monospace`, plus `cursive`, `fantasy`, `system-ui` and the `ui-*` aliases) always come from the fonts bundled in `Resources/Fonts`. They are keywords, so an `@font-face` rule cannot take them over, and they are what keeps snapshots reproducible. -2. `@font-face` declarations, collected per document by `FontFaceLoader` and carried on `DisplayList.Fonts`. Sources are tried in declaration order; `local()` resolves against the installed fonts and `url()` against `data:` URIs, or the network when - and only when - the browsing context has an `IDocumentLoader` configured, mirroring how images are handled. WOFF and WOFF2 are rejected up front because Skia cannot decode them. -3. Installed families, which depend on the host and are therefore not safe to assert in snapshots. -4. The bundled sans-serif, as the last resort. - -Table spans: a cell covers the columns and rows it spans, and a spanning cell's height is shared across the rows it covers rather than imposed on each of them. With `border-collapse: collapse` each cell paints only its top and left edge and the table adds the frame, so shared edges are drawn once and no rule is painted across a spanning cell. Cell content honours `vertical-align` (`top`, `middle`, `bottom`), defaulting to the `middle` that AngleSharp.Css resolves for cells; `baseline` is treated as `top`, since baselines are not aligned across a row. Note that on a cell `vertical-align` positions the content box, which is a different meaning from the inline shift `ParseVerticalAlign` applies to `super`, `sub` and friends. - -`border-radius` (rounded corners): the shorthand and all four longhands (`border-top-left-radius` etc.) are supported, each independently elliptical (`border-top-left-radius: 20px 10px`, or the whole-box `border-radius: 40px / 20px` slash shorthand) and represented backend-agnostically as `RenderCornerRadii` (`Rendering/RenderCornerRadii.cs`) - eight floats, X/Y per corner. `RenderCornerRadii.ClampToBox` implements the CSS corner-overlap-prevention algorithm (https://www.w3.org/TR/css-backgrounds-3/#corner-overlap): a single global scale factor, not a per-axis one, is applied to every radius so adjacent corners along the same edge never overlap - this is what turns a `border-radius` larger than half a box's height into a pill/stadium shape rather than a self-intersecting curve. A background fills with `SKRoundRect`/`DrawRoundRect` (`SkiaRenderBackend.DrawFillRect`) instead of `DrawRect` whenever `Radii` is non-zero. A border with rounded corners is painted differently depending on whether every edge shares the same width: a uniform-width border becomes a single `StrokeRoundedRectCommand` (one stroked ring, inset by half the border width, with the radius reduced by that same half-width since the stroke sits on the border's centerline); a mixed-width border (e.g. `border-right-width` wider than the others) has no single stroke width to give that ring, so it falls back to the existing four-straight-rectangle border path and the corners render square - a deliberate, documented limitation rather than an attempt at four independently-rounded quads. `outline` never rounds regardless of `border-radius`, matching the CSS spec, because `PaintOutline`'s internal `PaintBorder` call never passes the element's radii. Percentage radii resolve to pixels through AngleSharp.Css's computed-style engine before this renderer ever sees them. A confirmed AngleSharp.Css 1.1.0 bug - both the horizontal and vertical component of a percentage radius resolving against the *containing block's width* instead of per-axis against the element's own width/height, confirmed by rendering the same `%` radius against different viewport widths and observing the resulting pixel value track the viewport, not the element's own box - was reported with a reproducing test in AngleSharp.Css's own suite (`AngleSharp.Css.Tests/Styling/BorderRadiusPercentageResolutionTests.cs`) and is now **fixed upstream**, confirmed by `BuildDisplayList_ResolvesPercentageBorderRadiusAgainstBox` flipping from the old (wrong) 30px/30px to the correct per-axis 20px/8px with no renderer-side code change of its own - this renderer never carried a workaround for it, since there was nothing to intercept short of re-deriving the radius itself. - -`box-shadow` and `text-shadow`: both are parsed from AngleSharp.Css's `GetBoxShadow()`/`GetTextShadow()` computed-style accessors, which return every comma-separated layer already normalized with the color as `rgba(r, g, b, a)` regardless of how it was authored (`red`, `#f00`, ...) - `ExtractColorToken` (`HtmlRenderer.cs`) relies on that normalization to split a layer's color from its lengths by locating the `rgba(...)` call rather than naively splitting on whitespace, which the spaces after that function's commas would otherwise break. `text-shadow` is an inherited property and AngleSharp.Css already resolves that inheritance before this renderer sees it (a child with no `text-shadow` of its own reports its ancestor's value verbatim, and an explicit `text-shadow: none` reports the literal string `"none"`, not an empty one) - `ParseTextShadows` still takes an `inherited` fallback for defense in depth, mirroring how `color` and the other inherited text properties are already handled in `ResolveTextStyle`. `box-shadow` is not inherited, matching spec, so `ResolveBoxStyle` parses it directly with no inherited fallback. Multiple shadows paint back-to-front in reverse authoring order (`PaintBoxShadows`/`PaintTextShadows`), because CSS defines the first-listed shadow as topmost. A `box-shadow` layer is `RenderBoxShadow` (`Rendering/RenderShadow.cs`) - offset, blur, spread, color, and `inset` - painted via `DrawBoxShadowCommand`/`SkiaRenderBackend.DrawBoxShadow`, which reuses the same `RenderCornerRadii`/`SKRoundRect` machinery `border-radius` already established so a shadow follows the box's own corner rounding. An outset shadow is the border box's `SKRoundRect` grown by `SpreadRadius` (via `SKRoundRect.Inflate`/`Deflate` - verified functionally to adjust the rect *and* clamp each radius down to zero together, exactly matching the CSS spread semantics for free) and offset, blurred with `SKMaskFilter.CreateBlur`, then clipped with `SKClipOperation.Difference` against the *un-inflated* border box so the shadow never bleeds into the box's own interior even when its background is transparent - this clip-out is required by spec, not an approximation. An inset shadow is the mirror image: the border box shrunk by spread and offset, painted as the even-odd "ring" between that shrunk shape and a rect well outside the box, clipped with `SKClipOperation.Intersect` to the border box so it only shows inside. Both cases derive the Gaussian blur sigma as half the CSS blur radius (`shadow.BlurRadius / 2f`), the same approximation browsers commonly use; there is no dedicated conformance test pinning that constant, so treat it as adjustable if a real blurred-shadow comparison ever disagrees with it. `text-shadow` has no spread or inset, and paints as a second, blurred copy of the same text run (`DrawTextShadowCommand`/`SkiaRenderBackend.DrawTextShadow`) offset behind the real glyphs, shaped through the same `SkiaTextShaping` path as ordinary text so a shadow never drifts out of alignment with what it is shadowing. Painting order for a box is background, then `box-shadow` (all layers), then border, then outline - box-shadow must sit on top of the background (not behind it) even though it is visually "behind" the box's own content, because an inset shadow paints inside the box and would otherwise be hidden by an opaque background; this was a real bug caught by rendering the inset-shadow visual test and seeing no shadow at all, fixed by reordering `PaintBoxShadows` after `PaintBackground` at all three box-painting call sites. - -A box's own background/border/`box-shadow`/outline must paint *behind* its children, but an auto-sized box's height is only known once its children have been laid out (and their commands already appended to the display list) - so `LayoutElement`, `LayoutFlexContainer`, and `LayoutGridContainer` each capture `displayList.Commands.Count` as `boxPaintInsertIndex` before laying out their children/items, build the box's own paint commands into a scratch `DisplayList` buffer, and splice that buffer in at the captured index via `DisplayList.InsertRange` once the box's final size is known - rather than simply appending, which used to paint the box's own background *after*, and therefore on top of, its children. This was a real, confirmed bug (not hypothetical): a `
` with both a `background-color` and direct text content rendered the text completely invisible, and an absolutely-positioned or negative-`z-index` child was fully hidden behind its own containing block's background. Fixed by the splice approach and locked in by `BuildDisplayList_PaintsOwnBackgroundBehindOwnDirectTextContent`/`RenderToPng_PaintsOwnBackgroundBehindOwnDirectTextContent`; two pre-existing tests (`negative-z-index-behind-in-flow`/`absolute-positioned-out-of-flow` and their structural counterpart) had encoded the old, incorrect order as "expected" and were corrected to match CSS 2.1 Appendix E's actual stacking order (a stacking context's own background paints first, *then* its negative-z-index descendants, on top of it). - -`display: list-item` (`
    `/`