"""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)