Recursive AI

SVG

All oneimage docs

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.

C#
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:

AreaSupported
Structuresvg (nested too), g, defs, use, symbol, switch (systemLanguage, requiredFeatures), a, title/desc ignored
Shapesrect (with rx/ry), circle, ellipse, line, polyline, polygon, path
Paintingfill 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 serverslinearGradient, radialGradient (with focal point and fr), pattern, href inheritance, gradientTransform/patternTransform, spreadMethod, both unit systems
Clipping and maskingclipPath (nested, per-child clip-rule), mask (luminance and alpha, nested), overflow clipping of nested viewports and markers
Markersmarker-start/mid/end and the marker shorthand, orient, markerUnits, viewBox, refX/refY
Imagesimage with a data: URI (PNG, JPEG, GIF, WebP, BMP, TIFF, ICO and nested SVG, decoded by OneImage's own codecs), preserveAspectRatio, image-rendering
Texttext, tspan, textPath, per-character x/y/dx/dy/rotate, text-anchor, baselines, decorations, spacing, textLength, writing modes; rendered through OneImage.Text
Filtersthe filter element with the full SVG 1.1 primitive set, feDropShadow, and the CSS filter functions (blur(), drop-shadow(), grayscale(), ...)
CSSpresentation 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).

Everything up to and including the render tree is safe, bounds-checked code and runs under the 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). 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 featureBuilt from
Fill of a shapeFill(brush, path, rule, transform, opacity); shape-rendering: crispEdges/optimizeSpeed → antialias: false
StrokeDraw(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 paintBrush.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
PatternThe 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 groupThe 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-pathEntirely 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
maskThe 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
imageImage.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
TextOneImage.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
MarkersInstantiated into the render tree as groups with a transform per vertex
FiltersSee below

Filters

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

PrimitiveMaps to
feGaussianBlurGaussianBlur(sigma, sigmaY, edge: Constant); the input outside the filter region is transparent
feOffsetwhole-pixel offsets: Pad/Crop; fractional offsets: Affine translation
feFlooda fill of the primitive subregion
feBlendComposite(..., blend: BlendMode) (normal, multiply, screen, darken, lighten and the SVG 2 modes)
feCompositeAlphaComposition for over/in/out/atop/xor. New: arithmetic (k1..k4 on premultiplied channels, clamped)
feColorMatrixColorMatrix (matrix, saturate, hueRotate, luminanceToAlpha as a matrix) on straight color, as the SVG specification defines
feMergerepeated source-over Composite
feDropShadowblur + offset + flood + merge
feImagea data: image, or an element reference rendered like use
feComponentTransferper-channel identity/table/discrete/linear/gamma on straight color, evaluated directly on float
feConvolveMatrixdirect evaluation with target offset, bias, preserveAlpha, edgeMode duplicate/wrap/none
feMorphologyerode/dilate: separable min/max over a rectangular window (van Herk), so the time does not grow with the radius
feTiletiles the input's subregion over its own
feTurbulencethe Perlin turbulence of the specification (fractal noise and turbulence, seeds, octaves, stitch tiles)
feDisplacementMapnearest-pixel displacement by a channel of the second input
feDiffuseLighting, feSpecularLighting and the three light sourcesdirect evaluation of the specification's Phong terms over the alpha channel as a height map
CSS filter functionsdirect: 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); 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):

rowbefore blocksnowallocated beforenow
blur, sigma 30 units, 4096 px11.2 s762 ms388 MB74 MB
blur, sigma 8 units, 4096 px1.69 s579 ms (error 440 ms)74 MB
icon with blur, 256 px1.43 ms1.24 ms (blocks on every filter: 2.7 ms)318 KB
turbulence + lighting, 1024 pxnot drawn52.8 ms6.9 MB
Svg.Skia: blur 4096 px / turbulence + lighting 1024 px218 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)beforenowSvg.Skia (render)
blur, sigma 8 units, 1024 px (loaded document, render only)33.5 ms (error 45)10.7 ms11.9 ms
blur, sigma 8 units, 1024 px30.1 ms10.8 ms
drop shadow, 1024 px44.1 ms17.8 ms58.4 ms
drop shadow, 1024 px (loaded document, render only)4.8 ms3.9 ms
icon with blur, 256 px1.18 ms0.45 ms0.49 ms
blur, sigma 8 units, 4096 px491 ms156 ms210 ms
blur, sigma 30 units, 4096 px678 ms (error 98)206 ms (error 62)210 ms
turbulence + lighting, 1024 px51.5 ms50.1 ms53.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.

