fix(core): drop empty wrapper elements from the svg output

An optional component that came up empty left its wrapper behind. In
notionists that wrapper sits inside a mask, and a masked group without
content has no bounding box. AndroidSVG takes the mask size from that
box, so the whole file fails to render and Android gallery apps showed
those avatars as corrupted.

A wrapper is now left out when nothing inside it renders, unless it
carries an id. Same change in all six implementations, the rendered
image does not change.

Refs #553
This commit is contained in:
Florian Körner
2026-08-18 23:22:56 +02:00
parent c05d6a7022
commit 637f55bc94
11 changed files with 265 additions and 6 deletions
+14
View File
@@ -8,6 +8,20 @@ and this project adheres to
## [Unreleased]
### Fixed
- **Core (all languages):** Avatars no longer carry empty wrapper elements. An
optional component that came up empty left its wrapper behind, and in
`notionists` that wrapper sits inside a mask. A masked group without content
has no bounding box, and AndroidSVG takes the mask size from that box, so the
whole file fails to render. Gallery apps on Android showed such avatars as
corrupted while browsers drew them fine. Every file reported as broken has
such a wrapper, and the working ones from the same download do not. A wrapper
is now left out when nothing inside it renders, unless it carries an id that
something may point at. `bottts-neutral`, `clay`, `critters`, `notionists`,
and `squircles` were affected, `bottts-neutral` in about half of all seeds.
The rendered image does not change.
## [10.6.0] - 2026-08-16
### Added
+9
View File
@@ -301,6 +301,15 @@ class Renderer {
final children = _renderElements(element.children);
if (children.isEmpty) {
// A wrapper whose children all rendered to nothing, because an optional
// component came up empty, has no content left to group. It draws
// nothing either way, but a masked group without content has an empty
// bounding box, and strict SVG parsers reject the whole document over
// it. Wrappers that carry an id stay, so references keep resolving.
if (element.children.isNotEmpty && element.attributes?['id'] == null) {
return '';
}
return '<$name$attrs/>';
}
+9
View File
@@ -263,6 +263,15 @@ func (r *renderer) renderSvgElement(el *style.Element) (string, error) {
}
if children == "" {
// A wrapper whose children all rendered to nothing, because an optional
// component came up empty, has no content left to group. It draws
// nothing either way, but a masked group without content has an empty
// bounding box, and strict SVG parsers reject the whole document over
// it. Wrappers that carry an id stay, so references keep resolving.
if _, hasID := el.Attributes.Get("id"); len(el.Children) > 0 && !hasID {
return "", nil
}
return "<" + name + attrs + "/>", nil
}
return "<" + name + attrs + ">" + children + "</" + name + ">", nil
+12
View File
@@ -301,6 +301,18 @@ export class Renderer {
const children = this.#renderElements(element.children());
if (children.length === 0) {
// A wrapper whose children all rendered to nothing, because an optional
// component came up empty, has no content left to group. It draws
// nothing either way, but a masked group without content has an empty
// bounding box, and strict SVG parsers reject the whole document over
// it. Wrappers that carry an id stay, so references keep resolving.
if (
element.children().length > 0 &&
element.attributes()?.id === undefined
) {
return '';
}
return `<${name}${attrs}/>`;
}
+62
View File
@@ -154,6 +154,68 @@ describe('Renderer', () => {
assert.ok(svg.includes('<g id="outer"><g id="inner"><rect/></g></g>'));
});
it('should drop wrappers whose children all render to nothing', () => {
const style = new Style({
canvas: {
width: 100,
height: 100,
elements: [
{
type: 'element',
name: 'g',
attributes: { mask: 'url(#mask)' },
children: [
{ type: 'element', name: 'g', children: [{ type: 'component', name: 'eyes' }] },
],
},
{ type: 'element', name: 'rect' },
],
},
components: {
eyes: {
width: 50,
height: 50,
probability: 0,
variants: { open: { elements: [{ type: 'element', name: 'circle', attributes: { r: '5' } }] } },
},
},
});
const svg = new Avatar(style, { seed: 'test' }).toString();
assert.ok(!svg.includes('mask='), 'expected the empty wrappers to be dropped');
assert.ok(svg.includes('<rect/>'));
});
it('should keep an empty wrapper that carries an id', () => {
const style = new Style({
canvas: {
width: 100,
height: 100,
elements: [
{
type: 'element',
name: 'g',
attributes: { id: 'placeholder' },
children: [{ type: 'component', name: 'eyes' }],
},
],
},
components: {
eyes: {
width: 50,
height: 50,
probability: 0,
variants: { open: { elements: [{ type: 'element', name: 'circle', attributes: { r: '5' } }] } },
},
},
});
const svg = new Avatar(style, { seed: 'test' }).toString();
assert.ok(svg.includes('<g id="placeholder"/>'));
});
});
describe('text rendering', () => {
+12
View File
@@ -309,6 +309,18 @@ class Renderer
$children = $this->renderElements($element->children());
if (strlen($children) === 0) {
// A wrapper whose children all rendered to nothing, because an
// optional component came up empty, has no content left to group.
// It draws nothing either way, but a masked group without content
// has an empty bounding box, and strict SVG parsers reject the
// whole document over it. Wrappers that carry an id stay, so
// references keep resolving.
$ownAttributes = $element->attributes();
if (count($element->children()) > 0 && !isset($ownAttributes['id'])) {
return '';
}
return "<{$name}{$attrs}/>";
}
+48
View File
@@ -114,6 +114,54 @@ class RendererTest extends TestCase
$this->assertStringContainsString('<g id="outer"><g id="inner"><rect/></g></g>', $svg);
}
public function testDropsWrappersWhoseChildrenAllRenderToNothing(): void
{
$style = new Style([
'canvas' => [
'width' => 100, 'height' => 100,
'elements' => [
['type' => 'element', 'name' => 'g', 'attributes' => ['mask' => 'url(#mask)'], 'children' => [
['type' => 'element', 'name' => 'g', 'children' => [
['type' => 'component', 'name' => 'eyes'],
]],
]],
['type' => 'element', 'name' => 'rect'],
],
],
'components' => [
'eyes' => [
'width' => 50, 'height' => 50, 'probability' => 0,
'variants' => ['open' => ['elements' => [['type' => 'element', 'name' => 'circle', 'attributes' => ['r' => '5']]]]],
],
],
]);
$svg = (new Avatar($style, ['seed' => 'test']))->toString();
$this->assertStringNotContainsString('mask=', $svg);
$this->assertStringContainsString('<rect/>', $svg);
}
public function testKeepsAnEmptyWrapperThatCarriesAnId(): void
{
$style = new Style([
'canvas' => [
'width' => 100, 'height' => 100,
'elements' => [
['type' => 'element', 'name' => 'g', 'attributes' => ['id' => 'placeholder'], 'children' => [
['type' => 'component', 'name' => 'eyes'],
]],
],
],
'components' => [
'eyes' => [
'width' => 50, 'height' => 50, 'probability' => 0,
'variants' => ['open' => ['elements' => [['type' => 'element', 'name' => 'circle', 'attributes' => ['r' => '5']]]]],
],
],
]);
$svg = (new Avatar($style, ['seed' => 'test']))->toString();
$this->assertStringContainsString('<g id="placeholder"/>', $svg);
}
// text rendering
public function testTextContent(): void
+13
View File
@@ -271,6 +271,19 @@ class Renderer:
children = self._render_elements(element.children())
if len(children) == 0:
# A wrapper whose children all rendered to nothing, because an
# optional component came up empty, has no content left to group.
# It draws nothing either way, but a masked group without content
# has an empty bounding box, and strict SVG parsers reject the
# whole document over it. Wrappers that carry an id stay, so
# references keep resolving.
own_attributes = element.attributes()
if len(element.children()) > 0 and (
own_attributes is None or "id" not in own_attributes
):
return ""
return f"<{name}{attrs_str}/>"
return f"<{name}{attrs_str}>{children}</{name}>"
+68
View File
@@ -236,6 +236,74 @@ def test_nested_elements() -> None:
assert '<g id="outer"><g id="inner"><rect/></g></g>' in svg
_EYES_COMPONENT = {
"eyes": {
"width": 50,
"height": 50,
"probability": 0,
"variants": {
"open": {
"elements": [
{"type": "element", "name": "circle", "attributes": {"r": "5"}}
]
}
},
}
}
def test_drops_wrappers_whose_children_all_render_to_nothing() -> None:
style = Style(
{
"canvas": {
"width": 100,
"height": 100,
"elements": [
{
"type": "element",
"name": "g",
"attributes": {"mask": "url(#mask)"},
"children": [
{
"type": "element",
"name": "g",
"children": [{"type": "component", "name": "eyes"}],
}
],
},
{"type": "element", "name": "rect"},
],
},
"components": _EYES_COMPONENT,
}
)
svg = Avatar(style, {"seed": "test"}).to_string()
assert "mask=" not in svg
assert "<rect/>" in svg
def test_keeps_an_empty_wrapper_that_carries_an_id() -> None:
style = Style(
{
"canvas": {
"width": 100,
"height": 100,
"elements": [
{
"type": "element",
"name": "g",
"attributes": {"id": "placeholder"},
"children": [{"type": "component", "name": "eyes"}],
}
],
},
"components": _EYES_COMPONENT,
}
)
svg = Avatar(style, {"seed": "test"}).to_string()
assert '<g id="placeholder"/>' in svg
# ---------------------------------------------------------------------------
# text rendering
# ---------------------------------------------------------------------------
+17 -5
View File
@@ -265,11 +265,23 @@ impl<'a> Renderer<'a> {
let attrs = self.render_attributes(element.attributes())?;
let children = self.render_elements(element.children())?;
Ok(if children.is_empty() {
format!("<{name}{attrs}/>")
} else {
format!("<{name}{attrs}>{children}</{name}>")
})
if children.is_empty() {
// A wrapper whose children all rendered to nothing, because an
// optional component came up empty, has no content left to group.
// It draws nothing either way, but a masked group without content
// has an empty bounding box, and strict SVG parsers reject the
// whole document over it. Wrappers that carry an id stay, so
// references keep resolving.
let has_id = element.attributes().and_then(|a| a.get("id")).is_some();
if !element.children().is_empty() && !has_id {
return Ok(String::new());
}
return Ok(format!("<{name}{attrs}/>"));
}
Ok(format!("<{name}{attrs}>{children}</{name}>"))
}
fn render_text_element(&mut self, element: &Element) -> String {
+1 -1
View File
@@ -694,7 +694,7 @@
"variant1"
]
},
"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\" fill=\"none\" shape-rendering=\"auto\" aria-hidden=\"true\"><!-- Generated by DiceBear (https://www.dicebear.com) --><metadata xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\"><rdf:RDF><rdf:Description><dc:title>Thumbs</dc:title><dc:creator>DiceBear</dc:creator><dc:source xsi:type=\"dcterms:URI\">https://www.dicebear.com</dc:source><dcterms:license xsi:type=\"dcterms:URI\">https://creativecommons.org/publicdomain/zero/1.0/</dcterms:license><dc:rights>“Thumbs” (https://www.dicebear.com) by “DiceBear”, licensed under “CC0 1.0” (https://creativecommons.org/publicdomain/zero/1.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><defs><g id=\"head-default-9eab9655\"><g class=\"dbth-f\"><g class=\"dbth-eyes\"/><g class=\"dbth-eyes\"/></g></g><g id=\"body-default-9eab9655\"><path d=\"M45 0c24.85 0 45 19.4 45 43.33V130H0V43.33C0 19.4 20.15 0 45 0\" fill=\"#0a5b83\"/><use transform=\"translate(24 24) translate(-4.3674, -5.50764) rotate(-4.1297, 22, 20)\" href=\"#head-default-9eab9655\"/></g><g id=\"animation-none-9eab9655\"></g><clipPath id=\"clip-9eab9655\"><rect width=\"100\" height=\"100\" rx=\"0\" ry=\"0\"/></clipPath></defs><g clip-path=\"url(#clip-9eab9655)\"><rect width=\"100\" height=\"100\" fill=\"#69d2e7\"/><g class=\"dbth-c\"><use transform=\"translate(5 10) translate(0.40248, 2.1138) rotate(-2.9552, 45, 65)\" href=\"#body-default-9eab9655\"/></g><use href=\"#animation-none-9eab9655\"/></g></svg>",
"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\" fill=\"none\" shape-rendering=\"auto\" aria-hidden=\"true\"><!-- Generated by DiceBear (https://www.dicebear.com) --><metadata xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\"><rdf:RDF><rdf:Description><dc:title>Thumbs</dc:title><dc:creator>DiceBear</dc:creator><dc:source xsi:type=\"dcterms:URI\">https://www.dicebear.com</dc:source><dcterms:license xsi:type=\"dcterms:URI\">https://creativecommons.org/publicdomain/zero/1.0/</dcterms:license><dc:rights>“Thumbs” (https://www.dicebear.com) by “DiceBear”, licensed under “CC0 1.0” (https://creativecommons.org/publicdomain/zero/1.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><defs><g id=\"head-default-9eab9655\"></g><g id=\"body-default-9eab9655\"><path d=\"M45 0c24.85 0 45 19.4 45 43.33V130H0V43.33C0 19.4 20.15 0 45 0\" fill=\"#0a5b83\"/><use transform=\"translate(24 24) translate(-4.3674, -5.50764) rotate(-4.1297, 22, 20)\" href=\"#head-default-9eab9655\"/></g><g id=\"animation-none-9eab9655\"></g><clipPath id=\"clip-9eab9655\"><rect width=\"100\" height=\"100\" rx=\"0\" ry=\"0\"/></clipPath></defs><g clip-path=\"url(#clip-9eab9655)\"><rect width=\"100\" height=\"100\" fill=\"#69d2e7\"/><g class=\"dbth-c\"><use transform=\"translate(5 10) translate(0.40248, 2.1138) rotate(-2.9552, 45, 65)\" href=\"#body-default-9eab9655\"/></g><use href=\"#animation-none-9eab9655\"/></g></svg>",
"resolvedOptions": {
"backgroundColorFill": "solid",
"backgroundColor": [