140 lines
5.5 KiB
Python
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())
|