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:
@@ -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
|
||||
|
||||
@@ -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/>';
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}/>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}/>";
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user