Implement Paperjet updates
Some checks failed
CI / Backend (Python) (push) Failing after 27s
CI / Frontend (TypeScript) (push) Successful in 14s
CI / Container (Docker) (push) Has been skipped

This commit is contained in:
Elijah 2026-08-15 14:08:24 -07:00
parent 5e19b78259
commit 4569dea864
19 changed files with 1622 additions and 151 deletions

View file

@ -2,6 +2,7 @@
import logging
import math
import os
import re
import uuid
from collections.abc import Callable, Mapping, Sequence
@ -88,17 +89,148 @@ _SIGNATURE_FONT_FILES = {
}
_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:
filename = _SIGNATURE_FONT_FILES.get(str(family or "").strip().lower())
if not filename:
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" / filename
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
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 = (
@ -168,10 +300,242 @@ def _draw_polyline(
return
shape = page.new_shape()
shape.draw_polyline(points)
shape.finish(color=color, width=max(0.1, width), stroke_opacity=opacity)
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 {}
@ -182,6 +546,49 @@ def _style_at(styles: Any, line_number: int, character_number: int) -> Mapping[s
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,
@ -189,31 +596,62 @@ def _render_styled_text(
props: Mapping[str, Any],
styles: Any,
) -> bool:
if not isinstance(styles, Mapping) or not styles:
return False
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, tuple[float, float, float], float, str | None]] = []
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_name(
font_name, font_file = _font_choice(
font_family,
style.get("fontWeight") == "bold",
style.get("fontStyle") == "italic",
_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(fontname=font_name)
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, color, width, background))
measured.append(
(
character,
font_size,
font_name,
font_file,
color,
width,
background,
synthetic_italic,
)
)
total_width = sum(item[4] for item in measured)
total_width = sum(item[5] for item in measured)
align = str(props.get("align", "left"))
x = rect.x0
if align == "center":
@ -222,27 +660,43 @@ def _render_styled_text(
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:
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, y, x + width, y + max_size * 1.15),
pymupdf.Rect(x, background_top, x + width, background_top + background_height),
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,
)
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
@ -257,21 +711,50 @@ def render_text(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -
text = str(props.get("text", ""))
if not text:
return
styles = props.get("styles")
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")):
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)
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,
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:
@ -279,6 +762,16 @@ def render_draw(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -
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),
@ -321,20 +814,41 @@ def render_signature(page: pymupdf.Page, annotation: Annotation, assets_dir: Pat
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] = {
# 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),
"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_textbox(rect, text, **options)
page.insert_text(pymupdf.Point(text_x, text_baseline), text, **options)
def render_highlight(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None: