Overview
Donner implements all 17 SVG filter primitives from the Filter Effects Module Level 1 spec, plus CSS shorthand filter functions (blur(), brightness(), drop-shadow(), etc.). Filters run on both shipping backends, TinySkia (CPU) and Geode (GPU); the removed full-Skia backend had its own native lowering path.
Guarantees:
- All 17 primitives render correctly with in/result named buffer routing.
- filterUnits (objectBoundingBox, userSpaceOnUse) and primitiveUnits are fully supported.
- color-interpolation-filters (linearRGB/sRGB) is handled per-primitive.
- CSS shorthand filter functions map to the same filter graph as SVG <filter> elements.
- The resvg test suite passes on both backends, excluding documented skips for unimplemented features and known bugs (see the skip list in donner/svg/renderer/tests/resvg_test_suite.cc).
Tracking: #151
Architecture Snapshot
Data Flow
SVG DOM Filter System Renderer
--------- ------------- --------
<filter> FilterSystem:: RendererDriver::
<feBlur> ──parse──> buildFilterGraph() ──> resolveFilterGraph()
<feOffset> FilterGraph { preRenderFeImageFragments()
... nodes[] renderer.pushFilterLayer()
filterRegion ... render source graphic ...
primitiveUnits renderer.popFilterLayer()
} ├─ TinySkia: FilterGraphExecutor
└─ Geode: GeodeFilterEngine
Component Layers
| Layer | Location | Responsibility |
| SVG element classes | donner/svg/SVGFe*.h | DOM API, attribute accessors |
| Attribute parsing | donner/svg/parser/AttributeParser.cc | XML attribute -> ECS component |
| ECS components | donner/svg/components/filter/ | Data storage (FE*Component, FilterGraph) |
| Filter system | donner/svg/components/filter/FilterSystem.cc | DOM -> FilterGraph construction |
| Renderer driver | donner/svg/renderer/RendererDriver.cc | Orchestrates capture + graph execution |
| TinySkia executor | donner/svg/renderer/FilterGraphExecutor.cc | CPU pixmap-based graph execution |
| TinySkia filter lib | third_party/tiny-skia-cpp/src/tiny_skia/filter/ | Pure pixel math (all primitives) |
| Historical full-Skia backend | origin/skia_archive | Former native SkImageFilter DAG construction |
Filter Graph Model
FilterGraph {
nodes: vector<FilterNode>
filterRegion: optional<Box2d>
primitiveUnits: PrimitiveUnits
colorInterpolationFilters: ColorInterpolationFilters
userToPixelScale: Vector2d
}
FilterNode {
primitive: FilterPrimitive
inputs: vector<FilterInput>
result: optional<RcString>
x, y, width, height: optional<Lengthd>
}
Backend Execution
TinySkia backend (FilterGraphExecutor.cc):
- Allocates FloatPixmap buffers for each intermediate result.
- Executes nodes in document order, delegating pixel math to the tiny-skia-cpp filter library.
- Handles linearRGB/sRGB conversion, primitive subregion clipping (rotation-aware through filterFromDevice), and named buffer routing.
Historical full-Skia backend (removed in 2026-04, archived at origin/skia_archive):
- Built an SkImageFilter tree for all 17 primitives.
- Captured the source graphic into a raster SkSurface.
- Applied the filter DAG with rotation-aware region clipping through SkPath.
- Fell back to the shared FilterGraphExecutor for transformed blur chains and rotated feTile.
feImage Fragment References
feImage with href="#elementId" renders a same-document element into the filter output:
- preRenderFeImageFragments() creates an offscreen renderer and renders the referenced element. Elements in <defs> get shadow rendering instances from RenderingContext::createFeImageShadowTree. A recursion guard (feImageFragmentGuard_) prevents infinite loops.
- The fragment is rendered at its natural document position (no surfaceFromCanvasTransform_ offset).
- The filter pipeline resolves the primitive subregion in user space and positions the content at its unclipped origin. Omitted coordinates default individually to the filter region origin; primitive/filter clipping does not shift the content. Normal referenced geometry keeps its natural dimensions.
- No host shear: TinySkia offsets targetRect by the resolved origin converted to pixels. Geode encodes the inverse offset in the fragment image shader parameters.
- Host has shear/rotation: The existing fragment transform composes viewBoxScaleInv * Translate(primitiveOrigin) * deviceFromFilter. The CPU path rasterizes through RasterizeImagePremultiplied; Geode encodes its inverse in image parameters.
fragmentRegionTopLeft on filter_primitive::Image carries the fallback filter-region origin. Explicit or partially specified primitive subregions retain their unclipped resolved coordinates for placement separately from their pixel-space clipping bounds.
//donner/svg/renderer/tests:renderer_public_api_tests enforces exact complete/partial-coordinate, protrusion and translated-source placement in FeImageFragmentUsesItsOwnSubregionOrigin, using nearest sampling. //donner/svg/renderer/tests:resvg_test_suite_default_text enforces the three with-x-y comparisons with ordinary budgets. The configured Geode counterparts are //donner/svg/renderer/tests:renderer_public_api_tests_geode and //donner/svg/renderer/tests:resvg_test_suite_geode; full-text coverage uses the API target with --config=text-full and //donner/svg/renderer/tests:resvg_test_suite_max. These bounded cases do not establish arbitrary host scale, rotation, skew or reflection correctness.
Color Space Handling
Filter primitives default to linearRGB per the color-interpolation-filters property. CSS shorthand functions always use sRGB.
The executor wraps each node: convert inputs to the node's color space, execute the primitive, tag the output. The final output converts back to sRGB before compositing.
Conversion uses LUT-based srgbToLinear() / linearToSrgb() in the tiny-skia-cpp library.
Coordinate Space Handling
primitiveUnits controls how primitive subregion coordinates and attribute values are interpreted:
- userSpaceOnUse (default): Values are in the user coordinate system (CSS pixels).
- objectBoundingBox: Values are fractions of the filtered element's bounding box.
The executor resolves all coordinates to absolute pixel values before invoking filter operations.
Under host rotation/skew, deviceFromFilter transforms between filter user space and device pixels. The filter region is clipped using per-pixel point-in-rect testing against the user-space rectangle (not the AABB) to produce correct rotated boundaries.
API Surface
SVG Elements
Each filter primitive has a corresponding SVGFe*Element class (e.g., SVGFEGaussianBlurElement).
Renderer Interface
void pushFilterLayer(const FilterGraph& filterGraph, const optional<Box2d>& filterRegion);
void popFilterLayer();
CSS Filter Property
CSS filter property supports both url() references and shorthand functions:
filter: blur(5px) brightness(1.2);
filter: url(#myFilter) grayscale(50%);
Shorthand functions map to equivalent filter graph nodes internally.
Security and Safety
- Resource exhaustion: Filter region size is bounded (4096px expansion limit). Extreme blur radii and offsets are capped.
- Structural limits fail closed: A filter graph holds at most 32 primitives, counting every stage of a filter property together (each url() filter's primitives plus shorthand functions). Within a filter, feMerge takes at most 16 inputs, the tableValues list of any feFunc* element at most 1024 values whatever its type, and feConvolveMatrix an order of at most 64 on each axis and at most 4096 kernel values. A filter whose authored attributes exceed any of these limits is never truncated or partially applied: it fails closed with a warning naming the filter, the element it applies to is not rendered, and independent content still renders. An animated value over a limit, including an animated list of more than 64 numbers, is ignored and the base value renders.
- Invalid parameters follow the specification: Values that are wrong rather than too large, such as a kernel value count that does not match its order or a color matrix with the wrong number of values, keep the error handling the specification defines for that primitive.
- feImage fetching: Same-document fragment references use a recursion guard. External href follows the existing resource loading policy.
- feDisplacementMap: Cross-origin in2 produces transparent black per spec.
- Numeric overflow: Intermediate pixel values are clamped to [0, 1] at each pipeline stage.
- Parsing: Filter attribute parsers are fuzz-tested through the SVG parser fuzzing harness.
Convolution Bias
For feConvolveMatrix with preserveAlpha="false", alpha is the divided convolution sum plus unscaled bias. RGB receives bias multiplied by that raw resulting alpha, before clamping. Stored alpha is clamped to [0, 1] and RGB to [0, stored alpha]. With preserveAlpha="true", the filter convolves straight RGB, adds unscaled bias, and premultiplies by the unchanged source alpha. See Filter Effects 1, section 9.9.
Performance Notes
- Blur: Uses 3-pass box blur approximation for sigma >= 2.0 (O(w*h) per pass, using a running sum). Small sigma uses discrete Gaussian kernel.
- Morphology: Currently O(w*h*rx*ry). Could use van Herk/Gil-Werman for O(w*h).
- ConvolveMatrix: Full 2D convolution, O(w*h*orderX*orderY). Not separable.
- Lighting: Per-pixel normal computation + light vector. Spotlight uses per-pixel cone test.
- Historical full-Skia backend: the removed native SkImageFilter DAG avoided intermediate buffer copies for most graphs.
Testing and Observability
Test Targets
| Target | Backend | Description |
| //donner/svg/renderer/tests:resvg_test_suite_default_text | TinySkia | Full resvg golden image suite |
| //donner/svg/renderer/tests:renderer_tests | TinySkia | Deterministic golden tests |
| //donner/svg/renderer/tests:filter_graph_executor_tests | TinySkia | Unit tests for executor |
| //donner/svg/renderer/tests:convolve_matrix_bias_tests | TinySkia | Literal byte/float convolution bias expectations |
| third_party/tiny-skia-cpp/... | N/A | Per-primitive pixel math unit tests |
Adding Filter Tests
- Add an SVG fixture in donner/svg/renderer/testdata/ or use the resvg test suite.
- For resvg tests, add an INSTANTIATE_TEST_SUITE_P entry in resvg_test_suite.cc.
- Tests that differ by <100 pixels at threshold 0.01 need no override entry.
- Use `Params::WithThreshold()` for expected rendering differences (e.g., bilinear interpolation).
- Use `Params::Skip()` only for UB cases or unimplemented features (requires approval).
Verbose Debugging
Set `DONNER_RENDERER_TEST_VERBOSE=1` and `LLM=0` when running tests to get per-pixel diff output, transform traces, and filter pipeline debug logging.
Per-Primitive Reference
Primitive Summary
| Primitive | tiny-skia-cpp API | Skia API | Notes |
| feGaussianBlur | filter::gaussianBlur | SkImageFilters::Blur | 3-pass box blur approx |
| feFlood | filter::flood | SkImageFilters::Shader | Solid color fill |
| feOffset | filter::offset | SkImageFilters::Offset | Integer + bilinear subpixel |
| feColorMatrix | filter::colorMatrix | SkColorFilters::Matrix | matrix/saturate/hueRotate/luminanceToAlpha |
| feComposite | filter::composite | SkImageFilters::Blend/Arithmetic | Porter-Duff + arithmetic |
| feBlend | filter::blend | SkImageFilters::Blend | CSS blend modes |
| feComponentTransfer | filter::componentTransfer | CPU rasterization | LUT-based per-channel |
| feDropShadow | decomposed | decomposed | flood+composite+offset+blur+merge |
| feMorphology | filter::morphology | SkImageFilters::Dilate/Erode | erode/dilate |
| feConvolveMatrix | filter::convolveMatrix | CPU rasterization | Full 2D convolution |
| feTile | filter::tile | CPU rasterization | Modular tiling |
| feTurbulence | filter::turbulence | CPU rasterization | SVG-spec Perlin noise |
| feImage | renderer-only | renderer-only | External image / fragment ref |
| feDisplacementMap | filter::displacementMap | CPU rasterization | Channel-based displacement |
| feDiffuseLighting | filter::diffuseLighting | CPU rasterization | SVG-spec spotlight |
| feSpecularLighting | filter::specularLighting | CPU rasterization | Phong model |
Light sources (feDistantLight, fePointLight, feSpotLight) are children of lighting primitives. Point/spot light coordinates are scaled from user space to pixel space through deviceFromFilter. Z coordinates use the RMS scale factor.
RasterizeTransformedImagePremultiplied
Shared utility used in FilterGraphExecutor.cc (and previously by the removed full-Skia backend) for rasterizing images through affine transforms. Used by:
- feImage external images with host rotation/skew
- feImage fragment references with host shear
Uses bilinear interpolation with pixel-center sampling. Sample centers outside the source image produce transparent pixels (no edge clamping) to prevent images from bleeding beyond their preserveAspectRatio placement rectangle.
Limitations and Future Extensions
- feImage fragment references render through an 8-bit intermediate buffer; rendering directly into the float filter buffer would eliminate minor edge-pixel differences.
- BackgroundImage / BackgroundAlpha standard inputs are not implemented (require CSS compositing isolation groups).
- Filter application on the root <svg> element is not supported.
- CSS backdrop-filter is not supported.
- Filter parameter animation is not supported (separate animation milestone).
- Color space in mixed url() + CSS filter chains (filter: url(#f) grayscale()) has ambiguous spec behavior; Donner currently uses the SVG default (linearRGB) for the url() portion.