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

Process x=Align for SVG images #1003

Merged
merged 4 commits into from
Nov 3, 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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ This can also be enabled programmatically with `warnings.simplefilter('default',
* [`TextColumns()`](https://py-pdf.github.io/fpdf2/TextColumns.html) can now have images inserted (both raster and vector).
* [`TextColumns()`](https://py-pdf.github.io/fpdf2/TextColumns.html) can now advance to the next column with the new `new_column()` method or a FORM_FEED character (`\u000c`) in the text.
### Fixed
* `FPDF.image(x=Align.C)` used to fail for SVG images.
* Previously set dash patterns were not transferred correctly to new pages.
* Inserted Vector images used to ignore the `keep_aspect_ratio` argument.
* [`FPDF.fonts.FontFace`](https://py-pdf.github.io/fpdf2/fpdf/fonts.html#fpdf.fonts.FontFace): Now has a static `combine` method that allows overriding a default FontFace (e.g. for specific cells in a table). Unspecified properties of the override FontFace retain the values of the default.
Expand Down
62 changes: 13 additions & 49 deletions fpdf/fpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -3881,18 +3881,20 @@ def image(

def preload_image(self, name, dims=None):
"""
Read a raster (= non-vector) image and loads it in memory in this FPDF instance.
Following this call, the image is inserted in `.images`,
Read an image and load it into memory.
For raster images: Following this call, the image is inserted in `.images`,
and following calls to this method (or `FPDF.image`) will return (or re-use)
the same cached values, without re-reading the image.
For vector images: The data is loaded and the metadata extracted.

Args:
name: either a string representing a file path to an image, an URL to an image,
an io.BytesIO, or a instance of `PIL.Image.Image`
dims (Tuple[float]): optional dimensions as a tuple (width, height) to resize the image
before storing it in the PDF.
(raster only) before storing it in the PDF.

Returns: an instance of a subclass of `ImageInfo`
Returns: A tuple, consisting of the name, the image data, and an instance of a
subclass of `ImageInfo`,
"""
# Identify and load SVG data.
if str(name).endswith(".svg"):
Expand Down Expand Up @@ -3942,23 +3944,6 @@ def preload_image(self, name, dims=None):
self.images[name] = info
return name, img, info

@staticmethod
def _limit_to_aspect_ratio(x, y, w, h, aspect_w, aspect_h):
"""
Make an image fit within a bounding box, maintaining its proportions.
In the reduced dimension it will be centered within tha available space.
"""
ratio = aspect_w / aspect_h
if h * ratio < w:
new_w = h * ratio
new_h = h
x += (w - new_w) / 2
else: # => too wide, limiting width:
new_h = w / ratio
new_w = w
y += (h - new_h) / 2
return x, y, new_w, new_h

def _raster_image(
self,
name,
Expand All @@ -3978,13 +3963,7 @@ def _raster_image(
self._set_min_pdf_version("1.4")

# Automatic width and height calculation if needed
if w == 0 and h == 0: # Put image at 72 dpi
w = info["w"] / self.k
h = info["h"] / self.k
elif w == 0:
w = h * info["w"] / info["h"]
elif h == 0:
h = w * info["h"] / info["w"]
w, h = info.size_in_document_units(w, h, self.k)

if self.oversized_images and info["usages"] == 1 and not dims:
info = self._downscale_image(name, img, info, w, h)
Expand All @@ -3997,25 +3976,10 @@ def _raster_image(
if x is None:
x = self.x

if keep_aspect_ratio:
x, y, w, h = self._limit_to_aspect_ratio(
x, y, w, h, info.width, info.height
)

if not isinstance(x, Number):
if keep_aspect_ratio:
raise ValueError(
"FPDF.image(): 'keep_aspect_ratio' cannot be used with an enum value provided to `x`"
)
x = Align.coerce(x)
if x == Align.C:
x = (self.w - w) / 2
elif x == Align.R:
x = self.w - w - self.r_margin
elif x == Align.L:
x = self.l_margin
else:
raise ValueError(f"Unsupported 'x' value passed to .image(): {x}")
x = info.x_by_align(x, w, self, keep_aspect_ratio)
if keep_aspect_ratio:
x, y, w, h = info.scale_inside_box(x, y, w, h)

stream_content = (
f"q {w * self.k:.2f} 0 0 {h * self.k:.2f} {x * self.k:.2f} "
Expand Down Expand Up @@ -4090,10 +4054,10 @@ def _vector_image(
if x is None:
x = self.x

if not isinstance(x, Number):
x = info.x_by_align(x, w, self, keep_aspect_ratio)
if keep_aspect_ratio:
x, y, w, h = self._limit_to_aspect_ratio(
x, y, w, h, info.width, info.height
)
x, y, w, h = info.scale_inside_box(x, y, w, h)

_, _, path = svg.transform_to_rect_viewport(
scale=1, width=w, height=h, ignore_svg_top_attrs=True
Expand Down
104 changes: 76 additions & 28 deletions fpdf/image_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
except ImportError:
Image = None

from .svg import SVGObject
from .enums import Align
from .errors import FPDFException
from .svg import SVGObject


LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -86,10 +87,50 @@ def __str__(self):
d = {k: ("..." if k in ("data", "smask") else v) for k, v in self.items()}
return f"self.__class__.__name__({d})"

def scale_inside_box(self, x, y, w, h):
"""
Make an image fit within a bounding box, maintaining its proportions.
In the reduced dimension it will be centered within the available space.
"""
ratio = self.width / self.height
if h * ratio < w:
new_w = h * ratio
new_h = h
x += (w - new_w) / 2
else: # => too wide, limiting width:
new_h = w / ratio
new_w = w
y += (h - new_h) / 2
return x, y, new_w, new_h

@staticmethod
def x_by_align(x, w, pdf, keep_aspect_ratio):
if keep_aspect_ratio:
raise ValueError(
"FPDF.image(): 'keep_aspect_ratio' cannot be used with an enum value provided to `x`"
)
x = Align.coerce(x)
if x == Align.C:
return (pdf.w - w) / 2
if x == Align.R:
return pdf.w - w - pdf.r_margin
if x == Align.L:
return pdf.l_margin
raise ValueError(f"Unsupported 'x' value passed to .image(): {x}")


class RasterImageInfo(ImageInfo):
"Information about a raster image used in the PDF document"
# pass

def size_in_document_units(self, w, h, k):
if w == 0 and h == 0: # Put image at 72 dpi
w = self["w"] / k
h = self["h"] / k
elif w == 0:
w = h * self["w"] / self["h"]
elif h == 0:
h = w * self["h"] / self["w"]
return w, h


class VectorImageInfo(ImageInfo):
Expand Down Expand Up @@ -171,7 +212,9 @@ def get_img_info(filename, img=None, image_filter="AUTO", dims=None):

is_pil_img = True
keep_bytes_io_open = False
jpeg_inverted = False # flag to check whether a cmyk image is jpeg or not, if set to True the decode array is inverted in output.py
# Flag to check whether a cmyk image is jpeg or not, if set to True the decode array
# is inverted in output.py
jpeg_inverted = False
img_raw_data = None
if not img or isinstance(img, (Path, str)):
img_raw_data = load_image(filename)
Expand Down Expand Up @@ -225,18 +268,21 @@ def get_img_info(filename, img=None, image_filter="AUTO", dims=None):
if img.mode == "L":
dpn, bpc, colspace = 1, 8, "DeviceGray"
img_raw_data.seek(0)
return {
"data": img_raw_data.read(),
"w": w,
"h": h,
"cs": colspace,
"iccp": iccp,
"dpn": dpn,
"bpc": bpc,
"f": image_filter,
"inverted": jpeg_inverted,
"dp": f"/Predictor 15 /Colors {dpn} /Columns {w}",
}
info.update(
{
"data": img_raw_data.read(),
"w": w,
"h": h,
"cs": colspace,
"iccp": iccp,
"dpn": dpn,
"bpc": bpc,
"f": image_filter,
"inverted": jpeg_inverted,
"dp": f"/Predictor 15 /Colors {dpn} /Columns {w}",
}
)
return info
# We can directly copy the data out of a CCITT Group 4 encoded TIFF, if it
# only contains a single strip
if (
Expand Down Expand Up @@ -270,18 +316,21 @@ def get_img_info(filename, img=None, image_filter="AUTO", dims=None):
else:
raise ValueError(f"unsupported FillOrder: {fillorder}")
dpn, bpc, colspace = 1, 1, "DeviceGray"
return {
"data": ccittrawdata,
"w": w,
"h": h,
"iccp": None,
"dpn": dpn,
"cs": colspace,
"bpc": bpc,
"f": image_filter,
"inverted": jpeg_inverted,
"dp": f"/BlackIs1 {str(not inverted).lower()} /Columns {w} /K -1 /Rows {h}",
}
info.update(
{
"data": ccittrawdata,
"w": w,
"h": h,
"iccp": None,
"dpn": dpn,
"cs": colspace,
"bpc": bpc,
"f": image_filter,
"inverted": jpeg_inverted,
"dp": f"/BlackIs1 {str(not inverted).lower()} /Columns {w} /K -1 /Rows {h}",
}
)
return info

# garbage collection
img_raw_data = None
Expand Down Expand Up @@ -365,7 +414,6 @@ def get_img_info(filename, img=None, image_filter="AUTO", dims=None):
"dp": dp,
}
)

return info


Expand Down
Binary file modified test/image/image_x_align_center.pdf
Binary file not shown.
Binary file modified test/image/image_x_align_right.pdf
Binary file not shown.
7 changes: 7 additions & 0 deletions test/image/test_image_align.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@

HERE = Path(__file__).resolve().parent
IMAGE_PATH = HERE / "png_images/ba2b2b6e72ca0e4683bb640e2d5572f8.png"
SVG_PATH = HERE / "../svg/svg_sources/SVG_logo_fixed_dimensions.svg"


def test_image_x_align_center(tmp_path):
pdf = FPDF()
pdf.add_page()
pdf.image(IMAGE_PATH, x="C")
pdf.image(IMAGE_PATH, x=Align.C)
pdf.add_page()
pdf.image(SVG_PATH, x="C")
pdf.image(SVG_PATH, x=Align.C)
assert_pdf_equal(pdf, HERE / "image_x_align_center.pdf", tmp_path)


Expand All @@ -21,4 +25,7 @@ def test_image_x_align_right(tmp_path):
pdf.add_page()
pdf.image(IMAGE_PATH, x="R")
pdf.image(IMAGE_PATH, x=Align.R)
pdf.add_page()
pdf.image(SVG_PATH, x="R")
pdf.image(SVG_PATH, x=Align.R)
assert_pdf_equal(pdf, HERE / "image_x_align_right.pdf", tmp_path)
2 changes: 1 addition & 1 deletion test/image/test_load_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def build_pdf_with_big_images():

with time_execution() as duration:
build_pdf_with_big_images()
assert duration.seconds > 0.3
assert duration.seconds > 0.25

with time_execution() as duration:
build_pdf_with_big_images()
Expand Down