diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 50d88e4..994bcdc 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -51,6 +51,8 @@ jobs: uses: actions/setup-node@v4 with: node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json - name: Install dependencies working-directory: frontend @@ -92,67 +94,38 @@ jobs: run: | set -Eeuo pipefail - docker rm --force paperjet-ci >/dev/null 2>&1 || true - docker run \ --detach \ --name paperjet-ci \ + --publish 18080:80 \ --env PAPERJET_SECRET_KEY=ci-only-secret \ --env PAPERJET_COOKIE_SECURE=false \ --env PAPERJET_DEBUG=false \ paperjet:ci cleanup() { - echo "----- PaperJet container logs -----" - docker logs paperjet-ci || true docker rm --force paperjet-ci >/dev/null 2>&1 || true } trap cleanup EXIT - for attempt in $(seq 1 30); do - running=$(docker inspect --format '{{.State.Running}}' paperjet-ci 2>/dev/null || true) - if [ "$running" != "true" ]; then - echo "PaperJet container exited before becoming ready" - exit 1 - fi - - if docker exec paperjet-ci \ - python -c "import urllib.request; response = urllib.request.urlopen('http://127.0.0.1/', timeout=2); assert response.status == 200" \ - >/dev/null 2>&1 \ - && docker exec paperjet-ci \ - python -c "import urllib.request; response = urllib.request.urlopen('http://127.0.0.1/api/v1/health', timeout=2); assert response.status == 200" \ - >/dev/null 2>&1; then - break - fi - - sleep 1 - done - - running=$(docker inspect --format '{{.State.Running}}' paperjet-ci) - if [ "$running" != "true" ]; then - echo "PaperJet container did not become ready within 30 seconds" - exit 1 - fi - - docker exec paperjet-ci \ - python -c "import urllib.request; response = urllib.request.urlopen('http://127.0.0.1/', timeout=2); assert response.status == 200" - docker exec paperjet-ci \ - python -c "import urllib.request; response = urllib.request.urlopen('http://127.0.0.1/api/v1/health', timeout=2); assert response.status == 200" + curl --fail --retry 30 --retry-delay 1 --retry-connrefused \ + http://127.0.0.1:18080/ + curl --fail --retry 30 --retry-delay 1 --retry-connrefused \ + http://127.0.0.1:18080/api/v1/health for attempt in $(seq 1 30); do status=$(docker inspect --format '{{.State.Health.Status}}' paperjet-ci) if [ "$status" = "healthy" ]; then - echo "PaperJet smoke test passed" exit 0 fi if [ "$status" = "unhealthy" ]; then - echo "PaperJet container healthcheck reported unhealthy" + docker logs paperjet-ci exit 1 fi sleep 1 done - echo "PaperJet container did not become healthy within 30 seconds" + docker logs paperjet-ci exit 1 - name: Log into Local Registry diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 6510f13..0000000 --- a/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -docker-entrypoint.sh text eol=lf -*.sh text eol=lf diff --git a/Dockerfile b/Dockerfile index ca84b75..0282e40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,23 +12,6 @@ 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. @@ -57,7 +40,6 @@ 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 108a6b3..5aca730 100644 --- a/backend/app/schemas/annotations.py +++ b/backend/app/schemas/annotations.py @@ -46,7 +46,6 @@ class DrawProps(BaseModel): svgPath: str | None = None strokeColor: str = "#000000" strokeWidth: float = 2 - strokeWidthUnit: Literal["pdf", "screen"] | None = None opacity: float = 1.0 @@ -66,7 +65,6 @@ class SignatureTypeProps(BaseModel): text: str fontFamily: str color: str = "#000000" - fontSize: float | None = None class SignatureAnnotation(AnnotationBase): @@ -100,7 +98,6 @@ 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 2503448..a6af6f0 100644 --- a/backend/app/services/export/renderer.py +++ b/backend/app/services/export/renderer.py @@ -2,7 +2,6 @@ import logging import math -import os import re import uuid from collections.abc import Callable, Mapping, Sequence @@ -89,148 +88,17 @@ _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: - 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: + filename = _SIGNATURE_FONT_FILES.get(str(family or "").strip().lower()) + if not filename: return None - path = Path(__file__).parents[2] / "assets" / "fonts" / legacy_filename + 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, - requested_size: Any = None, + rect: pymupdf.Rect, text: str, font_file: Path | None, font_name: str ) -> 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 = ( @@ -300,242 +168,10 @@ def _draw_polyline( 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.finish(color=color, width=max(0.1, width), 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 {} @@ -546,49 +182,6 @@ 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, @@ -596,62 +189,31 @@ def _render_styled_text( props: Mapping[str, Any], styles: Any, ) -> bool: - if not isinstance(styles, Mapping): - styles = {} + 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, - Path | None, - tuple[float, float, float], - float, - str | None, - bool, - ] - ] = [] + 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_file = _font_choice( + font_name = _font_name( 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), + style.get("fontWeight") == "bold", + style.get("fontStyle") == "italic", ) color = _color(style.get("fill", props.get("color"))) - font = ( - pymupdf.Font(fontfile=str(font_file)) - if font_file - else pymupdf.Font(fontname=font_name) - ) + 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, - font_file, - color, - width, - background, - synthetic_italic, - ) - ) + measured.append((character, font_size, font_name, color, width, background)) - total_width = sum(item[5] for item in measured) + total_width = sum(item[4] for item in measured) align = str(props.get("align", "left")) x = rect.x0 if align == "center": @@ -660,43 +222,27 @@ def _render_styled_text( 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: + 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, background_top, x + width, background_top + background_height), + pymupdf.Rect(x, y, x + width, y + max_size * 1.15), 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) + 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 @@ -711,50 +257,21 @@ 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") - 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) + 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) - 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") + 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, ) - 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: @@ -762,16 +279,6 @@ 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), @@ -814,41 +321,20 @@ 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] = { - "fontsize": font_size, + # 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, } - # 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) + page.insert_textbox(rect, 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 4a4c6d9..16711f2 100644 --- a/backend/app/tests/test_export.py +++ b/backend/app/tests/test_export.py @@ -3,9 +3,8 @@ import json import pymupdf -import pytest -from app.services.export.renderer import _needs_synthetic_italic, export_annotations +from app.services.export.renderer import export_annotations def _source_pdf(path) -> None: @@ -63,7 +62,6 @@ def test_export_preserves_rotation_and_flattens_supported_annotations(tmp_path) "text": "Ava", "fontFamily": "Great Vibes", "color": "#000000", - "fontSize": 48, }, }, { @@ -94,21 +92,6 @@ 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) @@ -119,409 +102,6 @@ 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 1b5ce79..2cf6166 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,7 +30,6 @@ }, "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", @@ -511,6 +510,16 @@ "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", @@ -1085,7 +1094,6 @@ "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": { @@ -1709,66 +1717,6 @@ "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", @@ -1815,26 +1763,6 @@ "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", @@ -1894,20 +1822,12 @@ "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", @@ -2432,29 +2352,6 @@ "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", @@ -3001,13 +2898,6 @@ "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", @@ -4271,16 +4161,6 @@ "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", @@ -4705,21 +4585,6 @@ "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", @@ -4778,13 +4643,6 @@ "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", @@ -5275,7 +5133,6 @@ "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 444ecff..d8d738c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,7 +35,6 @@ }, "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 aefbb6a..5b570ec 100644 --- a/frontend/src/features/editor/canvas/AnnotationLayer.tsx +++ b/frontend/src/features/editor/canvas/AnnotationLayer.tsx @@ -4,9 +4,7 @@ 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; @@ -52,28 +50,13 @@ 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 = - annotation.type === 'signature' && annotation.props.mode === 'type' - ? signatureTextBounds(object) - : object.getBoundingRect(); - const updates: Partial = { + const bounds = object.getBoundingRect(); + useEditorStore.getState().updateAnnotation(object.id, { 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 deleted file mode 100644 index 154ed38..0000000 --- a/frontend/src/features/editor/toolbar/TextFormatToolbar.test.tsx +++ /dev/null @@ -1,145 +0,0 @@ -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 3a9bfa9..3e7dbee 100644 --- a/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx +++ b/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx @@ -22,55 +22,6 @@ 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']; @@ -82,21 +33,6 @@ 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)) { @@ -114,69 +50,92 @@ 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 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]; + 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]; + } + } + } + } } } - } - 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); + + // 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; } + + // 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(); } } - - 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); + 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 } }); + updateAnnotation(annotationId, { props: { ...props, ...newProps } }); } else { - useEditorStore.getState().setDraftAnnotation({ ...textAnn, props: { ...props, ...newProps } }); + useEditorStore.getState().setDraftAnnotation({ + ...textAnn, + props: { ...props, ...newProps } + }); } - setStyleRevision((revision) => revision + 1); }; const handleDuplicate = () => { @@ -272,15 +231,15 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text {/* Bold / Italic */}