Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

updates to SVG conversion: defs, shapes and clipping paths #968

Merged
merged 13 commits into from
Oct 24, 2023
Merged
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 .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# my files
.env
.DS_Store

# codecov.io
coverage.xml
Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ in order to get warned about deprecated features used in your code.
This can also be enabled programmatically with `warnings.simplefilter('default', DeprecationWarning)`.

## [2.7.7] - Not released yet

### Added
* SVG importing now supports clipping paths, and `defs` tags anywhere in the SVG file

## [2.7.6] - 2023-10-11
This release is the first performed from the [@py-pdf GitHub org](https://github.com/py-pdf), where `fpdf2` migrated.
Expand Down
19 changes: 19 additions & 0 deletions docs/Development.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ pre-commit install
To run tests, `cd` into `fpdf2` repository, install the dependencies using
`pip install -r test/requirements.txt`, and run `pytest`.

You may also need to install [SWIG](https://swig.org/index.html) and [Ghostscript](https://www.ghostscript.com/).

You can run a single test by executing: `pytest -k function_name`.

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

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

```
import fpdf

svg = fpdf.svg.SVGObject.from_file("path/to/file.svg")
pdf = fpdf.FPDF(unit="pt", format=(svg.width, svg.height))
pdf.add_page()
svg.draw_to_page(pdf)

assert_pdf_equal(
pdf,
"path/for/pdf/output.pdf",
"path/for/pdf/",
generate=True
)
```

## Testing performances

### Code speed & profiling
Expand Down
2 changes: 2 additions & 0 deletions docs/SVG.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ Additionally, `cairosvg` offers various options for optimizing the rendering per
- stroke & fill coloring and opacity
- basic stroke styling
- Inline CSS styling via `style="..."` attributes.
- clipping paths
- `defs` tags anywhere in the SVG code

## Currently Unsupported Notable SVG Features ##

Expand Down
87 changes: 64 additions & 23 deletions fpdf/svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
GraphicsContext,
GraphicsStyle,
PaintedPath,
ClippingPath,
Transform,
)

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

@staticmethod
def new_path(tag):
def new_path(tag, clipping_path: bool = False):
"""Create a new path with the appropriate styles."""
path = PaintedPath()
if clipping_path:
path = ClippingPath()
apply_styles(path, tag)

return path

@classmethod
def rect(cls, tag):
def rect(cls, tag, clipping_path: bool = False):
"""Convert an SVG <rect> into a PDF path."""
# svg rect is wound clockwise
if "x" in tag.attrib:
Expand Down Expand Up @@ -387,33 +390,33 @@ def rect(cls, tag):
if ry > (height / 2):
ry = height / 2

path = cls.new_path(tag)
path = cls.new_path(tag, clipping_path)

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

@classmethod
def circle(cls, tag):
def circle(cls, tag, clipping_path: bool = False):
"""Convert an SVG <circle> into a PDF path."""
cx = float(tag.attrib.get("cx", 0))
cy = float(tag.attrib.get("cy", 0))
r = float(tag.attrib["r"])

path = cls.new_path(tag)
path = cls.new_path(tag, clipping_path)

path.circle(cx, cy, r)
return path

@classmethod
def ellipse(cls, tag):
def ellipse(cls, tag, clipping_path: bool = False):
"""Convert an SVG <ellipse> into a PDF path."""
cx = float(tag.attrib.get("cx", 0))
cy = float(tag.attrib.get("cy", 0))

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

path = cls.new_path(tag)
path = cls.new_path(tag, clipping_path)

if (rx == ry == "auto") or (rx == 0) or (ry == 0):
return path
Expand Down Expand Up @@ -457,11 +460,11 @@ def polyline(cls, tag):
return path

@classmethod
def polygon(cls, tag):
def polygon(cls, tag, clipping_path: bool = False):
"""Convert an SVG <polygon> into a PDF path."""
points = tag.attrib["points"]

path = cls.new_path(tag)
path = cls.new_path(tag, clipping_path)

points = "M" + points + "Z"
svg_path_converter(path, points)
Expand Down Expand Up @@ -665,6 +668,12 @@ def __init__(self, svg_text):
self.extract_shape_info(svg_tree)
self.convert_graphics(svg_tree)

@force_nodocument
def update_xref(self, key, referenced):
if key:
key = "#" + key if not key.startswith("#") else key
self.cross_references[key] = referenced

@force_nodocument
def extract_shape_info(self, root_tag):
"""Collect shape info from the given SVG."""
Expand Down Expand Up @@ -859,7 +868,15 @@ def handle_defs(self, defs):
self.build_group(child)
if child.tag in xmlns_lookup("svg", "path"):
self.build_path(child)
# We could/should also support <defs> that are rect, circle, ellipse, line, polyline, polygon...
elif child.tag in shape_tags:
self.build_shape(child)
if child.tag in xmlns_lookup("svg", "clipPath"):
try:
clip_id = child.attrib["id"]
except KeyError:
clip_id = None
for child_ in child:
self.build_clipping_path(child_, clip_id)

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

for candidate in xmlns_lookup("xlink", "href"):
for candidate in xmlns_lookup("xlink", "href", "id"):
try:
ref = xref.attrib[candidate]
break
Expand Down Expand Up @@ -901,22 +918,23 @@ def build_group(self, group, pdf_group=None):
pdf_group = GraphicsContext()
apply_styles(pdf_group, group)

# handle defs before anything else
for child in [
child for child in group if child.tag in xmlns_lookup("svg", "defs")
]:
self.handle_defs(child)

for child in group:
if child.tag in xmlns_lookup("svg", "defs"):
self.handle_defs(child)
if child.tag in xmlns_lookup("svg", "g"):
pdf_group.add_item(self.build_group(child))
if child.tag in xmlns_lookup("svg", "path"):
pdf_group.add_item(self.build_path(child))
elif child.tag in shape_tags:
pdf_group.add_item(getattr(ShapeBuilder, shape_tags[child.tag])(child))
pdf_group.add_item(self.build_shape(child))
if child.tag in xmlns_lookup("svg", "use"):
pdf_group.add_item(self.build_xref(child))

try:
self.cross_references["#" + group.attrib["id"]] = pdf_group
except KeyError:
pass
self.update_xref(group.attrib.get("id"), pdf_group)

return pdf_group

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

svg_path = path.attrib.get("d", None)
svg_path = path.attrib.get("d")

if svg_path is not None:
svg_path_converter(pdf_path, svg_path)

try:
self.cross_references["#" + path.attrib["id"]] = pdf_path
except KeyError:
pass
self.update_xref(path.attrib.get("id"), pdf_path)

return pdf_path

@force_nodocument
def build_shape(self, shape):
"""Convert an SVG shape tag into a PDF path object. Necessary to make xref (because ShapeBuilder doesn't have access to this object.)"""
shape_path = getattr(ShapeBuilder, shape_tags[shape.tag])(shape)
self.apply_clipping_path(shape_path, shape)

self.update_xref(shape.attrib.get("id"), shape_path)

return shape_path

@force_nodocument
def build_clipping_path(self, shape, clip_id):
clipping_path_shape = getattr(ShapeBuilder, shape_tags[shape.tag])(shape, True)

self.update_xref(clip_id, clipping_path_shape)

return clipping_path_shape

@force_nodocument
def apply_clipping_path(self, stylable, svg_element):
clipping_path = svg_element.attrib.get("clip-path")
if clipping_path:
clipping_path_id = re.search(r"url\((\#\w+)\)", clipping_path)
stylable.clipping_path = self.cross_references[clipping_path_id[1]]
Binary file added test/svg/generated_pdf/clip_path.pdf
Binary file not shown.
Binary file added test/svg/generated_pdf/shapes_def_test.pdf
Binary file not shown.
6 changes: 5 additions & 1 deletion test/svg/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -759,13 +759,17 @@ def Gs(**kwargs):
pytest.param(svgfile("SVG_logo.svg"), id="SVG logo from wikipedia"),
pytest.param(svgfile("viewbox.svg"), id="weird viewbox"),
pytest.param(svgfile("search.svg"), id="search icon"), # issue 356
# discovered while investigatin issue 358:
# discovered while investigating issue 358:
pytest.param(svgfile("issue_358b.svg"), id="repeated relative move"),
pytest.param(svgfile("issue_358.svg"), id="arc start & initial point"), # issue 358
pytest.param(svgfile("Ghostscript_colorcircle.svg"), id="ghostscript colorcircle"),
pytest.param(svgfile("Ghostscript_escher.svg"), id="ghostscript escher"),
pytest.param(svgfile("use-xlink-href.svg"), id="use xlink:href - issue #446"),
pytest.param(svgfile("rgb-color-issue-480.svg"), id="rgb() color - issue #480"),
pytest.param(
svgfile("shapes_def_test.svg"), id="shapes defined in 'defs' tag - issue #858"
),
pytest.param(svgfile("clip_path.svg"), id="clip path - issue #858"),
)

svg_path_edge_cases = (
Expand Down
18 changes: 18 additions & 0 deletions test/svg/svg_sources/clip_path.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions test/svg/svg_sources/shapes_def_test.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.