# onerender docs, all pages (OneRender 1.0.0) Every page of https://recursiveai.org/onerender/docs/ and https://recursiveai.org/onerender/cookbook/ as markdown, getting started first. Index: https://recursiveai.org/onerender/llms.txt # Getting started with OneRender Install OneRender for .NET 10, render an HTML invoice to a PNG and to a tagged PDF without a browser, supply its style sheet and images from your own code, read the diagnostics, and query the layout. ## Install OneRender targets `net10.0`, so you need the .NET 10 SDK. Every package shares one version; this guide uses 1.0.0. ```shell dotnet new console -o OneRenderStart cd OneRenderStart dotnet add package RecursiveAI.OneRender.Pdf --version 1.0.0 ``` `RecursiveAI.OneRender.Pdf` brings in everything this guide needs: the engine (`RecursiveAI.OneRender`), the rasterizer (`RecursiveAI.OneRender.Image`) and the default fonts (`RecursiveAI.OneRender.Fonts`). If you only want images, install `RecursiveAI.OneRender.Image` instead. The libraries are NativeAOT-, trim- and WebAssembly-clean and contain no `unsafe` code. OneRender has no JavaScript, no networking and no clock. It never fetches anything: your code hands it the HTML, and the style sheets, images and fonts the HTML refers to. ## The sample page The steps below render `site/invoice.html`, a one-page invoice that links a style sheet and a logo: ```html Invoice INV-1042
Harbor Street Books

Invoice INV-1042

Billed to Harbor Street Books, October 14, 2026

