|
|
Donner SVG 0.8.0-pre
SVG editor and embeddable C++20 engine.
|
Status: Developer reference. Text structure is shared by Geode and tiny-skia, with no known backend-specific layout divergence. The resvg matrix runs two active modes in Geode builds: TinyGolden and GeodeGolden. Each backend gates independently against its reference golden; direct Geode-to-tiny pixel comparison is not an active mode because their analytic and finite-sample coverage can legitimately differ.
Related: 0017 §Phase 4b, 0041 anti-aliasing, 0042 Slug implementation, 0021 §Geode / Resvg Override Policy
RendererGeode::drawText and RendererTinySkia::drawText are two backend-specific consumers of one shared text layer. Everything that determines what to draw — glyph placement, paint resolution, decoration geometry, font-size/scale — is computed once, above both backends and below TextEngine; each backend only rasterizes the result.
donner/svg/renderer/PlacedTextGeometry.{h,cc} is the pure-geometry layer both backends call (lib target, no backend/paint types):
Each backend's drawText loops the shared placement/bounds output and rasterizes:
Invariant: both backends consume the same placed glyph and decoration geometry. Changes to the shared layer are verified by :text_engine_tests and :text_span_positioning_tests; rendered output is independently gated by the TinyGolden and GeodeGolden modes of :resvg_test_suite plus the focused renderer golden targets.
0 known structural text divergences (catalog in section 4). Geode analytic coverage and tiny-skia finite-sample coverage can differ at glyph edges, so pixel identity between the backends is not a correctness requirement. Each backend instead has an exact or thresholded reference oracle close to the affected resvg case.
The Geode-enabled build of //donner/svg/renderer/tests:resvg_test_suite runs each active case in two modes:
Both modes use the case's local ImageComparisonParams. There is no category side table, backend disable list, or direct Geode-to-tiny comparison mode. A legitimate backend-specific reference is attached to that case with withGeodeGoldenOverride and a current reason. A structural or semantic error is fixed in the shared layer or backend consumer; it is not absorbed by a wider threshold. See 0021 for the active policy.
Each entry below was a place the two backends drifted (or where Geode was missing a feature). All are resolved; they double as regression-relevant implementation notes.
| # | Divergence | Root cause | Fix |
|---|---|---|---|
| D1 | text-decoration not drawn | geode drew no underline/overline/line-through | d1742348c — decoration geometry |
| D2 | stroked-glyph ring fill rule | geode used NonZero (solid interior); the ring needs EvenOdd | 2314efb0d — stroke→fill |
| D3 | pattern-fill on text | geode drawText had no pattern path → glyphs unfilled + a staged patternFillPaint slot leaked to the next shape | 1e2eb2b6f — paint resolution |
| D4 | stretch+rotate transform order | tiny applied stretch on the raw outline then Rotate*Translate; geode used Scale*Rotate*Translate — diverges only when stretchScale≠1 and rotate≠0 | structurally fixed by placedGlyphOutline (latent: no suite test triggers it) |
Geode drawText was missing gradient handling and had incomplete pattern handling — resolveSpanFill/resolveSolidStroke collapsed gradient refs (glyphs unfilled / element-gradient text rendered nothing), and only the patternFillPaint slot was consumed, never patternStrokePaint. Fix (reusing geode's existing gradient infra, not a new abstraction): geode computes the text bbox via the shared computeTextBounds, routes gradient fill/stroke through drawPaintedPathAgainst(textBbox, …), and pattern stroke through the patternStrokePaint slot. tiny-skia then dropped its inline text-bbox loop and adopted the same computeTextBounds (proven a pixel no-op — 95-test before/after diff identical). One bbox implementation now serves both backends.
| # | test | geode↔tiny px (before → after) | outcome |
|---|---|---|---|
| B15 | painting/fill/radial-gradient-on-text | 14562 → 3 | un-gated |
| B16 | painting/stroke/pattern-on-text | 13115 → 39 | un-gated |
| B18 | painting/fill/linear-gradient-on-text | 10195 → 1 | un-gated |
| B17 | painting/stroke/linear-gradient-on-text | 11917 → 465 | edge-floor |
| B11 | text/tspan/tspan-bbox-2 | 2929 → 694 | edge-floor |
| B12 | text/tspan/tspan-bbox-1 | 1803 → 702 | edge-floor |
(B19 paint-servers/pattern/text-child is a <pattern> containing text — already rendered correctly, edge-floor — distinct from B16 which is a pattern as the text fill.)
B1–B6 (the largest cluster) were not a geode rendering gap — they were a shared-layout state-accumulation bug. resolvePerSpanLayoutStyles pushed onto span.ancestorBaselineShifts without ever clearing, and it runs per draw(). The parity harness draws each document twice (geode then tiny on the same ComputedTextComponent), so the second backend saw doubled ancestor shifts. Position dump: geode (1st pass) y=74.4 (correct 2×20%), tiny (2nd pass) y=61.6 (3×20%). Fix: span.ancestorBaselineShifts.clear() before re-populating → idempotent layout. tiny single-pass output unchanged (clear is a no-op on the empty default; verified byte-identical across 96 text tests).
| # | test | geode↔tiny px (before → after) |
|---|---|---|
| B1 | text/baseline-shift/nested-with-baseline-2 | 19750 → 702 |
| B2 | text/baseline-shift/nested-with-baseline-1 | 12886 → 702 |
| B3 | text/baseline-shift/mixed-nested | 4338 → 690 |
| B4 | text/baseline-shift/deeply-nested-super | 4320 → 720 |
| B5 | text/baseline-shift/nested-super | 2870 → 677 |
| B6 | text/baseline-shift/nested-length | 2438 → 686 |
All six now render correctly at the ~677–720 px edge floor. This same double-draw idempotency class also surfaced a production feImage-fragment bug (unrelated to text; see 0017 §Phase 4b and the appendix).
| # | test | geode↔tiny px (before → after) |
|---|---|---|
| B7 | text/text-decoration/underline-with-dy-list-2 | 4643 → 1177 |
| B8 | text/text-decoration/underline-with-rotate-list-4 | 4561 → 1145 |
The baseline-shift fix cleared the structural part of B7/B8. The residual is the 4× fringe on the gray stroke-ring + gradient: the plain-black siblings dy-list-1 (699 px) and rotate-list-3 (686 px) are already accepted edge-floor, proving dy and rotate are consumed correctly; glyph interiors are zero-diff and double-draw was ruled out (tiny-twice = 0 px). Both are accepted edge-floor.
These were never structural — they render correctly and the diff is cumulative edge fringe (many lines / long strings / on-path small text / tiled fields):
| # | test | px |
|---|---|---|
| B9 | text/text-decoration/tspan-decoration | 1822 |
| B10 | text/font-size/named-value | 3488 (named keywords are on <rect>s; the text is all size-12 and renders correct) |
| B13 | text/textPath/dy-with-tiny-coordinates | 2219 |
| B14 | text/letter-spacing/on-Arabic | 932 |
| B19 | paint-servers/pattern/text-child | 1663 |
Note: at strict-0 the characterization also listed font-size/negative-size (5588) and tspan/with-opacity (1599) as bugs; both drop below the 100-px flat budget at 0.02 and are not gated.
For the record — these were surfaced by the same parity run and are tracked elsewhere:
Condensed from the parity push; preserved so the conclusions aren't re-litigated.
The opening thesis was that the two drawTexts were full parallel reimplementations that would keep drifting, and the durable fix was to hoist all of placement / paint / decoration / font-size into a single shared PlacedText builder emitting a backend-agnostic op list, collapsing each drawText to a thin op consumer.
What actually shipped: the geometry slices were hoisted (PlacedTextGeometry: placedGlyphOutline, transformPath, computeTextBounds — §1.2). The remaining divergences turned out not to be drift in shared logic but either (a) a feature missing from geode (gradient/stroke-pattern on text — fixed by targeted convergence reusing geode's existing infra) or (b) a shared-layout idempotency bug (baseline-shift). Once those were fixed there was no remaining drift to justify the larger paint-descriptor abstraction, so the full op-list hoist was descoped — both backends now map the same paint servers + the same computeTextBounds. If future drift reappears, the op-list builder remains the recorded durable fix.
All 37 filter divergences were resolved, mostly by fixing geode's inconsistent color-interpolation-filters (linearRGB) handling:
Pattern worth remembering: geode's filter engine inconsistently applied color-interpolation-filters. The original gap spanned feComposite, feComponentTransfer, feTurbulence, feDisplacementMap (all fixed); feGaussianBlur / feColorMatrix / feBlend already had it.