Skip to content

Commit

Permalink
Merge d39b03d into 99e4d6d
Browse files Browse the repository at this point in the history
  • Loading branch information
afriedman412 authored Oct 24, 2023
2 parents 99e4d6d + d39b03d commit fc6efe3
Show file tree
Hide file tree
Showing 10 changed files with 136 additions and 25 deletions.
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.

0 comments on commit fc6efe3

Please sign in to comment.