diff --git a/Dockerfile b/Dockerfile index 0282e40..ca84b75 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,23 @@ RUN npm ci --legacy-peer-deps COPY frontend/ ./ RUN npm run build +# PyMuPDF embeds TrueType/OpenType fonts, while the browser packages ship +# WOFF2 files. Convert the exact browser assets so exported text and typed +# signatures use the same glyph metrics as the Fabric preview. +FROM python:3.12-slim AS frontend-fonts + +RUN pip install --no-cache-dir fonttools brotli + +COPY --from=frontend-build /frontend/node_modules/@fontsource/outfit /fontsource/outfit +COPY --from=frontend-build /frontend/node_modules/@fontsource/plus-jakarta-sans /fontsource/plus-jakarta-sans +COPY --from=frontend-build /frontend/node_modules/@fontsource/allura /fontsource/allura +COPY --from=frontend-build /frontend/node_modules/@fontsource/caveat /fontsource/caveat +COPY --from=frontend-build /frontend/node_modules/@fontsource/dancing-script /fontsource/dancing-script +COPY --from=frontend-build /frontend/node_modules/@fontsource/great-vibes /fontsource/great-vibes +COPY --from=frontend-build /frontend/node_modules/@fontsource/sacramento /fontsource/sacramento +COPY scripts/convert_frontend_fonts.py /usr/local/bin/convert-frontend-fonts.py +RUN python /usr/local/bin/convert-frontend-fonts.py /fontsource /web-fonts + # The runtime image contains both public-facing nginx and the loopback-only # FastAPI/uvicorn process. This keeps deployment to one container while # preserving the existing nginx -> API boundary. @@ -40,6 +57,7 @@ COPY backend/app ./app RUN pip install --no-cache-dir . COPY --from=frontend-build /frontend/dist /usr/share/nginx/html +COPY --from=frontend-fonts /web-fonts /app/app/assets/fonts/web COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf COPY docker-entrypoint.sh /usr/local/bin/paperjet-entrypoint diff --git a/backend/app/schemas/annotations.py b/backend/app/schemas/annotations.py index 5aca730..108a6b3 100644 --- a/backend/app/schemas/annotations.py +++ b/backend/app/schemas/annotations.py @@ -46,6 +46,7 @@ class DrawProps(BaseModel): svgPath: str | None = None strokeColor: str = "#000000" strokeWidth: float = 2 + strokeWidthUnit: Literal["pdf", "screen"] | None = None opacity: float = 1.0 @@ -65,6 +66,7 @@ class SignatureTypeProps(BaseModel): text: str fontFamily: str color: str = "#000000" + fontSize: float | None = None class SignatureAnnotation(AnnotationBase): @@ -98,6 +100,7 @@ class ShapeProps(BaseModel): strokeColor: str = "#000000" fillColor: str = "transparent" strokeWidth: float = 2 + strokeWidthUnit: Literal["pdf", "screen"] | None = None start: tuple[float, float] | None = None end: tuple[float, float] | None = None diff --git a/backend/app/services/export/renderer.py b/backend/app/services/export/renderer.py index a6af6f0..2503448 100644 --- a/backend/app/services/export/renderer.py +++ b/backend/app/services/export/renderer.py @@ -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: diff --git a/backend/app/tests/test_export.py b/backend/app/tests/test_export.py index 16711f2..4a4c6d9 100644 --- a/backend/app/tests/test_export.py +++ b/backend/app/tests/test_export.py @@ -3,8 +3,9 @@ import json import pymupdf +import pytest -from app.services.export.renderer import export_annotations +from app.services.export.renderer import _needs_synthetic_italic, export_annotations def _source_pdf(path) -> None: @@ -62,6 +63,7 @@ def test_export_preserves_rotation_and_flattens_supported_annotations(tmp_path) "text": "Ava", "fontFamily": "Great Vibes", "color": "#000000", + "fontSize": 48, }, }, { @@ -92,6 +94,21 @@ def test_export_preserves_rotation_and_flattens_supported_annotations(tmp_path) assert "Exported text" in page.get_text("text") assert "Ava" in page.get_text("text") + signature_spans = [ + span + for block in page.get_text("dict")["blocks"] + if "lines" in block + for line in block["lines"] + for span in line["spans"] + if span["text"] == "Ava" + ] + assert signature_spans + signature_span = signature_spans[0] + assert signature_span["bbox"][2] - signature_span["bbox"][0] == pytest.approx(160) + assert signature_span["bbox"][3] - signature_span["bbox"][1] == pytest.approx( + (signature_span["ascender"] - signature_span["descender"]) * 48 + ) + # Normalize only for inspection: the exported PDF still retains the # original page rotation above. page.set_rotation(0) @@ -102,6 +119,409 @@ def test_export_preserves_rotation_and_flattens_supported_annotations(tmp_path) assert len(page.get_drawings()) >= 3 +def test_export_renders_text_when_saved_box_is_short(tmp_path) -> None: + source = tmp_path / "source.pdf" + document = pymupdf.open() + document.new_page(width=240, height=320) + document.save(source) + document.close() + + annotations = [ + { + "id": "short-text", + "page": 0, + "type": "text", + "rect": {"x": 20, "y": 20, "width": 120, "height": 12}, + "props": { + "text": "Small text", + "fontFamily": "Liberation Sans", + "fontSize": 14, + "color": "#000000", + "align": "left", + "lineHeight": 1.2, + }, + }, + { + "id": "styled-multiline", + "page": 0, + "type": "text", + "rect": {"x": 20, "y": 60, "width": 120, "height": 18}, + "props": { + "text": "Styled\ntext", + "fontFamily": "Liberation Sans", + "fontSize": 14, + "color": "#000000", + "align": "left", + "lineHeight": 1.2, + "styles": { + "0": {"0": {"fill": "#ff0000", "fontWeight": "bold"}}, + "1": {"0": {"fill": "#0000ff", "fontStyle": "italic"}}, + }, + }, + }, + ] + + exported = export_annotations(source, annotations, tmp_path / "assets") + + with pymupdf.open(stream=exported, filetype="pdf") as document: + text = document[0].get_text("text") + assert "Small text" in text + assert "Styled" in text + assert "text" in text + + +def test_export_uses_the_selected_text_font(tmp_path) -> None: + source = tmp_path / "source.pdf" + document = pymupdf.open() + document.new_page(width=300, height=140) + document.save(source) + document.close() + + annotations = [ + { + "id": "outfit-text", + "page": 0, + "type": "text", + "rect": {"x": 20, "y": 20, "width": 220, "height": 40}, + "props": { + "text": "Outfit text", + "fontFamily": "Outfit", + "fontSize": 24, + "color": "#000000", + }, + } + ] + + exported = export_annotations(source, annotations, tmp_path / "assets") + + with pymupdf.open(stream=exported, filetype="pdf") as document: + spans = [ + span + for block in document[0].get_text("dict")["blocks"] + if "lines" in block + for line in block["lines"] + for span in line["spans"] + if span["text"] == "Outfit text" + ] + assert spans + assert "outfit" in spans[0]["font"].lower() + + +def test_export_uses_an_embeddable_serif_font_for_times_new_roman(tmp_path) -> None: + source = tmp_path / "source.pdf" + document = pymupdf.open() + document.new_page(width=300, height=120) + document.save(source) + document.close() + + annotations = [ + { + "id": "times-text", + "page": 0, + "type": "text", + "rect": {"x": 20, "y": 20, "width": 240, "height": 40}, + "props": { + "text": "Times New Roman", + "fontFamily": "Times New Roman", + "fontSize": 20, + "color": "#000000", + }, + } + ] + + exported = export_annotations(source, annotations, tmp_path / "assets") + + with pymupdf.open(stream=exported, filetype="pdf") as document: + spans = [ + span + for block in document[0].get_text("dict")["blocks"] + if "lines" in block + for line in block["lines"] + for span in line["spans"] + if span["text"] == "Times New Roman" + ] + assert spans + assert "liberationserif" in spans[0]["font"].lower() + + +def test_export_highlight_tracks_styled_text_instead_of_the_saved_box(tmp_path) -> None: + source = tmp_path / "source.pdf" + document = pymupdf.open() + document.new_page(width=320, height=140) + document.save(source) + document.close() + + annotation_rect = pymupdf.Rect(20, 20, 260, 50) + annotations = [ + { + "id": "styled-text", + "page": 0, + "type": "text", + "rect": { + "x": annotation_rect.x0, + "y": annotation_rect.y0, + "width": annotation_rect.width, + "height": annotation_rect.height, + }, + "props": { + "text": "Highlighted text", + "fontFamily": "Liberation Sans", + "fontSize": 18, + "color": "#000000", + "highlightColor": "#ffff00", + "styles": {"0": {"0": {"fill": "#ff0000"}}}, + }, + } + ] + + exported = export_annotations(source, annotations, tmp_path / "assets") + + with pymupdf.open(stream=exported, filetype="pdf") as document: + fill_drawings = [drawing for drawing in document[0].get_drawings() if drawing["fill"]] + assert fill_drawings + assert all(drawing["rect"] != annotation_rect for drawing in fill_drawings) + assert all(drawing["rect"].width < annotation_rect.width for drawing in fill_drawings) + + +def test_export_preserves_smooth_freehand_strokes(tmp_path) -> None: + source = tmp_path / "source.pdf" + document = pymupdf.open() + document.new_page(width=300, height=180) + document.save(source) + document.close() + + annotations = [ + { + "id": "smooth-draw", + "page": 0, + "type": "draw", + "rect": {"x": 20, "y": 20, "width": 180, "height": 100}, + "props": { + "svgPath": "M 0 0 Q 45 100 90 0 Q 135 -100 180 0", + "strokeColor": "#000000", + "strokeWidth": 2, + "strokeWidthUnit": "pdf", + "opacity": 1, + }, + } + ] + + exported = export_annotations(source, annotations, tmp_path / "assets") + + with pymupdf.open(stream=exported, filetype="pdf") as document: + drawings = document[0].get_drawings() + items = [item for drawing in drawings for item in drawing["items"]] + assert any(item[0] == "c" for item in items) + assert all(all(cap == 1 for cap in drawing["lineCap"]) for drawing in drawings) + assert all(drawing["lineJoin"] == 1 for drawing in drawings) + + +def test_export_does_not_shrink_saved_typed_signature_size(tmp_path) -> None: + source = tmp_path / "source.pdf" + document = pymupdf.open() + document.new_page(width=300, height=180) + document.save(source) + document.close() + + annotations = [ + { + "id": "typed-signature", + "page": 0, + "type": "signature", + "rect": {"x": 20, "y": 20, "width": 160, "height": 60}, + "props": { + "mode": "type", + "text": "John Doe", + "fontFamily": "Great Vibes", + "fontSize": 48, + "color": "#000000", + }, + } + ] + + exported = export_annotations(source, annotations, tmp_path / "assets") + + with pymupdf.open(stream=exported, filetype="pdf") as document: + spans = [ + span + for block in document[0].get_text("dict")["blocks"] + if "lines" in block + for line in block["lines"] + for span in line["spans"] + if span["text"] == "John Doe" + ] + assert spans + span = spans[0] + assert span["bbox"][2] - span["bbox"][0] == pytest.approx(160, abs=0.01) + assert span["bbox"][3] - span["bbox"][1] == pytest.approx( + (span["ascender"] - span["descender"]) * 48 + ) + + +def test_export_does_not_double_apply_typed_signature_glyph_bearing(tmp_path) -> None: + source = tmp_path / "source.pdf" + document = pymupdf.open() + document.new_page(width=320, height=160) + document.save(source) + document.close() + + annotations = [ + { + "id": "offset-signature", + "page": 0, + "type": "signature", + "rect": {"x": 120, "y": 40, "width": 190, "height": 70}, + "props": { + "mode": "type", + "text": "John Doe", + "fontFamily": "Great Vibes", + "fontSize": 48, + # Stale records from the previous implementation may still + # contain this value. The font already carries the bearing. + "fontOffsetX": 4, + "color": "#000000", + }, + } + ] + + exported = export_annotations(source, annotations, tmp_path / "assets") + + with pymupdf.open(stream=exported, filetype="pdf") as document: + spans = [ + span + for block in document[0].get_text("dict")["blocks"] + if "lines" in block + for line in block["lines"] + for span in line["spans"] + if span["text"] == "John Doe" + ] + assert spans + assert spans[0]["bbox"][0] == pytest.approx(120) + assert spans[0]["bbox"][2] - spans[0]["bbox"][0] == pytest.approx(190) + + pixmap = document[0].get_pixmap(alpha=False) + dark_x = [ + index % pixmap.width + for index in range(pixmap.width * pixmap.height) + if min(pixmap.samples[index * 3 : index * 3 + 3]) < 128 + ] + assert min(dark_x) == pytest.approx(117, abs=1) + + +def test_outfit_italic_uses_browser_equivalent_synthetic_oblique() -> None: + assert _needs_synthetic_italic("Outfit", False, "italic") + assert _needs_synthetic_italic("Outfit", True, "italic") + assert not _needs_synthetic_italic("Outfit", False, "normal") + assert not _needs_synthetic_italic("Plus Jakarta Sans", False, "italic") + + +def test_export_slants_outfit_italic_to_the_right(tmp_path) -> None: + source = tmp_path / "source.pdf" + document = pymupdf.open() + document.new_page(width=360, height=140) + document.save(source) + document.close() + + annotations = [ + { + "page": 0, + "type": "text", + "rect": {"x": 20, "y": 20, "width": 120, "height": 80}, + "props": { + "text": "test", + "fontFamily": "Outfit", + "fontSize": 48, + "color": "#0000ff", + }, + }, + { + "page": 0, + "type": "text", + "rect": {"x": 180, "y": 20, "width": 120, "height": 80}, + "props": { + "text": "test", + "fontFamily": "Outfit", + "fontSize": 48, + "color": "#0000ff", + "italic": True, + }, + }, + ] + + exported = export_annotations(source, annotations, tmp_path / "assets") + + with pymupdf.open(stream=exported, filetype="pdf") as document: + spans = [ + span + for block in document[0].get_text("dict")["blocks"] + if "lines" in block + for line in block["lines"] + for span in line["spans"] + if span["text"] == "test" + ] + assert len(spans) == 2 + pixmap = document[0].get_pixmap(alpha=False) + + def blue_pixels(row: int, start: int, end: int) -> list[int]: + return [ + x + for x in range(start, end) + if pixmap.samples[(row * pixmap.width + x) * 3 + 2] > 120 + and pixmap.samples[(row * pixmap.width + x) * 3] < 100 + ] + + normal_top = min(blue_pixels(40, 0, 140)) - 20 + italic_top = min(blue_pixels(40, 140, 340)) - 180 + normal_bottom = min(blue_pixels(65, 0, 140)) - 20 + italic_bottom = min(blue_pixels(65, 140, 340)) - 180 + assert italic_top > normal_top + 5 + assert italic_bottom == pytest.approx(normal_bottom, abs=1) + + +def test_export_preserves_canonical_stroke_widths(tmp_path) -> None: + source = tmp_path / "source.pdf" + document = pymupdf.open() + document.new_page(width=240, height=320) + document.save(source) + document.close() + + annotations = [ + { + "id": "draw", + "page": 0, + "type": "draw", + "rect": {"x": 20, "y": 20, "width": 100, "height": 40}, + "props": { + "paths": [[0, 0], [100, 40]], + "strokeColor": "#000000", + "strokeWidth": 1.25, + "strokeWidthUnit": "pdf", + "opacity": 1, + }, + }, + { + "id": "shape", + "page": 0, + "type": "shape", + "rect": {"x": 20, "y": 100, "width": 100, "height": 40}, + "props": { + "kind": "rect", + "strokeColor": "#000000", + "fillColor": "transparent", + "strokeWidth": 2.5, + "strokeWidthUnit": "pdf", + }, + }, + ] + + exported = export_annotations(source, annotations, tmp_path / "assets") + + with pymupdf.open(stream=exported, filetype="pdf") as document: + widths = [float(drawing["width"]) for drawing in document[0].get_drawings()] + assert any(width == pytest.approx(1.25) for width in widths) + assert any(width == pytest.approx(2.5) for width in widths) + + def test_export_does_not_mutate_annotation_input(tmp_path) -> None: source = tmp_path / "source.pdf" _source_pdf(source) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2cf6166..1b5ce79 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/fabric": "^5.3.11", @@ -510,16 +511,6 @@ "node": ">=20.19.0" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1094,6 +1085,7 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1717,6 +1709,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", @@ -1763,6 +1815,26 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -1822,12 +1894,20 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2352,6 +2432,29 @@ "node": ">=6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2898,6 +3001,13 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.371", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", @@ -4161,6 +4271,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4585,6 +4705,21 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -4643,6 +4778,13 @@ "react": "^19.2.7" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-router": { "version": "7.17.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", @@ -5133,6 +5275,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD", "optional": true }, diff --git a/frontend/package.json b/frontend/package.json index d8d738c..444ecff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,6 +35,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/fabric": "^5.3.11", diff --git a/frontend/src/features/editor/canvas/AnnotationLayer.tsx b/frontend/src/features/editor/canvas/AnnotationLayer.tsx index 5b570ec..aefbb6a 100644 --- a/frontend/src/features/editor/canvas/AnnotationLayer.tsx +++ b/frontend/src/features/editor/canvas/AnnotationLayer.tsx @@ -4,7 +4,9 @@ import { useEditorStore } from '../store'; import { getTool } from '../../../lib/annotations/registry'; import type { ViewportParams } from '../../../lib/coords'; import { screenRectToPdf } from '../../../lib/coords'; +import type { Annotation, SignatureAnnotation } from '../../../lib/annotations/types'; import { TextFormatToolbar } from '../toolbar/TextFormatToolbar'; +import { signatureTextBounds } from '../tools/signatureGeometry'; interface AnnotationLayerProps { pageNumber: number; @@ -50,13 +52,28 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A if (!object?.id) return; const annotation = useEditorStore.getState().annotations.find((item) => item.id === object.id); if (!annotation) return; - const bounds = object.getBoundingRect(); - useEditorStore.getState().updateAnnotation(object.id, { + const bounds = + annotation.type === 'signature' && annotation.props.mode === 'type' + ? signatureTextBounds(object) + : object.getBoundingRect(); + const updates: Partial = { rect: screenRectToPdf( { x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height }, latestViewportParams.current, ), - }); + }; + if (annotation.type === 'signature' && annotation.props.mode === 'type') { + const textObject = object as fabric.Text; + const screenFontSize = textObject.fontSize * (textObject.scaleY || 1); + if (Number.isFinite(screenFontSize) && latestViewportParams.current.scale > 0) { + const nextFontSize = screenFontSize / latestViewportParams.current.scale; + updates.props = { + ...(annotation as SignatureAnnotation).props, + fontSize: nextFontSize, + }; + } + } + useEditorStore.getState().updateAnnotation(object.id, updates); }); const updateSelection = () => { diff --git a/frontend/src/features/editor/toolbar/TextFormatToolbar.test.tsx b/frontend/src/features/editor/toolbar/TextFormatToolbar.test.tsx new file mode 100644 index 0000000..154ed38 --- /dev/null +++ b/frontend/src/features/editor/toolbar/TextFormatToolbar.test.tsx @@ -0,0 +1,145 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { TextFormatToolbar } from './TextFormatToolbar'; +import { useEditorStore } from '../store'; +import type { TextAnnotation } from '../../../lib/annotations/types'; +import type { Canvas } from 'fabric'; + +const annotation: TextAnnotation = { + id: 'text-1', + page: 0, + type: 'text', + rect: { x: 20, y: 20, width: 120, height: 32 }, + rotation: 0, + z: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + props: { + text: 'Test text', + fontFamily: 'Liberation Sans', + fontSize: 14, + color: '#000000', + align: 'left', + bold: false, + italic: false, + lineHeight: 1.2, + highlightColor: null, + }, +}; + +const viewportParams = { + scale: 1, + rotation: 0, + canonicalWidth: 612, + canonicalHeight: 792, +}; + +function makeCanvas(activeObject: Record | null = null) { + const listeners = new Map void>>(); + return { + getActiveObject: vi.fn(() => activeObject), + requestRenderAll: vi.fn(), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + const handlers = listeners.get(event) ?? new Set(); + handlers.add(handler); + listeners.set(event, handlers); + }), + off: vi.fn((event: string, handler?: (...args: unknown[]) => void) => { + if (!handler) { + listeners.delete(event); + return; + } + listeners.get(event)?.delete(handler); + }), + emit: (event: string) => listeners.get(event)?.forEach((handler) => handler()), + } as unknown as Canvas & { emit: (event: string) => void }; +} + +afterEach(() => { + useEditorStore.getState().setAnnotations([]); + useEditorStore.getState().setDraftAnnotation(null); +}); + +describe('TextFormatToolbar', () => { + it('does not show stale bold or italic state without the matching active text object', () => { + useEditorStore.getState().setAnnotations([ + { + ...annotation, + props: { ...annotation.props, bold: true, italic: true }, + }, + ]); + const canvas = makeCanvas(); + + render( + , + ); + + expect(screen.getByTitle('Bold')).not.toHaveClass('bg-blue-100'); + expect(screen.getByTitle('Italic')).not.toHaveClass('bg-blue-100'); + }); + + it('persists selected-range styles without replacing the annotation base props', () => { + useEditorStore.getState().setAnnotations([annotation]); + const activeObject: Record = { + id: annotation.id, + text: annotation.props.text, + isEditing: true, + selectionStart: 0, + selectionEnd: 4, + styles: {}, + setSelectionStyles: vi.fn((styles: Record) => { + activeObject.styles = { '0': { '0': styles } }; + }), + set: vi.fn(), + initDimensions: vi.fn(), + setCoords: vi.fn(), + }; + const canvas = makeCanvas(activeObject); + + render( + , + ); + + fireEvent.click(screen.getByTitle('Bold')); + + const updated = useEditorStore.getState().annotations[0] as TextAnnotation; + expect(activeObject.setSelectionStyles).toHaveBeenCalledWith({ fontWeight: 'bold' }); + expect(updated.props.bold).toBe(false); + expect(updated.props.styles).toEqual({ '0': { '0': { fontWeight: 'bold' } } }); + }); + + it('updates the active state when Fabric reports a text selection change', () => { + useEditorStore.getState().setAnnotations([annotation]); + const activeObject: Record = { + id: annotation.id, + text: annotation.props.text, + isEditing: true, + selectionStart: 0, + selectionEnd: 4, + styles: {}, + getSelectionStyles: vi.fn(() => [{ fontStyle: 'italic' }]), + }; + const canvas = makeCanvas(activeObject); + + render( + , + ); + + expect(screen.getByTitle('Italic')).toHaveClass('bg-blue-100'); + activeObject.getSelectionStyles = vi.fn(() => [{ fontStyle: 'normal' }]); + act(() => canvas.emit('text:selection:changed')); + expect(screen.getByTitle('Italic')).not.toHaveClass('bg-blue-100'); + }); +}); diff --git a/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx b/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx index 3e7dbee..3a9bfa9 100644 --- a/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx +++ b/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx @@ -22,6 +22,55 @@ interface TextFormatToolbarProps { canvas: Canvas; } +type ToolbarTextObject = fabric.Textbox & { + id?: string; + customHeight?: number; + styles?: Record>>; +}; + +function cloneInlineStyles(styles: unknown): Record | null { + if (!styles || typeof styles !== 'object' || Object.keys(styles).length === 0) return null; + return JSON.parse(JSON.stringify(styles)) as Record; +} + +function isBoldValue(value: unknown) { + return value === 'bold' || value === 700 || value === '700'; +} + +function isItalicValue(value: unknown) { + return value === 'italic' || value === 'oblique'; +} + +function hasSelectedRange(object: ToolbarTextObject) { + return Boolean( + object.isEditing && + typeof object.selectionStart === 'number' && + typeof object.selectionEnd === 'number' && + object.selectionEnd > object.selectionStart, + ); +} + +function baseStyleValue(object: ToolbarTextObject, property: string): unknown { + const fabricObject = object as unknown as { + get?: (name: string) => unknown; + [key: string]: unknown; + }; + return typeof fabricObject.get === 'function' ? fabricObject.get(property) : fabricObject[property]; +} + +function selectedStyleValue(object: ToolbarTextObject, property: string): unknown { + if (!object.isEditing || typeof object.getSelectionStyles !== 'function') { + return baseStyleValue(object, property); + } + const start = object.selectionStart ?? 0; + const end = object.selectionEnd ?? start; + const styles = object.getSelectionStyles(start, Math.max(start + 1, end), true) as Array>; + if (!styles.length) return baseStyleValue(object, property); + const values = styles.map((style) => style[property]); + if (values.every((value) => value === values[0]) && values[0] !== undefined) return values[0]; + return baseStyleValue(object, property); +} + const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans', 'Arial', 'Times New Roman', 'Courier New']; const SIZES = [6, 7, 8, 10, 12, 14, 16, 18, 24, 36, 48, 72]; const COLORS = ['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#ffffff']; @@ -33,6 +82,21 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text const [activeDropdown, setActiveDropdown] = useState<'font' | 'size' | 'color' | 'highlight' | null>(null); const toolbarRef = useRef(null); + const [, setStyleRevision] = useState(0); + + useEffect(() => { + const refresh = () => setStyleRevision((revision) => revision + 1); + const events = [ + 'text:selection:changed', + 'text:changed', + 'selection:created', + 'selection:updated', + 'selection:cleared', + ] as const; + events.forEach((event) => canvas.on(event, refresh)); + return () => events.forEach((event) => canvas.off(event, refresh)); + }, [canvas]); + useEffect(() => { function handleClickOutside(event: MouseEvent) { if (toolbarRef.current && !toolbarRef.current.contains(event.target as Node)) { @@ -50,92 +114,69 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text const textAnn = annotation as TextAnnotation; const props = textAnn.props; + const activeObject = canvas.getActiveObject() as ToolbarTextObject | undefined; + const activeTextObject = activeObject?.id === annotationId ? activeObject : undefined; + const boldActive = Boolean(activeTextObject && isBoldValue(selectedStyleValue(activeTextObject, 'fontWeight'))); + const italicActive = Boolean(activeTextObject && isItalicValue(selectedStyleValue(activeTextObject, 'fontStyle'))); + const pt = pdfRectToScreen(textAnn.rect, viewportParams); const top = pt.y - 48; // 48px above const left = pt.x; const applyStyle = (styleName: string, value: unknown, globalPropName: keyof TextProps, globalValue?: unknown) => { - const activeObj = canvas.getActiveObject() as (fabric.Textbox & { id?: string; customHeight?: number }) | undefined; - if (activeObj) { - if (activeObj && activeObj.id === annotationId) { - const isStructural = styleName === 'fontSize' || styleName === 'fontFamily'; - - if (isStructural) { - // Structural properties MUST be applied to the base object. Fabric 7's bounding box calculations - // frequently fail when inline styles are used for size/font. - activeObj.set(styleName, value); - - // Obliterate any inline styles for this property so the base property strictly applies to all text - if (activeObj.styles) { - for (const line in activeObj.styles) { - for (const char in activeObj.styles[line]) { - if (activeObj.styles[line][char]) { - delete activeObj.styles[line][char][styleName]; - } - } - } - } - - // If the box is empty and we are currently editing it, Fabric's invisible cursor - // cache will still stubbornly hold the old size unless we violently wipe it. - if (activeObj.isEditing && !activeObj.text) { - activeObj.styles = {}; - if (activeObj.hiddenTextarea) { - if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`; - if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = String(value); - } - } - } else { - // Cosmetic properties (bold, italic, color) work fine with inline styles - if (activeObj.isEditing) { - activeObj.setSelectionStyles({ [styleName]: value }); - if (!activeObj.text) { - activeObj.set(styleName, value); - } - } else { - // If NOT editing, they selected the whole box. Update the base property. - activeObj.set(styleName, value); - - // Clear any inline styles for this property so the base property actually takes effect! - if (activeObj.styles) { - for (const line in activeObj.styles) { - for (const char in activeObj.styles[line]) { - if (activeObj.styles[line][char]) { - delete (activeObj.styles[line][char] as Record)[styleName]; - } - } - } - } + const activeObj = canvas.getActiveObject() as ToolbarTextObject | undefined; + if (!activeObj || activeObj.id !== annotationId) return; + + const isStructural = styleName === 'fontSize' || styleName === 'fontFamily'; + const rangeSelected = hasSelectedRange(activeObj); + + if (rangeSelected && !isStructural) { + activeObj.setSelectionStyles({ [styleName]: value }); + } else { + // Structural properties and non-editing changes apply to the base object. + // Inline values for the same property are removed so the base value is + // not shadowed after the object is reloaded from the store. + activeObj.set(styleName, value); + if (activeObj.styles) { + for (const line of Object.values(activeObj.styles)) { + for (const character of Object.values(line)) { + if (character) delete (character as unknown as Record)[styleName]; } } - - // Critical: Fabric 7 heavily caches text. We MUST mark it dirty to force a redraw! - activeObj.dirty = true; - if ('_forceClearCache' in activeObj) { - (activeObj as typeof activeObj & { _forceClearCache?: boolean })._forceClearCache = true; + } + if (activeObj.isEditing && !activeObj.text) { + activeObj.styles = {}; + if (activeObj.hiddenTextarea) { + if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`; + if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = String(value); } - - // Remove manual height constraint so the box can grow with the new font size - delete activeObj.customHeight; - if (activeObj.initDimensions) activeObj.initDimensions(); - activeObj.setCoords(); - canvas.requestRenderAll(); } } - - const finalGlobalValue = globalValue !== undefined ? globalValue : value; - const newProps = { [globalPropName]: finalGlobalValue } as Partial; - setDefaultTextProps(newProps); - - // Always update store so the toolbar displays the new value - if (!isDraft) { - updateAnnotation(annotationId, { props: { ...props, ...newProps } }); - } else { - useEditorStore.getState().setDraftAnnotation({ - ...textAnn, - props: { ...props, ...newProps } - }); + + activeObj.dirty = true; + if ('_forceClearCache' in activeObj) { + (activeObj as ToolbarTextObject & { _forceClearCache?: boolean })._forceClearCache = true; } + delete activeObj.customHeight; + activeObj.initDimensions(); + activeObj.setCoords(); + canvas.requestRenderAll(); + + const defaultProps: Partial = { + [globalPropName]: globalValue !== undefined ? globalValue : value, + } as Partial; + const newProps: Partial = { + ...(!rangeSelected || isStructural ? defaultProps : {}), + ...(rangeSelected && !isStructural ? { styles: cloneInlineStyles(activeObj.styles) } : {}), + } as Partial; + setDefaultTextProps(defaultProps); + + if (!isDraft) { + updateAnnotation(annotationId, { props: { ...props, ...newProps } }); + } else { + useEditorStore.getState().setDraftAnnotation({ ...textAnn, props: { ...props, ...newProps } }); + } + setStyleRevision((revision) => revision + 1); }; const handleDuplicate = () => { @@ -231,15 +272,15 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text {/* Bold / Italic */}