Files
pdf/tests/edits/test_reflow_paragraph.py
T

140 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Tests for `reflow_paragraph` — Word/Adobe-style paragraph re-layout.
Editing paragraph text re-wraps it within the column width, re-justifies, and pushes the
following content down/up by the line-count delta. This is what fixes the justified-text
"word collision" that the old line-level shift produced.
Run: gateway/.venv/Scripts/python.exe tests/edits/test_reflow_paragraph.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "gateway"))
import pdfengine # noqa: E402
def _build_pdf() -> bytes:
# A 3-line paragraph (Helvetica 11, baselines 700/686/672) + a footer line at y=600.
content = (
b"BT /F1 11 Tf 72 700 Td (The quick brown fox jumps over the lazy) Tj ET\n"
b"BT /F1 11 Tf 72 686 Td (dog near the river bank on a sunny) Tj ET\n"
b"BT /F1 11 Tf 72 672 Td (afternoon in early spring.) Tj ET\n"
b"BT /F1 11 Tf 72 600 Td (FOOTER LINE) Tj ET\n"
)
objs = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
b"/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
b"<< /Length %d >>\nstream\n" % len(content) + content + b"endstream",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
]
pdf = b"%PDF-1.7\n"
offs = []
for i, o in enumerate(objs, 1):
offs.append(len(pdf))
pdf += b"%d 0 obj\n" % i + o + b"\nendobj\n"
xref = len(pdf)
pdf += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
for o in offs:
pdf += b"%010d 00000 n \n" % o
pdf += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref)
return pdf
def _para_text(p) -> str:
return " ".join("".join(r.text for r in ln.runs) for ln in p.lines)
def _footer_baseline(model):
for p in model.paragraphs:
for ln in p.lines:
if "FOOTER" in "".join(r.text for r in ln.runs):
return ln.baseline_y
return None
def _reflow(longer: bool):
doc = pdfengine.PdfDocument.load_from_memory(_build_pdf(), "")
m = doc.get_page(0).extract_document_model()
para = m.paragraphs[0]
obj_idxs = [oi for ln in para.lines for r in ln.runs for oi in r.object_indices]
col_left = min(ln.x for ln in para.lines)
col_right = max(ln.x + ln.w for ln in para.lines)
first_baseline = max(ln.baseline_y for ln in para.lines)
bls = sorted((ln.baseline_y for ln in para.lines), reverse=True)
leading = abs(bls[0] - bls[1])
if longer:
new_text = ("The quick brown fox jumps over the lazy dog near the river bank on a sunny "
"afternoon in early spring while birds sing softly and the gentle breeze "
"carries the scent of fresh blossoms across the meadow.")
else:
new_text = "The quick brown fox."
op = {"version": "1.0", "operations": [{
"id": "r1", "type": "reflow_paragraph", "pageIndex": 0, "data": {
"objectIndices": obj_idxs,
"runs": [{"text": new_text, "internalFontId": para.lines[0].runs[0].internal_font_id,
"fontSize": 11.0, "color": "#000000"}],
"columnLeft": col_left, "columnRight": col_right, "firstBaselineY": first_baseline,
"leading": leading, "oldLineCount": len(para.lines), "align": "justify"}}]}
doc.apply_edits(json.dumps(op))
out = doc.save_full()
m2 = pdfengine.PdfDocument.load_from_memory(out, "").get_page(0).extract_document_model()
return m2, leading, len(para.lines)
def test_grow_wraps_and_pushes_down():
m2, leading, old_lines = _reflow(longer=True)
new_lines = len(m2.paragraphs[0].lines)
assert new_lines > old_lines, f"expected more lines after growing text, got {new_lines} (was {old_lines})"
footer = _footer_baseline(m2)
expected = 600 - (new_lines - old_lines) * leading
assert footer is not None and abs(footer - expected) < 2.0, \
f"footer should be pushed to ~{expected:.0f}, got {footer}"
print(f" ok grow: {old_lines}->{new_lines} lines, footer 600->{footer:.0f} (pushed {(new_lines-old_lines)*leading:.0f})")
def test_shrink_pulls_up():
m2, leading, old_lines = _reflow(longer=False)
new_lines = len(m2.paragraphs[0].lines)
assert new_lines <= old_lines
footer = _footer_baseline(m2)
expected = 600 - (new_lines - old_lines) * leading # new<old -> negative delta -> footer moves UP
assert footer is not None and abs(footer - expected) < 2.0, \
f"footer should pull up to ~{expected:.0f}, got {footer}"
print(f" ok shrink: {old_lines}->{new_lines} lines, footer 600->{footer:.0f}")
def test_no_word_overlap():
# After a justified reflow, words on a line must not overlap (the old bug collided them).
m2, _, _ = _reflow(longer=True)
for ln in m2.paragraphs[0].lines:
runs = sorted(ln.runs, key=lambda r: r.x)
for a, b in zip(runs, runs[1:]):
assert a.x + a.w <= b.x + 1.0, f"overlap: run ends {a.x + a.w:.1f} > next start {b.x:.1f}"
print(" ok no-overlap: justified lines have monotonic, non-colliding runs")
def main() -> int:
try:
test_grow_wraps_and_pushes_down()
test_shrink_pulls_up()
test_no_word_overlap()
except AssertionError as exc:
print(f" FAIL: {exc}")
return 1
print("\n3/3 passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())