Add basic <symbol> support in SVG #1819
Conversation
|
Thank you for your feedback @andersonhc ! I think I've finally solved the scaling issue with Please let me know what you think. |
| viewbox = symbol.attrib.get("viewBox") | ||
| if viewbox: | ||
| parts = viewbox.replace(",", " ").split() | ||
| if len(parts) >= 4: | ||
| setattr(group, "_vw", float(parts[2])) | ||
| setattr(group, "_vh", float(parts[3])) |
There was a problem hiding this comment.
Right now the test is failing:
@force_nodocument
def build_symbol(self, symbol: "Element") -> GraphicsContext:
"""Parse <symbol> as reusable content, not rendered directly."""
group = self.build_group(symbol)
viewbox = symbol.attrib.get("viewBox")
if viewbox:
parts = viewbox.replace(",", " ").split()
if len(parts) >= 4:
> setattr(group, "_vw", float(parts[2]))
E AttributeError: 'GraphicsContext' object has no attribute '_vw'
I don't think adding the viewbox attribute to the GraphicsContext is the best approach.
I recommend looking into using the GaphicsContext's transform to apply the viewbox.
Apply translate(-vx, -vy) @ scale(1 / vw, 1 / vh) to the symbol group should make it normalized to unit coordinates. In build_xref(), apply scale(width, height) @ translate(x, y).
There was a problem hiding this comment.
Hi @andersonhc , I've implemented your suggestion using transforms. The viewBox is now applied in build_symbol with scale(1/vw, 1/vh) @ translate(-vx, -vy), and build_xref applies scale(width, height) when specified. I also ignore percentage values for width/height. I tested it and it should be working now. Let me know what you think !
andersonhc
left a comment
There was a problem hiding this comment.
Please see suggested changes.
I would also add you to add a new item on test/svg/parameters.py on the test_svg_sources set:
pytest.param(svgfile("symbol.svg"), id="symbol reused in different placements"),
then create the file test/svg/svg_sources/symbol.svg with the content below:
<svg viewBox="0 0 80 20" xmlns="http://www.w3.org/2000/svg">
<!-- Our symbol in its own coordinate system -->
<symbol id="myDot" width="10" height="10" viewBox="0 0 2 2">
<circle cx="1" cy="1" r="1" />
</symbol>
<!-- A grid to materialize our symbol positioning -->
<path
d="M0,10 h80 M10,0 v20 M25,0 v20 M40,0 v20 M55,0 v20 M70,0 v20"
fill="none"
stroke="pink" />
<!-- All instances of our symbol -->
<use href="#myDot" x="5" y="5" opacity="1.0" />
<use href="#myDot" x="20" y="5" opacity="0.8" />
<use href="#myDot" x="35" y="5" opacity="0.6" />
<use href="#myDot" x="50" y="5" opacity="0.4" />
<use href="#myDot" x="65" y="5" opacity="0.2" />
</svg>
After that you will need to execute test_svg_conversion() once adding generate=True to assert_pdf_equal so it can generate the new PDF reference file.
| "turn": math.tau, | ||
| } | ||
|
|
||
|
|
There was a problem hiding this comment.
| class SymbolInfo(NamedTuple): | |
| """Reusable SVG <symbol> sizing metadata.""" | |
| viewbox: tuple[float, float, float, float] | |
| width: Optional[float] | |
| height: Optional[float] | |
| @force_nodocument | |
| def resolve_optional_length(length_str: Optional[str]) -> Optional[float]: | |
| """Resolve an optional absolute SVG length, ignoring unsupported percentages.""" | |
| if not length_str or "%" in length_str: | |
| return None | |
| return resolve_length(length_str) | |
| @force_nodocument | |
| def parse_viewbox(viewbox: str) -> tuple[float, float, float, float]: | |
| """Parse an SVG viewBox into min-x, min-y, width, and height values.""" | |
| parts = [float(num) for num in NUMBER_SPLIT.split(viewbox.strip()) if num] | |
| if len(parts) != 4: | |
| raise ValueError(f"invalid viewBox {viewbox}") | |
| vx, vy, vw, vh = parts | |
| if (vw < 0) or (vh < 0): | |
| raise ValueError(f"invalid negative width/height in viewBox {viewbox}") | |
| return vx, vy, vw, vh | |
| ) -> None: | ||
| self.image_cache = image_cache # Needed to render images | ||
| self.resource_access_policy = resource_access_policy | ||
| self.cross_references: dict[str, Any] = {} |
There was a problem hiding this comment.
| self.cross_references: dict[str, Any] = {} | |
| self.symbol_info: dict[str, SymbolInfo] = {} |
| @force_nodocument | ||
| def build_symbol(self, symbol: "Element") -> GraphicsContext: | ||
| """Parse <symbol> as reusable content, not rendered directly.""" | ||
| group = self.build_group(symbol) | ||
| viewbox = symbol.attrib.get("viewBox") | ||
| if viewbox: | ||
| parts = viewbox.replace(",", " ").split() | ||
| if len(parts) >= 4: | ||
| vx, vy, vw, vh = (float(p) for p in parts[:4]) | ||
| group.transform = Transform.scaling( | ||
| x=1 / vw, y=1 / vh | ||
| ) @ Transform.translation(x=-vx, y=-vy) | ||
| return group | ||
|
|
There was a problem hiding this comment.
| @force_nodocument | |
| def build_symbol(self, symbol: "Element") -> GraphicsContext: | |
| """Parse <symbol> as reusable content, not rendered directly.""" | |
| group = self.build_group(symbol) | |
| viewbox = symbol.attrib.get("viewBox") | |
| if viewbox: | |
| parts = viewbox.replace(",", " ").split() | |
| if len(parts) >= 4: | |
| vx, vy, vw, vh = (float(p) for p in parts[:4]) | |
| group.transform = Transform.scaling( | |
| x=1 / vw, y=1 / vh | |
| ) @ Transform.translation(x=-vx, y=-vy) | |
| return group | |
| @force_nodocument | |
| def build_symbol(self, symbol: "Element") -> GraphicsContext: | |
| """Parse <symbol> as reusable content, not rendered directly.""" | |
| group = self.build_group(symbol) | |
| viewbox = symbol.attrib.get("viewBox") | |
| if viewbox: | |
| vx, vy, vw, vh = parse_viewbox(viewbox) | |
| if vw == 0 or vh == 0: | |
| group = GraphicsContext() | |
| else: | |
| group.transform = Transform.translation( | |
| x=-vx, y=-vy | |
| ) @ Transform.scaling(x=1 / vw, y=1 / vh) | |
| symbol_id = symbol.attrib.get("id") | |
| if symbol_id: | |
| symbol_key = ( | |
| "#" + symbol_id if not symbol_id.startswith("#") else symbol_id | |
| ) | |
| self.symbol_info[symbol_key] = SymbolInfo( | |
| viewbox=(vx, vy, vw, vh), | |
| width=resolve_optional_length(symbol.attrib.get("width")), | |
| height=resolve_optional_length(symbol.attrib.get("height")), | |
| ) | |
| return group | |
| target = self.cross_references[ref] | ||
| pdf_group.add_item(target) |
There was a problem hiding this comment.
| target = self.cross_references[ref] | |
| pdf_group.add_item(target) | |
| pdf_group.add_item(self.cross_references[ref]) |
I don't think there's the need for the extra step here
| if "x" in xref.attrib or "y" in xref.attrib: | ||
| # Quoting the SVG spec - 5.6.2. Layout of re-used graphics: | ||
| # > The x and y properties define an additional transformation translate(x,y) | ||
| x, y = float(xref.attrib.get("x", 0)), float(xref.attrib.get("y", 0)) | ||
| pdf_group.transform = Transform.translation(x=x, y=y) | ||
| # Note that we currently do not support "width" & "height" in <use> | ||
| # Note that we currently do not support "width" & "height" with % in <use> | ||
|
|
||
| if "width" in xref.attrib or "height" in xref.attrib: | ||
| w_str = xref.attrib.get("width", "") | ||
| h_str = xref.attrib.get("height", "") | ||
| if "%" not in w_str and "%" not in h_str: | ||
| w = float(w_str) if w_str else 1 | ||
| h = float(h_str) if h_str else 1 | ||
| if pdf_group.transform is None: | ||
| pdf_group.transform = Transform.scaling(x=w, y=h) | ||
| else: | ||
| pdf_group.transform = ( | ||
| Transform.scaling(x=w, y=h) @ pdf_group.transform | ||
| ) |
There was a problem hiding this comment.
| ) | |
| placement_transform = None | |
| if "x" in xref.attrib or "y" in xref.attrib: | |
| # Quoting the SVG spec - 5.6.2. Layout of re-used graphics: | |
| # > The x and y properties define an additional transformation translate(x,y) | |
| x, y = float(xref.attrib.get("x", 0)), float(xref.attrib.get("y", 0)) | |
| placement_transform = Transform.translation(x=x, y=y) | |
| # Note that we currently do not support "width" & "height" with % in <use> | |
| symbol_info = self.symbol_info.get(ref) | |
| if symbol_info: | |
| _, _, vw, vh = symbol_info.viewbox | |
| width = ( | |
| resolve_optional_length(xref.attrib.get("width")) | |
| or symbol_info.width | |
| or vw | |
| ) | |
| height = ( | |
| resolve_optional_length(xref.attrib.get("height")) | |
| or symbol_info.height | |
| or vh | |
| ) | |
| symbol_transform = Transform.scaling(x=width, y=height) | |
| if placement_transform is None: | |
| placement_transform = symbol_transform | |
| else: | |
| placement_transform = symbol_transform @ placement_transform | |
| if placement_transform: | |
| if pdf_group.transform: | |
| pdf_group.transform = placement_transform @ pdf_group.transform | |
| else: | |
| pdf_group.transform = placement_transform |
| def test_svg_symbol(self): | ||
| svg_data = ( | ||
| '<svg xmlns="http://www.w3.org/2000/svg" ' | ||
| 'xmlns:xlink="http://www.w3.org/1999/xlink">' | ||
| "<defs>" | ||
| '<symbol id="rond" width="10" height="10" viewBox="0 0 2 2"><circle cx="1" cy="1" r="1" fill="red"/></symbol>' | ||
| "</defs>" | ||
| '<use href="#rond" x="10" y="10" width="40" height="40"/>' | ||
| "</svg>" | ||
| ) | ||
| svg = fpdf.svg.SVGObject(svg_data) | ||
| assert svg is not None | ||
| assert "#rond" in svg.cross_references | ||
|
|
There was a problem hiding this comment.
| def test_svg_symbol(self): | |
| svg_data = ( | |
| '<svg xmlns="http://www.w3.org/2000/svg" ' | |
| 'xmlns:xlink="http://www.w3.org/1999/xlink">' | |
| "<defs>" | |
| '<symbol id="rond" width="10" height="10" viewBox="0 0 2 2"><circle cx="1" cy="1" r="1" fill="red"/></symbol>' | |
| "</defs>" | |
| '<use href="#rond" x="10" y="10" width="40" height="40"/>' | |
| "</svg>" | |
| ) | |
| svg = fpdf.svg.SVGObject(svg_data) | |
| assert svg is not None | |
| assert "#rond" in svg.cross_references | |
| def test_svg_symbol(self): | |
| svg_data = ( | |
| '<svg xmlns="http://www.w3.org/2000/svg" ' | |
| 'xmlns:xlink="http://www.w3.org/1999/xlink">' | |
| "<defs>" | |
| '<symbol id="rond" width="10" height="10" viewBox="0 0 2 2"><circle cx="1" cy="1" r="1" fill="red"/></symbol>' | |
| "</defs>" | |
| '<use href="#rond" x="10" y="10" width="40" height="40"/>' | |
| "</svg>" | |
| ) | |
| svg = fpdf.svg.SVGObject(svg_data) | |
| assert svg is not None | |
| assert "#rond" in svg.cross_references | |
| def test_svg_symbol_uses_symbol_dimensions(self): | |
| svg_data = ( | |
| '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 20">' | |
| '<symbol id="myDot" width="10" height="10" viewBox="0 0 2 2">' | |
| '<circle cx="1" cy="1" r="1" />' | |
| "</symbol>" | |
| '<use href="#myDot" x="5" y="5" />' | |
| "</svg>" | |
| ) | |
| svg = fpdf.svg.SVGObject(svg_data) | |
| symbol_use = svg.base_group.path_items[0] | |
| assert tuple(symbol_use.transform) == pytest.approx((10, 0, 0, 10, 5, 5)) | |
| assert tuple(symbol_use.path_items[0].transform) == pytest.approx( | |
| (0.5, 0, 0, 0.5, 0, 0) | |
| ) | |
| def test_svg_symbol_use_dimensions_override_symbol_dimensions(self): | |
| svg_data = ( | |
| '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 20">' | |
| '<symbol id="myDot" width="10" height="10" viewBox="0 0 2 2">' | |
| '<circle cx="1" cy="1" r="1" />' | |
| "</symbol>" | |
| '<use href="#myDot" x="5" y="5" width="40" height="20" />' | |
| "</svg>" | |
| ) | |
| svg = fpdf.svg.SVGObject(svg_data) | |
| symbol_use = svg.base_group.path_items[0] | |
| assert tuple(symbol_use.transform) == pytest.approx((40, 0, 0, 20, 5, 5)) | |
| def test_svg_symbol_viewbox_origin_is_translated_before_scaling(self): | |
| svg_data = ( | |
| '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 20">' | |
| '<symbol id="myDot" width="10" height="10" viewBox="10 10 2 2">' | |
| '<circle cx="11" cy="11" r="1" />' | |
| "</symbol>" | |
| '<use href="#myDot" x="5" y="5" />' | |
| "</svg>" | |
| ) | |
| svg = fpdf.svg.SVGObject(svg_data) | |
| symbol_use = svg.base_group.path_items[0] | |
| assert tuple(symbol_use.transform) == pytest.approx((10, 0, 0, 10, 5, 5)) | |
| assert tuple(symbol_use.path_items[0].transform) == pytest.approx( | |
| (0.5, 0, 0, 0.5, -5, -5) | |
| ) | |
| def test_use_width_height_do_not_scale_non_symbol_references(self): | |
| svg_data = ( | |
| '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 20">' | |
| '<defs><path id="path" d="M 0 0 L 1 2 Z"/></defs>' | |
| '<use href="#path" x="5" y="5" width="40" height="20" />' | |
| "</svg>" | |
| ) | |
| svg = fpdf.svg.SVGObject(svg_data) | |
| path_use = svg.base_group.path_items[0] | |
| assert tuple(path_use.transform) == pytest.approx((1, 0, 0, 1, 5, 5)) |
|
Thanks for the detailed feedback! I will implement your suggestions and get back to you once it's done, or if I don't understand some details. |

Add basic support in SVG parsing
This PR adds support for SVG
<symbol>elements.Checklist:
A unit test is covering the code added / modified by this PR
In case of a new feature, docstrings have been added, with also some documentation in the
docs/folder (N/A)A mention of the change is present in
CHANGELOG.mdThis PR is ready to be merged
By submitting this pull request, I confirm that my contribution is made under the terms of the GNU LGPL 3.0 license.