Implement Paperjet updates
This commit is contained in:
parent
5e19b78259
commit
4569dea864
19 changed files with 1622 additions and 151 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue