Status: future · Priority: medium · Depends on: Icons & SVG (subset + icon system), Graphics APIs (GPU path choice), Animations (choreography for later JS-driven animation)
Note: This is a future plan, not a commitment. The syntax and API shown here are proposals — they can be completely different when actually implemented.
We write this down as a team (Oct 2026): Morph renders raster images today and no SVG at all. For SVG support we decided to build our own engine instead of embedding a third-party one — for deep-level control over the render pipeline and for advanced dead-code elimination, so the final binary contains only the math of the SVG features the app actually uses. The rendering target is 1:1 with Chrome at best performance; JS-driven SVG animation arrives later, when animating with JS lands.
Companion product doc: Icons & SVG (the <svg> subset, morph-icons, morph icons:add). That page is the icon-system track; this page is the engine underneath it — Phase 0 here is the subset described there.
stbi_load in gl_renderer.h:529-540: PNG/JPEG/BMP/GIF/PSD/HDR — no SVG). Zero SVG parsing exists in crates/ or runtime/. An .svg file cannot be displayed at all.MORPH_FEATURE_SVG_* defines per capability, emitted from the actual element/attribute set found at build.@keyframes on SVG presentation attributes reuses the existing animation engine; JS-driven SVG animation waits for the imperative animation API (animations.md scope). SMIL is not planned.Every app needs resolution-independent graphics — icons, logos, illustrations — and raster formats fail that job structurally:
currentColor lets icons inherit text styling — dark mode and accent changes flow through with zero asset variants. Raster assets need a full duplicate set per theme.In short: SVG is the only format that is simultaneously scalable, small, themeable, designer-native, and animatable. That combination is why it gets an engine, not just a decoder.
| Piece | State |
|---|---|
Raster image decode (stbi_load → texture upload on compositor) |
✅ Shipped (render/gl_renderer.h:529, ui/image.h:20) |
| SVG parsing, DOM, rendering | ❌ Nothing — no matches for svg/SVG anywhere in crates/ or runtime/ outside vendored HarfBuzz headers (SVG color-emoji tables, unrelated) |
<svg> subset plan (path/rect/circle, viewBox, currentColor, CPU tessellation + cache) |
❌ Planned in Icons & SVG — becomes Phase 0 of this engine |
morph-icons / morph icons:add |
❌ Planned in Icons & SVG |
"Image support" in Morph therefore means raster formats only. SVG support starts from zero — which is exactly why owning the engine is affordable now: there is no legacy SVG path to migrate.
Qt's SVG story is widgets-era, frozen at a subset:
QSvgRenderer + QSvgWidget (plus QGraphicsSvgItem, and QSvgGenerator for writing SVG). Renders onto any QPaintDevice through QPainter — i.e. CPU raster through Qt's paint engine..svg through the SVG image-format plugin (raster-at-load → texture at one size, vector crispness lost on scale), render via QSvgRenderer into a QQuickImageProvider, or hand-author vectors with QtQuick.Shapes (a different, QML-native path API — not SVG).Net: Qt treats SVG as static pictures that get rasterized early. Good enough for icons; wrong for resolution-independent, animatable vector content.
Chrome treats SVG as a document, fully integrated into the browser pipeline:
SVGElement classes, SVGLength, path-segment lists, <use> shadow trees — scriptable through V8 like HTML.LayoutSVGShape family) and participate in style recalc, layout, and paint. Presentation attributes (fill, stroke-width, transform) are CSS-styled and therefore animatable by the same engines as HTML.<svg> (full document: DOM + style + script + interaction) vs SVG-as-image (<img src="a.svg">, CSS background-image) — rasterized, no scripts, no interaction, no external subresources.Net: maximum fidelity and dynamism, paid for with a DOM, a style engine pass, script bindings, and megabytes of always-on machinery — costs that only make sense when the input is an arbitrary stranger's document.
| Qt | Chrome | Morph (this plan) | |
|---|---|---|---|
| Input model | static files, known subset | arbitrary documents from strangers | static files, known at build |
| DOM / scripting | none (renders first frame) | full SVG DOM + V8 | none in v1; JS-driven animation later via the imperative animation API, not a live SVG DOM |
| Animation | none (static 1.2 Tiny) | SMIL + CSS + Web Animations | CSS @keyframes on presentation attributes (existing engine); JS later; SMIL never |
| Rendering backend | QPainter CPU raster |
Skia (shared with page) | Morph's own GL pipeline (SDF shaders, batch renderer, Flash/Forge) |
| Vector crispness on scale | lost (raster-at-load in Quick) | kept (re-raster / re-record) | kept (re-tessellate + per-size cache) |
| Subset policy | fixed 1.2 Tiny, forever | everything (plus compat quirks) | grows by build-gated capabilities; morph check names unsupported elements |
| Binary cost of SVG | QtSvg module always linked when used | whole browser | only the math of features used (MORPH_FEATURE_SVG_*) |
| 1:1 look target | no (subset rendering) | self (is the reference) | Chrome screenshots on a fixed corpus |
The same AOT argument from the animation work applies,_move for move:
morph build sees every .svg and every inline <svg> in the app. Chrome must support everything because page N+1 is unknown; Qt froze a subset for the same reason (can't negotiate per app). We can support exactly what the app uses — and prove it at build.currentColor from theme, animated attributes) takes the cache path. Neither Qt (raster-at-load) nor Chrome (raster-always-late) can precompute per app.morph check diagnostics and reject the rest by name at build — silent misrendering is a build error, not a runtime surprise.We evaluated the usual candidates — resvg (most complete static renderer), nanosvg/lunasvg (small parsers/rasterizers), ThorVG (retained-mode vector scene graph with its own animation and threading). We decided against all of them, for reasons that compound:
tiny-skia, font stacks, and more) or a C++ one (ThorVG with its own build) into every Morph binary contradicts the lean-binary story (162KB hello-world). Our engine is a few thousand lines of path math plus reuse of FreeType/HarfBuzz already shipped.morph check, rendering 1:1 with Chrome inside the subset — rather than a broad subset rendering approximately.Our decision as a team: we own the parser, the tessellator, and the paint lowering; we reuse FreeType/HarfBuzz for <text> shaping and the existing GL backend for raster. No new runtime dependency.
Elements: svg, path, rect, circle, ellipse, line, polyline, polygon, g, use (same-document), symbol, defs. Attributes: viewBox, fill (incl. currentColor inheriting text color and none), basic stroke + stroke-width, transform (translate/scale/rotate), opacity, fill-rule. Path commands: M L H V C S Q T A Z both cases. Tessellate to triangles on CPU at build for static SVGs; cache per (path, size) at runtime for layout-sized ones — the glyph-cache pattern from icons-svg.md.
linearGradient / radialGradient (both objectBoundingBox and userSpaceOnUse), clipPath, mask (luminance + alpha), pattern (static tiling), extended stroke (stroke-dasharray, linecap, linejoin, miterlimit), fill-opacity / stroke-opacity, <text> (shaped with the shipped HarfBuzz/FreeType pipeline — no new text engine), preserveAspectRatio full align/meet-or-slice semantics, nested svg, image inside SVG (raster only, same formats as <img>).
Explicitly deferred past 1:1: filter primitives (feGaussianBlur first, full filter graph later — each primitive is its own gated capability), foreignObject, color fonts inside SVG, SVG-as-image external subresources (matches Chrome's no-external-resources rule for image context).
Presentation attributes are style: fill, stroke, opacity, transform on SVG nodes animate with the existing @keyframes / transition machinery (fixing-animation.md §3). transform-box / transform-origin semantics per SVG2 for correct rotation centers. No new timing code — Phase 2 of the animation plan (spec-exact easings, shared solver) covers SVG ticks too.
When the imperative animation API lands (animations.md scope), SVG attributes become animatable targets through it — attribute writes flow through the same invalidation → tessellation-refresh → damage path as CSS animation. This is not a live SVG DOM with scriptable SVGElement objects (Chrome's model): it is JS writing attributes on Morph nodes, exactly like JS writing styles on HTML nodes. SMIL (<animate>, <animateTransform>) is never planned — CSS animations already cover declarative motion, and one declarative system is enough.
1:1 means pixels, not architecture: for every file in the parity corpus, Morph's render must match a Chrome screenshot within tolerance at the same viewport/DPR. Method:
meet vs slice, currentColor chains). Corpus lives in-repo; additions ship with the feature that needs them.shape-rendering honored (auto default, crispEdges disables AA) since icon work needs it.morph build; the binary carries triangles + paint records. Runtime tessellation runs only for layout-dependent sizes and animated attributes.MorphNodes — transform/opacity animation on them takes the same compositor path as HTML (see fixing-animation.md §3.4 and the planned compositor clock). Re-tessellation never runs for a pure transform/opacity animation.Mirrors the existing feature_set.rs + #ifdef MORPH_FEATURE_* pattern:
MORPH_FEATURE_SVG_PATH, _SVG_ARC, _SVG_GRADIENT, _SVG_CLIP, _SVG_MASK, _SVG_PATTERN, _SVG_FILTER_* (per primitive), _SVG_TEXT, _SVG_ANIMATE_CSS (animation hooks on SVG nodes).morph build scans the app's SVG corpus (files + inline <svg>) for used elements/attributes/commands and emits exactly the defines needed. An icon-only app (paths + fills) links no gradient, filter, mask, or text math.morph check reports per-file feature usage (svg-gradient: linearGradient used by 3 files → +gradient math) so size growth is visible and attributable — the same philosophy as the animation drop warnings..text; each capability adds KB-scale increments, never a step function. Full static SVG 1.1 stays well under resvg-equivalent weight because tessellation and text shaping reuse shipped code.viewBox + preserveAspectRatio: default xMidYMid meet letterboxes; slice crops; none stretches. Wrong default here misrenders every icon — test the full align matrix.currentColor chains: fill="currentColor" inherits the node's text color, including through <use> and theme changes at runtime (no bake-through of the resolved color).<use> / <symbol>: same-document references only in v1; style inheritance crosses the use boundary per spec (inheritable properties take the <use> context, not the <defs> context).objectBoundingBox (default, relative, the common case) vs userSpaceOnUse (absolute, must survive ancestor transforms) — different math, different bugs.A with large-arc/sweep combinations plus endpoint/center parameterization edge cases (zero radii → line, identical endpoints) per spec, not per intuition.d, lone M, unclosed subpaths with Z semantics for fill vs stroke.% lengths and user units: resolve against viewport/viewBox per attribute (x/y/width/height vs stroke-width rules differ).<text> shaping: reuse HarfBuzz/FreeType; textLength + lengthAdjust spacing/xAdvance adjustments; font fallback identical to UI text.url(#id) only in v1.morph check naming file + element + line; runtime renders the valid remainder (never a blank box without a diagnostic).d (path morphing) is not in scope — only paint/transform/opacity attributes animate; d changes re-tessellate discretely (Chrome behavior for non-interpolable values).Why not just embed resvg / ThorVG? Three compounding reasons (§7): our GL pipeline (damage tracking, SDF shaders, batching) can't consume a foreign rasterizer without losing frame-level optimizations; no vendor offers build-driven per-feature elimination, so every app would link all of static SVG 1.1; and vendoring a second SVG stack contradicts the lean-binary story. Forking one to retrofit DCE would cost more than owning the math.
Will SVG support bloat my binary?
Only by what is used (§11). No SVG at all → zero SVG code (feature defines stay off, like MORPH_FEATURE_ANIMATION today). Twelve monochrome icons → path/fill math, KB-scale. Gradients/filters/text link if and when the corpus uses them, and morph check shows the attribution.
Will my SVG look exactly like in Chrome?
Inside the supported subset, yes — that is the §9 contract, gated by corpus screenshots at 1x/2x. Outside the subset, morph check rejects the file's offending elements at build instead of misrendering. Chrome-quirk rendering (historical bugs) is intentionally not replicated; divergences are logged.
Can SVG be animated?
CSS @keyframes / transition on paint and transform attributes (Phase 2, reusing the animation engine — no new timing code). JS-driven animation arrives with the imperative animation API (Phase 3). SMIL (<animate>) will not be supported — one declarative system is enough. Path-morphing (d interpolation) is out of scope.
<img src="app.svg"> vs inline <svg>?
Both are supported, with Chrome's context split: inline SVG is styleable (currentColor, CSS animation) and interactive; SVG-as-image is rasterized without scripts, interaction, or external subresources. Same file, different context rules — same as browsers.
Scripts inside SVG? Never executed — same as Chrome's SVG-as-image context, applied everywhere. There is no SVG script engine on any roadmap.
Filters? Text? Masks?
Gradients, clips, masks, patterns, and shaped <text> are Phase 1 (static, gated per capability). Filter primitives start with feGaussianBlur and grow per-primitive after 1:1 lands. foreignObject is not planned.
How does this relate to morph-icons?
The icon system (Icons & SVG) becomes the first consumer of Phase 0: morph-icons ships SVGs that compile through this engine, tree-shaken as before. Icons stay monochrome-path-first; the engine is what lets them (and later full illustrations) render 1:1.
Why is Qt's subset (SVG 1.2 Tiny) not enough? It freezes out gradients-as-designed, masks, and animation — the features real-world icon sets and illustrations use. And Qt rasterizes at load, losing vector crispness on scale. Our subset is build-determined per app instead of frozen for everyone.
Why a custom engine — what exactly is wrong with using resvg or ThorVG?
Four concrete mismatches, not philosophy (§7): neither engine targets a damage-tracked GL renderer, so animated content would cost a full texture upload per frame; neither offers build-driven per-feature elimination, so every app links all of static SVG 1.1; pulling resvg drags a Rust-side dependency tree (tiny-skia, font stack) into a C++ link, and ThorVG brings its own threading and scene-graph model that fights the MorphNode tree; and tracking upstream releases for security fixes becomes a permanent tax. Owning a few thousand lines of path math over the already-shipped FreeType/HarfBuzz/GL stack is cheaper over the project's lifetime.
Will SVGs exported from Figma or Illustrator work?
Static vector content generally yes: groups, transforms, clip paths, gradients, and expanded strokes are all in scope. Typical export cruft to watch: filter-based drop shadows (need feGaussianBlur, phased), text left as <text> (needs the referenced fonts at runtime — outlining text to paths avoids that), and base64-embedded raster images (supported as raster, same formats as <img>). morph check names anything unsupported with file, element, and line instead of misrendering.
What happens to unsupported features — a silent hole in the picture? Never silent. Unsupported elements fail the build diagnostic naming file + element + line; at runtime the valid remainder renders. A blank box without a diagnostic is treated as a bug in the engine, not author error.
Will SVG stay crisp on HiDPI and window resize?
Yes — unlike raster-at-load approaches, geometry re-tessellates per size into the per-(path, size) cache, and the parity corpus gates 1x and 2x screenshots. shape-rendering="crispEdges" is honored for pixel-art-style icons.
Can SVG follow dark mode / theming?
Inline <svg> inherits currentColor from surrounding text styling, so themed icons work with zero props — same mechanism as morph-icons. SVG loaded as an image cannot inherit page styles (Chrome parity: image context is sealed).
A designer handed over a SMIL-animated SVG — will it play?
It renders as its first frame. <animate> / <animateTransform> elements are flagged by morph check with guidance to convert the motion to CSS @keyframes, which the engine supports on the same attributes. Self-animating image-context SVG is not on any roadmap.
Text with custom fonts or emoji inside SVG?
<text> shapes through the same HarfBuzz/FreeType stack as UI text, with the same font fallback — custom fonts work if the app ships them. COLR/CPAL color-emoji glyphs are out of scope initially (same position as icons-svg.md).
Do <title> and <desc> do anything?
No visual effect (Chrome parity). They are parsed and retained so the future screen-reader work (platform/accessibility.md) can expose them as accessible names — decorative SVGs should still carry aria-hidden equivalents when that lands.
Filters, drop shadows, blurs?
feGaussianBlur ships first (covers glows and soft shadows); the remaining primitives grow per-capability afterward, each behind its own MORPH_FEATURE_SVG_FILTER_* define. For UI shadows, the CSS box-shadow path (see more-properties.md) is preferred where it applies — no SVG needed.
How much binary weight does each icon add?
Roughly: the path/fill core is amortized once (KB-scale), then each icon adds its tessellated geometry — tens to hundreds of bytes for typical icon paths. morph-icons tree-shakes unused icons before the engine ever sees them, and morph check attributes feature cost per file, so growth is always traceable to its cause.
| Piece | State |
|---|---|
Raster image decode (stbi_load, no SVG) |
✅ Shipped |
| SVG parsing / DOM / rendering | ❌ Not built (this page) |
<svg> subset + tessellation + cache (Phase 0) |
❌ Planned — also tracked in Icons & SVG |
Full-fidelity static set: gradients, clip, mask, pattern, <text>, preserveAspectRatio (Phase 1) |
❌ Planned |
| Parity corpus + Chrome screenshot gating (§9) | ❌ Planned |
Per-capability MORPH_FEATURE_SVG_* elimination (§11) |
❌ Planned |
| CSS animation on SVG attributes (Phase 2) | ❌ Planned (reuses animation engine) |
| JS-driven SVG animation (Phase 3) | ❌ Far future (needs imperative animation API) |
| SMIL / scripts in SVG | ❌ Explicit non-goal |
<text> subset — full shaping via HarfBuzz is reuse, but textPath, tspan positioning, and bidirectional edge cases each add surface. Which ships in Phase 1 vs later?feGaussianBlur first is assumed (shadows/glows cover most UI); confirm against real icon/illustration corpora before locking..svg only, or also inline <svg> in .mx? Inline enables currentColor/theming ergonomics but needs JSX-namespace handling in the parser. (Recommendation: files first, inline second.)morph icons:add pipeline — project icon sets compile through Phase 0; who owns validation diagnostics shared with morph check?<use> cross-document references — same-document only in v1; is cross-file use href="icons.svg#id" ever needed, or does tree-shaking make it redundant?morph check validation diagnostics naming file/element/line.<svg> node type in layout/paint/flatten.currentColor, viewBox + preserveAspectRatio, transforms — wire into the existing style/layout system.<text> → nested svg/image; one MORPH_FEATURE_SVG_* each with size attribution in morph check.transform-box semantics.--morph-self-test 0 failures; binary-size report per capability.morph-icons, morph icons:add), Tooling (package angle).transform-box, geometry properties), SVG 1.1 2nd ed. (rendering model, arc parameterization, gradients/masks), CSS color (currentColor), shape-rendering.QSvgRenderer / QSvgWidget docs (doc.qt.io/qt-6/svgrendering.html) — static SVG 1.2 Tiny, no scripts/DOM/animation; QtSvg carries third-party XSVG code.LayoutSVGShape family, SVG DOM + V8 bindings), Skia backend, SMIL/CSS/Web-Animations sources, inline-vs-image context split; WPE/WebKit SVG-engine rewrite notes for layout-architecture reference.