Files
2026-09-08 11:00:05 +05:30

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)