Donner SVG main
0.8.0 ยท 382bd0190.8.0 main
SVG editor and embeddable C⁠+⁠+⁠20 engine.
Loading...
Searching...
No Matches
Frequently Asked Questions

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.

Why does my gradient not show up?

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):

  • No stops: the element is not painted. If you wrote the fallback form fill="url(#g) red", the fallback color is used instead; with no fallback, the shape is not filled.
  • Exactly one stop: paints as a solid color of that stop. This is spec behavior, not a bug.
  • gradientUnits="objectBoundingBox" (the default) on a shape with a zero-area bounding box: no gradient. The common case is a gradient stroke on a perfectly horizontal or vertical line, where one bounding-box dimension is zero. The gradient cannot be mapped, so Donner uses the fallback color if one was given and otherwise draws nothing. stroke="url(#g) green" on a flat line paints green.
  • href template gradients: a gradient with no stops of its own but an href to another gradient inherits that gradient's stops and attributes (x1, y1, spreadMethod, and so on). A gradient with its own stops ignores the template's stops but still inherits unspecified attributes. Pointing href at a non-gradient element is ignored with a parse warning.

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?.

Why is my text invisible, or not shaping correctly?

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.

When should I use SVGPathElement's API instead of setting the d string?

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.

Why is my <symbol> the wrong size when I instantiate it with <use>?

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:

  • Missing width/height on both <symbol> and <use> default to 100%, meaning the symbol fills its container rather than snapping to some intrinsic size.
  • width/height on <use> override the symbol's own width/height. x/y do not override; the symbol's and the use's x/y add together.
  • preserveAspectRatio defaults to xMidYMid meet, so a symbol dropped into a box with a different aspect ratio is letterboxed, not stretched. Use preserveAspectRatio="none" to stretch.
  • refX/refY default to the top-left corner, which is not the same as 0. If you expected the symbol centered on the <use> point, set refX/refY explicitly.

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.

Why doesn't my filter output match Chrome's exactly?

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:

  • enable-background / BackgroundImage / BackgroundAlpha do nothing. These SVG 1.1 filter inputs were removed in SVG 2. Donner treats them as unresolved references, which produce transparent black. Use CSS mix-blend-mode and isolation for backdrop effects. See What SVG features does Donner not support, and what happens if I use them?.
  • The filter region is bounded. Region expansion is capped at 4096px and extreme blur radii and convolution kernels are clamped, as a resource-exhaustion guard. A filter that expands the region without bound in a browser may be clipped in Donner.

For the architecture, backend details, and feImage handling, see SVG Filter Effects and <filter>.

How do I load an SVG and render it to pixels in the fewest lines?

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.

