Skip to content

Commit

Permalink
image() method now inserts .svg images as PDF paths (#337)
Browse files Browse the repository at this point in the history
  • Loading branch information
Lucas-C authored Feb 7, 2022
1 parent 7816932 commit ba9d99e
Show file tree
Hide file tree
Showing 16 changed files with 322 additions and 60 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and [PEP 440](https://www.python.org/dev/peps/pep-0440/).
- documentation on combining `borb` & `fpdf2`: [Creating a borb.pdf.document.Document from a FPDF instance](https://pyfpdf.github.io/fpdf2/ExistingPDFs.html)

### Changed
- `image()` method now insert `.svg` images as PDF paths
- log level of `_substitute_page_number()` has been lowered from `INFO` to `DEBUG`

### Fixed
Expand Down
17 changes: 16 additions & 1 deletion docs/Images.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pdf.image("docs/fpdf2-logo.png", x=20, y=60)
pdf.output("pdf-with-image.pdf")
```


## Assembling images ##
`fpdf2` can be an easy solution to assemble images into a PDF.

Expand Down Expand Up @@ -67,9 +68,23 @@ pdf.output("pdf-with-image.pdf")
```


## SVG images ##

SVG images passed to the [`image()`](fpdf/fpdf.html#fpdf.fpdf.FPDF.image) method
will be embedded as [PDF paths](SVG.md):
```python
from fpdf import FPDF

pdf = FPDF()
pdf.add_page()
pdf.image("SVG_logo.svg", w=100)
pdf.output("pdf-with-vector-image.pdf")
```


## Retrieve images from URLs ##

URLs to images can be directly passed to the [`image`](fpdf/fpdf.html#fpdf.fpdf.FPDF.image) method:
URLs to images can be directly passed to the [`image()`](fpdf/fpdf.html#fpdf.fpdf.FPDF.image) method:

```python
pdf.image("https://upload.wikimedia.org/wikipedia/commons/7/70/Example.png")
Expand Down
79 changes: 77 additions & 2 deletions fpdf/fpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,22 @@
from functools import wraps
from pathlib import Path
from typing import Callable, NamedTuple, Optional, Union, List
from xml.etree.ElementTree import ParseError, XML

from PIL import Image

from .actions import Action
from .errors import FPDFException, FPDFPageFormatException
from .fonts import fpdf_charwidths
from .graphics_state import GraphicsStateMixin
from .image_parsing import get_img_info, load_image, SUPPORTED_IMAGE_FILTERS
from .line_break import Fragment, MultiLineBreak
from .outline import serialize_outline, OutlineSection
from . import drawing
from .recorder import FPDFRecorder
from .structure_tree import MarkedContent, StructureTreeBuilder
from .ttfonts import TTFontFile
from .graphics_state import GraphicsStateMixin
from .svg import Percent, SVGObject
from .util import (
enclose_in_parens,
escape_parens,
Expand Down Expand Up @@ -2724,11 +2726,17 @@ def image(
'"type" is unused and will soon be deprecated',
PendingDeprecationWarning,
)
if str(name).endswith(".svg"):
# Insert it as a PDF path:
img = load_image(str(name))
return self._vector_image(img, x, y, w, h, link, title, alt_text)
if isinstance(name, str):
img = None
elif isinstance(name, Image.Image):
name, img = hashlib.md5(name.tobytes()).hexdigest(), name
elif isinstance(name, io.BytesIO):
if _is_xml(name):
return self._vector_image(name, x, y, w, h, link, title, alt_text)
name, img = hashlib.md5(name.getvalue()).hexdigest(), name
else:
name, img = str(name), name
Expand Down Expand Up @@ -2760,7 +2768,6 @@ def image(
self._perform_page_break_if_need_be(h)
y = self.y
self.y += h

if x is None:
x = self.x

Expand All @@ -2778,6 +2785,66 @@ def image(

return info

def _vector_image(
self,
img: io.BytesIO,
x=None,
y=None,
w=0,
h=0,
link="",
title=None,
alt_text=None,
):
svg = SVGObject(img.getvalue())
if w == 0 and h == 0:
if not svg.width or not svg.height:
raise ValueError(
'<svg> has no "height" / "width": w= or h= must be provided to FPDF.image()'
)
w = (
svg.width * self.epw / 100
if isinstance(svg.width, Percent)
else svg.width
)
h = (
svg.height * self.eph / 100
if isinstance(svg.height, Percent)
else svg.height
)
else:
_, _, vw, vh = svg.viewbox
if w == 0:
w = vw * h / vh
elif h == 0:
h = vh * w / vw

# Flowing mode
if y is None:
self._perform_page_break_if_need_be(h)
y = self.y
self.y += h
if x is None:
x = self.x

_, _, path = svg.transform_to_rect_viewport(
scale=1, width=w, height=h, ignore_svg_top_attrs=True
)
path.transform = path.transform @ drawing.Transform.translation(x, y)

try:
old_x, old_y = self.x, self.y
self.set_xy(0, 0)
if title or alt_text:
with self._marked_sequence(title=title, alt_text=alt_text):
self.draw_path(path)
else:
self.draw_path(path)
finally:
self.set_xy(old_x, old_y)
if link:
self.link(x, y, w, h, link)

def _downscale_image(self, name, img, info, w, h):
width_in_pt, height_in_pt = w * self.k, h * self.k
lowres_name = f"lowres-{name}"
Expand Down Expand Up @@ -4116,6 +4183,14 @@ def _sizeof_fmt(num, suffix="B"):
return f"{num:.1f}Yi{suffix}"


def _is_xml(img: io.BytesIO):
try:
XML(img.getvalue())
return True
except ParseError:
return False


sys.modules[__name__].__class__ = WarnOnDeprecatedModuleAttributes


Expand Down
4 changes: 2 additions & 2 deletions fpdf/image_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def load_image(filename):
"""
This method is used to load external resources, such as images.
It is automatically called when resource added to document by `FPDF.image()`.
It always return a BytesIO buffer.
"""
# if a bytesio instance is passed in, use it as is.
if isinstance(filename, BytesIO):
Expand All @@ -33,8 +34,7 @@ def _decode_base64_image(base64Image):
"Decode the base 64 image string into an io byte stream."
imageData = base64Image.split("base64,")[1]
decodedData = base64.b64decode(imageData)
imageBytes = BytesIO(decodedData)
return imageBytes
return BytesIO(decodedData)


def get_img_info(img, image_filter="AUTO", dims=None):
Expand Down
62 changes: 36 additions & 26 deletions fpdf/svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -788,27 +788,21 @@ def extract_shape_info(self, root_tag):
else:
self.preserve_ar = True

if width is None:
width = Percent(100)
else:
self.width = None
if width is not None:
width.strip()
if width.endswith("%"):
width = Percent(width[:-1])
self.width = Percent(width[:-1])
else:
width = resolve_length(width)

self.width = width
self.width = resolve_length(width)

if height is None:
height = Percent(100)
else:
self.height = None
if height is not None:
height.strip()
if height.endswith("%"):
height = Percent(height[:-1])
self.height = Percent(height[:-1])
else:
height = resolve_length(height)

self.height = height
self.height = resolve_length(height)

if viewbox is None:
self.viewbox = None
Expand Down Expand Up @@ -852,9 +846,11 @@ def transform_to_page_viewport(self, pdf, align_viewbox=True):
The same thing as `SVGObject.transform_to_rect_viewport`.
"""

return self.transform_to_rect_viewport(pdf.k, pdf.w, pdf.h, align_viewbox)
return self.transform_to_rect_viewport(pdf.k, pdf.epw, pdf.eph, align_viewbox)

def transform_to_rect_viewport(self, scale, width, height, align_viewbox=True):
def transform_to_rect_viewport(
self, scale, width, height, align_viewbox=True, ignore_svg_top_attrs=False
):
"""
Size the converted SVG paths to an arbitrarily sized viewport.
Expand All @@ -869,6 +865,9 @@ def transform_to_rect_viewport(self, scale, width, height, align_viewbox=True):
height (Number): the height of the viewport to scale to in document units.
align_viewbox (bool): if True, mimic some of the SVG alignment rules if the
viewbox aspect ratio does not match that of the viewport.
ignore_svg_top_attrs (bool): ignore <svg> top attributes like "width", "height"
or "preserveAspectRatio" when figuring the image dimensions.
Require width & height to be provided as parameters.
Returns:
A tuple of (width, height, `fpdf.drawing.GraphicsContext`), where width and
Expand All @@ -878,20 +877,32 @@ def transform_to_rect_viewport(self, scale, width, height, align_viewbox=True):
converted from the SVG, scaled to the given viewport size.
"""

if isinstance(self.width, Percent):
if ignore_svg_top_attrs:
vp_width = width
elif isinstance(self.width, Percent):
if not width:
raise ValueError(
'SVG "width" is a percentage, hence a viewport width is required'
)
vp_width = self.width * width / 100
else:
vp_width = self.width

if isinstance(self.height, Percent):
vp_width = self.width or width

if ignore_svg_top_attrs:
vp_height = height
elif isinstance(self.height, Percent):
if not height:
raise ValueError(
'SVG "height" is a percentage, hence a viewport height is required'
)
vp_height = self.height * height / 100
else:
vp_height = self.height
vp_height = self.height or height

if scale != 1:
transform = drawing.Transform.scaling(1 / scale)
else:
if scale == 1:
transform = drawing.Transform.identity()
else:
transform = drawing.Transform.scaling(1 / scale)

if self.viewbox:
vx, vy, vw, vh = self.viewbox
Expand All @@ -902,7 +913,7 @@ def transform_to_rect_viewport(self, scale, width, height, align_viewbox=True):
w_ratio = vp_width / vw
h_ratio = vp_height / vh

if self.preserve_ar and (w_ratio != h_ratio):
if not ignore_svg_top_attrs and self.preserve_ar and (w_ratio != h_ratio):
w_ratio = h_ratio = min(w_ratio, h_ratio)

transform = (
Expand Down Expand Up @@ -934,7 +945,6 @@ def draw_to_page(self, pdf, x=None, y=None, debug_stream=None):
debug_stream (io.TextIO): the stream to which rendering debug info will be
written.
"""

_, _, path = self.transform_to_page_viewport(pdf)

old_x, old_y = pdf.x, pdf.y
Expand Down
Loading

0 comments on commit ba9d99e

Please sign in to comment.