541 lines
17 KiB
Python
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
|