---
title: "SVG"
url: https://recursiveai.org/oneimage/docs/svg/
description: "SVG and SVGZ as an image format drawn at the size you ask for, what it supports, and how it is checked against the SVG test suite."
version: "1.10.1"
source: https://github.com/recursive-ai-oss/oneimage-csharp/blob/1cac5abc37fd3268eb6bab6aa657ca381a6b11a1/docs/svg.md
---

# SVG

Design of `RecursiveAI.OneImage.Svg` (assembly and namespace `OneImage.Svg`): SVG as an image format that renders directly at the size you ask for. This page describes what the package is, how it is built, and where it stands. The package detects SVG, reads its natural size and draws shapes, paths, strokes, gradients, patterns, markers, groups with opacity and blend modes, transforms, viewports, `use` and `symbol`, clip paths, masks, style sheets, `switch`, `data:` images, filters and text at any size and one region at a time.

```csharp
using OneImage;
using OneImage.Svg;

var config = new ImageConfiguration().Add(SvgFormat.Instance);   // not part of ImageConfiguration.Default, so it trims away when unused
var load = new LoadOptions { Configuration = config };

ImageInfo info = Image.Identify("logo.svg", load);                // natural size, nothing rendered
Image.Load("logo.svg", load).Resize(width: 400).Save("logo-400.png");   // rasterized at 400 px, not scaled up from the natural size
```

## Scope

Static SVG 1.1 and the SVG Tiny-style content found in practice, plus the parts of SVG 2 and CSS that documents rely on:

