Paperjet/backend/app/services/export/renderer.py

465 lines
17 KiB
Python

"""Flatten canonical annotation data into a PDF with PyMuPDF."""
import logging
import math
import re
import uuid
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import Any
import pymupdf
logger = logging.getLogger(__name__)
Annotation = Mapping[str, Any]
Handler = Callable[[pymupdf.Page, Annotation, Path], None]
def _rect(annotation: Annotation) -> pymupdf.Rect | None:
value = annotation.get("rect")
if not isinstance(value, Mapping):
return None
try:
x = float(value["x"])
y = float(value["y"])
width = float(value["width"])
height = float(value["height"])
except (KeyError, TypeError, ValueError):
return None
if width < 0 or height < 0:
return None
return pymupdf.Rect(x, y, x + width, y + height)
def _props(annotation: Annotation) -> Mapping[str, Any]:
value = annotation.get("props")
return value if isinstance(value, Mapping) else {}
def _color(
value: Any,
fallback: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> tuple[float, float, float]:
if not isinstance(value, str):
return fallback
text = value.strip().lstrip("#")
if len(text) == 3:
text = "".join(char * 2 for char in text)
if len(text) != 6:
return fallback
try:
return tuple(int(text[index : index + 2], 16) / 255 for index in (0, 2, 4)) # type: ignore[return-value]
except ValueError:
return fallback
def _opacity(value: Any, fallback: float = 1.0) -> float:
try:
return max(0.0, min(1.0, float(value)))
except (TypeError, ValueError):
return fallback
def _font_name(family: Any, bold: Any = False, italic: Any = False) -> str:
name = str(family or "Liberation Sans").lower()
is_serif = "times" in name or "serif" in name
is_mono = "courier" in name or "mono" in name
if is_serif:
normal, bold_name, italic_name, bold_italic_name = "tiro", "tibo", "tiit", "tibi"
elif is_mono:
normal, bold_name, italic_name, bold_italic_name = "cour", "cobo", "coit", "cobi"
else:
normal, bold_name, italic_name, bold_italic_name = "helv", "hebo", "heit", "hebi"
if bold and italic:
return bold_italic_name
if bold:
return bold_name
if italic:
return italic_name
return normal
_SIGNATURE_FONT_FILES = {
"great vibes": "GreatVibes-Regular.ttf",
"allura": "allura.ttf",
"sacramento": "sacramento.ttf",
"dancing script": "dancing-script.ttf",
"caveat": "caveat.ttf",
}
def _signature_font_file(family: Any) -> Path | None:
filename = _SIGNATURE_FONT_FILES.get(str(family or "").strip().lower())
if not filename:
return None
path = Path(__file__).parents[2] / "assets" / "fonts" / filename
return path if path.is_file() else None
def _signature_font_size(
rect: pymupdf.Rect, text: str, font_file: Path | None, font_name: str
) -> float:
size = max(1.0, min(48.0, rect.height * 0.55))
try:
font = (
pymupdf.Font(fontfile=str(font_file)) if font_file else pymupdf.Font(fontname=font_name)
)
text_width = font.text_length(text, fontsize=size)
if text_width > rect.width and text_width > 0:
size *= max(0.1, rect.width / text_width) * 0.95
except Exception:
# A missing optional font must not prevent a PDF export.
pass
return max(1.0, size)
def _points_from_props(annotation: Annotation, rect: pymupdf.Rect) -> list[pymupdf.Point]:
props = _props(annotation)
raw_points = props.get("paths")
points: list[pymupdf.Point] = []
if isinstance(raw_points, Sequence) and not isinstance(raw_points, (str, bytes)):
for raw_point in raw_points:
if isinstance(raw_point, Sequence) and len(raw_point) == 2:
try:
points.append(
pymupdf.Point(
rect.x0 + float(raw_point[0]),
rect.y0 + float(raw_point[1]),
)
)
except (TypeError, ValueError):
continue
if len(points) >= 2:
return points
# Older clients stored a Fabric SVG path. Approximate its command end
# points and normalize them into the canonical annotation rectangle.
svg_path = props.get("svgPath")
if not isinstance(svg_path, str):
return []
raw = [float(value) for value in re.findall(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?", svg_path)]
if len(raw) < 4:
return []
pairs = list(zip(raw[::2], raw[1::2], strict=False))
min_x = min(point[0] for point in pairs)
max_x = max(point[0] for point in pairs)
min_y = min(point[1] for point in pairs)
max_y = max(point[1] for point in pairs)
source_width = max(max_x - min_x, 1e-6)
source_height = max(max_y - min_y, 1e-6)
return [
pymupdf.Point(
rect.x0 + (x - min_x) / source_width * rect.width,
rect.y0 + (y - min_y) / source_height * rect.height,
)
for x, y in pairs
]
def _draw_polyline(
page: pymupdf.Page,
points: list[pymupdf.Point],
*,
color: tuple[float, float, float],
width: float,
opacity: float,
) -> None:
if len(points) < 2:
return
shape = page.new_shape()
shape.draw_polyline(points)
shape.finish(color=color, width=max(0.1, width), stroke_opacity=opacity)
shape.commit(overlay=True)
def _style_at(styles: Any, line_number: int, character_number: int) -> Mapping[str, Any]:
if not isinstance(styles, Mapping):
return {}
line_styles = styles.get(str(line_number), styles.get(line_number))
if not isinstance(line_styles, Mapping):
return {}
character_style = line_styles.get(str(character_number), line_styles.get(character_number))
return character_style if isinstance(character_style, Mapping) else {}
def _render_styled_text(
page: pymupdf.Page,
rect: pymupdf.Rect,
text: str,
props: Mapping[str, Any],
styles: Any,
) -> bool:
if not isinstance(styles, Mapping) or not styles:
return False
base_size = max(1.0, float(props.get("fontSize", 14)))
line_height = max(0.5, float(props.get("lineHeight", 1.2)))
lines = text.split("\n")
y = rect.y0
for line_number, line in enumerate(lines):
measured: list[tuple[str, float, str, tuple[float, float, float], float, str | None]] = []
for character_number, character in enumerate(line):
style = _style_at(styles, line_number, character_number)
font_size = max(1.0, float(style.get("fontSize", base_size)))
font_family = style.get("fontFamily", props.get("fontFamily"))
font_name = _font_name(
font_family,
style.get("fontWeight") == "bold",
style.get("fontStyle") == "italic",
)
color = _color(style.get("fill", props.get("color")))
font = pymupdf.Font(fontname=font_name)
width = float(font.text_length(character, fontsize=font_size))
background = style.get("textBackgroundColor", props.get("highlightColor"))
measured.append((character, font_size, font_name, color, width, background))
total_width = sum(item[4] for item in measured)
align = str(props.get("align", "left"))
x = rect.x0
if align == "center":
x += max(0.0, (rect.width - total_width) / 2)
elif align == "right":
x += max(0.0, rect.width - total_width)
max_size = max((item[1] for item in measured), default=base_size)
for character, font_size, font_name, color, width, background in measured:
if (
isinstance(background, str)
and background.lower() not in {"", "transparent", "none"}
):
page.draw_rect(
pymupdf.Rect(x, y, x + width, y + max_size * 1.15),
color=None,
fill=_color(background),
fill_opacity=0.3,
overlay=True,
)
if character != " ":
page.insert_text(
pymupdf.Point(x, y + font_size),
character,
fontsize=font_size,
fontname=font_name,
color=color,
overlay=True,
)
x += width
y += max_size * line_height
return True
def render_text(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
rect = _rect(annotation)
if rect is None:
return
props = _props(annotation)
text = str(props.get("text", ""))
if not text:
return
highlight = props.get("highlightColor")
if isinstance(highlight, str) and highlight and highlight.lower() != "transparent":
page.draw_rect(rect, color=None, fill=_color(highlight), fill_opacity=0.3, overlay=True)
if _render_styled_text(page, rect, text, props, props.get("styles")):
return
align = {"left": 0, "center": 1, "right": 2}.get(str(props.get("align", "left")), 0)
page.insert_textbox(
rect,
text,
fontsize=max(1.0, float(props.get("fontSize", 14))),
fontname=_font_name(props.get("fontFamily"), props.get("bold"), props.get("italic")),
color=_color(props.get("color")),
align=align,
overlay=True,
)
def render_draw(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
rect = _rect(annotation)
if rect is None:
return
props = _props(annotation)
_draw_polyline(
page,
_points_from_props(annotation, rect),
color=_color(props.get("strokeColor")),
width=float(props.get("strokeWidth", 2)),
opacity=_opacity(props.get("opacity")),
)
def _asset_path(assets_dir: Path, ref: Any) -> Path | None:
if not isinstance(ref, str):
return None
try:
safe_ref = str(uuid.UUID(ref))
except ValueError:
return None
path = assets_dir / safe_ref
return path if path.is_file() else None
def render_image(page: pymupdf.Page, annotation: Annotation, assets_dir: Path) -> None:
rect = _rect(annotation)
asset = _asset_path(assets_dir, _props(annotation).get("ref"))
if rect is None or asset is None:
return
page.insert_image(rect, filename=str(asset), keep_proportion=False, overlay=True)
def render_signature(page: pymupdf.Page, annotation: Annotation, assets_dir: Path) -> None:
rect = _rect(annotation)
props = _props(annotation)
if rect is None:
return
if props.get("mode") == "draw":
asset = _asset_path(assets_dir, props.get("ref"))
if asset is not None:
page.insert_image(rect, filename=str(asset), keep_proportion=False, overlay=True)
return
text = str(props.get("text", ""))
if text:
font_file = _signature_font_file(props.get("fontFamily"))
font_name = _font_name(props.get("fontFamily"), False, False)
options: dict[str, Any] = {
# Script fonts have a taller ascender than the built-in PDF fonts;
# leave room inside the editor's saved bounding rectangle so a
# valid signature is never silently dropped for not fitting.
"fontsize": _signature_font_size(rect, text, font_file, font_name),
"color": _color(props.get("color")),
"overlay": True,
}
if font_file:
options["fontname"] = f"paperjet-{font_file.stem.lower()}"
options["fontfile"] = str(font_file)
else:
options["fontname"] = font_name
page.insert_textbox(rect, text, **options)
def render_highlight(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
rect = _rect(annotation)
if rect is None:
return
props = _props(annotation)
page.draw_rect(
rect,
color=None,
fill=_color(props.get("color"), (1.0, 0.92, 0.1)),
fill_opacity=_opacity(props.get("opacity"), 0.3),
overlay=True,
)
def render_shape(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
rect = _rect(annotation)
if rect is None:
return
props = _props(annotation)
kind = str(props.get("kind", "rect"))
stroke = _color(props.get("strokeColor"))
fill_value = props.get("fillColor")
fill = None if fill_value in (None, "", "transparent", "none") else _color(fill_value)
width = max(0.1, float(props.get("strokeWidth", 2)))
if kind == "ellipse":
page.draw_oval(rect, color=stroke, fill=fill, width=width, overlay=True)
elif kind in {"line", "arrow"}:
start = props.get("start")
end = props.get("end")
if (
isinstance(start, Sequence)
and not isinstance(start, (str, bytes))
and len(start) == 2
and isinstance(end, Sequence)
and not isinstance(end, (str, bytes))
and len(end) == 2
):
try:
start_point = pymupdf.Point(
rect.x0 + float(start[0]) * rect.width,
rect.y0 + float(start[1]) * rect.height,
)
end_point = pymupdf.Point(
rect.x0 + float(end[0]) * rect.width,
rect.y0 + float(end[1]) * rect.height,
)
except (TypeError, ValueError):
start_point, end_point = rect.tl, rect.br
else:
start_point, end_point = rect.tl, rect.br
page.draw_line(start_point, end_point, color=stroke, width=width, overlay=True)
if kind == "arrow":
angle = math.atan2(end_point.y - start_point.y, end_point.x - start_point.x)
length = min(
12.0,
max(
5.0, math.hypot(end_point.x - start_point.x, end_point.y - start_point.y) * 0.2
),
)
left = pymupdf.Point(
end_point.x - length * math.cos(angle - math.pi / 6),
end_point.y - length * math.sin(angle - math.pi / 6),
)
right = pymupdf.Point(
end_point.x - length * math.cos(angle + math.pi / 6),
end_point.y - length * math.sin(angle + math.pi / 6),
)
page.draw_polyline([left, end_point, right], color=stroke, width=width, overlay=True)
else:
page.draw_rect(rect, color=stroke, fill=fill, width=width, overlay=True)
HANDLERS: dict[str, Handler] = {
"text": render_text,
"draw": render_draw,
"signature": render_signature,
"image": render_image,
"highlight": render_highlight,
"shape": render_shape,
}
def export_annotations(
pdf_path: Path,
annotations: Sequence[Annotation],
assets_dir: Path,
) -> bytes:
"""Return a flattened PDF while preserving the source page rotations."""
with pymupdf.open(pdf_path) as document:
rotations = [page.rotation for page in document]
try:
for page in document:
if page.rotation:
page.set_rotation(0)
page_annotations: dict[int, list[Annotation]] = {}
for annotation in annotations:
try:
page_number = int(annotation.get("page", -1))
except (TypeError, ValueError):
logger.warning("Skipping annotation with invalid page: %r", annotation)
continue
if 0 <= page_number < len(document):
page_annotations.setdefault(page_number, []).append(annotation)
else:
logger.warning("Skipping annotation outside document pages: %r", annotation)
for page_number, page in enumerate(document):
ordered = sorted(
page_annotations.get(page_number, []),
key=lambda item: int(item.get("z", 0)),
)
for annotation in ordered:
annotation_type = annotation.get("type")
handler = HANDLERS.get(str(annotation_type))
if handler is None:
logger.warning("Skipping unsupported annotation type %r", annotation_type)
continue
try:
handler(page, annotation, assets_dir)
except Exception:
logger.exception("Skipping malformed %s annotation", annotation_type)
finally:
for page, rotation in zip(document, rotations, strict=True):
if page.rotation != rotation:
page.set_rotation(rotation)
return document.tobytes(garbage=4, deflate=True)