979 lines
34 KiB
Python
979 lines
34 KiB
Python
"""Flatten canonical annotation data into a PDF with PyMuPDF."""
|
|
|
|
import logging
|
|
import math
|
|
import os
|
|
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",
|
|
}
|
|
|
|
|
|
_WEB_FONT_FILES: dict[tuple[str, bool, bool], tuple[str, ...]] = {
|
|
("outfit", False, False): ("outfit-400.ttf",),
|
|
("outfit", True, False): ("outfit-700.ttf", "outfit-400.ttf"),
|
|
("plus jakarta sans", False, False): ("plus-jakarta-sans-400.ttf",),
|
|
("plus jakarta sans", True, False): ("plus-jakarta-sans-700.ttf", "plus-jakarta-sans-400.ttf"),
|
|
("plus jakarta sans", False, True): (
|
|
"plus-jakarta-sans-400-italic.ttf",
|
|
"plus-jakarta-sans-400.ttf",
|
|
),
|
|
("plus jakarta sans", True, True): (
|
|
"plus-jakarta-sans-700-italic.ttf",
|
|
"plus-jakarta-sans-700.ttf",
|
|
),
|
|
("great vibes", False, False): ("great-vibes-400.ttf", "GreatVibes-Regular.ttf"),
|
|
("allura", False, False): ("allura-400.ttf", "allura.ttf"),
|
|
("sacramento", False, False): ("sacramento-400.ttf", "sacramento.ttf"),
|
|
("dancing script", False, False): ("dancing-script-400.ttf", "dancing-script.ttf"),
|
|
("caveat", False, False): ("caveat-400.ttf", "caveat.ttf"),
|
|
}
|
|
|
|
|
|
_SYSTEM_FONT_FAMILIES = {
|
|
"arial": "LiberationSans",
|
|
"helvetica": "LiberationSans",
|
|
"liberation sans": "LiberationSans",
|
|
"courier": "LiberationMono",
|
|
"courier new": "LiberationMono",
|
|
"liberation mono": "LiberationMono",
|
|
"mono": "LiberationMono",
|
|
"serif": "LiberationSerif",
|
|
"times": "LiberationSerif",
|
|
"times new roman": "LiberationSerif",
|
|
"liberation serif": "LiberationSerif",
|
|
}
|
|
|
|
|
|
def _is_bold(value: Any) -> bool:
|
|
return value is True or str(value).strip().lower() in {"bold", "700", "800", "900"}
|
|
|
|
|
|
def _is_italic(value: Any) -> bool:
|
|
return value is True or str(value).strip().lower() in {"italic", "oblique"}
|
|
|
|
|
|
def _variant_filename(stem: str, bold: bool, italic: bool) -> str:
|
|
if bold and italic:
|
|
suffix = "-BoldItalic"
|
|
elif bold:
|
|
suffix = "-Bold"
|
|
elif italic:
|
|
suffix = "-Italic"
|
|
else:
|
|
suffix = "-Regular"
|
|
return f"{stem}{suffix}.ttf"
|
|
|
|
|
|
def _font_roots() -> tuple[Path, ...]:
|
|
source_root = Path(__file__).parents[2] / "assets" / "fonts"
|
|
configured_root = os.environ.get("PAPERJET_FONT_DIR")
|
|
roots = [Path(configured_root)] if configured_root else []
|
|
roots.extend(
|
|
[
|
|
source_root / "web",
|
|
source_root,
|
|
Path("/usr/share/fonts/truetype/liberation"),
|
|
Path("/usr/local/share/fonts"),
|
|
]
|
|
)
|
|
return tuple(dict.fromkeys(roots))
|
|
|
|
|
|
def _font_file(family: Any, bold: Any = False, italic: Any = False) -> Path | None:
|
|
normalized = str(family or "").strip().lower()
|
|
bold_flag = _is_bold(bold)
|
|
italic_flag = _is_italic(italic)
|
|
key = (normalized, bold_flag, italic_flag)
|
|
filenames = _WEB_FONT_FILES.get(key)
|
|
if filenames is None and italic_flag:
|
|
filenames = _WEB_FONT_FILES.get((key[0], key[1], False))
|
|
if filenames is None:
|
|
system_stem = _SYSTEM_FONT_FAMILIES.get(normalized)
|
|
filenames = (_variant_filename(system_stem, bold_flag, italic_flag),) if system_stem else ()
|
|
for root in _font_roots():
|
|
for filename in filenames:
|
|
path = root / filename
|
|
if path.is_file():
|
|
return path
|
|
return None
|
|
|
|
|
|
def _font_choice(family: Any, bold: Any = False, italic: Any = False) -> tuple[str, Path | None]:
|
|
font_file = _font_file(family, bold, italic)
|
|
if font_file is not None:
|
|
return f"paperjet-{font_file.stem.lower()}", font_file
|
|
return _font_name(family, _is_bold(bold), _is_italic(italic)), None
|
|
|
|
|
|
def _needs_synthetic_italic(family: Any, bold: Any = False, italic: Any = False) -> bool:
|
|
if not _is_italic(italic):
|
|
return False
|
|
normalized = str(family or "").strip().lower()
|
|
if not any(key[0] == normalized for key in _WEB_FONT_FILES):
|
|
return False
|
|
return (normalized, _is_bold(bold), True) not in _WEB_FONT_FILES
|
|
|
|
|
|
def _synthetic_italic_morph(origin: pymupdf.Point) -> tuple[pymupdf.Point, pymupdf.Matrix]:
|
|
# Match the browser's synthesized oblique for families without an italic face.
|
|
shear = math.tan(math.radians(14))
|
|
return origin, pymupdf.Matrix(1, 0, shear, 1, 0, 0)
|
|
|
|
|
|
def _signature_font_file(family: Any) -> Path | None:
|
|
normalized = str(family or "").strip().lower()
|
|
for filename in _WEB_FONT_FILES.get((normalized, False, False), ()):
|
|
for root in _font_roots():
|
|
path = root / filename
|
|
if path.is_file():
|
|
return path
|
|
legacy_filename = _SIGNATURE_FONT_FILES.get(normalized)
|
|
if not legacy_filename:
|
|
return None
|
|
path = Path(__file__).parents[2] / "assets" / "fonts" / legacy_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,
|
|
requested_size: Any = None,
|
|
) -> float:
|
|
if requested_size is not None:
|
|
try:
|
|
return max(1.0, float(requested_size))
|
|
except (TypeError, ValueError):
|
|
return 48.0
|
|
|
|
# Legacy typed signatures did not persist a font size. Preserve their
|
|
# existing sizing behavior while new records use the preview's canonical
|
|
# size above.
|
|
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),
|
|
lineCap=1,
|
|
lineJoin=1,
|
|
closePath=False,
|
|
stroke_opacity=opacity,
|
|
)
|
|
shape.commit(overlay=True)
|
|
|
|
|
|
_SVG_PATH_TOKEN = re.compile(r"[A-Za-z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?")
|
|
_SVG_PATH_ARITY = {"M": 2, "L": 2, "Q": 4, "C": 6}
|
|
|
|
|
|
def _parse_svg_path(value: str) -> list[tuple[str, tuple[float, ...]]]:
|
|
tokens = _SVG_PATH_TOKEN.findall(value)
|
|
commands: list[tuple[str, tuple[float, ...]]] = []
|
|
command: str | None = None
|
|
relative = False
|
|
move_pending = False
|
|
index = 0
|
|
current = (0.0, 0.0)
|
|
subpath_start = current
|
|
|
|
while index < len(tokens):
|
|
token = tokens[index]
|
|
if token.isalpha():
|
|
command = token.upper()
|
|
relative = token.islower()
|
|
index += 1
|
|
if command == "Z":
|
|
commands.append(("Z", ()))
|
|
current = subpath_start
|
|
command = None
|
|
continue
|
|
if command not in _SVG_PATH_ARITY:
|
|
return []
|
|
move_pending = command == "M"
|
|
|
|
if command is None:
|
|
return []
|
|
arity = _SVG_PATH_ARITY[command]
|
|
if index + arity > len(tokens):
|
|
return []
|
|
if any(tokens[index + offset].isalpha() for offset in range(arity)):
|
|
return []
|
|
try:
|
|
values = tuple(float(tokens[index + offset]) for offset in range(arity))
|
|
except ValueError:
|
|
return []
|
|
index += arity
|
|
|
|
base_x, base_y = current
|
|
if command in {"M", "L"}:
|
|
point = (
|
|
values[0] + (base_x if relative else 0.0),
|
|
values[1] + (base_y if relative else 0.0),
|
|
)
|
|
output_command = "M" if move_pending else "L"
|
|
commands.append((output_command, point))
|
|
current = point
|
|
if output_command == "M":
|
|
subpath_start = point
|
|
move_pending = False
|
|
command = "L" if command == "M" else command
|
|
elif command == "Q":
|
|
control = (
|
|
values[0] + (base_x if relative else 0.0),
|
|
values[1] + (base_y if relative else 0.0),
|
|
)
|
|
endpoint = (
|
|
values[2] + (base_x if relative else 0.0),
|
|
values[3] + (base_y if relative else 0.0),
|
|
)
|
|
commands.append(("Q", (*control, *endpoint)))
|
|
current = endpoint
|
|
else:
|
|
control_one = (
|
|
values[0] + (base_x if relative else 0.0),
|
|
values[1] + (base_y if relative else 0.0),
|
|
)
|
|
control_two = (
|
|
values[2] + (base_x if relative else 0.0),
|
|
values[3] + (base_y if relative else 0.0),
|
|
)
|
|
endpoint = (
|
|
values[4] + (base_x if relative else 0.0),
|
|
values[5] + (base_y if relative else 0.0),
|
|
)
|
|
commands.append(("C", (*control_one, *control_two, *endpoint)))
|
|
current = endpoint
|
|
|
|
return commands
|
|
|
|
|
|
def _draw_svg_path(
|
|
page: pymupdf.Page,
|
|
rect: pymupdf.Rect,
|
|
svg_path: str,
|
|
*,
|
|
color: tuple[float, float, float],
|
|
width: float,
|
|
opacity: float,
|
|
) -> bool:
|
|
parsed = _parse_svg_path(svg_path)
|
|
if not parsed:
|
|
return False
|
|
|
|
segments: list[tuple[str, tuple[tuple[float, float], ...]]] = []
|
|
samples: list[tuple[float, float]] = []
|
|
current = (0.0, 0.0)
|
|
subpath_start = current
|
|
|
|
def add_line(start: tuple[float, float], end: tuple[float, float]) -> None:
|
|
segments.append(("L", (start, end)))
|
|
samples.extend((start, end))
|
|
|
|
def add_quadratic(
|
|
start: tuple[float, float],
|
|
control: tuple[float, float],
|
|
end: tuple[float, float],
|
|
) -> None:
|
|
segments.append(("Q", (start, control, end)))
|
|
for step in range(21):
|
|
t = step / 20
|
|
inverse = 1 - t
|
|
samples.append(
|
|
(
|
|
inverse * inverse * start[0]
|
|
+ 2 * inverse * t * control[0]
|
|
+ t * t * end[0],
|
|
inverse * inverse * start[1]
|
|
+ 2 * inverse * t * control[1]
|
|
+ t * t * end[1],
|
|
)
|
|
)
|
|
|
|
def add_cubic(
|
|
start: tuple[float, float],
|
|
control_one: tuple[float, float],
|
|
control_two: tuple[float, float],
|
|
end: tuple[float, float],
|
|
) -> None:
|
|
segments.append(("C", (start, control_one, control_two, end)))
|
|
for step in range(21):
|
|
t = step / 20
|
|
inverse = 1 - t
|
|
samples.append(
|
|
(
|
|
inverse**3 * start[0]
|
|
+ 3 * inverse**2 * t * control_one[0]
|
|
+ 3 * inverse * t**2 * control_two[0]
|
|
+ t**3 * end[0],
|
|
inverse**3 * start[1]
|
|
+ 3 * inverse**2 * t * control_one[1]
|
|
+ 3 * inverse * t**2 * control_two[1]
|
|
+ t**3 * end[1],
|
|
)
|
|
)
|
|
|
|
for command, values in parsed:
|
|
if command == "M":
|
|
current = (values[0], values[1])
|
|
subpath_start = current
|
|
samples.append(current)
|
|
elif command == "L":
|
|
endpoint = (values[0], values[1])
|
|
add_line(current, endpoint)
|
|
current = endpoint
|
|
elif command == "Q":
|
|
control = (values[0], values[1])
|
|
endpoint = (values[2], values[3])
|
|
add_quadratic(current, control, endpoint)
|
|
current = endpoint
|
|
elif command == "C":
|
|
control_one = (values[0], values[1])
|
|
control_two = (values[2], values[3])
|
|
endpoint = (values[4], values[5])
|
|
add_cubic(current, control_one, control_two, endpoint)
|
|
current = endpoint
|
|
elif command == "Z":
|
|
if current != subpath_start:
|
|
add_line(current, subpath_start)
|
|
current = subpath_start
|
|
|
|
if not segments or not samples:
|
|
return False
|
|
min_x = min(point[0] for point in samples)
|
|
max_x = max(point[0] for point in samples)
|
|
min_y = min(point[1] for point in samples)
|
|
max_y = max(point[1] for point in samples)
|
|
source_width = max(max_x - min_x, 1e-6)
|
|
source_height = max(max_y - min_y, 1e-6)
|
|
|
|
def map_point(point: tuple[float, float]) -> pymupdf.Point:
|
|
return pymupdf.Point(
|
|
rect.x0 + (point[0] - min_x) / source_width * rect.width,
|
|
rect.y0 + (point[1] - min_y) / source_height * rect.height,
|
|
)
|
|
|
|
shape = page.new_shape()
|
|
for command, points in segments:
|
|
mapped = tuple(map_point(point) for point in points)
|
|
if command == "L":
|
|
shape.draw_line(mapped[0], mapped[1])
|
|
elif command == "Q":
|
|
curve_start, curve_control, curve_end = mapped
|
|
mapped_control_one = pymupdf.Point(
|
|
curve_start.x + (curve_control.x - curve_start.x) * 2 / 3,
|
|
curve_start.y + (curve_control.y - curve_start.y) * 2 / 3,
|
|
)
|
|
mapped_control_two = pymupdf.Point(
|
|
curve_end.x + (curve_control.x - curve_end.x) * 2 / 3,
|
|
curve_end.y + (curve_control.y - curve_end.y) * 2 / 3,
|
|
)
|
|
shape.draw_bezier(
|
|
curve_start,
|
|
mapped_control_one,
|
|
mapped_control_two,
|
|
curve_end,
|
|
)
|
|
else:
|
|
shape.draw_bezier(*mapped)
|
|
shape.finish(
|
|
color=color,
|
|
width=max(0.1, width),
|
|
lineCap=1,
|
|
lineJoin=1,
|
|
closePath=False,
|
|
stroke_opacity=opacity,
|
|
)
|
|
shape.commit(overlay=True)
|
|
return 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 _insert_text_lines(
|
|
page: pymupdf.Page,
|
|
rect: pymupdf.Rect,
|
|
text: str,
|
|
*,
|
|
fontsize: float,
|
|
font_name: str,
|
|
font_file: Path | None = None,
|
|
color: tuple[float, float, float],
|
|
align: int = 0,
|
|
line_height: float = 1.2,
|
|
synthetic_italic: bool = False,
|
|
) -> None:
|
|
"""Insert text without requiring it to fit inside the saved rectangle.
|
|
|
|
Fabric keeps text visible even when a manually sized textbox is shorter
|
|
than the font metrics. PyMuPDF's textbox helper instead returns a negative
|
|
spare-height value and may insert nothing, so this fallback preserves the
|
|
preview's overflow behavior.
|
|
"""
|
|
font = pymupdf.Font(fontfile=str(font_file)) if font_file else pymupdf.Font(fontname=font_name)
|
|
for line_number, line in enumerate(text.split("\n")):
|
|
line_width = float(font.text_length(line, fontsize=fontsize))
|
|
x = rect.x0
|
|
if align == 1:
|
|
x += max(0.0, (rect.width - line_width) / 2)
|
|
elif align == 2:
|
|
x += max(0.0, rect.width - line_width)
|
|
|
|
options: dict[str, Any] = {
|
|
"fontsize": fontsize,
|
|
"fontname": font_name,
|
|
"color": color,
|
|
"overlay": True,
|
|
}
|
|
if font_file:
|
|
options["fontfile"] = str(font_file)
|
|
origin = pymupdf.Point(x, rect.y0 + fontsize + line_number * fontsize * line_height)
|
|
if synthetic_italic:
|
|
options["morph"] = _synthetic_italic_morph(origin)
|
|
page.insert_text(origin, line, **options)
|
|
|
|
|
|
def _render_styled_text(
|
|
page: pymupdf.Page,
|
|
rect: pymupdf.Rect,
|
|
text: str,
|
|
props: Mapping[str, Any],
|
|
styles: Any,
|
|
) -> bool:
|
|
if not isinstance(styles, Mapping):
|
|
styles = {}
|
|
|
|
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,
|
|
Path | None,
|
|
tuple[float, float, float],
|
|
float,
|
|
str | None,
|
|
bool,
|
|
]
|
|
] = []
|
|
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_file = _font_choice(
|
|
font_family,
|
|
_is_bold(style.get("fontWeight", "bold" if props.get("bold") else None)),
|
|
_is_italic(style.get("fontStyle", "italic" if props.get("italic") else None)),
|
|
)
|
|
synthetic_italic = _needs_synthetic_italic(
|
|
font_family,
|
|
style.get("fontWeight", "bold" if props.get("bold") else None),
|
|
style.get("fontStyle", "italic" if props.get("italic") else None),
|
|
)
|
|
color = _color(style.get("fill", props.get("color")))
|
|
font = (
|
|
pymupdf.Font(fontfile=str(font_file))
|
|
if font_file
|
|
else 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,
|
|
font_file,
|
|
color,
|
|
width,
|
|
background,
|
|
synthetic_italic,
|
|
)
|
|
)
|
|
|
|
total_width = sum(item[5] 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)
|
|
line_box_height = max_size * line_height
|
|
background_top = y + max(0.0, (line_box_height - max_size) / 2)
|
|
background_height = max_size * 1.15
|
|
for (
|
|
character,
|
|
font_size,
|
|
font_name,
|
|
font_file,
|
|
color,
|
|
width,
|
|
background,
|
|
synthetic_italic,
|
|
) in measured:
|
|
if (
|
|
isinstance(background, str)
|
|
and background.lower() not in {"", "transparent", "none"}
|
|
):
|
|
page.draw_rect(
|
|
pymupdf.Rect(x, background_top, x + width, background_top + background_height),
|
|
color=None,
|
|
fill=_color(background),
|
|
fill_opacity=0.3,
|
|
overlay=True,
|
|
)
|
|
if character != " ":
|
|
options: dict[str, Any] = {
|
|
"fontsize": font_size,
|
|
"fontname": font_name,
|
|
"color": color,
|
|
"overlay": True,
|
|
}
|
|
if font_file:
|
|
options["fontfile"] = str(font_file)
|
|
origin = pymupdf.Point(x, y + font_size)
|
|
if synthetic_italic:
|
|
options["morph"] = _synthetic_italic_morph(origin)
|
|
page.insert_text(origin, character, **options)
|
|
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
|
|
styles = props.get("styles")
|
|
highlight = props.get("highlightColor")
|
|
has_highlight = (
|
|
isinstance(highlight, str)
|
|
and highlight.strip().lower() not in {"", "transparent", "none"}
|
|
)
|
|
if has_highlight or (isinstance(styles, Mapping) and bool(styles)):
|
|
_render_styled_text(page, rect, text, props, styles)
|
|
return
|
|
align = {"left": 0, "center": 1, "right": 2}.get(str(props.get("align", "left")), 0)
|
|
font_size = max(1.0, float(props.get("fontSize", 14)))
|
|
font_name, font_file = _font_choice(
|
|
props.get("fontFamily"), props.get("bold"), props.get("italic")
|
|
)
|
|
synthetic_italic = _needs_synthetic_italic(
|
|
props.get("fontFamily"), props.get("bold"), props.get("italic")
|
|
)
|
|
options: dict[str, Any] = {
|
|
"fontsize": font_size,
|
|
"fontname": font_name,
|
|
"color": _color(props.get("color")),
|
|
"align": align,
|
|
"overlay": True,
|
|
}
|
|
if font_file:
|
|
options["fontfile"] = str(font_file)
|
|
if synthetic_italic:
|
|
options["morph"] = _synthetic_italic_morph(
|
|
pymupdf.Point(rect.x0, rect.y0 + font_size)
|
|
)
|
|
result = page.insert_textbox(rect, text, **options)
|
|
if result < 0:
|
|
_insert_text_lines(
|
|
page,
|
|
rect,
|
|
text,
|
|
fontsize=font_size,
|
|
font_name=font_name,
|
|
font_file=font_file,
|
|
color=_color(props.get("color")),
|
|
align=align,
|
|
line_height=max(0.5, float(props.get("lineHeight", 1.2))),
|
|
synthetic_italic=synthetic_italic,
|
|
)
|
|
|
|
|
|
def render_draw(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
|
|
rect = _rect(annotation)
|
|
if rect is None:
|
|
return
|
|
props = _props(annotation)
|
|
svg_path = props.get("svgPath")
|
|
if isinstance(svg_path, str) and _draw_svg_path(
|
|
page,
|
|
rect,
|
|
svg_path,
|
|
color=_color(props.get("strokeColor")),
|
|
width=float(props.get("strokeWidth", 2)),
|
|
opacity=_opacity(props.get("opacity")),
|
|
):
|
|
return
|
|
_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)
|
|
font_size = _signature_font_size(
|
|
rect,
|
|
text,
|
|
font_file,
|
|
font_name,
|
|
props.get("fontSize"),
|
|
)
|
|
font = (
|
|
pymupdf.Font(fontfile=str(font_file))
|
|
if font_file
|
|
else pymupdf.Font(fontname=font_name)
|
|
)
|
|
options: dict[str, Any] = {
|
|
"fontsize": font_size,
|
|
"color": _color(props.get("color")),
|
|
"overlay": True,
|
|
}
|
|
# The font carries its own negative script-glyph bearings, so the PDF
|
|
# insertion origin is the Fabric text box edge. Fit the run to the
|
|
# canonical width to account for browser/PDF shaping differences and
|
|
# for intentional non-uniform resizing.
|
|
text_x = rect.x0
|
|
text_baseline = rect.y0 + font.ascender * font_size
|
|
text_width = float(font.text_length(text, fontsize=font_size))
|
|
if text_width > 0 and rect.width > 0:
|
|
options["morph"] = (
|
|
pymupdf.Point(text_x, text_baseline),
|
|
pymupdf.Matrix(rect.width / text_width, 0, 0, 1, 0, 0),
|
|
)
|
|
if font_file:
|
|
options["fontname"] = f"paperjet-{font_file.stem.lower()}"
|
|
options["fontfile"] = str(font_file)
|
|
else:
|
|
options["fontname"] = font_name
|
|
page.insert_text(pymupdf.Point(text_x, text_baseline), 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)
|