| Area | Supported |
|---|---|
| Structure | `svg` (nested too), `g`, `defs`, `use`, `symbol`, `switch` (`systemLanguage`, `requiredFeatures`), `a`, `title`/`desc` ignored |
| Shapes | `rect` (with `rx`/`ry`), `circle`, `ellipse`, `line`, `polyline`, `polygon`, `path` |
| Painting | fill and stroke (width, caps, joins, miter limit, dashes), `fill-rule`, opacities, `paint-order`, `vector-effect: non-scaling-stroke`, `shape-rendering`, `visibility`, `display`, `color`/`currentColor`, `context-fill`/`context-stroke`, `mix-blend-mode`, `isolation`, `opacity` |
| Paint servers | `linearGradient`, `radialGradient` (with focal point and `fr`), `pattern`, href inheritance, `gradientTransform`/`patternTransform`, `spreadMethod`, both unit systems |
| Clipping and masking | `clipPath` (nested, per-child `clip-rule`), `mask` (luminance and alpha, nested), `overflow` clipping of nested viewports and markers |
| Markers | `marker-start`/`mid`/`end` and the `marker` shorthand, `orient`, `markerUnits`, `viewBox`, `refX`/`refY` |
| Images | `image` with a `data:` URI (PNG, JPEG, GIF, WebP, BMP, TIFF, ICO and nested SVG, decoded by OneImage's own codecs), `preserveAspectRatio`, `image-rendering` |
| Text | `text`, `tspan`, `textPath`, per-character `x`/`y`/`dx`/`dy`/`rotate`, `text-anchor`, baselines, decorations, spacing, `textLength`, writing modes; rendered through `OneImage.Text` |
| Filters | the `filter` element with the full SVG 1.1 primitive set, `feDropShadow`, and the CSS filter functions (`blur()`, `drop-shadow()`, `grayscale()`, ...) |
| CSS | presentation attributes, the `style` attribute, `<style>` with type, class, id, universal, attribute, descendant, child and sibling selectors, the structural pseudo-classes and `:not()`/`:is()`/`:where()`, specificity, source order, `!important`, `inherit`, `initial`, `unset`, `currentColor`, `transform` and `transform-origin` as properties |

Not supported, and never to be: SMIL animation, scripting and event attributes, `foreignObject`, `BackgroundImage`/`enable-background`, and any external fetch. Animation elements are ignored and the document renders as its first frame; `script` and `foreignObject` content is skipped.

**External references fail closed.** The package never opens a file, a URL or any other resource named by a document: `href`/`xlink:href` other than `#id` and `data:`, `@import`, `@font-face` sources, `url()` in CSS, the `xml-stylesheet` instruction. Such a reference is treated as dangling, which SVG already defines: an `image` or `use` renders nothing, a paint server reference falls back to its fallback color or `none`, a missing `filter` reference removes the element, a missing `clip-path`/`mask` is ignored. No error is raised and no I/O happens. A resolver hook can be added later as an explicit opt-in; there is none now.

## Architecture

```
bytes ─▶ source ─▶ XML parser ─▶ document ─▶ styles ─▶ render tree ─▶ renderer ─▶ pixels
         (gunzip,   (strict,      (typed     (CSS       (resolved,     (region at a
          caps)      bounded)      elements)  cascade)   size-free)     given scale)
```

1. **Source.** Reads the stream under a byte cap. Gzip data (`.svgz`) is detected by its signature and inflated with the library's own inflate, under the document cap and the expansion-ratio limit, then the stored CRC-32 and size are checked.
2. **XML parser** (`Xml/XmlParser.cs`). Our own, strict, XML 1.0 plus Namespaces, over UTF-8, UTF-16 (with BOM) and Latin-1/ASCII. It produces a plain tree of elements, attributes (namespace-resolved) and text.
3. **Document.** The tree is turned into typed SVG elements and attributes: lengths, colors, transforms, `viewBox`, `preserveAspectRatio`, paint, path data (parsed by the drawing layer's strict path parser). Unknown elements and attributes are kept as inert.
4. **Styles.** CSS is parsed, rules are matched and ordered, and every element gets computed property values with inheritance applied.
5. **Render tree.** The styled document is resolved into an immutable tree in user units, independent of the output size: groups (transform, opacity, clip, mask, filter, blend), paths (geometry, fill, stroke), images and text-as-outlines. `use`, `symbol`, nested `svg`, `switch`, markers and pattern contents are instantiated here, under budgets. References are resolved here too: a cycle or a dangling reference is turned into the defined fallback, never into a loop. The tree is built once per document and shared by every render of it.
6. **Renderer.** Draws a rectangle of the output at a given scale from the render tree, using the existing engine (see [Mapping to existing operations](#mapping-to-existing-operations)).

Everything up to and including the render tree is safe, bounds-checked code and runs under the [limits](#limits). There is no `unsafe` outside the core's kernel layer.

### XML parser: our own

The parser is written for this package rather than built on `System.Xml`. The deciding reasons, in order:

- **Policy is the point.** `XmlReader` can be made safe (`DtdProcessing.Prohibit`, no resolver), but then it is a safe configuration of a general parser. Ours cannot be configured unsafely: any `<!DOCTYPE` fails, and no entity reference except the five predefined ones and numeric character references is honored, so a document can never grow while it is read. There is nothing to forget to switch off.
- **Limits at the source.** Element count, depth, attributes per element, name length and namespace declarations in scope are enforced as the parser goes, before the memory they guard is allocated. `XmlReaderSettings` offers only character-count caps, so the rest would have to be counted in a wrapper anyway.
- **Iterative and allocation-aware.** The tree is built with an explicit stack (a 300,000-deep document is parsed without growing the call stack when the depth limit is raised) with interned names.
- **Trimming and AOT.** No dependency on `System.Xml`'s reader stack, which is large for trimmed or WASM apps.
- **Cost.** Measured on a 15 MiB document of 300,000 elements (local smoke, shared host at load ≈4.5, .NET 10.0.400): our parser builds the whole tree in 145–180 ms and 137 MiB allocated, including the 30 MiB UTF-16 copy of the text; `XmlReader` with DTDs prohibited only *reads* the same document, without building a tree, in 105–180 ms and 28 MiB. The two are the same speed class.

Deliberate deviations from a full XML processor: no DTDs of any kind (so documents with an internal subset, a handful of which exist in the wild, are rejected rather than partly honored), and encodings other than UTF-8, UTF-16 with BOM, ASCII and ISO-8859-1 are refused with a clear message instead of being guessed.

### Detection

`SvgFormat.Matches` sniffs the first 4 KiB: an optional BOM and white space, then any mix of XML declaration, comments, processing instructions and a DOCTYPE, then a start tag whose local name is `svg`. Gzip data is recognized by its signature and the first 2 KiB of its content is sniffed the same way, so a gzip file that is not an SVG is not claimed. The extensions are `.svg` and `.svgz`; the MIME type is `image/svg+xml`.

### Natural size

`Identify` reads only the root element's start tag (within the first 1 MiB), so it stays cheap on huge files. The natural size in CSS pixels (96 per inch) is:

- `width` and `height` when both are absolute lengths (`px`, `pt`, `pc`, `mm`, `cm`, `in`; `em` and `ex` at 16 and 8 px);
- percentages, zero and negative values count as absent; a missing side is derived from the other through the `viewBox` aspect ratio; with neither, the `viewBox` size is used;
- the pixel size is the size rounded **up**, at least 1; a malformed or non-finite value fails with `ImageFormatException`.

A side that nothing fixes takes the extent of the painted content: the far edge of the bounding box of all shapes, stroke width included (resvg measures it the same way). That needs the whole document, so `Identify` then reads and builds it (once; the render tree is kept for the pixels), and a document that has neither a size nor anything to draw fails with `ImageFormatException`. Zero and negative sizes follow the same rule where resvg's reference rendering is empty; this renders the content instead, which is the more useful answer.

## Rendering at the requested size

Rasterizing an SVG and then scaling the bitmap throws away exactly what makes SVG worth having. The core already treats this kind of thing like JPEG shrink-on-load: a decoder advertises what it can do and the graph pushes the request down. SVG gets the same treatment.

**Core changes** (small and additive; made):

- `DecoderCapabilities.RenderAtSize` (a new flag): the decoder can produce its frame at any pixel size, not only 1/2, 1/4, 1/8. It is meant to be combined with `RandomAccess`, which the SVG decoder also sets: a 128×128 region is cheap to render because only the render-tree nodes that touch it are drawn.
- `DecodeRequest` gains `TargetWidth` and `TargetHeight` (0 = the natural size; the existing two-argument constructor is kept). The `Region` of the request is then expressed in target pixels.
- A graph rewrite beside the existing shrink-on-load one (`DecodeSourceNode.TryPushRenderSize`, tried first in the resampling step): when `Resize`/`Thumbnail` sits directly on an undisturbed decode of a `RenderAtSize` decoder, the decode is re-created at the final size and no resample node is added. `Fit.Stretch` and `Contain` map to the target directly; `Cover` renders at the covering size and its crop is now pushed down as a region of the decode; `Pad` renders at the contained size and pads. Upscaling follows the fit rules: `Resize` enlarges by default, `Thumbnail` only with `upscale: true`, and an enlarged render is sharper, not blurrier. Rotation, `Affine` and other geometry ops keep working on the natural-size raster and are not rewritten. The rewrite does not look through an orientation node (SVG has no orientation, so none is added).

Without the rewrite, loading still works: the image has its natural size and downstream operations act on that raster. Output is always sRGB 8-bit straight RGBA, with a transparent background unless the caller flattens it.

**Region rendering.** One entry point draws "this rectangle of the output at this scale". The scale maps user space to pixels through the root `viewBox` and `preserveAspectRatio` into the target size (non-uniform if the caller asks for a stretched size). Per request the renderer:

1. looks up the render-tree nodes whose device bounds meet the region (a bounding-box grid built once per tree), in paint order;
2. paints into a `Canvas` the size of the region, with the region offset folded into the transform;
3. writes rows to the sink.

Each request is cut into bands of full region width and at most 128 rows (fewer for very wide regions, so a band stays under 4 megapixels), drawn one after another into a band-sized canvas; layers are band-sized too, and the frame itself is never held in layers. The core asks for one 128×128 tile at a time, so a tile is one band. Filter regions larger than a tile get their own bounded region (see [Limits](#limits)). Results do not depend on how the frame is cut: shapes are rasterized by exact area per pixel, and a 256² region of a 4096² render equals the same pixels of the whole render (tested; differences are at most 1 level, in practice zero). The bounding-box grid is a 64×64 grid over the natural area; a node covering more than 16 cells is kept in a short list every query checks.

## Mapping to existing operations

| SVG feature | Built from |
|---|---|
| Fill of a shape | `Fill(brush, path, rule, transform, opacity)`; `shape-rendering: crispEdges`/`optimizeSpeed` → `antialias: false` |
| Stroke | `Draw(pen, path, ...)`: width, `StrokeCap`, `StrokeJoin` (miter, round, bevel; SVG 2 `miter-clip`/`arcs` fall back to miter/bevel as SVG 2 allows), miter limit, dashes. Non-scaling stroke: stroke the transformed path |
| Solid, linear, radial paint | `Brush.Solid`, `Brush.LinearGradient`, `Brush.RadialGradient` with `GradientRepetition` for `spreadMethod`. **New:** an affine `transform` on gradient brushes (for `gradientTransform` and bounding-box units), and a focal/two-point-conical radial gradient |
| Pattern | The tile is rendered to an image at device resolution and painted with `Brush.FromImage` through the pattern transform. **New:** smooth (bilinear) sampling for non-axis-aligned pattern transforms |
| `opacity`, `mix-blend-mode`, `isolation` on a group | The group is drawn to an offscreen `Canvas` layer, then `Composite(layer, opacity, blend: BlendMode, alpha: AlphaComposition)`. Groups with none of these, no clip, mask or filter are drawn straight into the parent (no layer). A single shape with only `opacity` uses the fill/stroke opacity parameter |
| `clip-path` | Entirely geometric: each child shape (with its own `clip-rule`, transform and nested `clip-path`) is resolved with `PathBoolean`, the children are united (`UnionAll`), a nested clip is intersected, and the result becomes the clip path of the fills and strokes it covers (`Fill(..., clip:)`, whose coverage is multiplied into the fill coverage). A clip in `objectBoundingBox` units on a group needs the group's box, so the group is drawn to a layer and composited through the region |
| `mask` | The mask content is rendered to a layer; its luminance or its alpha (`mask-type: alpha`) times the mask region becomes the coverage of the masked layer, through the new `Image.ApplyMask` (alpha or luminance times alpha, with an offset; see the changelog). A mask on a mask narrows the first one the same way |
| Viewport clipping (`overflow`, nested `svg`, `symbol`, markers) | A rectangle path through `Clip` |
| `image` | `Image.Load` on the decoded `data:` bytes (limits apply), shrunk with `Resize` when drawn small, then painted through a brush derived from the public `Brush` (bilinear or nearest sampling, edges extended) over the fitted rectangle, so `preserveAspectRatio`, transforms and rotation are one placement matrix; a nested SVG document is drawn into a layer with its own render tree and composited through the same rectangle |
| Text | `OneImage.Text`: `TextBuilder.GeneratePath` gives outlines that are filled, stroked, clipped like any path; `TextOptions.Path` carries `textPath`; rich runs carry per-run font, decoration and fill. Per-character `x`/`y`/`dx`/`dy`/`rotate` needs the shaped glyphs placed individually: **new in `OneImage.Text`:** a public way to get each cluster's outline and advance after shaping |
| Markers | Instantiated into the render tree as groups with a transform per vertex |
| Filters | See below |

### Filters

A `filter` becomes a small graph of primitives evaluated on the element's rendered layer. Evaluation happens in linear-light premultiplied float:

| Primitive | Maps to |
|---|---|
| `feGaussianBlur` | `GaussianBlur(sigma, sigmaY, edge: Constant)`; the input outside the filter region is transparent |
| `feOffset` | whole-pixel offsets: `Pad`/`Crop`; fractional offsets: `Affine` translation |
| `feFlood` | a fill of the primitive subregion |
| `feBlend` | `Composite(..., blend: BlendMode)` (normal, multiply, screen, darken, lighten and the SVG 2 modes) |
| `feComposite` | `AlphaComposition` for over/in/out/atop/xor. **New:** `arithmetic` (k1..k4 on premultiplied channels, clamped) |
| `feColorMatrix` | `ColorMatrix` (matrix, saturate, hueRotate, luminanceToAlpha as a matrix) on straight color, as the SVG specification defines |
| `feMerge` | repeated source-over `Composite` |
| `feDropShadow` | blur + offset + flood + merge |
| `feImage` | a `data:` image, or an element reference rendered like `use` |
| `feComponentTransfer` | per-channel identity/table/discrete/linear/gamma on straight color, evaluated directly on float |
| `feConvolveMatrix` | direct evaluation with target offset, bias, `preserveAlpha`, `edgeMode` duplicate/wrap/none |
| `feMorphology` | erode/dilate: separable min/max over a rectangular window (van Herk), so the time does not grow with the radius |
| `feTile` | tiles the input's subregion over its own |
| `feTurbulence` | the Perlin turbulence of the specification (fractal noise and turbulence, seeds, octaves, stitch tiles) |
| `feDisplacementMap` | nearest-pixel displacement by a channel of the second input |
| `feDiffuseLighting`, `feSpecularLighting` and the three light sources | direct evaluation of the specification's Phong terms over the alpha channel as a height map |
| CSS filter functions | direct: `blur()` → `GaussianBlur`; `brightness`, `contrast`, `grayscale`, `hue-rotate`, `invert`, `opacity`, `saturate`, `sepia` → `ColorMatrix`/tone ops; `drop-shadow()` as `feDropShadow` |

The filter region (`x`, `y`, `width`, `height` in `objectBoundingBox` or `userSpaceOnUse`, default −10%, −10%, 120%, 120%) and primitive subregions are resolved in device pixels. The region needed for a tile is the tile grown by the filter's margin (blur radius, offset, morphology radius, convolution order), then clipped to the filter region. Filters on rotated or skewed content run on the pixel grid scaled by the transform's axis scales and the result is mapped back by the remaining rotation or skew.

### Filters, part one

Files: `Render/SceneBuilder.Filters.cs` (the `filter` property and filter elements into `RFilter` stages), `Render/Filters/` (`FilterRenderer` plans and runs a layer's filters, `FilterOps` and `FilterBlur` are the float kernels, `FilterBuffer` holds premultiplied float32 in sRGB or linear light). The blur and the color conversions between 8-bit, sRGB float and linear float run on AVX kernels (see [Filter speed](#filter-speed)); everything else is plain safe C#.

- **Evaluation.** The layer's content is drawn into a pixel grid, converted to premultiplied float once, run through the primitives (each converts its inputs to its own color space, from tables with interpolation) and rounded to 8 bits once at the end. Blur follows the specification's three-box approximation from a standard deviation of 2 pixels, and a true kernel below that; edges are transparent. Offsets move whole pixels (the scaled distance is cut toward zero, as the reference renderer does).
- **Regions.** The region the output needs, grown by the sum of the primitives' reach and cut to the filter region, is all that is computed, so a region render equals the same pixels of a whole render (tested: at most one level). A filter chain is planned from its last stage back, each stage producing what the next reads. A wrapping convolution reads the whole filter region.
- **Rotated and skewed content.** The filters run on the element's own axes scaled to the output; the result is drawn back through the remaining rotation or skew with bilinear sampling. Offsets and deviations are therefore along the element's axes (as in browsers), where the reference renderer applies them along the output's axes; the `complex-transform` tests differ from their references only in edge pixels.
- **Subregions.** Defaults are the filter region. With `primitiveUnits=objectBoundingBox`, `feFlood`, `feOffset` and `feImage` measure their fractions in the object's box and every other primitive in the filter region, which is what the reference images show.
- **`feImage`.** An element reference is drawn like a `use` with its origin at the subregion's; a picture is scaled with a Mitchell filter on encoded values into the subregion by `preserveAspectRatio`. Recursion through `feImage` (an element that carries the filter it is drawn by) drops the `filter` of that element wherever it appears.
- **CSS functions** run in sRGB; their region is the content's box grown by what the function adds around it. `drop-shadow()` with no offset and no blur draws nothing extra.
### Filters, part two

Files: `Render/SceneBuilder.FilterEffects.cs` (the primitives below into the `RFilter` model), `Render/Filters/FilterEffects.cs` (their float kernels), `Render/Filters/FilterBlockCache.cs` (shared output blocks).

- **`feComponentTransfer`.** Un-premultiply, apply the last `feFunc*` of each channel, clamp to 0..1, premultiply by the new alpha. An unknown `type`, a `table`/`discrete` without values, or values that do not parse leave the channel as it is. The alpha function sees transparent pixels too, so a table on alpha can fill them.
- **`feMorphology`.** The window is (2r+1) pixels wide on each axis, where r is the radius times the grid scale rounded to nearest. A radius of zero on one axis leaves that axis alone; a negative or missing radius, three numbers, or both zero leave the input as it is. Outside the region is transparent, so an erosion wider than the region is empty (and costs nothing); a huge dilation needs the whole region and fails typed when the region is over the pixel limit.
- **`feTile`.** The input's tile rectangle is its subregion when it sets one, otherwise the union of the tile rectangles of what it reads (the specification's default subregion), or the filter region for a standard input. That rectangle is only for tiling: a primitive that sets no subregion still fills the whole filter region, as in the reference renderings. Tiling reads the whole region, so such a filter is computed as a single block.
- **`feTurbulence`.** The reference algorithm of the specification, with one deliberate difference: the lattice vectors are all normalized, none is drawn again. The Filter Effects text adds a redraw of zero and over-long vectors that no renderer implements, and the reference images follow the original SVG 1.1 generator (verified: alpha matches to within 0.3 of a level). The point of a pixel is the user-space position of its top-left corner, in the element's own space. `stitchTiles` takes its tile from the subregion scaled to pixels (not user units), which is what the references show; the test is marked undefined behavior in the suite. `numOctaves` is capped at 10 (the next octave adds less than 2⁻¹⁰), negative or non-integer values, a negative or malformed `baseFrequency` give a transparent result. The values are in the primitive's color space.
- **`feDisplacementMap`.** `scale` is multiplied by the grid scale; each pixel takes the source pixel at its position plus `scale × (channel − ½)`, nearest, and transparent beyond the work rectangle. The map is read as straight color in the primitive's space. The reach is half the scale.
- **Lighting.** The surface is the alpha channel; the normal uses the specification's Sobel kernels in the interior and its edge and corner kernels on the filter region's border. Diffuse and specular follow the specification's formulas, with the light color in the primitive's color space; specular alpha is the largest color channel and the color stays as computed (it is ≤ alpha, so it reads as premultiplied). `surfaceScale` stays in pixel units of the grid while a light's position and height scale with it (heights by the mean scale), which is what the reference images show. A light's coordinates are user units even with `primitiveUnits=objectBoundingBox`. No light source, or a specular exponent outside 1..128, gives a transparent result; a negative constant gives opaque black; a spot light's non-positive `specularExponent` is read as 1; `limitingConeAngle` cuts hard (no smoothing). `kernelUnitLength`, when given, samples the surface at that distance (bilinear, border extended) instead of the pixel grid.
- **`feConvolveMatrix` completions.** A kernel sum that is zero within rounding (0.1 × 8 − 0.8) divides by 1, not by the rounding residue. The remaining differences from the references are the two undefined-behavior tests (`bias=0.5`, `edgeMode=wrap` with a kernel wider than the target).
- **Margins, blocks and cost.** Component transfer, tile, turbulence and the lighting kernels read at most their own pixel (lighting: one kernel unit); morphology reads its window, displacement half its scale. A filter whose margin is wide (32 pixels or more: a large blur, a large dilation) over a region of at least 128 K pixels would make every 128-pixel tile of the output refilter several times its own area, so its output is computed in blocks of 512 or 1024 pixels on a grid anchored to the element, kept as 8-bit RGBA under one byte budget (96 MiB, least recently used evicted, given back when the scene is collected) and shared by every request and thread that touches them. A block is computed once, by the first request, and the others wait for it. Blocks are equal to the same pixels computed alone to within one level (tested, with concurrent readers and evictions). Rendering the same loaded image again therefore reads blocks instead of filtering again. Measured: 4096-pixel render of 400 shapes under a blur of σ 8: 1316 → 578 ms; σ 30: 9.8 s → 0.85 s (shared development host, load 7).
- **Limits.** Pixels of the filter region × cells of a convolution kernel are limited to 2³³ (typed `ImageLimitException` before any buffer exists); the kernel itself to 4096 cells (a larger or malformed one makes the primitive transparent). Everything else is bounded by `MaxFilterPixels` on what a request needs, and by constant-time or capped algorithms: morphology costs the same for any radius, turbulence octaves are capped, displacement and lighting reads are clamped to the work rectangle.
- **Not matched.** `filter/subregion-and-primitiveUnits=objectBoundingBox-1` and `-2` (0.8%: with a `width` fraction on a blur the reference cuts at half of the region's far edge, not at half of its width; unexplained, not copied); the 6 `complex-transform` tests (edge pixels: the filters run on the element's axes, the references along the output's; `feTile` 9.7% because tiles resample); `feColorMatrix/type=matrix-with-non-normalized-values` (0.27%, borderline); two undefined-behavior convolution tests. That makes 395 of 406 `filters` files (the 7 `color-adjust` tests with text pass since the text step).

Benchmarks (`SvgFilterBenchmarks`; BenchmarkDotNet 0.15.8 short job, `[MemoryDiagnoser]`; worker: AMD Ryzen 9 7950X3D, 32 logical cores, .NET SDK 10.0.402 / runtime 10.0.13, load average 3.0 before, 3.6 to 4.2 during and 4.1 after from other jobs; load + render unless noted, so nothing is read from cached blocks):

| row | before blocks | now | allocated before | now |
|---|---:|---:|---:|---:|
| blur, sigma 30 units, 4096 px | 11.2 s | 762 ms | 388 MB | 74 MB |
| blur, sigma 8 units, 4096 px | 1.69 s | 579 ms (error 440 ms) | | 74 MB |
| icon with blur, 256 px | 1.43 ms | 1.24 ms (blocks on every filter: 2.7 ms) | | 318 KB |
| turbulence + lighting, 1024 px | not drawn | 52.8 ms | | 6.9 MB |
| Svg.Skia: blur 4096 px / turbulence + lighting 1024 px | | 218 ms / 55 ms | | |

A filtered group is read in place and its tiles go back to the pool right after it is composited, like the other layers.

### Filter speed

The blur and the pixel conversions are vector code with scalar references, and the result is bit for bit what the scalar code gives (rendered documents hash the same whole and in regions, and the resvg collection scores the same file by file).

- **Blur** (`Render/Filters/FilterBlur.cs`, kernels in core `Kernels/BlurKernel.cs`). The sums are doubles with the same operations in the same order as the scalar reference `FilterBlurReference`, and each box rounds to float, so nothing changes numerically. What changed is the layout and the instruction mix: a line (a row, or a column read in place with a stride) goes through all three boxes in line-sized scratch instead of three trips over the whole buffer; the four channels of a pixel are one 256-bit vector of doubles; four lines run together, because the add-then-subtract of one running sum is a latency chain; lines that are entirely zero are skipped (their blur is zero); and buffers of 128 K pixels or more are cut into row groups or column bands that run in parallel at the per-call degree. The blur is in place, so the second full-size float buffer is gone. Single-threaded, a 176 x 176 tile takes 0.6 ns per pixel per box horizontally and 0.8 vertically (scalar: 6.3 and 5.3). Without AVX the scalar reference runs.
- **Conversions** (`Kernels/FilterColorKernel.cs`, AVX2). Two pixels per step: 8-bit to premultiplied float, sRGB to linear and back with the 4096-step table (gathers) and the scalar code's clamps, and premultiplied float to 8-bit. A pair holding a NaN where the scalar `Math.Min`/`Math.Clamp` rules differ from the vector ones goes through the scalar code. A source layer stays 8-bit until a primitive reads it, so a linear-light primitive reads it straight into linear float (one rounding sequence, the same as expanding to sRGB float and converting). Only the part of the result that lands in the target is converted back to 8 bits. Measured on one thread: 8-bit to linear 3.9 ns per pixel (scalar 9.5), sRGB to linear 3.4 (7.1), linear to 8-bit 5.5 (12.2).
- **Not changed.** The 128-pixel tiles still refilter their margin when the margin is under 32 pixels (block output is quantized to 8 bits and equal only to within a level, so it cannot replace tile output byte for byte); drawing the layer's content into the work area is the largest remaining cost of the 1024 px rows.

Measured with `SvgFilterBenchmarks` (BenchmarkDotNet 0.15.8 short job, `[MemoryDiagnoser]`; worker: AMD Ryzen 9 7950X3D, 32 logical cores, .NET SDK 10.0.402 / runtime 10.0.13; load average 1.4 before and 2.8 after the final run; the "before" run was at 4.7 to 5.3, other jobs on the worker). Before and after use the same benchmark: the OneImage rows copy into a pixel buffer kept between iterations, because the earlier rows allocated and filled a fresh 4 MB `ToArray` result per iteration, which was the source of their full (Gen2) collections. No filter row has a Gen2 collection now. "Before" is the commit this section was written on top of.

| row (load + render unless noted) | before | now | Svg.Skia (render) |
|---|---:|---:|---:|
| blur, sigma 8 units, 1024 px (loaded document, render only) | 33.5 ms (error 45) | 10.7 ms | 11.9 ms |
| blur, sigma 8 units, 1024 px | 30.1 ms | 10.8 ms | |
| drop shadow, 1024 px | 44.1 ms | 17.8 ms | 58.4 ms |
| drop shadow, 1024 px (loaded document, render only) | 4.8 ms | 3.9 ms | |
| icon with blur, 256 px | 1.18 ms | 0.45 ms | 0.49 ms |
| blur, sigma 8 units, 4096 px | 491 ms | 156 ms | 210 ms |
| blur, sigma 30 units, 4096 px | 678 ms (error 98) | 206 ms (error 62) | 210 ms |
| turbulence + lighting, 1024 px | 51.5 ms | 50.1 ms | 53.2 ms |

Allocation per iteration: 1024 px blur 2.0 MB (was 2.1), 4096 px blur 7.1 MB (was 6.8), turbulence + lighting unchanged. The sigma 30 row's spread is wide (other jobs on the worker); it sits at Svg.Skia's figure, not clearly under it.

## Color

- The document and output are **sRGB**. Colors parse to sRGB (`icc-color()` fallbacks are ignored). Painting, compositing, blending, opacity and gradient interpolation happen on encoded sRGB values in premultiplied float — SVG's default `color-interpolation: sRGB` — and are rounded to 8 bits when a layer is stored.
- `color-interpolation: linearRGB` on gradients is honored through the `linear` option the brushes already have. On a `mask` it is ignored: the luminance is always computed on the stored sRGB values, as browsers and the reference renderer do (`Image.ApplyMask` has a `linear` option for other callers).
- **Filters** follow `color-interpolation-filters`, whose default is `linearRGB`. A filter's layer is converted to linear sRGB once through the color engine's built-in linear profile, held as premultiplied float32 for the whole primitive graph, and converted back once at the end, so a chain of primitives does not round to 8 bits between steps. A primitive that asks for `sRGB` converts its inputs and outputs at that boundary. Primitives that must see straight color (`feColorMatrix`, `feComponentTransfer`) un-premultiply for the operation, as specified.
- Raster images embedded as `data:` URIs keep their own color profile and are converted to sRGB when drawn. Output frames carry no profile.

## Limits

Every bound is checked before the allocation or work it guards; breaking one raises `ImageLimitException`. `SvgLimits` (set per registration: `new SvgFormat(new SvgLimits { ... })`) holds these, alongside the ordinary `ImageLimits` of the load call (pixels per frame, dimension, expansion ratio), which also bound the output size.

| Limit | Default | Where it applies |
|---|---:|---|
| Document bytes (file, and gzip output) | 32 MiB | source — implemented |
| Elements | 500,000 | XML parser — implemented |
| Element nesting depth | 256 | XML parser — implemented |
| Attributes per element | 512 | XML parser — implemented |
| Name length | 256 characters | XML parser — implemented |
| Namespace declarations in scope | 256 | XML parser — implemented |
| Root start-tag scan for `Identify` (`MaxRootTagScanBytes`) | 1 MiB | source — implemented: a root start tag that does not end inside the window fails with `ImageLimitException` |
| Gzip expansion ratio | `ImageLimits.MaxExpansionRatio` (1100) | source — implemented, checked while inflating (deflate cannot exceed about 1030:1 on its own, so the document cap is what stops a default-limit bomb) |
| CSS text (all `<style>` and `style` attributes; `MaxCssBytes`, in characters) | 2 MiB | styles — implemented |
| CSS rules (`MaxCssRules`, one per selector of a list) | 20,000 | styles — implemented |
| Selector matching work (`MaxCssMatchSteps`) | 100 million steps | styles — implemented: every simple-selector test and every ancestor or sibling move is a step; rules are indexed by id, class and type to keep honest documents far below this. A hostile sheet (a descendant chain that cannot match, against 200 nested groups and 100,000 shapes) is refused after about 0.4 s and 90 MiB allocated (local smoke, loaded host) |
| Path segments in a document (`MaxPathSegments`: moves, lines, curves and arcs of `path` data and the points of `polyline`/`polygon`, nested images included) | 4 million | document, while path data is read — implemented: a path of 5.7 million segments (30 MiB of data) is refused after about 0.5 s and 360 MiB allocated; 600,000 points draw in 0.3 s and 450 MiB. A path attribute is bounded by the document cap (32 MiB), whitespace and all |
| Instantiated render nodes (`use`, `symbol`, markers, pattern and mask contents, clip children, text glyphs) | 500,000 (`MaxInstances`) | render tree — implemented for elements reached through `use`, `symbol` and nested content: every instantiated element is counted as it is created, so a fan-out that multiplies stops at the budget (500,000 fan-out copies cost about 0.4 s and 75 MiB before the refusal, measured), and the refusal is remembered. Memoizing a `use`'s size is not done. A clip child counts as 16 instances because resolving it is a polygon boolean operation (a clip of 1,000 shapes in bounding-box units, used by 800 elements of different sizes, is refused after about 3.5 s); a clip resolved again for the same transform and box is reused from a cache of 4,096 entries |
| Reference depth (`use`→`use`, `clip-path`→`clip-path`, `mask`→`mask`, `pattern`/`marker`/`filter` chains) | 32 (`MaxReferenceDepth`) | render tree; `use` chains implemented; cycles are detected separately and draw nothing |
| Nested isolated layers (groups with `opacity`, `mask`, or a `clip-path` that needs its box), and masks chained through `mask` on a mask | 32 (`MaxLayerDepth`) | render tree and renderer — implemented; deeper nesting fails closed with `ImageLimitException` before anything is drawn |
| Nested image documents (`MaxImageDepth`) | 3 levels | `image` of an SVG data URI; they share the budgets above |
| `data:` payload per image / per document (`MaxImageBytes`, `MaxTotalImageBytes`) | 16 MiB / 64 MiB | render tree; decoded under `ImageLimits`; the encoded length is checked before the payload is decoded |
| Text characters (`MaxTextCharacters`) | 1 million | render tree — implemented: counted as text is read and again for every copy `use` instantiates; past it the load fails with `ImageLimitException` before any layout |
| Pattern tile size | 16 megapixels (`MaxPatternTilePixels`), 16,384 pixels per side | renderer, after scaling to device pixels — implemented: a larger tile is drawn at a coarser resolution, not refused; each level of pattern nesting gets half of its parent's share. The side cap exists because a pattern skewed by 90 degrees stretches one axis by 10^16: a 1 × 16-million-pixel tile fits the area budget but cost over a minute to draw |
| Offscreen layer | one band: region width × at most 128 rows, under 4 megapixels | renderer — implemented |
| Filter region | 64 megapixels (`MaxFilterPixels`) | renderer, in device pixels after clipping to what the output needs plus the margin the primitives read — implemented: a filter that needs more fails with `ImageLimitException` before any buffer exists |
| Filter primitives per filter / filter nesting | 256 / 4 (`MaxFilterPrimitives`, `MaxFilterDepth`) | render tree — implemented; nesting counts filters reached through `feImage` |
| Convolution kernel cells, convolution work, turbulence octaves | 4096; region pixels × cells ≤ 2³³; 10 | render tree and renderer — implemented: an invalid or oversized kernel makes the primitive transparent, too much work fails with `ImageLimitException`, octaves are clamped. Morphology costs the same for any radius |
| Filter output blocks kept | 96 MiB, process-wide | `FilterBlockCache` — implemented; least recently used blocks are dropped and recomputed |

Hardening runs (libFuzzer with SharpFuzz, seeded from every committed SVG under 16 KiB): `svg-xml` 7.2 million executions in 10 minutes, `svg-render` 1.1 million in 10 minutes, `svg` (path data) 3.7 million in 10 minutes, `font` 193,000 in 5 minutes, with peak resident memory of 52, 142, 29 and 204 MB. They found three problems, each fixed with a repro under `tests/testdata/svg`: a font size beyond float range escaped as `ArgumentOutOfRangeException` (two documents), and a pattern skewed by 90 degrees took 69 seconds because its tile was one pixel wide and 16 million tall (now capped at 16,384 pixels per side; 12 ms).

Every row above has a hostile-document test (`SvgLimitsTests`) that checks the typed refusal and bounds its time and allocation; the measured costs are in the test log (`ONEIMAGE_LIMIT_LOG=<file>` appends one line per case). Refusals are cheap, from well under a millisecond (nesting, names, attributes) to about half a second and a few hundred MiB for the limits that only trip after the work is read (path segments, CSS match steps, instances). Glyph outlines are the heaviest instances, about 2.4 KB each, so text fanned out through `use` costs the most: the refusal at 500,000 glyphs allocates about 1.2 GiB over 1.4 s (peak working set about 300 MiB above the baseline).

Cancellation is observed at least once per tile and per filter primitive. Rendering errors that are not limits (a malformed path, an unusable gradient) follow the SVG error-handling rules — the element is not rendered — and never raise.

Lazy behavior follows the rest of the library: `Identify` and `Load` read the root start tag (the whole document when the root gives no size); the rest of the document is read when pixels are first pulled, so a malformed body fails then with `ImageFormatException`. The render tree is built once per loaded image, under a lock, from the first request; a document that fails (a malformed body, a limit) fails the same way on every later request without redoing the work. `Load(..., validate: true)` decodes the frame once at its natural size, which parses and builds the document.

### What is drawn so far

- **Document and style.** A typed element tree of the SVG-namespace elements (non-SVG elements and their content are skipped); the cascade described below; `inherit`, `initial` and `unset`; inherited and non-inherited properties computed per element; `currentColor` kept as a keyword and resolved against the element's own `color`. An invalid value is ignored as if unwritten, except a negative `stroke-width`, which drops the stroke.
- **Style sheets.** Every `<style>` element whose `type` is empty or `text/css` (CDATA included, anywhere in the document, in document order) is parsed into rules. Supported selectors: type, `*`, `#id`, `.class`, attribute tests (`[a]`, `=`, `~=`, `|=`, `^=`, `$=`, `*=`, with the `i` flag), the descendant, `>`, `+` and `~` combinators, `:first-child`, `:last-child`, `:only-child`, `:nth-child()`, `:nth-last-child()`, the `-of-type` forms, `:empty`, `:root`, `:not()`, `:is()` and `:where()` over compound selectors; `:hover`, `:link` and the like never match in a static rendering. A selector this engine does not know (pseudo-elements, namespaces, other pseudo-classes) drops its rule, as CSS says. At-rules (`@import`, `@media`, `@font-face`, ...) are skipped without being read. Selectors see the document's own attributes, presentation attributes included, and count only SVG elements as siblings. Per element the declared properties are applied in this order, later over earlier: presentation attributes; matching rules by specificity (ids, then classes, attributes and pseudo-classes, then types) and source order; the `style` attribute; then the `!important` declarations in that same order. Rules apply to the document, so `use` instances copy the cascaded values of the element they reference and inherit from the `use`. Matching is right to left with backtracking on explicit arrays, no recursion, under a step budget (see [Limits](#limits)).
- **Conditional processing.** An element with `systemLanguage`, `requiredFeatures` or `requiredExtensions` renders only when all hold. A language tag in `systemLanguage` matches a configured language (`new SvgFormat(languages: ...)`, default `en`) when they are equal or the language is a prefix of the tag before a hyphen, ignoring case (`en` matches `en-GB`); an empty list is false. Extensions are not supported, so any `requiredExtensions` is false; `requiredFeatures` counts as true unless empty. `switch` renders the first child that can be rendered (a shape, `g`, `a`, `svg`, `use`, `image`, `text`, `switch`, `foreignObject`) whose conditions hold, even when `display: none` hides it; unknown elements and comments are skipped; the `switch` element itself takes part in styling and transforms like a group. The conditions also apply to the root `svg`. Conditions on `defs`, gradients and `clipPath` have no effect on how they are referenced.
- **Images.** `image` draws an `href`/`xlink:href` that is a `data:` URI (base64, white space allowed, or percent-encoded): any raster format the library reads, or an SVG document (plain or gzip), told apart by content, not by the media type. A missing or `auto` `width`/`height` comes from the picture (both from its size, one through its aspect ratio); `preserveAspectRatio` places it, `slice` cuts it to the viewport; `transform`, `opacity`, `visibility` and `display` apply; `image-rendering: optimizeSpeed`, `pixelated` and `crisp-edges` sample the nearest pixel. A raster is shrunk once per scale to the size it is drawn at (the library's default filter, on encoded values), then sampled bilinearly through the placement, with the border pixels extended so the fill's anti-aliasing gives the edge; a color profile is converted to sRGB. A picture is read once per `image` element however many times `use` instantiates it, and the shrunk pixels are kept for the life of the loaded image. A nested SVG is drawn as vectors, to its own bounds, through a layer. A picture that cannot be read is dangling and draws nothing; an external `href` is never fetched.
- **`transform-origin`.** One to three values (keywords, lengths, percentages); the reference box is the nearest `viewBox`. On the root, percentages refer to the viewport and the default stays the center. `transform-box` is not supported (the box is always the view box). The origin also applies to the `transform` of a `clipPath`, to `patternTransform` (reference box: the view box, as for drawn elements) and to `gradientTransform`: with `userSpaceOnUse` units the view box, with `objectBoundingBox` units the unit square, so `center` is 0.5 and a plain number is a fraction of the box.
- **Colors.** Keywords, `transparent`, `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`/`rgba()`, `hsl()`/`hsla()` and `hwb()` in the comma and the space syntax, with percentages, angle units and `/` alpha. Integers and percentages may not be mixed among the three channels of `rgb()`.
- **Geometry.** `rect` (with `rx`/`ry` and the auto rule), `circle`, `ellipse` (SVG 2 `auto` radii), `line`, `polyline`, `polygon`, and `path` with the whole grammar. Path data is read by the package's own parser, which draws what precedes the first error (the core's parser rejects the whole path) and reads arc flags without separators. A zero-length subpath draws a dot under round and square caps. Numbers beyond ±10¹⁵ are clamped, so hostile coordinates cannot reach the drawing code as infinities.
- **Paint.** `fill`, `stroke` with solid colors, `fill-rule`, `fill-opacity`, `stroke-opacity`, `opacity`; `shape-rendering` crisp values turn anti-aliasing off. `visibility` and `display`. `paint-order` orders fill, stroke and markers.
- **Paint servers.** `linearGradient` and `radialGradient` with `gradientUnits`, `gradientTransform`, `spreadMethod`, stops (offsets clamped and kept non-decreasing, `stop-color` with `currentColor` and `inherit`, `stop-opacity`), `href` inheritance (attributes only from a gradient of the same kind, stops from the first gradient in the chain that has some, loops end the chain) and the SVG 2 focal point and `fr` as a two-circle gradient (a focal circle outside the end circle is a cone, painted only inside it; equal concentric circles paint their disc in the first color). Stops interpolate as straight, non-premultiplied colors. A gradient with one stop is that color, with no usable geometry (both ends equal, no radius) the last stop, with no stops or a box without area (bounding-box units) it is unusable. `pattern` with `patternUnits`, `patternContentUnits`, `viewBox` and `preserveAspectRatio`, `patternTransform`, `href` inheritance of attributes and children, nested patterns. The pattern content is built once per pattern element, the tile is drawn at the resolution it is seen at (rounded up to whole pixels, then stretched to the exact period) and repeated through an image brush: nearest sampling when the pattern is not turned or skewed, bilinear otherwise. A tile past `SvgLimits.MaxPatternTilePixels` is drawn at a coarser resolution. An unusable server, or a reference that is dangling or points at another kind of element, uses its fallback (`url(#id) color`) or paints nothing; a pattern whose own content is painted by itself, directly or in a loop, paints nothing there. `context-fill` and `context-stroke` take the paints of the nearest `use` (gradients and patterns included, with the box of what the `use` drew), and nothing without one. `color-interpolation: linearRGB` on gradients is not supported.
- **Stroke.** Width, caps, joins (`miter-clip` and `arcs` fall back to miter), miter limit, dash array (an odd count is repeated) and offset, through the core stroker; the stroke outline is made once per scale and reused by every tile.
- **Structure.** Transforms (attribute and style property), `viewBox` and `preserveAspectRatio` on the root and nested `svg`, `g`, `a`, `defs` (not drawn), `use` (with `x`, `y`, `width`, `height`), `symbol` (viewBox, size from the `use`, its own `transform` ignored as in SVG 1.1), nested `svg` with the default clip (`overflow: visible` or `auto` removes it). The root's `transform` takes its origin at the center of the viewport, and the root's `background-color` paints the whole output.
- **Clipping.** `clip-path: url(#id)` on shapes, groups, `use`, nested `svg` and the root. `clipPath` children are shapes (`rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`, `path`) and `use` that points straight at one; `g`, `symbol`, `image`, `switch` and text (until text exists) contribute nothing, as do children with `display: none` or `visibility: hidden`. Each child has its own `transform`, `clip-rule` (also inherited from the `clipPath`) and nested `clip-path`; the `clipPath` has `clipPathUnits`, `transform` and its own `clip-path` (applied in the referencing element's space). A non-invertible transform, an empty `clipPath`, or `objectBoundingBox` units on an element with no area clip everything; a dangling reference, a reference to something that is not a `clipPath`, and a non-`url()` value such as `circle()` are ignored. A reference that leads back to a clip being resolved is ignored, which breaks cycles. A clip is resolved in scene space with the polygon boolean engine (flattened at 0.02 px), so a user-space clip costs nothing per group, its children inherit it, and shapes are culled against its bounds. `overflow` on nested `svg` and `symbol` clips the viewport (`visible` and `auto` do not; `inherit` takes the parent's); the legacy `clip` property is not used, as in the reference renderer.
- **Masking.** `mask: url(#id)` on shapes, groups, `use`, nested `svg` and the root: `maskUnits` (default `objectBoundingBox`, region -10%/-10%/120%/120%), `maskContentUnits`, `x`/`y`/`width`/`height` with percentages, `mask-type` (`luminance` or `alpha`, as an attribute or in `style`; an invalid value is luminance), a `mask` on the mask element, masks inside mask content, and masks on elements with a clip or opacity. The masked element is drawn to a layer as large as its bounds inside the band, the mask content to another, and the layer is composited through the mask by `Image.ApplyMask`. A mask whose content is empty, or whose units need the box of an element with no area, hides the element; a dangling or wrongly typed reference is ignored. Cycles of masks are broken the way the reference does it: masks are visited in document order and the `mask` link that closes a cycle is dropped everywhere. `color-interpolation` is ignored.
- **Markers.** `marker-start`, `marker-mid`, `marker-end` and the `marker` shorthand (a style declaration only, as the specification says) on `path`, `line`, `polyline`, `polygon` and, as SVG 2 has it, `rect`, `circle` and `ellipse`. `markerUnits` (an invalid value is `strokeWidth`), `markerWidth`/`markerHeight` (3 by default; zero or negative draws nothing), `refX`/`refY`, `viewBox` with `preserveAspectRatio`, `overflow` (hidden by default; `visible`, `auto` and an `inherit` that finds nothing leave the marker unclipped), and `orient` as `auto`, `auto-start-reverse` or an angle in `deg`, `grad`, `rad` or `turn`. A path's vertices are the start of each subpath and the end of each segment; a closepath is a straight segment back to the start, and a second closepath in a row adds nothing. The direction at a vertex is the average of the outgoing and incoming directions (the short way round; exactly opposite ones give their plain average), at the first vertex of a subpath only the outgoing one and at its last only the incoming one, even when the subpath is closed. A curve's direction at an end comes from its first or last control point that differs from the end, a zero-length segment takes its neighbor's direction, and an arc is split into pieces of at most a quarter turn whose joints are vertices too. The marker's content takes its style from the marker's own ancestors, not from the shape; `context-fill` and `context-stroke` are the shape's paints. A marker that is already being drawn draws nothing inside itself, and a `use` chain inside a marker starts afresh. The shape and its markers are one group when the shape has `opacity`, a blend mode, a clip or a mask; `paint-order` puts the markers before, between or after the fill and stroke. Every marker instance counts against `MaxInstances`.
- **Blending and isolation.** `mix-blend-mode` (every CSS blend mode; `plus-lighter` is the Porter-Duff plus operator, `AlphaComposition.Plus`: premultiplied colors and alpha add and clamp at 1) and `isolation: isolate` are style properties, not presentation attributes, so an attribute is ignored. An element with a blend mode, and an isolated group, is drawn to a layer and composited once: inside an isolated group blends only see the group. Layers cover only the part of the band their content can touch.
- **Layers.** A group with `opacity` below 1, and a shape with both fill and stroke and `opacity` below 1, is drawn to a band-sized layer and composited once, so overlaps inside it do not show through.
- **Units.** `px`, `pt`, `pc`, `mm`, `cm`, `in`, `em`, `ex` (half the font size), `rem` and `%` of the viewport (width, height, or the diagonal measure for lengths that follow neither). The default font size is 16.
- **Filters (parts one and two).** The `filter` property with `url(#id)` references and CSS filter functions in any mix and order. Filter elements: `filterUnits`, `primitiveUnits`, region defaults of -10% / 120%, `href` inheritance of attributes and primitives (loops and non-filter targets end the chain), primitive subregions, `in`/`in2`/`result` (an input that names nothing is the previous result, or the source graphic for the first primitive; `BackgroundImage`, `BackgroundAlpha`, `FillPaint` and `StrokePaint` are read as the source graphic), `color-interpolation-filters` per primitive. Primitives: `feGaussianBlur`, `feOffset`, `feFlood`, `feMerge`, `feBlend` (all 16 modes), `feComposite` (including `arithmetic` and `lighter`), `feColorMatrix`, `feConvolveMatrix`, `feImage` (a `data:` picture or an element drawn like `use`), `feDropShadow`; `feComponentTransfer`, `feMorphology`, `feTile`, `feTurbulence`, `feDisplacementMap`, `feDiffuseLighting` and `feSpecularLighting` with `feDistantLight`, `fePointLight` and `feSpotLight` (part two, under [Filters, part two](#filters-part-two)). Functions: `blur`, `drop-shadow`, `brightness`, `contrast`, `grayscale`, `hue-rotate`, `invert`, `opacity`, `saturate`, `sepia`; an invalid function drops the whole declaration, an unresolved `url()` among others is skipped, and a list of only unresolved references removes the element. A filter runs before clip, mask and opacity; a clip in force outside a filtered element applies to the filter's result. Details are under [Filters, part one](#filters-part-one).
- **References** (`use`) are instantiated under budgets (see [Limits](#limits)); a `use` of itself, of an ancestor, or around a cycle draws nothing; a dangling or external reference draws nothing. A `symbol` instantiated with a size gives percentage lengths inside it that size.

Seams for the following steps: style sheets add their declarations between the presentation attributes and the `style` attribute in `SvgDocument.Build`; paint servers are in; markers are in (`SceneBuilder.Markers.cs`: a shape with markers pushes one frame that instantiates them one at a time, so a marker inside itself is seen as active); text outlines resolve paints through `SceneBuilder.ResolvePaint` (it takes the geometry for the bounding box); filters wrap or add `RNode`s where `EmitShape` and `Open` create them (the frame already carries an inherited clip path in scene space and decides when a group needs a layer; `Decorate` adds an element's `clip-path` and `mask`, and `OverflowClip` is the viewport clip that markers and patterns reuse); text becomes `RPath`s from outlines. A node that is not a path (`RImage`) is drawn by `SceneRenderer.DrawNode` through `ImageRenderer`.

### What is drawn: text

Text is laid out by the SVG package and shaped and outlined by `OneImage.Text`; the glyph outlines go through the same fill, stroke, clip and paint-server code as any path.

- **Elements.** `text`, `tspan`, `a`, `tref` (it may point at any SVG element and takes its character data) and `textPath` (to a `path` or any basic shape; `startOffset` in user units or percent of the path length, `side=right`, subpaths followed in order, glyphs whose middle falls off the path are dropped). Glyphs on a path sit with their middle on the path and turn with the chord they span.
- **Positions.** Per-character `x`, `y`, `dx`, `dy` and `rotate` lists, each applying to the characters of its element in order and nearest-element-wins; an absolute position starts a new chunk. `text-anchor` anchors a chunk by its advance; `textLength` with `lengthAdjust` spacing or spacingAndGlyphs per chunk; `letter-spacing` and `word-spacing` (lengths, `em`, and percentages of the root viewport width, which is how the test-suite reference reads them).
- **Fonts.** `SvgFontOptions` (passed as `new SvgFormat(limits, languages, fonts)`) holds fonts added from files, bytes or streams, generic-family mappings, fallback families and whether installed fonts are scanned (default on). `font-family` lists, `font-weight`, `font-style`, `font-stretch`, the `font` shorthand and `font-variant: small-caps` (the font's `smcp` glyphs, or capitals at 0.8 of the size). Characters no listed font has fall back to the fallback families, then to the installed fonts. Without any usable font a text element draws nothing and the rest of the document renders. The initial and `medium` font size is `SvgFontOptions.DefaultFontSize`, 16 px by default as in browsers; the named sizes step by 1.2 around it (`xx-small` is medium / 1.2³, `xx-large` medium × 1.2³), and `em`, percentages and `rem` that reach the top of the document are relative to it.
- **Shaping.** Ligatures, kerning (`font-kerning`, and the SVG 1.1 `kerning="0"`), complex scripts and bidirectional text (`direction`, `unicode-bidi` embed and override) come from `OneImage.Text`. Kerning also applies across a `tspan` boundary between runs of one family that differ in size or weight, using the first run's font.
- **Baselines.** `dominant-baseline`, `alignment-baseline`, `baseline-shift` (including `super` and `sub` from the font's own metrics).
- **Painting.** Fill and stroke with paint servers (a gradient or pattern in `objectBoundingBox` units measures the line box: each glyph's advance wide and the font's ascent plus descent tall, not the ink), `context-fill`/`context-stroke`, `paint-order`, opacity, masks and filters on the text element. Underline, overline and line-through are drawn as bars with the font's metrics and share one bar across a run. `text-rendering: optimizeSpeed` on the `text` element draws its glyphs without anti-aliasing. Text inside a `clipPath` clips like geometry.
- **Whitespace.** `xml:space` and `white-space`; collapsing runs across `tspan` boundaries, and a preserved space ends a collapsing run.

- **Vertical text.** `writing-mode` `tb`, `tb-rl`, `vertical-rl`, `vertical-lr` (and `sideways-*`) on the `text` element (on a `tspan` it has no effect). The characters run down the column from `y`, with `x` the middle line of the column. As in the mixed vertical mode of `OneImage.Text`, characters with Vertical_Orientation U or Tu (ideographs, kana, Hangul) stand upright and are shaped with the font's `vert` forms; the rest (Latin, digits, Arabic) lie rotated a quarter turn clockwise, with the font's ascent-plus-descent box centered on the column line. `dx` moves across the column, `dy` along it, `text-anchor` anchors the extent along `y`, and right-to-left runs are reordered down the column. `glyph-orientation-vertical` and `text-orientation` are not used (the reference ignores them too), and all four vertical modes lay out the same way (each column starts at its own `x`). Underlines are bars on the rotated baseline, to its left. Text on a path ignores the writing mode.
- **Text style through `use`.** A `use` element's ancestors are the ancestors of the text it instantiates for everything that inherits (`font-family`, `text-anchor`, `writing-mode`, `direction`, `letter-spacing`, decorations); the surroundings of the referenced element itself no longer count.
- **Opacity on spans.** `opacity` on a `tspan`, `textPath` or `a` fades the characters of that element (and its descendants) as one group, with nested groups inside it. The reference renderer ignores it (`tspan/with-opacity`), so that one test differs on purpose.
- **Ligatures and positions.** A character with its own `dx` or `dy`, or a `rotate` that differs from the previous character's, ends the run the shaper sees, so it cannot be held inside a ligature; combining marks and joiners stay with their base.

Known gaps: variable-font axes, bitmap and COLRv1 glyphs, and vertical `text-orientation`.

## Testing against resvg's suite

Correctness is measured against the public test collection of the resvg project: its SVG files and reference PNGs, only. The committed subset lives in `tests/testdata/resvg` (MIT OR Apache-2.0, notice in `THIRD-PARTY-NOTICES.md`); `tools/fetch-resvg-tests.sh` downloads the full collection with its fonts at a pinned commit, and `ONEIMAGE_RESVG_TESTS` points the tests at it.

`ResvgConformance` loads every SVG, renders it at its reference PNG's size (300 px wide for the 200×200 test files) through the same `Resize` call a thumbnail request makes, and compares it to the reference with a stated tolerance: both images are composited over white, a pixel differs when any channel is off by more than 24 of 255, and a test passes when at most 0.25% of its pixels differ. The tolerance absorbs anti-aliasing differences between rasterizers, not wrong shapes or colors. It prints a pass-rate table per category (set `ONEIMAGE_SVG_REPORT` to also write it to a file), with the exclusions listed:

- DTD or entity declarations (refused by policy);
- external file references (refused by policy);
- scripts, animation, `foreignObject`, `@import` (not supported).

Behavior is specified from the SVG and CSS specifications and checked against the reference images; no renderer's source is consulted.

### Results

1,748 files (42 excluded), the full collection with the suite's fonts, run on the render-at-size path. "Before" is the skeleton, which could not draw.

| category | rendered | pass before | pass now | rate now |
|---|---:|---:|---:|---:|
| shapes | 133 | 0 | 128 | 96.2% |
| painting | 307 | 0 | 277 | 90.2% |
| structure | 230 | 0 | 197 | 85.7% |
| paint-servers | 154 | 0 | 146 | 94.8% |
| masking | 93 | 0 | 91 | 97.8% |
| filters | 406 | 0 | 395 | 97.3% |
| text | 383 | 0 | 279 | 72.8% |
| **all** | 1,706 | 0 | 1,513 | 88.7% |

Masking: 87 of 93. The 6 failures: `clip/simple-case` (an `image` under a clip, 2.6% of pixels) and 5 that clip with text. All 46 `clipPath` tests that do not use text, the `clip-rule` test and all 40 `mask` tests pass.

The filter and text passes are tests whose result happens not to need the feature (an empty result, or an element that is skipped). Subcategories in scope for this step:

| subcategory | pass |
|---|---:|
| shapes: rect, circle, ellipse, line, polyline, polygon | 75 / 76 |
| shapes: path | 53 / 57 |
| painting: stroke-width, stroke-linecap, stroke-linejoin, stroke-miterlimit, fill-rule | 25 / 26 |
| painting: stroke (solid paint) | 13 / 20 |
| painting: stroke-dasharray, stroke-dashoffset | 10 / 23 |
| painting: fill, fill-opacity, stroke-opacity, opacity, color | 74 / 91 |
| painting: display, visibility, shape-rendering, overflow | 10 / 29 |
| structure: svg (viewBox, preserveAspectRatio, nested svg, sizes) | 34 / 43 |
| structure: use, symbol, g, defs | 59 / 67 |
| structure: transform, style-attribute | 17 / 23 |

Of the 85 failures in these subcategories (and `fill`/`opacity`/`display` and so on), 27 are anti-aliasing borderlines: 0.25–1% of the pixels differ by more than the 24-level tolerance, all on thin strokes, dash ends and edges where two shapes share an edge. This renderer rasterizes by exact area; the reference comes from a supersampling rasterizer that treats strokes thinner than a pixel as hairlines, so edge pixels differ by 10–15% coverage. On a 1000×800 drawing of about 3,000 shapes (the benchmark document), 0.39% of the pixels differ by more than 24 levels from resvg 0.48.1 run as an external program (mean difference 1.4 levels). The rest, by reason:

- later steps: `fill`/`stroke` with gradients and patterns (painting fill, stroke, fill-opacity, stroke-opacity, defs, `funcIRI` tests), `context-fill`/`context-stroke`, markers, `paint-order`, `mix-blend-mode`, `isolation`, `use/complex-style-resolving-order`, `clipPath`/`mask`, filters and text (`display`/`visibility` on `tspan`, text in `defs`);
- deliberate differences: `shapes/rect/rem-values` (resvg does not support `rem`, so its reference shows nothing; this renders it as CSS defines), `painting/fill/rgba-0-127-0-50percent` (CSS Color 4 accepts a percentage alpha in the comma syntax; the reference rejects it), `structure/svg/zero-size` and `negative-size` (resvg's reference is empty; this renders the content at its measured size), `structure/svg/not-UTF-8-encoding` (Windows-1251 is not a supported encoding), `painting/shape-rendering/on-horizontal-line` (the pixel rows a non-anti-aliased line covers when its edge falls exactly on a pixel center), `structure/svg/preserveAspectRatio=none` (a 0.75 px frame stroke that the reference draws as a hairline).

### Text results

The text category, rendered with the suite's own fonts, generic families as the suite sets them, `DefaultFontSize = 12` (the suite's references were made with a 12 px `medium` font; our default is 16, the browsers') and no installed fonts (`ONEIMAGE_RESVG_TESTS` pointing at the full collection). Before the text unit text passed 16 of 383; the first text unit reached 269 (70.2%) and vertical writing and `use` inheritance bring it to 279 of 383 (72.8%). The whole table is 1416 of 1706 (83.0%; 1406 before this step). No category lost a test, and one test moved on purpose: `tspan/with-opacity` passed while the opacity of a `tspan` was ignored, as the reference ignores it, and differs now that it is applied as SVG 2 says.

| subdirectory | pass / rendered |
|---|---:|
| alignment-baseline | 16 / 19 |
| baseline-shift, dominant-baseline, font-size, font-size-adjust, font-stretch, font-style, font-variant, glyph-orientation-horizontal, glyph-orientation-vertical, word-spacing, kerning, font-kerning, text-rendering, tref | all |
| font-family, font-weight, tspan, text-anchor, text, textLength, letter-spacing | 12/13, 11/12, 28/32, 14/15, 38/44, 10/12, 9/12 |
| text-decoration | 11 / 22 |
| lengthAdjust | 1 / 4 |
| writing-mode | 17 / 24 |
| textPath | 7 / 43 |
| font-variation-settings, `font/` (variable-font and auto-axis tests), color-font | 0/14, 1/2, 1/10 |
| direction, unicode-bidi | 1/2, 0/1 |

Why the rest fails:

- **Variable fonts (about 28 tests: font-variation-settings, `font/`) and color fonts (9: bitmap, SVG-table and COLRv1 glyphs):** `OneImage.Text` reads neither variation axes nor those glyph tables. Out of scope for this unit.
- **Vertical writing (7 of the 24 writing-mode tests, plus lengthAdjust/vertical and alignment-baseline/hanging-on-vertical):** the layout agrees with the reference (columns, orientation per character, anchors, `dx`/`dy`; `tb-with-negative-dy-and-text-anchor`, `tb-with-rotate` and `mixed-languages-with-tb` are within 0.3-0.45%), but upright ideographs sit about 0.17 px (16-20 px text) to 1.2 px (64 px text) lower in the reference than the font's own vertical origin (`vmtx` top side bearing plus the glyph's top) puts them; no rule explains the amount, so `japanese-with-tb` (2.45%), `tb-and-punctuation` (0.96%) and the underline variants stay open. `tb-with-rotate` and `tb-with-rotate-and-underline` are marked undefined behavior in the suite. `lengthAdjust/vertical` stretches along the column, which this does not do, and `hanging-on-vertical` moves baselines across the column, which it does not either.
- **textPath (36):** every one is a small positional disagreement, 0.5-0.8% of pixels on the simple cases and more on curves and closed paths. A second look, on `m-L-Z-path` (a straight horizontal path, so flattening and tangent angle cannot matter): per glyph the reference sits from -0.4 to +0.3 user px from where the exact advances put it, in no pattern (not the sign of the advance's fraction, not rounding of advances or positions to whole pixels, and kerning is absent in that string), while plain text with the same font agrees to 0.01 px. Flattening tolerance, chord against tangent angle and arc length were already swept in the first pass. The reference's coverage is sampled on a 4 x 4 grid and could account for part of it. No rule was found, so it stays an unexplained difference.
- **Edge anti-aliasing (text-decoration 0.25–0.5%: all-types-*, style-resolving-*, underline, underline-with-y-list, tspan-decoration, indirect-with-multiple-colors; text/escaped-text-3, tspan/pseudo-multi-line, tspan/mixed-xml-space-2, lengthAdjust/with-underline and /text-on-path, textLength/on-a-single-tspan, complex-grapheme-split-by-tspan, xml-lang=ja):** the bars and outlines agree to a fraction of a pixel; the difference is the exact-area coverage against the reference's sampled coverage on stroked text edges.
- **Arabic and mixed-script (arabic, arabic-with-dx, arabic-with-dy, underline-with-dy-on-arabic, letter-spacing/on-Arabic and mixed-scripts, textLength/arabic, unicode-bidi/bidi-override, direction/rtl, zalgo, glyph-splitting):** shaping, mark placement or the reference's handling of spacing across joined letters differs from ours.
- **Deliberate:** `tspan/with-opacity` (the reference ignores `opacity` on a `tspan`; SVG 2 and browsers apply it) and `direction/rtl` — with `direction: rtl` we anchor `start` at the right edge, as the SVG specification says; the reference does not flip the anchor.
- **Filters on text (text/filter-bbox, letter-spacing/filter-bbox, textPath/with-filter):** the filter region of a text element's bounding box; another unit's area.
- **Font fallback and synthesis (font-family/fallback-1, font-weight/650, font/fallback-1):** which face the reference picks for an intermediate weight and for a missing-glyph fallback.

Text, a 800 x 1000 page of 40 lines of `tspan`s (bold, colored, sized, kerned, some with per-character `dx` and `rotate`) and a block of text on a curve, drawn at 800 px wide with the committed Noto Sans subsets (BenchmarkDotNet, default job, `[MemoryDiagnoser]`; worker: AMD Ryzen 9 7950X3D, 32 logical cores, .NET 10.0.402 / runtime 10.0.13, load average 3.9 before, 4.2 after from another job; no other library compared, because Svg.Skia takes its fonts from the system):

| row | before | now | allocated before | now |
|---|---:|---:|---:|---:|
| text page, OneImage load + render | 9.1 ms | 6.0 ms | 15.1 MB | 10.2 MB |
| text page, OneImage render (loaded) | 4.6 ms | 3.3 ms | 8.7 MB | 3.7 MB |

(Rerun after the render-cost work with the short job, BenchmarkDotNet 0.15.8 short job, `[MemoryDiagnoser]`; worker: AMD Ryzen 9 7950X3D, 32 logical cores, .NET SDK 10.0.402 / runtime 10.0.13, load average 2.9 before, 5.7 to 8.6 during and 6.1 after (other jobs on the worker); "before" is the commit before that work, same session.)

### Paint servers results

First run of the 154 rendered `paint-servers` tests passed 11; now 146 pass. The eight failures:

- tile period (deliberate): resvg rounds a pattern tile up to whole pixels and repeats it at that period, so with a tile of 28.8 px its copies drift against the exact geometry; this renderer keeps the exact period, so the seams between tiles fall up to one pixel elsewhere (`pattern/invalid-patternUnits-and-patternContentUnits`, `self-recursive`, `self-recursive-on-child`, `pattern-on-child`: they pass with the rounded period; `out-of-order-referencing` and `recursive-on-child` differ by 2.4% even then, on nested tiles of a few pixels);
- `pattern/transform-and-patternTransform`: a rotation cancelled by its opposite leaves a pattern that is axis-aligned up to rounding noise; the reference smooths its tile, this renderer snaps it (6%, edge pixels of the tile);
- `pattern/text-child`: text in a pattern (the text step);
- `radialGradient/invalid-xlink-href` is excluded (external reference).

Of the `painting/context` and `painting/paint-order` tests, 3 pass and the rest need other steps: markers (`in-marker`, `in-nested-marker`, `in-nested-use-and-marker`, `on-shape-with-zero-size-bbox`, `with-gradient-on-marker`, `with-pattern-on-marker`, `with-viewbox`, and all of `paint-order` except `on-text`/`on-tspan`, which need text), text (`with-text`) and two with other causes: `with-pattern-in-use` (0.8%, tile period) and `with-pattern-objectBoundingBox-in-use` (21%, its tile size does not match the reference, cause not found). The three gradient `transform-origin` tests pass. Gradients and patterns on text (`painting/fill`, `painting/stroke`) wait for the text step. Fifteen `filters` tests that passed before the paint servers (invalid `feConvolveMatrix`, lighting and morphology parameters whose reference shows nothing) now fail: they fill with a pattern, which is drawn now, and the filter that should remove the element is the filter step's.

### Results: style sheets, conditional processing, images and `transform-origin`

With paint servers, clip and mask merged in, `structure` passes 188 of 230 (81.7%). The six `transform-origin` tests on `clipPath`, gradients and patterns now pass (the property is read for `gradientTransform`, `patternTransform` and the `clipPath` transform too): `structure` passes 194 of 230 (84.3%). The table and reasons below were measured on the style-sheet work alone, before that merge.

The `structure` category after this step, by directory (pass / rendered; excluded tests are external files, scripts and DTDs):

| directory | before | now |
|---|---:|---:|
| style (style sheets) | 6 / 17 | 17 / 17 |
| style-attribute | 4 / 4 | 4 / 4 |
| switch | 1 / 13 | 13 / 13 |
| systemLanguage | 4 / 10 | 6 / 10 |
| use, symbol, g | 57 / 60 | 59 / 60 |
| image | 0 / 34 | 13 / 34 |
| transform-origin | 3 / 23 | 15 / 23 |
| svg | 34 / 43 | 35 / 43 |
| transform | 13 / 19 | 13 / 19 |
| defs | 2 / 7 | 2 / 7 |
| **structure** | 124 / 230 (53.9%) | 177 / 230 (77.0%) |

The 53 that still differ, by reason (27 + 23 + 3):

- **Edge anti-aliasing, 27.** The reference rasterizer samples coverage on a 4 x 4 grid, so a vertical or horizontal edge lands on a multiple of a quarter pixel (a stroke edge at 1.875 px is drawn at 2.0, and 5.625 at 5.75); this renderer keeps exact area. At 3.75 output pixels per unit (`viewBox` 80 drawn at 300) the 1 unit frame that ends each of these test files differs by 33 of 255 along its four edges, which is 2.6% of the pixels against the 0.25% allowed. In 12 `image` tests (the raster formats, `nested-*`, `with-transform`) that frame is the only difference: 0% of the pixels in the interior differ, and the pictures match the reference to within bilinear rounding. Seven nested-SVG image tests add 0.3% on the edges of their rounded squares to the frame, six `transform` tests and `svg/no-size` and `svg/preserveAspectRatio=none` differ by 0.27 to 2.6% on edges alone. Comparing each pixel with its eight neighbors in the reference does not absorb it: the difference is in coverage, not position.
- **Another step's feature, 23:** gradients and patterns (`defs` 4, `svg/funcIRI-*` 3, `systemLanguage` on `defs` and a gradient 2, `use/complex-style-resolving-order`), `clipPath` (`systemLanguage/on-clipPath`, `image/objectBoundingBox-clip-on-sliced-image`) and text (`defs/style-inheritance-on-text`, `systemLanguage/on-tspan`, `transform-origin/on-text` and `on-text-path`, `image/embedded-svg-with-text`).
- **Deliberate differences, 3:** `svg/zero-size` and `svg/negative-size` (see above) and `svg/not-UTF-8-encoding`.

Cost of the cascade, local smoke on a loaded shared host (load 13-15, .NET 10.0.400, BenchmarkDotNet short job, high variance): a drawing of 20,000 shapes with 400 style rules (type, class, id, attribute, child, descendant and structural selectors) is parsed, cascaded and turned into a render tree in 276 ms and 94 MB, against 186 ms and 90 MB for the same drawing with every declaration on the element. Hostile sheets against a 2.7 MB document of 100,000 shapes (default limits, same host): a failing descendant chain under 200 nested groups and a 20-step general-sibling chain over 100,000 siblings both reach the 100 million step budget and fail with `ImageLimitException` after 0.4 s and 0.9 s (about 90 MB allocated, 265 MB peak for the process); `:nth-of-type` over 100,000 siblings finishes in 0.4 s because positions are counted once per parent.

### Markers and blending results

`painting` went from 165 to 256 of 307 (53.7% to 83.4%) on the committed subset, on top of the style-sheet step. By subcategory (before, now): marker 7 to 58 of 63, mix-blend-mode 4 to 19 of 21, isolation 1 to 2 of 2, paint-order 0 to 12 of 14, overflow 1 to 5 of 5, context 6 to 13 of 16, shape-rendering 5 to 6 of 8. The 51 `painting` tests that still fail, with their reasons:

- text, which is a later step (22): `*-on-text`, `*-on-tspan`, `tref`, `marker/with-a-text-child`, `marker-on-text`, `context/with-text`, the gradients and patterns on text;
- dash positions (13: `stroke-dasharray` and `stroke-dashoffset`, 0.28 to 0.37% against the 0.25% allowance): this renderer places dash ends by the exact arc length, resvg by a flattened outline, so the ends of the dashes of a circle drift against each other by up to 0.4 px per turn (measured on `even-count`: 0.01 px at the first edge, 0.38 px after one turn). The gap is anti-aliasing at the dash ends, not a wrong shape;
- `visibility/bbox-impact-1`, `-2`, `opacity/bBox-impact` (11.7%): a hidden or transparent element still counts in the bounding box a `clip-path` in `objectBoundingBox` units is resolved against; the clip step's;
- `image-rendering/high-quality`, `optimizeSpeed`, `optimizeSpeed-on-SVG` (2.6 to 2.8%) and `on-feImage` (filters): the image step's;
- `context/with-pattern-in-use` (0.83%) and `with-pattern-objectBoundingBox-in-use` (21.6%): pattern tile period, as described under paint servers;
- `marker/orient=auto-on-M-C-C-4` and `-5` (0.26 and 0.25%): the markers are placed as in the reference; the difference is the stroke of the sharp turn where the two curves meet, anti-aliasing at the limit of the allowance; `orient=auto-on-M-L-Z` (0.44%): the stroke of a closed line that goes there and back was drawn twice as dense as the reference, because the stroke outline's two overlapping halves added their partial coverage. The stroker now merges outlines that lie on themselves (see the drawing page), and these three pass;
- `mix-blend-mode/saturation` (2.4%): the reference is brighter than the W3C non-separable formula gives (we follow the formula);
- `shape-rendering/on-horizontal-line` (0.54%): with anti-aliasing off, a pixel whose center lies exactly on the edge of a 1.5 px line is in the reference and out here;
- `fill/rgba-0-127-0-50percent` (64%): the reference renders black for `rgba()` with a percent alpha; this renderer follows CSS Color 4 and draws the translucent green;
- `shape-rendering/optimizeSpeed-on-text`: text.

Two marker rules were taken from the reference images, not from the specification: the first vertex of a closed subpath has only the outgoing direction (the specification averages in the closing segment), and the vertex a closepath makes at the start of a closed subpath that another subpath follows is not drawn.

A marker- and blend-heavy drawing, 1000 x 800 with 250 paths of 25 vertices carrying arrow, dot and square markers and 60 groups blended with `mix-blend-mode` and `isolation`, and a polyline of 10,000 vertices with a marker on every vertex, both at 1024 px wide (local smoke, load 15 to 16 on a shared host, AMD Ryzen 9 7950X3D, .NET 10.0.11, BenchmarkDotNet short job, `SvgMarkerBenchmarks`):

| row | before | now | allocated before | now |
|---|---:|---:|---:|---:|
| markers and blends, OneImage load + render | 120.6 ms | 39.9 ms | 83.3 MB | 25.3 MB |
| markers and blends, OneImage render (loaded) | 82.6 ms | 28.8 ms | 64.9 MB | 6.7 MB |
| markers and blends, Svg.Skia load + render | 125.3 ms | 103.9 ms | 18.7 MB | 18.7 MB |
| markers and blends, Svg.Skia render (loaded) | 82.8 ms | 69.8 ms | managed 184 B | managed 184 B |
| 10k-vertex polyline, OneImage load + render | 62.0 ms | 30.4 ms | 99.8 MB | 47.8 MB |
| 10k-vertex polyline, OneImage render (loaded) | 26.1 ms | 10.6 ms | 59.8 MB | 7.4 MB |
| 10k-vertex polyline, Svg.Skia load + render | 101.7 ms | 79.2 ms | 30.6 MB | 30.4 MB |
| 10k-vertex polyline, Svg.Skia render (loaded) | 28.4 ms | 28.2 ms | managed 184 B | managed 184 B |

(`SvgMarkerBenchmarks`; BenchmarkDotNet 0.15.8 short job, `[MemoryDiagnoser]`; worker: AMD Ryzen 9 7950X3D, 32 logical cores, .NET SDK 10.0.402 / runtime 10.0.13, load average 2.9 before, 5.7 to 8.6 during and 6.1 after (other jobs on the worker); "before" is the commit before the render-cost work, run in the same session.) The 10,000-vertex document renders with a peak resident set of 136 MB for the whole process (a stopwatch run of ten renders, measured before the work above). Rendering is ahead of Svg.Skia once loaded and including the load. A blended group's layer is read in place and its tiles return to the pool, so the allocation left in the rendering rows is the output array and the per-fill objects; the load rows now spend most of their allocation on parsing and building the render tree.

## Roadmap

Planned order. Each step is a coherent piece with its own observable claim, measured by the conformance table above and the stated tests.

1. **Document model and shapes** (done). The core render-at-size hook and rewrite; typed document; presentation attributes and the `style` attribute; the render tree for `g`, shapes and `path`; solid fill and stroke with all stroke properties; transforms, `viewBox`, `preserveAspectRatio`, nested `svg`, basic `use`/`symbol`; region rendering; bounding-box natural size. Claim: `shapes` and the stroke/fill groups of `painting` pass at a stated rate, and `Thumbnail` of an SVG renders at the target size (compared against rasterizing then resizing). Result: 96.2% of `shapes`; the render-at-size output equals a direct render within 1 level and differs from a raster downscale (see [Results](#results)).
2. **CSS and structure** (done). `<style>`, selectors, cascade, inheritance, `currentColor`, `switch`, `systemLanguage`, `use` budgets and cycle handling, `data:` images, `transform-origin`. Claim: `structure` passes at a stated rate; the CSS cost limits are tested with hostile stylesheets. Result: 77.0% of `structure` on its own, with the reasons for the rest above; every style, switch and `use` test that does not need another step passes.
3. **Paint servers** (done). Gradients (including focal radial and gradient transforms), patterns, `paint-order`, `context-fill`/`context-stroke` through `use`, plus the brush additions in the core. Claim: `paint-servers` pass rate. Result: 146 of 154 (94.8%); see [Paint servers](#paint-servers-results).
4. **Painting extras** (done, see [Markers and blending](#markers-and-blending-results)). Markers, `mix-blend-mode`, `isolation`, group opacity layers, `shape-rendering`, `image-rendering`, `visibility`, `display`. Claim: `painting` pass rate.
5. **Clipping and masking** (done). `clipPath`, `mask`, viewport clipping; the layer-multiply op (`Image.ApplyMask`). Claim: `masking` pass rate (see [Results](#results)).
6. **Filters, part one** (done). The filter graph, regions and subregions, linear-light float evaluation, and the primitives that map onto existing operations, `feDropShadow` and the CSS filter functions. Claim: the corresponding `filters` directories.
7. **Filters, part two** (done). Component transfer, morphology, tile, convolution completions, turbulence, displacement, lighting, and shared output blocks for filters with wide margins. Claim: 395 of the 406 `filters` files (97.3%, with the suite's fonts; 298 before this step), no test of any other category changed.
8. **Text** (done, see [Text results](#text-results)). `text`/`tspan`/`textPath`/`tref`, per-character positioning through the public cluster API in `OneImage.Text`, fonts from `SvgFontOptions`, fallback, decorations, bidirectional text, paint servers on text. Claim: `text` pass rate 72.8% (279 of 383); variable and color fonts wait on `OneImage.Text`.
9. **Hardening and speed.** Fuzz targets for the document, CSS and renderer; hostile-input tests for every limit; benchmarks (24-megapixel and thumbnail renders, large maps, filter-heavy art) against the .NET SVG renderers and libvips/librsvg; the package readme, release notes and publishing setup. Claim: no fuzz findings after the stated run, and the benchmark table.

## Status

Done: package skeleton, format detection (plain and gzip), natural size (including the content-bounds fallback), the strict bounded XML parser, the typed document and styles, style sheets with the cascade, `switch` and conditional attributes, `data:` images (raster and nested SVG), `transform-origin`, the render tree and region renderer for shapes, strokes, layers, transforms, viewports and `use`/`symbol`, render-at-size in the core, the limits listed above, and the conformance harness with the committed test files. Fuzzed: `svg-xml` (3.9 million executions in 150 s) and `svg-render` (0.7 million in 150 s after fixing one crash, a path whose coordinates overflowed a float, whose repro is in `tests/testdata/svg`; then two runs of 130 s (0.55 and 0.44 million executions), no findings, with style sheets, `switch` and images in the seeds and the CSS and image limits lowered).

A 24×24 icon at 256 px and a 1000×800 illustration of about 3,000 shapes at 1024 px wide (`SvgBenchmarks`; BenchmarkDotNet 0.15.8 short job, `[MemoryDiagnoser]`; worker: AMD Ryzen 9 7950X3D, 32 logical cores, .NET SDK 10.0.402 / runtime 10.0.13, load average 2.9 before, 5.7 to 8.6 during and 6.1 after (other jobs on the worker)). "Before" is the same benchmark on the commit before the render-cost work, run in the same session:

| row | before | now | allocated before | now |
|---|---:|---:|---:|---:|
| icon, OneImage load + render | 329 µs | 141 µs | 434 KB | 323 KB |
| icon, OneImage render (loaded) | 298 µs | 118 µs | 387 KB | 276 KB |
| icon, Svg.Skia 5.2.3 load + render | 334 µs | 363 µs | 78 KB | 78 KB |
| icon, Svg.Skia render (loaded) | 192 µs | 183 µs | managed 184 B | managed 184 B |
| illustration, OneImage load + render | 36.4 ms | 25.2 ms | 57.4 MB | 27.6 MB |
| illustration, OneImage render (loaded) | 25.2 ms | 11.5 ms | 34.9 MB | 5.0 MB |
| illustration, Svg.Skia load + render | 81.6 ms | 78.7 ms | 21.0 MB | 21.0 MB |
| illustration, Svg.Skia render (loaded) | 50.4 ms | 38.5 ms | managed 189 B | managed 184 B |

Measured earlier on another host: resvg 0.48.1 as an external program takes 112 ms for the illustration (process, parse, render, PNG write) and 3.0 ms for the icon. What was slow: a 128 x 128 tile render costs a canvas and, for every shape, a flattened path, an edge table and a coverage buffer; those are now reused from fill to fill on a thread (the tile rasterizer clears and sums only the cells an edge touched, not the whole 130 x 128 accumulator), a layer is read in place and its tiles go back to the pool instead of waiting for the garbage collector, the RGBA 8-bit/float conversions that layer compositing runs per row no longer go through temporary float rows, and tile workers no longer contend on a shared scratch bag. The output is byte for byte the same as before (`bench/OneImage.Benchmarks` `svg-hash` renders the sample documents whole and in regions and prints a hash for each), and the resvg harness has the same result for every test. Rendering a small drawing in one pass instead of tile by tile was tried and was slower (icon 0.49 ms against 0.26 ms): the tiles render in parallel, and a single call draws each shape's tiles one after another. Gradients and patterns are measured in [Paint servers](#paint-servers-results).

Paint servers, a 1000 x 800 drawing of about 600 shapes with 56 gradients and one of about 250 shapes with 26 patterns (rotated, skewed, nested), drawn at 1024 px wide (local smoke, load 18–20 on a shared host, .NET 10.0.11, BenchmarkDotNet short job, `SvgPaintBenchmarks`):

| row | mean | allocated |
|---|---:|---:|
| gradients, OneImage load + render | 15.6 ms | 11.7 MB |
| gradients, OneImage render (loaded) | 12.3 ms | 7.1 MB |
| gradients, Svg.Skia load + render | 22.0 ms | 7.4 MB |
| gradients, Svg.Skia render (loaded) | 13.8 ms | managed 184 B |
| patterns, OneImage load + render | 15.0 ms | 31.7 MB |
| patterns, OneImage render (loaded) | 11.8 ms | 7.0 MB |
| patterns, Svg.Skia load + render | 91.2 ms | 87.9 MB |
| patterns, Svg.Skia render (loaded) | 13.2 ms | managed 184 B |
