212 lines
7.7 KiB
Python
212 lines
7.7 KiB
Python
"""Pure-Python PDF geometry helpers that replace the small subset of
|
|
PyMuPDF (``fitz``) functionality used across the editor, signing, and
|
|
retrieval modules: ``fitz.Rect``, ``Page.rotation_matrix`` /
|
|
``Page.derotation_matrix``, and ``fitz.get_text_length``.
|
|
|
|
All coordinates here use the same convention PyMuPDF used throughout this
|
|
codebase: origin at the top-left of the *physical* (unrotated) page,
|
|
x increasing right, y increasing DOWN. This matches every stored bbox
|
|
(``[x0, y0, x1, y1]``) already persisted/produced elsewhere in the app, so
|
|
this module is a drop-in behavioural replacement, not a redesign.
|
|
|
|
No external binary dependency. Uses only ``reportlab.pdfbase.pdfmetrics``
|
|
(BSD license) for Base-14 text width, matching Adobe's standard AFM metrics
|
|
that PyMuPDF's ``fitz.get_text_length(..., fontname="helv")`` also used.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Tuple
|
|
|
|
from reportlab.pdfbase import pdfmetrics
|
|
|
|
# Maps the small set of fitz built-in font names used in this codebase
|
|
# ("helv") to their reportlab Base-14 equivalents.
|
|
_FITZ_TO_BASE14 = {
|
|
"helv": "Helvetica",
|
|
"hebo": "Helvetica-Bold",
|
|
"heit": "Helvetica-Oblique",
|
|
"hebi": "Helvetica-BoldOblique",
|
|
"tiro": "Times-Roman",
|
|
"tibo": "Times-Bold",
|
|
"tiit": "Times-Italic",
|
|
"tibi": "Times-BoldItalic",
|
|
"cour": "Courier",
|
|
"cobo": "Courier-Bold",
|
|
"coit": "Courier-Oblique",
|
|
"cobi": "Courier-BoldOblique",
|
|
}
|
|
|
|
|
|
def get_text_length(text: str, fontname: str = "helv", fontsize: float = 12.0) -> float:
|
|
"""Replacement for ``fitz.get_text_length``. Uses reportlab's Base-14
|
|
AFM metrics, which are the same standard Adobe metrics PyMuPDF's builtin
|
|
fonts (``helv``, ``tiro``, ``cour`` ...) were derived from, so widths
|
|
match to sub-point precision for the ASCII/Latin-1 range these labels use.
|
|
"""
|
|
if not text:
|
|
return 0.0
|
|
base14 = _FITZ_TO_BASE14.get(fontname, "Helvetica")
|
|
return pdfmetrics.stringWidth(text, base14, fontsize)
|
|
|
|
|
|
@dataclass
|
|
class Rect:
|
|
"""Minimal drop-in for the subset of ``fitz.Rect`` used in this codebase."""
|
|
|
|
x0: float
|
|
y0: float
|
|
x1: float
|
|
y1: float
|
|
|
|
@property
|
|
def width(self) -> float:
|
|
return self.x1 - self.x0
|
|
|
|
@property
|
|
def height(self) -> float:
|
|
return self.y1 - self.y0
|
|
|
|
def as_list(self) -> list:
|
|
return [self.x0, self.y0, self.x1, self.y1]
|
|
|
|
def __iter__(self):
|
|
return iter((self.x0, self.y0, self.x1, self.y1))
|
|
|
|
|
|
def visual_to_physical_rect(rect: Rect, page_width: float, page_height: float, rotation: int) -> Rect:
|
|
"""Equivalent of ``visual_rect * (~page.rotation_matrix)``.
|
|
|
|
``rect`` is expressed in "visual" (as-displayed / rotated) coordinates —
|
|
e.g. fractions of the on-screen page width/height sent by the browser,
|
|
already converted to points. ``page_width``/``page_height`` are the
|
|
*physical* (unrotated) MediaBox dimensions. ``rotation`` is the page's
|
|
``/Rotate`` value (0, 90, 180, or 270 — the only values the PDF spec
|
|
allows), degrees clockwise.
|
|
|
|
Returns the equivalent rect in physical (unrotated) page-content space,
|
|
which is the coordinate system PDF content-stream drawing operators
|
|
(and every bbox already stored in this codebase) use, regardless of
|
|
``/Rotate``.
|
|
"""
|
|
r = rotation % 360
|
|
x0, y0, x1, y1 = rect.x0, rect.y0, rect.x1, rect.y1
|
|
|
|
if r == 0:
|
|
return Rect(x0, y0, x1, y1)
|
|
|
|
if r == 90:
|
|
# px = vy, py = H - vx (H = physical page height)
|
|
px0, py0 = y0, page_height - x1
|
|
px1, py1 = y1, page_height - x0
|
|
return Rect(min(px0, px1), min(py0, py1), max(px0, px1), max(py0, py1))
|
|
|
|
if r == 180:
|
|
# px = W - vx, py = H - vy
|
|
px0, py0 = page_width - x1, page_height - y1
|
|
px1, py1 = page_width - x0, page_height - y0
|
|
return Rect(px0, py0, px1, py1)
|
|
|
|
if r == 270:
|
|
# px = W - vy, py = vx
|
|
px0, py0 = page_width - y1, x0
|
|
px1, py1 = page_width - y0, x1
|
|
return Rect(min(px0, px1), min(py0, py1), max(px0, px1), max(py0, py1))
|
|
|
|
raise ValueError(f"Unsupported /Rotate value: {rotation} (must be 0/90/180/270)")
|
|
|
|
|
|
def physical_to_visual_rect(rect: Rect, page_width: float, page_height: float, rotation: int) -> Rect:
|
|
"""Inverse of :func:`visual_to_physical_rect` — equivalent of
|
|
``physical_rect * page.rotation_matrix``.
|
|
"""
|
|
r = rotation % 360
|
|
x0, y0, x1, y1 = rect.x0, rect.y0, rect.x1, rect.y1
|
|
|
|
if r == 0:
|
|
return Rect(x0, y0, x1, y1)
|
|
|
|
if r == 90:
|
|
# forward: vx = H - py, vy = px (H = physical page height)
|
|
vx0, vy0 = page_height - y1, x0
|
|
vx1, vy1 = page_height - y0, x1
|
|
return Rect(min(vx0, vx1), min(vy0, vy1), max(vx0, vx1), max(vy0, vy1))
|
|
|
|
if r == 180:
|
|
vx0, vy0 = page_width - x1, page_height - y1
|
|
vx1, vy1 = page_width - x0, page_height - y0
|
|
return Rect(vx0, vy0, vx1, vy1)
|
|
|
|
if r == 270:
|
|
# forward: vx = py, vy = W - px
|
|
vx0, vy0 = y0, page_width - x1
|
|
vx1, vy1 = y1, page_width - x0
|
|
return Rect(min(vx0, vx1), min(vy0, vy1), max(vx0, vx1), max(vy0, vy1))
|
|
|
|
raise ValueError(f"Unsupported /Rotate value: {rotation} (must be 0/90/180/270)")
|
|
|
|
|
|
def rotate_image_for_page(image_bytes: bytes, rotation: int) -> bytes:
|
|
"""Equivalent of PyMuPDF's ``Page.insert_image(..., rotate=page.rotation)``:
|
|
pre-rotates raster content clockwise by ``rotation`` degrees so that,
|
|
once embedded in the page's (unrotated) content stream, it displays
|
|
upright after the viewer applies the page's own ``/Rotate``.
|
|
"""
|
|
r = rotation % 360
|
|
if r == 0:
|
|
return image_bytes
|
|
|
|
from io import BytesIO
|
|
from PIL import Image
|
|
|
|
img = Image.open(BytesIO(image_bytes))
|
|
img.load()
|
|
# Empirically verified against PyMuPDF's Page.insert_image(rotate=N):
|
|
# PIL's Image.rotate(N) (counter-clockwise) reproduces the same upright
|
|
# result fitz's insert_image(rotate=N) produces on a page with /Rotate=N.
|
|
rotated = img.rotate(r, expand=True)
|
|
out = BytesIO()
|
|
rotated.save(out, format="PNG")
|
|
return out.getvalue()
|
|
|
|
|
|
def page_display_size(page_width: float, page_height: float, rotation: int) -> Tuple[float, float]:
|
|
"""Returns (displayed_width, displayed_height) after applying /Rotate."""
|
|
if rotation % 180 == 90:
|
|
return page_height, page_width
|
|
return page_width, page_height
|
|
|
|
|
|
def local_box_placement_transform(
|
|
rotation: int,
|
|
box_w: float,
|
|
box_h: float,
|
|
physical_rect: Rect,
|
|
page_height: float,
|
|
):
|
|
"""Returns a ``pypdf.Transformation`` that places a ``box_w`` x ``box_h``
|
|
"local" page/image — drawn upright, in native PDF space (y-up, origin
|
|
bottom-left) — onto a page whose own ``/Rotate`` is ``rotation``, such
|
|
that it lands at ``physical_rect`` (already the correct, rotation-aware
|
|
physical footprint, e.g. from :func:`visual_to_physical_rect`) and
|
|
displays upright to a viewer.
|
|
|
|
Equivalent in effect to PyMuPDF's ``Page.insert_image(rect, rotate=N)`` /
|
|
``Page.insert_textbox(rect, rotate=N)``, but works for both raster
|
|
(merge an image-only page) and vector (merge a text/drawing page)
|
|
content via ``pypdf.PageObject.merge_transformed_page``. Empirically
|
|
verified against PyMuPDF's own rendering for rotation in (0, 90, 180, 270).
|
|
"""
|
|
from pypdf import Transformation
|
|
|
|
t0 = Transformation().rotate(rotation)
|
|
a, b, c, d, e, f = t0.ctm
|
|
corners = [(0, 0), (box_w, 0), (0, box_h), (box_w, box_h)]
|
|
rotated = [(a * x + c * y + e, b * x + d * y + f) for x, y in corners]
|
|
mx = min(p[0] for p in rotated)
|
|
my = min(p[1] for p in rotated)
|
|
native_x0 = physical_rect.x0
|
|
native_y0 = page_height - physical_rect.y1
|
|
return t0.translate(native_x0 - mx, native_y0 - my)
|