fix(formatter_htmlish): keep SVG text under xml:space="preserve" as written (#28382)

```html
<svg>
  <text x="0" y="10" xml:space="preserve">  a  <tspan>b</tspan>  c</text>
</svg>
```

Preserve these as-is.
This commit is contained in:
Yuji Sugiura authored and GitHub committed 2026-10-30 16:54:21 +09:00
1 parent fb3aefa8d6
commit f3dd201682
5 files changed
+119 -8

No files matched your search

@@ -274,6 +274,36 @@ whitespace between them is kept or omitted as written, as between HTML inline el
Prettier lays out every SVG element but `<svg>` as a block (prettier/prettier#17713, prettier/prettier#10121).
With `htmlWhitespaceSensitivity: "ignore"` they stay blocks, as HTML inline elements do.
## svg-xml-space-preserve
- Why: semantics (whitespace in an SVG `<text>` under `xml:space="preserve"` renders as written; prettier/prettier#10121)
- Pin: `tests/fixtures/html/svg-xml-space-preserve.html`
```html
<!-- input -->
<svg><text x="0" y="10" xml:space="preserve"> a <tspan>b</tspan> c</text></svg>
<!-- ours -->
<svg>
<text x="0" y="10" xml:space="preserve"> a <tspan>b</tspan> c</text>
</svg>
<!-- prettier -->
<svg>
<text x="0" y="10" xml:space="preserve">
a
<tspan>b</tspan>
c
</text>
</svg>
```
An SVG `<text>` (or an element in one) under `xml:space="preserve"`, on it or an SVG ancestor, keeps its content as written, as a `pre` does.
Browsers render every space of it, and a line break with its indentation as spaces.
They differ beyond the element itself: Gecko applies an ancestor's `preserve` and keeps it under a `xml:space="default"`, Blink does neither,
so any `preserve` above counts.
Prettier lays the content out as blocks, adding and removing whitespace that renders.
## dialog-out-of-flow
- Why: semantics (whitespace added on both sides of a `dialog` renders between its inline neighbors)
+27 -6
View File
@@ -384,7 +384,7 @@ impl Builder<'_> {
}
NodeKind::Element(el) => {
self_closing = closes_at_start_tag(node);
pre_like = is_pre_like(el);
pre_like = is_pre_like(el) || self.preserves_svg_text(id, el);
script_like = matches!(el.key, "script" | "style");
}
_ => {}
@@ -429,11 +429,31 @@ impl Builder<'_> {
}
}
/// An SVG `<text>`, or an element in one, under `xml:space="preserve"` on it or an SVG ancestor:
/// its whitespace renders as written (DIVERGENCES.md#svg-xml-space-preserve).
fn preserves_svg_text(&self, id: NodeId, el: &Element<'_>) -> bool {
let source = self.tree.source;
el.namespace == Namespace::Svg
&& (el.key == "text" || self.in_svg_text(id))
&& self.svg_elements(Some(id)).any(|el| {
el.attributes.iter().any(|attribute| {
self.tree.attribute_is(attribute.name.slice(source), "xml:space")
&& attribute
.value
.is_some_and(|value| value.span.slice(source) == "preserve")
})
})
}
/// Whether an SVG element is inside a `<text>`, whose content lays out as inline text.
fn in_svg_text(&self, id: NodeId) -> bool {
std::iter::successors(self.tree.root[id].parent, |&p| self.tree.root[p].parent)
self.svg_elements(self.tree.root[id].parent).any(|el| el.key == "text")
}
/// The SVG elements from `from` up, until one that is not.
fn svg_elements(&self, from: Option<NodeId>) -> impl Iterator<Item = &Element<'_>> {
std::iter::successors(from, |&p| self.tree.root[p].parent)
.map_while(|p| self.tree.element(p).filter(|el| el.namespace == Namespace::Svg))
.any(|el| el.key == "text")
}
/// Whether whitespace on one side of the node is significant, before the neighbor's side is combined in:
@@ -495,7 +515,7 @@ impl Builder<'_> {
leading: bool,
) -> bool {
let t = &self.tree;
let (item, parent) = (&t[id], &t[parent_id]);
let parent = &t[parent_id];
let text_or_interpolation = |id: NodeId| t.is_text(id) || t.is_interpolation(id);
let sensitive =
if text_or_interpolation(flow_item) && neighbor.is_some_and(text_or_interpolation) {
@@ -507,8 +527,9 @@ impl Builder<'_> {
} else if let Some(neighbor) = neighbor {
self.display_of(neighbor).sides_sensitive()
} else {
// An HTML `pre`'s own edges, not those of all pre-like content
!(parent_id == NodeId::DOCUMENT
|| item.pre_like
|| t.element(id).is_some_and(is_pre_like)
|| parent.script_like
|| !parent.display.edges_sensitive())
};
@@ -619,7 +640,7 @@ fn is_whitespace_text(node: &Node<'_>) -> bool {
matches!(&node.kind, NodeKind::Text(text) if text.is_whitespace_only())
}
/// CSS `white-space: pre*`.
/// An HTML element whose default style is `white-space: pre*`.
fn is_pre_like(el: &Element<'_>) -> bool {
el.namespace != Namespace::Svg
&& matches!(el.key, "pre" | "textarea" | "listing" | "plaintext" | "xmp")
+3 -2
View File
@@ -38,7 +38,8 @@ pub struct Facts {
/// No children and no end tag: the start tag closes the node (Prettier's `isSelfClosing`).
/// Text-like nodes count too.
pub self_closing: bool,
/// CSS `white-space: pre*`: `pre`, `textarea`, `listing`, `plaintext`, `xmp`, outside SVG.
/// CSS `white-space: pre*`: `pre`, `textarea`, `listing`, `plaintext`, `xmp`, outside SVG;
/// an SVG `<text>`, or an element in one, under `xml:space="preserve"` (DIVERGENCES.md#svg-xml-space-preserve).
/// Children keep their whitespace and indentation.
pub pre_like: bool,
/// `script` / `style`: children keep their whitespace.
@@ -46,7 +47,7 @@ pub struct Facts {
/// The deepest last descendant is text-like, or the node itself is
/// (an interpolation ends in its text).
pub ends_in_text_like: bool,
/// The node is a `pre` without an end tag, or ends in one through its last children without end tags:
/// The node is pre-like without an end tag, or ends in one through its last children without end tags:
/// what follows it up to the end tag that closes them is `pre` content.
pub ends_in_pre: bool,
/// A CDATA section, or a text that merged one: its sections are never re-wrapped.
@@ -0,0 +1,5 @@
<svg><text x="0" y="10" xml:space="preserve"> a <tspan>b</tspan> c</text></svg>
<svg xml:space="preserve"><g><text x="0" y="10"> a
b </text></g></svg>
<svg><text x="0" y="10"> a <tspan xml:space="preserve"> b </tspan> c</text></svg>
<svg><text x="0">a<tspan> <tspan xml:space="preserve">b</tspan></tspan> c</text></svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1,54 @@
---
source: crates/oxc_formatter_htmlish/tests/fixtures/mod.rs
---
==================== Input ====================
<svg><text x="0" y="10" xml:space="preserve"> a <tspan>b</tspan> c</text></svg>
<svg xml:space="preserve"><g><text x="0" y="10"> a
b </text></g></svg>
<svg><text x="0" y="10"> a <tspan xml:space="preserve"> b </tspan> c</text></svg>
<svg><text x="0">a<tspan> <tspan xml:space="preserve">b</tspan></tspan> c</text></svg>
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
<svg>
<text x="0" y="10" xml:space="preserve"> a <tspan>b</tspan> c</text>
</svg>
<svg xml:space="preserve">
<g>
<text x="0" y="10"
> a
b </text>
</g>
</svg>
<svg>
<text x="0" y="10">a <tspan xml:space="preserve"> b </tspan> c</text>
</svg>
<svg>
<text x="0">
a<tspan> <tspan xml:space="preserve">b</tspan></tspan> c
</text>
</svg>
-------------------
{ printWidth: 100 }
-------------------
<svg><text x="0" y="10" xml:space="preserve"> a <tspan>b</tspan> c</text></svg>
<svg xml:space="preserve">
<g>
<text x="0" y="10"
> a
b </text>
</g>
</svg>
<svg>
<text x="0" y="10">a <tspan xml:space="preserve"> b </tspan> c</text>
</svg>
<svg>
<text x="0">
a<tspan> <tspan xml:space="preserve">b</tspan></tspan> c
</text>
</svg>
===================== End =====================