Skip to content

Commit eac6120

Browse files
authored
updates to SVG conversion: defs, shapes and clipping paths (#968)
* SVG tags now read before any other tags are read, shapes in tags now work, added additional installs to Development.md * clip-path and clipPath implemented in SVG conversion * ran black * todo fixed * removing leftover pdfs * updated shapes defs test, added documentation, removed extraneous files * adding example code for generating pdfs for testing * changeglog fixed
1 parent 99e4d6d commit eac6120

File tree

10 files changed

+136
-25
lines changed

10 files changed

+136
-25
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# my files
22
.env
3+
.DS_Store
34

45
# codecov.io
56
coverage.xml

CHANGELOG.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ in order to get warned about deprecated features used in your code.
1717
This can also be enabled programmatically with `warnings.simplefilter('default', DeprecationWarning)`.
1818

1919
## [2.7.7] - Not released yet
20-
20+
### Added
21+
* SVG importing now supports clipping paths, and `defs` tags anywhere in the SVG file
2122

2223
## [2.7.6] - 2023-10-11
2324
This release is the first performed from the [@py-pdf GitHub org](https://github.com/py-pdf), where `fpdf2` migrated.

docs/Development.md

+19
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ pre-commit install
7070
To run tests, `cd` into `fpdf2` repository, install the dependencies using
7171
`pip install -r test/requirements.txt`, and run `pytest`.
7272

73+
You may also need to install [SWIG](https://swig.org/index.html) and [Ghostscript](https://www.ghostscript.com/).
74+
7375
You can run a single test by executing: `pytest -k function_name`.
7476

7577
Alternatively, you can use [Tox](https://tox.readthedocs.io/en/latest/).
@@ -116,9 +118,26 @@ All generated PDF files (including those processed by `qpdf`) will be stored in
116118
last test runs will be saved and then automatically deleted, so you can
117119
check the output in case of a failed test.
118120

121+
### Generating PDF files for testing
119122
In order to generate a "reference" PDF file, simply call `assert_pdf_equal`
120123
once with `generate=True`.
121124

125+
```
126+
import fpdf
127+
128+
svg = fpdf.svg.SVGObject.from_file("path/to/file.svg")
129+
pdf = fpdf.FPDF(unit="pt", format=(svg.width, svg.height))
130+
pdf.add_page()
131+
svg.draw_to_page(pdf)
132+
133+
assert_pdf_equal(
134+
pdf,
135+
"path/for/pdf/output.pdf",
136+
"path/for/pdf/",
137+
generate=True
138+
)
139+
```
140+
122141
## Testing performances
123142

124143
### Code speed & profiling

docs/SVG.md

+2
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ Additionally, `cairosvg` offers various options for optimizing the rendering per
190190
- stroke & fill coloring and opacity
191191
- basic stroke styling
192192
- Inline CSS styling via `style="..."` attributes.
193+
- clipping paths
194+
- `defs` tags anywhere in the SVG code
193195

194196
## Currently Unsupported Notable SVG Features ##
195197

fpdf/svg.py

+64-23
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
GraphicsContext,
2727
GraphicsStyle,
2828
PaintedPath,
29+
ClippingPath,
2930
Transform,
3031
)
3132

@@ -329,15 +330,17 @@ class ShapeBuilder:
329330
"""A namespace within which methods for converting basic shapes can be looked up."""
330331

331332
@staticmethod
332-
def new_path(tag):
333+
def new_path(tag, clipping_path: bool = False):
333334
"""Create a new path with the appropriate styles."""
334335
path = PaintedPath()
336+
if clipping_path:
337+
path = ClippingPath()
335338
apply_styles(path, tag)
336339

337340
return path
338341

339342
@classmethod
340-
def rect(cls, tag):
343+
def rect(cls, tag, clipping_path: bool = False):
341344
"""Convert an SVG <rect> into a PDF path."""
342345
# svg rect is wound clockwise
343346
if "x" in tag.attrib:
@@ -387,33 +390,33 @@ def rect(cls, tag):
387390
if ry > (height / 2):
388391
ry = height / 2
389392

390-
path = cls.new_path(tag)
393+
path = cls.new_path(tag, clipping_path)
391394

392395
path.rectangle(x, y, width, height, rx, ry)
393396
return path
394397

395398
@classmethod
396-
def circle(cls, tag):
399+
def circle(cls, tag, clipping_path: bool = False):
397400
"""Convert an SVG <circle> into a PDF path."""
398401
cx = float(tag.attrib.get("cx", 0))
399402
cy = float(tag.attrib.get("cy", 0))
400403
r = float(tag.attrib["r"])
401404

402-
path = cls.new_path(tag)
405+
path = cls.new_path(tag, clipping_path)
403406

404407
path.circle(cx, cy, r)
405408
return path
406409

407410
@classmethod
408-
def ellipse(cls, tag):
411+
def ellipse(cls, tag, clipping_path: bool = False):
409412
"""Convert an SVG <ellipse> into a PDF path."""
410413
cx = float(tag.attrib.get("cx", 0))
411414
cy = float(tag.attrib.get("cy", 0))
412415

413416
rx = tag.attrib.get("rx", "auto")
414417
ry = tag.attrib.get("ry", "auto")
415418

416-
path = cls.new_path(tag)
419+
path = cls.new_path(tag, clipping_path)
417420

418421
if (rx == ry == "auto") or (rx == 0) or (ry == 0):
419422
return path
@@ -457,11 +460,11 @@ def polyline(cls, tag):
457460
return path
458461

459462
@classmethod
460-
def polygon(cls, tag):
463+
def polygon(cls, tag, clipping_path: bool = False):
461464
"""Convert an SVG <polygon> into a PDF path."""
462465
points = tag.attrib["points"]
463466

464-
path = cls.new_path(tag)
467+
path = cls.new_path(tag, clipping_path)
465468

466469
points = "M" + points + "Z"
467470
svg_path_converter(path, points)
@@ -665,6 +668,12 @@ def __init__(self, svg_text):
665668
self.extract_shape_info(svg_tree)
666669
self.convert_graphics(svg_tree)
667670

671+
@force_nodocument
672+
def update_xref(self, key, referenced):
673+
if key:
674+
key = "#" + key if not key.startswith("#") else key
675+
self.cross_references[key] = referenced
676+
668677
@force_nodocument
669678
def extract_shape_info(self, root_tag):
670679
"""Collect shape info from the given SVG."""
@@ -859,7 +868,15 @@ def handle_defs(self, defs):
859868
self.build_group(child)
860869
if child.tag in xmlns_lookup("svg", "path"):
861870
self.build_path(child)
862-
# We could/should also support <defs> that are rect, circle, ellipse, line, polyline, polygon...
871+
elif child.tag in shape_tags:
872+
self.build_shape(child)
873+
if child.tag in xmlns_lookup("svg", "clipPath"):
874+
try:
875+
clip_id = child.attrib["id"]
876+
except KeyError:
877+
clip_id = None
878+
for child_ in child:
879+
self.build_clipping_path(child_, clip_id)
863880

864881
# this assumes xrefs only reference already-defined ids.
865882
# I don't know if this is required by the SVG spec.
@@ -869,7 +886,7 @@ def build_xref(self, xref):
869886
pdf_group = GraphicsContext()
870887
apply_styles(pdf_group, xref)
871888

872-
for candidate in xmlns_lookup("xlink", "href"):
889+
for candidate in xmlns_lookup("xlink", "href", "id"):
873890
try:
874891
ref = xref.attrib[candidate]
875892
break
@@ -901,22 +918,23 @@ def build_group(self, group, pdf_group=None):
901918
pdf_group = GraphicsContext()
902919
apply_styles(pdf_group, group)
903920

921+
# handle defs before anything else
922+
for child in [
923+
child for child in group if child.tag in xmlns_lookup("svg", "defs")
924+
]:
925+
self.handle_defs(child)
926+
904927
for child in group:
905-
if child.tag in xmlns_lookup("svg", "defs"):
906-
self.handle_defs(child)
907928
if child.tag in xmlns_lookup("svg", "g"):
908929
pdf_group.add_item(self.build_group(child))
909930
if child.tag in xmlns_lookup("svg", "path"):
910931
pdf_group.add_item(self.build_path(child))
911932
elif child.tag in shape_tags:
912-
pdf_group.add_item(getattr(ShapeBuilder, shape_tags[child.tag])(child))
933+
pdf_group.add_item(self.build_shape(child))
913934
if child.tag in xmlns_lookup("svg", "use"):
914935
pdf_group.add_item(self.build_xref(child))
915936

916-
try:
917-
self.cross_references["#" + group.attrib["id"]] = pdf_group
918-
except KeyError:
919-
pass
937+
self.update_xref(group.attrib.get("id"), pdf_group)
920938

921939
return pdf_group
922940

@@ -925,15 +943,38 @@ def build_path(self, path):
925943
"""Convert an SVG <path> tag into a PDF path object."""
926944
pdf_path = PaintedPath()
927945
apply_styles(pdf_path, path)
946+
self.apply_clipping_path(pdf_path, path)
928947

929-
svg_path = path.attrib.get("d", None)
948+
svg_path = path.attrib.get("d")
930949

931950
if svg_path is not None:
932951
svg_path_converter(pdf_path, svg_path)
933952

934-
try:
935-
self.cross_references["#" + path.attrib["id"]] = pdf_path
936-
except KeyError:
937-
pass
953+
self.update_xref(path.attrib.get("id"), pdf_path)
938954

939955
return pdf_path
956+
957+
@force_nodocument
958+
def build_shape(self, shape):
959+
"""Convert an SVG shape tag into a PDF path object. Necessary to make xref (because ShapeBuilder doesn't have access to this object.)"""
960+
shape_path = getattr(ShapeBuilder, shape_tags[shape.tag])(shape)
961+
self.apply_clipping_path(shape_path, shape)
962+
963+
self.update_xref(shape.attrib.get("id"), shape_path)
964+
965+
return shape_path
966+
967+
@force_nodocument
968+
def build_clipping_path(self, shape, clip_id):
969+
clipping_path_shape = getattr(ShapeBuilder, shape_tags[shape.tag])(shape, True)
970+
971+
self.update_xref(clip_id, clipping_path_shape)
972+
973+
return clipping_path_shape
974+
975+
@force_nodocument
976+
def apply_clipping_path(self, stylable, svg_element):
977+
clipping_path = svg_element.attrib.get("clip-path")
978+
if clipping_path:
979+
clipping_path_id = re.search(r"url\((\#\w+)\)", clipping_path)
980+
stylable.clipping_path = self.cross_references[clipping_path_id[1]]

test/svg/generated_pdf/clip_path.pdf

1.23 KB
Binary file not shown.
1.41 KB
Binary file not shown.

test/svg/parameters.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -759,13 +759,17 @@ def Gs(**kwargs):
759759
pytest.param(svgfile("SVG_logo.svg"), id="SVG logo from wikipedia"),
760760
pytest.param(svgfile("viewbox.svg"), id="weird viewbox"),
761761
pytest.param(svgfile("search.svg"), id="search icon"), # issue 356
762-
# discovered while investigatin issue 358:
762+
# discovered while investigating issue 358:
763763
pytest.param(svgfile("issue_358b.svg"), id="repeated relative move"),
764764
pytest.param(svgfile("issue_358.svg"), id="arc start & initial point"), # issue 358
765765
pytest.param(svgfile("Ghostscript_colorcircle.svg"), id="ghostscript colorcircle"),
766766
pytest.param(svgfile("Ghostscript_escher.svg"), id="ghostscript escher"),
767767
pytest.param(svgfile("use-xlink-href.svg"), id="use xlink:href - issue #446"),
768768
pytest.param(svgfile("rgb-color-issue-480.svg"), id="rgb() color - issue #480"),
769+
pytest.param(
770+
svgfile("shapes_def_test.svg"), id="shapes defined in 'defs' tag - issue #858"
771+
),
772+
pytest.param(svgfile("clip_path.svg"), id="clip path - issue #858"),
769773
)
770774

771775
svg_path_edge_cases = (

test/svg/svg_sources/clip_path.svg

+18
Loading
+25
Loading

0 commit comments

Comments
 (0)