|
|
Donner 0.8.0-pre
Embeddable browser-grade SVG2 engine
|
Status: Developer reference. Text parity between the Geode and tiny-skia backends is complete — 0 structural divergences remain; the only residual geode↔tiny diff is the accepted sub-pixel coverage floor (0041). This doc describes the shared text layer both backends consume and how per-test parity is expressed in ImageComparisonParams. Geode runs the same params as the CPU variants; a parity-only exception is a per-test disableGeodeParity(reason) (see 0021 §Geode / Resvg Override Policy). The §4 catalog records the resolved divergences as implementation notes.
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: tiny-skia text output is the parity reference. Any change to the shared layer must keep tiny-skia byte-identical (verified against :resvg_test_suite text tests + :renderer_tests); Geode converges to it.
0 structural text divergences (catalog in §4). The remaining geode↔tiny text diff is the accepted-by-design sub-pixel coverage floor — geode renders the correct glyphs/positions/colors; the residual is the thin edge band + the resvg harness 0.5px crosshair overlay (see 0041 §2, proven sample-independent). No text test needs a parity exception: the residual stays within each test's normal ImageComparisonParams budget.
Parity runs in the geode-enabled build of //donner/svg/renderer/tests:resvg_test_suite (it rides the *_geode wrapper under bazel test //...). Each test runs up to three comparison modes; text is validated on the GeodeTinyParity mode. See 0017 §Phase 4b for the full mode matrix.
Policy (text and non-text alike): GeodeTinyParity compares geode↔tiny-skia at each test's own ImageComparisonParams threshold and max-pixel budget — the same budget its golden comparison uses — with pixelmatch includeAA=false. There is no separate geode threshold table; a parity diff over the test's budget fails, never absorbed by a larger budget (that would be masking). See 0021 §Geode / Resvg Override Policy.
To add a text test: add it to the suite as usual. If it renders correctly but its geode↔tiny diff exceeds the test's budget (the accepted edge floor), attach a disableGeodeParity("<reason, e.g. 0039 edge floor>") to that test's Params. If it renders wrong, that's a real bug — fix the shared layer or the backend consumer, don't add an exception. The standing goal (per 0021) is fewer exceptions over time.
The parity oracle is tiny-skia, so a tiny-skia text regression could mask a geode one. This is mitigated because the TinyGolden mode gates tiny-skia against the resvg ground truth in the same run.
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.