From f1402d66f7da7c1d32ffb1c24a3a4f8df81210f0 Mon Sep 17 00:00:00 2001 From: tmassenya Date: Thu, 19 Mar 2026 22:34:16 +0100 Subject: [PATCH 1/8] Automatically format test SVG --- test/svg/test_svg.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/svg/test_svg.py b/test/svg/test_svg.py index 9b5be05fd..ec8358b6d 100644 --- a/test/svg/test_svg.py +++ b/test/svg/test_svg.py @@ -245,6 +245,16 @@ def test_missing_xref(self): with pytest.raises(ValueError): fpdf.svg.SVGObject(svg_data) + def test_svg_symbol(self): + svg_data = ( + '' + '' + '' + ) + with pytest.raises(ValueError): + fpdf.svg.SVGObject(svg_data) + def test_svg_conversion_no_transparency(self, tmp_path): svg = fpdf.svg.SVGObject.from_file(parameters.svgfile("SVG_logo.svg")) From 08c997fcc6630a2ae825907c2b3e1efda207f027 Mon Sep 17 00:00:00 2001 From: tmassenya Date: Fri, 10 Apr 2026 16:27:17 +0200 Subject: [PATCH 2/8] add basic and tests --- fpdf/svg.py | 9 +++++++++ test/svg/test_svg.py | 8 ++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/fpdf/svg.py b/fpdf/svg.py index 2e4f16583..f937554c5 100644 --- a/fpdf/svg.py +++ b/fpdf/svg.py @@ -1456,6 +1456,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"): # tags aren't supported but we need to recurse into them to # render nested elements. @@ -1489,6 +1491,11 @@ def handle_defs(self, defs: "Element") -> None: without_ns(child.tag), ) + @force_nodocument + def build_symbol(self, symbol: "Element") -> GraphicsContext: + """Parse as reusable content, not rendered directly.""" + return self.build_group(symbol) + # this assumes xrefs only reference already-defined ids. # I don't know if this is required by the SVG spec. @force_nodocument @@ -1550,6 +1557,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"): diff --git a/test/svg/test_svg.py b/test/svg/test_svg.py index 7ed4f2f6e..1cb2d8fcd 100644 --- a/test/svg/test_svg.py +++ b/test/svg/test_svg.py @@ -250,11 +250,15 @@ def test_svg_symbol(self): svg_data = ( '' + "" '' + "" '' + "" ) - with pytest.raises(ValueError): - fpdf.svg.SVGObject(svg_data) + svg = fpdf.svg.SVGObject(svg_data) + assert svg is not None + assert "#rond" in svg.cross_references def test_svg_conversion_no_transparency(self, tmp_path): svg = fpdf.svg.SVGObject.from_file(parameters.svgfile("SVG_logo.svg")) From 4b5ede7b7a3890d75e1d9269000346224e701246 Mon Sep 17 00:00:00 2001 From: tmassenya Date: Wed, 15 Apr 2026 13:01:23 +0200 Subject: [PATCH 3/8] Update changelog for SVG support --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e3b89b5..d7fb1a99e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` elements in the SVG parser ### 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) * preserve boundary-neutral formatting during bidirectional text preprocessing - _cf._ [issue #1779](https://github.com/py-pdf/fpdf2/issues/1779) From 97b9aa47542bff32339b8c7136af7304eb6d11f0 Mon Sep 17 00:00:00 2001 From: tmassenya Date: Tue, 21 Apr 2026 18:29:50 +0200 Subject: [PATCH 4/8] scale adjustment for symbols --- fpdf/svg.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/fpdf/svg.py b/fpdf/svg.py index f937554c5..2cb8bab66 100644 --- a/fpdf/svg.py +++ b/fpdf/svg.py @@ -1494,7 +1494,14 @@ def handle_defs(self, defs: "Element") -> None: @force_nodocument def build_symbol(self, symbol: "Element") -> GraphicsContext: """Parse as reusable content, not rendered directly.""" - return self.build_group(symbol) + 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])) + setattr(group, "_vh", float(parts[3])) + return group # this assumes xrefs only reference already-defined ids. # I don't know if this is required by the SVG spec. @@ -1515,7 +1522,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) except KeyError: raise ValueError( f"use {xref} references nonexistent ref id {ref}" @@ -1528,6 +1536,24 @@ def build_xref(self, xref: "Element") -> GraphicsContext: pdf_group.transform = Transform.translation(x=x, y=y) # Note that we currently do not support "width" & "height" in + if "width" in xref.attrib or "height" in xref.attrib: + w = float(xref.attrib.get("width", 1)) + h = float(xref.attrib.get("height", 1)) + + target = self.cross_references.get(ref) + vw = getattr(target, "_vw", w) + vh = getattr(target, "_vh", h) + + scale_x = w / vw if vw else 1 + scale_y = h / vh if vh else 1 + + if pdf_group.transform is None: + pdf_group.transform = Transform.scaling(x=scale_x, y=scale_y) + else: + pdf_group.transform = pdf_group.transform @ Transform.scaling( + x=scale_x, y=scale_y + ) + return pdf_group @force_nodocument From 23402a125526377ff0cc2ff1793980c227b74a49 Mon Sep 17 00:00:00 2001 From: tmassenya Date: Mon, 4 May 2026 12:56:43 +0200 Subject: [PATCH 5/8] Fix scaling using transforms instead of setattr --- fpdf/svg.py | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/fpdf/svg.py b/fpdf/svg.py index 3bc7db38e..dbe26a97a 100644 --- a/fpdf/svg.py +++ b/fpdf/svg.py @@ -1516,8 +1516,10 @@ def build_symbol(self, symbol: "Element") -> GraphicsContext: if viewbox: parts = viewbox.replace(",", " ").split() if len(parts) >= 4: - setattr(group, "_vw", float(parts[2])) - setattr(group, "_vh", float(parts[3])) + 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 # this assumes xrefs only reference already-defined ids. @@ -1551,25 +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 + # Note that we currently do not support "width" & "height" with % in if "width" in xref.attrib or "height" in xref.attrib: - w = float(xref.attrib.get("width", 1)) - h = float(xref.attrib.get("height", 1)) - - target = self.cross_references.get(ref) - vw = getattr(target, "_vw", w) - vh = getattr(target, "_vh", h) - - scale_x = w / vw if vw else 1 - scale_y = h / vh if vh else 1 - - if pdf_group.transform is None: - pdf_group.transform = Transform.scaling(x=scale_x, y=scale_y) - else: - pdf_group.transform = pdf_group.transform @ Transform.scaling( - x=scale_x, y=scale_y - ) + 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 From a5e14f7ce8e2f14b125588303344dafd62463c85 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 23:03:14 -0400 Subject: [PATCH 6/8] Update step-security/harden-runner action to v2.19.2 (#1850) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/continuous-integration-workflow.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index ef22d0801..bbe3224a6 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -180,7 +180,7 @@ jobs: - name: Harden Runner # Security hardening because this is a sensitive job, # where extra care should be taken NOT to leak any secret - uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + uses: step-security/harden-runner@9ca718d3bf646d6534007c269a635b3e54cadf99 # v2.19.2 with: egress-policy: block allowed-endpoints: @@ -254,7 +254,7 @@ jobs: - name: Harden Runner # Security hardening because this is a sensitive job, # where extra care should be taken NOT to leak any secret - uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + uses: step-security/harden-runner@9ca718d3bf646d6534007c269a635b3e54cadf99 # v2.19.2 with: egress-policy: block allowed-endpoints: From 450d119369944cbef8ce3542d026c380d7ec964e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 22:18:23 -0400 Subject: [PATCH 7/8] Update step-security/harden-runner action to v2.19.3 (#1851) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/continuous-integration-workflow.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index bbe3224a6..13f1c8b6d 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -180,7 +180,7 @@ jobs: - name: Harden Runner # Security hardening because this is a sensitive job, # where extra care should be taken NOT to leak any secret - uses: step-security/harden-runner@9ca718d3bf646d6534007c269a635b3e54cadf99 # v2.19.2 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: @@ -254,7 +254,7 @@ jobs: - name: Harden Runner # Security hardening because this is a sensitive job, # where extra care should be taken NOT to leak any secret - uses: step-security/harden-runner@9ca718d3bf646d6534007c269a635b3e54cadf99 # v2.19.2 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: From 66218677da427d4559dcc31f8732a3508bed3b6a Mon Sep 17 00:00:00 2001 From: tmassenya Date: Sat, 16 May 2026 12:36:57 +0200 Subject: [PATCH 8/8] Implement suggestions for SVG support --- fpdf/svg.py | 94 +++++++++++++++++++++++------- test/svg/generated_pdf/symbol.pdf | Bin 0 -> 1710 bytes test/svg/parameters.py | 1 + test/svg/svg_sources/symbol.svg | 19 ++++++ test/svg/test_svg.py | 84 +++++++++++++++++++++----- 5 files changed, 162 insertions(+), 36 deletions(-) create mode 100644 test/svg/generated_pdf/symbol.pdf create mode 100644 test/svg/svg_sources/symbol.svg diff --git a/fpdf/svg.py b/fpdf/svg.py index dbe26a97a..b29d78dd5 100644 --- a/fpdf/svg.py +++ b/fpdf/svg.py @@ -150,6 +150,34 @@ class Percent(float): } +class SymbolInfo(NamedTuple): + """Reusable SVG 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 + + # in CSS the default length unit is px, but as far as I can tell, for SVG interpreting # unitless numbers as being expressed in pt is more appropriate. Particularly, the # scaling we do using viewBox attempts to scale so that 1 svg user unit = 1 pdf pt @@ -913,6 +941,7 @@ def __init__( self.image_cache = image_cache # Needed to render images self.resource_access_policy = resource_access_policy self.cross_references: dict[str, Any] = {} + self.symbol_info: dict[str, SymbolInfo] = {} self.css_class_styles: dict[str, dict[str, Any]] = {} self.gradient_definitions: dict[str, GradientPaint] = ( {} @@ -1514,12 +1543,23 @@ def build_symbol(self, symbol: "Element") -> GraphicsContext: 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) + 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. @@ -1541,32 +1581,42 @@ def build_xref(self, xref: "Element") -> GraphicsContext: raise ValueError(f"use {xref} doesn't contain known xref attribute") try: - target = self.cross_references[ref] - pdf_group.add_item(target) + pdf_group.add_item(self.cross_references[ref]) except KeyError: raise ValueError( f"use {xref} references nonexistent ref id {ref}" ) from None + 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)) - pdf_group.transform = Transform.translation(x=x, y=y) + placement_transform = Transform.translation(x=x, y=y) # Note that we currently do not support "width" & "height" with % in - - 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 - ) + 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 return pdf_group diff --git a/test/svg/generated_pdf/symbol.pdf b/test/svg/generated_pdf/symbol.pdf new file mode 100644 index 0000000000000000000000000000000000000000..cba84ffa321606333a8d5c35dfb86da899c0501d GIT binary patch literal 1710 zcmah~U1$_n6n;n^$}|)#Qt-+1&|urx%)PTavpc$tW@mQRU}R-?iwex+01Na z@5)A6C@oU)2L+KfqNaZamHOaYDJ@dziw|wlwiIlj7-*?YU!;n@*>h(n*>PjdJnX%5 z?)mQh&UfxN+r8VgUZ=frsr&BIy=xK$0^az3DV36B!>hX-s3h-nCTcJo!=?PFB=2Gq zj-B=zFibF#B)x=yB7SkLI8$XH@3tpdP3r5Dm^&dhQzDz@s?6;#amRDR9YMAnz60EP zrGgs>=%790)j7yHmlj)8X z<}wk>sc`0FkrkUWla;&)Cdq@$o#gvaHC>=;+-LTb)HwV7$v0lOPPz^~Jij>l?&4R| z9hf&xVhN*=F8i*&Yk-DqpRM<)5mhLPde@n%@X@<`Er<9HK2-;5!qMdGY^XN(fV;u$J4N0vp_er>I&Y3W)kt^J`~o3U z)&(6%V*8F^X^PF@>OJZF#Zy>O4)s(%bShZS@%mg3`6+CDhfir16zu7fc~ptk%YECaFhBS^C^!e1&`-{L9*3Zhl04v+JqUV& z%^omeI7N+2Oi5=*JgsLeRZ)pWsH&Kj+Mm|@b;aykJ0s|KjoUsCx<(aMle)VzIZOHv D@%Q5v literal 0 HcmV?d00001 diff --git a/test/svg/parameters.py b/test/svg/parameters.py index fc5558df8..86ffe794a 100644 --- a/test/svg/parameters.py +++ b/test/svg/parameters.py @@ -794,6 +794,7 @@ def Gs(**kwargs): pytest.param(svgfile("gradient_spread_methods.svg"), id="Gradient spread methods"), # issue 1831 pytest.param(svgfile("sunburst.svg"), id="small arc round to zero"), + pytest.param(svgfile("symbol.svg"), id="symbol reused in different placements"), ) svg_path_edge_cases = ( diff --git a/test/svg/svg_sources/symbol.svg b/test/svg/svg_sources/symbol.svg new file mode 100644 index 000000000..aa08da05c --- /dev/null +++ b/test/svg/svg_sources/symbol.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/svg/test_svg.py b/test/svg/test_svg.py index 1cb2d8fcd..fbffe930c 100644 --- a/test/svg/test_svg.py +++ b/test/svg/test_svg.py @@ -246,20 +246,6 @@ def test_missing_xref(self): with pytest.raises(ValueError): fpdf.svg.SVGObject(svg_data) - def test_svg_symbol(self): - svg_data = ( - '' - "" - '' - "" - '' - "" - ) - svg = fpdf.svg.SVGObject(svg_data) - assert svg is not None - assert "#rond" in svg.cross_references - def test_svg_conversion_no_transparency(self, tmp_path): svg = fpdf.svg.SVGObject.from_file(parameters.svgfile("SVG_logo.svg")) @@ -355,6 +341,76 @@ def test_svg_text_ttf_font(self, tmp_path): tmp_path, ) + def test_svg_symbol(self): + svg_data = ( + '' + "" + '' + "" + '' + "" + ) + 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 = 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 = 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 = 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 = 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_user_space_gradient_tracks_svg_image_transform(tmp_path): svg_data = """