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