#include "donner/svg/SVG.h"
using namespace donner;
using namespace donner::svg;
std::string_view source = /* your SVG text */;
ParseResult<SVGDocument> maybeDocument = parser::SVGParser::ParseSVG(source, warnings);
if (maybeDocument.hasError()) {
std::cerr << "Parse error: " << maybeDocument.error() << "\n";
return 1;
}
SVGDocument document = std::move(maybeDocument.result());
Renderer renderer;
renderer.draw(document);
A parser result, which may contain a result of type T, or an error, or both.
Definition ParseResult.h:17
bool hasError() const noexcept
Returns true if this ParseResult contains an error.
Definition ParseResult.h:110
T & result() &
Returns the contained result.
Definition ParseResult.h:51
ParseDiagnostic & error() &
Returns the contained error.
Definition ParseResult.h:81
Represents a parsed SVG document containing a tree of SVGElement nodes.
Definition SVGDocument.h:106
Collects parse warnings during parsing.
Definition ParseWarningSink.h:29
Backend-agnostic renderer that resolves to the active build backend (tiny-skia by default,...
Definition Renderer.h:82
void draw(SVGDocument &document) override
Draws the SVG document using the active backend.
static ParseResult< SVGDocument > ParseSVG(std::string_view source, ParseWarningSink &warningSink, Options options={}, SVGDocument::Settings settings={}) noexcept
Parses an SVG XML document from a string (typically the contents of a .svg file).
Donner SVG library, which can load, manipulate and render SVG files.
Top-level Donner namespace, which is split into different sub-namespaces such as donner::svg and donn...

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.

Do I have to keep the SVG source string alive after parsing?

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.

How do I mutate the DOM and re-render efficiently?

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:

document.withWriteAccess([&](SVGDocumentMutation& mutation) {
std::optional<SVGElement> rect = document.querySelector("#status");
if (!rect) return;
mutation.setAttribute(*rect, "fill", "green");
mutation.setAttribute(*rect, "stroke", "black");
});
std::optional< SVGElement > querySelector(std::string_view selector)
Find the first descendant of the root <svg> element, in document order, that matches the given CSS se...
decltype(auto) withWriteAccess(Callback &&callback) const
Run a callback with scoped write access to this document.
Definition SVGDocument.h:819
Typed DOM mutation helper used by SVGDocument::withWriteAccess.
Definition SVGDocument.h:717
void setAttribute(SVGElement element, const xml::XMLQualifiedNameRef &name, std::string_view value)
Set an element attribute.

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.

What SVG features does Donner not support, and what happens if I use them?

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:

  • SVG fonts (<font>, <glyph>, <missing-glyph>) and the alternate-glyph family (<altGlyph>, <altGlyphDef>, <glyphRef>): not recognized. Use TrueType/OpenType or WOFF2 through @font-face.
  • <tref> and <cursor>: not implemented; use plain text and the CSS cursor property.
  • The CSS 2 clip: rect(...) property: not implemented. Use clip-path with an inset() shape.
  • enable-background, BackgroundImage, BackgroundAlpha: removed filter inputs; they resolve to transparent black. See Why doesn't my filter output match Chrome's exactly?.
  • xml:base: removed and not implemented (xml:space is honored for <text> whitespace).

Other current-feature gap:

  • xml:lang: retained by SVG 2 but not interpreted by Donner for text language or font selection. This is separate from the implemented systemLanguage conditional-processing attribute.

Two behaviors that are often mistaken for bugs:

  • vector-effect="non-scaling-stroke" is supported. It keeps stroke width and dash lengths constant in host (root canvas) space under uniform and non-uniform scaling and shear: the path centerline is transformed into host space before it is dashed and stroked, so an anisotropic transform cannot stretch the stroke. See Why are my stroke widths wrong when I scale?.
  • An unknown element becomes an SVGUnknownElement. It parses fine and lives in the DOM, but it has no geometry and renders nothing. See Why did parsing succeed but my element still not render?.

The authoritative list, with per-feature rationale and the modern replacement, is Unsupported SVG 1.1 Features.

Why did parsing succeed but my element still not render?

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:

  • display: none removes the element and its entire subtree from rendering. (visibility: hidden is different: the element still participates in layout but is not painted.)
  • Children of <defs> are non-renderable by design; they only appear when referenced, for example through <use> or a paint url(). The same is true of the contents of <mask>, <pattern>, and <symbol>, which render only when instantiated.
  • Zero-size geometry drops out. A rect, image, or nested svg with width="0" or height="0", or a degenerate viewport, has empty bounds and is not drawn.
  • Unknown tags become SVGUnknownElement and render nothing (see What SVG features does Donner not support, and what happens if I use them?).
  • Wrong or missing namespace is the easiest to miss. An element whose namespace is not http://www.w3.org/2000/svg is dropped from the tree with a warning. This covers pasting an SVG fragment with no xmlns declaration and seeing nothing render. Either add the xmlns declaration or set SVGParser::Options::parseAsInlineSVG = true.

To see the warnings, pass a live donner::ParseWarningSink and iterate it:

auto maybeDocument = parser::SVGParser::ParseSVG(source, warnings);
// ... handle hasError() ...
if (warnings.hasWarnings()) {
for (const ParseDiagnostic& w : warnings.warnings()) std::cerr << " " << w << "\n";
}
const std::vector< ParseDiagnostic > & warnings() const
Access the collected warnings.
Definition ParseWarningSink.h:79
bool hasWarnings() const
Returns true if any warnings have been added.
Definition ParseWarningSink.h:82
A diagnostic message from a parser, with severity, source range, and human-readable reason.
Definition ParseDiagnostic.h:31

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?.

Something looks off but parsing succeeded. How do I get diagnostics?

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:

ParseResult<SVGDocument> maybeDocument = parser::SVGParser::ParseSVG(source, warnings);
if (maybeDocument.hasError()) {
std::cerr << "Fatal: " << maybeDocument.error() << "\n"; // includes line:column
return 1;
}
for (const ParseDiagnostic& w : warnings.warnings()) {
std::cerr << "Warning: " << w << "\n"; // also line:column + reason
}

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.

How do I embed Donner without dragging in the editor?

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.

What threads can I touch the DOM from?

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.

Why are my stroke widths wrong when I scale?

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.

Previous Next
Home Getting started