ItemQtyAmount
Shelf labels400$120.00
Reading lamps6$354.00
Total$474.00
``` `site/invoice.css` sits next to it, along with `site/logo.png`: ```css body { font: 15px/1.5 Arial, sans-serif; color: #222; margin: 32px } header { display: flex; align-items: center; gap: 16px; margin: 0 0 20px } h1 { font-family: Georgia, serif; font-weight: normal; margin: 0 } .to { color: #555; margin: 0 } table { width: 100%; border-collapse: collapse } th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #ddd } td:last-child, th:last-child { text-align: right } tfoot td { font-weight: bold; border-bottom: 0 } ``` The snippets are one program, in order. Put these `using` directives at the top of `Program.cs`: ```csharp using OneRender; using OneRender.Image; using OneRender.Interaction; using OneRender.Layout; using OneRender.Painting; using OneRender.Pdf; using RecursiveAI.OneHtml; ``` ## Render HTML to a PNG `HtmlRender.ToImage` lays the page out in a viewport of the size you give, in CSS pixels, and paints it: ```csharp string html = File.ReadAllText("site/invoice.html"); RgbaImage first = HtmlRender.ToImage(html, new HtmlRenderOptions { Width = 640, Height = 360 }); File.WriteAllBytes("first.png", first.ToPng()); Console.WriteLine($"first.png: {first.Width} x {first.Height}"); ``` ```text first.png: 640 x 360 ``` This first image has no styling and no logo: nothing supplied `invoice.css` or `logo.png`, and the engine won’t go looking for them. The text is set in the bundled fonts. Arial, Helvetica, Times New Roman, Courier New, Calibri and Cambria map to metric-compatible Liberation, Carlito and Caladea faces, and a family it doesn’t have, like Georgia here, falls back to the generic family you list after it. The output is the same on every machine. ## Supply style sheets and images Give the page an address with `BaseUrl` and a `Resources` callback. The engine resolves each reference against the base address and asks the callback for the bytes. Return `null` for anything you don’t have. ```csharp const string baseUrl = "https://billing.example/invoices/"; byte[]? Resources(string address) { string name = address.StartsWith(baseUrl) ? address[baseUrl.Length..] : ""; string path = Path.Combine("site", name); Console.WriteLine($" asked for {address}"); return name.Length > 0 && File.Exists(path) ? File.ReadAllBytes(path) : null; } RgbaImage image = HtmlRender.ToImage(html, new HtmlRenderOptions { Width = 640, Height = 360, DevicePixelRatio = 2, BaseUrl = baseUrl, Resources = Resources, }); File.WriteAllBytes("invoice.png", image.ToPng()); Console.WriteLine($"invoice.png: {image.Width} x {image.Height}"); ``` ```text asked for https://billing.example/invoices/invoice.css asked for https://billing.example/invoices/logo.png invoice.png: 1280 x 720 ``` The address is only a name: nothing is fetched from `billing.example`. Your callback decides what each address means, so it can read files, look in a database or serve embedded resources, and it never exposes anything you didn’t mean to hand over. `DevicePixelRatio = 2` renders the same 640 by 360 layout at twice the pixel density. The same callback serves fonts named in `@font-face` rules. ## Render HTML to a PDF `HtmlPdf.ToPdf` lays the page out on pages and writes a tagged PDF, page by page, to a stream: ```csharp using (FileStream file = File.Create("invoice.pdf")) { HtmlPdfResult result = HtmlPdf.ToPdf(html, file, new HtmlPdfOptions { Page = PdfPageSetup.Letter, BaseUrl = baseUrl, Resources = Resources, }); Console.WriteLine($"invoice.pdf: {result.PageCount} page, {result.Diagnostics.Count} diagnostics"); } ``` ```text asked for https://billing.example/invoices/invoice.css asked for https://billing.example/invoices/logo.png invoice.pdf: 1 page, 0 diagnostics ``` - **Tagged by default.** The headings, paragraphs, table, header cells and the logo’s `alt` text become the PDF’s structure tree, and the `` and `lang` become the document title and language. Text is real text with the fonts subsetted, so it can be selected, searched and extracted. - **Page setup.** `PdfPageSetup.Letter` and `PdfPageSetup.A4` have half-inch margins; `new PdfPageSetup(width, height, top, right, bottom, left)` takes CSS pixels (96 per inch). A `@page` rule in the document overrides it. - **Bytes instead of a stream.** `HtmlPdf.ToPdf(html, options)` returns a `byte[]`. ## Read the diagnostics Nothing in a document makes the renderers throw. Missing resources, unsupported features and limits become diagnostics. Here the callback supplies nothing at all: ```csharp HtmlPdfResult missing = HtmlPdf.ToPdf(html, Stream.Null, new HtmlPdfOptions { BaseUrl = baseUrl, Resources = _ => null }); foreach (Diagnostic d in missing.Diagnostics) Console.WriteLine($"{d.Kind}: {d.Message}"); ``` ```text MissingResource: an image was not supplied by the host ``` The missing logo is reported. A style sheet your callback doesn’t supply is left out without a diagnostic, the way a browser skips a sheet that failed to load, so log what your callback misses if you need to know. Characters that no font covers are recorded on the font provider instead; [Fonts](https://recursiveai.org/onerender/cookbook/fonts/) shows how to read them and which package to add. ## Query the layout Under the one-call renderers are public types you can use yourself. `InteractivePage` lays a page out once and answers the questions a browser’s DOM would, synchronously: ```csharp var options = new HtmlPageOptions(FontProviders.CreateDefault()) { ViewportWidth = 640, ViewportHeight = 360, BaseUrl = baseUrl, StylesheetBytes = Resources, }; InteractivePage page = InteractivePage.Create(html, options); Node total = page.Page.Document.Root.QuerySelector("tfoot td:last-child"); Rect box = page.Layout.GetBoundingClientRect(total); Console.WriteLine($"total cell: x {box.X:F1}, y {box.Y:F1}, {box.Width:F1} x {box.Height:F1}"); Console.WriteLine($"at (100, 150): <{page.ElementFromPoint(100, 150).LocalName}>"); var list = new DisplayList(); page.Page.Paint(list); Console.WriteLine($"display list: {list.Count} items"); RgbaImage again = DisplayListRasterizer.Render(list, 640, 360); File.WriteAllBytes("low-level.png", again.ToPng()); ``` ```text asked for https://billing.example/invoices/invoice.css total cell: x 425.8, y 225.0, 182.2 x 35.5 at (100, 150): <th> display list: 21 items ``` - The document is a OneHtml `Document`, so `QuerySelector` takes any CSS selector. - `page.Layout` has the CSSOM View queries: bounding and client rectangles, offset and scroll sizes. `ElementFromPoint` is a hit test. - Painting writes a display list: backgrounds, borders, glyph runs and images with stable ids. The rasterizer turns it into pixels, the PDF writer into pages, and a game engine can draw it with its own GPU code ([The display list](https://recursiveai.org/onerender/docs/display-list/)). ## Next steps - [HTML to PDF](https://recursiveai.org/onerender/cookbook/html-to-pdf/) and [HTML to PNG](https://recursiveai.org/onerender/cookbook/html-to-png/) cover the options of each renderer. - [Headers, footers and page breaks](https://recursiveai.org/onerender/cookbook/paged-media/) shows print CSS: page size, running headers, page numbers and forced breaks. - [Render a Razor component to PDF](https://recursiveai.org/onerender/cookbook/razor-to-pdf/) does it in one call inside ASP.NET Core. - [Replace a headless browser](https://recursiveai.org/onerender/cookbook/replace-headless-browser/) maps Puppeteer and Playwright calls to OneRender. - [The overview](https://recursiveai.org/onerender/docs/overview/) lists every package, and [PDF output](https://recursiveai.org/onerender/docs/pdf/) has every rule of the PDF writer. <!-- https://recursiveai.org/onerender/docs/overview/ --> # OneRender An HTML and CSS layout and rendering engine for .NET, written from the specifications. HTML in (or a styled tree built in C#), pixels, a tagged PDF, or a retained display list out. The same engine lays out a 200-page report and a game UI that updates every frame. It has no JavaScript, no networking and no clock: the host passes the time and supplies stylesheets, images and fonts. ## Packages | Package | What it is | Depends on | |---|---|---| | `RecursiveAI.OneRender` | The engine: styled-node tree, computed values, box tree, layout, fragmentation and paged media, paint to a display list, hit testing, DOM-standard events, focus, scrolling, form controls and text editing, CSS Transitions and Animations. | OneHtml, OneFont | | `RecursiveAI.OneRender.Image` | Display list to pixels (`HtmlRender.ToImage`, `DisplayListRasterizer`). The reference rasterizer. | OneRender, OneImage, `.Fonts` | | `RecursiveAI.OneRender.Pdf` | Display list to tagged PDF (`HtmlPdf.ToPdf`, `DisplayListPdfWriter`). | OneRender, OnePdf (core), `.Image` | | `RecursiveAI.OneRender.Razor` | A Razor component to a PDF or an image in one call. | OneRender, `.Image`, `.Pdf`, the ASP.NET Core shared framework | | `RecursiveAI.OneRender.Accessibility` | Rendered page facts (boxes, paint order, text boxes, colors behind text) in OneHtml's `RenderedFacts` shape. | OneRender, OneHtml.Accessibility | | `RecursiveAI.OneRender.Fonts` | Default fonts: Liberation Sans/Serif/Mono, Carlito, Caladea. | fonts only | | `RecursiveAI.OneRender.Fonts.Noto.<Script>` (29), `.Fonts.Cjk`, `.Fonts.Emoji` | Opt-in script coverage. | `.Fonts` | Every library targets `net10.0` and is NativeAOT-, trim- and WebAssembly-clean (the Razor package needs ASP.NET Core and is not part of the WebAssembly check). Family libraries come from NuGet at the versions pinned in `Directory.Packages.props`. ## Quick start HTML to PNG (`RecursiveAI.OneRender.Image`): ```csharp using OneRender.Image; RgbaImage image = HtmlRender.ToImage( "<h1 style='font-family:serif'>Hello</h1><p>Rendered without a browser.</p>", new HtmlRenderOptions { Width = 600, Height = 200, DevicePixelRatio = 2 }); File.WriteAllBytes("hello.png", image.ToPng()); ``` HTML to PDF (`RecursiveAI.OneRender.Pdf`). The file is tagged by default: ```csharp using OneRender.Pdf; byte[] pdf = HtmlPdf.ToPdf(html, new HtmlPdfOptions { Page = PdfPageSetup.A4 }); // or HtmlPdf.ToPdf(html, stream, options) to write page by page ``` `@page` rules, margin boxes, page counters, running strings and `target-counter` work in the PDF path ([docs/architecture.md](https://recursiveai.org/onerender/docs/architecture/), [docs/pdf.md](https://recursiveai.org/onerender/docs/pdf/)). A Razor component to PDF or PNG (`RecursiveAI.OneRender.Razor`, details in [docs/razor.md](https://recursiveai.org/onerender/docs/razor/)): ```csharp builder.Services.AddOneRender(); app.MapGet("/invoice.pdf", async (RazorRenderer r, CancellationToken ct) => { RazorPdfResult pdf = await r.RenderPdfAsync<Invoice>(new Dictionary<string, object?> { ["Number"] = "INV-1" }, cancellationToken: ct); return Results.File(pdf.Bytes!, "application/pdf"); }); ``` Stylesheets, images and fonts the document references are never fetched by the engine. Pass them in with `HtmlRenderOptions.Resources` / `HtmlPdfOptions.Resources` (a function from absolute address to bytes); the Razor package serves them from `wwwroot`. ## The low level The happy paths above are built on public types you can use directly: ```csharp using OneRender; using OneRender.Image; using OneRender.Painting; var options = new HtmlPageOptions(FontProviders.CreateDefault()) { ViewportWidth = 800, ViewportHeight = 600 }; HtmlPage page = HtmlPage.Create(html, options); // OneHtml parse + cascade -> styled tree -> boxes -> fragments // page.Styled, page.Boxes, page.Fragments are struct-of-arrays trees with readonly struct handles. var list = new DisplayList(); page.Paint(list); // stacking contexts and paint order -> display list RgbaImage pixels = DisplayListRasterizer.Render(list, 800, 600); ``` Without HTML, build a `StyledTree` (`AddRoot`, `AddElement`, `AddText`, `StyleDeclaration`) and give it to a `LivePage`. The display list is a documented, versioned format ([docs/display-list.md](https://recursiveai.org/onerender/docs/display-list/)): items have stable ids, glyph runs carry the face, glyph ids, positions and source text, and semantic tags are bracketed begin/end items. Backends read only the list. Layout queries (`LayoutQueries`: offsets, bounding rects, `elementFromPoint`) answer synchronously after flushing pending changes. Layout explanation and box-to-source mapping are off by default and cost nothing when off. ## Interactive pages and game UIs `InteractivePage` wraps a page with hit testing, DOM-standard events (capture, target, bubble, `preventDefault`, one listener model), focus and `:focus-visible`, `:hover`/`:active`, scrolling with scroll snap, form controls with text editing and IME composition, and the animation clock. The host owns time: ```csharp InteractivePage ui = InteractivePage.Create(html, options, new InteractionOptions { AnimationStartTime = 0 }); var painter = new Painter(); var retained = new RetainedDisplayList(); var paint = new PaintOptions(800, 600) { Scroll = ui.Page.Scroll }; painter.Paint(ui.Page.Fragments, retained, paint); // every frame, with the host's clock in milliseconds: InputOutcome outcome = ui.Advance(nowMs); // transitions and animations outcome |= ui.HandlePointer(new PointerInput { Action = PointerAction.Move, X = x, Y = y, Time = nowMs }); if ((outcome & InputOutcome.Relaid) != 0) { painter.Paint(ui.Page.Fragments, retained, paint); // new layout: paint again } else { ui.PatchAnimations(painter, retained); // transform and opacity patched in place, no relayout } for (int i = 0; i < retained.Damage.Count; i++) { RectF area = retained.Damage[i]; // redraw only this area from retained.Current } retained.ClearDamage(); ``` `LivePage` is the same machinery over a `StyledTree` you edit: edits are logged as change sets, `Flush()` restyles and relays out only what changed and repaints into a `RetainedDisplayList`, and the result equals a full rebuild (checked by randomized edit-sequence tests and a fuzz target). Steady-state frames and the common incremental changes (hover restyle, text change, transform/opacity animation) allocate nothing; the allocation tests are in `tests/OneRender.Tests`. See [docs/animations.md](https://recursiveai.org/onerender/docs/animations/), [docs/forms.md](https://recursiveai.org/onerender/docs/forms/) and [docs/benchmarks.md](https://recursiveai.org/onerender/docs/benchmarks/). ## Fonts `RecursiveAI.OneRender.Fonts` embeds Liberation Sans, Serif and Mono (also answering to Arial, Helvetica, Times New Roman and Courier New), Carlito (Calibri) and Caladea (Cambria), unmodified, so output is the same on every machine. `.Image` references it, so the happy paths need no setup. Script coverage is opt-in: reference `RecursiveAI.OneRender.Fonts.Noto.<Script>` (Arabic, Hebrew, Devanagari, Thai and 25 more), `.Fonts.Cjk` (Noto Sans CJK, about 32 MB) or `.Fonts.Emoji` (Noto Color Emoji) and the package registers itself at start-up. When a glyph is missing, the engine records a diagnostic that names the package that covers it. System fonts are used only through a provider you pass in. WOFF and WOFF2 `@font-face` fonts work; on browser WebAssembly WOFF2 needs decoded bytes from the host because the platform has no Brotli. Details: [docs/text.md](https://recursiveai.org/onerender/docs/text/). ## Design - **Deterministic.** The same input, fonts and host time give the same fragments, display list, pixels and PDF bytes on every platform. No culture-sensitive APIs, ordinal comparisons, defined iteration orders. - **No network, no clock, no processes.** Resources come from the host's resolver; time comes from the host (`Advance(nowMs)`; renders default to time 0). - **Safe code.** No `unsafe`, no reflection, no runtime codegen. Hostile input is bounded: depth, node, page, counter, grid-track and iteration limits are checked before the work they guard, and recursion never follows tree depth. Fuzz targets for the parsers, layout, paint, incremental edits, PDF output and font data are in `fuzz/`. - **Two layers.** Every package exposes the low level; the happy-path APIs use only it. ## Support matrix | | | |---|---| | Runtime | `net10.0` | | NativeAOT | `tests/OneRender.AotSmoke` published with warnings as errors and run on win-x64, linux-x64, linux-arm64, osx-arm64 (CI) | | Trimming / WebAssembly | `tests/OneRender.WasmSmoke` published for `browser-wasm` with warnings as errors; the IL trim analysis is the check | | Build and tests | win-x64, linux-x64, linux-arm64, osx-arm64 (CI) | ## How it is checked Layout is compared with Chrome for Testing 156 on the same font files, element by element ([docs/geometry-differential.md](https://recursiveai.org/onerender/docs/geometry-differential/)); for example the six document templates are 99.967% within tolerance at both 1280×800 and 390×844. Web Platform Tests reftests gate each CSS module with a floor that only rises ([docs/wpt-results.md](https://recursiveai.org/onerender/docs/wpt-results/)). Tagged PDFs are validated against PDF/UA-1 with veraPDF. Measured numbers are under `results/`. ## Known limitations - **PDF/UA-1 with complex scripts.** Tagged PDF/UA-1 output with complex-script text (full Arabic, Devanagari or Thai sentences) can fail veraPDF rule 7.21.7, because glyphs inside multi-glyph clusters have no `ToUnicode` mapping. Extracted text is still correct. Pages that mix scripts with short complex-script words pass. - `<img>` of an SVG file is not drawn; inline `<svg>` is. - Vertical writing modes, date/time/color pickers, file input, `datalist`, CSS footnotes, scroll-driven animations and view transitions are not implemented. The full lists are in [docs/index.md](https://recursiveai.org/onerender/docs/index/) and the per-topic pages. ## Build and test You need the .NET SDK named in `global.json` (a newer feature band also works). ``` dotnet restore OneRender.slnx dotnet build --no-restore -c Release OneRender.slnx dotnet test --no-build -c Release --solution OneRender.slnx ``` Build output goes to `artifacts/` at the repository root, or to the directory in the `ONERENDER_ARTIFACTS` environment variable. Smoke apps check ahead-of-time compilation and trimming: ``` dotnet publish tests/OneRender.AotSmoke -c Release -r linux-x64 -warnaserror dotnet publish tests/OneRender.WasmSmoke -c Release -warnaserror ``` Use your own runtime identifier for the first (`win-x64`, `linux-arm64`, `osx-arm64`); NativeAOT on Linux needs `clang` and `zlib1g-dev`. Benchmarks: `bench/` ([docs/benchmarks.md](https://recursiveai.org/onerender/docs/benchmarks/)). Fuzzing: `fuzz/README.md`. Packages: `dotnet pack OneRender.slnx -c Release -o <dir>`, then `python3 tools/ci/check-packages.py <dir>`. The display list backends draw from is specified in `docs/display-list.md`. A game engine that draws the list itself (atlas keys, damage, host scopes, test-harness helpers) starts at `docs/game-hosts.md`. ## Licence `MIT OR Apache-2.0`, copyright Recursive AI, LLC (`LICENSE-MIT`, `LICENSE-APACHE`). Font packages are `(MIT OR Apache-2.0) AND OFL-1.1`. Bundled third-party material is listed in `THIRD-PARTY-NOTICES.md`. <!-- https://recursiveai.org/onerender/docs/pdf/ --> # PDF output (`RecursiveAI.OneRender.Pdf`) The backend turns the retained display list (`display-list.md`, format 1.2) into a PDF with OnePdf core's writer. It never reaches back into layout: everything it knows is in the list. Fonts, images and the structure tree come from the list items and their ids. ## API | Layer | Entry point | Use | |---|---|---| | Low level | `DisplayListPdfWriter(Stream, PdfOutputOptions?, IImageResolver?)`, `AddPage(list, PdfPageSetup[, RectF region])`, `Finish()`, `Diagnostics`, `PageCount` | one list, any number of pages, each page a region of the list in CSS px | | Happy path | `HtmlPdf.ToPdf(html, Stream, HtmlPdfOptions?, ct)` returns `HtmlPdfResult(PageCount, Diagnostics)`; `HtmlPdf.ToPdf(html, options)` returns the bytes | HTML to PDF in one call; the happy path uses only the public low-level pieces (`HtmlPage`, `PageFragmentation`, `Painter`, `DisplayListPdfWriter`) | `PdfPageSetup` is in CSS px (96 per inch; `A4` and `Letter` have 48 px margins); each edge is at most 14,400 px. `PdfOutputOptions` carries title, author, language, creation date (none by default, so output is deterministic), `Tagged` (default true), `Profile`, `Compress`, `DocumentId`, `RasterDpi` (default 192), `MaxPages` (default 100,000). With `Profile = None` the file streams: bytes are written as each page is committed and memory does not grow with the page count (the display list and layout of the whole document are of course in memory). With `PdfUA1`, `PdfA2a` or both, OnePdf can only write the XMP metadata when it re-saves a finished document, so the file is built in memory first, then re-saved with the identification; the write fails with `PdfRenderException` listing the rules it breaks if the file is not conforming. ## Pages `HtmlPdf.ToPdf` builds a `PagedDocument` (see the paged media section of `architecture.md`), so print style sheets apply: `@page` size and margins (the page setup in the options is only the default), margin boxes, named pages, forced breaks with blank pages, running strings, `counter(page)`/`counter(pages)`, tables of contents with `target-counter()` and `leader()`, and `thead`/`tfoot` groups (in 1.0.0 a table that continues onto another page keeps room for its header and footer there, but the copies don’t show). Break opportunities are those of CSS Fragmentation 3 §4.1: between block siblings, between line boxes, and between the rows of a table; a row's cells are never split, so a row taller than a page overflows it. Page *i* of the tree occupies the region at x = i · stride, so the pages sit side by side in one display list and each page is written from its own region, together with its page layer. The page layer (page background and margin boxes) is drawn first, as artifacts, so the running headers and page numbers are not in the structure tree and do not split the structure elements of the content. In-document links (`href="#id"`) are link annotations to named destinations the writer adds at the position of each target; `target-counter()` and `target-text()` make no link by themselves. A document that would need more than `MaxPages` pages makes `ToPdf` throw `PdfRenderException`. For a long document, `ToPdf` collects the heap between building, painting and writing (at 40 pages or more) and does not keep the cascade alive after the trees are built; the 10,000-row table (213 pages) peaks at about 340 MB instead of 440 MB. Layout and the display list of the whole document are still in memory; paging them out would need a layout that can be resumed page by page. ## Vector or island Everything PDF can express is written as vector content: | Display list | PDF | |---|---| | rectangles, rounded rectangles, paths | fills (even-odd and non-zero), curves | | borders: every style, radii, per-side colors | side quads and rings clipped to the corner diagonals, dashes as stroked lines | | box shadows without blur | filled shapes (inset via clip); blurred shadows are islands | | linear and radial gradients (and repeating ones) | axial and radial shadings; stops with alpha add a luminosity soft mask | | conic gradients | 180 wedge fills (a documented approximation; PDF has no conic shading) | | images | Flate-compressed RGB with a separate soft-mask for alpha; tiled and cropped placements | | glyph runs | text with the subsetted font, advances from the run's positions, vertical offsets from the run's y | | clips, transforms | `W n`, `cm`; 3D transforms are flattened to their affine part, with a diagnostic | | opacity, blend groups | `ca`/`CA`, `/BM`, or a transparency-group form when the group has more than one leaf | | opacity-only filters | as opacity | | masks whose layers are all `add` and of one mode | soft masks | Islands: anything with `PdfNative == false` or that PDF cannot express (filters other than opacity, backdrop filters, inline SVG, blurred shadows and text shadows, masks with other composites) is rasterized by the reference rasterizer (`.Image`) at `RasterDpi`, at most 16 million pixels per island and 400 million per document, and placed as an image. Each island adds a diagnostic that names the item and the resolution. An island over the cap is drawn at reduced resolution (per island) or skipped with a diagnostic (per document). A blurred box shadow is an island at a lower resolution than `RasterDpi`: a blur has no detail finer than its standard deviation (half the blur radius, CSS Backgrounds 3 §7.2), so the picture is sampled at 3 samples per deviation at most, and a wide shadow is a small image. The picture is clipped in the page content by the border box (outside it removed for an outer shadow, inside it kept for an inset one), so the box edge stays exact whatever the picture resolution. The picture is computed directly rather than drawn by the reference rasterizer: the coverage of the shadow shape (the signed distance of each sample to the rounded outline, as a one-pixel ramp), blurred by a separable Gaussian of standard deviation half the blur radius reaching three deviations, with edges extended, the colour's channels constant and its opacity times the coverage as the alpha channel. The Gaussian's exponential is computed from field operations only, so the bytes are the same on every platform. Shadows with equal geometry, colour and resolution (the same rectangle relative to its picture, radii, offset, spread, blur, in 1/64 px) share one embedded image. Known gaps, each with a diagnostic where it changes the picture: blend modes `hue`, `saturation`, `color` and `luminosity` have no operator in the writer and draw as normal; graphics-state nesting beyond 28 levels is flattened; pixelated image sampling is not requested (PDF viewers interpolate). ## Text A glyph run becomes real text. The writer embeds the face as a subset (OnePdf does the subsetting at output; the engine rebuilds an sfnt from the face's tables because it keeps no file bytes, and the shipped font files are never altered). `/ToUnicode` maps each used glyph to the source text of its cluster, and the marked-content `/ActualText` carries the run's source text, so extraction returns the source, including ligatures and bidi runs. When a profile is requested, a character the face lacks is not shown as `.notdef` (ISO 14289-1 7.21.8, ISO 19005-2 6.2.11.8): the glyph slot keeps its advance and draws the font's space, and the text stays in the structure. Without a profile the `.notdef` box is drawn like on screen. ## Tags Tagging is on by default (`Tagged`). The painter adds `BeginTag`/`EndTag` items when `PaintOptions.Tags` is set; the happy path sets it from `Tagged`. The mapping follows HTML-AAM / the PDF 2.0 HTML mapping: | HTML | Structure type | |---|---| | `h1`–`h6` | `H1`–`H6` | | `p`, `pre`, `dt`, `dd` | `P` | | `blockquote` | `BlockQuote` | | `ul`, `ol`, `menu` | `L`; `li` → `LI` with the marker as `Lbl` and the content in a virtual `LBody` | | `table` | `Table` only if it has a `th` (so layout tables stay untagged); `thead`/`tbody`/`tfoot`, `tr`, `th` (with `Scope` Column or Row), `td` | | `caption`, `figcaption` | `Caption` | | `a[href]` | `Link` with a link annotation (absolute URLs; the `aria-label` is the alternative description) | | `code`, `kbd`, `samp` | `Code` | | `img`, inline `svg` | `Figure` with `Alt`; an image with no or empty `alt`, or `role=presentation`, is an artifact | | `section`, `article` | `Sect` | | `div`, `main`, `nav`, `aside`, `header`, `footer`, `figure`, `form`, `fieldset`, `address`, `dl` | `Div` | Everything painted outside a tagged element (page backgrounds, borders of untagged boxes, decoration) is marked as an artifact. The document root is `Document`; the document language and the title come from the options, or the `lang` attribute and `<title>` in the happy path; `DisplayDocumentTitle` is set when there is a title. Empty `td`/`th` get an invisible anchor fill so the cell exists in the structure (otherwise the table is irregular, Matterhorn 15-003). Nesting is capped: the painter emits at most 14 tags in a chain, and the structure writer folds anything deeper than 96 into its parent. Elements inside a transparency-group form XObject cannot be tagged separately; their content belongs to the enclosing element. Identity: a tag item's element is `Id >> 16` (the box, the part, the occurrence in the low 16 bits). A pop of any other scope closes the tags open above it, so scopes always nest. ## Limits `PdfPageSetup` edges are 1 to 14,400 px and the margins must leave content (`ArgumentOutOfRangeException`); `MaxPages` fails with `PdfRenderException`; island pixel caps as above; tag nesting as above; clip and transform nesting 21 `q` levels per page and 6 transparency-group forms deep, beyond which the inner ones are flattened with a diagnostic. Only `PdfRenderException`, `ArgumentException` (and its subclasses), `DisplayListException` and `OperationCanceledException` leave the public methods. The `pdf` fuzz target (`fuzz/`) feeds hostile display lists, page sizes and profiles through the writer and checks the file reopens. ## Known limitation: complex-script text and PDF/UA-1 Tagged PDF/UA-1 output with complex-script text (full Arabic, Devanagari or Thai sentences) can fail veraPDF rule 7.21.7, because glyphs inside multi-glyph clusters have no `ToUnicode` mapping. Extracted text is still correct. Pages that mix scripts with short complex-script words pass. ## Checks - `tools/pdf/fetch-verapdf.sh` fetches veraPDF 1.30.3 (from software.verapdf.org, SHA-256 pinned) and a user-local Eclipse Temurin 21 JRE; veraPDF is GPLv3+/MPLv2+ and is only run as a separate program, never linked, committed or shipped. `tools/pdf/run-verapdf.sh` writes the six templates and the paged sample (`testdata/templates/paged`: contents with leaders and links, running header, page X of Y, a 90-row table with a repeated header) and validates each against PDF/UA-1 (`--flavour ua1`): all seven pass with zero failed rules. `results/paged/weasyprint.md` compares page counts and break positions with WeasyPrint, run as a separate program (information only). - `tests/OneRender.Pdf.Tests` run OnePdf's own PDF/UA-1 check on the same six, compare extracted text with the document text, pin table pagination and the limits. - `tools/pdf/compare-pixels.py` compares the raster backend's picture of each page with poppler's rendering of the PDF (mean SSIM over 7×7 windows on luma). Information only: the two rasterizers anti-alias text differently, so the templates score 0.89 to 0.98 per page (the long text-only report pages are lowest, about 0.92 on average). - `tools/pdf/OneRender.PdfRun` writes a generated 50-page report or a 10,000-row table and prints time and memory. ## Choices the specs leave open - The page is laid out at the content width of the first page and each page region is clipped to the content area; the margins hold the page layer. - A profile other than `None` buffers the file; `None` streams. - Links are annotations only for non-fragment targets; in-document anchors need positions the list does not carry. - Conic gradients are 180 wedges; at 192 dpi islands rasterize at twice the screen resolution. <!-- https://recursiveai.org/onerender/docs/razor/ --> # Razor components to PDF and images `RecursiveAI.OneRender.Razor` renders a Razor component to a tagged PDF or to pixels in one call. It runs the component on .NET's `HtmlRenderer`, serves the document's style sheets, images and fonts from the app's web root and static web assets, and passes the markup to `HtmlPdf.ToPdf` or `HtmlRender.ToImage`. Everything it does can be done by hand with those public APIs; it adds no private hooks. The package references the ASP.NET Core shared framework and nothing else. ```csharp builder.Services.AddOneRender(); // singleton RazorRenderer over IWebHostEnvironment.WebRootFileProvider app.MapGet("/invoice.pdf", async (RazorRenderer r, CancellationToken ct) => { RazorPdfResult pdf = await r.RenderPdfAsync<Invoice>(new Dictionary<string, object?> { ["Number"] = "INV-1" }, cancellationToken: ct); return Results.File(pdf.Bytes!, "application/pdf"); }); RazorImageResult img = await renderer.RenderImageAsync<Invoice>(parameters, new HtmlRenderOptions { Width = 800, Height = 600 }); byte[] png = img.ToPng(); ``` ## Behavior - **Parameters** are a name-to-value dictionary (null for none). The component renders in a fresh service scope per call, so scoped services behave as in a request. - **Documents and fragments.** A component whose output has an `<html>` element is used as is (a `<!DOCTYPE html>` is added if missing). Otherwise the markup is wrapped in a document with `RazorRenderOptions.Language` (default `en`), `Title` and one `<link>` per entry of `Stylesheets`. For a tagged PDF with a title and language, render a whole document or set those options. - **Options.** `HtmlPdfOptions` and `HtmlRenderOptions` pass through (page setup, output profile such as PDF/UA-1, viewport, device-pixel ratio, fonts). Their `BaseUrl` and `Resources` are replaced: the web root is asked first, then the options' own `Resources`. - **Assets.** `StaticAssetResolver` maps an address to a path below the web root (`css/app.css`, `/img/a.png`, `http://localhost/x?v=1`, `_content/<Library>/site.css`), decodes `data:` addresses, and refuses `..` segments. `RazorRenderOptions.AdditionalAssets` adds providers searched after the web root. In a web host `IWebHostEnvironment.WebRootFileProvider` already includes the static web assets of referenced libraries (in development through the manifest, when published as files under `wwwroot/_content`). - **Missing assets never throw.** The engine records a diagnostic and lays out without the file; the result's `MissingAssets` lists each path no provider had, and `RazorPdfResult.Diagnostics` carries the engine's diagnostics. - **Async and cancellation.** Rendering the component is asynchronous (components may await); laying out and painting then run on the calling continuation. Every method takes a `CancellationToken`: a token cancelled before the call, or while a component is still rendering, throws `OperationCanceledException`; layout and paint check it as `HtmlPdf` and `HtmlRender` do. - **Streams.** `RenderPdfAsync<T>(Stream, ...)` writes to the caller's stream and returns the page count with `Bytes` null. ## Sample `samples/InvoiceApp` is an ASP.NET app with an invoice component styled by Tailwind CSS 4.3.3 (`wwwroot/css/app.css`, compiled by `samples/InvoiceApp/build-css.sh` from `Styles/app.css`; the compiled file keeps Tailwind's MIT notice, see THIRD-PARTY-NOTICES). It serves `/invoice.pdf` (PDF/UA-1) and `/invoice.png`. `tests/OneRender.Razor.Tests` renders it through the renderer and through `HtmlPdf`/`HtmlRender` directly and requires byte-identical PDFs and pixel-identical images; `tools/pdf/run-verapdf-razor.sh` validates the sample's PDF against PDF/UA-1 with veraPDF. ## Limits - `<img>` of an SVG file is not drawn by `.Image` or `.Pdf` (only inline `<svg>` is); use PNG, JPEG, WebP or inline SVG. - The package is not part of the WebAssembly smoke (it needs ASP.NET Core). Generic entry points require the component type to be preserved by the trimmer; type arguments written in code are. <!-- https://recursiveai.org/onerender/docs/text/ --> # Text service (`OneRender.Text`) The service inline layout calls to turn computed font properties and text into shaped glyphs. It is independent of the box and fragment trees; layout reaches it through `IFontProvider` (`OneRender.Layout`), and `TextFontProvider` is the engine's implementation. OneFont does the shaping, bidi, line-break opportunities and grapheme segmentation; this layer adds CSS font matching, `@font-face`, per-character fallback, a shaped-run cache and metrics. ``` ComputedValues ─▶ TextFontProvider ─▶ FontRequest ─▶ FontCatalog.Resolve ─▶ ResolvedFontList (per-char fallback) │ │ └──────── Shape(text) ─▶ TextShaper (bounded cache) ◀────┘ ─▶ ShapedText ``` ## What layout calls | Call | Does | Frame cost | |---|---|---| | `IFontProvider.Resolve(in style)` | Font matching (CSS Fonts 4 §5.2) for the style's family list, weight, style and stretch. | 0 B after first use of the style | | `IFontProvider.Shape(text, in style, rtl)` | Shapes one bidi-level run with fallback; returns a cached `ShapedText`. | 0 B on a cache hit | | `IFontProvider.GetMetrics(in style)` | `FontMetricsInfo` of the primary face at `font-size`. | 0 B | | `TextFontProvider.Shaper.ShapeInto(run, text, request, rtl)` | Reshapes into a retained `ShapedText` buffer (no cache). | 0 B once the buffer has grown | | `TextAnalysis` | `GetBidiRuns` (UAX #9), `GetVisualOrder`, `GetLineBreakOpportunities` (UAX #14), `GetGraphemeStarts`. | caller-provided buffers | | `IFontProvider.Catalog` | Add fonts (`AddFont`), `@font-face` rules (`AddFontFace`), generic mapping, resolver, system-font provider, diagnostics. | document path | Providers, catalogs and shapers are single-thread objects. The happy path is `OneRender.Image.FontProviders`: `CreateDefault(resolver, systemFonts)` builds a `TextFontProvider` with the packaged faces and their aliases (`DefaultFonts.TryResolveFamily`) registered, and `ForCurrentThread()` returns one cached per thread. `HtmlRender.ToImage`, `HtmlPage` callers, `GeometryProbe` and the WPT and geometry tools use it. Page `@font-face` rules are added to `provider.Catalog` via `FontFaceRule.FromNode` or `FontFaceRule.Create`. Adding a face invalidates resolved lists (`FontCatalog.Version` increments), and cached runs for stale lists are no longer returned. ## Matching - Family list in order. A generic keyword resolves through `SetGenericFamily` if the host set one, otherwise through the aliases (with `DefaultFonts`: `sans-serif`, `system-ui` and `ui-sans-serif` → Liberation Sans, `serif` → Liberation Serif, `monospace` → Liberation Mono). Names are compared ASCII case-insensitively and go through the aliases first (Arial, Helvetica, Times New Roman, Courier New, Calibri, Cambria). - Within a family, CSS Fonts 4 §5.2: stretch first, then style, then weight, using the spec's search directions (weight 400/500 special cases, lighter below / heavier above the target). - After the request's list come the default faces: sans-serif, serif, monospace, then other registered families. - Synthetic bold applies when the request weight is at least 600 and the chosen face's heaviest weight is under 600; synthetic italic slants by 14° (or the requested oblique angle) when the chosen face is upright. Both obey `font-synthesis` and are reported as flags on `ResolvedFace` for paint; the text service never alters glyph outlines or advances. - `local()` consults only the host's `ISystemFontProvider` (off by default); it never sees the registered faces. - Computed-value conversion (`TextFontProvider.GetRequest`): a `font-size` of 0 or less becomes 0.001 px, weight is clamped to 1–1000. The request cache holds 4096 distinct font property sets and is cleared when full. ## First available font, `ex`, `ch`, `ic` The first available font of a style (CSS Fonts 4 §5.1) is the first listed family with a usable face whose `unicode-range` includes U+0020; a family limited to other characters is skipped even when listed first. The default faces after the author's list do not count in this pass. If no listed family qualifies, the old rule applies: the first family with a space glyph, else with any usable face. `PrimaryFace` and the strut metrics use it. The style computer asks the font provider for that face's metrics once per computed style, right after `font-family`, `font-size`, `font-weight`, `font-style` and `font-stretch` are known, and resolves `ex` (x-height), `ch` (advance of "0") and `ic` (advance of U+6C34) from them (CSS Values 4 §6.1.1). Where the face lacks the measure, the spec's fallbacks apply: 0.5 em for `ex` and `ch`, 1 em for `ic`. `font-size` itself is computed in the parent's font, so its `ex` and `ch` use the parent's face. The font set must be complete before styles are computed (the page registers `@font-face` rules first); cached styles keep the numbers they were computed with. A page built without a font provider uses the fallbacks. `rex`, `rcap`, `rch` and `ric` (CSS Values 4 §6.1.2) use the root element's font, not the element's: after the root's style is computed its face gives the four ratios, which every later style receives (`LengthContext.RootExRatio`, `RootCapRatio`, `RootChRatio`, `RootIcRatio`), so `10rch` is the same length in a monospace span as in the root. The root's own `rex` uses its own font. A style cached under a different root x-height ratio is recomputed. `white-space` is a shorthand of `white-space-collapse` and `text-wrap-mode` (CSS Text 4 §3.1, with `white-space-trim` accepted and ignored); OneHtml expands it, and both longhands inherit, so a child of `white-space: pre` keeps preserved newlines and no wrapping. ## `@font-face` `FontFaceRule` carries `font-family`, `src`, `font-weight`/`-style`/`-stretch` (ranges, with `auto` = 400 / normal / 100%; reversed ranges are swapped), `unicode-range` and `font-display` (recorded only: there is no clock-driven swap). `src` entries are tried in order: `url()` through the host's `IFontResourceResolver` (the engine never fetches; `data:` URLs are decoded in place), `local()` through the system provider, `format()`/`tech()` filtering (unknown formats, colour, AAT and palette tech are skipped; `variations` is accepted and the default instance is used). Faces load lazily the first time a character needs them. A face outside its `unicode-range` is skipped for that character. Failures become diagnostics, never exceptions. **In a document.** `HtmlPage.Create` removes the `@font-face` faces left by an earlier document from the provider's catalog, then walks the document's style sheets (top level, `@media`, `@supports` and `@layer` blocks that apply) and adds every rule. Font files are fetched through the page's `StylesheetBytes` lookup, the same host callback as style sheets, so a relative `src` resolves against the sheet's address. `HtmlRender`, `HtmlRenderSeam` and the WPT runner get this through `HtmlPage`. Faces load lazily during layout, so a font that first appears in a later incremental change is not loaded by it. `FontLoader.Load(bytes, limits)` reads TrueType/OpenType, WOFF (zlib) and WOFF2 (Brotli, with the glyf/loca and hmtx transforms). `FontLimits` bounds input size (32 MiB), decoded size (64 MiB) and table count (256) before anything is allocated. Runtimes without a native Brotli (browser WebAssembly) report WOFF2 as `UnsupportedFormat` with a message. ## Fallback and diagnostics `ResolvedFontList.Select(codePoint)` walks the list (cluster-aware: a base character and its combining marks stay in one face where one face covers the cluster). Private-use characters skip the generic and default faces. A character no face covers renders the primary face's `.notdef` and records a `TextDiagnostic` of kind missing glyph whose `Package` names the opt-in package that would cover it (`RecursiveAI.OneRender.Fonts.Noto.<Script>`, `.Fonts.Cjk`, `.Fonts.Emoji`; the table is `FontPackages`). `TextDiagnostics` keeps 512 entries and counts the rest in `Dropped`. Canonical-equivalence matching (Fonts 4 §5.4's normalization step) is not done. ## Opt-in fonts packages The default fonts cover Latin, Greek, Cyrillic and (through Liberation) most of Hebrew. Every other script comes from a package that ships unmodified upstream release files; `FontPackages` names the package for a missing character, and the table below is kept equal to it by a test (`OneRender.FontPackageTests`). | Package | Fonts (release) | Covers | |---|---|---| | `RecursiveAI.OneRender.Fonts.Noto.<Script>` | Noto Sans `<Script>` Regular and Bold where the release has a Bold (`unhinted/ttf` files; Noto Serif Tibetan, the only Tibetan family published) | Armenian, Hebrew, Arabic, Syriac, Thaana, Nko, Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil, Telugu, Kannada, Malayalam, Sinhala, Thai, Lao, Tibetan, Myanmar, Georgian, Ethiopic, Cherokee, Khmer, Mongolian, Balinese, Javanese; `Symbols` (Noto Sans Symbols and Symbols 2), `Math` (Noto Sans Math) | | `RecursiveAI.OneRender.Fonts.Cjk` | Noto Sans CJK 2.004, the Regular and Bold `.ttc` collections | Chinese, Japanese, Korean; families `Noto Sans CJK JP`, `KR`, `SC`, `TC`, `HK` (collection faces 0 to 4) | | `RecursiveAI.OneRender.Fonts.Emoji` | Noto Color Emoji 2.051, `NotoColorEmoji_WindowsCompatible.ttf` | color emoji, ZWJ sequences, flags | Source URLs, release tags and SHA-256 hashes are in each package's `fonts/MANIFEST.tsv`; `tools/fonts/optional_fonts.py` fetches, lays out and verifies them. The Sans families are shipped in the two weights `font-weight` matching needs (400 and 700); the other weights of a release, the hinted and variable builds, the UI and Serif variants and the Mono CJK faces are left out to keep each package small (the largest, CJK, is about 40 MB; Noto Serif CJK would add more than 100 MB and is not shipped). The CJK collections are used as published: all five regional faces are registered from the same bytes, so `font-family: "Noto Sans CJK SC"` selects the Simplified Chinese forms. The engine does not choose a region from `lang`; without a family name the Japanese face is the fallback. The emoji file is the upstream build with color bitmaps (`CBDT`/`CBLC`, PNG) and an empty `glyf` table, which every TrueType reader accepts; the `NotoColorEmoji.ttf` build without `glyf` is refused by OneFont, and the COLRv1 build needs vector paint graphs that no backend draws. **Registration.** Each package has a static class (`NotoArabicFonts`, `CjkFonts`, `EmojiFonts`) with `Package` (the faces, `FaceIndex` for collections, the licence and manifest streams) and `Register()`. An application that references the package registers it at start-up: the package's build files add a module initializer to the application that calls `Register()` (no reflection, safe for NativeAOT and trimming). `OptionalFonts.Packages` lists the registered packages in a fixed order (text scripts by package id, then emoji), whatever the registration order. `FontProviders.CreateDefault` registers their faces after the default fonts, so they are fallback faces: they supply only characters the listed families and the default fonts lack, and a missing-glyph diagnostic for their script goes away. `ForCurrentThread()` starts a new provider when a package is registered later. A host with its own `FontCatalog` reads `OptionalFonts.Packages` (or one `Package`) and adds the faces itself. **Color bitmap glyphs.** `ColorBitmapGlyphs.TryGet` reads a glyph's PNG and metrics from `CBLC`/`CBDT` (strike with the most pixels per em that has the glyph; index formats 1 and 3, image formats 17 and 18; anything else is no glyph). `.Image` draws each glyph as its bitmap, scaled by `font-size / ppem`, placed by the bearings, ignoring `color`; a blurred text shadow of such a run is not drawn. `.Pdf` writes the run's text invisibly (so it stays selectable and extractable, with `/ToUnicode`/`/ActualText` as for any run) and draws the bitmaps as images over it. COLR (v0 or v1), sbix and SVG-in-OpenType glyphs are not drawn. **PDF/UA and complex scripts.** OnePdf writes the text of a cluster that shaping split into several glyphs (Arabic letters with dots, Indic conjuncts, Thai marks) as `/ActualText`, so extraction returns the source text exactly, but veraPDF's PDF/UA-1 rule 7.21.7 wants a `/ToUnicode` entry for each such glyph and reports them. Pages in those scripts are checked for extraction; pages in the other scripts, and in words that shape one glyph per cluster, pass veraPDF (`tools/pdf/run-verapdf-fonts.sh`). ## `ShapedText` Glyphs are in **visual (display) order**, in CSS px, **y down** from the baseline origin, advances unrounded (rounding policy is decided against the Chrome differential). - `GlyphIds`, `X`, `Y`, `Advances`, `GlyphClusters` (per glyph: UTF-16 offset of its cluster in `Text`), `Width`, `Size`, `RightToLeft`. - `Clusters`: `GlyphCluster` records (text range, glyph range, advance, missing flag) that tile the text; line breaking cuts runs at cluster boundaries. - `Segments`: maximal glyph ranges in one face (`ResolvedFace` with its synthetic bold/italic flags). The display list's glyph run item takes one segment: the OneFont `FontFace`, glyph ids, positions, source text and cluster map. - `MissingGlyphCount`, `FirstMissingGlyph`. Kerning and common ligatures are on by default; `FontFeatureSettings` carries `font-kerning`, `font-variant-*` subsets and `font-feature-settings` tags. Text over `TextShaper.MaxRunLength` (1 Mi code units) throws `ArgumentException`. **Cache.** `TextShaper` keeps a 4-way set-associative table (default 4096 entries, LRU within a set) keyed on text, matched list, size and direction; texts up to 2048 code units are cached. A hit returns the same `ShapedText` object (do not modify it). `ClearCache` empties it. ## Metrics `FontMetricsInfo` (px at the requested size): ascent, descent, line gap (the horizontal header; the OS/2 typographic metrics when the font sets USE_TYPO_METRICS or has no header values; the Windows metrics as the last resort), x-height, cap-height, underline and strikethrough position and thickness, zero advance, and the `size-adjust`/`ascent-override`/`descent-override`/`line-gap-override` descriptors of an `@font-face` face applied. Values are not rounded. `font-size-adjust` is computed by layout from `XHeight`, `CapHeight` and `ZeroAdvance`. ## Limits and determinism Hostile font data fails with a status and detail, at the `FontLimits` above. A fuzz target (`fuzz/`, target `fontdata`) drives the loader, descriptor parser, matching, fallback and shaping. Nothing in the service reads a clock, the file system or the network; the same fonts and inputs give the same runs on every platform. <!-- https://recursiveai.org/onerender/docs/layout-conventions/ --> # Layout conventions Places where the specifications leave room, or where the engine follows what Chrome does and the specification text is looser. Each entry says what is done, the spec text it rests on, and the evidence. A port can reproduce flow layout from these notes and the specifications. Evidence is Chrome 156 (headless, fonts as in `geometry-differential.md`) measured through the geometry differential or a one-page probe; `testdata/text-block/` holds the pages. ## User-agent defaults the shared sheet lacks **Body margin.** `body { margin: 8px }`, applied as a user-origin rule so author rules and presentational hints override it. HTML §15.3.2 (The page): when no `marginwidth`/`leftmargin`/`marginheight`/`topmargin` attribute gives the body's margins "a default value of 8px is expected to be used". Pages that do not reset it shift every box by 8 px. ## Fonts **Generic `monospace` size.** A `font-family` list that is exactly the generic keyword `monospace` takes a size 13/16 of what the other lists take, wherever the size comes from a keyword (the default `medium` included). `monospace, monospace` and `"Courier New", monospace` are lists and keep the full size. CSS Fonts 4 §2.5 leaves the keyword table to the user agent. - A size given as a length (`font-size: 20px`, `1.2em`) is never scaled, and a length size that is only inherited keeps its value when the family changes. - An inherited keyword size is recomputed in the child's own family, so a proportional `span` inside a monospace block returns to the full size. - Keyword sizes inside a bare monospace list scale by the same 13/16 (the table is 9, 10, 13, 16, 18, 24, 32, 48 px at medium 16 px). - Evidence: `<code>` and `<pre>` heights on `essay`; `<span style="font-family:monospace">` inside `font-size:20px` is 20 px (no scaling), inside a keyword size it is scaled. **Relative sizes and weights do not compound.** `font-size` given as `em`, `%`, `larger` or `smaller`, and `font-weight: bolder`/`lighter`, are resolved once, against the parent, on the element that declares them. A descendant that only inherits takes the parent's computed value (CSS Values 4 §7.1, CSS Cascade 4 §7.3). Evidence: `field-notes` (`blockquote { font-size: 1.05em }` around a paragraph). ## Text **`white-space` through ancestors.** The `white-space` shorthand on an ancestor sets `white-space-collapse` and `text-wrap-mode` on it; both inherit, so inline elements and anonymous boxes inside keep preserved newlines, tabs and no-wrap (CSS Text 4 §3, CSS Text 3 §4.1.1). Evidence: `preformatted`, `<pre><code>`. **Preserved newlines.** A preserved segment break always ends a line, and one on a line of its own leaves an empty line as tall as the strut (the same as `<br>`). A trailing newline does not add a line (CSS Text 3 §4.1.3). Evidence: `preformatted` (`pre-line` with a blank line). **`text-transform`.** `none`, `capitalize`, `uppercase`, `lowercase`, by simple one-to-one case mapping per UTF-16 unit with the invariant culture, plus `ß` to `SS` for `uppercase`; every output character keeps the source index of the character it came from. `capitalize` puts the first letter of a word in upper case; a word continues over letters, digits and an apostrophe between letters, and across inline-element boundaries. Not implemented: `full-width`, `full-size-kana`, special casing beyond `ß`, language-specific mappings. Evidence: `terms` (uppercase headings and a `text-transform: uppercase` paragraph change where lines wrap). ## Flow **Empty block with `clear`.** CSS2 §9.5.2 says clearance "potentially" arises. For a block with no content and `height: auto` or `0` whose `clear` names a side that has floats in its formatting context, margins do not collapse through it, whether or not the floats reach it (CSS2 §8.3.1 requires "no clearance" for collapsing through; Chrome applies the rule whenever floats are present on that side). With the box placed at its hypothetical position or just below the floats (§9.5.2), the margins after it run from its top margin edge: next.y = box.y − margin-top + max(margin-top, margin-bottom, next.margin-top) With `margin-top: 0` that is the familiar margin before plus the larger of the margin after and the next box's margin. As the last child of its parent the plain rule holds (the parent reaches the box's border edge). Evidence: thirteen probe pages (float above, float reaching, margins 0/1/10/40, no float, no clear, adjacent float, new formatting context after it) all agree to the pixel; four are committed as `testdata/text-block/clear-*`. WPT `clear-clearance-calculation-003` and `margin-collapse-018`/`-027` hold under the same rule. ## Geometry grid **Line heights are cut to 1/64 px.** A used `line-height` that is not a whole number of 1/64 px (1.6 × 16 px = 25.6 px) is rounded down to the 1/64 px grid before the line box is built: 25.59375 px, not 25.6. Three such lines are 76.78125 px; without the rule they are 76.8 px, and over a long page the 0.02 px per paragraph adds up to whole pixels (the `report` and `statement` templates were off from the first mismatching paragraph to the end). Other lengths are not quantized: after this rule `templates` agrees with the browser to the tolerance everywhere else, so the engine keeps doubles. `LayoutUnits.Floor` is the one place the grid is written. Evidence: `testdata/templates/report` (Carlito 16 px / 1.6 inside `p`: browser heights 76.78 for three lines, 25.59 per `li`), `testdata/templates/statement`; before the rule 1,117 and 330 elements of the two templates were off, after it none. ## Line breaking where the browser differs from UAX #14 OneFont follows UAX #14. The browser breaks at a few more places and at a few fewer; the engine adjusts the opportunities after OneFont returns them (`InlineWorker.AdjustOpportunities`). Each row was measured as the width of `width: min-content` text in Chrome 156 (`testdata/chrome-probes/breaks`, `slash`). | Text | Browser | UAX #14 | Engine | |---|---|---|---| | `INV-2026-0418`, `1-2`, `Q1-2026`, `Net-30` (hyphen after a letter or digit, digits next) | breaks after each hyphen | no break (LB25 HY × NU) | breaks after the hyphen when the character before it is a letter or digit | | `-5`, `£12-3` start, `1.5%-2` (hyphen as a sign: at the start of a word or after `%`, `$`, `£`) | no break | no break | no break | | `a/b`, `and/or`, `a/1`, `http://x.test/a/b`, `dir/file.txt` | no break | break after `/` before a letter | no break after `/` when a letter, digit or `/` follows | | `a\|b`, `a!b` | no break | break after `\|`, break after `!` | no break when a letter or digit follows | | `a?b`, `a-b`, `a;b` | break after `?` and `-` | same | unchanged | A URL therefore wraps after a `?` or a hyphen and nowhere else, and a long path overflows its box instead of breaking (CSS Text 3 §5.5 `overflow-wrap` still applies). Not adjusted: `a--b` (the browser breaks between the hyphens). ## Tables and cells **`align` on a cell does not reach a table inside it.** `td align=center` centers the cell's content (HTML §15.3.8 "align descendants to the center") and places a table child by auto margins, but text inside that nested table starts over with the initial `text-align`. Email templates rely on it: a `td align=center` wrapping a left-aligned content table. Evidence: `testdata/templates/email`, the footer link was 74 px to the right before the rule. ## Box sizing **An over-wide box has no negative auto margin.** When a box with `margin-left: auto` (or right) is wider than its container, the auto margin is 0, not negative (CSS2 §10.3.3: if the width plus margins exceed the container, auto margins count as zero). A right-aligned table 320 px wide in a 278 px container starts at the container's left edge. Evidence: `templates/invoice` at 390 px. **Aspect ratio on flex containers.** A flex container with `aspect-ratio`, a definite width and an auto height takes its height from the ratio; its content height is the automatic minimum unless the box scrolls or has an explicit `min-height` (`min-height: 0` included), which turns that minimum off (CSS Sizing 4 §5). Block containers follow the same rule. Evidence: `chrome-probes/aspect` (an `avatar` of a 36 px wide flex box with a one-line label: 36 px tall, not 20 px). **Baseline of an inline flex or grid container.** It sits on the baseline of its first item (Flexbox §8.5, Grid §11.6) whatever its `overflow`, and an item without a baseline contributes the bottom edge of its border box. The line box around such a container is then no taller than the container when the first item's baseline lies above the strut's descender. This also holds when the container is only measured, as the content of a flex item. Evidence: `chrome-probes/ifx`, `ifx2`, `menu` (a menu `ul` of `display: inline-flex` in a navbar: 49 px, not 56 px; a stepper `display: inline-grid; overflow: auto` followed by a block: no 7 px descender). ## Columns **Balanced columns start from an equal share.** The balanced column height is the larger of the smallest height at which the content fits and an equal share of everything in the multicol container, trailing margin included; nothing trails the columns after that. Two columns of 9 items of 25.59 px with 4 px margins are 266.34 px high (the share, margin included); 18 items split 9/9 over 390 px columns where the second column is the taller one end at the last item's border edge, 4 px less than a content-plus-margin measure. Evidence: `templates/report`, the contents list at 1280 px and 390 px. ## Custom properties **`@property` initial values.** A custom property registered with `@property` and an `initial-value` has that value on every element (on the root, for `inherits: true`) until a declaration changes it. The cascade library does not read `@property`, so the engine feeds the initial values back as a user-origin sheet (`*, *::before, *::after { --x: v }` or `:root { --x: v }`), which any author declaration overrides. Registrations are read from top-level rules and from `@layer` and `@supports` blocks; at most 4,096 are used, values over 4,096 characters or containing `{`, `}` or `;` are ignored. Tailwind 4 declares `--tw-border-style: solid` this way, so without the rule every `border` utility draws no border. Evidence: `tailwind/flowbite-components` (cards lost 1 px of border on every side). ## HTML presentation **Closed `details`.** Everything in a `details` without `open` except its first `summary` has no box (HTML §15.5.5: the content slot is `content-visibility: hidden`). The engine applies `details:not([open]) > :not(summary:first-of-type) { display: none !important }` as a user-origin rule so an author's `display` on the hidden content cannot reveal it. Evidence: `tailwind/daisy-showcase` (a menu's dropdown `ul`). ## Replaced elements **A ratio with nothing to share.** A replaced box with an aspect ratio, no natural size, no specified width or height and an indefinite available width (an image that has not loaded, in a shrink-to-fit or flex-item measurement) has a 0 by 0 starting size. CSS2 §10.4 then scales with the ratio, not with the starting size's own 0/0: `min-height: 120px` on a 7/3 box gives a width of 280px. The result is always finite; a box whose size would come out non-finite contributes 0 to the intrinsic sizes of its container. Before this, one such image turned the main size of every flex container around it into NaN and placed later grid items at a non-finite offset. ## Form controls Sizes, colors and the paint of each control are in `forms.md`. The layout rules that reach beyond controls: **Flex min-content contribution.** A flex item's min-content contribution is its own min-content size even when `flex: none` (an inflexible, auto-basis item): the spec's clamp to the flex base size is not applied. Chrome 160 px against 196 px in `testdata/forms-probes/avatar/b`. **Fieldset content box and legend.** A grid or flex fieldset lays out only the content after its rendered legend, in an anonymous box inside its padding, with no gap between the legend and the first row. Evidence: `tailwind/daisy-app`, `forms-probes`. **Relative positioning of atomic inlines.** An inline-block, inline-flex or replaced box with `position: relative` is drawn shifted by its insets without moving the line (CSS2 §9.4.3). Evidence: WPT `css-transforms/transform-input-*` references. **Percentage heights of atomic inlines** resolve against the height of the block container the line is in, and stay `auto` when it is indefinite (CSS2 §10.5). Evidence: WPT `css-ui/widget-percentage-height-001`, `css-flexbox/flexitem-stretch-range`. **Table cell alignment.** A cell's `height` is a minimum: `vertical-align: bottom | middle` aligns what the children fill plus the bottom padding and border, not the specified height (CSS2 §17.5.3). Evidence: WPT `CSS2/linebox/vertical-align-applies-to-007`. ## Known deviations kept - **Initial quote and a space.** Chrome breaks after `« ` at the start of a text run; UAX #14 (LB15a, Unicode 15.1) does not, and OneFont follows the standard. A `float` whose text starts with `«` is wider in the engine in narrow containers. Pages avoid the pattern. - **Generated content** (`::before`/`::after`, `q` quotes, `list-style-position: inside` markers, `::first-line`) is not laid out yet; pages in `text-block` avoid it. - **Form controls** are laid out and painted as Chrome 156 does (`forms.md`). Known gaps: text editing, date, time, color and file inputs, and `datalist`; the select popup is not a true top layer; `:checked` and the other state selectors do not follow user changes. - **Transforms in the differential.** The browser reports a transformed element's box after the transform; the engine reports the layout box. `templates/receipt` has one such element (`scaleY(1.6)`). <!-- https://recursiveai.org/onerender/docs/game-hosts/ --> # Driving the engine from a game host A game engine that draws the display list with its own GPU renderer owns three things: the loop, the drawing, and the caches behind the drawing (glyph atlases, image textures, rasterized SVG). The engine owns layout, style, input handling and the list. This page is the whole surface between them. The list format itself is in [display-list.md](https://recursiveai.org/onerender/docs/display-list/); interaction in [architecture.md](https://recursiveai.org/onerender/docs/architecture/); the cost of each frame in [benchmarks.md](https://recursiveai.org/onerender/docs/benchmarks/). There is no GPU code in the engine, and nothing here needs a clock, a thread or a file: the host passes the time of every frame and every input, and supplies images through its own tables. The same inputs on the same fonts give the same list on every machine. ## Contents 1. [Two front ends, one list](#two-front-ends-one-list) 2. [The loop](#the-loop) 3. [Walking the list](#walking-the-list) 4. [Atlas keys](#atlas-keys) 5. [Damage](#damage) 6. [Host scopes](#host-scopes) 7. [Harness support](#harness-support) 8. [Determinism and limits](#determinism-and-limits) 9. [What the list already carries](#what-the-list-already-carries) 10. [The cost of an animated layout property](#the-cost-of-an-animated-layout-property) ## Two front ends, one list - **A C# builder** (`StyledTree`, `StyleDeclaration`) with no HTML anywhere, run by `LivePage`: edit the tree (`SetDeclaration`, `SetText`, `SetStates`, insert, remove), call `Flush()`, read `LivePage.Display`. Style values are CSS text through the same parsers as a stylesheet, so a builder tree and an HTML page compute alike. - **HTML and CSS** (`InteractivePage`, over OneHtml's document and cascade): the same pipeline plus hit testing, DOM events, focus, forms, scrolling input and CSS transitions and animations on the host's clock. Both end in a `RetainedDisplayList`, which the host reads. ## The loop The builder path: ```csharp var tree = new StyledTree(); StyledNode root = tree.AddRoot("div", new StyleDeclaration().Set("width", "1280px").Set("height", "720px")); // ... AddElement, AddText ... var page = new LivePage(tree, new LivePageOptions(fonts) { ViewportWidth = 1280, ViewportHeight = 720, Images = images, DevicePixelRatio = 2 }); while (running) { // edits from game state: tree.SetText(node, "Gold 120"); tree.SetStates(node, ElementStates.Hover); ... FlushResult r = page.Flush(); // restyle, patch boxes, lay out what changed, update the list; 0 B for the usual edits if (r.Repainted) { Draw(page.Display.Current, page.Display.Damage); // your renderer page.Display.ClearDamage(); } } ``` The HTML path adds input and the animation clock: ```csharp InteractivePage page = InteractivePage.Create(html, new HtmlPageOptions(fonts) { ViewportWidth = w, ViewportHeight = h, Images = images }, new InteractionOptions { AnimationStartTime = nowMs }); var painter = new Painter(); var list = new RetainedDisplayList(); var paint = new PaintOptions(w, h) { Scroll = page.Page.Scroll, Images = images, DevicePixelRatio = dpr }; painter.Paint(page.Page.Fragments, list, paint); each frame: InputOutcome o = InputOutcome.None; foreach (input in frameInputs) o |= page.HandlePointer(input); // also HandleKey, HandleWheel, InsertText o |= page.Advance(nowMs); // transitions and animations, on your clock if ((o & (InputOutcome.Relaid | InputOutcome.EditChanged)) != 0) page.Repaint(painter, list, paint); // rewrites only what changed else if ((o & InputOutcome.Animated) != 0) page.PatchAnimations(painter, list); // transform and opacity only: no layout if ((o & InputOutcome.Scrolled) != 0) painter.PatchScroll(list); Draw(list.Current, list.Damage); list.ClearDamage(); ``` Steady-state frames and the usual changes (hover restyle, a text change, one subtree relayout, a transform or opacity animation, a layout-property transition) allocate 0 B; the tests that hold this are listed in [benchmarks.md](https://recursiveai.org/onerender/docs/benchmarks/). Hover and text through the HTML front end still allocate in OneHtml's cascade; the builder path does not. ## Walking the list A frame is the items of `RetainedDisplayList.Current` in order, back to front. A GPU backend keeps one stack and never recurses; the full sketch is in [display-list.md](https://recursiveai.org/onerender/docs/display-list/#writing-a-backend). The short version for a host with passes: ```csharp DisplayList list = retained.Current; if (list.RequiredMinor > DisplayList.FormatMinor) throw new NotSupportedException(); // newer than this reader stack.Clear(); for (int i = 0; i < list.Count; i++) { DisplayItem it = list[i]; switch (it.Kind) { case ItemKind.PushHostScope: stack.Push(currentMaterial); currentMaterial = it.HostScope; break; // your number case ItemKind.PopHostScope: currentMaterial = stack.Pop(); break; case ItemKind.PushClipRect: case ItemKind.PushClipRoundedRect: case ItemKind.PushClipPath: /* push clip */ break; case ItemKind.PopClip: /* pop clip */ break; case ItemKind.PushTransform: /* current = it.Matrix * current */ break; case ItemKind.PushGroup: /* opacity it.Opacity, blend it.Blend (including PlusLighter) */ break; case ItemKind.FillRect: case ItemKind.FillRoundedRect: case ItemKind.Border: case ItemKind.BoxShadow: /* ... */ break; case ItemKind.GlyphRun: DrawGlyphs(it.GlyphRun, currentMaterial); break; case ItemKind.Image: DrawImage(it, currentMaterial); break; case ItemKind.SvgImage: DrawSvg(it, currentMaterial); break; case ItemKind.BeginTag: case ItemKind.EndTag: break; // structure only // PushFilter, PushMask, PushMaskLayer, BackdropFilter, PushTransform3D: effects, drawn as scopes (see below) } } ``` `it.RootBounds` is the item's box in root space after transforms and clips, and is empty when nothing can show: skip those and cull against the viewport or the damage rectangle with it. Coordinates are CSS px; multiply by your device-pixel ratio. Tags are only emitted when `PaintOptions.Tags` is set, which an interactive frame does not set. ## Atlas keys | Cache | Key | Where it comes from | |---|---|---| | glyph atlas | (`FaceKey`, size × DPR, glyph id) | `GlyphRunView.FaceKey`, `FontSize`, `GlyphIds`. The face key is the same in every list, frame and process for the same font file. `FaceIndex` only numbers the faces of one list; do not key on it. | | rasterized SVG | (`SvgKey`, size × DPR) | `DisplayItem.SvgKey`: equal for equal markup and `currentColor`, whatever the rectangle, list or frame. Read `SvgMarkup` and rasterize only on a miss. | | image texture | the handle | `DisplayItem.Image` is the `ImageHandle` the host's `IImageSource` gave the engine; the engine never sees pixels. | | per-item GPU data | (`Id`, `ContentHash`; and `ContextHash` and `RootBounds` for anything baked with position or enclosing effects) | stable ids; hashes change when the item changes. | Glyph outlines come from `GlyphRunView.Face` (a `OneFont.FontFace`, shared: every list refers to the same object for the same font) and glyph positions from `OriginX`, `X[]`, `Y[]`; the outline is `face.GetOutline(glyphId)` scaled by `FontSize / UnitsPerEm`, y flipped. The face key is defined in [display-list.md](https://recursiveai.org/onerender/docs/display-list/#glyph-runs), the SVG key in [display-list.md](https://recursiveai.org/onerender/docs/display-list/#effects-12), so a port can reproduce them. ## Damage `RetainedDisplayList.Damage` collects what changed since the host last called `ClearDamage()`: at most 16 rectangles in root CSS px (`ToDevicePixels(dpr)` for the bounding box in whole device pixels). It covers every pixel that differs between the frames, and an edit of one item adds only that item's bounds. Every update path (flush, repaint, patch of a transform, opacity, scroll or animation) adds to it. **New pixels behind an image handle.** When the host replaces the pixels of a handle and keeps their size, it says so: ```csharp int items = page.Display.InvalidateImage(new ImageHandle(7)); // LivePage.InvalidateImage(handle) does the same ``` Every `Image` item that draws handle 7 (plain images, tiled backgrounds, the pieces of a nine-slice border image) adds its bounds to the damage; nothing is laid out or painted again, no item or hash changes, and the call allocates nothing (one pass over the items). A host that caches the texture by handle re-uploads it itself. With the list alone: `DisplayList.InvalidateImage(handle, damageRegion)`. A new size is a layout change, which goes through the styled tree. ## Host scopes A host scope names a pass: a pair of items (`PushHostScope`, `PopHostScope`, [display list 1.3](https://recursiveai.org/onerender/docs/display-list/#host-scopes-13)) carrying one `int` the host chose. The engine gives it no meaning, and a backend that ignores it draws the content unchanged. **Setting it.** On an element, with the custom property `--host-scope`: ```css .glow { --host-scope: 3; } /* any integer in the int range: material 3 */ .plain { --host-scope: none; } /* no scope (the initial state) */ ``` or from the builder, `new StyleDeclaration().SetHostScope(3)` (the same as `.Set("--host-scope", "3")`). It also works in an inline `style` attribute and under `:hover` and the other states, so a hover can switch a material. `none`, a non-integer or anything else invalid means no scope. The property is not animatable: a transition or animation never interpolates it. **Which element.** Custom properties inherit, but a scope belongs to the element that sets it: in HTML an element gets a scope only where its `--host-scope` differs from its parent's, so setting it on a container scopes the container once and not each descendant, and a child that sets another value opens a nested scope inside it. (In the builder, a declaration applies to its own node only.) An element with a scope paints as one unit, like one with `isolation: isolate`: it is a stacking context, so nothing from outside paints between its parts, and a scope on an inline element makes it paint atomically as an opacity would. **Where it sits.** For one element the engine emits, outermost first: the scroll or sticky transform, the element's `transform`, its opacity/blend group, its mask, `clip-path` and filter, **the host scope**, then its background and border, its overflow clip and its descendants. A host that renders each scope as a pass therefore gets the element's whole subtree, and the pass is drawn under the element's own transform, opacity and filter. Scopes nest inside each other and inside every other scope kind; the innermost applies. The reader keeps its own stack (above) or finds the enclosing scope through `ParentIndex`. **Cost and compatibility.** A scope's number joins the context hash of everything inside it, so changing the number damages exactly what is inside. A list with no scope is still readable by a 1.2 reader (`RequiredMinor` is 3 only for lists that contain one). ## Harness support For tests and automation that drive a UI by name. **Finding.** `InteractivePage.FindElement(name)` returns the first element in document order whose `id` is `name`; failing that, the first form control (`button`, `fieldset`, `input`, `object`, `output`, `select`, `textarea`) whose `name` attribute is `name`; else the null node. Comparison is exact and the scan allocates nothing. For a builder tree, give nodes names with `StyledTree.SetName(node, "gold")` and find them with `StyledTree.FindByName("gold")`, which falls back, in a tree built from HTML, to the same id and form-name rules. A name belongs to one node (naming another node moves it), a removed node is not found, and `Clear()` forgets all names. Names are harness state: layout never reads them. **Activating.** `InteractivePage.Click(node, timeMs)` is HTML's `click()`: a `click` event (a `PointerEvent` with `detail` 0 and no position) goes to the element along the normal capture, target, bubble path with the time stamp you pass, and unless a listener cancels it the element's activation runs (a checkbox or radio toggles before the event so listeners see the new state and is put back if a listener cancels; a label activates its control; a select opens). No `pointerdown`, `mousedown`, `pointerup` or `mouseup` fire, nothing is hit-tested and focus does not move, so the result does not depend on layout, scrolling or what covers the element, and a disabled control does nothing, as `click()` says. It returns an `InputOutcome` like every input. For a test that must go through hit testing, compute the center from `Layout.GetBoundingClientRect(node)` and send `HandlePointer` down and up. ```csharp Node go = page.FindElement("start"); page.Events.AddEventListener(EventTarget.Of(go), "click", e => started = true); page.Click(go, nowMs); ``` A builder tree has no DOM, so it has no events to dispatch: the host that drives a builder UI dispatches its own input to the nodes it finds by name. ## Determinism and limits - No clock: time enters through `Advance(nowMs)`, input timestamps and `Click(node, timeMs)`. No threads, no file system, no network; images and fonts come from the host's tables. - The same document, fonts, viewport, inputs and times give the same list, ids, hashes and keys on every platform. Keys (`FaceKey`, `SvgKey`) are FNV-1a over defined bytes, not runtime hash codes. - Iteration orders are defined; `FindElement` and `FindByName` return the first match in document order. - Bounds are checked before work: 256 open scopes, 1,048,576 items, and the other limits of [display-list.md](https://recursiveai.org/onerender/docs/display-list/#limits-and-failure). ## What the list already carries - **Images** are `ImageHandle` values the host resolved through `IImageSource`; source and destination rectangles are in image pixels and CSS px; `Smooth` and `Pixelated` sampling. Tiled backgrounds are one item with a tile and an area. - **Nine-slice** (`border-image`) is decomposed into ordinary `Image` items, one per slice, all on one handle with different source rectangles. - **Additive blending** is `BlendMode.PlusLighter` on a `PushGroup` (`mix-blend-mode: plus-lighter`). - **Glyph runs** share the `OneFont.FontFace` objects, so shaping and outlines are the host's to use directly. - **Ids** are stable 64-bit values: repainting an unchanged box gives the same ids, which is what damage and your per-item caches use. - **Effects** are scopes: filters, masks (with layers), backdrop filters, `clip-path`, 3D transforms with perspective. ## The cost of an animated layout property A CSS transition on one row's `width` in the 1,000-node HUD, ticked every frame through `InteractivePage.Advance` and `Repaint` (`MotionBenchmarks.WidthTransitionTick`): each frame interpolates the width, restyles the row, lays out its subtree and updates the list in place. Measured on an AMD Ryzen 9 7950X3D (benchmarks pinned to 8 cores, BenchmarkDotNet, 3 launches of 40 iterations): **65 µs per frame and 0 B per frame**. For comparison on the same page: a hover restyle is 3 µs, a text change 46 µs, a subtree resize 405 µs, inserting a 50-node subtree 551 µs and removing it 401 µs, all 0 B. See [benchmarks.md](https://recursiveai.org/onerender/docs/benchmarks/) for the method and `results/bench/interactive/gate-e7f53db/` for the run. <!-- https://recursiveai.org/onerender/docs/display-list/ --> # The display list The display list is what the painter produces and every backend consumes: a flat, ordered, retained list of paint items for one frame or page. This document is the format. A backend (the image rasterizer, the PDF writer, a game engine's GPU renderer) can be built from it alone; it never needs the layout trees, the style system or the source of the engine. Types live in `RecursiveAI.OneRender`, namespace `OneRender.Painting`: `DisplayList`, `DisplayItem`, `RetainedDisplayList`, `DamageRegion`. The reference rasterizer is `OneRender.Image.DisplayListRasterizer`. ## Contents 1. [Version](#version) 2. [Space, units and conventions](#space-units-and-conventions) 3. [Structure](#structure) 4. [Ids](#ids) 5. [Items](#items) 6. [Paints and gradients](#paints-and-gradients) 7. [Borders](#borders) 8. [Box shadows](#box-shadows) 9. [Images](#images) 10. [Glyph runs](#glyph-runs) 11. [Clips, transforms, groups, tags](#clips-transforms-groups-tags) - [Effects (1.2)](#effects-12) - [Host scopes (1.3)](#host-scopes-13) 12. [Bounds](#bounds) 13. [Damage](#damage) 14. [Patching](#patching) 15. [Retained lists and allocation](#retained-lists-and-allocation) 16. [Limits and failure](#limits-and-failure) 17. [Writing a backend](#writing-a-backend) 18. [The image backend](#the-image-backend) 19. [Choices the specs leave open](#choices-the-specs-leave-open) ## Version The format has a major and a minor number (`DisplayList.FormatMajor`, `FormatMinor`; now 1.3). - A reader of major *N* must refuse lists of any other major. - A minor release only **adds**: new item kinds (new numbers), new properties on existing kinds with defaults that mean "as before", new enum members. It never renumbers, repurposes or removes. - `DisplayList.RequiredMinor` is the lowest minor a reader needs to draw this particular list. A reader of minor *M* must refuse a list whose `RequiredMinor` is above *M*, because an item it does not know cannot be ignored: masks, filters and clip-path shapes change the pixels of everything inside them. A list that uses only items of version 1.0 has `RequiredMinor` 0 whatever library version made it. Format 1.1 (below) adds two properties; a list that uses either has `RequiredMinor` 1. Format 1.2 adds the effect items listed under [Effects](#effects-12); a list that uses any has `RequiredMinor` 2. Format 1.3 adds the host scope (`PushHostScope`/`PopHostScope`); a list that contains one has `RequiredMinor` 3, and every other list keeps the `RequiredMinor` it had, so a reader of 1.2 still reads it. The 1.3 reader properties `DisplayItem.SvgKey`, `GlyphRunView.FaceKey` and the image invalidation call add no item and change no list. - Kind numbers 32 to 63 are reserved for effects that need a minor bump. 1.2 uses 32 to 38 and 40 (see [Effects](#effects-12)) and 1.3 uses 41 and 42 (see [Host scopes](#host-scopes-13)); 39 and 43 to 63 stay reserved. They follow the same push/pop discipline as the existing pairs, and a reader that does not know them refuses the list rather than skipping them. `clip-path` needs no kind of its own: its shapes are `PushClipRect`, `PushClipRoundedRect` and `PushClipPath`. - Everything that is not a public member (storage layout, pool indices, hash values) is not part of the format. Reading goes through `DisplayList[i]` and `DisplayItem`. ## Space, units and conventions - **Units:** CSS pixels, as `float`. The list knows nothing about device pixels. A backend multiplies every coordinate and length by its device-pixel ratio when it draws (`RasterOptions.DevicePixelRatio` in the image backend). - **Axes:** x grows to the right, y grows downward. The origin is the top-left of the root (the viewport or the page). - **Root space:** the space of the list before any transform item. Transform items change the space for their content only until the matching pop. - **Rectangles** are `RectF(X, Y, Width, Height)`; a rectangle with zero, negative or NaN width or height is empty and paints nothing. The list rejects NaN, infinities and any coordinate beyond ±1e9 with `ArgumentOutOfRangeException` before storing anything. - **Matrices** are `System.Numerics.Matrix3x2` in row-vector form: `x' = x*M11 + y*M21 + M31`, `y' = x*M12 + y*M22 + M32`. `m1 * m2` applies `m1` first. A transform item's matrix maps its content's space to its parent's space; `transform-origin` is already folded into it by the painter. - **Colors** are `PaintColor(R, G, B, A)`, sRGB, straight (not premultiplied) alpha, floats in 0 to 1 (values outside are clamped when stored). Other color spaces are resolved to sRGB by the painter. - **Pixel snapping:** none in the list or in the reference rasterizer. A painter that wants crisp edges (CSS pixel-snapped backgrounds and borders) snaps the rectangles it emits. - **Anti-aliasing:** edges of all shapes are anti-aliased; the exact filter is the backend's. ## Structure A list is a sequence of **items** in paint order, back to front. Items of two kinds: - **Leaf items** paint something: `FillRect`, `FillRoundedRect`, `FillPath`, `Border`, `BoxShadow`, `Image`, `GlyphRun`. - **Push items** open a scope and a matching **pop item** closes it: clips (`PushClipRect`/`PushClipRoundedRect`/`PushClipPath` … `PopClip`), `PushTransform` … `PopTransform`, `PushGroup` … `PopGroup`, `BeginTag` … `EndTag`. Scopes nest strictly (a stack): a pop must close the innermost open push, and its family must match (any clip push closes with `PopClip`). Nesting is limited to 256 open scopes of all families together. A finished list has none open (`Validate()` throws `DisplayListException` otherwise; `RetainedDisplayList.Commit` calls it). For every item a reader can ask (`DisplayItem`): | Property | Meaning | |---|---| | `Kind`, `Id` | what it is; the painter's id (`ItemId.None` for pops) | | `LocalBounds` | the area the item can touch in the space it is painted in; empty for items without pixels | | `RootBounds` | `LocalBounds` through all enclosing transforms, intersected with all enclosing clips' bounds; empty if clipped away entirely | | `ParentIndex` | index of the innermost enclosing push, or -1 | | `MatchIndex` | for a push the index of its pop, for a pop the index of its push | | `ContentHash`, `ContextHash` | hashes for change detection, see [Damage](#damage) | A backend walks the items once in order, keeping its own stacks (see [Writing a backend](#writing-a-backend)). It never needs recursion, and the list never nests deeper than 256. ## Ids Every item except a pop has an `ItemId` (a `ulong`, zero reserved) chosen by **whoever paints the item**. The contract: - Ids are unique within one list; adding a duplicate or zero id throws `DisplayListException`. - Repainting an unchanged box emits the **same id**. That is what lets damage tell "unchanged" from "removed and added", and lets a backend keep a resource (a GPU texture, a PDF XObject) per id. - Ids carry no order and no meaning to the list. `ItemId.Of(owner, part, sub)` packs `owner << 32 | part << 16 | sub` as a convention: one owner number per box or text run, one part per thing it paints (background layers, border, shadow, text fragments), one sub-index per repeat. - A push item has an id of its own and so does a tag; patching addresses a transform or group by its id. ## Items All fields are in CSS pixels unless stated. "Rect" is the item's primary rectangle (`DisplayItem.Rect`). | Kind | Number | Fields | Notes | |---|---|---|---| | `FillRect` | 1 | rect, paint | | | `FillRoundedRect` | 2 | rect, radii, paint | radii constrained on entry | | `FillPath` | 3 | verbs, coordinates, fill rule, paint | | | `Border` | 4 | rect (border box), radii (outer), four sides | see [Borders](#borders) | | `BoxShadow` | 5 | rect, radii, color, offset x/y, blur, spread, inset | see [Box shadows](#box-shadows) | | `Image` | 6 | image handle, source rect, destination rect, sampling | see [Images](#images) | | `SvgImage` | 8 | rect, `currentColor`, svg markup | 1.2, see [Effects](#effects-12) | | `GlyphRun` | 7 | face, size, origin, color, glyph ids, x, y, clusters, text, direction | see [Glyph runs](#glyph-runs) | | `PushClipRect` | 16 | rect | | | `PushClipRoundedRect` | 17 | rect, radii | | | `PushClipPath` | 18 | verbs, coordinates, fill rule | | | `PopClip` | 19 | | | | `PushTransform` | 20 | matrix | | | `PopTransform` | 21 | | | | `PushGroup` | 22 | opacity, blend mode | | | `PopGroup` | 23 | | | | `BeginTag` | 24 | role, alt text, link target, language | | | `EndTag` | 25 | | | | `PushFilter` / `PopFilter` | 32 / 33 | filter chain | 1.2 | | `PushMask` / `PopMask` | 34 / 35 | | 1.2 | | `PushMaskLayer` / `PopMaskLayer` | 36 / 37 | mode, composite operator | 1.2, only directly inside a mask | | `BackdropFilter` | 38 | rect, radii, filter chain | 1.2 | | `PushTransform3D` | 40 | 4×4 matrix | 1.2; closed by `PopTransform` | | `PushHostScope` / `PopHostScope` | 41 / 42 | host number (`int`) | 1.3, see [Host scopes](#host-scopes-13) | **Radii** (`CornerRadii`) are eight numbers: a horizontal and a vertical radius for each corner (top-left, top-right, bottom-right, bottom-left), an elliptical quarter at each corner. The list stores radii after CSS Backgrounds 3 §5.5: negative radii become zero, and if the radii on any side add up to more than that side, all eight are multiplied by the one factor that makes the worst side fit. A consumer therefore draws the stored radii as they are. A corner whose horizontal or vertical radius is zero is square. **Paths** are arrays of verbs (`Move` 2 coordinates, `Line` 2, `Quad` 4 (control, end), `Cubic` 6 (control 1, control 2, end), `Close` 0) and a flat coordinate array whose length must match the verbs exactly. A figure starts with `Move`; `Close` ends it. After a `Close` the next figure starts with a new `Move`. Fill rules are `NonZero` and `EvenOdd` (SVG `fill-rule`). ## Paints and gradients Fill items carry a `Paint`: a solid color, or a gradient created on **the same list** before the item (`LinearGradient`, `RadialGradient`, `ConicGradient`). A gradient paint is valid only for its list and only until the list is cleared. A gradient is described in the coordinates of the item that uses it, not in the unit square of a box: the painter has already resolved the gradient box, the ending shape, angles and `calc()` lengths. To tile a background (`background-repeat`), the painter emits one fill per tile or clips and offsets; the list has no tiling. **Stops** are `(offset, color)`. The painter has applied color-stop fixup (CSS Images 3 §3.4.3: missing positions distributed, positions clamped to be non-decreasing) and expanded transition hints into stops. The list only raises a smaller offset to the previous one. Offsets may lie outside 0 to 1. Between stops colors interpolate **linearly in premultiplied sRGB** (CSS Images 3 §3.5.2). Two stops at one offset make a hard edge: a position equal to that offset takes the later stop's color. Before the first stop the first color holds and after the last the last color, unless the gradient repeats. | Kind | Geometry | Position *t* of a point | |---|---|---| | Linear | start (x0, y0), end (x1, y1) | the point's projection on the line, 0 at start and 1 at end: `((p − p0)·d) / |d|²` (CSS Images 3 §3.1) | | Radial | center (cx, cy), radii (rx, ry) | `sqrt(((x − cx)/rx)² + ((y − cy)/ry)²)`: 0 at the center, 1 on the ellipse (§3.2); a circle when the radii are equal | | Conic | center (cx, cy), from-angle (radians) | the angle of the point clockwise from "up" (negative y), minus the from-angle, in turns and wrapped to [0, 1) (CSS Images 4 §3.3) | **Repeating** gradients map *t* into the interval from the first to the last stop: `t' = first + frac((t − first) / (last − first)) · (last − first)`. **Degenerate cases:** a linear gradient whose line has zero length, and a radial gradient with a zero radius, paint the last stop's color everywhere. A repeating gradient whose first and last offsets coincide paints the average of the stop colors (CSS Images 3 §3.5). ## Borders `Border` draws the ring between the rounded **border edge** (the item's rect and radii, the outer edge) and the **padding edge**, following CSS Backgrounds 3 §4 to §5. - The padding edge is the rect pulled in by the four side widths (top, right, bottom, left). Each inner radius is the outer radius minus the width of the adjacent side (horizontal radius minus the left or right width, vertical radius minus the top or bottom width), floored at zero (§5.2). If the widths leave no inner rectangle, the ring is the whole outer shape. - Each side owns the part of the ring on its side of the **corner diagonals**: the line from each outer corner of the rect to the matching inner corner of the padding rectangle (§5.3). Where the widths of opposite sides exceed the box, the inner corners are brought together in proportion to the widths. Where a side's width is zero the neighbours' diagonals meet it square. - A side is not drawn if its width is zero, its style is `None` or `Hidden`, or its color is fully transparent. The painter passes width zero for `None` and `Hidden`. - When all four sides are `Solid` with one color, the ring is drawn as one shape (no seams along the diagonals). Styles, per side (`BorderStyle`): | Style | Drawing | |---|---| | `Solid` | the side's part of the ring in the side color | | `Dashed` | along the middle of the ring: dashes of length 2w, gaps of w, w the side width, cut to the side's part of the ring | | `Dotted` | round dots of diameter w with a gap of w, cut the same way | | `DoubleLine` | two solid rings, each a third of the width, with a third between them. Widths under 3 px draw solid | | `Groove`, `Ridge` | two half-width rings, an outer one and an inner one, one dark and one in the side color. Groove: the outer ring is dark on the top and left sides and light on the bottom and right sides, the inner one the opposite; ridge swaps them | | `Inset` | dark on the top and left sides, the side color on the bottom and right | | `Outset` | the side color on the top and left sides, dark on the bottom and right | "Dark" is the side color with each RGB channel multiplied by 2/3 (alpha unchanged). The dash and dot patterns start at the top-left of the middle line and are not fitted to side lengths. These pattern, dash and darkening choices are the reference rasterizer's (see [Choices the specs leave open](#choices-the-specs-leave-open)); the geometry above is normative. ## Box shadows `BoxShadow` follows CSS Backgrounds 3 §7. - **Outer** (`inset` false): `rect` and `radii` are the border box. The shadow shape is the rect moved by (offset x, offset y) and grown by `spread` on every side (shrunk if negative); each non-zero radius grows by `spread` and is floored at zero, and a square corner stays square (§7.1). The shape is blurred and painted in `color`, but **not inside the border-box edge** (the border box, with its radii, is cut out). If the grown shape is empty, nothing is drawn. - **Inset** (`inset` true): `rect` and `radii` are the **padding box**. The shadow is the area of the padding box that lies *outside* the "hole": the rect moved by the offset and **shrunk** by `spread`, with each non-zero radius reduced by `spread`. The area is blurred and painted in `color`, clipped to the padding box. - **Blur:** the shadow is blurred with a Gaussian of standard deviation `blur / 2` (§7.2); `blur` 0 is a hard edge. A blur reaches about 3 standard deviations (1.5 × `blur`) beyond the shape. ## Images `Image` draws part of a **host-resolved** image. The list holds an `ImageHandle` (any `int` the host chose) and never the pixels; the host maps handles to pixels or textures when it draws. `source` is in the image's own pixels; an empty source means the whole image. `destination` is the rectangle the source is scaled to fit exactly (the painter has already applied `object-fit`, `object-position`, `background-size` and repeat). `sampling` is `Smooth` (bilinear or better) or `Pixelated` (nearest neighbour; `image-rendering: pixelated` and `crisp-edges`). **New pixels behind a handle.** When the host replaces the pixels of a handle and keeps their size, nothing in the list changes. `DisplayList.InvalidateImage(handle, damage)` (and `RetainedDisplayList.InvalidateImage(handle)`, `LivePage.InvalidateImage(handle)`) adds the root bounds of every `Image` item that draws the handle to a damage region: plain images, tiled backgrounds and every piece of a nine-slice border image (which are ordinary `Image` items with different source and destination rectangles on one handle). It returns the number of items, does one pass over the items, and allocates nothing. A new size is a layout change and goes through the styled tree. A backend must not sample outside the source rectangle: edges repeat the border texels (clamp), so scaled images neither fade at their edges nor bleed their neighbours. **Tiled images (1.1).** An `Image` item with the tiled flag (`IsTiledImage`, made by `DrawImageTiled`) repeats the destination rectangle (the *tile*) across `ImageTileArea`: tile *(i, j)* is the destination moved by `(i · (width + ImageTileGapX), j · (height + ImageTileGapY))` for every integer *i*, *j*, including negative ones, and only the part inside the area is drawn. A gap is what `background-repeat: space` leaves between tiles; `round` and `repeat` use a gap of 0. The painter emits one tiled item per `url()` background layer that repeats; gradient layers repeat as one filled rectangle per tile (at most 4,096 per layer, with a diagnostic beyond), each on the pixel-snapped edges of its tile. `Sampling` is bit 0 of the flags (bit 1 is the tiled flag). A reader of 1.0 refuses the list (`RequiredMinor` 1). ## Glyph runs A `GlyphRun` is one run of glyphs of one face, size and color. - `Face`: a `OneFont.FontFace`. The list keeps the reference until it is cleared; `FaceIndex` numbers the distinct faces of the list and means nothing outside it. `FaceKey` (1.3) is a 64-bit key of the face that is the same in every list, frame and process for the same font file: FNV-1a 64 over the family, subfamily, full and PostScript names (UTF-16, low byte first, each closed by a 0xFE byte; a missing name by 0xFF), then units per em, glyph count, weight, and a flag word (italic 1, bold 2, CFF 4) as little-endian 32-bit integers, then the first 54 bytes of the `head` table. A glyph atlas keys on (`FaceKey`, size × device-pixel ratio, glyph id). - `FontSize` (CSS px) and `Origin` (x, y of the run's baseline origin). - Glyph *i* has id `GlyphIds[i]` in the face, and its **pen position** is `(OriginX + X[i], OriginY + Y[i])`; `Y` is the offset from the baseline, positive downward. Glyphs are in **visual order**, left to right on screen, whatever the text direction. - **Outline:** the face's outline for the glyph id, in font units with y up, scaled by `FontSize / face.UnitsPerEm` and flipped to y down: a font point `(fx, fy)` maps to `(pen.x + fx·s, pen.y − fy·s)` with `s = FontSize / UnitsPerEm`. Fill with the non-zero rule in `Color`. - `Text`: the source text of the run in **logical order** (UTF-16), and `Clusters[i]`: the index into `Text` of the first code unit of the cluster that produced glyph *i*. Glyphs of one cluster share a value; for a left-to-right run the values do not decrease along the glyphs, for a right-to-left run (`RightToLeft`) they do not increase. A cluster runs from its index to the next larger distinct index (or to the end). A PDF writer builds `/ToUnicode` from (glyph id → text of its cluster) and emits `/ActualText` when one cluster has several glyphs or a glyph stands for several characters (ligatures); a text extractor reads the clusters back in logical order. - `Blur` (1.1, default 0, CSS px): the standard deviation of a Gaussian blur is `Blur / 2`, as for box shadows. A run with a blur is a `text-shadow`: the glyphs are drawn into a mask, blurred, and filled with `Color`. Bounds grow by `1.5 × Blur`. A list with a blurred run has `RequiredMinor` 1; a run without blur is the same item as in 1.0. - Glyph-level details such as hinting are the backend's business; the list never contains hinted outlines. ## Clips, transforms, groups, tags - **Clips.** Content between a clip push and its pop is clipped to the rectangle, rounded rectangle or path (in the space current at the push) intersected with every enclosing clip. Edges are anti-aliased. `PushClipPath` takes a fill rule. A clip with empty shape clips everything. - **Transforms.** `PushTransform` multiplies the current transform by the matrix for everything up to the matching pop; clips pushed inside move with it. `PushTransform3D` on the builder takes a 4×4 matrix (row vectors, as `System.Numerics.Matrix4x4`). Without perspective (`M14` and `M24` zero) the plane maps affinely and the builder stores an ordinary `PushTransform` of the 2D part `(M11, M12, M21, M22, M41, M42)` divided by `M44`, which must then be positive. With perspective the whole matrix is stored (kind 40) and the content, drawn flat in its own plane, is projected: `w = x·M14 + y·M24 + M44`, `x' = (x·M11 + y·M21 + M41) / w`, `y' = (x·M12 + y·M22 + M42) / w`. `M44` is the `w` of the page origin and can be negative; what matters is `w` at the points of the content, and a point with `w ≤ 0` is behind the viewer and is not drawn. Inside a projection a further perspective is not nested: it is flattened to its affine part. A matrix that cannot be inverted flattens its content away; nothing inside it is drawn. - **Groups.** `PushGroup(opacity, blend)` is an isolated group (Compositing and Blending 1 §3 to §4): its content is painted into a transparent layer, and when the group ends the layer is composited onto what is behind it using `blend` (§10, plus `PlusLighter`) with the group's opacity multiplied into the layer's alpha. Opacity is 0 to 1. Blend modes inside the group blend with the group's own content, not the backdrop. Use it for `opacity` below 1, `mix-blend-mode` and `isolation: isolate` (opacity 1, normal). Clips enclosing the group apply to the composited layer once. Groups nest, and opacities multiply. - **Tags.** `BeginTag(role, alt, link, language)` / `EndTag` carry structure and no pixels. Everything painted inside belongs to the element. `TagRole` names follow PDF structure types (`Document`, `Part`, `Section`, `Div`, `Paragraph`, `Span`, `Heading1`–`6`, `List`, `ListItem`, `ListLabel`, `ListBody`, `Table`, `TableRow`, `TableHeaderCell`, `TableDataCell`, `TableHead`, `TableBody`, `TableFoot`, `Figure`, `Caption`, `Link`, `BlockQuote`, `Code`, `Form`) plus `Artifact` for decoration that is not part of the structure. `alt` is the alternative text (for `Figure` and for anything whose accessible name differs from its text), `link` the destination of a `Link` (an absolute URL or a `#fragment`), `language` a BCP 47 tag when it differs from the parent. A `TableHeaderCell` or `TableDataCell` tag also carries the column and row span of its cell (`DisplayItem.ColumnSpan`, `RowSpan`, 1 when absent; a `rowspan` of 0 is resolved to the rows left in its group); the spans join the item hash only when above 1. A backend that has no use for structure ignores tags; they never change drawing. The painter emits tags only when `PaintOptions.Tags` is set. A tag item's id is `ItemId.Of(box, 0x7F00 + virtual part, occurrence)`, and `Id >> 16` identifies the element for backends (two scopes of one element, such as a box split across pages, share it). Popping any other scope first closes the tags opened inside it, so tags always nest with the other scopes. Empty table cells carry a transparent `FillRect` (part `0x7F10`) so that the cell exists in document order. `docs/pdf.md` has the HTML mapping. ### Effects (1.2) Every effect scope is a push and a pop on the shared stack; the 256-level nesting limit applies. A backend that draws a list refuses `RequiredMinor` above what it knows. - **Filters** (`PushFilter`, Filter Effects 1 §5). Content up to `PopFilter` is painted into a layer, the chain is applied in order, and the result is composited like any content (enclosing clips apply to the result). One to 32 operations, each `FilterOp(Kind, A, B, C, Color)`: `Blur` (A = standard deviation in CSS px, at most 4096), `Brightness`, `Contrast`, `Grayscale`, `Invert`, `Opacity`, `Saturate`, `Sepia` (A = amount), `HueRotate` (A = radians), `DropShadow` (A, B = offset, C = standard deviation, color). The item's bounds grow by the chain's outset (three standard deviations of each blur and shadow plus offsets), so damage covers blurred ink. - **Backdrop filter** (`BackdropFilter`, Filter Effects 2 §2). A leaf: the pixels of the current layer inside the rounded rectangle are replaced by the filtered pixels. The painter emits it before the element's own background. - **Masks** (`PushMask`, Masking 1 §6). Directly inside, zero to 16 `PushMaskLayer` scopes describe the mask bottom layer first, each painted into a transparent layer whose alpha (`Alpha`) or luminance times alpha (`Luminance`) is its coverage, combined with the layers before it by `Add` (source over), `Subtract` (source out), `Intersect` (source in) or `Exclude` (xor). The items after the layers, up to `PopMask`, are the masked content, multiplied by the combined coverage. A mask with no layers hides its content. - **Inline SVG** (`SvgImage`). The serialized `<svg>` element (at most 1,048,576 characters) is drawn as an image exactly the size of the rectangle: the backend replaces the root's `width` and `height` with the rectangle's size and keeps `viewBox` and `preserveAspectRatio`; `currentColor` is the item's color. The painter draws the element's own box; nothing in the markup refers to the page. `SvgKey` (1.3) is a 64-bit content key: FNV-1a 64 over the UTF-16 code units of the markup (low byte first), then the four channels of the clamped `currentColor` (R, G, B, A) as little-endian IEEE 754 single-precision bits. Equal markup and color give equal keys in every list, frame and process, whatever the rectangle; a host caches its rasterization by (`SvgKey`, size × device-pixel ratio) and reads `SvgMarkup` only on a miss. - **Projection** (`PushTransform3D`) is described under [Clips, transforms, groups, tags](#clips-transforms-groups-tags). `PatchTransform3D(id, matrix)` replaces the matrix in place (0 B) like `PatchTransform`. - **Scope order** the painter uses for one element: transform, then the opacity/blend group, then the mask, then the clip-path, then the filter, then the element (Filter Effects 1 §3, Masking 1 §5: filter, clip, mask, opacity). - **PDF.** `DisplayItem.PdfNative` says whether a PDF writer can express an item: opacity and blend groups, masks (soft masks) and an opacity-only filter chain are native; other filters, backdrop filters, projections and SVG images are drawn as rasterized islands with a diagnostic. - **Limits.** 32 filter operations, blur 4096 px, 16 mask layers, 1,048,576 SVG characters, 256 scopes; each is checked before the item is added. The painter stops opening scopes before the nesting limit and records `LimitExceeded`. ### Host scopes (1.3) A host that draws the list through its own render graph often needs to split it: this material for these elements, that layer for those. A host scope is a push and a pop on the shared stack carrying one integer the host chose (`DisplayList.PushHostScope(id, value)`, `PopHostScope()`; read back with `DisplayItem.HostScope`). The engine attaches no meaning to the number: any `int` is valid, zero and negative ones included. - **Drawing is unchanged.** A backend that does not know the host's numbers draws the content as if the scope were not there. The image rasterizer and the PDF writer do exactly that (a PDF with a scope is byte for byte the PDF of the same page without one). - **Nesting.** A scope nests like any other: it closes the tags opened inside it, counts toward the 256-scope limit, may sit inside and around clips, transforms, groups, filters and masks, and scopes may nest inside each other (the innermost applies; a host that wants the outer number reads `ParentIndex` upward). A mask layer is still only valid directly inside its mask. - **Context.** The scope's value joins the context hash of everything inside it, so changing only the number damages exactly what is inside the scope. - **What the painter emits.** An element that sets `--host-scope` (or `StyleDeclaration.SetHostScope`, see `docs/game-hosts.md`) paints as one unit, like an element with `isolation: isolate` (a stacking context, so nothing outside it paints between its parts), and its items sit in this order, outermost first: the scroll or sticky offset transform, the element's own `transform`, its opacity/blend group, its mask, its `clip-path`, its filter, **the host scope**, then its background and border, its overflow clip and its descendants. The scope is inside the element's effects and around its own box and everything in it, so a host that renders the scope as a pass draws the pass with the element's transform, opacity and filter applied around it. - **A list without a scope** keeps its old `RequiredMinor`; a list with one has 3. A reader of 1.2 refuses it, because it cannot split the list the way the host wants. - **Ids.** The scope has an id of its own, derived from its element like the ids of its other scopes, so it is the same on every repaint of an unchanged element. ## Bounds - `LocalBounds` is computed when an item is added: for rects, the rect; for paths, the bounding box of all control points; for glyph runs, the glyph pen positions and advances widened by the face's ascent and descent (at least 1 em above and ½ em below the baseline, since accents and descenders can exceed the nominal metrics) and by ¼ em on both sides; for an outer shadow, the shadow rect plus `max(spread, 0) + 1.5 × blur` on every side; for an inset shadow, the padding box. Bounds are **conservative**: they may be larger than the ink, never smaller. - `RootBounds` maps `LocalBounds` through the enclosing transforms (the bounding box of the four mapped corners) and intersects it with the bounds of the enclosing clips (the bounding box of each clip's shape, mapped the same way). It is empty when nothing of the item can show. - A backend may use `RootBounds` to cull items against its viewport or a damaged region. ## Damage `DamageRegion` holds the changed area in root space (CSS px). It keeps up to 16 rectangles; a rectangle that overlaps or touches an existing one is merged with it into the bounding box of both, and when the 17th disjoint rectangle arrives all collapse into one. `Bounds` is the bounding rectangle of everything; `ToDevicePixels(dpr)` rounds that outward to whole device pixels (`RectI`). `DisplayList.ComputeDamage(previous, next, damage)` adds to the region the area where `next` differs from `previous`: - Items are matched by **id**. - A matched item is **unchanged** if its kind, `ContentHash`, `ContextHash` and `RootBounds` are all equal. Otherwise both its old and its new `RootBounds` are added. - An item only in `next` adds its `RootBounds`; an item only in `previous` adds its old `RootBounds`. - Reordering: if kept items change their relative paint order, every kept item that now comes earlier than one that used to come before it counts as changed, since what it overlaps changed. - `ContextHash` mixes the kinds and parameters of all enclosing clips, transforms and groups, so moving, clipping or fading a group changes every item inside it. Tags are not part of the context: changing a tag, or adding one around unchanged content, causes no damage. - Push and pop items have empty bounds and add nothing themselves; their effect arrives through the items inside. The result is **conservative and exact in the sense that matters**: every pixel that differs between a rasterization of `previous` and of `next` lies inside the region (the tests rasterize random edit sequences and check this), and an edit of one item changes only that item's bounds. `RetainedDisplayList` keeps two lists and the accumulated region. `Begin()` returns the cleared back list; the painter fills it and calls `Commit()`, which validates, diffs it against the current list, adds the difference to `Damage`, and swaps. The first commit damages everything. The viewer redraws `Damage` and calls `ClearDamage()`. ## Patching Transform and opacity changes of a subtree that already exists do not need a repaint: - `PatchTransform(id, matrix, damage)` replaces the matrix of the `PushTransform` with that id. - `PatchOpacity(id, opacity, damage)` replaces the opacity of the `PushGroup` with that id. Both update the list **in place**: the item's payload, the context and the `RootBounds` of everything inside it. The damage added is the old and new bounds of every item inside (for opacity the bounds do not move, so just those). The patched list is identical, in damage terms and bounds, to a list built from scratch with the new value (tested on random scenes). They throw `ArgumentException` if the id is missing, names another kind, or the item is not yet closed. `RetainedDisplayList.PatchTransform/PatchOpacity` patch the current list and add to `Damage`. Patching does not allocate. ## Retained lists and allocation A `DisplayList` pools all of its storage: items, numbers, paths, gradient stops, glyph arrays, source text, strings, the id table and the push stack. `Clear()` resets the counts and keeps the arrays. Refilling a list of the same shape, committing it into a `RetainedDisplayList` (diff included) and patching all allocate **0 B** once the lists have grown to the frame's size; the tests assert this on a 1,000-item list. What allocates: growth to a larger size than ever before, and anything the painter itself allocates (paths in `PathBuffer`s should be reused; strings passed to `BeginTag` should be existing strings). ## Limits and failure | Limit | Value | Failure | |---|---|---| | items per list | 1,048,576 | `DisplayListLimitException` | | open scopes (nesting) | 256 | `DisplayListLimitException` | | verbs per path | 1,048,576; 8,388,608 per list | `DisplayListLimitException` | | glyphs per list | 4,194,304 | `DisplayListLimitException` | | source text per list | 8,388,608 UTF-16 units | `DisplayListLimitException` | | stops per gradient | 1,024 | `DisplayListLimitException` | | gradients per list | 262,144 | `DisplayListLimitException` | | distinct faces per list | 4,096 | `DisplayListLimitException` | | strings per list | 1,048,576 | `DisplayListLimitException` | | damage rectangles | 16 | merged into one | | coordinates | finite, within ±1e9 | `ArgumentOutOfRangeException` | All checks run **before** the add changes any state, so a refused add leaves the list as it was. Unbalanced pops, a pop of the wrong family, duplicate or zero ids and unclosed scopes throw `DisplayListException`. Nothing in the list recurses on nesting depth; every traversal uses the item indices and the explicit stacks. ## Writing a backend Read the items in order. Keep a stack with one entry per open push. A sketch: ``` state = { transform = scale(dpr), clip = none, target = surface } stack = [] for i in 0 .. list.Count - 1: item = list[i] switch item.Kind: leaf kinds: if item.RootBounds is empty or outside your viewport: continue # optional culling draw item with state.transform, state.clip, state.target PushClip*: stack.push(state); state.clip = state.clip ∩ shape (in state.transform) PushTransform: stack.push(state); state.transform = item.Matrix * state.transform PushGroup: stack.push(state); allocate a transparent layer; state.target = layer; state.clip = none BeginTag: stack.push(state) # structure backends open an element here Pop*: group: composite layer into the saved target with opacity, blend and the saved clip; then state = stack.pop() EndTag: state = stack.pop() ``` Notes for a GPU adapter: - **Caching.** Key resources by `item.Id`; they stay valid while `item.ContentHash` is unchanged (and, for anything baked with position or enclosing effects, `item.ContextHash` and `item.RootBounds`). Glyph runs: cache glyph outlines or atlas entries by (`FaceKey`, size × device-pixel ratio, glyph id); `FaceIndex` only numbers the faces of one list. Inline SVG: cache the rasterization by (`SvgKey`, size × device-pixel ratio). Images: the host owns the pixels behind a handle and says when they changed (see [Images](#images)). Host scopes: split the list into passes ([Host scopes](#host-scopes-13)). `docs/game-hosts.md` walks through all of this. - **Redraw.** Redraw only the `DamageRegion` of a `RetainedDisplayList`, with scissor and the culling above. - **Changing only a transform or opacity** (an animation) arrives as a patch with damage and needs no rebuilt resources: the `ContentHash` of the items inside does not change. - **Groups** may be realized as render targets, or as per-draw alpha when the group holds a single leaf item and the blend is `Normal`. - A backend that cannot draw something (a blend mode, an effect of a later minor version) should say so and refuse the list rather than approximate silently. ## The image backend `RecursiveAI.OneRender.Image` rasterizes a list through oneimage: ```csharp RgbaImage img = DisplayListRasterizer.Render(list, widthPx, heightPx, new RasterOptions { DevicePixelRatio = 2f, Images = resolver }); DisplayListRasterizer.RenderRegion(list, surface, damage.ToDevicePixels(2f), options); // redraw only a region ``` - The result is RGBA, straight alpha, rows top to bottom, on a surface cleared to `RasterOptions.Background` (default opaque white). Width and height are in device pixels, 1 to 16,384 each and at most 64 Mpixel. - `RenderRegion` clears the region to the background and redraws every item that touches it, clipped to the region; its result equals the same region of a full render (tested). - **Blending** is on encoded sRGB values. **Determinism:** the same list, options and resolver give the same bytes on every run (tested); the golden images tolerate 2 levels per channel for floating point differences between CPUs. - **Groups** are layers of the full surface size: at most 16 open at once and 128 Mpixel of live layers, else `RasterizationException`. **Blur** deviations are limited to 256 device pixels. - Items that need path booleans (non-rectangular or rotated clips, borders other than four equal solid sides, shadows) accept device coordinates within ±33,554,432 pixels (a oneimage limit); beyond that `Render` throws `RasterizationException`. Fills, glyph runs and images are not limited that way. - `Render` and `RenderRegion` throw only `ArgumentNullException`, `ArgumentOutOfRangeException`, `DisplayListException` (unclosed list), `RasterizationException` and `OperationCanceledException`. What the backend builds around oneimage, because oneimage has no direct equivalent: | Need | oneimage has | Built here | |---|---|---| | conic gradients | linear, radial, elliptic | a `Brush` computing all three CSS gradients in double precision with premultiplied interpolation | | elliptical corner radii | circular `RoundedRectangle` | cubic Bézier quarter ellipses | | border sides, styles | strokes, fills, path booleans | ring paths clipped by corner diagonals; dashes and dots as stroked middle lines | | box shadows | `GaussianBlur` on images | an 8-bit coverage mask, blurred and applied through a mask brush so colors never fringe | | image sampling with clamped edges | `ImageBrush` fades the edges when not tiling | an own brush with bilinear and nearest sampling | | nested non-rectangular clips | one clip path per fill | path intersection on push; rectangles are intersected as rectangles | | groups with opacity and blend | `Fill` with blend and opacity | a layer canvas composited through an image brush | | `plus-lighter` | `AlphaComposition.Plus` | used for the composite | ## Choices the specs leave open - **Pixel snapping (painter, checked against Chrome 156 output):** each edge of a background, border, outline, clip and decoration rectangle is placed at `round(x · dpr) / dpr`, half rounding up, and a rectangle's far edge is snapped on its own, so a 10.4 px box at 10.4 px covers device pixels 10 to 20 inclusive and two boxes that meet on a fractional edge share one pixel edge with no blended column. Border widths are floored to whole device pixels with a minimum of one (1.6 px paints 1 px at scale 1, 3 device pixels at scale 2 for 1.6 px; 0.4 px paints 1). Glyph positions are not snapped. Evidence: `tools/snap/probe.py`, results in the Paint section of `architecture.md`. These are choices of the reference rasterizer where CSS does not decide. None has been compared with Chrome's output yet; each is a proposal to revisit with differential evidence. - Dash length 2w and gap w for `dashed`; round dots with a gap of w for `dotted`; the pattern starts at the top-left of the middle line. - `double` borders thinner than 3 px draw solid. - "Dark" for groove, ridge, inset and outset is 2/3 of each channel; black is not treated specially. - A square corner stays square when a shadow spread grows or shrinks it. - `transform-style: preserve-3d` is flattened: each element is projected on its own plane and nested 3D contexts do not intersect. - Backdrop filters are painted before the element's own group, so the element's `opacity` does not fade them. - Filter blur and color operations work on premultiplied sRGB floats; luminance masks use the sRGB coefficients 0.2126, 0.7152 and 0.0722. - A clip shape beyond ±33,554,432 device pixels leaves the previous clip in place. - A bilinear image sample clamps at the edges of the source rectangle; scaling down by more than 2× is not filtered further (no mip-mapping), so a large reduction aliases. - Group, filter and mask layers cover only the device region their content can reach (bounds plus filter outset, clipped to the surface and the enclosing clip); the sum over open layers is capped at 134,217,728 (2^27) pixels and the nesting at 32 layers, beyond which `Render` throws `RasterizationException`. <!-- https://recursiveai.org/onerender/docs/forms/ --> # Form controls Form controls are laid out and painted the way Chrome 156 does by default, so boxes and pixels agree with it on the same fonts. This page is the normative record: every number below was measured on Chrome 156 with Liberation Sans standing in for Arial (the same font files on both sides), as a black box through the geometry and pixel harnesses (`tools/forms/`, `testdata/forms-probes/`). The specifications leave all of it open: HTML §15.5 only says controls are replaced-like "widgets", and CSS UI 4 §6 (`appearance`) says that `appearance: none` turns the widget into an ordinary box. Code: `src/OneRender/Forms/` (kinds, metrics, state), `Tree/HtmlFrontEnd.Forms.cs` (UA adjustments, fieldset split, select popup), `Painting/Painter.Controls.cs` (widget paint), `Interaction/InteractivePage.Forms.cs` (activation, events, select popup, range). ## Model A control is an ordinary element box whose computed values are adjusted when the styled tree is built (`AdjustControlStyle`): its widget width and height are written into `width`/`height` when those are `auto`, the inner text is made single-line where Chrome does, and paint-only properties (`Control`, `ControlState`, `ControlFraction`) tell the painter what to draw. The widget size only stands in for `auto`: `WidthIsIntrinsic` and `HeightIsIntrinsic` keep stretching (grid, flex, both-inset absolute positioning) working. `appearance: none` leaves the box as plain CSS (a checkbox becomes 0 by 0). The value and placeholder are a synthetic text child (`RefreshContent` rebuilds it when the form state changes); a text editor plugs in there. Text, password, email, number, search, url and tel inputs share one layout. Inputs without a native look (`hidden`) get no box. ## Sizes (border boxes; font size 13.3333px unless the author sets one) | Control | Rule | Evidence | |---|---|---| | Text field content width | `floor((size - 1) * A + W + 0.75)`, A = OS/2 `xAvgCharWidth` / upem * font size (kept when its fraction is below 0.5, else rounded up), W = `head` (xMax - xMin) / upem * font size. `size` defaults to 20. Default font: 185 px border box. | `testdata/forms-probes/text*`, sizes 1..40 and font sizes 10..30 | | Text field height | line (round(ascent) + round(descent) + round(line gap)) + 6 (2 px borders, 1 px padding). `height` smaller than the line is respected; the text is centered and overflows evenly. | probes `text`, WPT `css-overflow/input-scrollable-region-001` | | Text field baseline | border + padding + round(ascent), also when empty | probe `text` | | Textarea | width round(cols * A) + 15 + padding + border; height rows * line; default 20 x 2; monospace 13.3333px, padding 2, 1 px border | probe `text2` | | Drop-down select | width ceil(widest option label) + 20 + padding + border; height round(ascent) + round(descent) + 4 | probe `select` | | Listbox select | rows (`size`, or 4 when `multiple`) * (max(line, floor(1.2 em)) + 1); width widest label + 4 + 2 | probe `select` | | Button, `input` button types | padding 1px 6px, 2 px outset border, `#efefef` face; shrink-to-fit to its label | probe `misc` | | Checkbox, radio | 13 x 13; margins 3 3 3 4 (checkbox) and 3 3 0 5 (radio); baseline at the bottom border edge | probe `misc` | | Range | 129 x 16, margin 2; baseline at the bottom border edge | probe `misc` | | `progress` | 10 em x 1 em with its own `em`, `vertical-align: -0.2em` | probe `misc` | | `meter` | 5 em x 1 em, same alignment | probe `misc` | | `fieldset` | 2px groove `#efefef`, padding .35em .75em .625em; the legend is shrink-to-fit | probe `misc` | A control stretches like an `auto` width in a grid column or flex cross axis (`WidthIsIntrinsic`). A range, textarea and select also stretch in height (`HeightIsIntrinsic`); a checkbox, radio, progress and meter keep their size. Probes: WPT `css-grid/alignment/grid-self-alignment-stretch-input-range`, `css-flexbox/flexitem-stretch-range`, `css-position/position-absolute-semi-replaced-stretch-*`. A percentage `height` that cannot resolve behaves as `auto` (CSS Sizing 3 §5.2.1), so the control keeps its own height (a minimum height of the widget height does it); an inline-block's percentage height resolves against the height of the block container it sits in (CSS2 §10.5). With both insets given and `auto` size, an absolutely positioned control fills the space between them (CSS Position 3 §5.3), text fields and buttons included. **Centered content.** The text of a button, text field and drop-down is centered in the box when the box is taller than the text (a stretched item, `min-height`, an explicit height). With a length `height` the extra goes into the top padding at style time (probe: height 50 / 60 px puts the text 17.5 / 22 px below the top); otherwise layout moves the line boxes after the content is laid out. A text field also centers when the box is too short (the text overflows evenly at both ends). ## Colors and metrics (paint) Chrome paints native widgets itself; these are the values read from its screenshots (`tools/forms/`, pixel dumps). - **Text field, textarea, menu select, listbox:** the layout border is 2/1/1 px; the paint is a 1 px `#767676` border at the border-box edge, radius about 2, white face (a menu select's face is white although its computed background is `#efefef`). - **Button:** 1 px `#767676` border, radius about 3, face `#efefef`. Hover `#e5e5e5` with border `#4f4f4f`, active `#f5f5f5` with `#464646` are not probed. - **Disabled:** face `rgba(239,239,239,.3)`, border `rgba(118,118,118,.3)`, text `#545454`; a select's text about `#a5a5a5`, a button's about `#ababab`. - **Select arrow:** a chevron of two 2 px strokes through (R-13, cy-3.2), (R-9, cy+1.6), (R-5, cy-3.2), R the right border edge, cy the vertical center. **Textarea resizer:** two 1 px `#666` lines, (R-3.5, B-9.5) to (R-9.5, B-3.5) and (R-3.5, B-5.5) to (R-5.5, B-3.5). - **Checkbox:** 13 x 13, radius 2, 1 px `#767676` border, white; checked fills with the accent color (default `#0075ff`) and draws a white check (polyline about (3.2, 6.4), (5.5, 8.7), (9.7, 2.8), width 1.9; black when the accent is light). Disabled: face `#f8f8f8`, border `#d1d1d1`, checked `#d1d1d1` with a `#ededed` mark. A checkbox stretched by a grid column draws 13 px, centered. - **Radio:** 13 px circle with a 1 px `#767676` ring; checked: 1 px accent ring, 2 px white, a 7 px accent dot. - **Range:** the track is 8 px tall, centered, inset 1 px from each end, a pill with a 1 px `#b2b2b2` border and `#efefef` inside; the part up to the value is the accent color; the thumb is a 16 px accent circle whose center travels from left + 8 to right - 8. Disabled: `#cbcbcb`, `#fafafa`, `#d2d2d2`. - **Progress:** the same track edge to edge, the fill reaching the border with a square end; indeterminate draws a chunk from 0.24 to 0.44 of the width. **Meter:** track border `#cbcbcb`, inside `#efefef`, fill inset 1 px: optimum `#107c10`, even-less-good `#d83b01` (the suboptimum yellow `#ffb900` is not probed). - **`accent-color`:** `auto` is `#0075ff`; any color, `currentcolor` resolved against the control's own `color` (WPT `css-ui/accent-color-parent-currentcolor`). - **Focus ring (`:focus-visible`):** 2 px `#101010`; its outer edge is at the border-box edge for boxes with a border (inputs, buttons, select, textarea, a bordered `div`), 1 px outside for borderless boxes; checkbox and radio use `outline-offset: 2px`; a range uses an outer offset of 1. - **Fieldset:** the top border is drawn through the middle of the legend (legend top + floor((legend height - border) / 2)); the gap in it is exactly the legend's border box. A `display: grid | flex` fieldset becomes a block holding the legend plus an anonymous box that carries the original display and its grid or flex properties: the first row starts after the legend and padding, with no gap (daisyUI's fieldset, `tailwind/daisy-app`). A subgrid fieldset keeps its own grid (WPT `css-grid/subgrid/independent-formatting-context-fieldset`). ## Interaction (`InteractivePage`) - **Activation:** a click toggles a checkbox or radio before the `click` listeners run (legacy pre-activation, reverted when `preventDefault` is called), then fires `input` and `change`. Radios in one group (same form owner and `name`) uncheck each other. A click on a `label` activates its control. Disabled controls do nothing. - **Buttons:** a submit button fires `submit` (a `SubmitEvent` with `submitter`) after constraint validation; invalid controls get an `invalid` event and the submit is cancelled; `novalidate` skips validation. A reset button restores defaults and fires `reset`. Nothing navigates. - **Range:** pointer drag and the arrow keys (one `step`), Page Up and Page Down (a tenth of the range) and Home and End change the value and fire `input`; `change` follows when the drag ends or the key is released. - **Select:** a click (or Space, Arrow Down and Arrow Up on the focused select) opens the popup; in the open list the arrows, Home and End move the highlight, Enter or Space or a click chooses and fires `input` and `change`, and Escape, a click outside or a wheel scroll closes it. Typing characters moves the highlight to the next option whose label starts with the typed prefix (type-ahead; the prefix resets after 1 second of host time). A listbox selects on click and arrow keys; Ctrl/Cmd-click toggles one option, Shift-click or Shift+Arrow extends from the anchor, and Ctrl+A selects all in a `multiple` list. - **Popup (top layer):** a box under the select, as wide as it. It is painted after everything else in the root stacking context, outside every ancestor clip and stacking context, and the hit tester tries it first. Chrome draws this list in a native window that screenshots do not capture, so its look is ours. - **State and selectors:** the user's changes live in `FormState`; the rendered content, `form.inputChecked`, `inputValue` and `optionSelected` facts follow it. `:checked`, `:disabled`, `:indeterminate` and `:placeholder-shown` selectors are evaluated by OneHtml against the parsed attributes and do not follow later user changes yet (OneHtml has no dynamic form state); the painter reads the live state, so the widget is right, but a `:checked` rule does not restyle after a click. ## Text editing Text-like inputs (text, password, email, number, search, url, tel) and `textarea` are editable. A control's content is a value text node (always present, possibly empty) plus a placeholder node that shows text only while the value is empty. `InteractivePage` owns one editing session per control; the builder path uses `LiveTextEditor` with the same engine and no DOM events. - **Caret and selection.** The caret is a 1 px bar in `caret-color` (`auto` is the text color), blinking with a 500 ms half period on host time (`InteractivePage.Advance`; `NextCaretBlinkTime` says when the next toggle is due, and any edit or move shows the caret). Focusing a field by script puts the caret at 0; tabbing into a single-line input selects all its text. A click places the caret, a double click selects a word, a triple click the line (the whole value in a single-line input), and a drag extends from the press point. Arrows move by grapheme cluster, Ctrl/Alt+Arrow by word (to the end of the word, as on Linux), Home and End to the line ends, Ctrl+Home/End to the value ends, and Shift extends. Ctrl or Cmd+A selects all. Up and Down move between lines in a textarea and keep the goal column. - **Editing.** Typing, Backspace and Delete (by cluster, or by word with Ctrl/Alt), Enter in a textarea, `maxlength` (counted in UTF-16 units; paste is truncated), `readonly` (selection and copy only) and `disabled` (nothing). Newlines are normalized to `\n`; a single-line field drops them. An `<input type=number>` accepts only digits, one `.`, signs and `e`. A password field draws one bullet per UTF-16 unit and never copies or cuts its text. Undo and redo (Ctrl/Cmd+Z, Shift+Z or Y) keep up to 2,048 entries and 1,048,576 characters; typing runs and deletion runs coalesce into one entry. - **Scrolling.** A single-line field scrolls horizontally to keep the caret visible; a textarea wraps (`white-space: pre-wrap`) and scrolls vertically. - **Clipboard.** The host sets `InteractivePage.Clipboard` (an `ITextClipboard`); Ctrl/Cmd+C, X, V and the `Copy`/`Cut`/`Paste` methods call it, fire `copy`/`cut`/`paste` events (cancelable), and the engine touches nothing else. - **IME.** The host calls `CompositionStart`, `CompositionUpdate(preedit, caret, targetStart, targetEnd)` any number of times, then `CompositionEnd(committed)` (an empty string cancels). The preedit is spliced into the drawn text with an underline (2 px for the target clause, 1 px otherwise); `TryGetCompositionRect` and `TryGetCaretRect` return the rectangles in page coordinates for the candidate window. Keys during a composition are ignored by the editor. - **Events.** Per UI Events and Input Events 2: `keydown`, then `beforeinput` (cancelable; `inputType`, `data`, `isComposing`), the edit, `input`, then `keyup`. A composition fires `compositionstart`, `beforeinput` (insertCompositionText), `compositionupdate`, `input`, and ends with `compositionend`. `change` fires when a field loses focus or Enter is pressed with a changed value; `select` and `selectionchange` fire when the selection moves. Enter in a single-line input submits through the form's default button. - **Paint.** Selection, caret and preedit underline are ordinary `FillRect` items with stable ids (`ControlId(fragment, 10..18)`) inside a clip and a translate for the field window, so no display-list item kind was added and the format version is unchanged. The selected text is drawn again in the selection foreground over its fill when one is set. The default selection fill is `#3390ff` (not probed against Chrome); `TextEditView.SelectionBackground`/`SelectionForeground` set it, because OneHtml 1.2.0 has no `::selection` pseudo-element yet. - **Limits.** Caret movement and selection follow logical order, not visual order, in mixed-direction text. The arrow keys do not step a number input. A trailing newline in a textarea does not add a blank line to the scroll height. Date, time, color and file pickers, `datalist` and PDF form fields are not built; neither are hover and active button colors, the suboptimum meter color, or the popup's look beyond ours. ## Allocation Toggling a checkbox and hovering a button on a builder-made tree (`StyledTree.SetControl`, `SetStateStyle`) allocate nothing once warm (test `TogglingACheckboxAndHoveringAButtonOnABuilderTreeAllocateNothing`). On an HTML page the state change goes through OneHtml's element-state update, which allocates until its non-allocating variant is available. Typing a character allocates nothing once warm, on an HTML page, on the 1,000-node HUD page and on a builder tree (`TextEditingTests`, `GameUiIncrementalTests`); it takes about 0.7 ms on the HUD page in a release build. <!-- https://recursiveai.org/onerender/docs/animations/ --> # Transitions and animations: matching choices and deviations Normative notes for the behavior in `Animation/` where the specifications leave room or the engine differs. The tests and the WPT replay are named in `architecture.md`. ## Choices - **Before-change style.** The style a transition starts from is the previous tree's style of the same element as the page last showed it, so a running transition or animation counts at its current value (Transitions 1 §3 says the same for transitions, and for animations in the "before-change style" definition). - **Reversal.** A transition whose end value equals the start of a running one reverses with the shortening factor and reversing-adjusted start of Transitions 1 §3.2. - **Discrete transitions.** `allow-discrete` transitions flip at the midpoint, except `display`: a transition with `none` at one end keeps the other value for the whole interval, and a transition of `display` from `block` to `none` keeps the element displayed until it ends. An element whose after-change `display` is `none` starts no other transition and runs no animation (Transitions 2 §3.1). - **Keyframes.** `unset` in a keyframe means `inherit` or `initial` by property. Declarations marked `!important` and custom properties in a keyframe are ignored. The implicit 0% and 100% keyframes take the element's underlying value. Animation names compare case-sensitively; `"none"` as a string is an animation named none. - **Computed text.** `animation-name` keeps names that read as keywords quoted; identifiers are escaped (CSSOM "serialize an identifier"). `steps(n, start)` and `steps(n, jump-start)` serialize as written. `animation-duration: auto` computes to `0s` (no scroll timelines). `transition-property` keeps the case of unknown names. Times and the iteration count accept `calc()` and the other math functions that need no layout context. - **Ends of a length mix.** Interpolating a px length with a percentage gives `calc()`; at the end of the range the result is the end value itself, at the start it stays a sum such as `calc(0% + 40px)` (matches Chrome 156, and the WPT interpolation tests expect it). - **`vertical-align`.** `baseline` takes part in interpolation as the zero offset. - **Colors.** All colors interpolate premultiplied in sRGB, whatever syntax they were written in. A color written in `oklab()` or another space interpolates in sRGB too, so a mid-point differs slightly from Chrome. `currentcolor` in a shadow or filter resolves against the element's cascaded `color`. - **Serialization of channels.** Color channels round half away from zero when serialized (`rgb(178.5)` is 179). - **Events.** `animationiteration` is dispatched for the iteration boundary a frame lands in; an iteration boundary skipped by a late frame is not repeated. `transitionend` and `animationend` carry the elapsed time of the whole effect. A cancelled effect that never started a run (a transition cancelled before its first tick) fires nothing. - **Order of effects.** Base style, then transitions, then animations in the order of `animation-name`; a later animation of the same property wins. ## Known deviations - Colors in `oklab()`/`oklch()`/`color-mix()` do not interpolate in their own space. - `font-size` animation does not recompute `em` lengths of element descendants (text follows). - Animated colors, shadows and filters repaint and are not patched in place; only transform and opacity are. - Pseudo-elements, `::marker` and `::first-line` do not animate. - `animation-composition`, `animation-timeline`, `animation-range` and `var()` inside `@keyframes` are not implemented. - A change that adds or removes elements from the styled tree (a `display: none` flip, generated content, list markers) builds the page again; transitions the same change started were already registered by then, and the second pass finds them running. - A page parsed without `HtmlPageOptions.AnimationTime` and not wrapped in `InteractivePage` is static. ## Replay of the WPT helpers `testdata/wpt-motion/` holds the css-transitions and css-animations testharness files that need no script: the `parsing/` files (replayed like the other parsing files, `WptStyleRunner`) and the files calling `test_interpolation`, `test_no_interpolation` and `test_not_animatable`. For the latter, `WptMotionRunner` rebuilds what the helper builds: a target and a reference element, the reference carrying the expected value as a plain declaration; the target gets a 100 s animation or transition with a -50 s delay and the helper's timing function, which yields the wanted progress at the midpoint. A transition starts from a style change (focusing the target). The comparison is the engine's own computed text on both sides. The Web Animations variant of the helper, composition tests and everything that needs promises, `getAnimations()`, event handlers or frames are excluded with a reason in `excluded.tsv`. `expected-failures.tsv` lists every replayed failure with its reason; `floors.tsv` holds each module's rate, which only rises. <!-- https://recursiveai.org/onerender/docs/accessibility-facts/ --> # Rendered page facts `RecursiveAI.OneRender.Accessibility` lays a document out with the engine and returns OneHtml's `RenderedFacts` (format version 1) for it: the same facts, with the same meaning, that a `DOMSnapshot.captureSnapshot` capture from a browser yields, so a consumer can switch sources without changing its rules. ## Route The package reads the laid-out page and hands each box straight to OneHtml.Accessibility's `RenderedFactsBuilder` (OneHtml 1.2): no snapshot JSON is written or parsed, and there is no cap on the size of the page as bytes. `RenderedFactsLimits` (`FactsOptions.Limits`) bound boxes, text boxes and strings and throw `RenderedFactsLimitException` before storing past a bound; `FactsOptions.BackgroundScanLimit` (default 16384 display items) bounds the backward scan for colors behind one text box. All walks are iterative; cost is linear in nodes, text boxes and display items. Nodes are numbered as a snapshot would number them (document order, pseudo-elements included, the doctype counted but not a node of the facts), so `BackendNodeId` is that number plus one and `SnapshotNodeIndex` the number. Generated content (`::marker`, `::before`, `::after`) is added as generated boxes of its host: the pseudo-element's own box, then the box of its generated text. The facts are the same as the earlier snapshot route gave on the text-block, templates and tailwind pages (every box, text box, style value, paint order and color), except that `StackingContextNodeIndex` is set for every node, as the builder derives it, including the doctype that the adapter left at -1. ## API - Low level: `AccessibilityFacts.FromLayout(page, queries, displayList, paintTrace, options, styleContext, ct)` for a page the host already laid out and painted, with a `PaintTrace` (an `IPaintObserver`) attached to the host's `Painter`. - Mid level: `FromPage(HtmlPage, …)` paints and queries itself; `FromInteractive(InteractivePage, …)` reads the page's own layout and style context, so hover and focus state show in the facts. - Happy path: `AccessibilityFacts.Render(html or Document, HtmlPageOptions, FactsOptions?, ct)` returns a `RenderedPage` (document, page, facts). - `FactsOptions.ComputedStyles` lists the properties to serialize; `StandardStyleList` is the 96-property list used by the browser captures. ## Fact semantics - **Boxes.** One layout item per element that has a principal-box fragment: the union of its fragments' border boxes in document coordinates, CSS px, ignoring transforms. `display: none` and `display: contents` have no box; `visibility: hidden` and `opacity: 0` keep theirs. Generated `::before`, `::after` and `::marker` boxes are child nodes with a box item. - **Text.** A text item exists only where a text fragment exists. Text boxes are in layout order (not sorted), as UTF-16 start and length into the layout text; a preserved line feed gets an extra zero-width box. The layout text is the source text with `text-transform` applied when that keeps its length. - **Rects.** Offset, client and scroll rectangles come from the CSSOM View queries; `br` and `wbr` report zeros. - **Paint order.** A "layer" is any positioned box, stacking context, or box that clips (`overflow` other than `visible`). Layers are numbered in the order the painter enters them (the root element is 1, the document 0); boxes painted into the same layer share a value. Stacking-context flags come from the painter (CSS 2.2 Appendix E, plus the CSS Transforms, Compositing and Filter Effects triggers). - **Color behind text.** Given only for an element with no element children and non-blank painted direct text. The display list is scanned backward from the text for fills (solid; a gradient counts as its first stop) covering the text box origin, composited over white with alpha quantized to 8 bits and each channel rounded when two colors mix. Opacity groups that enclose the text are not part of the background and count in the text opacity; groups that closed earlier flatten their fills with their opacity. The color is absent when an image, inline SVG or backdrop filter covers the point, or when a fill sits inside a closed filter, mask or non-normal blend scope (those change pixels in ways one color can't express); fills inside mask layers are skipped. - **Text opacity.** The product of the opacities of the groups enclosing the text, as a 32-bit float. - **Computed styles.** Resolved values in the browser's serialization: used `width`/`height`, used padding and margin when specified as percent or `auto`, `currentcolor` replaced by the color, unitless `line-height` as px, `min-*: auto` as `0px` outside flex and grid, `word-spacing: normal` as `0px`, `outline-width: none` as `3px`, numbers with six significant digits. - **Form state.** `inputValue` for every input (empty by default; `on` for checkbox and radio), `checked`, textarea text, option selectedness by the HTML selectedness algorithm. No click-ability facts. ## Known differences from the browser captures Markers have no text and their boxes are shapes, not text-width boxes; authored `oklch()` and similar colors serialize as `rgb()`; transforms are not applied to bounds; scroll offsets are ignored at the blending point; `option` elements get boxes inside `select` and `progress` gets none; initial `text-align`, `font-family` and `top`/`left` resolved values differ in places; sub-pixel layout rounding differs in offset rects (agreement within 1 px is 100% on the text-block set). ## Agreement with the browser `tools/geomdiff` commands `facts-capture` and `facts-compare` (`scripts/run-facts.sh`) capture the browser's facts on the same page with the same font files and compare fact by fact. Per-set summaries for the committed sets are in `results/facts/<set>/`. <!-- https://recursiveai.org/onerender/docs/geometry-differential/ --> # Chrome geometry differential Screen layout is checked against Chrome: for every page of a corpus set, every element's border box as Chrome lays it out is compared with the box our engine gives the same element. This document says what is measured, how, and how to reproduce it. The tooling is `tools/geomdiff/` (C#, `ClientWebSocket` DevTools client, source-generated JSON); nothing under `src/` depends on it. ## Pins | What | Value | |---|---| | Chrome for Testing | 156.0.8078.47, linux-x64, `chrome-linux64.zip`, sha256 `e6c94490…8774a50` (full hash in `tools/geomdiff/scripts/fetch-chrome.sh`) | | Fonts | Liberation 2.1.5, Carlito @3a810cab, Caladea @336a529c; upstream URLs and sha256 in `tools/geomdiff/scripts/fonts.tsv` | | Tailwind CSS | v4.3.3 standalone CLI, sha256 in `build-tailwind.sh` | | daisyUI, Flowbite | npm releases 5.7.50 and 4.0.2 (MIT), pinned by the registry's sha512 in `build-tailwind.sh` | Chrome is a black box: only DevTools Protocol output is read. It is fetched to `$ONERENDER_HOME/cache/chrome/` (default `~/onerender`), never committed, never redistributed, and only ever loads frozen content with the network off. ## Fonts `scripts/fontconfig.sh <fonts-dir> <out.conf>` writes a fontconfig file that exposes exactly the font files under `<fonts-dir>` and nothing else (Chrome is started with `FONTCONFIG_FILE` pointing at it). `<fonts-dir>` is either the cache filled by `scripts/fetch-fonts.sh` or the fonts package folder (`src/OneRender.Fonts/fonts`); both have the same layout and bytes. Set `FONTS_DIR` for `run-set.sh` to choose. The file maps names the way the fonts package does: `sans-serif`, `system-ui`, `ui-sans-serif`, Arial, Helvetica → Liberation Sans; `serif`, `ui-serif`, Times, Times New Roman, `cursive`, `fantasy` → Liberation Serif; `monospace`, `ui-monospace`, Courier, Courier New → Liberation Mono; Calibri → Carlito; Cambria → Caladea. A pattern that names nothing else ends at Liberation Sans. Hinting and sub-pixel rendering are off (see "Chrome settings"). `onerender-geomdiff fonts-check` loads `testdata/geomdiff/fonts-check.html` and asks Chrome (`CSS.getPlatformFontsForNode`) which platform fonts rendered each line; it fails if any is outside the five shipped families or not the expected one. The result is `results/geometry/fonts-check.md`. ## Corpus sets A set is a directory of frozen pages. Each page directory holds `index.html` (UTF-8), `page.json` (format `onerender-snapshot/1`: page id, the URL it is loaded from, and the list of resources with their URL, file and content type) and `res/`. Chrome replays a page from these files only: its own HTML, the listed resources, an empty answer for any other stylesheet or sub-document, and a blocked request for everything else (scripts, images, fonts, media, fetches). Scripts never run. Images and web fonts therefore never load, on either side; data: URLs do. | Set | Pages | How it is made | |---|---|---| | `onehtml-2026-10a` | 600 | A stratified sample of OneHtml's frozen crawl. Strata are crawl source (two) × body size third (three), equal allocation (100 each, taken in body-hash order). Each page is loaded offline from the crawl's stored bytes with scripts disabled; the serialized DOM (doctype + `outerHTML`, `<script>` elements removed) and every stylesheet served become the snapshot. Local only: never committed, never leaves the host; only aggregate numbers are published (`results/geometry/onehtml-2026-10a/`, no page identifiers). | | `tailwind` | 7 | `testdata/tailwind/pages/*.html`: our own pages using Tailwind utilities, daisyUI components and Flowbite-style component markup, compiled by `scripts/build-tailwind.sh`. The Flowbite theme CSS is imported; its JavaScript plugin does not load in the standalone CLI, so Flowbite-style pages use plain utilities. | | `text-block` | 18 | `testdata/text-block/`: our own articles, documentation, blog index, plain email, mixed scripts, footnotes, positioned badges and sticky bars, `white-space` blocks, images with fixed sizes, box-model and typography specimens, and four small clearing probes. Only block, inline, floats, positioning and overflow: no flex, grid, tables, forms or generated content. This is the gate set for flow layout. Where the engine follows Chrome beyond the specifications, `layout-conventions.md` says so. | | `templates` | 6 | `testdata/templates/`: invoice, statement (180 rows), receipt, certificate, email (table layout), annual report (18 chapters). Generated by `testdata/templates/generate.py` (seeded; output committed). Reused later for PDF output and benchmarks. | | `chrome-probes` | 9 | `testdata/chrome-probes/`: one small page per Chrome-matching rule recorded in `layout-conventions.md` (line-height quantization, break tailoring, balanced columns, auto margins in over-wide boxes, flex and grid baselines, aspect-ratio on flex containers, closed `details`, `@property` initial values). Each page was probed against Chrome before the rule was written; the set stays at 100% on both viewports and guards those rules. | | `forms` | 4 | `testdata/forms/`: a sign-up form, a settings page, a checkout form and a dense gallery of every control in each state. Gate: at least 99% per viewport (1280x800: 100%, 390x844: 99.70%; the one miss is a drop-down select inside a grid's min-content measure). Controls follow `forms.md`. | ## Element identity An element is identified by its **path**: the indexes of the element and of each ancestor element among the *element* children of its parent, joined by `/`. The document element is `0`, its second element child `0/1`, the first child of that `0/1/0`. Text, comments and the doctype are not counted. Why: it needs only the parsed tree, which both sides build from the same HTML bytes by the HTML parsing standard, so it needs no ids and no instrumentation of the page. If two parsers build different trees the paths disagree, and the tag name stored beside each path exposes it (`tag conflicts` in the summary). On Chrome's side, pseudo-elements, shadow trees (the insides of form controls), `<template>` contents and other documents (frames) are not part of the tree and are skipped. ## Capture format `<out>/<page id>/<W>x<H>.json.gz`, format `onerender-geometry/1`: ``` { "format": "onerender-geometry/1", "source": "chrome", "page": "<id>", "viewport": {"width": 1280, "height": 800}, "documentWidth": 1280, "documentHeight": 4210, "elements": [ {"path": "0/1/0", "tag": "div", "display": "block", "text": true, "box": [x, y, w, h]}, … ] } ``` Boxes are CSS pixels, origin at the document's top-left (scroll offset zero), rounded to 4 decimals. `box` is absent when the element generates none (`display: none`, `display: contents`). For an inline element that wraps, the box is the union of its line boxes. `text` is true when the element has a direct text child that is not only white space. `display` is Chrome's computed value. `<out>/run.json` (`onerender-geometry-run/1`) records the browser version, user agent, every flag, the font set id (a hash of the font files), the viewports and any page that failed. **Viewports.** 1280×800 (the size OneHtml's own captures use) and 390×844 (a phone's CSS width; device scale factor 1, no mobile emulation, no `<meta viewport>` handling). Every set is captured at both. ## Chrome settings `--headless=new`, device scale factor 1, `--hide-scrollbars`, `--font-render-hinting=none` and `--disable-lcd-text` (linear glyph advances with sub-pixel positioning, which is what an outline-based text engine produces), site isolation off, `HttpsUpgrades` off (a page recorded under an `http:` address must keep it), and a dead proxy plus an empty host resolver as a second wall behind request interception. One browser context per page. The full list is in `run.json`. To make two captures of one page agree, the capture waits for the load event, a short settle, every non-lazy image to finish (a blocked image reports its failure after the load event and changes the layout), and then puts every CSS animation and transition at its end state (animations that never end are cancelled, which restores the base style). An inspector style sheet sets `content-visibility: visible !important` on everything, because `auto` skips the layout of content away from the viewport and does so differently from run to run. **The reference is therefore the settled, fully laid-out page**; the engine is compared against that: no animation in progress, `content-visibility` treated as `visible`. The snapshot step does none of this to the stored HTML. ## Comparison For each page and viewport the comparer pairs elements by path. An element is *counted* when it has a box on at least one side. It is *within tolerance* when, for each axis, the position and the size differ from Chrome's by at most `max(1 px, 0.5% of Chrome's extent on that axis)` (x and width use the width; y and height use the height). | Class | Meaning | |---|---| | `position-only` | x or y out of tolerance, size within | | `size-only` | width or height out of tolerance, position within | | `position-and-size` | both | | `text-run` | a size difference (with or without a position difference) on an element that holds text of its own or is `display: inline`: its extent comes from glyph advances and line breaking, not the box model | | `missing-ours` | Chrome has a box, we have none (or an element with that path but another tag) | | `missing-chrome` | we have a box, Chrome has none | Every mismatch also records whether it is a **root cause**: no ancestor of it mismatches (other than by being missing). A shifted subtree is one root cause and many position-only descendants, so the root-cause counts are the better measure of how many things are wrong. **Rate** = within tolerance ÷ counted, pooled over a set's pages. The gate is 99% per viewport; `summary.json` also gives pages at the gate and the median and minimum page rate. `--engine` asks our side for boxes through `OneRender.GeometryProbe.LayOut(html, documentUrl, resolveResource, width, height, fonts)`: HTML in, `ElementBox` (path, tag, border box) out. It runs the engine's whole front half (parse, cascade, box tree, layout) through `HtmlPage` and reads each element's border box from the fragment tree. Elements without a box are absent, which the comparison counts as `missing-ours`. The tool passes the default fonts. Results under `engine` are from this path; `stub-engine` holds the baseline from before the engine could lay anything out. ## Results `results/geometry/<set>/`: `capture-run.json` (the Chrome run's `run.json`), and for each of `chrome-vs-chrome` (two independent captures: determinism and the font pin), `perturbed` (Chrome against a deliberately wrong copy; `expected-classes.json` holds the counts the wrongness must be reported as, and the comparer exits non-zero if they differ) and `engine`: `summary.json` (`onerender-geometry-summary/1`: tolerance, browser, font set, one row per viewport and a total with class counts, root-cause counts, tag conflicts, pages at the gate, median and minimum page rate, the ten tags with the most mismatches, `gateMet`), `summary.md`, and for the sets we wrote, `pages.md` (one row per page and viewport). Per-page mismatch detail (`onerender-geometry-detail/1`: path, tag, class, root cause, both boxes) is written beside the captures on the machine that ran them and is not committed. The perturbation moves about 2% of boxes, resizes 2%, moves and resizes 2%, resizes 2% of text-bearing boxes, drops 1% and adds one element Chrome does not have per page, chosen by a seeded hash of page and path. ## Known residual non-determinism Two captures of the same pages agree on 99.985% of counted elements in `onehtml-2026-10a`; all differences are in two pages: zero-size inline spans whose x differs between runs, and SVG icons that measure 12 px in one run and 14 px in the other. They are left as measured and bound what "Chrome against Chrome" can show. Nothing else has differed between captures. ## Reproduce On a host with .NET 10, `curl`, `unzip`, `tar` and `openssl` (state goes to `$ONERENDER_HOME`, default `~/onerender`): ``` cd tools/geomdiff/scripts ./build.sh # builds the tool in Release ./fetch-fonts.sh && ./fontconfig.sh ~/onerender/cache/fonts ~/onerender/cache/fonts.conf ./geomdiff.sh fonts-check --page ../../../testdata/geomdiff/fonts-check.html --out fonts-check.md \ --chrome "$(./fetch-chrome.sh | tail -1)" --fontconfig ~/onerender/cache/fonts.conf ./geomdiff.sh snapshot-static --src ../../../testdata/templates --out ~/onerender/corpus/templates ./build-tailwind.sh && ./geomdiff.sh snapshot-static --src ~/onerender/corpus/tailwind-src --out ~/onerender/corpus/tailwind ./geomdiff.sh snapshot-corpus --corpus <crawl dir with manifest.jsonl and bodies/> --count 600 --jobs 6 \ --out ~/onerender/corpus/onehtml-2026-10a --chrome <chrome> --fontconfig ~/onerender/cache/fonts.conf JOBS=6 ./run-set.sh <set> ~/onerender/corpus/<set> ../../../results/geometry --page-table ``` `run-set.sh` takes `PHASES` (`a`, `b`, `compare`) to split a long set into separate jobs, and `NO_SANDBOX=1` for hosts that forbid Chrome's sandbox (only frozen offline content is loaded). Each command's options are listed by running the tool without arguments. ## Non-finite engine boxes An engine box coordinate that is NaN or infinite is a defect. The comparison does not stop on it: the coordinate is replaced by 1e12, which mismatches any real box, and a line naming the page, viewport and element path goes to standard error. <!-- https://recursiveai.org/onerender/docs/wpt-results/ --> # WPT reftest runner and results `tools/OneRender.WptRunner` runs the [web-platform-tests](https://github.com/web-platform-tests/wpt) reftests of the modules listed in `tools/wpt/modules.json` and records per-module pass rates. Layout and paint work is gated on those rates. The runner talks to the engine through one seam, `OneRender.Image.HtmlRenderSeam.Render(html, uri, width, height, resources)`, which returns an RGBA image. The seam runs the whole pipeline: parse, cascade, box tree, layout, paint, rasterize. ## Reftest rules implemented From the WPT reftest documentation: - The page is rendered in an 800x600 viewport. A test and its reference are compared pixel for pixel. - A test may link several references with `<link rel=match>` and `<link rel=mismatch>`. It passes when at least one match reference matches and every mismatch reference differs. - A match reference that links references of its own continues a chain, which must also hold. Chains do not continue through a mismatch reference, and a reference already being checked up the chain counts as holding. - `<meta name=fuzzy content="[ref:]maxDifference=a[-b];totalPixels=c[-d]">`, names optional. The maximum difference is the largest per-channel (R, G, B) absolute difference; the pixel count is the number of pixels differing in any of those channels. A single number n means exactly n, a range is inclusive, and a `ref:` prefix limits the allowance to that reference. Without fuzzy the images must be identical. Images of different sizes never match. ## Print reftests A file whose name ends in `-print` right before the extension and any `.tentative`-style flags (`bar-print.html`, `bar-print.tentative.html`; not `bar-print-001.html`), or any file under a directory named `print`, is a print reftest (WPT's file-name rules). Both the test and its references are paginated and compared page by page: - Page size 12.7 cm by 7.62 cm (480 by 288 CSS px) with 12.7 mm (48 px) margins, unless the document's own `@page` rules say otherwise. - The comparison is the ordinary one (`match`/`mismatch`, chains, fuzzy), applied to each page separately; the fuzzy allowance is per page. A `match` needs the same number of pages and every page equal; a `mismatch` holds when the page counts differ or any page differs. Page images of different sizes never match. - `<meta name=reftest-pages content="range">` limits the comparison to those pages (1-based, `-2,4,6,7,9-`). As in WPT, a range belongs to the document that declares it: an unprefixed range selects pages of the test itself, `ref.html:range` selects pages of that reference, and a document without an entry is compared on every page. - A test is `blank` when every compared page of the test is blank. - **onerender:** `PagedDocument.Create` with the print-reftest default page, each page drawn as its page layer (page background and margin boxes) under the page's slice of the content, over white. All pages' content is rasterized in one surface, so a document wider than 16,384 px of side-by-side pages (about 34 pages of this size) fails with an error. - **Chrome:** `Page.printToPDF` with the same defaults (`preferCSSPageSize`, backgrounds on), then poppler's `pdftoppm -r 96 -aaVector no` to bitmaps (a PDF rasterizer, not a layout engine; it must be on the PATH). PDF rasterization puts a content edge that falls between device pixels on one side or the other, so Chrome sometimes fails a test whose geometry matches exactly (a 1 px row at a page top); that is the calibration's noise floor for print. `ONERENDER_WPT_TRACE=1` writes the stack of a rendering error to stderr. ## Outcomes | Outcome | Meaning | |---|---| | `pass` | The comparison holds and the test image has painted content. | | `fail` | The comparison does not hold, or rendering threw (reason `error: ...`). | | `blank` | The comparison holds but the test image is a single flat colour of nothing: fully transparent or opaque white throughout. A renderer that draws nothing therefore never passes. Some real tests ("passes if nothing red is visible") also land here, for every renderer, Chrome included. | | `skipped` | The runner cannot run the test; the reason is recorded. | Skip reasons: `testharness.js test`, `script-dependent` (script in the test or any reference), `reftest-wait (needs script)`, `print reftest (paged output)` (only for a renderer that cannot paginate; both renderers here can), `reference file missing: <path>`. Rate = pass / (total - skipped), or 0 when nothing was runnable. ## Module list `tools/wpt/modules.json` maps each module id to WPT directories. Module ids are the `css/` directory names, except CSS 2 which is split: | Module | Directories under `css/` | |---|---| | css2-flow | CSS2/normal-flow, box-display, box, margin-padding-clear, linebox, visudet, visuren, text, bidi-text | | css2-floats | CSS2/floats, floats-clear | | css2-positioning | CSS2/positioning, abspos, zindex, stacking-context, zorder, visufx | | css2-tables | CSS2/tables | | css2-generated | CSS2/generate, generated-content, lists | | css-flexbox, css-grid, css-tables, css-position, css-backgrounds, css-borders, css-values, css-color, css-fonts, css-text, css-inline, css-overflow, css-transforms, css-transitions, css-animations, css-break, css-page, css-multicol, css-ui, css-lists, css-images, css-text-decor | the directory of the same name | A test is a `.html`, `.htm`, `.xht`, `.xhtml` or `.svg` file under those directories that links a reference or loads testharness.js. Files in directories named `support`, `resources`, `reference`, `references`, `tools` are never tests. ## Fetching and running ``` tools/wpt/fetch-wpt.sh # pinned commit in tools/wpt/WPT_COMMIT -> $ONERENDER_CACHE/wpt/<commit>/ (default ~/onerender/cache) tools/wpt/fetch-chrome.sh # Chrome for Testing 156.0.8078.47, SHA-256 checked -> $ONERENDER_CACHE/chrome/156.0.8078.47/ dotnet build tools/OneRender.WptRunner -c Release R=<artifacts>/bin/OneRender.WptRunner/release/OneRender.WptRunner.dll W=~/onerender/cache/wpt/$(cat tools/wpt/WPT_COMMIT) dotnet $R run --wpt $W --renderer onerender --out testdata/wpt-results # whole module list, engine seam (seconds) dotnet $R run --wpt $W --renderer chrome --sample 400 --parallel 6 --out testdata/wpt-results # Chrome calibration sample dotnet $R run --wpt $W --renderer onerender --module css-flexbox # one module, console only dotnet $R run --wpt $W --renderer onerender --module css-flexbox --filter /abspos/ # a subset by path dotnet $R run --wpt $W --renderer onerender --test css/css-flexbox/abspos/abspos-autopos-htb-ltr.html --dump dumps # one test, PNGs of every image ``` Run from the repository root (the default module file is `tools/wpt/modules.json`; `--modules` overrides). Without `--out` nothing is written. `--sample n` takes every Nth test in path order so a module has at most about n tests; the stride is recorded in the results. Chrome mode serves the checkout over loopback HTTP, drives headless Chrome over the DevTools protocol (one tab per `--parallel`), waits for load and web fonts, and takes the first screenshot that equals the one before it. It is a black box used to check the runner, never an engine input. `--fonts <dir>` restricts Chrome to the fonts in a directory through a private fontconfig file. ## XHTML tests Files ending in `.xht` or `.xhtml` are XML documents in the suite, and many of them (and most of the references of CSS2) write their stylesheet as `<style><![CDATA[ ... ]]></style>`. The engine's front end is an HTML parser, which reads that as a broken selector and drops the first rule, so those pages used to render without their style and tests compared two blank pages. The runner therefore rewrites these files before handing them to the engine (`tools/OneRender.WptRunner/XhtmlSource.cs`): - CDATA sections become their character data (XML 1.0 §2.7): verbatim inside `style` and `script`, escaped text elsewhere. - An empty-element tag on a non-void element (`<div/>`, `<td />`) becomes a start and an end tag (XML 1.0 §3.1), since HTML ignores the slash and would nest what follows. - The XML declaration and other processing instructions are dropped. This is **not a conforming XML parser**: nothing is checked for well-formedness, there is no namespace processing, no DTD entity declarations, and no XML tree building (an XML document gets no implicit `html`, `head`, `body` or `tbody`; the HTML parser still adds them). Documents that depend on those differences can still pass or fail for the wrong reason. The rewrite lives in the harness, not the engine; `HtmlRender` and `HtmlPage` take HTML only. ## Results layout ``` testdata/wpt-results/<wpt-commit>/<renderer>/<module>.json one per module testdata/wpt-results/<wpt-commit>/<renderer>/summary.json table of all module files in the directory testdata/wpt-results/<wpt-commit>/<renderer>/summary.md the same as a table, plus the most common failure and skip reasons ``` `<renderer>` is `onerender` (the engine through the seam) or `chrome`. The directory `stub` holds the zero baseline from before the engine could draw. Re-running a subset into the same directory rewrites those module files and regenerates the summaries from all files present. Module files list tests in ordinal path order so runs diff cleanly. Module file, `formatVersion` 1: ```json { "formatVersion": 1, "wptCommit": "<40 hex>", "renderer": "onerender|chrome", "rendererVersion": "Chrome/156.0.8078.47", "module": "css-flexbox", "discovered": 1398, "sampleStride": 1, "counts": { "total": 1398, "pass": 0, "fail": 0, "blank": 952, "skipped": 446 }, "rate": "0.0000", "pass": ["css/css-flexbox/a.html"], "blank": ["css/css-flexbox/b.html"], "fail": { "css/css-flexbox/c.html": "== c-ref.html differs (max 255, 733 px)" }, "skipped": { "css/css-flexbox/d.html": "testharness.js test" } } ``` - `discovered` is the module's test count before sampling; `total` is how many were run (equal unless `sampleStride` > 1). - Test ids are WPT-relative paths. - Failure reasons: `== <ref> differs (max <largest channel difference>, <n> px)`, `!= <ref> identical`, `error: <exception>: <message>`. - `rate` is a string with four decimals. Floors are set against `<module>.json` of the `onerender` renderer: a floor is a minimum `pass` count or `rate`. Changing `formatVersion` means a new number, never a silent change. ## Calibration against Chrome Chrome for Testing 156.0.8078.47 on Linux, headless, system fonts, sample stride so each module has at most about 400 tests. It shows the comparator, fuzzy handling and chains behave as WPT's own runner does: where Chrome fails, the reasons are Chrome or test conditions, not the runner. Numbers are in `testdata/wpt-results/<commit>/chrome/summary.md`: 7,326 sampled tests, 4,096 pass, 503 fail, 7 blank, 2,720 skipped, rate 0.889 (0.935 without the 227 failures in `grid-lanes` and `line-clamp`, which Chrome does not ship by default). Two runs of the same sample can differ by a test or two under heavy host load (a late-loading image or font), so compare Chrome numbers to a percent, not a test. The failures concentrate in: - features Chrome ships only behind flags or not yet (`css-grid/grid-lanes`, `css-overflow/line-clamp` block ellipsis, `css-break` grid-lanes, `css-inline` initial-letter and text-box-trim, `css-borders` corner-shape); - tests whose expectation does not match Chrome's current behaviour (for example `css-backgrounds/background-size/vector`: Chrome sizes the viewBox-only SVG at 256 px tall where the reference draws 128 px); - text tests that depend on installed fonts rather than the Ahem web font (`content-*`, `table-anonymous-objects-*`, hyphenation without dictionaries, control characters). <!-- https://recursiveai.org/onerender/docs/pixel-similarity/ --> # Pixel similarity against Chrome Information only: no gate depends on it. It shows how close our pixels are to Chrome's on the pages the geometry differential uses, and where they differ most. The tool is the `pixels` command of `tools/geomdiff` (`Pixels.cs`, `Ssim.cs`), run per set by `tools/geomdiff/scripts/run-pixels.sh`; the summaries are in `results/pixels/<set>/` (`summary.md` and `pages.tsv`, one row per page and viewport). ## Method - **Chrome side:** the frozen page is replayed offline exactly as for the geometry capture (same Chrome for Testing 156.0.8078.47 flags, fonts through the private fontconfig file, 1280×800 and 390×844, device scale factor 1, settled page: images loaded, animations at their end, `content-visibility` forced visible). The screenshot is `Page.captureScreenshot` with `captureBeyondViewport` over the whole document, from the top left, cropped to 4000 CSS px tall (`--max-height`). - **Our side:** the same HTML and resources through `HtmlPage`, `Painter` and `DisplayListRasterizer` (what `HtmlRender.ToImage` does), with the layout viewport at the capture's size, so `vh` and percentages resolve as in Chrome, and an image as tall as the screenshot, on a white page. - **Score:** SSIM (Wang et al. 2004) on Rec. 601 luma, uniform 8×8 windows moved 4 px, K1 = 0.01, K2 = 0.03, range 255, mean over windows; 1 is identical. `pages.tsv` also gives the share of pixels whose RGB differs at all, which is harsh: one pixel of anti-aliasing counts. ## Results Chrome for Testing 156.0.8078.47, font set from the `.Fonts` package files, engine at the commit that adds this page. | Set | Viewport | Pages | Mean SSIM | Min SSIM | Lowest page | |---|---|---|---|---|---| | text-block | 1280×800 | 18 | 0.9885 | 0.9694 | field-notes | | text-block | 390×844 | 18 | 0.9749 | 0.9504 | field-notes | | templates | 1280×800 | 6 | 0.9880 | 0.9822 | report | | templates | 390×844 | 6 | 0.9793 | 0.9596 | report | | tailwind | 1280×800 | 7 | 0.9725 | 0.9272 | flowbite-components | | tailwind | 390×844 | 7 | 0.9506 | 0.8709 | flowbite-components | | chrome-probes | 1280×800 | 9 | 0.9998 | 0.9996 | foot | | chrome-probes | 390×844 | 9 | 0.9995 | 0.9990 | foot | | grid-lab | 1280×800 | 6 | 1.0000 | 1.0000 | (all identical) | | grid-lab | 390×844 | 6 | 1.0000 | 1.0000 | (all identical) | What the lowest page shows (looked at side by side, `flowbite-components` at 390×844): the form controls (inputs, checkbox, password field) are not drawn in Chrome's default style yet, a card header that Chrome paints with a gradient is plain white in ours (a Tailwind 4 gradient utility; the cause is not yet traced), and the timeline dots are squares. Pages of the other sets were not inspected one by one; their layout agrees with Chrome's (geometry 100% and 99.97%), so the remaining difference is mostly glyph and edge anti-aliasing. ## Reproduce On a host with the geometry differential set up (see `geometry-differential.md`), with the frozen sets under `$ONERENDER_HOME/corpus/<set>`: ``` tools/geomdiff/scripts/build.sh NO_SANDBOX=1 JOBS=4 tools/geomdiff/scripts/run-pixels.sh tailwind ~/onerender/corpus/tailwind results/pixels [dump-dir] ``` With a dump directory, `<page>-<viewport>-chrome.png` and `-ours.png` are written for looking at by eye; they are not committed. <!-- https://recursiveai.org/onerender/docs/benchmarks/ --> # Interactive benchmarks and the gate The interactive bar, measured on a 1,000-node game-UI page (`testdata/game-ui/hud.html`, 1280×720): - a typical change takes at most 1 ms for restyle, relayout and display-list update together, and allocates 0 B; - a frame in which nothing changed allocates 0 B and does next to no work; - transform and opacity animations patch the display list without laying anything out; - the engine is no slower than OneGame.UI on the same changes. ## Running the gate On the benchmark host, from the repository root: ``` tools/bench/run-interactive-gate.sh # the gate: takes the host's benchmark lock, publishable tools/bench/run-interactive-gate.sh --smoke # short, no lock: proves the harness runs, numbers are not publishable ``` The script builds both benchmark projects in Release, then runs BenchmarkDotNet with fixed settings (gate: 15 warm-up and 40 measured iterations of 200 ms in 3 launches; smoke: 5 and 10 in one launch), pinned with `taskset` to `GATE_CORES` (default `8-15`; smoke uses `SMOKE_CORES`, default `0-7`, at low priority). The gate takes `~/WORKER-BENCH.lock` atomically and releases it on exit; it never waits, and exits with status 3 and the holder's line when the lock is held. It needs the .NET 10 SDK (`DOTNET_DIR`, default `~/dotnet10`). `ONERENDER_WORK` (default `~/onerender`) is where build output and results go, and `GATE_COMMIT` is recorded in the machine record. ## Reading the results The results folder (`$ONERENDER_WORK/bench/<mode>-<time>/`) holds: - `machine.txt`: CPU model, logical cores, pinned cores, governor, SDK, BenchmarkDotNet arguments, and the load average with the busiest processes before the run, every 30 s during it and after it. A gate run is publishable only if nothing else heavy shows up there. - `summary.md`: two tables. - *Bar*: every engine benchmark with its mean in microseconds and allocated bytes; PASS needs a mean of at most 1000 µs and 0 B. - *Comparison*: the same four changes through OneGame.UI and through the engine; the last column says whether the engine is no slower. - *Like for like*: OneGame.UI's own flex-only page through the engine's builder front end (see below), next to OneGame.UI's numbers, with the ratio, and the scene check that ran first (`equivalence.txt`). - `<project>/BenchmarkDotNet.Artifacts/results/`: BenchmarkDotNet's markdown, CSV, HTML and JSON per benchmark class, and `<project>/run.log`. The benchmarks: | Class | Benchmark | What one call does | |---|---|---| | `IncrementalBenchmarks` | `IdleFrame` | `LivePage.Flush` with nothing pending | | | `HoverRestyle` | moves hover from one inventory slot to another, then flushes (restyle, boxes, layout of what changed, repaint with damage) | | | `TextChange` | sets a label's text, then flushes | | | `SubtreeRelayout` | resizes one quest row (a declaration swap), then flushes | | `StructuralBenchmarks` | `InsertSubtree`, `RemoveSubtree`, `MoveSubtree` | a floating layer of about 50 nodes is inserted, removed, or moved under another parent, then a flush; each call is one measured invocation with its setup (putting the layer in or out) outside the timing | | | `ChurnFrame` | one frame of the churn soak: a 50-node layer in and the last one out, one chat line in and the oldest out, then a flush | | `MotionBenchmarks` | `CompositorTick100` | advances the clock 16 ms with 100 transform and opacity animations running, then patches the display list | | | `MixedTick100Compositor4Layout` | the same 100, plus width animations on two slots and color and background-color animations; repaints when layout or paint properties changed | | | `WidthTransitionTick` | a CSS transition on one row's width (`transition: width` on `#quest3`, started by hovering it, 400,000 px over 100,000 s so it never ends and moves about 0.06 px a frame): advances the clock 16 ms, interpolates the width, lays out the row's subtree again and updates the display list. The frame of a game UI that animates a layout property; it has its own table in `summary.md` | | | `IdleAdvance` | advances the clock on a page with no animation | | `OneGameUiBenchmarks` | the same four as `IncrementalBenchmarks` | the change, then `UiTree.Frame()` (bindings, style, layout, draw-list update) | | `FlexHudBenchmarks` | `IdleFrame`, `HoverRestyle`, `HoverWithHitTest`, `TextChange`, `SubtreeRelayout` | the same changes on the page `OneGameUiBenchmarks` builds, built through the engine's `StyledTree` (see below) | The setup of each benchmark runs its changes 200–400 times first so the JIT has promoted the code, because a running game is warm. ## Timing lives here, not in the unit tests The unit tests assert nothing about wall time, which depends on the load of the machine. They assert allocation, counted work (for example flex measurements per item) and results. The time of the large and hostile cases they used to clock is a benchmark row: `StressBenchmarks` (100,000 flex items with and without wrapping, 10,000 floats, 100,000 grid items, a hit test on a page of about a thousand nodes, and a frame of the HUD churn in which one element's `left` and one label's width take new values every frame): dotnet run -c Release --project bench/OneRender.Benchmarks -- --filter '*StressBenchmarks*' On the benchmark host this takes the worker lock like any other timing run. ## The OneGame.UI comparison `bench/OneRender.Benchmarks.OneGameUi` references OneGame.UI through its published package (`RecursiveAI.OneGame.UI` and `.Text`, pinned in `Directory.Packages.props`) and uses only its public builder, style-sheet and input API. It is a benchmark project only; nothing under `src/` refers to it. It contains `OneGameUiBenchmarks`, which builds the same page as `GameUiScene` with `Ui.Row`, `Ui.Column`, `Ui.Label` and style sheet `:hover` rules, shaped with the same Liberation Sans files, and it compiles the engine's `IncrementalBenchmarks` in, so one report holds both sides. Differences to keep in mind when reading it: - the page's CSS grids become wrapping rows of fixed-size cells, because OneGame.UI lays out with flexbox only; - the engine side runs the builder front end of the engine (`LivePage`), the way a game host would drive it; - OneGame.UI's hover goes through `UiTree.Dispatch` with a pointer move, which includes its hit test; the engine's hover sets the element states directly. ## Like for like: the flex-only page through the engine The comparison above runs the engine on the page written in full CSS (a 192-cell grid for the minimap, a grid for the inventory), which is more than OneGame.UI can express, so the subtree row is not like for like. `FlexHudBenchmarks` reverses it: the page `OneGameUiBenchmarks` builds with OneGame.UI (about 1,000 nodes, flex only: wrapping rows of fixed-size cells in place of the grids) is built again as a `StyledTree` by `FlexHudScene`, with style declarations and no HTML, and driven through `LivePage` as a game host would. Every OneGame.UI `Row`, `Column`, `Box` and `Label` is one element, in the same order, with the other library's flex defaults written out (`display:flex`, row direction, `box-sizing:border-box`, `flex-shrink:1`). - **The scene check.** Before the benchmarks, `OneRender.Benchmarks.OneGameUi.dll --verify-scene` builds both pages and walks them together: the same number of elements with the same nesting, and every border box within 0.5 px, after the first layout and again after the text change and the resize. Any difference stops the run (setup of the benchmark class makes the same check). The result is in `equivalence.txt` and in the summary. Two things were needed to make the pages agree: labels carry `line-height: 1.1499`, which is the fractional line height OneGame.UI takes from the font (CSS `normal` rounds the line to a whole pixel, which drifts by 0.2 px per label), and the bold face is registered with the OneGame.UI font library under the same family name as the regular one so that `font-weight: 700` selects it (the earlier setup used a separate family name, so bold labels there were measured in the regular face; the numbers of `OneGameUiBenchmarks` are not affected beyond that). - **The changes** are the ones above. `HoverRestyle` sets the element states directly; `HoverWithHitTest` finds the element under the pointer first, which is what OneGame.UI's pointer dispatch does, so it is the fairer partner of OneGame.UI's hover row. - **Reading it.** The two sides still differ in what a frame includes: OneGame.UI's `Frame()` also runs data bindings and builds its own draw list; the engine's `Flush` restyles, patches boxes, lays out what changed and repaints into the retained display list with damage. # The document benchmark HTML to tagged PDF (`HtmlPdf.ToPdf`, default options) against headless Chrome print-to-PDF, with WeasyPrint run for information. One command on the benchmark host: `tools/bench/run-documents-gate.sh` (takes the benchmark lock inside the job, exits 3 if it is held, records CPU, cores and load before, during and after) or `tools/bench/run-documents-gate.sh --smoke` (no lock, short, not publishable). It writes `summary.md`, `machine.txt`, the raw per-run lines and the PDFs under `$ONERENDER_WORK/bench/`; a copy of a run's summary and raw lines is kept in `results/bench/documents/`. ## The documents `tools/bench/documents/make-documents.py` writes them as single HTML files with the style sheets inlined: | Document | Source | Size | |---|---|---| | `invoice` | `testdata/templates/invoice` | 2 pages | | `report` | the report template's generator with 19 chapters | 50 pages | | `table10k` | one table of 10,000 rows with a repeating header | about 210 pages | | `tailwind` | a marketing page built with Tailwind 4.3.3 plus daisyUI and Flowbite (`tools/geomdiff/scripts/build-tailwind.sh`), 420 KB of CSS | 5 pages | ## The tools and what is measured - **onerender, process**: `tools/bench/OneRender.PdfCli`, published as a NativeAOT executable, one fresh process per run. A command-line or serverless host ships the engine this way. The same call under the JIT (`dotnet OneRender.Benchmarks.Documents.dll render`) is measured too, for information; its first run includes JIT compilation of the whole pipeline. - **onerender, warm**: BenchmarkDotNet (`bench/OneRender.Benchmarks.Documents`, `MemoryDiagnoser`), one call in an already running process: mean time and bytes allocated. - **Chrome**: `tools/bench/OneRender.ChromePdf` drives Chrome for Testing 156.0.8078.47 over the DevTools Protocol (the client of the geometry differential) with `Page.printToPDF` on A4, half-inch margins, backgrounds, tagged output, `@page` size honoured. The same launch flags as the differential (headless, no GPU, site isolation off). Fonts are limited by the fontconfig file to the font files the fonts package ships. *Cold*: a new browser per run, timed from the start of its process to the arrival of the PDF. *Warm*: further prints, each in a new tab, in the last cold browser. - **WeasyPrint**: a fresh `weasyprint` process per run from its own `uv` environment (version recorded in `machine.txt`), same fontconfig file. - **Memory**: peak of the whole process tree, sampled every 10 ms from `/proc`: the proportional set size (shared pages divided among the processes that map them; the figure the verdict uses) and the sum of resident sizes (shared pages counted in every process, so it overstates a multi-process browser). Warm Chrome memory is not reported: closed tabs leave their processes behind, so it only grows. - **Verdict** for a document: onerender is ahead of Chrome on process wall time, warm time and peak PSS. The output page counts are in the table so a difference in pagination is visible; they are not required to match. ## Changing what is compared Everything lives in the three scripts under `tools/bench/` and `tools/bench/documents/`; the number of runs is at the top of `run-documents-gate.sh`. The set of font files for Chrome and WeasyPrint is `tools/geomdiff/scripts/fetch-fonts.sh`. <!-- https://recursiveai.org/onerender/docs/architecture/ --> # Architecture: trees, handles and the layout entry This page describes the front half of the pipeline as built: the styled-node tree, computed values, the box tree and the fragment tree. Layout algorithms, paint and the display list build on it. ``` OneHtml Document + DocumentStyles ──► HtmlFrontEnd ─┐ ├─► StyledTree ─► BoxTree ─► ILayoutEngine ─► FragmentTree C# builder (StyleDeclaration) ──────────────────────┘ (+ StyleTable) ``` ## Shared rules - **Handles.** `StyledNode`, `Box` and `Fragment` are `readonly struct { Tree, Index }`. They hold no data; every accessor reads a column of the owning tree. The default value is the null handle (`IsNull`). A handle is valid until its tree is cleared or rebuilt; indexes are dense, assigned in document order (parents before children). - **Storage.** Each tree is struct-of-arrays: parallel `int`/`byte`/`double` columns and first-child / next-sibling links, grown by doubling and **retained** across `Clear()` / `Build()`. Rebuilding a tree of the same size allocates nothing (asserted by tests for a 1,000-node document built from HTML and from the builder). - **No recursion on depth.** Every traversal (building, dumping, layout helpers) uses an explicit stack. Trees of 100,000 levels or 100,000 siblings build and dump. - **Limits.** `TreeLimits` bounds node count (2M), depth (1,048,576) and text length (32M UTF-16 units); the box tree allows twice the node limit. A limit is checked before anything is stored; the over-limit node is dropped and a `LimitExceeded` entry goes to `StyledTree.Diagnostics` (capped at 256 entries, the rest counted in `Dropped`). Declarations that survive the cascade but do not parse raise `InvalidValue` diagnostics. - **Determinism.** Ordinal comparisons only, no culture-sensitive APIs, no clock, no file or network access. The host supplies natural sizes of replaced content through `IResourceResolver` and fonts through `IFontProvider`. ## Computed values (`OneRender.Style`) - `PropertyId` lists the longhands the engine models (display, box model, position, sizing, borders, overflow, font, text, color and a few more). Shorthands are expanded before they reach the pipeline: by OneHtml's cascade for the HTML front end and by `StyleDeclaration.TrySet` for the builder, with `ShorthandFallbacks` covering `border-radius`, `overflow`, `flex`, `grid` and the others the cascade keeps whole. (`white-space`, `text-wrap` and `border-block`/`border-inline` are expanded by OneHtml 1.2 on both paths.) - `ComputedValues` is one struct of typed fields (enums, `Length`, `ColorValue`, doubles). **Styles are interned**: a `StyleTable` stores one row per distinct computed style, and a node holds a `StyleId`. A document with thousands of nodes typically has tens of rows. Rows are found by value equality, so `StyleId` equality means identical style. The table holds at most 65,536 rows at once; rows nothing refers to any more are freed and reused (see Long-running stability). If a single flush needs more than the table can hold, a diagnostic is raised and the parent's style is reused. - A computed style is cached by (declaration source, version, parent `StyleId`, root font size); a warm rebuild computes nothing and allocates nothing. - `Length` keeps what layout needs: pixels for every absolute, font-relative and viewport unit, and an unresolved form for percentages and `calc()` containing percentages (`CalcValue` evaluates against a containing-block size at layout time). Keywords (`auto`, `min-content`, `max-content`, `fit-content`, `normal`, `none`) are `LengthKind` values. - `inherit`, `initial` and `unset` are resolved when the style is computed. `revert` and `revert-layer` act as `unset` in builder trees, which have no earlier origin. - **Invalid declarations are dropped in the cascade.** `StyleValidation.Validator` is the engine's grammar offered to OneHtml as `StyleSourceOptions.DeclarationValidator` (`HtmlPage` sets it; a host that builds its own `DocumentStyles` should too). OneHtml asks it once per declaration, when a rule's declarations are first collected, for author and user declarations of properties OneHtml does not type itself. A rejected declaration neither wins nor counts as `!important`, so an earlier valid one stands (CSS Syntax 3 section 5.4.6, CSS Cascade 4 section 4.3). For a longhand the validator runs the same parser that computes the value, against initial values, so the two cannot disagree; for `border-radius`, `overflow`, `flex`, `grid` and the other shorthands OneHtml passes through whole, it expands them with `ShorthandFallbacks` and checks every longhand. The check parses the value text again (OneHtml's token range cannot be turned back into a value list), which costs one small allocation per declaration per stylesheet and nothing per restyle. It holds one lock around a shared scratch computer. - What still reaches the style computer as invalid behaves as `unset` and raises `InvalidValue` (CSS Variables 1 section 3.2 for `var()`): values with `var()`, which are only known at computed-value time; properties OneHtml types itself (`display`, `position`, the offsets, `overflow-x/y`, `visibility`, `font-size`, `font-weight`, `font-style`, `font-family`), where OneHtml's own grammar decides; and builder trees, which have no cascade. - What is deliberately lossy is listed, with the reason, in `tests/OneRender.Tests/testdata/wpt-style/expected-failures.tsv` (colors are stored as sRGB, `content` with counters is kept as raw text, and so on). ## Styled-node tree (`OneRender.Tree.StyledTree`) Elements and text nodes in document order, each with a tag name (builder trees and diagnostics), a `StyleId` and, for the HTML front end, the `Node.Index` of the OneHtml node it came from (`StyledTree.FindBySource`). Two front ends feed it: - `HtmlFrontEnd.Build(DocumentStyles)` walks the OneHtml document with an explicit stack, visits elements and text (not comments), computes styles from the cascade, and adds `::before`, `::after` and `::marker` nodes from OneHtml's pseudo-element styles when they generate content. Elements the user-agent sheet hides (`head`, `script`, `style`) are `display: none` and get no box. - The builder: `AddRoot` / `AddElement` / `AddText` with a `StyleDeclaration` of CSS text per node (`new StyleDeclaration().Set("width", "50%")`). It goes through the same value parsers as a style sheet. Replaced elements (`img`, `video`, `canvas`, `iframe`, `embed`, `object`, `svg`) have no children in the styled tree. Their natural size comes from `IResourceResolver` (or `StyledTree.SetNaturalSize` in a builder tree); with none, 300x150 for non-images and NaN for images. ## Box tree (`OneRender.Tree.BoxTree`) `BoxTree.Build()` generates boxes from the styled tree following CSS Display 3 and CSS2 §9.2. - A `Box` has a `Kind` (Container, Inline, Text, Replaced, LineBreak, Marker), an outer `Level` (Block or Inline) and, for containers, a `FormattingContext` (Block, FlowRoot, Flex, Grid, Table and its parts). Flex, grid and table boxes are marked only; table internals are not fixed up. - `display: none` makes no box; `display: contents` makes the element's children boxes of its parent. - Anonymous block boxes wrap inline runs when a container also has block-level in-flow children (§9.2.1.1), and anonymous inline (text) boxes wrap text (§9.2.2.1). Out-of-flow boxes and outside markers do not split or start runs. - A block inside an inline splits the inline into pieces linked by `Box.NextPiece` (`Continuation` marks the later ones); nesting deeper than 64 turns the block into an inline-block and raises a diagnostic. - Whitespace-only text that collapses is dropped at run edges, in flex and grid containers and inside table structure (CSS Text 3 §4.1.1). Children of flex and grid containers are blockified; their text runs get anonymous items. - `list-item` boxes get a `Marker` box (a placeholder; none when `list-style-type` is `none`). - `BoxTree.PrincipalBox(StyledNode)` maps a styled node to its (first) box. - `BoxTree.Dump()` prints an indented text form, used by tests to compare the two front ends. ## Fragment tree (`OneRender.Layout`) Layout writes a `FragmentTree` and paint, hit testing and the harnesses read it. - Kinds: `Box` (a box fragment, one per box or per split piece), `Line` (a line box inside an inline formatting context), `Text` (a run of text in one line, referencing a glyph run) and `Column` (a fragmentainer, see "Fragmentation"; it has no box). - **Coordinates.** Every fragment's `BorderBox` is in **CSS px, relative to the border-box origin of its parent fragment**; the root's is relative to the initial containing block. `AbsoluteBorderBox` and `AbsoluteOrigin` walk up the chain. `Border` and `Padding` are the resolved edge widths; the content box follows from them. `ScrollableOverflow` is in the fragment's own coordinates and covers the box's descendants' overflow; `Baseline` is measured from the border-box top. - **Glyph runs.** `AddGlyphRun` stores a OneFont `FontFace`, the font size, glyph ids, x/y positions and cluster indexes (into the box's text) in shared columns; a `Text` fragment holds a `GlyphRunId`. - **Fragmentation.** A box that splits (inline pieces, later fragmentainers) has several fragments chained by `Fragment.NextOfBox`; `FragmentOf(box)` returns the first. `Fragment.FragmentainerIndex` is the index of the column a fragment landed in (0 outside any fragmentation context). - **Keyed by element.** `TryGetBorderBox(StyledNode, out Rect)` and `TryGetBorderBox(Node, out Rect)` (a OneHtml node) return the union of the element's fragments in document coordinates; this is what the geometry differential compares. - `FragmentTree.Dump()` prints the tree as text. ## The layout entry ```csharp public interface ILayoutEngine { void Layout(BoxTree boxes, in LayoutInput input, FragmentTree output, CancellationToken cancellationToken = default); } ``` `LayoutInput` carries the initial containing block (`AvailableWidth`, and `AvailableHeight` or NaN when the content decides) and an `IFontProvider`, the text service layout calls: `Resolve(in ComputedValues)` matches a style to a font list with per-character fallback, `Shape(text, in ComputedValues, rtl)` returns a cached `ShapedText`, and `GetMetrics(in ComputedValues)` returns the primary face's metrics (see `text.md`; `TextFontProvider` is the engine's implementation). An implementation clears `output`, writes a fragment for the root and for every box that generates one in tree order, reads nothing but its arguments, and checks the token every few hundred boxes. The result is a pure function of the box tree, the fonts and the available size. Block and inline flow, floats, positioning, flex, grid and tables are separate implementations behind this one entry; fragmentation is a stage the block engine runs at the end (see "Fragmentation"). ## Block layout (`BlockLayoutEngine`) `BlockLayoutEngine` implements `ILayoutEngine` for normal-flow block-level boxes: the block formatting context of CSS2 §9.4.1, widths (§10.3.3, replaced §10.3.2/§10.3.4 with the constraint table of §10.4), heights (§10.5 percentages that behave as `auto`, §10.6.3, min/max §10.7), `box-sizing`, `aspect-ratio` (Sizing 4 §5), margin collapsing (§8.3.1) and intrinsic sizes (Sizing 3 §5). Grid and table containers are laid out as plain block containers until their own layout exists (flex containers: see "Flex layout"); floats and `clear` are described under "Floats" below; absolutely positioned boxes are described under "Positioning". - **No recursion.** One explicit stack of frames (a struct array kept between runs) walks the box tree; a frame is a box whose children are being placed. Depth is capped at 262,144 levels (a diagnostic, and the deeper boxes get no fragment). Laying out an unchanged tree again allocates nothing. - **Coordinates.** Every fragment is added when its box is opened, with its border-box rectangle fixed when it closes. Child rectangles are relative to the parent's border box, as the fragment tree requires. `Baseline` of a block is its first in-flow line or child baseline from the border-box top, NaN when there is none. - **Margin collapsing.** One margin run (largest positive plus most negative) is kept for the block formatting context. A box whose top margin adjoins its first child's (no top border or padding, not a BFC root) is *pending*: the run keeps growing through the chain of pending boxes and, when the first thing with content (or a box with its own top border or padding, or a BFC root) is reached, it is resolved once and moves the outermost pending box; nested pending boxes sit at offset 0 in their parent. A pending box that closes without content collapses through: it sits where a box with a non-zero bottom border would (§8.3.1), and its bottom margin joins the run. At the end of a box the run passes through to the parent only if the height is auto, nothing is below the last child, the box is not a BFC root, and min/max/aspect-ratio do not change the height; otherwise it is resolved into the content height. Boxes that establish a BFC are the root element, anything but `display: block`/`list-item`/anonymous containers, floats, absolutely positioned boxes and scroll containers (`overflow` other than `visible`/`clip`). - **Intrinsic sizes** (`GetIntrinsicSizes`) are content-box min-content and max-content widths, cached per box until the next `Layout`, computed with an explicit stack. A block's sizes are the largest child contribution (the child's definite width or intrinsic size, clamped by min/max width, plus fixed margins, border and padding). They back `min-content`, `max-content`, `fit-content` and shrink-to-fit widths. Percentages count as auto. - **Re-entry.** `LayoutIndependent` lays out one box as its own BFC root, nested inside a running layout; inline layout uses it for inline-block and other atomic boxes (`shrinkToFit` selects CSS2 §10.3.5 widths). - **List items.** An outside `::marker` box gets a fragment one `font-size` wide and one line high just outside the item's start edge, at the content top. This is a placeholder until real markers. - **Not yet:** the block-axis `fit-content`. ### The inline seam (`IInlineLayout`) A block container with `ChildLayout.Inline` hands its children to an `IInlineLayout`: ```csharp InlineLayoutResult Layout(in InlineLayoutRequest request, FragmentTree output, Fragment parent, CancellationToken ct); void Measure(Box container, IFontProvider fonts, out double minContent, out double maxContent); ``` - The request carries the container, the content-box width to fill, the content box's offset inside the container's border box (`ContentLeft`, `ContentTop`, where lines are added as children of `parent`), the content box's origin in the BFC root's content box (`BfcLeft`, `BfcTop`, where float exclusions are looked up), the font provider and the exclusion space (`Exclusions`, see "Floats"). A request built without one has the empty `NoExclusions` space. - The result is the total line height, the line count and the first and last baselines from the content top. A container with lines counts as having content for margin collapsing; the block layout settles the margin run before the call. - `Measure` returns the content-box min-content and max-content widths. - The result also carries the extent of everything the lines occupy (`HasOverflow`, `Overflow*`, in the container's border-box coordinates), which the block layout adds to the scrollable overflow, and `QueuedAbsolutes`, true when the inline layout has already handed the absolute boxes of its content to the block layout (`IAtomicInlineHost.QueueAbsolute`). - `InlineLayoutEngine` (see "Inline layout") is the default. `PlaceholderInlineLayout` is the stand-in for tests of the block layout that need no fonts: a container with any non-blank text, replaced or atomic inline child gets one empty line box as tall as `line-height` (`normal` is 1.2 × font size) and the intrinsic inline sizes are 0. ### Floats Floats, `clear` and the boxes that avoid floats live in `Layout/Block/BlockLayoutEngine.Floats.cs` and `.Open.cs`. - **Placement (CSS2 §9.5.1).** Each block formatting context keeps its floats as margin boxes in the coordinates of its root's content box, sorted by top (rule 5 makes the tops non-decreasing). A float is laid out first, as the root of a context of its own, so its size is known; its position is then the highest one (rule 8) that is as far left, or right, as the earlier floats allow (rules 2, 3, 7 and 9), not above the flow position it was met at, the top of its containing block or the line it sits on (rules 4 to 6). Rule 3 is measured against the other side's floats wherever they are; rule 7 applies whenever another float on the same side is in the way, even outside the containing block, and a float with none to its side may stick out of its containing block. `float: inline-start | inline-end` and the logical `clear` values are resolved to left and right by the computed style. - **Width and height.** A float's `auto` width is shrink-to-fit (§10.3.5, from the intrinsic-size cache) and its auto margins are 0; a floated replaced box uses §10.3.6. The auto height of a block formatting context root reaches the bottom margin edge of its lowest float (§10.6.7); floats are also added to the root's scrollable overflow. Intrinsic sizes count floats (Sizing 3 §5.2): the widest float for min-content, and the sum of consecutive floats (broken by `clear`) for max-content. - **Floats and margins.** A float is placed where a block would be, after the margins collected so far. While the top margin of the box that holds it is still pending (§8.3.1) the float is held until that position is settled, so that a larger margin collapsing through the container moves the float with it. A box that starts a formatting context is separated from adjoining floats: a margin that would pull them down does not. - **Clearance (§9.5.2).** A box with `clear` gets clearance when its hypothetical position, with its own top margin and the margins of the first children that adjoin it collapsed into the run, is above the bottom edge of the floats it clears, or when a float adjoining it would otherwise be pulled down with it. The margins before the box end there; its border edge goes to the bottom edge of the floats whatever its own margin is. A cleared box that is still pending keeps the margin collapsing with its first child, and its position is raised to the floats' edge when the pending chain is settled. - **Boxes beside floats (§9.5).** An in-flow block that is a block formatting context root, and block-level replaced boxes, are put beside the floats at their top edge. An `auto` width shrinks to the room between the floats (not below its min-content width); a definite width fits when its border box and start margin do (an over-constrained end margin is dropped). If the box does not fit it moves down to the next float edge, and with no float edge left it stays. - **The exclusion space.** `IExclusionSpace`, reached through `InlineLayoutRequest.Exclusions`, is what inline layout asks: - `Query(bfcTop, height, bfcLeft, bfcRight, out start, out end, out nextEdge)` returns the usable inline range of a band (floats whose margin box overlaps the band; with height 0 the single line at `bfcTop`) and the smallest bottom edge among them, the next y where more room can appear (`+Infinity` when no float intersects). `start == end` means no room. - `PlaceFloat(request, container, floatBox, minBfcTop, out marginBox)` lays out and places a float met in inline content. `minBfcTop` is the top of the current line when the float fits on that line and the line's bottom when it does not; the inline layout decides, which is the hook for rule 6 and 7 of §9.5.1. The float's fragment is added to the container's fragment and the margin box comes back in block-formatting-context coordinates. - `PlaceholderInlineLayout` places the floats of its container at the top of the content box, then puts its one line in the room beside them, below the next float edge when there is none. - **Fragments.** A float's fragment is a child of its containing block's fragment, in tree order; painting orders it (Appendix E step 5). - **Bounds.** No recursion on tree depth: floats in the block flow are frames of the same explicit stack (nested floats need no call depth), and floats in inline content nest one engine call each, at most 32 deep (then a diagnostic and no fragment). A context stops searching after 50 million float visits or 262,144 floats: later floats are stacked below everything placed so far, lines ignore floats, and a diagnostic says so. Placement only scans the floats that can still matter (those ending below the lowest float top), so rows of thousands of floats cost about linear time. The margin-collapse look-ahead for clearance follows at most 64 first children. All storage is kept between layouts. - **Chosen where the specs leave room.** Floats in a settled position use the margins collected so far (matching what browsers do); a cleared box's border edge ignores its own margin when clearance applies; the 'separated from adjoining floats' rule for formatting-context boxes uses the smaller of the two outcomes (pulled down or not). - **Checked by.** `FloatLayoutTests` (spec examples by fragment border boxes, allocation and limits) and `WptFloatReftests`: 58 CSS2 `floats` and `floats-clear` reftests whose page and reference render to the same pixels through `HtmlRender`, with the whole-pixel line height injected. The test with `ONERENDER_FLOAT_SWEEP` set sweeps the whole directories of a web-platform-tests checkout and writes one result per test. ## Inline layout (`Layout/Inline/`) `InlineLayoutEngine` is the `IInlineLayout` behind `BlockLayoutEngine()`. It lays out one inline formatting context (CSS2 §9.4.2, CSS Text 3, CSS Inline 3) per call, as one paragraph, and keeps one worker per nesting depth because atomic inlines re-enter it through `IAtomicInlineHost` (at most 40 deep; deeper content is dropped). Everything is stored in arrays that grow and are reused, so a relayout of unchanged content allocates nothing. - **Collect.** An explicit-stack walk of the container's subtree produces the paragraph text and raw elements: text runs, inline box open and close, atomic inlines (U+FFFC in the text), tabs, forced breaks, floats and absolute boxes. White space is processed here (CSS Text 3 §4.1, every `white-space-collapse` and `text-wrap-mode` value): collapsing crosses inline box boundaries, segment breaks and `tab-size` follow the mode, and each character keeps a flag for "collapsible", "word separator" and the source offset it came from. `unicode-bidi` embeddings, isolates and overrides become the UAX #9 controls. - **Prepare.** UAX #14 opportunities and grapheme starts come from OneFont; `AdjustOpportunities` turns them into CSS's: `white-space`, `word-break` (normal, break-all, keep-all, break-word), `line-break: anywhere`, soft hyphens (`hyphens: manual` only; `none` removes them), the `break-spaces` rule, and breaks around atomic inlines. Bidi levels come from UAX #9; a paragraph with a left-to-right base and no character at U+0590 or above skips the algorithm (all levels are 0). Text is split by level and into pieces of at most 1,024 characters (at a break opportunity or grapheme boundary) and shaped through `IFontProvider.Shape`, whose cache is keyed by text and style, so an unchanged paragraph hits it without allocating (a page with more than about 4,096 distinct runs thrashes that cache and allocates on a relayout). - **Scan.** `ScanLine` is the greedy breaker: it accumulates clusters, remembers the last opportunity that fits, rolls back to it, hangs trailing collapsible and preserved spaces (CSS Text 3 §4.1.3), and breaks inside a word only when the line has no opportunity (`overflow-wrap: break-word | anywhere`). Min-content and max-content (`Measure`) run the same scan with an available width of 0 and infinity; a forced break splits max-content, atomic inlines count whole, `anywhere` counts in min-content and `break-word` does not. - **Lines.** For each line the exclusion space is asked for the usable range at the line's top and height; a line whose first unbreakable item does not fit moves down to the next float edge. Floats met in the content are placed with `PlaceFloat`: at the line's top when the content before the float plus its shrink-to-fit width fits on the line, below the line otherwise (CSS2 §9.5.1 rule 6); the line is scanned again when a float joined it. The line's height needs the vertical metrics of its pieces, and the strut is always part of it (§10.8.1). - **Vertical metrics.** Each inline box has ascent, descent and half-leading from its font and `line-height`; `vertical-align` shifts it (baseline, sub, super, text-top, text-bottom, middle, lengths and percentages of the line height; top and bottom align to the line box after the others are placed). An inline-block's baseline is the baseline of its last in-flow line, or its bottom margin edge when `overflow` is not `visible` or it has no line. The first baseline is stored on the container's fragment (`SetBaseline`); an inline-block's last baseline is found by walking its fragments. - **Emit.** Alignment (`text-align`, `text-align-last`, `text-indent` with `hanging` and `each-line`, justification between words, `letter-spacing`, `word-spacing`), then bidi reordering of the line's pieces (UAX #9 L2), then fragments: a `Line` fragment per line; inside it one `Box` fragment per run of consecutive pieces of an inline box (so a box split across lines, or across a nested bidi run, has several pieces chained with `NextOfBox`, with `ContinuesBefore`/`ContinuesAfter`, and its border and padding only on the first and last piece, `box-decoration-break: slice`); inside those one `Text` fragment per font face of each piece with a glyph run (glyph ids, positions, source offsets). Atomic inlines are laid out through the host while preparing and moved into the right fragment with `FragmentTree.Reparent`. `TryGetBorderBox` of an inline element is the union of its pieces. - **Bounds.** No recursion on tree depth (a 10,000-deep span nest is walked with the explicit stack). A paragraph holds at most 4,194,304 characters (the rest is dropped); line scanning checks the cancellation token every 1,024 steps; a line that cannot place anything still consumes one cluster, so there is no endless loop. Costs on the development machine: a 1,000,000-character paragraph (12,171 lines) lays out in about 0.3 s including the document build, a 100,000-character unbreakable word in about 25 ms (one line) or 30 ms with `overflow-wrap: anywhere` (4,000 lines), a 10,000-deep inline nest in about 30 ms. All are linear: shaping is per piece of at most 1,024 characters and a line is scanned once, plus once more for each float that joins it. - **Choices where the specs leave room** (each checked against Chrome by the `inline-lab` pages, where 98.7% to 99.4% of the elements are within the geometry tolerance): - Ascent, descent and line gap are rounded to whole pixels (half away from zero) before half-leading is computed; the half-leading's top is the floor of half the leading and the bottom takes the rest. This put the lab from 9% to 90% within tolerance. - `sub` lowers by a fifth of the parent's font size plus 1 px and `super` raises by a third plus 1 px. - An inline box's open and close edges take the bidi level of the next/previous content, so the box's pieces stay contiguous. Known deviation: an RTL-direction box around left-to-right text puts its start edge on the wrong side. - `text-justify: inter-character` is treated as `inter-word`; `line-break: strict | loose | normal` are alike; letter-spacing does not disable ligatures; a percentage `word-spacing` is a fraction of the separator's advance. - An empty inline box alone on a line produces no fragment (Chrome gives a zero-size box). - Percentage `line-height`, and `em` lengths of `letter-spacing`, `word-spacing`, `text-indent` and `tab-size`, inherit as the ancestor's computed length (CSS Values 4 §7.1), not as text resolved against the descendant's font. - **Not done:** `::first-line` and `::first-letter`, `text-overflow`, line clamping, `hyphens: auto`, `text-decoration` and text shadows, vertical writing modes, ruby, `box-decoration-break: clone`, inline-flex, inline-grid and inline-table as anything but blocks, markers inside the line, and `initial-letter`. - **Checked by.** `InlineLayoutTests` (a section per topic, the 0 B relayout of a 1,000-box text page and the limits above); the `inline` fuzz target; the `inline-lab` differential pages in `testdata/inline-lab/`. ## Positioning (`BlockLayoutEngine.Positioned.cs`) - **Containing blocks** (CSS2 §10.1, Position 3 §2). Each block frame carries `CbFrag`, the fragment of its nearest positioned ancestor (`position` other than `static`), or -1 for the initial containing block. Nested layouts started through `LayoutIndependent` inherit it from the frame that started them. A positioned inline box is a containing block too (§10.1 item 4): inline layout passes the fragment of the nearest positioned inline box around an absolute box as that box's containing block. When the inline box is split across lines this is the piece that holds the absolute box, not the union of the first and last pieces. - **Relative** (§9.4.3). The shift is applied when a box closes, to the fragment rectangle only: the flow position that siblings and the parent's height see is unchanged. A shifted box adds its shifted rectangle to the parent's scrollable overflow. `left` wins over `right` in a left-to-right containing block, `top` over `bottom` always; percentage `top`/`bottom` against a height that depends on content are `auto`. - **Absolute and fixed** (§10.3.7, §10.6.4; replaced §10.3.8, §10.6.5). While placing the in-flow children of a block, an absolute child is not laid out. It gets a placeholder fragment, appended as the next child of its *parent's* fragment so fragment order is document order (paint order needs that), and a queue entry holding its static position in the parent's border-box coordinates. The static top is the cursor plus the pending margin run (0 when the parent's own top margin is still pending). After the whole tree is done the queue is resolved in discovery order, so a box is resolved after the box that contains it: 1. document-space origins of all fragments are computed in one pass (parents precede children), so a containing block's padding box and a parent's offset cost O(1) however deep they are; 2. `SolveAbsoluteAxis` solves one axis (`start + margins + size + end + padding/border = containing size`) with `auto` as NaN: every case of §10.3.7, and §10.6.4 with the vertical differences (static top, equal margins even when negative, bottom ignored when over-constrained); the containing block's direction picks the ignored inset. Min/max apply as §10.4 says, by solving again with the violated size. An `auto` width is shrink-to-fit from the intrinsic sizes; an `auto` height is the content height (laid out once with the solved width forced into the box) or follows from `top`/`bottom` or `aspect-ratio`; 3. the box is laid out through the same entry as every independent block (`LayoutIndependent`) with the solved sizes forced, into the placeholder, then moved to its place. Percentages of the box itself resolve against the containing block's padding box. Fixed boxes use the viewport (`LayoutInput`, at scroll position 0) as their containing block; painting counters the viewport scroll, so layout needs no scroll input. The root element can itself be `absolute`/`fixed`; it is then placed in the initial containing block. - **With floats.** An absolute box inside a float (or in any nested formatting context) is queued like any other: the float lays its content out in its own context, and the box's static position is taken from the float's frame. Resolution runs after every float of the document is placed, and lays the box out in a formatting context of its own, so it is neither pushed by nor does it push floats. A relatively positioned float is placed as a float first and drawn shifted afterwards; the exclusion it leaves for lines and later floats stays where the float was placed, and its shifted rectangle adds to the scrollable overflow. - **Absolute boxes inside inline content.** The inline layout reports each one with `IAtomicInlineHost.QueueAbsolute`: the static position is the inline position reached on the line when the box is met, at the top of the line box. A box with nothing but out-of-flow boxes before it on its line is at the start edge of the container's content box, however floats narrow the line. An inline layout that does not report (the placeholder) leaves the box at the start of the content box (`QueueInlineAbsolutes`). - **Relatively positioned inline boxes** are drawn shifted by their offset; the line and the box's content move with the fragment and the line is unchanged. - **Sticky** (Position 3 §3.4) takes no part in layout (it is in flow like `relative` with no offset). The shift is computed at paint time, see below. - **Bounds.** The queue is capped at 4M boxes, nothing recurses, the resolution pass is linear in fragments plus queue entries, and the token is checked every 64 boxes. A 3,000-deep nest of positioned boxes with an absolute child at each level lays out in milliseconds (test). ## Overflow and scrolling - **Overflow values** (Overflow 3 §3.1) are already computed with the visible/clip → auto/hidden rule when the other axis scrolls. A *scroll container* is a box with `overflow` other than `visible`/`clip` on an axis; `overflow: clip` clips like `hidden` but is not scrollable. `overflow-clip-margin` (Overflow 4 §3.2: `<visual-box> || <length>`, padding-box and 0 by default) outsets the clip edge on `clip` axes only. - **Viewport propagation** (Overflow 3 §3.3). `ViewportOverflow.Resolve` picks the root's `overflow` or, when the root is `html` with `visible` and has a `body` child with something else, the body's; `visible` becomes `auto` and `clip` becomes `hidden` for the viewport. The element it came from neither clips, nor is a scroll container, nor establishes a block formatting context for that reason. Layout and the painter both ask the same function. - **Scrollable overflow** (Overflow 3 §2.2) is recorded on every box fragment (`Fragment.ScrollableOverflow`, in the fragment's border-box coordinates). It starts from the border box and takes in-flow children's overflow (shifted by relative offsets). A box that clips an axis contributes only its border box on that axis. A scroll container's own area starts at its padding box, and content that reaches past the end of the content box also brings the end padding in. Absolute boxes add their overflow to the fragment of their containing block, not their parent, and from there to every ancestor that does not clip; fixed boxes add nothing. This is one pass over the fragments in reverse order after the queue is resolved. - **Scroll positions** are host input in a `ScrollState`: one viewport offset and one offset per scroll container (keyed by box index, so call `Clear()` after rebuilding the box tree). They are stored as requested and clamped when used to what `ScrollState.GetRange` (a container) or `GetViewportRange` (the root's scrollable overflow beyond the viewport) allows: `0..scrollable overflow − padding box`, and for `direction: rtl` containers `overflow start − padding start .. 0`. `HtmlPage.Scroll` is the page's state. Layout never reads it. - **Painting scrolls.** `PaintOptions.Scroll` turns it on. A scroll container that can scroll emits `PushClipRect` (fixed) and, inside it, a `PushTransform` that translates its content by the negative offset; the viewport gets one around everything but the canvas; fixed boxes get a counter-translation by the viewport offset; sticky boxes get a translation of their own. A box painted outside its containing block's scope (an absolute or relative box deferred to its stacking context) gets a copy of the scroll transform of each scroll container on its containing-block chain, so it scrolls with the container but not with ancestors it escapes. Items are only emitted when the container can scroll, which depends on layout, never on the offset. Their ids use parts 6 (scroll container), 7 (sticky), 8 (fixed counter-scroll) and 9 (copy of a scroll transform pushed for a deferred box), and `ItemId.Of(0, 6, 0)` for the viewport. - **Changing a position never repaints.** `Painter.PatchScroll(retained)` recomputes the translation of every scroll, viewport, fixed and sticky item the last paint emitted and calls `PatchTransform` on those that changed; the damage is the old and new bounds of what moved, clipped by the container, and nothing allocates. The patched list equals a fresh paint (tested by comparing ids, bounds and both hashes). The sequence for a host is: `page.Scroll.SetOffset(box, x, y)`, `painter.PatchScroll(retained)`, draw the damage. - **Sticky shifts** are `StickyPositioning.Offset(borderBox, margins, containingBlock, scrollport, scroll, insets)`, a pure function: on each axis the box is moved inward of the scrollport edge by its inset (the scrollport shifted by the scroll offset, in unscrolled coordinates) and then limited so its margin box stays in its containing block (the parent's content box); with both insets on an axis, the start inset wins. The painter records the geometry in document space when it paints, so a scroll change is arithmetic on a table. A sticky box inside another sticky box is shifted relative to the outer one's final position. The scrollport of a sticky box is its nearest scroll container's padding box, or the viewport. - **Not done:** scrollbars (no gutter, nothing is drawn), scroll snapping, scroll events, `scroll-behavior`, `overscroll`, `contain`, and the overflow of the content of an atomic inline (the border box is used). - **Overflow of inline content.** Line boxes, inline box edges, text that runs past the end of its line (an unbreakable word, a `nowrap` line) and atomic inlines add to the scrollable overflow of the container (`InlineLayoutResult.Overflow*`). ## Flex layout (`Layout/Flex/`, `BlockLayoutEngine.Flex.cs`) Flexbox Level 1 §9 on the block engine's frame stack, with Box Alignment 3 for `justify-content`, `align-content`, `align-items`, `align-self` (including `safe`/`unsafe`) and `gap`. Block-level and inline-level flex containers are both handled; the container is a formatting-context root like a BFC root, so floats stay outside it and margins do not collapse through it. - **Style.** `flex-direction`, `flex-wrap`, `flex-grow`, `flex-shrink`, `flex-basis`, `order`, the alignment properties and `row-gap`/`column-gap` are typed in `ComputedValues`. The cascade layer does not expand `flex`, `flex-flow` and `place-*`, so `ShorthandFallbacks` does, with the Flexbox §7 rules (`flex: 1` is `1 1 0`, `flex: auto` is `1 1 auto`, `none` is `0 0 auto`). - **Phases per container.** Init (collect in-flow items in `order`, skipping `display: none`; absolutely positioned children are queued for the static position), Base (flex base size and hypothetical main size per item, §9.2), Lines (§9.3), Resolve flexible lengths (§9.7, with min/max violation freezing; capped at 256 rounds), Cross (§9.4 hypothetical cross sizes, line cross size, `align-content`, stretch), Final (§9.5 main-axis placement, §9.6 cross-axis alignment, §8.3 baseline alignment) and Close. - **Trial layouts.** Measuring an item means laying it out at a forced border-box width and/or height (`ForceW`/`ForceH`), then truncating the fragments it produced (`FragmentTree.Mark`/`Truncate`). The measurement is memoized per box by the forced sizes, the containing block size, and the containing height only when the subtree has percentage block sizes. A trial-mode container skips its own final phase, so a chain of nested flex containers is measured in linear time. The exact item layout happens once. A budget of 1M + 64 per box trials guards hostile input; past it a diagnostic is recorded and sizes come from style only. - **`min-width/height: auto`** (§4.5) uses the smaller of the content-size suggestion and the specified size suggestion, clamped by `max`; for aspect-ratio items the transferred suggestion applies. Column containers compute the content-size suggestion with a trial at the resolved width. - **Intrinsic sizes** (§9.9) are in `FlexIntrinsic.cs` and feed the shared intrinsic cache: single-line containers sum item contributions along the main axis, multi-line take the largest for min-content, and gaps are added. - **Absolutely positioned children** (§4.1) use the flex container's content box as the containing block and are aligned with `justify-content`/`align-self` when both insets on the axis are `auto`. - **Choices where the specs leave room (to be checked against the Chrome differential once text lands):** overflow alignment defaults to `unsafe`; `space-around`/`space-evenly` with negative free space fall back to `safe center` and `space-between` to `flex-start`; `align-content: normal` behaves as `stretch`; `baseline` in a column container falls back to `flex-start`; `place-items`/`place-self` ignore the justify half (it has no effect on flex items); `float` and `clear` on items are ignored. - **Bounds.** No recursion (a 50,000-deep nest of flex containers lays out), 100,000 items lay out in well under a second, the measurement count for a 2,000-level alternating nest is at most twice the depth, and relayout of a retained tree allocates nothing (the zero-byte assertion covers a page of more than a thousand flex items). - **Not done:** `visibility: collapse` on items, `list-item` flex containers' outside marker, percentage and `min-content` `flex-basis` in column containers (treated as `content`), multi-line column containers size trial layouts from the container width rather than the line width, vertical writing modes. Text items take their min-content and max-content from the inline layout's intrinsic sizes and their baselines from its lines. ## Fragmentation (`Layout/Fragmentation/`) CSS Fragmentation 3 over the fragment tree. The design is **lay out as one strip, then cut**: a fragmented flow is first laid out as one tall continuous block at the fragmentainers' inline size, which the block and inline engines already do; the `Fragmenter` then finds the break points in that strip, chooses breaks, and rewrites the fragment tree into one fragment per fragmentainer. Breaking never feeds back into inline layout, because every fragmentainer of a flow has the same inline size. - **Fragmentainers.** `FragmentationKind` says what is being filled. `Column` is used by multi-column layout. `Page` selects the page values of the break properties (`page`, `left`, `right`, `recto`, `verso` force; `avoid-page` discourages) and is what paged media uses with `Slice` over fragmentainer sizes that differ page by page (`Slice` takes the block size of the first fragmentainer, the second and so on, the last one repeating). A paged unit supplies the sizes, calls `Collect`, `Finish`, `Slice` and `Apply` (or `Plan`), and lays its page boxes and margin boxes around the fragmentainers; the core does not change. `left`/`right`/`recto`/`verso` are carried in `BreakCandidate.Bits`; inserting blank pages is the page layer's job. - **Strip coordinates** are block offsets from the top of the unfragmented flow. A break point is a pair: where the content above ends (`Ye`) and where the content below resumes in the next fragmentainer (`Ys`). The distance between them is the truncated margin. - **Break points** (§4.1), found by one explicit-stack walk of the strip (`Collect`): class A between in-flow block siblings and class B between the lines of a block container. Class C (between a container's content edge and its content) is used only for the empty room below the last child of a box, which behaves like an empty box. Floats are not break points and are never cut. Boxes that cannot fragment are *monolithic* (§4.1): replaced boxes, markers, scroll containers (`overflow` other than `visible`/`clip`), boxes with `contain: size`, and nested multi-column containers. Their insides contribute no break points and a forced break inside them has no effect. On pages a monolithic box or float taller than the page stays whole on the page it starts on and its background and borders carry on in the pages the overflow reaches, as pieces without content. - **Flex, grid and tables.** A flex container fragments between its flex lines (Flexbox 1 §12) and a grid between its rows; items on one line sit side by side, so a break is offered only where no item spans it, and the strip cut then splits each item that crosses it. Reversed flex containers are walked in block order. Page names are ignored inside flex and grid containers. Table rows break between rows; the places inside a row (its cells' lines, and empty cells) are used only as a last resort, when the row is taller than the fragmentainer, so a row that fits the next page moves whole. An empty box with a height has no break points but may be cut anywhere when it is what does not fit (the "break anywhere" last resort of §4.4, applied to boxes that have nothing in them). - **Break values** (§3). `break-before` and `break-after` on the first and last in-flow child propagate to the parent chain (§3.1.1), and the values at a break point combine. A forced value (`column` in columns, the page values in pages) forces the break and overrides avoid values; `break-inside` of every ancestor applies (rules 2 and 4); `always` and the `page-break-*` aliases are accepted (§3.4). - **Choosing breaks** (§4.3, §4.4, `Slice`). For each fragmentainer: the first forced break that fits wins; if the rest of the flow fits, it is the last fragmentainer; otherwise the break is the latest candidate that fits (counting the border and padding that `box-decoration-break: clone` repeats at the break) among those no rule forbids. Candidates carry a *level*: 0 breaks no rule, 1 breaks only orphans/widows (rule 3), 2 breaks an avoid rule (rules 1, 2, 4). The search takes the latest level-0 candidate, else the latest level-1 one, else the latest candidate of any level, so conflicting avoids end in the latest possible break. If nothing fits, the first break after the point where content stops fitting is used and the content overflows the fragmentainer (monolithic overflow). A break at the very top of a fragmentainer is never chosen unless forced (§4.4 progress), fragmentainers are at least 1px high (§4), and a break that would cut through a float is level 2. Orphans and widows: a break between lines `j` and `n - j` is level 1 unless `j >= orphans` and `n - j >= widows`; a block with fewer lines than either value is kept whole. - **Margins at breaks** (§5.2). At an unforced break the margin between the two boxes is dropped (the next fragmentainer starts at the top of the later box); at a forced break the margin above is dropped and the later box keeps its own top margin (clamped to the collapsed gap). Cloned margins are always zero. - **Splitting boxes** (§5.3, §5.4, `Apply`). A box that crosses breaks gets one fragment per fragmentainer it occupies. The first keeps the original fragment, so handles stay valid; the others are copies without children, chained after it with `NextOfBox`. A piece that continues extends to the end of its fragmentainer (the box's content box "fills the remaining extent"). With `slice` the pieces take the original border and padding only at the real start and end (the top and bottom edges of the other pieces are 0, so painting needs no special case); with `clone` every piece keeps all of them and the break leaves less room. Lines, text, floats, absolutely positioned boxes and monolithic boxes are not split; each goes to the fragmentainer where its top is. Every fragment records its `FragmentainerIndex`; pieces of a box are in index order. - **Bounds.** No recursion (explicit stacks, 3,000-deep and 100,000-line flows are tested). `Slice` is O(fragmentainers × log candidates) with prefix tables, so a 100,000-line paragraph cuts in milliseconds. A flow makes at most 100,000 fragmentainers and 4,000,000 extra box pieces; past either the rest stays whole in the last or current fragmentainer and a diagnostic is recorded. All storage is pooled: re-cutting an unchanged flow allocates nothing. - **Choices where the specs leave room.** Class C points other than the end gap are not used. Floats and absolutely positioned boxes are never fragmented (Fragmentation 3 leaves their fragmentation to the layout mode). A box whose containing block is inside the strip is placed by the strip's coordinates, so an absolutely positioned box is assigned to the column its top lands in. Percentage heights inside a fragmented flow resolve against the container, not the column. ## Paged media (`Paged/`, `Layout/PageFragmentation.cs`, `Tree/ContentList.cs`, `Tree/GeneratedContent.cs`) CSS Paged Media 3, CSS Generated Content 3 and GCPM 3 on top of the fragmenter. `PagedDocument.Create(html, PagedOptions)` is the entry; the PDF happy path goes through it. - **Pipeline.** The document is cascaded with the `print` media type, laid out as one strip at the first page's content width, and cut by `PageFragmentation.Paginate` into page-sized fragmentainers placed side by side (page *i* at x = i · stride). Each page then gets a *page layer*: the page box's background and its margin boxes, laid out as a small HTML document of their own (a probe document per distinct page style) and painted in page coordinates. Content is painted once for all pages (`PaintContent`); a page is a region of that list plus its layer (`GetPage`, `GetPageLayer`). - **`@page`** (`PageRules.cs`): `size` (keywords, `landscape`/`portrait`, one or two lengths), `margin` with percentages against the page box, the selectors `:first`, `:left`, `:right`, `:blank` and a page name, combined by specificity like selectors (names beat pseudo-classes by the specificity rule of §7.3, later rules win ties). Page *i* is a left or right page by its number and the document's direction; a right-to-left document starts on a left page. Blank pages that a `left`/`right`/`recto`/`verso` break inserts count in the numbering. - **Named pages.** `page` is inherited through `auto`. A change of the used name between siblings forces a break (§7.3). Page sizes are per page, so the cut is planned again when a named page has a different height: at most 4 passes. Pages whose content width differs from the one the strip was laid out at keep the strip's width (content is cut or padded) and a diagnostic says so. - **Margin boxes.** The 16 boxes are sized by the algorithm of §5.3 (`ComputeBoxRects`): corners are fixed by the margins; the three boxes of an edge share the edge's length by the resolved-width rules, with `auto` widths distributed by the content's max-content widths (`Distribute`), and a box that has no content takes no room. A margin box is a flex column with `overflow: hidden`, and the `vertical-align` of the box sets its `justify-content`. Their text properties inherit from the root element, not from the page. - **Generated content.** `GeneratedContent` evaluates `content`, `counter-reset/-increment/-set` (including `reversed()`, whose initial value is the sum of the negated increments in its scope, found by a bounded walk), `counter()`, `counters()`, `attr()`, quotes, `string-set`/`string()`, `target-counter()`, `target-counters()`, `target-text()` and `leader()` while the front end builds the styled tree. Counter scopes follow the element tree with `display: contents` elements transparent. A counter name that is `none` or a CSS-wide keyword makes the value invalid and the generated box empty. `counter(page)` and `counter(pages)` are known only after a cut: when content reads them, or reads a `target-counter()` of a later page, the content is built again with the numbers of the previous pass, at most three times; if the numbers do not settle the last ones are used and a diagnostic is recorded. `string()` takes `first`, `start`, `last` and `first-except` over the `string-set` events on each page. - **Leaders.** `leader()` evaluates to a pattern between two private marker characters. Inline layout turns that into a text run that takes no room while a line is broken and is given the room the other pieces of the line leave when the line is placed; it shows whole pattern clusters and the text after it ends at the line end. The glyph run's source text is the pattern. Leaders share the room equally. The pattern is not aligned across lines. - **Table headers and footers.** A `thead`/`tfoot` is reserved on every page a table continues onto (`CloneTop`/`CloneBottom` of the candidates) and copied there (`FragmentTree.CloneSubtree`, traits `Repeated`). In 1.0.0 the copies don’t show in the PDF: the header copy is placed above the page’s content area, where it is clipped, and the footer copy is not written. The copies are artifacts in tagged output: they stay inside the table's structure element, so the table is one element across pages. Header and footer groups are not split. - **Limits.** `MaxPages` (default and ceiling 100,000) is checked before every cut: forced breaks and blank pages past it are not taken, `Truncated` is set and the rest stays on the last page. A `content` value is bounded (16 KiB of source, 64 items, 64 KiB of text once evaluated), `@page` rules (1,024) and their declarations (256) by count, blocks by length (64 KiB) and nesting (16), counters by scope depth and number (4,096), string-set events by count, scan budgets for reversed counters by node count. Nothing recurses over the tree. - **Not done.** `position: running()`/`element()` (the cascade does not keep `running()`), footnotes, leaders aligned across lines, `@counter-style` rules and the counter styles that `CounterStyles.cs` lacks (such as `georgian`), the implicit `list-item` counter for reversed lists. ## Multi-column layout (`Layout/Multicol/`) CSS Multi-column Layout 1. A container with `column-count` or `column-width` other than `auto` (`ComputedValues.IsMulticol`) is a block container that establishes a block formatting context (§2). - **Used count and width** (§3.4, `BlockLayoutEngine.ResolveColumns`): with only `column-width`, `N = max(1, floor((W + gap) / (width + gap)))`; with both, `N` is capped by `column-count`; with only `column-count`, `N = count`. The used width is `(W - (N - 1) * gap) / N`. `column-gap: normal` is `1em`; percentages resolve against the content width. At most 10,000 columns per row. - **Layout.** The container's children are laid out as a normal block flow at the column width (the frame's content width is the column width). When the container closes, `Fragmenter.Plan` picks each row's column height and cuts the strip; after the whole layout (and the resolution of absolutely positioned boxes) `Apply` rewrites the container: its children become `Column` fragments, each positioned at `contentLeft + k * (columnWidth + gap)` (mirrored for `direction: rtl`) with its content as children relative to the column. A trial layout that is discarded (flex measurement) drops its plans. - **Rows and spanners** (§6). A direct in-flow block child with `column-span: all` is laid out at the container's full content width and splits the flow into rows: the content before it is balanced on its own, then the spanner, then the content after. The gaps in the strip around a spanner (margins) are kept. A row with no content creates no columns. Spanners deeper than a direct child have no effect yet. - **Height.** Every row's columns share one height. Non-final rows always balance. The final row balances (`column-fill: balance`, the default) or fills (`auto`): balancing finds the smallest height, in steps of 1/64 px, at which the row fits in `N` columns without overflow and without breaking an avoid or orphans/widows rule (`Fragmenter.Balance`: a binary search of at most 40 trial slices, repeated once with the rules relaxed if that fails; each trial stops as soon as it needs more than `N` columns). With a definite `height` (or a `max-height`) the final row is limited to what is left; if the content still does not fit, the extra columns are added in the inline direction after the last one (§8) and become scrollable overflow. With `column-fill: auto` and an `auto` height all the content stays in one column unless a forced break splits it. The container's height is its `height`, or the rows and spanners, clamped by `min-height`/`max-height`. - **Column rules** (§4): `Painter` draws one border item (part 10 of the container's box id, sub id the column's index) in the middle of the gap between two adjacent columns of the same row, as high as the columns, after the container's background and border and before its content. Rules separate columns that exist; a spanner ends the run. `column-rule` and `columns` expand to their longhands (`ShorthandFallbacks`). - **Intrinsic sizes** (no section in level 1): min-content is `N * max(content min, column-width) + (N - 1) * gap` with `N = max(1, column-count)`; max-content is the same with the max-content width. - **Scrollable overflow** of the container is its border box plus the columns, so overflow columns extend it sideways; overflow of the content inside columns is not propagated past the container. - **Not done:** `column-span: all` on non-children, nested multi-column containers that break across the outer columns (a nested container stays whole in one outer column), fragmentation of flex, grid and table containers, `column-height`, `column-wrap`, and vertical writing modes. ## Grid layout (`Layout/Grid/`) CSS Grid 2 as a formatting context of the block engine. `OpenChild` calls `GridOpen` for a container whose context is grid (flex has the same hook one line above); `GridIntrinsicSizes` answers min-content and max-content widths for shrink-to-fit and for items that are grids themselves (`display: inline-grid` is an atomic inline through `LayoutIndependent`). A grid nested more than 24 grid items deep is laid out as a block with a diagnostic. - **Style.** `GridTypes.cs` holds `GridTrackList` (head, `repeat()` body, tail, line names, `subgrid`), `GridTrackSize`, `GridLine`, `GridAreas`; `StyleComputer.Grid.cs` parses the longhands and `ShorthandFallbacks.Grid.cs` the shorthands (`grid`, `grid-template` with the ASCII-art form, `grid-area`, `grid-row`, `grid-column`, `place-*`). Property ids are appended after `ColumnGap`. - **Contexts.** A grid and every subgrid below it form one flat list of contexts built parents first; an axis of a context either owns its tracks or points to the owner context with an offset and a span. Everything is held in per-depth pooled arrays (`GridLevel`), so a relayout of an unchanged grid allocates nothing; named lines are the exception (the name index allocates per layout). - **Placement** (`GridPlacement.cs`, §8): line resolution with names, spans, negative lines and implicit names from areas; sparse and dense auto-placement for row and column flow over an occupancy bitmap; `order` sorts the items first. Subgrid placement is clamped to the parent span. - **Track sizing** (`GridTrackSizer.cs`, §12): initialize, resolve intrinsic sizes in span order (items spanning flexible tracks last, and only they raise flexible tracks that have an intrinsic minimum), maximize, expand flexible tracks (find the size of an fr; indefinite space uses the items' max-content contributions), stretch auto tracks. `auto-fit` collapses unused tracks and their gutters. Columns are sized parents first; rows children first, because a subgrid's height is an input of its parent's rows. Row percentages resolve in a second pass once the container height is known. - **Items.** Each item is measured by laying it out at its used width with `_absForce` and truncating the fragments (`FragmentTree.Mark/Truncate`), then laid out again at the final size in its area. Replaced items get their used size through `_gridReplacedForce`. Baseline-aligned items in the same first row get a shim so their baselines meet; a box without a baseline uses its border-box bottom. - **Subgrid** (§9): subgridded items contribute their own children to the parent's tracks; the subgrid's margin, border and padding at an edge, and half the gap difference elsewhere, become extra margin on the items there. Line names from the parent are visible inside, and a subgrid with an automatic span covers as many tracks as its name list. - **Absolutely positioned children** (§10): with grid lines the containing block is the grid area (only when the grid container is the containing block; a missing line is the padding edge); without lines the static position is the box's own alignment inside the container's content box. - **Limits.** 10,000 tracks per axis (a 1,000,000-track `repeat()` is clamped, line numbers clamp to the same bound as §5.4 allows), 1,048,576 items per container, 50 million cell visits in placement (then the remaining items stack one per row), 100 million units of sizing work, 32 levels of shared subgrid tracks (deeper ones become independent grids). All checks run before the work they guard; nothing recurses on tree depth. - **Known deviations.** Inline-axis `baseline` falls back to start; last-baseline container baselines are not produced; `justify-items: legacy` computes to `normal`; masonry (`grid-lanes`), fragmentation across pages and non-horizontal writing modes are not implemented. - **Checked by.** `GridLayoutTests` (placement, auto-repeat, sizing, alignment, baselines, subgrid, abspos, 0 B relayout of a 1,000-item grid with subgrids, hostile limits), the `grid` fuzz target, `testdata/grid-lab/` against Chrome (`results/grid/summary.md`) and WPT `css-grid`. ## Tables (`Layout/Table/`, `BoxTree.Table.cs`) - **Box generation.** `display: table`, `table-row`, `table-cell` and the rest build the table box tree; missing parents and children are generated as anonymous table objects (CSS 2.1 §17.2.1) by an iterative pass over the box tree, so nesting depth never reaches the call stack. HTML tables get their display values from the user agent sheet; `colspan`, `rowspan` and `span` are read by the HTML front end and clamped to 1000 and 65534 (HTML §4.9.11). - **Grid.** Rows and cells are placed into a slot grid (spans, `thead`/`tfoot` ordering, `col`/`colgroup`, `visibility: collapse` rows and columns). Limits: nesting 16 tables deep, 1000 columns, 4 million collapsed-border slots; a table nested deeper is laid out as blocks, cells beyond the column limit are dropped, and each case records a `LimitExceeded` diagnostic. - **Columns.** Auto layout takes each column's min- and max-content width from its single-column cells, then distributes the excess of spanning cells by max-content weight, preferring columns that are not fixed. A table narrower than its columns' sum in the min case overflows; wider space goes first to auto columns, then fixed, then percent. Percent columns are served first when the available width allows. `table-layout: fixed` takes widths from the first row and `col` elements only (CSS 2.1 §17.5.2.1). A caption's min- and max-content widths widen the table. - **Rows.** A row is as tall as its tallest cell (spanning cells share the excess among the rows they span) and at least its specified height. `vertical-align` in a cell is top, middle, bottom or baseline (§17.5.3); baseline cells align on the first baseline in the row. A cell is laid out once; percent heights inside cells do not resolve. - **Borders.** With `border-collapse: separate`, `border-spacing` and the cell borders are ordinary box geometry and `empty-cells: hide` suppresses empty cells' decoration. With `collapse`, borders resolve per edge by the conflict rules of §17.6.2.1 (hidden wins, then width, then style, then cell over row over group over column over table); the before half of a shared border is `floor(width/2)` and the after half is the rest. The winners are stored per fragment (`FragmentTree.TableBorders`) and the painter draws them in place of the box's own border. - **Captions.** A caption is laid out above or below the rows by `caption-side`. It lives inside the table fragment (the table wrapper and the table box are one fragment), so a table border and background also surround the caption; this differs from the specification, where the wrapper is the table's parent. - **Backgrounds.** Table, column group, column, row group, row and cell backgrounds paint in that order (§17.5.1). Column and column group fragments exist only when they have a background. - **Presentational hints** (HTML §15.3.8). `width`, `height`, `cellspacing`, `cellpadding`, `border` and `align` on `table`, `width`, `height` and `align` on `td` and `th`, and `align` on `tr`, apply where the computed value is still the user agent default, because the cascade has no hint layer. `align` on a cell centers a table child the way a centered block does in a browser. `bgcolor` and `valign` are not implemented. - **Fragmentation.** In paged media the rows of a table are break points and a row's cells are never split; header and footer groups are reserved on each page the table continues onto (see Paged media above). - **Cost.** A relayout of an unchanged table reuses the grid and allocates nothing (checked for 1,000 cells); a 10,000-row table lays out in time and memory linear in its rows. - **Checked by.** `TableLayoutTests` (anonymous objects, spans, auto and fixed layout, collapsed borders, captions, alignment, hints, 0 B relayout, the 10,000-row shape, hostile spans and nesting, relayout equal to a fresh layout), the `table` fuzz target, WPT `css2-tables` and `css-tables`, and the `templates` set against Chrome. ## Paint (`Painting/Painter*.cs`) The painter walks the fragment tree in CSS 2 Appendix E order and writes the display list (`display-list.md`). The second paint area added the pieces below; each is computed from typed values in `Style/PaintTypes.cs` and painted by one method. - **Backgrounds** (Backgrounds 3 §3). One painted layer per `background-image` entry; the other lists cycle (§3.1). Layers paint last to first under `background-color`, which fills the clip box of the final layer. Origin, clip (`border-box`, `padding-box`, `content-box`; rounded by the box's radii) and size (`auto` with the natural ratio, `cover`, `contain`, lengths) follow §3.4 to §3.9. Repeat follows §3.5: `repeat`, `no-repeat`, `space` (the whole number of tiles that fit, spread with equal gaps; a single tile is placed at the start) and `round` (the tile is rescaled to fit a whole number, at least one). `background-position-x/-y` are the longhands (OneHtml 1.0 expands the shorthand into them). A `url()` layer that repeats is one tiled image item. A gradient layer is one rectangle per tile on snapped edges, so no seam shows. `fixed` attachment is relative to the viewport: the layer sits inside a translation that `PatchScroll` updates in place, the same entry the fixed-position patch uses. The canvas takes the root's (or the body's) background (§2.11.2). `background-clip: text` and `border-area` are not painted and record a diagnostic. - **Gradients** (Images 3 §3). Linear, radial and conic, plain and repeating. Stops get the fixup of §3.4.1 (missing positions spread evenly, positions raised to the largest one before, transition hints expanded to a few intermediate stops). Interpolation is premultiplied sRGB, which is what the display list defines and what Chrome does for legacy colors; the `in <colorspace>` syntax is not parsed yet. - **Borders.** Widths floor to whole device pixels (minimum one). `border-image` (§6) slices the source into nine parts, paints the corners, repeats, rounds, stretches or spaces the edges, and draws a gradient source through a transform and clip; `fill` paints the middle. Radius clipping of backgrounds is by clip box (§5.3). - **Shadows** (§7). Box shadows paint last to first, outer before the box, inset after the background and before the border, with spread and blur. - **Outlines** (UI 4 §4). Drawn after the content of the paint unit that owns them, outside the border edge, offset by `outline-offset`, with the border-style look. `outline-style: auto` is a 2 px ring in the outline color with a 1 px white ring inside it, a fixed deterministic look; it follows the border radius. - **Text paint.** `text-shadow` paints each shadow as a blurred copy of the glyph run before the text, the first shadow on top. Decorations (Text Decoration 4) are drawn by the box that declares them across its inline text: underline and overline under the text, line-through over it. Thickness is the font's `post` underline thickness (minimum one device pixel) and the line's edges snap like any rectangle. The underline's center sits half the font's descent below the baseline, which is what Chrome 156 does with the packaged fonts rather than what the `post` table's underline position says: probing 10 to 48 px Liberation Sans, the rule puts the underline on the same rows as Chrome at 10 of 13 sizes and one row off at 18, 24 and 40 px (`UNDERLINE=1` mode of `tools/snap/probe.py`); other families are not probed. The strike-through comes from the `OS/2` strikeout metrics; `text-underline-offset` and `-position: under` override; `solid`, `double`, `dotted`, `dashed` and a zigzag `wavy`. Skip-ink is off. Markers: `disc`, `circle` and `square` are shapes beside the first line; `decimal`, alpha, roman and greek markers are text built from the item's index among its list siblings; `list-style-image` draws the image. `start`, `reversed` and `value` are not read, and `list-style-position: inside` markers are not painted. - **Replaced content.** `img` paints through `object-fit`, `object-position` and `image-rendering` (Images 3 §5); the natural size comes from the host's `IImageSource`. A missing image paints nothing and records a diagnostic; alt text and the broken-image icon are not drawn. - **Pixel snapping.** Every edge of a background, border, outline, clip and decoration rectangle is placed at `round(x · dpr) / dpr` (half up), and the far edge is snapped on its own. Evidence (Chrome for Testing 156.0.8078.47, headless, `tools/snap/probe.py`; columns shown as `.` white, `R` red, `B` blue): | Case | Chrome result | |---|---| | left 10.4, width 10.4 | red on columns 10 to 20 (edges 10 and 21) | | left 10.5, width 10 | columns 11 to 20 (10.5 rounds up to 11, 20.5 to 21) | | left 10.6, width 10 | columns 11 to 20 | | boxes at 0 and 10.5, both 10.5 wide | red 0 to 10, blue 11 to 20, no blended column | | boxes at 0 and 10.3, both 10.3 wide | red 0 to 9, blue 10 to 20 | | scale 2, left 10.25, width 10 | 20 device pixels from device column 21 (20.5 rounds up) | | scale 2, left 10.2, width 10 | 20 device pixels from column 20 | | scale 1.5, left 10.2, width 10 | 15 device pixels from column 15 | | top 10.5, height 10 | rows 11 to 20 | | border 0.4, 0.9, 1.0, 1.6 px at scale 1 | 1 px | | border 2.5, 2.99 px at scale 1 | 2 px | | border 0.4, 0.6, 1.3, 1.6 px at scale 2 | 1, 1, 2, 3 device pixels | Known difference: a box narrower than half a device pixel whose edges snap to the same pixel is painted one pixel wide by Chrome and not at all by us. - **Resources.** The painter never loads anything: images come from `PaintOptions.Images`, fonts from `PaintOptions.Fonts`. `.Image` supplies `ImageStore`, which decodes through oneimage from the host's `Resources` lookup and answers all of layout (`IResourceResolver`), painting (`IImageSource`) and rasterizing (`IImageResolver`). - **Ids.** Items of this area take ids from a second id space (`Id2`, keyed by the owner, a part number and an index) with a free-id search on collision, so ids stay stable across unchanged frames. - **Allocation.** Repainting an unchanged page with layered backgrounds, border-image, shadows, outlines, decorations, text-shadow, markers and images, and patching fixed backgrounds on scroll, allocate 0 bytes once warm (`PaintAllocationTests`). - **Checked by.** `PaintStyleTests` (parsing and computed values), `PaintPixelTests` (analytic pixels plus two golden scenes), `PaintAllocationTests`. ### Third paint area: transforms, effects and inline SVG (`Painter.Effects.cs`, `TransformGeometry.cs`) - **Transforms** (Transforms 1 §6 to §13, Transforms 2). `transform`, the individual `translate`, `rotate` and `scale`, `transform-origin` (with z), `transform-box`, `perspective`, `perspective-origin`, `transform-style` and `backface-visibility` are typed values in `Style/EffectTypes.cs`. `TransformGeometry` composes `translate · rotate · scale · transform` around the origin into one 4×4 matrix in the containing space; the painter and hit testing both use it, so they agree. A parent's `perspective` multiplies the matrix of each child. A box whose back faces the viewer with `backface-visibility: hidden` gets an empty clip. `preserve-3d` is flattened (a diagnostic when a projection is used); nested projections are flattened to their affine part. A matrix that does not map the plane in front of the viewer paints nothing. - **Layout effects of a transform** (Transforms 1 §2). A box with a transform or `perspective` is the containing block of its absolute and fixed descendants: `Frame.CbFrag` stops at it, and `Frame.TfFrag` is the nearest such ancestor for `position: fixed` (which then scrolls with it and no longer takes the viewport patch). The bounding box of the transformed border box and transformed overflow replaces the untransformed extent in the parent's scrollable overflow (`BlockLayoutEngine.Transform.cs`; block, flex and grid items; floats and table cells are not handled yet). - **Scopes.** One element opens, outermost first: transform (and backdrop filter, a leaf before the group), group (opacity, `mix-blend-mode`, `isolation`), mask, clip-path, filter, then the element's own painting; `ExitFx` and `ExitTransform` close them in reverse. All of them count against the 256-level scope budget and stop opening before it with a `LimitExceeded` diagnostic. - **Filters** (Filter Effects 1). The `filter` functions resolve to at most 32 `FilterOp`s (blur and drop-shadow radii capped at 4096 px). `url(#id)` filter references are not rendered (diagnostic). - **Clip paths** (Masking 1 §5). `inset()` with rounding, `circle()`, `ellipse()`, `polygon()` (at most 4096 points), `path()` (SVG path data through `PathDataParser`, arcs become cubics, at most 8192 segments) and the geometry boxes (`border-box`, `padding-box`, `content-box`, `margin-box`; the SVG boxes act as the border box). `clip-path: url(#id)` names a `<clipPath>` of an inline svg in the same document: the front end flattens its `rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon` and `path` children to one path (`SvgClipDefs`, at most 256 per document), in user space or, for `clipPathUnits="objectBoundingBox"`, scaled to the border box. A `transform` on a clip child, `use` inside a `clipPath` and a `clip-path` on the `clipPath` itself are not applied. - **Masks.** `mask-image` layers (gradients and images) with the mask longhands and the `mask` shorthand, at most 16 layers, are painted through the background layer code into `PushMaskLayer` scopes. `mask-image: url(#id)` to an inline `<mask>` is not rendered (that layer masks everything, with a diagnostic). - **Compositing.** `mix-blend-mode`, `isolation` and `background-blend-mode` (a group around the background layers of the box). - **Inline SVG.** An `<svg>` element is replaced content. Its natural size is its `width` and `height` or the ratio of its `viewBox`; `HtmlFrontEnd` serializes the subtree (iteratively, at most 20,000 nodes and 1,048,576 characters) and the painter emits one `SvgImage` item; `.Image` renders it through OneImage's SVG renderer at the item's size. Text inside an inline svg is not drawn yet (the SVG renderer is not given the engine's fonts), and the page's CSS rules do not reach inside the svg. - **Allocation.** Repainting a page with all of these allocates 0 bytes once warm, and patching a transform or an opacity needs neither layout nor allocation (`EffectsPixelTests`). - **Checked by.** `EffectsPixelTests` (analytic pixels per family, a golden scene, allocation), `DisplayListEffectsTests` (limits, nesting, patching, damage), the `effects` fuzz target, WPT css-transforms, css-masking, filter-effects and compositing. ## Interaction (`Interaction/`) `InteractivePage` wraps an `HtmlPage` with everything a host needs to feed input: hit testing, DOM events, focus, hover/active state, scrolling input and CSSOM View queries. Inputs are plain structs (`PointerInput`, `WheelInput`, `KeyInput`) carrying the host's timestamp; the engine has no clock, so double-click timing uses those timestamps. A host that draws the display list with its own GPU renderer finds the whole surface in `docs/game-hosts.md`: host scopes (display list 1.3, the `--host-scope` property), per-handle image invalidation, stable glyph-face and SVG keys, name lookup (`FindElement`, `StyledTree.FindByName`) and a synthetic `Click`. - **Hit testing (`HitTester`).** The fragment tree is indexed once per page into stacking contexts and the CSS 2.1 Appendix E phases, as the painter walks them, then searched in reverse paint order with an explicit stack (no recursion on tree depth). A candidate is rejected when it is outside an ancestor clip (overflow, rounded corners), `visibility` is not visible, or `pointer-events: none`. Scroll offsets and transforms of the ancestors are applied to the point (at most `MaxEffectChain` = 1024 such ancestors per fragment; beyond that the outermost are ignored). A transform makes a stacking context. The root element receives hits on the empty canvas; points outside the viewport hit nothing. A hit on text carries the character offset under the point. Known limits: sticky offsets, `clip-path`, masks and filters are not applied. A point within about 0.02 px of a transformed box's edge may differ from Chrome. - **Events (`EventDispatcher`).** The DOM Living Standard dispatch algorithm: the path from the target up through the document to the window; capture, target and bubble phases; `stopPropagation`, `stopImmediatePropagation`, `preventDefault` with `cancelable` and `passive`; `once`; listener lists snapshotted per node so additions and removals during dispatch behave as specified; `composedPath()`. `EventTarget` is a handle over a OneHtml node or the window; C# listeners and a later script runtime use the same registry. The path is capped at `MaxPathLength` = 2048 targets (document and window always kept). Event objects are pooled and valid only during the listener call. - **Pointer, focus and keyboard order (UI Events).** Each pointer event is followed at once by its mouse compatibility event. Moving between elements fires `pointerout`, `mouseout`, `pointerleave`, `mouseleave`, `pointerover`, `mouseover`, `pointerenter`, `mouseenter` (leave/enter on each ancestor that changes), then `pointermove`/`mousemove`. A canceled `pointerdown` suppresses the mouse events until release but not focus. `click` goes to the nearest common ancestor of the down and up targets; the click count grows while presses are within 500 ms and 5 px. Focus changes fire `blur`, `focusout`, `focus`, `focusin`. - **Focus (`FocusRules`).** Focusable areas and the sequential order of HTML §6.6: positive `tabindex` first in ascending order (ties in tree order), then `tabindex=0` and natively focusable elements in tree order; negative `tabindex` is focusable but not a tab stop. Tab wraps by default. `:focus-visible` follows the Selectors 4 heuristic: keyboard-caused focus matches, pointer focus on a non-text control does not. `:hover`, `:active`, `:focus` and `:focus-within` are applied by restyling with a new `StyleContext`. - **Scrolling input.** Wheel (line = 40 px, page = 0.875 of the viewport, Shift turns vertical into horizontal) and keys (arrows, Page Up/Down, Home/End, space) go to the nearest scrollable ancestor and chain to its parent at the limit, ending at the viewport. They use the scroll patch of the Overflow section, so there is no relayout, and fire `scroll` events. `ScrollIntoView` scrolls the nearest way. - **CSSOM View (`LayoutQueries`).** `GetBoundingClientRect` and `GetClientRects` (after scroll and transforms) and the layout-space `Offset*`, `Client*`, `Scroll*` metrics. `offsetLeft`/`offsetTop` are relative to the offset parent's padding edge, or to the page origin when the offset parent is the body or null. `scrollWidth`/`scrollHeight` are the padding-edge-based overflow extent, never below `clientWidth`/`clientHeight`. Values were compared with Chrome 156 on three probe pages (`testdata/interaction`, produced by `tools/interaction/probe.sh`). - **State-change seam.** After an input that changes `:hover`, `:active` or `:focus` state, the page computes the list of `ElementStateChange` (the element and the `ElementStates` bits that flipped, for every element in the affected chains) and passes it to `StateChangeHandler` (`IStateChangeHandler.ElementStatesChanged(page, changes, context)`) with the new `StyleContext`. If the handler returns true it has restyled and relaid out itself; otherwise the page restyles from the new context and rebuilds the box, fragment and hit structures when any computed value changed. `HandlePointer`, `HandleWheel` and `HandleKey` return `InputOutcome` flags. An incremental restyle engine plugs in at this interface. - **Allocation.** A hit test, a pointer move that changes no hovered element, event dispatch without state change, wheel scrolling that keeps the hovered element, and viewport scrolling allocate 0 bytes once warm (`InteractionAllocationTests`). A change of hover or focus state on an HTML page restyles in place (see Incremental updates); OneHtml's cascade still allocates about 24 KB for a hover move on the 1,000-node sample, while the same change through the styled-node tree allocates nothing. ## Forms (`Forms/`, `HtmlFrontEnd.Forms.cs`, `Painter.Controls.cs`, `InteractivePage.Forms.cs`) Controls are ordinary boxes with Chrome's default metrics; `docs/forms.md` holds every rule and its probe evidence. - **Style.** When the styled tree is built (and again on restyle), `AdjustControlStyle` classifies the element (`ControlKind`), writes the widget size into `auto` width and height, makes the text single-line where needed and records paint-only values (`Control`, `ControlState`, `ControlFraction`; they differ in paint, never in layout). `WidthIsIntrinsic`/`HeightIsIntrinsic` let stretching and both-inset positioning treat the widget size as `auto`. A `display: grid | flex` fieldset is split into a block and an anonymous content box that carries the grid or flex properties. - **Layout.** Block layout centers the lines of buttons, text fields and drop-downs in a taller box (`CentersItsContent`, skipped on a memo hit because the copied children are already moved); intrinsic sizes of a control are its widget width. - **Paint.** `Painter.Controls.cs` draws the widgets from the control state and the fragment box (accent color, focus ring, disabled colors); with `appearance: none` the normal box painting applies. - **State.** `FormState` holds what the user changed (checked, selected options, range values, open select); `HtmlPage.Form` owns it, `RefreshContent` rebuilds the value and option text. On the builder path `StyledTree.SetControl` stamps the kind and state per node, and the incremental pipeline treats it as a style change (0 B once warm). - **Interaction.** Activation, labels, radio groups, submit and reset with constraint validation, range drag and keys, and the select popup go through the normal DOM event dispatch (`input`, `change`, `submit`, `invalid`, `reset`); the popup is a synthetic box painted in a top layer above the root stacking context. Text editing (`Forms/TextEditor`, `TextEditSession`, `TextGeometry`, `InteractivePage.Editing.cs`, `LiveTextEditor`, `Painter.Editing.cs`) is specified in `docs/forms.md`; the painter reads a `TextEditView` for caret, selection and preedit. ## Incremental updates (`LivePage`, `PipelineUpdater`, `StyledTree.Changes.cs`, `BoxTree.Incremental.cs`, `BlockLayoutEngine.Reuse.cs`) One change-set model serves both front ends. The result of any sequence of edits followed by a flush equals building the page from scratch from the final tree; the equality fuzz (`IncrementalEqualityTests`, the `incremental` fuzz target) checks styles, boxes, fragments and the display list with item ids after every edit. - **Change set.** `StyledTree` logs each edited node once, with flags (`Style`, `Text`, `TextStructure`, `Children`, `Replaced`, `Added`, `Removed`), coalesced per node. The log holds `MaxPendingChanges` (4,096) nodes; beyond that `PendingOverflowed` is set and the next flush rebuilds everything, which is also the fallback for a huge edit. Edits: `SetDeclaration`, `SetProperty`, `SetStateStyle` (hover, active and focus layers, applied focus < hover < active), `SetStates`, `SetText`, `InsertElement`, `InsertText`, `Remove`, `Move`, `Invalidate`. A removed node keeps its index and handle until the flush has consumed the change set; then its slot is reused (see Long-running stability). HTML pages feed the same machinery from `IStateChangeHandler` state changes (`HtmlPage.TryRestyle`). - **Restyle.** Dirty nodes are recomputed; a child is re-evaluated only while its parent's style id changed, so inheritance stops where computed values do not change. Styles are interned in `StyleTable`; an unchanged result keeps the same id. A viewport change resets the table and recomputes everything. - **Boxes.** A style change that leaves `display`, out-of-flow-ness, `white-space-collapse` and `list-style-position` unchanged is patched into the existing boxes (anonymous boxes below follow their parent's style) and the box and its ancestors are marked dirty. A **structural** edit (a node inserted, removed or moved, text that becomes empty or white space or stops being, a style change to `display`, out-of-flow-ness, `white-space-collapse` or `list-style-position`) patches the box tree in place (`BoxTree.Structure.cs`). The nearest ancestor of the edit whose box is a block container has its children generated again from the styled children, by the same code a full build runs: inline boxes and generated-content boxes on the way are made again with everything below them, the container's anonymous boxes (CSS2 §9.2.1.1 block wrappers, §17.2.1 table fix-up, flex and grid item wrappers) are dissolved and made again, blockification and white-space trimming are decided over the whole child list, and an inline box split around a block is made again with its continuations. A direct child whose node did not change keeps its box and everything below it, so layout reuse (box dirty marks) and the paint journal see it as the same box. Boxes that go away return their slots to a free list that the next new box takes, so a UI that adds and removes nodes every frame keeps its columns the same size; new boxes and the container's chain are marked dirty and hazards are recomputed for the new boxes and the ancestors. Box numbers are therefore not in tree order after an edit; nothing reads them as an order (the percentage-height flag of flex memoization is computed over the tree, not over numbers), and display-list ids, which name a box by its number, stay the same for every box an edit did not remake. The equality tests compare ids by each box's place in the tree. Only these still generate every box again: a change to the root element's own `display`, out-of-flow-ness, `white-space-collapse` or `list-style-position`; more than `MaxPendingChanges` edited nodes in one flush; a viewport change or a font change (every style row is recomputed); a cancelled flush; the box limit reached while generating. The epoch (which drops every layout record) only moves on a full build. The painter paints the whole list after a structural edit (the fragment tree no longer has the old shape); a display-list patch is for edits that keep the shape. When the old and new styles differ only in values that paint reads (colors, backgrounds, shadows, outlines, text decoration, opacity) no layout runs. Transforms, filters, masks and `clip-path` take the layout path because they can make containing blocks or ink overflow. - **Layout reuse.** Layout writes into a scratch fragment tree and swaps it into the published one, so handles stay valid. When a block, flow-root, flex or (non-subgrid) grid container that is not dirty and whose subtree has no floats, out-of-flow boxes, columns or tables is opened with the same frame as last time (width, content origin, bottom edge, content width, specified, minimum, maximum and ratio heights, masked flags, grid, table and nesting depth), its children's fragments are copied from the earlier tree and the values the children leave in the frame are restored (a flex container also restores the definite heights it sets while closing). There is one record per box, stamped with the layout that wrote it: a record written in the current layout is never read back, dirty boxes' records are dropped when a layout starts, and a visit that cannot be described by a record drops the box's record. The copy moves a contiguous (preorder) run of fragments column by column, shifting the child and sibling links by a constant with vector adds, and falls back to one fragment at a time when the run is not contiguous. Flex item measurements (the trial layouts) are kept between layouts for boxes whose subtree did not change; a box that was dirty in a layout loses its earlier measurements for good, so they cannot come back when it is clean again. Intrinsic-size caches are kept and cleared along dirty chains. A new box-tree epoch, font catalog version or output object invalidates everything. A trial layout that is undone (flex measurement, grid sizing) drops the records and column plans made inside it. The layout also leaves a `LayoutDelta`: whether the new fragment tree has the same shape as the published one (same count, kinds, boxes and parents) and which fragments it wrote anew with a different position, size, text run or overflow. - **Paint.** The retained display list is repainted with stable ids; damage is computed from item content and context hashes (display list format 1.2). Transform and opacity patches are unchanged. After a full paint the painter keeps a journal: for each fragment the span of items it wrote, its paint-order position (phase list and z-index) and flags for what makes it unsafe to rewrite alone (scopes opened around it, clips of containing blocks, text decoration, outlines, tables, multi-column, sticky, effects). When the layout delta is aligned, `Painter.TryPatch` re-classifies only the fragments in the delta (and the subtrees below the ones that moved) and the boxes whose style or text was patched (`BoxTree` records them; text boxes below a restyled box are included), then paints just those over their old slots (`DisplayList.BeginRewrite`: items are built at the tail, moved into the old slot, payload of equal size is overwritten in place, damage collects the old and new bounds). It gives up and the whole list is painted when a fragment's paint-order position, owner or flags changed, an unsafe fragment is touched, the item sequence would differ, the payload grew past twice its size at the last full paint, the viewport scroll range changed whether the page scrolls, or the changed set exceeds half the fragments. The canvas item is rewritten when the root's extent changed. `InteractivePage.Repaint` does the same for pages built from HTML, and patches transform and opacity animations of elements that did not change otherwise (the animation controller reports them apart from paint and layout changes, so they are neither marked dirty nor relaid out). - **Flush and queries.** `LivePage.Flush` runs restyle, box patching, layout and paint and returns what ran. `LayoutQueries` over a `LivePage` flushes pending edits before answering (CSSOM View's forced layout); over an `HtmlPage` the changes are already applied. The hit tester re-indexes when the fragment tree's version moves, not only when its count does. - **Long-running stability.** A UI that adds and removes nodes every frame for hours must not grow, so nothing here is append-only. - *Slots.* `Remove` (and the marker removal a style change can cause) queues the root of the removed subtree. When the flush has consumed the change set (`StyledTree.ClearPending`), every node of every queued subtree gives its slot back: its columns are reset (declarations, replaced source, states, control info, text span), its generation moves on and its index goes on a free list; the next `NewNode` pops it. A tree no page tracks reclaims when an insert finds no free slot. `Count` is the number of slots ever needed at once (an index bound, not a node count); `LiveCount`, `FreeSlots` and `Capacity` say the rest. `MaxNodes` bounds the nodes in the tree, not the slots ever used. Every column indexed by node (`_chg`, `_oldStyle`, the declaration layers, ...) is sized by the slot count, so the arrays stop growing once the peak is reached. The box tree's node-to-box table is kept up to date by the structural patch (a freed box clears its node's entry; the tables grow with the slot count), so nothing keyed by node index outlives a flush. Per-node state that lives outside the tree (event listeners, hover, focus and animation state) is keyed by OneHtml node on pages built from HTML, whose document is immutable, so it never sees a slot reused; builder pages have none. A `ScrollState` is keyed by the scroll container's element (styled node index plus the slot's generation), so an offset stays with its element through inserts, removals and moves around it, and is forgotten when the node is removed (its slot's generation moves on, so a node that takes the slot starts at 0). - *Handles.* `StyledNode` carries the slot's generation next to the index. `IsValid` is false once the node's slot was reclaimed or the tree cleared. Editing methods reject an invalid handle with `ArgumentException`; reading members throw `InvalidOperationException`; `BoxTree.PrincipalBox`, `LayoutQueries` and `ComputedStyleSerializer` answer "no box" or null for one. Equality includes the generation, so a stale handle never equals the node that reuses its slot. Until the flush after a `Remove` the removed handles stay valid, because the change set still names them. - *Text.* The text arena compacts when it fills (including while texts are added), counting only live nodes, so it follows the live text and not the text ever stored; the text limit counts live text. - *Style caches.* Styles computed from a builder `StyleDeclaration` (and from the layer keys of hover, active and focus declarations) are remembered on the declaration through a `ConditionalWeakTable`, at most 32 entries per declaration, not in the table's global map. A host that makes a declaration per inserted node does not keep them alive; one that keeps and shares declarations keeps their entries, so reusing them computes nothing. The OneHtml front end keeps its map (its keys live as long as the document). - *Style rows.* `StyleTable` interns distinct computed styles by value (at most 65,536 rows at a time), so no row has one owner that could free it; the table sweeps instead (`StyleTable.Reclaim`, mark and sweep). Every object that keeps `StyleId`s registers with the table as an `IStyleHolder` (weak reference): `StyledTree` (the style of every live node, and the old style of the nodes with a pending change), `BoxTree` (every box, anonymous boxes included) and the `AnimationController` (the private rows of animated elements and of the descendants that follow them). A sweep clears a mark per row, asks each holder to mark what it still reads, then frees every unmarked row to a free list (lowest index first, and the high-water mark shrinks when the top rows are free). Per-row bookkeeping follows: the by-value index is rebuilt, entries of the strong caches (`_intern`, anonymous-box rows) that name a freed row as value or parent are removed, and a per-row generation moves on so entries of the weak per-declaration caches that name a freed row stop matching (checked at lookup, so the weak tables are never walked). The inline layout's font-metrics cache is keyed by row and drops its entries through `StyleTable.ResetGeneration`, which a sweep that freed rows also bumps. - *When.* `PipelineUpdater.UpdateBoxesAndLayout` calls `ReclaimIfNeeded` after it cleared the pending changes: the one point of a flush where no style id is held in a local, for `LivePage` and the HTML page alike. A sweep is due when the rows in use exceed the number the last sweep left by at least 1,024 (more for a big document: the larger of 1,024 and the rows the holders keep, up to 16,384), so a sweep costs a few microseconds per thousand frames of the HUD page and allocates nothing. `Reclaim()` is also public for a host that wants to sweep at a time of its own, with the same rule: no `StyleId` (from `StyledNode.Style`, `Box.Style`, `GetAnonymous`) may be kept across a flush. - *One interval of grace.* A row that no holder reads survives a sweep when it was looked up or made since the previous sweep (a per-row epoch stamp). A style that a host asks for again and again through a cached declaration (the variants of a damage-number row) is never computed twice, because its row is touched in every interval; a style that was used once and dropped (a position that took a new value) goes at the second sweep. The number of rows therefore settles at what the holders keep plus about two intervals of new values. - *Releasing private rows* (`ReleasePrivate`) keeps the values and moves the row's generation on; the row can be handed out again at once. The transition code still reads a released row as a node's "before" style until its restyle is done, so rows are never cleared. - *Proven by* sweeping after every flush in the edit-sequence tests whose edits mostly make values no row holds (`EditSession` fresh-values profile; incremental equals a fresh build, and the `incremental` fuzz target has the same profile), and by the soak below. - *Measured.* The HUD page with a moving element (its `left` is the frame number in px and a label's `width` cycles) on top of the churn, a new style row per frame: the table size, live rows and heap stay flat over 1,000,000 frames and the engine's share of a frame (the flush, leaving out the host's own work of parsing the new values) allocates 0 bytes in steady state (`ChurnSoakTests`; `ONERENDER_SOAK_FRAMES_MOVING` sets the length, default 30,000). At 1,000,000 frames in a release build: style rows 3,304 (2,347 live) after the first 100,000 frames and unchanged to the end, the heap 27,172 KiB after warm-up and 27,139 KiB at the end, 980 sweeps, and 125,048 bytes allocated by all the flushes together (a one-time cost, the same at 28,000 frames). Known limit: computing a style that declares `display`, `color` or `background` allocates (OneHtml's typed value parsers), so an element with such declarations whose parent's style changes every frame allocates ~100 bytes per such property on that frame; the soak's moving elements use geometry only. - *Measured, churn.* The game-UI page (about 1,070 nodes) with a floating layer of about 50 nodes added and the previous one removed every frame, plus a chat line in and the oldest out, then a flush: slot count, capacity, style rows and the heap after a forced collection are identical after warm-up and after the run (`ChurnSoakTests`, with a declaration made per node as well as with shared ones), and a steady-state frame allocates 0 bytes with shared declarations. - **Bounds and cancellation.** The pending log, node count and depth are bounded; a cancelled flush leaves the edits pending and the next flush starts again from the styled tree. - **Allocation and time.** On the 1,000-node game-UI sample (`testdata/game-ui/hud.html`, the same scene through the builder), hover restyle, a text change and a one-subtree relayout allocate 0 bytes once warm and take about 3 µs (hover), 50 µs (text) and 0.35 to 0.45 ms (one subtree, which moves most of the page and paints it in full) in a release build on a loaded 32-core server part (`IncrementalBenchmarks` for the gated run; `GameUiIncrementalTests.WorkPerChange` checks without a clock that none of the changes generates the boxes again). A nothing-in-common reuse is guarded by key tests (`ReuseKeyTests`); nothing inside a multi-column container is reused, because the column cut rewrites its fragments after the layout. ## Animations (`Animation/`) CSS Transitions 1 and CSS Animations 1, timed by the host. The engine reads no clock: the host passes the time of the frame to `InteractivePage.Advance(nowMs)` (any monotonic source, the same one that stamps its inputs; the start is `InteractionOptions.AnimationStartTime`). `HtmlPage.Create` without `HtmlPageOptions.AnimationTime` is a static page: the animated properties show their cascaded values, so documents and reftests are unchanged. `docs/animations.md` lists the matching choices and deviations. - **Where it sits.** `AnimationController.Recalc` runs on the styled tree after the front end has built it and before the box tree is made. It compares each element with the same element of the previous tree (the before-change style), starts, retargets, reverses and cancels transitions (Transitions 1 §3), matches `animation-name` against the `@keyframes` collected from the document's sheets (`KeyframesRegistry`; the cascade does not carry them), and gives every animated element a private style row that replaces its style in the tree. `Tick(now)` rewrites those rows in place and reports the most expensive kind of change it made (`MotionLevel`): none, compositor (only `transform`, the individual transform properties or `opacity`), paint (colors, shadows, filters) or layout. - **Values.** `PropertyAnimator` has one animation type per property: numbers and integers, lengths and percentages (a px and % mix becomes a `calc()`), colors (premultiplied sRGB), border radii, positions, shadow lists, filter lists, `clip-path` shapes of the same kind, transform lists (matching functions per argument, otherwise through 4×4 decomposition in `TransformMath` with quaternion interpolation), `visibility`, `line-height`, `vertical-align` and `display`; everything else is discrete and flips at the midpoint (Web Animations 1 §5.3), or runs only with `transition-behavior: allow-discrete`. `EasingFunction` is CSS Easing 1: `cubic-bezier()` with tangent extrapolation, `steps()` with the jump terms, `linear()` with stop filling. `EffectTiming.Sample` is the Web Animations timing model (phase, iteration, direction, fill). - **Through the incremental pipeline.** On an interactive page a state change restyles in place (see Incremental updates); `AnimationController.RecalcPending` then runs for the restyled elements only, with each one's before-change style taken from the change log, so hover and focus transitions start without a full recalculation. When any restyled element has or had effects, its style row differs in content from the logged old style (rows are rewritten under their ids), so the boxes are generated again before layout; the cascade and the walk over unaffected elements are still skipped. A tick that changes a paint-only or layout property marks only the animated elements and the descendants that follow an inherited value (the controller's `ChangedNodes`): `BoxTree.ApplyAnimatedRows` refreshes the anonymous boxes below them and, for layout properties, marks their boxes dirty, and `PipelineUpdater.LayoutAll` lays out only the dirty subtrees and ancestors with the page's reusing layout engine. Rows of finished effects and of replaced followers go back to the style table for reuse. On the HUD page of about 1,800 nodes, ticking width and color on a handful of elements and repainting allocates 0 B once warm (`MotionGameUiTests`), and ticks equal a fresh render at the same time (`HtmlPage.Create` with `HtmlPageOptions.AnimationTime`, which starts the animations at the clock's origin and samples them at that time). - **Compositor path.** An element whose transform or opacity is animated always gets a transform scope or opacity group in the display list, recorded as a patch entry. `InteractivePage.Advance` returns `InputOutcome.Animated` alone for such a tick; `PatchAnimations(painter, list)` then replaces the matrices and group opacities in place and adds the damage. No relayout, no allocation (`MotionAllocationTests`: 100 animations on a page of about 1,500 nodes, 0 B per frame, including frames that deliver `animationiteration` events). If a transform moves between planar and perspective the list cannot be patched and `PatchAnimations` returns -1: paint again. - **Inheritance.** The text and the descendants that inherit an animated inherited property (`color`, `visibility`, `font-size`, ...) get private rows too and receive its animated value at each tick. Descendants that set the property themselves, or run effects of their own, keep their values. Lengths in `em` of element descendants are not recomputed when `font-size` animates. - **Events.** `transitionrun`, `transitionstart`, `transitionend`, `transitioncancel` and `animationstart`, `animationiteration`, `animationend`, `animationcancel` are queued by the tick, sorted by the time they were scheduled for, then composite order, kind and element (a total order), and dispatched through `InteractivePage.Events` (`TransitionEvent`, `AnimationEvent`; they bubble and cannot be cancelled). Events of the first frame wait for the first `Advance`, so listeners added after `Create` see them. - **Reduced motion.** `HtmlPageOptions.PrefersReducedMotion` sets the `prefers-reduced-motion` media feature; the page's own rules decide what to do. - **Bounds.** 64 items per comma list, 1,024 keyframe blocks per rule, 100,000 animated elements, 256 transitions and 64 animations per element, 65,536 queued events, 4,096 followers per element; times are clamped to ±1e12 ms and iteration counts to 1e9; easing outputs to ±1e9; `steps()` to 2^20 steps and `linear()` to its stop limit. Nothing recurses over tree depth. - **Out of scope.** Scroll-driven animations (`animation-timeline`, `animation-range`), `animation-composition`, `@starting-style`, view transitions, registered-property animation, the Web Animations script API, `var()` inside `@keyframes`, pseudo-element animations. - **Checked by.** `MotionEasingTimingTests`, `MotionInterpolationTests`, `MotionPageTests` (start, reverse and cancel rules, event order, pause, inheritance), `MotionAllocationTests`, the `motion` fuzz target, and the replay of the css-transitions and css-animations testharness helpers (`WptMotionReplayTests`, `testdata/wpt-motion`): the parsing and computed-value files through the style replay and the interpolation helpers through the engine, with expectations, exclusions and per-module floors beside the files. <!-- https://recursiveai.org/onerender/docs/index/ --> # Documentation Normative descriptions of how the engine behaves where the specifications leave room, and of its public formats. Start with the [README](https://recursiveai.org/onerender/docs/overview/) for what the packages are and how to call them. | Page | What it covers | |---|---| | [architecture.md](https://recursiveai.org/onerender/docs/architecture/) | The pipeline: styled-node tree, computed values, box tree, layout algorithms (block, inline, float, positioned, flex, grid, table, multi-column, paged), paint, incremental updates, interaction, animations. Each section lists what checks it. | | [layout-conventions.md](https://recursiveai.org/onerender/docs/layout-conventions/) | Choices made where the specifications allow several results, each with the browser evidence and the spec text, and the user agent style sheet. | | [text.md](https://recursiveai.org/onerender/docs/text/) | The text service: font resolution and fallback, shaping, `@font-face`, limits on hostile font data. | | [display-list.md](https://recursiveai.org/onerender/docs/display-list/) | The versioned display list format: items, ids, scopes, damage, in-place patching. What a backend or a GPU adapter consumes. | | [forms.md](https://recursiveai.org/onerender/docs/forms/) | Form controls, their metrics and states, text editing and IME composition. | | [animations.md](https://recursiveai.org/onerender/docs/animations/) | CSS Transitions and Animations on the host's clock, and where they deviate from the specifications. | | [pdf.md](https://recursiveai.org/onerender/docs/pdf/) | PDF output: pages, vector or raster islands, tags, limits, known limitations, how it is validated. | | [razor.md](https://recursiveai.org/onerender/docs/razor/) | Rendering a Razor component to a PDF or an image. | | [accessibility-facts.md](https://recursiveai.org/onerender/docs/accessibility-facts/) | The rendered page facts produced for accessibility checks. | | [geometry-differential.md](https://recursiveai.org/onerender/docs/geometry-differential/) | How layout is compared with Chrome, element by element: formats, tolerance, corpus sets. | | [pixel-similarity.md](https://recursiveai.org/onerender/docs/pixel-similarity/) | Pixel comparison with Chrome screenshots (information only). | | [wpt-results.md](https://recursiveai.org/onerender/docs/wpt-results/) | The Web Platform Tests reftest runner, how results are counted, and the floors. | | [benchmarks.md](https://recursiveai.org/onerender/docs/benchmarks/) | The interactive and document benchmarks, how they are run and how to read them. | Measured results are in [`results/`](https://github.com/recursive-ai-oss/onerender-csharp/tree/1fc2990e8d013c56ca24cf327cf375b101bba1fa/results). Fuzz targets are described in [`fuzz/README.md`](https://github.com/recursive-ai-oss/onerender-csharp/blob/1fc2990e8d013c56ca24cf327cf375b101bba1fa/fuzz/README.md), CI in [`.github/workflows/ci.yml`](https://github.com/recursive-ai-oss/onerender-csharp/blob/1fc2990e8d013c56ca24cf327cf375b101bba1fa/.github/workflows/ci.yml) and the package checks in [`tools/ci/`](https://github.com/recursive-ai-oss/onerender-csharp/tree/1fc2990e8d013c56ca24cf327cf375b101bba1fa/tools/ci). <!-- https://recursiveai.org/onerender/docs/changelog/ --> # Changelog All notable changes are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow [Semantic Versioning](https://semver.org/). ## [1.0.0] - 2026-10-27 First release. All packages share one version. ### Added - **RecursiveAI.OneRender** (the engine; depends on OneHtml and OneFont only) - Styled-node tree with typed computed values, fed by OneHtml's DOM and cascade or by a C# builder (`StyledTree`); box tree; incremental layout into a fragment tree. - Layout: block and inline formatting, margin collapsing, floats, positioning (relative, absolute, fixed, sticky), overflow and scrolling, flexbox, grid with subgrid, the CSS table model, multi-column, intrinsic sizing, aspect-ratio, bidi text with OneFont shaping and line breaking, `@font-face` (WOFF and WOFF2). - Paged media: fragmentation, `@page`, margin boxes, page counters, named pages, `string-set`, `target-counter`, leaders, room for table headers and footers on continuation pages (the copies don’t show in 1.0.0). - Paint into a retained, versioned display list with stable item IDs and damage regions: backgrounds, gradients, borders and radii, shadows, transforms, opacity, blend modes, clip paths, masks, filters, images, inline SVG, generated content and lists. Transform and opacity changes patch the list without relayout. - Interaction: hit testing, DOM-standard events (capture, target, bubble, `preventDefault`), focus, scrolling with scroll snap, text selection, CSS Transitions and Animations on the host's clock, synchronous layout queries. - Form controls with Chrome's default look, text editing with caret, selection, undo, clipboard hooks and IME composition, and the `select` dropdown as an overlay in the same display list. - Incremental restyle, relayout and repaint equal a full recompute; steady-state frames and the common incremental changes (hover, text change, one subtree relayout, transform and opacity animation) allocate nothing. - Layout explanation and box-to-source mapping, off by default. - NativeAOT-, trim- and WebAssembly-clean; no network, clock or process access. - **RecursiveAI.OneRender.Image**: display list to pixels (`HtmlRender.ToImage`, `DisplayListRasterizer`) through OneImage; inline SVG; colour bitmap glyphs. - **RecursiveAI.OneRender.Pdf**: display list to PDF (`HtmlPdf.ToPdf`, `DisplayListPdfWriter`) through OnePdf core: tagged by default from HTML semantics, `/ToUnicode` and `/ActualText`, font subsetting, PDF/UA-1 and PDF/A-2a profiles, rasterized islands where PDF cannot express an effect. - **RecursiveAI.OneRender.Razor**: `RazorRenderer` renders a Razor component to HTML, PDF or an image in one call, with `wwwroot` and static web asset resolution and `AddOneRender()`. - **RecursiveAI.OneRender.Accessibility**: rendered page facts (boxes, paint order, visibility, text boxes, background colours) in OneHtml's `RenderedFacts` shape. - **RecursiveAI.OneRender.Fonts**: Liberation Sans, Serif and Mono, Carlito and Caladea, unmodified, with their OFL texts. - **RecursiveAI.OneRender.Fonts.Noto.\*** (29 script packages), **.Fonts.Cjk** and **.Fonts.Emoji**: opt-in script coverage; the engine names the package that covers any missing glyph. ### Known limitations - Tagged PDF/UA-1 output with full complex-script sentences (Arabic, Devanagari, Thai) can fail veraPDF rule 7.21.7, because glyphs inside multi-glyph clusters have no `ToUnicode` mapping. Extracted text is still correct. - Vertical writing modes, date/time/color pickers, file input, `datalist`, CSS footnotes, scroll-driven animations and view transitions are not implemented. - `<img>` of an SVG file is not drawn; inline `<svg>` is. - Container queries and `@scope` depend on OneHtml support that is not yet available. - WOFF2 fonts on browser WebAssembly need decoded bytes from the host (the platform has no Brotli). [1.0.0]: https://github.com/recursive-ai-oss/onerender-csharp/releases/tag/v1.0.0 <!-- https://recursiveai.org/onerender/cookbook/html-to-pdf/ --> # Convert HTML to PDF Turn an HTML page into a tagged PDF in C# with OneRender, with no browser: set the page size and margins, write metadata, stream the file to a response, and keep links working. ```csharp using OneRender.Pdf; string html = """ <!DOCTYPE html> <html lang="en"> <head> <title>Packing slip 5521

Packing slip 5521

Ship to: Harbor Street Books, 14 Harbor Street

Track this order · Returns

Returns

Return anything within 30 days.

"""; // 4 by 6 inches, a common label size, with quarter-inch margins. Units are CSS px: 96 per inch. var label = new PdfPageSetup(width: 4 * 96, height: 6 * 96, marginTop: 24, marginRight: 24, marginBottom: 24, marginLeft: 24); var options = new HtmlPdfOptions { Page = label, Output = new PdfOutputOptions { Author = "Harbor Street Books", Subject = "Order 5521", CreationDate = "D:20261027090000Z", // a PDF date string; the default is none }, }; byte[] bytes = HtmlPdf.ToPdf(html, options); File.WriteAllBytes("slip.pdf", bytes); Console.WriteLine($"slip.pdf: {bytes.Length:N0} bytes"); // In a web endpoint, write straight to the response body instead: pages stream out as they are finished. var response = new MemoryStream(); HtmlPdfResult result = HtmlPdf.ToPdf(html, response, options); Console.WriteLine($"{result.PageCount} pages, same bytes: {response.ToArray().AsSpan().SequenceEqual(bytes)}"); ``` ```text slip.pdf: 17,149 bytes 2 pages, same bytes: True ``` The file is 4 by 6 inches, two pages long, titled “Packing slip 5521” in English, with the author and subject set. The link to the tracking page is a link annotation, and “Returns” jumps to the heading on page 2. ## What goes into the file - **Structure.** The PDF is tagged from the HTML: headings, paragraphs, lists, tables with header scopes, links, and images with their `alt` text. Turn it off with `Tagged = false` in `PdfOutputOptions`. - **Title and language** come from `` and `<html lang>`, unless `Title` or `Language` is set in the options. - **Text** is real text in subsetted fonts, so it can be selected, searched and extracted, including ligatures and right-to-left runs. - **Vector output.** Backgrounds, borders, gradients, shadows without blur, transforms and clips are written as PDF drawing operations. Effects PDF can’t express, such as blurred shadows and filters, are rasterized at `RasterDpi` (192 by default) for just that area. - **The same bytes every time.** Nothing reads the clock: with no `CreationDate`, the same input gives the same file on every platform, which makes PDFs easy to compare in tests. ## Good to know - Pass a stream to write page by page. A `PdfProfile` other than `None` (see [Accessible PDF](https://recursiveai.org/onerender/cookbook/accessible-pdf/)) builds the file in memory first, then writes it. - `PdfPageSetup.A4` and `PdfPageSetup.Letter` have 48 px (half-inch) margins. A `@page` rule in the document wins over the options: see [Headers, footers and page breaks](https://recursiveai.org/onerender/cookbook/paged-media/). - Style sheets, images and fonts come through `BaseUrl` and `Resources`, as in [Getting started](https://recursiveai.org/onerender/docs/getting-started/#supply-style-sheets-and-images). Scripts don’t run, so render the final HTML on the server, for example from a [Razor component](https://recursiveai.org/onerender/cookbook/razor-to-pdf/). - `MaxPages` (100,000 by default) stops a runaway document with a `PdfRenderException`. Every rule of the writer is in [PDF output](https://recursiveai.org/onerender/docs/pdf/). <!-- https://recursiveai.org/onerender/cookbook/html-to-png/ --> # Convert HTML to PNG Render HTML and CSS to a PNG in C# with OneRender: a 1200 by 630 social card with a gradient and a logo, and a badge on a transparent background at three times the pixel density. ```csharp using OneRender.Image; using OneRender.Painting; string title = "Shipping weekly: 444 orders"; string html = $$""" <!DOCTYPE html> <html lang="en"> <style> body { margin: 0; width: 1200px; height: 630px; display: flex; flex-direction: column; justify-content: space-between; box-sizing: border-box; padding: 72px; font-family: sans-serif; color: #fff; background: linear-gradient(135deg, #16202c, #1d4ed8) } h1 { font-size: 64px; line-height: 1.1; margin: 0; max-width: 900px } footer { display: flex; align-items: center; gap: 20px; font-size: 28px; opacity: .85 } </style> <body> <h1>{{System.Net.WebUtility.HtmlEncode(title)}}</h1> <footer><img src="logo.png" width="64" height="64" alt=""> example.com/blog</footer> </body> </html> """; RgbaImage card = HtmlRender.ToImage(html, new HtmlRenderOptions { Width = 1200, Height = 630, BaseUrl = "file:///cards/", Resources = url => url == "file:///cards/logo.png" ? File.ReadAllBytes("logo.png") : null, }); File.WriteAllBytes("card.png", card.ToPng()); Console.WriteLine($"card.png: {card.Width}x{card.Height}"); // A badge on a transparent background, at 3x for high-density screens. RgbaImage badge = HtmlRender.ToImage( "<body style='margin:0'><span style='display:inline-block;padding:4px 10px;border-radius:999px;background:#16a34a;color:#fff;font:600 14px sans-serif'>Passing</span>", new HtmlRenderOptions { Width = 80, Height = 27, DevicePixelRatio = 3, PageColor = PaintColor.Transparent }); File.WriteAllBytes("badge.png", badge.ToPng()); Console.WriteLine($"badge.png: {badge.Width}x{badge.Height}, corner alpha {badge.Pixels[3]}"); ``` ```text card.png: 1200x630 badge.png: 240x81, corner alpha 0 ``` ## The options - **`Width` and `Height`** are the viewport in CSS pixels, from 1 to 16,384. The image shows exactly the viewport; content below it is cut off. To capture a whole page, measure it first: see [Replace a headless browser](https://recursiveai.org/onerender/cookbook/replace-headless-browser/#full-page-screenshots). - **`DevicePixelRatio`** scales the pixels, not the layout: the badge is laid out at 80 by 27 and drawn at 240 by 81. - **`PageColor`** is painted where the document paints no background. The default is opaque white; `PaintColor.Transparent` leaves those pixels clear. - **`Resources`** supplies images, style sheets and fonts by absolute address, and `BaseUrl` is what relative references resolve against. The engine fetches nothing itself. - **`AnimationTime`** shows CSS animations and transitions as they look that many milliseconds in. Without it, the page is drawn with no animation applied. ## The image `RgbaImage` holds `Width * Height * 4` bytes of 8-bit RGBA in `Pixels`, row by row from the top left, ready to hand to another library or a texture upload. `ToPng()` encodes it. ## Good to know - Images in the page are decoded by OneImage (PNG, JPEG, WebP, GIF, TIFF, BMP and ICO), and inline `<svg>` is drawn. An `<img>` pointing to an SVG file isn’t drawn in 1.0.0; inline the SVG instead. - Rendering is deterministic: the same HTML, resources and fonts give the same pixels on every machine, so snapshot tests can compare bytes. - Text uses the bundled fonts unless you add more: see [Fonts](https://recursiveai.org/onerender/cookbook/fonts/). The rasterizer and everything it draws are described in [the overview](https://recursiveai.org/onerender/docs/overview/) and [the display list](https://recursiveai.org/onerender/docs/display-list/). <!-- https://recursiveai.org/onerender/cookbook/razor-to-pdf/ --> # Render a Razor component to PDF Render a Razor component to a tagged PDF from an ASP.NET Core endpoint in one call, with its style sheet served from `wwwroot`, using `RecursiveAI.OneRender.Razor`. ```shell dotnet new web -o InvoiceWeb cd InvoiceWeb dotnet add package RecursiveAI.OneRender.Razor --version 1.0.0 ``` The component, `Components/Invoice.razor`, is a whole HTML document with parameters: ```html <!DOCTYPE html> <html lang="en"> <head> <title>Invoice @Number

Invoice @Number

Billed to @Customer

@foreach (var line in Lines) { }
ItemQtyAmount
@line.Item@line.Qty@line.Amount.ToString("C", System.Globalization.CultureInfo.GetCultureInfo("en-US"))

Total @Lines.Sum(l => l.Amount).ToString("C", System.Globalization.CultureInfo.GetCultureInfo("en-US"))

@code { [Parameter] public string Number { get; set; } = ""; [Parameter] public string Customer { get; set; } = ""; [Parameter] public IReadOnlyList Lines { get; set; } = []; public record InvoiceLine(string Item, int Qty, decimal Amount); } ``` `Components/_Imports.razor` holds `@using Microsoft.AspNetCore.Components`, and the style sheet is an ordinary file, `wwwroot/css/invoice.css`: ```css body { font: 15px/1.5 Arial, sans-serif; color: #222 } h1 { font-family: Georgia, serif; font-weight: normal; margin: 0 } .to { color: #555 } table { width: 100%; border-collapse: collapse } th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #ddd } .total { text-align: right; font-weight: bold } ``` `Program.cs` registers the renderer and maps the endpoint: ```csharp using InvoiceWeb.Components; using OneRender.Pdf; using OneRender.Razor; var builder = WebApplication.CreateBuilder(args); builder.Services.AddOneRender(); var app = builder.Build(); app.MapGet("/invoices/{number}.pdf", async (string number, RazorRenderer renderer, CancellationToken ct) => { var parameters = new Dictionary { ["Number"] = number, ["Customer"] = "Harbor Street Books", ["Lines"] = new Invoice.InvoiceLine[] { new("Shelf labels", 400, 120m), new("Reading lamps", 6, 354m) }, }; RazorPdfResult pdf = await renderer.RenderPdfAsync(parameters, new HtmlPdfOptions { Page = PdfPageSetup.Letter }, ct); foreach (string missing in pdf.MissingAssets) app.Logger.LogWarning("Missing asset {Path}", missing); return Results.File(pdf.Bytes!, "application/pdf", $"invoice-{number}.pdf"); }); app.Run(); ``` Run it and request `/invoices/INV-1042.pdf`: the response is a one-page Letter PDF titled “Invoice INV-1042”, tagged, styled by `invoice.css`, with no missing assets logged. ## How it works - **`AddOneRender()`** registers a singleton `RazorRenderer` over the app’s web root. Each call renders the component in a fresh service scope, so scoped services behave as they do in a request, and components may await. - **Assets** (``, ``, `@font-face`) are served from `wwwroot` and the static web assets of referenced libraries (`_content//...`). `data:` addresses work, and `..` segments are refused. An asset that isn’t found never throws: it is listed in `MissingAssets`. - **Fragments.** A component that renders only a fragment, with no `` element, is wrapped in a document using `RazorRenderOptions.Language` (`en` by default), `Title` and `Stylesheets`. Set those in `AddOneRender(o => ...)` so a tagged PDF still gets a title and language. - **Options pass through.** `HtmlPdfOptions` sets the page size and output, such as a [PDF/UA profile](https://recursiveai.org/onerender/cookbook/accessible-pdf/). Its `BaseUrl` and `Resources` are replaced by the web root, which is asked first. ## Images and HTML `RenderImageAsync` returns a `RazorImageResult` with `ToPng()`, taking `HtmlRenderOptions` for the viewport: ```csharp RazorImageResult img = await renderer.RenderImageAsync(parameters, new HtmlRenderOptions { Width = 800, Height = 600 }, ct); byte[] png = img.ToPng(); ``` `RenderHtmlAsync` returns the markup alone, and `RenderPdfAsync(stream, ...)` writes to your stream, such as the response body, and returns the page count with `Bytes` null. The package needs the ASP.NET Core shared framework. Everything it does goes through the public `HtmlPdf` and `HtmlRender` APIs. The details are in [Razor components](https://recursiveai.org/onerender/docs/razor/). # Fonts: defaults, CJK, emoji and Noto script packs Find out which characters the bundled fonts can’t draw, add the package that covers them (CJK, emoji or one of 29 Noto script packs), and load your own font with `@font-face`. OneRender never uses the fonts installed on the machine unless you pass a provider for them, so a page renders the same everywhere. `RecursiveAI.OneRender.Fonts`, which `.Image` and `.Pdf` bring in, has Liberation Sans, Serif and Mono and Carlito and Caladea. They also answer to Arial, Helvetica, Times New Roman, Courier New, Calibri and Cambria, with the same metrics. They cover Latin, Greek, Cyrillic and most of Hebrew. ## Find what’s missing A character no font covers is drawn as an empty box, and the font provider records which package would cover it: ```csharp using OneRender.Fonts; using OneRender.Image; using OneRender.Pdf; using OneRender.Text; string html = """ Greetings

Hello, Grüße, Привет, Γειά σου, שלום

Times New Roman maps to Liberation Serif. Courier New too.

日本語のテキスト, 中文, 한국어

مرحبا بالعالم · नमस्ते दुनिया · สวัสดีชาวโลก

Emoji: 🎉 👩‍💻 🇺🇸

"""; TextFontProvider fonts = FontProviders.CreateDefault(); HtmlPdf.ToPdf(html, File.Create("greetings.pdf"), new HtmlPdfOptions { Fonts = fonts }); // Which packages would cover what the default fonts lack: foreach (string package in fonts.Catalog.Diagnostics.Items .Where(d => d.Kind == TextDiagnosticKind.MissingGlyph) .Select(d => d.Package ?? "(no package)").Distinct()) Console.WriteLine(package); foreach (OptionalFontPackage p in OptionalFonts.Packages) Console.WriteLine($"registered: {p.PackageId}, {p.Faces.Count} faces"); File.WriteAllBytes("greetings.png", HtmlRender.ToImage(html, new HtmlRenderOptions { Width = 720, Height = 330 }).ToPng()); ``` With only the default fonts, it prints the packages to add: ```text RecursiveAI.OneRender.Fonts.Cjk RecursiveAI.OneRender.Fonts.Noto.Arabic RecursiveAI.OneRender.Fonts.Noto.Devanagari RecursiveAI.OneRender.Fonts.Noto.Thai RecursiveAI.OneRender.Fonts.Emoji ``` The diagnostics live on the provider you pass in `Fonts`, so create one per job when you want to read them. Each `TextDiagnostic` also has the code point and a `Message`. ## Add the packages ```shell dotnet add package RecursiveAI.OneRender.Fonts.Cjk --version 1.0.0 dotnet add package RecursiveAI.OneRender.Fonts.Noto.Arabic --version 1.0.0 dotnet add package RecursiveAI.OneRender.Fonts.Noto.Devanagari --version 1.0.0 dotnet add package RecursiveAI.OneRender.Fonts.Noto.Thai --version 1.0.0 dotnet add package RecursiveAI.OneRender.Fonts.Emoji --version 1.0.0 ``` There’s no code to change. Each package registers itself when the app starts, through a module initializer its build adds, with no reflection, so it works with NativeAOT and trimming. Run the same program again: ```text registered: RecursiveAI.OneRender.Fonts.Cjk, 10 faces registered: RecursiveAI.OneRender.Fonts.Noto.Arabic, 2 faces registered: RecursiveAI.OneRender.Fonts.Noto.Devanagari, 2 faces registered: RecursiveAI.OneRender.Fonts.Noto.Thai, 2 faces registered: RecursiveAI.OneRender.Fonts.Emoji, 1 faces ``` No missing glyphs are left, and every line is drawn: Arabic shaped right to left, Devanagari conjuncts, Thai, the Japanese, Chinese and Korean text, and the emoji in color, including the ZWJ sequence and the flag. In the PDF the emoji stay text you can copy, with the color bitmaps drawn over it. ## The packages - **`.Fonts.Noto.