LimitDefaultWhere it applies
Document bytes (file, and gzip output)32 MiBsource — implemented
Elements500,000XML parser — implemented
Element nesting depth256XML parser — implemented
Attributes per element512XML parser — implemented
Name length256 charactersXML parser — implemented
Namespace declarations in scope256XML parser — implemented
Root start-tag scan for Identify (MaxRootTagScanBytes)1 MiBsource — implemented: a root start tag that does not end inside the window fails with ImageLimitException
Gzip expansion ratioImageLimits.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 MiBstyles — implemented
CSS rules (MaxCssRules, one per selector of a list)20,000styles — implemented
Selector matching work (MaxCssMatchSteps)100 million stepsstyles — 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 milliondocument, 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 mask32 (MaxLayerDepth)render tree and renderer — implemented; deeper nesting fails closed with ImageLimitException before anything is drawn
Nested image documents (MaxImageDepth)3 levelsimage of an SVG data URI; they share the budgets above
data: payload per image / per document (MaxImageBytes, MaxTotalImageBytes)16 MiB / 64 MiBrender tree; decoded under ImageLimits; the encoded length is checked before the payload is decoded
Text characters (MaxTextCharacters)1 millionrender 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 size16 megapixels (MaxPatternTilePixels), 16,384 pixels per siderenderer, 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 layerone band: region width × at most 128 rows, under 4 megapixelsrenderer — implemented
Filter region64 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 nesting256 / 4 (MaxFilterPrimitives, MaxFilterDepth)render tree — implemented; nesting counts filters reached through feImage
Convolution kernel cells, convolution work, turbulence octaves4096; region pixels × cells ≤ 2³³; 10render 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 kept96 MiB, process-wideFilterBlockCache — 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).
  • 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). 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.
  • References (use) are instantiated under budgets (see 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 RNodes 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 RPaths 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.

categoryrenderedpass beforepass nowrate now
shapes133012896.2%
painting307027790.2%
structure230019785.7%
paint-servers154014694.8%
masking9309197.8%
filters406039597.3%
text383027972.8%
all1,70601,51388.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:

subcategorypass
shapes: rect, circle, ellipse, line, polyline, polygon75 / 76
shapes: path53 / 57
painting: stroke-width, stroke-linecap, stroke-linejoin, stroke-miterlimit, fill-rule25 / 26
painting: stroke (solid paint)13 / 20
painting: stroke-dasharray, stroke-dashoffset10 / 23
painting: fill, fill-opacity, stroke-opacity, opacity, color74 / 91
painting: display, visibility, shape-rendering, overflow10 / 29
structure: svg (viewBox, preserveAspectRatio, nested svg, sizes)34 / 43
structure: use, symbol, g, defs59 / 67
structure: transform, style-attribute17 / 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.

subdirectorypass / rendered
alignment-baseline16 / 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, trefall
font-family, font-weight, tspan, text-anchor, text, textLength, letter-spacing12/13, 11/12, 28/32, 14/15, 38/44, 10/12, 9/12
text-decoration11 / 22
lengthAdjust1 / 4
writing-mode17 / 24
textPath7 / 43
font-variation-settings, font/ (variable-font and auto-axis tests), color-font0/14, 1/2, 1/10
direction, unicode-bidi1/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 tspans (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):

rowbeforenowallocated beforenow
text page, OneImage load + render9.1 ms6.0 ms15.1 MB10.2 MB
text page, OneImage render (loaded)4.6 ms3.3 ms8.7 MB3.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):

directorybeforenow
style (style sheets)6 / 1717 / 17
style-attribute4 / 44 / 4
switch1 / 1313 / 13
systemLanguage4 / 106 / 10
use, symbol, g57 / 6059 / 60
image0 / 3413 / 34
transform-origin3 / 2315 / 23
svg34 / 4335 / 43
transform13 / 1913 / 19
defs2 / 72 / 7
structure124 / 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):

rowbeforenowallocated beforenow
markers and blends, OneImage load + render120.6 ms39.9 ms83.3 MB25.3 MB
markers and blends, OneImage render (loaded)82.6 ms28.8 ms64.9 MB6.7 MB
markers and blends, Svg.Skia load + render125.3 ms103.9 ms18.7 MB18.7 MB
markers and blends, Svg.Skia render (loaded)82.8 ms69.8 msmanaged 184 Bmanaged 184 B
10k-vertex polyline, OneImage load + render62.0 ms30.4 ms99.8 MB47.8 MB
10k-vertex polyline, OneImage render (loaded)26.1 ms10.6 ms59.8 MB7.4 MB
10k-vertex polyline, Svg.Skia load + render101.7 ms79.2 ms30.6 MB30.4 MB
10k-vertex polyline, Svg.Skia render (loaded)28.4 ms28.2 msmanaged 184 Bmanaged 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).
  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.
  4. Painting extras (done, see Markers and blending). 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).
  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/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:

rowbeforenowallocated beforenow
icon, OneImage load + render329 µs141 µs434 KB323 KB
icon, OneImage render (loaded)298 µs118 µs387 KB276 KB
icon, Svg.Skia 5.2.3 load + render334 µs363 µs78 KB78 KB
icon, Svg.Skia render (loaded)192 µs183 µsmanaged 184 Bmanaged 184 B
illustration, OneImage load + render36.4 ms25.2 ms57.4 MB27.6 MB
illustration, OneImage render (loaded)25.2 ms11.5 ms34.9 MB5.0 MB
illustration, Svg.Skia load + render81.6 ms78.7 ms21.0 MB21.0 MB
illustration, Svg.Skia render (loaded)50.4 ms38.5 msmanaged 189 Bmanaged 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, 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):

rowmeanallocated
gradients, OneImage load + render15.6 ms11.7 MB
gradients, OneImage render (loaded)12.3 ms7.1 MB
gradients, Svg.Skia load + render22.0 ms7.4 MB
gradients, Svg.Skia render (loaded)13.8 msmanaged 184 B
patterns, OneImage load + render15.0 ms31.7 MB
patterns, OneImage render (loaded)11.8 ms7.0 MB
patterns, Svg.Skia load + render91.2 ms87.9 MB
patterns, Svg.Skia render (loaded)13.2 msmanaged 184 B