Paperjet/backend/app/tests/test_export.py
Elijah 4569dea864
Some checks failed
CI / Backend (Python) (push) Failing after 27s
CI / Frontend (TypeScript) (push) Successful in 14s
CI / Container (Docker) (push) Has been skipped
Implement Paperjet updates
2026-08-15 14:08:24 -07:00

541 lines
17 KiB
Python

"""Regression coverage for canonical-coordinate PDF export."""
import json
import pymupdf
import pytest
from app.services.export.renderer import _needs_synthetic_italic, export_annotations
def _source_pdf(path) -> None:
document = pymupdf.open()
page = document.new_page(width=720, height=936)
page.set_cropbox(pymupdf.Rect(36, 72, 648, 864))
page.set_rotation(90)
document.save(path)
document.close()
def test_export_preserves_rotation_and_flattens_supported_annotations(tmp_path) -> None:
source = tmp_path / "source.pdf"
_source_pdf(source)
annotations = [
{
"id": "text",
"page": 0,
"type": "text",
"rect": {"x": 40, "y": 50, "width": 180, "height": 40},
"props": {
"text": "Exported text",
"fontFamily": "Liberation Sans",
"fontSize": 14,
"color": "#112233",
"styles": {"0": {"0": {"fill": "#ff0000", "fontWeight": "bold"}}},
},
},
{
"id": "line",
"page": 0,
"type": "draw",
"rect": {"x": 40, "y": 110, "width": 80, "height": 40},
"props": {
"paths": [[0, 0], [80, 40]],
"strokeColor": "#ff0000",
"strokeWidth": 2,
"opacity": 1,
},
},
{
"id": "highlight",
"page": 0,
"type": "highlight",
"rect": {"x": 40, "y": 170, "width": 100, "height": 20},
"props": {"color": "#ffff00", "opacity": 0.4},
},
{
"id": "signature",
"page": 0,
"type": "signature",
"rect": {"x": 40, "y": 220, "width": 160, "height": 50},
"props": {
"mode": "type",
"text": "Ava",
"fontFamily": "Great Vibes",
"color": "#000000",
"fontSize": 48,
},
},
{
"id": "shape",
"page": 0,
"type": "shape",
"rect": {"x": 180, "y": 170, "width": 60, "height": 30},
"props": {"kind": "ellipse", "strokeColor": "#0000ff", "strokeWidth": 2},
},
{
"id": "future",
"page": 0,
"type": "future-stamp",
"rect": {"x": 0, "y": 0, "width": 10, "height": 10},
"props": {},
},
]
exported = export_annotations(source, annotations, tmp_path / "assets")
output = tmp_path / "exported.pdf"
output.write_bytes(exported)
with pymupdf.open(output) as document:
page = document[0]
assert page.rotation == 90
assert page.rect.width == 792
assert page.rect.height == 612
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)
words = page.get_text("words")
text_word = next(word for word in words if word[4] == "Exported")
assert 35 <= text_word[0] <= 45
assert 45 <= text_word[1] <= 60
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)
annotations = [
{
"id": "unknown",
"page": 0,
"type": "not-yet-supported",
"rect": {"x": 1, "y": 2, "width": 3, "height": 4},
"props": {"future": True},
}
]
before = json.loads(json.dumps(annotations))
export_annotations(source, annotations, tmp_path / "assets")
assert annotations == before