Standardize handling of cropbox

... fixing various issues with PageImage. Also adds
force_mediabox parameter to Page.to_image(...).

Thanks to @stefanw for flagging:
    https://github.com/jsvine/pdfplumber/issues/1054
This commit is contained in:
Jeremy Singer-Vine
2024-01-05 10:14:44 -05:00
parent f4f4473a17
commit 07d9997ee5
7 changed files with 96 additions and 49 deletions
+2
View File
@@ -10,9 +10,11 @@ All notable changes to this project will be documented in this file. The format
- Add support for PDF 1.3 logical structure via `Page.structure_tree` (h/t @dhdaines). ([#963](https://github.com/jsvine/pdfplumber/pulls/963))
- Add "gswin64c" as another possible Ghostscript executable in `repair.py` (h/t @echedey-ls). ([#1032](https://github.com/jsvine/pdfplumber/issues/1030))
- Re-add `Page.close()` method, have `PDF.close()` close all pages as well, and improve relevant documentation (h/t @luketudge). ([#1042](https://github.com/jsvine/pdfplumber/issues/1042))
- Add `force_mediabox` parameter to `Page.to_image(...)`. ([#1054](https://github.com/jsvine/pdfplumber/issues/1054))
### Fixed
- Standardize handling of cropbox, fixing various issues with PageImage. ([#1054](https://github.com/jsvine/pdfplumber/issues/1054))
- Fix `Page.get_textmap` caching to allow for `extra_attrs=[...]`, by preconverting list kwargs to tuples. ([#1030](https://github.com/jsvine/pdfplumber/issues/1030))
+1
View File
@@ -273,6 +273,7 @@ To turn any page (including cropped pages) into an `PageImage` object, call `my_
- `width`: The desired image width in pixels. Default: unset, determined by `resolution`. Type: `int`.
- `height`: The desired image width in pixels. Default: unset, determined by `resolution`. Type: `int`.
- `antialias`: Whether to use antialiasing when creating the image. Setting to `True` creates images with less-jagged text and graphics, but with larger file sizes. Default: `False`. Type: `bool`.
- `force_mediabox`: Use the page's `.mediabox` dimensions, rather than the `.cropbox` dimensions. Default: `False`. Type: `bool`.
For instance:
+34 -24
View File
@@ -78,8 +78,12 @@ class PageImage:
original: Optional[PIL.Image.Image] = None,
resolution: Union[int, float] = DEFAULT_RESOLUTION,
antialias: bool = False,
force_mediabox: bool = False,
):
self.page = page
self.root = page if page.is_original else page.root_page
self.resolution = resolution
if original is None:
self.original = get_page_image(
stream=page.pdf.stream,
@@ -92,43 +96,49 @@ class PageImage:
else:
self.original = original
if page.is_original:
self.root = page
cropped = False
else:
self.root = page.root_page
cropped = page.root_page.bbox != page.bbox
self.scale = self.original.size[0] / (page.cropbox[2] - page.cropbox[0])
self.resolution = resolution
self.scale = self.original.size[0] / self.root.width
# This value represents the coordinates of the page,
# in page-unit values, that will be displayed.
self.bbox = (
page.bbox
if page.bbox != page.mediabox
else (page.mediabox if force_mediabox else page.cropbox)
)
if cropped:
cropbox = (
int((page.bbox[0] - page.root_page.bbox[0]) * self.scale),
int((page.bbox[1] - page.root_page.bbox[1]) * self.scale),
int((page.bbox[2] - page.root_page.bbox[0]) * self.scale),
int((page.bbox[3] - page.root_page.bbox[1]) * self.scale),
# If this value is different than the *Page*'s .cropbox
# (e.g., because the mediabox differs from the cropbox or
# or because we've used Page.crop(...)), then we'll need to
# crop the initially-converted image.
if page.bbox != page.cropbox:
crop_dims = self._reproject_bbox(page.cropbox)
bbox_dims = self._reproject_bbox(self.bbox)
self.original = self.original.crop(
(
bbox_dims[0] - crop_dims[0],
bbox_dims[1] - crop_dims[1],
bbox_dims[2] - crop_dims[0],
bbox_dims[3] - crop_dims[1],
)
)
self.original = self.original.crop(cropbox)
self.reset()
def _reproject_bbox(self, bbox: T_bbox) -> T_bbox:
def _reproject_bbox(self, bbox: T_bbox) -> Tuple[int, int, int, int]:
x0, top, x1, bottom = bbox
_x0, _top = self._reproject((x0, top))
_x1, _bottom = self._reproject((x1, bottom))
return (_x0, _top, _x1, _bottom)
def _reproject(self, coord: T_point) -> T_point:
def _reproject(self, coord: T_point) -> Tuple[int, int]:
"""
Given an (x0, top) tuple from the *root* coordinate system,
return an (x0, top) tuple in the *image* coordinate system.
"""
x0, top = coord
px0, ptop = self.page.bbox[:2]
rx0, rtop = self.root.bbox[:2]
_x0 = (x0 + rx0 - px0) * self.scale
_top = (top + rtop - ptop) * self.scale
return (_x0, _top)
_x0 = (x0 - self.bbox[0]) * self.scale
_top = (top - self.bbox[1]) * self.scale
return (int(_x0), int(_top))
def reset(self) -> "PageImage":
self.annotated = PIL.Image.new("RGB", self.original.size)
@@ -202,7 +212,7 @@ class PageImage:
stroke: T_color = DEFAULT_STROKE,
stroke_width: int = DEFAULT_STROKE_WIDTH,
) -> "PageImage":
points = (location, self.page.bbox[1], location, self.page.bbox[3])
points = (location, self.bbox[1], location, self.bbox[3])
self.draw.line(self._reproject_bbox(points), fill=stroke, width=stroke_width)
return self
@@ -222,7 +232,7 @@ class PageImage:
stroke: T_color = DEFAULT_STROKE,
stroke_width: int = DEFAULT_STROKE_WIDTH,
) -> "PageImage":
points = (self.page.bbox[0], location, self.page.bbox[2], location)
points = (self.bbox[0], location, self.bbox[2], location)
self.draw.line(self._reproject_bbox(points), fill=stroke, width=stroke_width)
return self
+50 -24
View File
@@ -185,6 +185,27 @@ def textmap_cacher(func: Callable[..., TextMap]) -> Callable[..., TextMap]:
return new_func
def _normalize_box(box_raw: T_bbox, rotation: T_num = 0) -> T_bbox:
# Per PDF Reference 3.8.4: "Note: Although rectangles are
# conventionally specified by their lower-left and upperright
# corners, it is acceptable to specify any two diagonally opposite
# corners."
x0, x1 = sorted((box_raw[0], box_raw[2]))
y0, y1 = sorted((box_raw[1], box_raw[3]))
if rotation in [90, 270]:
return (y0, x0, y1, x1)
else:
return (x0, y0, x1, y1)
# PDFs coordinate spaces refer to an origin in the bottom-left of the
# page; pdfplumber flips this vertically, so that the origin is in the
# top-left.
def _invert_box(box_raw: T_bbox, mb_height: T_num) -> T_bbox:
x0, y0, x1, y1 = box_raw
return (x0, mb_height - y1, x1, mb_height - y0)
class Page(Container):
cached_properties: List[str] = Container.cached_properties + ["_layout"]
is_original: bool = True
@@ -201,35 +222,34 @@ class Page(Container):
self.root_page = self
self.page_obj = page_obj
self.page_number = page_number
_rotation = resolve_all(self.page_obj.attrs.get("Rotate", 0)) or 0
self.rotation = _rotation % 360
self.page_obj.rotate = self.rotation
self.initial_doctop = initial_doctop
cropbox = page_obj.attrs.get("CropBox")
mediabox = page_obj.attrs.get("MediaBox")
def get_attr(key: str, default: Any = None) -> Any:
ref = page_obj.attrs.get(key)
return default if ref is None else resolve_all(ref)
self.cropbox = resolve_all(cropbox) if cropbox is not None else None
self.mediabox = resolve_all(mediabox) or self.cropbox
m = self.mediabox
# Per PDF Reference Table 3.27: "The number of degrees by which the
# page should be rotated clockwise when displayed or printed. The value
# must be a multiple of 90. Default value: 0"
_rotation = get_attr("Rotate", 0)
self.rotation = _rotation % 360
self.bbox: T_bbox = (
(
min(m[1], m[3]),
min(m[0], m[2]),
max(m[1], m[3]),
max(m[0], m[2]),
mb_raw = _normalize_box(get_attr("MediaBox"), self.rotation)
mb_height = mb_raw[3] - mb_raw[1]
self.mediabox = _invert_box(mb_raw, mb_height)
if "CropBox" in page_obj.attrs:
self.cropbox = _invert_box(
_normalize_box(get_attr("CropBox"), self.rotation), mb_height
)
if self.rotation in [90, 270]
else (
min(m[0], m[2]),
min(m[1], m[3]),
max(m[0], m[2]),
max(m[1], m[3]),
)
)
else:
self.cropbox = self.mediabox
# https://rednafi.com/python/lru_cache_on_methods/
# Page.bbox defaults to self.mediabox, but can be altered by Page.crop(...)
self.bbox = self.mediabox
# See https://rednafi.com/python/lru_cache_on_methods/
self.get_textmap = textmap_cacher(self._get_textmap)
def close(self) -> None:
@@ -542,6 +562,7 @@ class Page(Container):
width: Optional[Union[int, float]] = None,
height: Optional[Union[int, float]] = None,
antialias: bool = False,
force_mediabox: bool = False,
) -> "PageImage":
"""
You can pass a maximum of 1 of the following:
@@ -562,7 +583,10 @@ class Page(Container):
resolution = 72 * height / self.height
return PageImage(
self, resolution=resolution or DEFAULT_RESOLUTION, antialias=antialias
self,
resolution=resolution or DEFAULT_RESOLUTION,
antialias=antialias,
force_mediabox=force_mediabox,
)
def to_dict(self, object_types: Optional[List[str]] = None) -> Dict[str, Any]:
@@ -597,6 +621,8 @@ class DerivedPage(Page):
self.pdf = parent_page.pdf
self.page_obj = parent_page.page_obj
self.page_number = parent_page.page_number
self.mediabox = parent_page.mediabox
self.cropbox = parent_page.cropbox
self.flush_cache(Container.cached_properties)
self.get_textmap = textmap_cacher(self._get_textmap)
Binary file not shown.
+1 -1
View File
@@ -148,7 +148,7 @@ class Test(unittest.TestCase):
assert rotated.pages[0].width == 612
assert rotated.pages[0].height == 1008
assert rotated.pages[0].cropbox == self.pdf.pages[0].cropbox
assert rotated.pages[0].cropbox != self.pdf.pages[0].cropbox
assert rotated.pages[0].bbox != self.pdf.pages[0].bbox
def test_password(self):
+8
View File
@@ -73,6 +73,14 @@ class Test(unittest.TestCase):
im = self.pdf.pages[0].crop((10, 20, 30, 50)).to_image()
assert im.original.size == (20, 30)
def test_cropbox(self):
path = os.path.join(HERE, "pdfs/issue-1054-example.pdf")
with pdfplumber.open(path) as pdf:
im = pdf.pages[0].to_image()
assert im.original.size == (596, 842)
im = pdf.pages[0].to_image(force_mediabox=True)
assert im.original.size == (2227, 2923)
def test_copy(self):
assert self.im.copy().original == self.im.original