This page collects the questions that come up most often when first using Donner: cases that parse without error, compile without warning, and then render nothing. Each answer starts with the short version, then explains why Donner behaves that way and what to do about it. If you are new to Donner, read Getting Started and Donner API first; this page assumes you already have an donner::svg::SVGDocument.
Donner targets the SVG 2 specification, so "correct" here means what SVG 2 specifies, which sometimes differs from SVG 1.1 or from a particular browser. Where that distinction matters, the answer calls it out.
Almost always because the gradient has no <stop> children, or its objectBoundingBox coordinate space collapsed to zero. A gradient with zero stops is not an error and paints nothing; add at least one <stop> and it appears.
Donner resolves paint in two stages, and the split explains most gradient problems. First, at style-resolution time, fill="url(#g)" is resolved to a reference: this only checks that #g exists and is a gradient or pattern, not that it is usable. Second, at draw time, that reference is turned into a shader, and this is where stops and bounding boxes are inspected. A stopless gradient therefore passes stage one and produces nothing in stage two.
The specific rules Donner implements (all matching SVG 2):
A gradient that renders nothing does not emit a warning on the normal render path, so there is no log line to wait for. Diagnose it by checking the stops and the bounding box. See <linearGradient>, <radialGradient>, and donner::svg::SVGGradientElement, and the closely related Why did parsing succeed but my element still not render?.
Two different failures produce the same symptom: the glyphs are painted but not visible (a fill problem), or the glyphs were never produced (a font or shaping problem). Check the fill first, because it is the more common one.
The fill. Text obeys the ordinary fill property, which defaults to solid black. Black text on a transparent canvas is fine, but black text on a black or dark background is invisible, and fill="none" or fill-opacity="0" paints nothing. This is not text-specific; it is the same default used for shapes.
The font. Donner does not read system fonts. There is no fontconfig, CoreText, or DirectWrite lookup anywhere in the text path. Fonts come from @font-face rules or from calling donner::svg::FontManager::loadFontData() yourself, plus one embedded fallback (Public Sans). Text with no matching font does not vanish: it falls back to Public Sans and renders with Latin coverage. If your Latin text renders but your styled font does not, your @font-face source did not load (see below). For CJK, Arabic, or emoji text, see the shaping tier below.
The shaping tier. The default build shapes text with stb_truetype: one code point maps to one glyph through the font's cmap, with kern-table kerning and nothing else. No GSUB/GPOS means no ligatures, no mark positioning, no cursive joining, and no contextual substitution. Complex-script shaping (Arabic, Indic), OpenType features, WOFF2 web fonts, and color-emoji bitmap glyphs are available only in the HarfBuzz tier, built with --config=text-full. Under the default build, a code point the resolved font cannot map becomes .notdef (glyph 0), and Donner silently skips it rather than drawing a tofu box, so unsupported scripts render as empty space.
Other ways to get invisible text: a zero font-size produces a zero scale and no glyphs; an @font-face whose only source is url() needs a ResourceLoaderInterface to fetch it (with none provided, Donner warns and falls back); a local()-only @font-face never resolves because system font lookup is not implemented; and building with --config=tiny or --config=no-text compiles the text engine out entirely, so <text> renders nothing.
See <text>, <tspan>, <textPath>, and Elements: Text.
Use setD() and d() when you are round-tripping SVG source or letting the CSS cascade drive the geometry; use setSpline() when you already have geometry in hand and want to skip parsing entirely. They are two entry points to the same donner::svg::SVGPathElement; the choice depends on whether you start from a string or from geometry.
setD(RcString) stores the path-data string and marks the element dirty; the string is parsed lazily, the next time the computed geometry is built. d() returns exactly that stored string, not a normalized re-serialization. Use it when the d value comes from a file, an attribute, or a d: CSS property, because it participates in the normal attribute and cascade machinery.
setSpline(const Path&) takes a pre-built donner::Path and installs it as an override. It clears the d attribute, so afterward d() returns the empty string: Donner does not reverse a spline back into path-data text. Use it when you compute geometry programmatically, because it bypasses the parser completely (no tokenizing, no validation, no per-rebuild reparse cost).
To read back the resolved geometry regardless of which one you used, call computedSpline() on the donner::svg::SVGGeometryElement base class; it returns std::optional<Path> and is empty when the path is empty. A malformed d string never throws and never aborts the parse: Donner keeps whatever it parsed before the error (path parsing is deliberately partial) and records a warning, so a slightly broken d gives you a truncated path, not an empty document. See <path> and Why are my stroke widths wrong when I scale? for how that geometry then gets stroked.
Usually the symbol has no viewBox, and without a viewBox a symbol does not scale. Setting width/height on the <symbol> or the <use> without a viewBox only changes the clipping rectangle, not the content, so the artwork is cropped instead of resized. Add a viewBox and the content scales to fit.
A <symbol> establishes its own SVG viewport, much like a nested <svg>. The viewBox maps the symbol's internal coordinates onto that viewport, which is what makes the content stretch or shrink to the box the <use> provides. With a viewBox, the same symbol renders correctly at 40x40 or 120x120. Without one, the coordinates are taken literally in the parent's user space and the <use> dimensions merely clip.
The sizing rules to be aware of:
The full coordinate chain, with worked diagrams, is documented in <symbol> Usage: Coordinate System and Sizing Behavior. See also donner::svg::SVGSymbolElement and donner::svg::SVGUseElement.
Donner targets the Filter Effects specification rather than Chrome's output, and validates against the resvg test suite rather than against a browser. The default backend is Donner's own CPU pixel executor (tiny-skia), so pixel-exact parity with Chrome's GPU rasterizer is not a goal, and small differences in anti-aliasing and blur edges are expected.
Donner implements all 17 filter primitives plus the CSS shorthand functions (blur(), drop-shadow(), and friends), with correct in/result buffer routing, filterUnits, primitiveUnits, and per-primitive color-interpolation-filters. Filter primitives default to linearRGB interpolation per spec (CSS shorthand functions use sRGB), which is correct but can be surprising when comparing against a tool that interpolates in a different color space.
The behavioral differences, beyond sub-pixel variation:
For the architecture, backend details, and feImage handling, see SVG Filter Effects and <filter>.
Parse the source, check for an error, and pass the document to a donner::svg::Renderer. There is no need to set a canvas size if the SVG declares its own width/height/viewBox.
From there, pick your output. renderer.save("out.png") writes a PNG; renderer.takeSnapshot() returns a donner::svg::RendererBitmap with the raw RGBA pixels; renderer.width() and renderer.height() give the dimensions. To force a specific output size (equivalent to resizing a browser window), call document.setCanvasSize(w, h) before drawing, or document.useAutomaticCanvasSize() to go back to auto.
Two things to watch for. First, the source buffer you pass to ParseSVG is referenced internally by the returned SVGDocument, so it must outlive the document; do not free it while the document is still alive. Second, ParseSVG takes a ParseWarningSink& by reference and the argument is not optional; pass ParseWarningSink::Disabled() to discard warnings (it skips the formatting work entirely), but read Why did parsing succeed but my element still not render? first. A complete runnable version is examples/cmake_consumer/main.cc.
Yes. The buffer you pass to SVGParser::ParseSVG is referenced internally by the returned donner::svg::SVGDocument, not copied wholesale, so it must outlive the document. Free it too early and you get a dangling reference, not a clean error.
This is the most common lifetime mistake in Donner. ParseSVG takes a std::string_view, and the document holds onto pieces of that memory (for example, string data it did not need to allocate a fresh copy of). If the std::string, std::vector<char>, or other owner of that text goes out of scope while you are still holding the SVGDocument, later DOM reads or renders will touch freed memory.
Keep the source owner alive at least as long as the document. Store them together, or give the source the same or wider scope as the SVGDocument. If you cannot guarantee that, copy the SVG text into a buffer you own and keep it. See How do I load an SVG and render it to pixels in the fewest lines? for the full parse-to-pixels flow.
Keep one donner::svg::Renderer alive and call draw() again after your edits; do not reparse the SVG and do not construct a fresh renderer per frame. Donner's renderer is two-phase: it builds and caches a render tree, and DOM mutations mark only the affected elements dirty, so a redraw recomputes just what changed.
The efficient loop is: find the element (document.querySelector("#id")), change it (setAttribute, setStyle, or a typed setter like SVGCircleElement::setCx), then call renderer.draw(document) again. The unchanged subtree is reused from the cached render tree. This is much cheaper than re-parsing, which discards the cached work.
When applying a batch of edits, wrap them so they are treated as one logical update rather than many. SVGDocument::withWriteAccess() groups mutations into a single mutation revision, which is cheaper than entering document access once per setter:
For an interactive viewer that draws every frame, use the frame lifecycle (beginFrame() / endFrame()) from the donner::svg::RendererInterface API instead of draw(), which is a convenience wrapper around it. If more than one thread touches the document, read What threads can I touch the DOM from? first. See also Donner API for the DOM manipulation surface.
Donner implements a broad SVG 2 static subset with documented gaps in advanced text, interaction properties, rendering hints, clipping, masking, image sampling, and affine non-scaling strokes. It also intentionally omits features SVG 2 removed or deprecated. The general rule for an unsupported feature is that it is ignored quietly: the document still parses, and the affected element either renders without that feature or does not render at all. You rarely get a hard error. See the README feature matrix for the current support summary.
Removed or deprecated in SVG 2, and intentionally not implemented:
Other current-feature gap:
Two behaviors that are often mistaken for bugs:
The authoritative list, with per-feature rationale and the modern replacement, is Unsupported SVG 1.1 Features.
A successful parse only means the XML and CSS were well-formed; it says nothing about whether an element is renderable. The common causes are being inside <defs>, having display:none, collapsing to zero size, or being in the wrong XML namespace. Start by printing the parse warnings, because the namespace case reports one.
The exclusion rules Donner applies while building the render tree:
To see the warnings, pass a live donner::ParseWarningSink and iterate it:
One more case to check if your CSS is not applying: SVGParser::Options::disableUserAttributes defaults to false, so non-presentation (user-defined) attributes are retained and attribute selectors like rect[data-role="status"] match. If you set it to true to skip them for performance, those selectors stop matching. If the missing element is a gradient fill, see Why does my gradient not show up?.
Donner splits problems into two buckets and reports them through two different channels. Fatal problems that stop parsing come back as an error on the donner::ParseResult; non-fatal problems that Donner recovered from (ignored attributes, unknown namespaces, malformed but partially parsed values) are collected in a donner::ParseWarningSink that you pass in. If your document rendered wrong but did not fail, the warning sink is the first place to look.
Pass a live sink, then check it after handling the error:
Both ParseResult::error() and each ParseDiagnostic stream a human-readable message with the exact line and column, so the location in the source is explicit. To turn diagnostics off, pass ParseWarningSink::Disabled(); it discards warnings without paying the cost of formatting them. Before disabling warnings on a document that does not render correctly, read Why did parsing succeed but my element still not render?. The namespace and ignored-attribute cases that cause silent non-rendering are reported here and nowhere else.
Depending on @donner gives you the parser, DOM, and renderer, and nothing else. The editor is a separate Bazel target (//donner/editor:editor) that depends on the core libraries, never the other way around, so linking Donner does not pull in ImGui, GLFW, or any editor code.
The @donner alias resolves to //:donner, whose only dependency is //donner/svg/renderer. That in turn brings in the SVG DOM (//donner/svg), its components, and a rendering backend (tiny-skia by default). No part of that graph reaches into //donner/editor. If you want even less, you can depend on the narrower targets directly: //donner/svg and //donner/svg/parser give you parsing and the DOM with no renderer, and //donner/css gives you the CSS engine standalone (see Using the CSS API).
The svg_viewer example links one optional editor helper for its text-editing demo, but the parser, DOM, and renderer it uses come from the core libraries. A binary that depends on @donner does not pull in that helper. For build setup, see Getting Started (Bazel) and its CMake section.
By default, exactly one: the thread that created the document. donner::svg::SVGDocument starts in single-threaded mode, which is the lowest-overhead path and asserts that DOM calls come from the owning thread. If you need worker threads to read or mutate the same document, opt in first.
Enable concurrent access with document.setThreadingMode(ThreadingMode::ConcurrentDom). After that, the public DOM APIs on SVGDocument and SVGElement are safe from multiple threads: reads can run concurrently, and writes are coordinated so the document stays consistent. Rendering is frame-based; a render observes a stable snapshot of the document for that frame, so a mutation made mid-render does not appear in the frame already being drawn; it appears in a later one.
For anything more than an occasional single call, use the batching APIs SVGDocument::withReadAccess() and SVGDocument::withWriteAccess(): they hold one access scope for the whole operation instead of re-entering document access on every call, which matters for selector-heavy traversal and grouped mutations. And if you reach past the public API to raw ECS state (SVGDocument::registry() or SVGElement::entityHandle()), you must hold an explicit access guard in concurrent mode; the guards assert if you forget.
The full contract, including how removed elements stay alive while you hold a handle to them, is in SVG DOM Threading And Lifetime, with the implementation-level details in Multithreading.
SVG specifies this behavior: stroke-width is measured in user units, so scaling the coordinate system (a large viewBox mapped into a small viewport, or a transform) scales the stroke along with the geometry. A shape scaled 2x gets a stroke twice as thick.
Use vector-effect="non-scaling-stroke" to hold the stroke width and dash pattern constant in the renderer's host (root canvas) space. Donner applies the property in both renderer backends, and viewport culling inflates the centerline bounds in whichever space the stroke is actually expanded in, so a downscaled non-scaling stroke is not clipped merely because its authored path bounds are small. Those culling bounds are the exact host-space stroke extent only for strokes that are drawn in host space; for the local-space cases below the cull uses the adjusted local width.
For paths, the centerline is transformed forward into host space and dashed and stroked there, so uniform scaling, non-uniform scaling, and shear all preserve the authored width and dash lengths. Stroke paint servers remain authored in the element's user space and are mapped onto the host-space outline. Text strokes and pattern-painted strokes are stroked in local space, so under an anisotropic transform their drawn width is the geometric mean of the two axis widths. See <path> and When should I use SVGPathElement's API instead of setting the d string? for setting geometry.