Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ This can also be enabled programmatically with `warnings.simplefilter('default',
## [2.8.8] - Not released yet
### Added
* Punjabi (pa) tutorial translation - thanks to @Pawansingh3889
* basic support for SVG `<symbol>` elements in the SVG parser
* `resource_access_policy` and [Security considerations](https://py-pdf.github.io/fpdf2/Security.html) documentation
### Fixed
* text rendering when the first text on a page starts with a fallback glyph - _cf._ [issue #1772](https://github.com/py-pdf/fpdf2/issues/1772)
Expand Down
36 changes: 34 additions & 2 deletions fpdf/svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -1473,6 +1473,8 @@ def handle_defs(self, defs: "Element") -> None:
for child in defs:
if child.tag in xmlns_lookup("svg", "g"):
self.build_group(child)
elif child.tag in xmlns_lookup("svg", "symbol"):
self.build_symbol(child)
elif child.tag in xmlns_lookup("svg", "a"):
# <a> tags aren't supported but we need to recurse into them to
# render nested elements.
Expand Down Expand Up @@ -1506,6 +1508,20 @@ def handle_defs(self, defs: "Element") -> None:
without_ns(child.tag),
)

@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

Comment on lines +1511 to +1524
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@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

# this assumes xrefs only reference already-defined ids.
# I don't know if this is required by the SVG spec.
@force_nodocument
Expand All @@ -1525,7 +1541,8 @@ def build_xref(self, xref: "Element") -> GraphicsContext:
raise ValueError(f"use {xref} doesn't contain known xref attribute")

try:
pdf_group.add_item(self.cross_references[ref])
target = self.cross_references[ref]
pdf_group.add_item(target)
Comment on lines +1544 to +1545
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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

except KeyError:
raise ValueError(
f"use {xref} references nonexistent ref id {ref}"
Expand All @@ -1536,7 +1553,20 @@ def build_xref(self, xref: "Element") -> GraphicsContext:
# > 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
)

return pdf_group

Expand Down Expand Up @@ -1567,6 +1597,8 @@ def build_group(
elif child.tag in xmlns_lookup("svg", "style"):
# Stylesheets already parsed globally.
continue
elif child.tag in xmlns_lookup("svg", "symbol"):
self.build_symbol(child)
elif child.tag in xmlns_lookup("svg", "g"):
pdf_group.add_item(self.build_group(child, None, merged_style), False)
elif child.tag in xmlns_lookup("svg", "a"):
Expand Down
14 changes: 14 additions & 0 deletions test/svg/test_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,20 @@ def test_missing_xref(self):
with pytest.raises(ValueError):
fpdf.svg.SVGObject(svg_data)

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

Comment on lines +249 to +262
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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))

def test_svg_conversion_no_transparency(self, tmp_path):
svg = fpdf.svg.SVGObject.from_file(parameters.svgfile("SVG_logo.svg"))

Expand Down
Loading