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.
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)
- 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. - 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. - 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. - Styles. CSS is parsed, rules are matched and ordered, and every element gets computed property values with inheritance applied.
- 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, nestedsvg,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. - 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.
XmlReadercan be made safe (DtdProcessing.Prohibit, no resolver), but then it is a safe configuration of a general parser. Ours cannot be configured unsafely: any<!DOCTYPEfails, 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.
XmlReaderSettingsoffers 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;
XmlReaderwith 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:
widthandheightwhen both are absolute lengths (px,pt,pc,mm,cm,in;emandexat 16 and 8 px);- percentages, zero and negative values count as absent; a missing side is derived from the other through the
viewBoxaspect ratio; with neither, theviewBoxsize 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 withRandomAccess, 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.DecodeRequestgainsTargetWidthandTargetHeight(0 = the natural size; the existing two-argument constructor is kept). TheRegionof 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): whenResize/Thumbnailsits directly on an undisturbed decode of aRenderAtSizedecoder, the decode is re-created at the final size and no resample node is added.Fit.StretchandContainmap to the target directly;Coverrenders at the covering size and its crop is now pushed down as a region of the decode;Padrenders at the contained size and pads. Upscaling follows the fit rules:Resizeenlarges by default,Thumbnailonly withupscale: true, and an enlarged render is sharper, not blurrier. Rotation,Affineand 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:
- looks up the render-tree nodes whose device bounds meet the region (a bounding-box grid built once per tree), in paint order;
- paints into a
Canvasthe size of the region, with the region offset folded into the transform; - 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 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); 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-transformtests differ from their references only in edge pixels. - Subregions. Defaults are the filter region. With
primitiveUnits=objectBoundingBox,feFlood,feOffsetandfeImagemeasure 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 ausewith its origin at the subregion's; a picture is scaled with a Mitchell filter on encoded values into the subregion bypreserveAspectRatio. Recursion throughfeImage(an element that carries the filter it is drawn by) drops thefilterof 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 lastfeFunc*of each channel, clamp to 0..1, premultiply by the new alpha. An unknowntype, atable/discretewithout 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.stitchTilestakes 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.numOctavesis capped at 10 (the next octave adds less than 2⁻¹⁰), negative or non-integer values, a negative or malformedbaseFrequencygive a transparent result. The values are in the primitive's color space.feDisplacementMap.scaleis multiplied by the grid scale; each pixel takes the source pixel at its position plusscale × (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).
surfaceScalestays 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 withprimitiveUnits=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-positivespecularExponentis read as 1;limitingConeAnglecuts hard (no smoothing).kernelUnitLength, when given, samples the surface at that distance (bilinear, border extended) instead of the pixel grid. feConvolveMatrixcompletions. 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=wrapwith 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
ImageLimitExceptionbefore any buffer exists); the kernel itself to 4096 cells (a larger or malformed one makes the primitive transparent). Everything else is bounded byMaxFilterPixelson 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-1and-2(0.8%: with awidthfraction on a blur the reference cuts at half of the region's far edge, not at half of its width; unexplained, not copied); the 6complex-transformtests (edge pixels: the filters run on the element's axes, the references along the output's;feTile9.7% because tiles resample);feColorMatrix/type=matrix-with-non-normalized-values(0.27%, borderline); two undefined-behavior convolution tests. That makes 395 of 406filtersfiles (the 7color-adjusttests 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 coreKernels/BlurKernel.cs). The sums are doubles with the same operations in the same order as the scalar referenceFilterBlurReference, 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 scalarMath.Min/Math.Clamprules 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 defaultcolor-interpolation: sRGB— and are rounded to 8 bits when a layer is stored. color-interpolation: linearRGBon gradients is honored through thelinearoption the brushes already have. On amaskit is ignored: the luminance is always computed on the stored sRGB values, as browsers and the reference renderer do (Image.ApplyMaskhas alinearoption for other callers).- Filters follow
color-interpolation-filters, whose default islinearRGB. 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 forsRGBconverts 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,initialandunset; inherited and non-inherited properties computed per element;currentColorkept as a keyword and resolved against the element's owncolor. An invalid value is ignored as if unwritten, except a negativestroke-width, which drops the stroke. - Style sheets. Every
<style>element whosetypeis empty ortext/css(CDATA included, anywhere in the document, in document order) is parsed into rules. Supported selectors: type,*,#id,.class, attribute tests ([a],=,~=,|=,^=,$=,*=, with theiflag), the descendant,>,+and~combinators,:first-child,:last-child,:only-child,:nth-child(),:nth-last-child(), the-of-typeforms,:empty,:root,:not(),:is()and:where()over compound selectors;:hover,:linkand 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; thestyleattribute; then the!importantdeclarations in that same order. Rules apply to the document, souseinstances copy the cascaded values of the element they reference and inherit from theuse. Matching is right to left with backtracking on explicit arrays, no recursion, under a step budget (see Limits). - Conditional processing. An element with
systemLanguage,requiredFeaturesorrequiredExtensionsrenders only when all hold. A language tag insystemLanguagematches a configured language (new SvgFormat(languages: ...), defaulten) when they are equal or the language is a prefix of the tag before a hyphen, ignoring case (enmatchesen-GB); an empty list is false. Extensions are not supported, so anyrequiredExtensionsis false;requiredFeaturescounts as true unless empty.switchrenders the first child that can be rendered (a shape,g,a,svg,use,image,text,switch,foreignObject) whose conditions hold, even whendisplay: nonehides it; unknown elements and comments are skipped; theswitchelement itself takes part in styling and transforms like a group. The conditions also apply to the rootsvg. Conditions ondefs, gradients andclipPathhave no effect on how they are referenced. - Images.
imagedraws anhref/xlink:hrefthat is adata: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 orautowidth/heightcomes from the picture (both from its size, one through its aspect ratio);preserveAspectRatioplaces it,slicecuts it to the viewport;transform,opacity,visibilityanddisplayapply;image-rendering: optimizeSpeed,pixelatedandcrisp-edgessample 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 perimageelement however many timesuseinstantiates 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 externalhrefis never fetched. transform-origin. One to three values (keywords, lengths, percentages); the reference box is the nearestviewBox. On the root, percentages refer to the viewport and the default stays the center.transform-boxis not supported (the box is always the view box). The origin also applies to thetransformof aclipPath, topatternTransform(reference box: the view box, as for drawn elements) and togradientTransform: withuserSpaceOnUseunits the view box, withobjectBoundingBoxunits the unit square, socenteris 0.5 and a plain number is a fraction of the box.- Colors. Keywords,
transparent,#rgb,#rgba,#rrggbb,#rrggbbaa,rgb()/rgba(),hsl()/hsla()andhwb()in the comma and the space syntax, with percentages, angle units and/alpha. Integers and percentages may not be mixed among the three channels ofrgb(). - Geometry.
rect(withrx/ryand the auto rule),circle,ellipse(SVG 2autoradii),line,polyline,polygon, andpathwith 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,strokewith solid colors,fill-rule,fill-opacity,stroke-opacity,opacity;shape-renderingcrisp values turn anti-aliasing off.visibilityanddisplay.paint-orderorders fill, stroke and markers. - Paint servers.
linearGradientandradialGradientwithgradientUnits,gradientTransform,spreadMethod, stops (offsets clamped and kept non-decreasing,stop-colorwithcurrentColorandinherit,stop-opacity),hrefinheritance (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 andfras 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.patternwithpatternUnits,patternContentUnits,viewBoxandpreserveAspectRatio,patternTransform,hrefinheritance 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 pastSvgLimits.MaxPatternTilePixelsis 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-fillandcontext-stroketake the paints of the nearestuse(gradients and patterns included, with the box of what theusedrew), and nothing without one.color-interpolation: linearRGBon gradients is not supported. - Stroke. Width, caps, joins (
miter-clipandarcsfall 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),
viewBoxandpreserveAspectRatioon the root and nestedsvg,g,a,defs(not drawn),use(withx,y,width,height),symbol(viewBox, size from theuse, its owntransformignored as in SVG 1.1), nestedsvgwith the default clip (overflow: visibleorautoremoves it). The root'stransformtakes its origin at the center of the viewport, and the root'sbackground-colorpaints the whole output. - Clipping.
clip-path: url(#id)on shapes, groups,use, nestedsvgand the root.clipPathchildren are shapes (rect,circle,ellipse,line,polyline,polygon,path) andusethat points straight at one;g,symbol,image,switchand text (until text exists) contribute nothing, as do children withdisplay: noneorvisibility: hidden. Each child has its owntransform,clip-rule(also inherited from theclipPath) and nestedclip-path; theclipPathhasclipPathUnits,transformand its ownclip-path(applied in the referencing element's space). A non-invertible transform, an emptyclipPath, orobjectBoundingBoxunits on an element with no area clip everything; a dangling reference, a reference to something that is not aclipPath, and a non-url()value such ascircle()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.overflowon nestedsvgandsymbolclips the viewport (visibleandautodo not;inherittakes the parent's); the legacyclipproperty is not used, as in the reference renderer. - Masking.
mask: url(#id)on shapes, groups,use, nestedsvgand the root:maskUnits(defaultobjectBoundingBox, region -10%/-10%/120%/120%),maskContentUnits,x/y/width/heightwith percentages,mask-type(luminanceoralpha, as an attribute or instyle; an invalid value is luminance), amaskon 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 byImage.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 themasklink that closes a cycle is dropped everywhere.color-interpolationis ignored. - Markers.
marker-start,marker-mid,marker-endand themarkershorthand (a style declaration only, as the specification says) onpath,line,polyline,polygonand, as SVG 2 has it,rect,circleandellipse.markerUnits(an invalid value isstrokeWidth),markerWidth/markerHeight(3 by default; zero or negative draws nothing),refX/refY,viewBoxwithpreserveAspectRatio,overflow(hidden by default;visible,autoand aninheritthat finds nothing leave the marker unclipped), andorientasauto,auto-start-reverseor an angle indeg,grad,radorturn. 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-fillandcontext-strokeare the shape's paints. A marker that is already being drawn draws nothing inside itself, and ausechain inside a marker starts afresh. The shape and its markers are one group when the shape hasopacity, a blend mode, a clip or a mask;paint-orderputs the markers before, between or after the fill and stroke. Every marker instance counts againstMaxInstances. - Blending and isolation.
mix-blend-mode(every CSS blend mode;plus-lighteris the Porter-Duff plus operator,AlphaComposition.Plus: premultiplied colors and alpha add and clamp at 1) andisolation: isolateare 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
opacitybelow 1, and a shape with both fill and stroke andopacitybelow 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),remand%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
filterproperty withurl(#id)references and CSS filter functions in any mix and order. Filter elements:filterUnits,primitiveUnits, region defaults of -10% / 120%,hrefinheritance 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,FillPaintandStrokePaintare read as the source graphic),color-interpolation-filtersper primitive. Primitives:feGaussianBlur,feOffset,feFlood,feMerge,feBlend(all 16 modes),feComposite(includingarithmeticandlighter),feColorMatrix,feConvolveMatrix,feImage(adata:picture or an element drawn likeuse),feDropShadow;feComponentTransfer,feMorphology,feTile,feTurbulence,feDisplacementMap,feDiffuseLightingandfeSpecularLightingwithfeDistantLight,fePointLightandfeSpotLight(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 unresolvedurl()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); auseof itself, of an ancestor, or around a cycle draws nothing; a dangling or external reference draws nothing. Asymbolinstantiated 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) andtextPath(to apathor any basic shape;startOffsetin 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,dyandrotatelists, each applying to the characters of its element in order and nearest-element-wins; an absolute position starts a new chunk.text-anchoranchors a chunk by its advance;textLengthwithlengthAdjustspacing or spacingAndGlyphs per chunk;letter-spacingandword-spacing(lengths,em, and percentages of the root viewport width, which is how the test-suite reference reads them).Fonts.
SvgFontOptions(passed asnew 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-familylists,font-weight,font-style,font-stretch, thefontshorthand andfont-variant: small-caps(the font'ssmcpglyphs, 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 andmediumfont size isSvgFontOptions.DefaultFontSize, 16 px by default as in browsers; the named sizes step by 1.2 around it (xx-smallis medium / 1.2³,xx-largemedium × 1.2³), andem, percentages andremthat reach the top of the document are relative to it.Shaping. Ligatures, kerning (
font-kerning, and the SVG 1.1kerning="0"), complex scripts and bidirectional text (direction,unicode-bidiembed and override) come fromOneImage.Text. Kerning also applies across atspanboundary between runs of one family that differ in size or weight, using the first run's font.Baselines.
dominant-baseline,alignment-baseline,baseline-shift(includingsuperandsubfrom the font's own metrics).Painting. Fill and stroke with paint servers (a gradient or pattern in
objectBoundingBoxunits 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: optimizeSpeedon thetextelement draws its glyphs without anti-aliasing. Text inside aclipPathclips like geometry.Whitespace.
xml:spaceandwhite-space; collapsing runs acrosstspanboundaries, and a preserved space ends a collapsing run.Vertical text.
writing-modetb,tb-rl,vertical-rl,vertical-lr(andsideways-*) on thetextelement (on atspanit has no effect). The characters run down the column fromy, withxthe middle line of the column. As in the mixed vertical mode ofOneImage.Text, characters with Vertical_Orientation U or Tu (ideographs, kana, Hangul) stand upright and are shaped with the font'svertforms; the rest (Latin, digits, Arabic) lie rotated a quarter turn clockwise, with the font's ascent-plus-descent box centered on the column line.dxmoves across the column,dyalong it,text-anchoranchors the extent alongy, and right-to-left runs are reordered down the column.glyph-orientation-verticalandtext-orientationare not used (the reference ignores them too), and all four vertical modes lay out the same way (each column starts at its ownx). Underlines are bars on the rotated baseline, to its left. Text on a path ignores the writing mode.Text style through
use. Auseelement'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.
opacityon atspan,textPathorafades 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
dxordy, or arotatethat 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/strokewith gradients and patterns (painting fill, stroke, fill-opacity, stroke-opacity, defs,funcIRItests),context-fill/context-stroke, markers,paint-order,mix-blend-mode,isolation,use/complex-style-resolving-order,clipPath/mask, filters and text (display/visibilityontspan, text indefs); - deliberate differences:
shapes/rect/rem-values(resvg does not supportrem, 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-sizeandnegative-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.Textreads 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-rotateandmixed-languages-with-tbare 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 (vmtxtop side bearing plus the glyph's top) puts them; no rule explains the amount, sojapanese-with-tb(2.45%),tb-and-punctuation(0.96%) and the underline variants stay open.tb-with-rotateandtb-with-rotate-and-underlineare marked undefined behavior in the suite.lengthAdjust/verticalstretches along the column, which this does not do, andhanging-on-verticalmoves 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 ignoresopacityon atspan; SVG 2 and browsers apply it) anddirection/rtl— withdirection: rtlwe anchorstartat 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):
| 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-referencingandrecursive-on-childdiffer 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-hrefis 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 (
viewBox80 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 12imagetests (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, sixtransformtests andsvg/no-sizeandsvg/preserveAspectRatio=nonediffer 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 (
defs4,svg/funcIRI-*3,systemLanguageondefsand 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-textandon-text-path,image/embedded-svg-with-text). - Deliberate differences, 3:
svg/zero-sizeandsvg/negative-size(see above) andsvg/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-dasharrayandstroke-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 oneven-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 aclip-pathinobjectBoundingBoxunits is resolved against; the clip step's;image-rendering/high-quality,optimizeSpeed,optimizeSpeed-on-SVG(2.6 to 2.8%) andon-feImage(filters): the image step's;context/with-pattern-in-use(0.83%) andwith-pattern-objectBoundingBox-in-use(21.6%): pattern tile period, as described under paint servers;marker/orient=auto-on-M-C-C-4and-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 forrgba()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.
- Document model and shapes (done). The core render-at-size hook and rewrite; typed document; presentation attributes and the
styleattribute; the render tree forg, shapes andpath; solid fill and stroke with all stroke properties; transforms,viewBox,preserveAspectRatio, nestedsvg, basicuse/symbol; region rendering; bounding-box natural size. Claim:shapesand the stroke/fill groups ofpaintingpass at a stated rate, andThumbnailof an SVG renders at the target size (compared against rasterizing then resizing). Result: 96.2% ofshapes; the render-at-size output equals a direct render within 1 level and differs from a raster downscale (see Results). - CSS and structure (done).
<style>, selectors, cascade, inheritance,currentColor,switch,systemLanguage,usebudgets and cycle handling,data:images,transform-origin. Claim:structurepasses at a stated rate; the CSS cost limits are tested with hostile stylesheets. Result: 77.0% ofstructureon its own, with the reasons for the rest above; every style, switch andusetest that does not need another step passes. - Paint servers (done). Gradients (including focal radial and gradient transforms), patterns,
paint-order,context-fill/context-strokethroughuse, plus the brush additions in the core. Claim:paint-serverspass rate. Result: 146 of 154 (94.8%); see Paint servers. - Painting extras (done, see Markers and blending). Markers,
mix-blend-mode,isolation, group opacity layers,shape-rendering,image-rendering,visibility,display. Claim:paintingpass rate. - Clipping and masking (done).
clipPath,mask, viewport clipping; the layer-multiply op (Image.ApplyMask). Claim:maskingpass rate (see Results). - Filters, part one (done). The filter graph, regions and subregions, linear-light float evaluation, and the primitives that map onto existing operations,
feDropShadowand the CSS filter functions. Claim: the correspondingfiltersdirectories. - 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
filtersfiles (97.3%, with the suite's fonts; 298 before this step), no test of any other category changed. - Text (done, see Text results).
text/tspan/textPath/tref, per-character positioning through the public cluster API inOneImage.Text, fonts fromSvgFontOptions, fallback, decorations, bidirectional text, paint servers on text. Claim:textpass rate 72.8% (279 of 383); variable and color fonts wait onOneImage.Text. - 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, 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 |