Re: [svgwg] space trimming in the context of text/tspan (#1114)

# SVG 1114 — space-trimming test matrix

Per the action item from the [2026-06-04 SVG WG meeting](https://www.w3.org/2026/06/04-svg-minutes.html), I've built a cross-browser test matrix for the SVG-text whitespace pipeline. It covers `xml:space`, CSS `white-space`, the CSS Text 4 longhands (`white-space-collapse`), and their interaction with content position (leading text, inside tspan, between tspans, trailing text).

## TL;DR

1. **Three engines, one outlier on LF.** WebKit unconditionally strips line-feed characters from the SVG-text run before any whitespace-mode (xml:space *or* CSS `white-space`) is consulted. Gecko and Blink both keep the LF and convert it to a SPACE in the default and preserve modes. Visible in **A3, A12, D3, E3** below.
2. **`preserve-spaces` is only shipping in Firefox today.** This is the spec-direction value that's supposed to back `xml:space="preserve"` per the §11 annotation + [CSS Text 4 §3.1](https://www.w3.org/TR/css-text-4/#white-space-collapsing). WebKit and Blink ignore the value and fall back to default `collapse`. See **E15**.
3. **Two Gecko/Blink splits, separable from issue 1114.**
   - `white-space:pre` + LF: only Gecko produces a real line break; Blink collapses to SPACE (**E3**).
   - `white-space:pre` *inherited* from an `<svg>` ancestor: only Gecko honours the inheritance into `<text>`; WebKit and Blink ignore it (**E13**).
4. **No engine actually preserves an LF *as an LF* under `xml:space="preserve"`** — Gecko and Blink follow SVG 1.1 §10.15's "convert each newline character into a space character" (so the LF is kept as a SPACE, not as an LF). WebKit strips. The only place an LF survives as itself in any engine is Gecko + `style="white-space:pre"`.

## Methodology

Test harness: a single control SVG with two `<tspan>` siblings, each tinted by a filter so any inserted whitespace is visible as a coloured/uncoloured gap.

```svg
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="100" style="background-color:#f0f0f0;">
  <defs>
    <filter id="dual-bg" x="0" y="0" width="100%" height="100%" filterUnits="objectBoundingBox">
      <feColorMatrix type="matrix" values="0 0 0 0 1.00  0 0 0 0 0.84  0 0 0 0 0.00  1 0 0 0 0" in="SourceGraphic" result="yellow-strip"/>
      <feMorphology operator="dilate" radius="4" in="yellow-strip" result="padded-yellow"/>
      <feColorMatrix type="matrix" values="0 0 0 0 0.00  0 0 0 0 1.00  0 0 0 0 1.00  0 0 1 0 0" in="SourceGraphic" result="cyan-strip"/>
      <feMorphology operator="dilate" radius="4" in="cyan-strip" result="padded-cyan"/>
      <feMerge><feMergeNode in="padded-yellow"/><feMergeNode in="padded-cyan"/><feMergeNode in="SourceGraphic"/></feMerge>
    </filter>
  </defs>
  <style>
    .main-text { font-family: Arial, sans-serif; font-size: 24px; font-weight: bold; filter: url(#dual-bg); }
    .abc { fill: #D2143A; } .def { fill: #1A5276; }
  </style>
  <text x="10" y="50" class="main-text"><tspan class="abc">ABC</tspan><tspan class="def">DEF</tspan></text>
</svg>
```

Each test inserts whitespace at one of seven positions inside the `<text>` body:

```
<text>[W0]<tspan class="abc">[W1]ABC[W2]</tspan>[W3]<tspan class="def">[W4]DEF[W5]</tspan>[W6]</text>
```

For every cell I capture three numbers from the same page that renders the SVG (no DevTools needed — the harness page reads them on load):

- `text.getNumberOfChars()` — characters in the SVG-text run *post-collapse*
- `text.getComputedTextLength()` — total advance width
- per-`<tspan>` of the same

Numbers are necessary because visual no-gap can mean (a) char stripped, (b) char laid out at zero advance (e.g. forced break), or (c) char clipped at edge. A screenshot can't disambiguate them; the API can.

Baseline `ABCDEF` ≈ 100.00px (Arial bold 24 + filter dilation differences across engines); 1 SPACE-glyph advance ≈ 6.67px.

Engines tested: **Safari 26 / WebKit 605.1.15** · **Firefox 153** · **Chrome 151**.

Legend: `␣` = U+0020 SPACE · `⇥` = U+0009 TAB · `↵` = U+000A LINE FEED · `⍽` = U+00A0 NBSP.

## Test matrix

Cells report `<observable> (ctl=<computedTextLength>, nc=<numberOfChars>)`.

### Group A — single / combined whitespace at W3 (between the two tspans), default mode

| ID | `<text>` body | WebKit | Gecko | Blink | Notes |
| --- | --- | --- | --- | --- | --- |
| A0 | `…ABC</tspan><tspan…>DEF…` | `ABCDEF` (ctl=99.996, nc=6) | `ABCDEF` (100.00, nc=6) | `ABCDEF` (100.00, nc=6) | Baseline. 3-engine interop. |
| A1 | `…</tspan>␣<tspan…` | `ABC␣DEF` (106.66, nc=7) | `ABC␣DEF` (106.67, nc=7) | `ABC␣DEF` (106.67, nc=7) | 1× SPACE preserved. 3-engine interop. |
| A2 | `…</tspan>⇥<tspan…` | `ABC␣DEF` (106.66, nc=7) | `ABC␣DEF` (106.67, nc=7) | `ABC␣DEF` (106.67, nc=7) | TAB→SPACE-advance, kept (nc unchanged). 3-engine interop. |
| **A3** | `…</tspan>↵<tspan…` | **`ABCDEF` (99.996, nc=6)** | `ABC␣DEF` (106.67, nc=7) | `ABC␣DEF` (106.67, nc=7) | **WK strips the LF (nc 7→6, char removed from layout); Gecko/Blink LF→SPACE.** Headline divergence. |
| A9 | `…</tspan>␣␣<tspan…` | `ABC␣DEF` (106.66, nc=7) | `ABC␣DEF` (106.67, nc=7) | `ABC␣DEF` (106.67, nc=7) | 2× SPACE → 1 (DOM=8 → nc=7 in all three; the redundant SPACE is *removed*, not zero-advance). |
| A11 | `…</tspan>␣↵␣<tspan…` | `ABC␣DEF` (106.66, nc=7) | `ABC␣DEF` (106.67, nc=7) | `ABC␣DEF` (106.67, nc=7) | DOM=9 → nc=7 in all three. WK route: strip-LF → 2 SPs → collapse to 1. Gecko/Blink: LF→SP → 3 SPs → collapse to 1. Same destination. |
| **A12** | `…</tspan>↵↵↵<tspan…` | **`ABCDEF` (99.996, nc=6)** | `ABC␣DEF` (106.67, nc=7) | `ABC␣DEF` (106.67, nc=7) | WK strips the entire LF run; Gecko/Blink collapse the run to 1 SPACE. |
| A14 | `…</tspan>⇥␣<tspan…` | `ABC␣DEF` (106.66, nc=7) | `ABC␣DEF` (106.67, nc=7) | `ABC␣DEF` (106.67, nc=7) | TAB+SP → 1 char in layout. 3-engine interop. |

### Group C — LF position sweep (default mode)

| ID | Position | WebKit | Gecko | Blink | Notes |
| --- | --- | --- | --- | --- | --- |
| C0 | W0 — leading text (`↵<tspan>…`) | `ABCDEF` (99.996, nc=6) | `ABCDEF` (100.00, nc=6) | `ABCDEF` (100.00, nc=6) | Run-edge LF. All three drop. |
| C1 | W1 — leading inside tspan1 (= run-edge) | `ABCDEF` (99.996, nc=6) | `ABCDEF` (100.00, nc=6) | `ABCDEF` (100.00, nc=6) | Same. |
| **C2** | W2 — trailing inside tspan1 (mid-run) | **`ABCDEF` (99.996, nc=6)** | `ABC␣DEF` (106.67, nc=7; tspan-abc.ctl=58.67) | `ABC␣DEF` (106.67, nc=7; tspan-abc.ctl=58.67) | WK strips. Gecko/Blink keep LF as SPACE *inside tspan-abc*. Disambiguates: edge-trim is **run-edge**, not tspan-edge. |
| C3 | W3 (= A3) | `ABCDEF` (99.996, nc=6) | `ABC␣DEF` (106.67, nc=7) | `ABC␣DEF` (106.67, nc=7) | Same as A3. |
| **C4** | W4 — leading inside tspan2 (mid-run) | **`ABCDEF` (99.996, nc=6)** | `ABC␣DEF` (106.67, nc=7; tspan-def.ctl=54.67) | `ABC␣DEF` (106.67, nc=7; tspan-def.ctl=54.67) | Symmetric to C2 at the other tspan boundary. |
| C5 | W5 — trailing inside tspan2 (= run-edge) | `ABCDEF` (99.996, nc=6) | `ABCDEF` (100.00, nc=6) | `ABCDEF` (100.00, nc=6) | Run-edge. All three drop. |
| C6 | W6 — trailing text | `ABCDEF` (99.996, nc=6) | `ABCDEF` (100.00, nc=6) | `ABCDEF` (100.00, nc=6) | Run-edge. All three drop. |
| **C7** | LFs at every slot — realistic XML indent | **`ABCDEF` (99.996, nc=6)** | `ABC␣DEF` (106.67, nc=7) | `ABC␣DEF` (106.67, nc=7) | DOM=13. WK strips all 7 LFs. Gecko/Blink edge-trim 4 LFs (W0/W1/W5/W6) and merge the 3 mid-run LFs (W2/W3/W4) into 1 SPACE attached to tspan-abc. |

**Refined model**: Gecko/Blink only edge-trim at the boundary of the *overall text run*, never at tspan-internal boundaries. WK's strip-LF and Gecko/Blink's edge-trim happen to coincide only at run-edges; mid-run LFs (anywhere — between tspans, trailing inside tspan1, leading inside tspan2) are where the divergence lives.

### Group D — `xml:space="preserve"` on `<text>`

| ID | Setup | WebKit | Gecko | Blink | Notes |
| --- | --- | --- | --- | --- | --- |
| D1 | preserve + 1× SP | `ABC␣DEF` (106.66, nc=7) | `ABC␣DEF` (106.67, nc=7) | `ABC␣DEF` (106.67, nc=7) | Sanity. 3-engine interop. |
| D2 | preserve + 2× SP | `ABC␣␣DEF` (113.33, nc=8) | `ABC␣␣DEF` (113.33, nc=8) | `ABC␣␣DEF` (113.34, nc=8) | Both kept. 3-engine interop. WK *does* honour preserve for SPACE runs. |
| **D3** | preserve + 1× LF | **`ABCDEF` (99.996, nc=6)** | `ABC␣DEF` (106.67, nc=7) | `ABC␣DEF` (106.67, nc=7) | **WK ignores preserve for LF — strip-LF still active.** Gecko/Blink convert LF→SPACE per SVG 1.1 §10.15 preserve. **No engine preserves the LF *as LF* under `xml:space="preserve"`.** |

### Group E — CSS `white-space` / `white-space-collapse`

| ID | Setup | WebKit | Gecko | Blink | Notes |
| --- | --- | --- | --- | --- | --- |
| E2 | `style="white-space:pre"` on `<text>` + 2× SP | `ABC␣␣DEF` (113.33, nc=8) | `ABC␣␣DEF` (113.33, nc=8) | `ABC␣␣DEF` (113.34, nc=8) | 3-engine interop when style is on `<text>` directly. |
| **E3** | `style="white-space:pre"` on `<text>` + 1× LF | **`ABCDEF` (99.996, nc=6)** — strip | **`ABC` ⏎ `DEF` (100.00, nc=7, bbox 56×56.78 — 2 lines)** — forced break | `ABC␣DEF` (106.67, nc=7) — LF→SP | **3-way divergence, 3 different mechanisms.** Only Gecko follows CSS Text 3 here. |
| **E13** | `style="white-space:pre"` on `<svg>` (inherited into `<text>`) + 2× SP | `ABC␣DEF` (106.66, nc=7) — inheritance ignored | `ABC␣␣DEF` (113.33, nc=8) — inheritance honoured | `ABC␣DEF` (106.67, nc=7) — inheritance ignored | **2-vs-1 split.** Only Gecko honours CSS `white-space` *inherited* from `<svg>`. WK + Blink ignore, treating it as default. |
| **E15** | `style="white-space-collapse:preserve-spaces"` on `<text>` + 2× SP | `ABC␣DEF` (106.66, nc=7) — value not implemented | `ABC␣␣DEF` (113.33, nc=8) — value implemented | `ABC␣DEF` (106.67, nc=7) — value not implemented | **Spec-direction headline.** Only Firefox 153 ships `preserve-spaces`. WK + Blink ignore the value; falls back to default `collapse`. |
| E16 | preserve-spaces + 1× LF | `ABCDEF` (99.996, nc=6) — same as A3 | `ABC␣DEF` (106.67, nc=7) — same as A3 | `ABC␣DEF` (106.67, nc=7) — same as A3 | Indistinguishable from default-mode A3 in every engine — adds no extra signal beyond E15. |
| E17 | preserve-spaces + 1× TAB | `ABC␣DEF` (106.66, nc=7) — same as A2 | `ABC␣DEF` (106.67, nc=7) — same as A2 | `ABC␣DEF` (106.67, nc=7) — same as A2 | TAB→SP is already the default in every engine, so no observable signal. |

### Group F — `xml:space` × CSS `white-space` precedence

| ID | xml:space | white-space | WebKit | Gecko | Blink | Notes |
| --- | --- | --- | --- | --- | --- | --- |
| F1 | `preserve` | `normal` | `ABC␣DEF` (106.66, nc=7) | `ABC␣DEF` (106.67, nc=7) | `ABC␣DEF` (106.67, nc=7) | Explicit CSS overrides `xml:space` presentation attribute. Collapse to 1. 3-engine interop. |
| F2 | `default` | `pre` | `ABC␣␣DEF` (113.33, nc=8) | `ABC␣␣DEF` (113.33, nc=8) | `ABC␣␣DEF` (113.34, nc=8) | Inverse — CSS keeps both. 3-engine interop. **SVG 2 cascade direction is fully working for SPACE in every engine** when CSS is set directly on `<text>`. |

## Spec-direction analysis

The §11 annotation and the [CSS Text 4 §3.1](https://www.w3.org/TR/css-text-4/#white-space-collapsing) text point to a clean simplification: drop the SVG 1.1 §10.15 prose, define `xml:space="preserve"` as a UA-stylesheet rule mapping to `white-space-collapse: preserve-spaces`. Concretely how each engine maps onto that target:

| Engine | `xml:space="preserve"` LF behaviour today (D3) | `preserve-spaces` implemented? (E15) | Gap to spec target |
| --- | --- | --- | --- |
| **Gecko** | LF→SPACE, kept (correct) | **Yes (Firefox 153)** | None. Could switch to UA-stylesheet rule immediately. |
| **Blink** | LF→SPACE, kept (correct via the existing path) | **No** | Implement `preserve-spaces`. UA-stylesheet rule cannot land yet — would silently regress to default. |
| **WebKit** | LF stripped (incorrect) | **No** ([webkit#60264](https://bugs.webkit.org/show_bug.cgi?id=60264)) | Two blockers: (a) implement `preserve-spaces`; (b) remove the SVG-1.1 strip-LF pre-step. *Either alone is insufficient* — strip-LF would still erase the LF before CSS Text could apply preserve-spaces. |


-- 
GitHub Notification of comment by karlcow
Please view or discuss this issue at https://github.com/w3c/svgwg/issues/1114#issuecomment-4677556050 using your GitHub account


-- 
Sent via github-notify-ml as configured in https://github.com/w3c/github-notify-ml-config

Received on Thursday, 11 June 2026 05:43:55 UTC