275 lines
12 KiB
Python
275 lines
12 KiB
Python
"""Tests for CROSS-PAGE reflow — text that overflows the page bottom flows onto the next page
|
|
(creating it if needed), Word/Adobe style, while pre-existing footers stay put.
|
|
|
|
Run: gateway/.venv/Scripts/python.exe tests/edits/test_cross_page_reflow.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
import os
|
|
_BUILD_LIB = r"C:\Users\furqa\pdfeng-build\win-local-pdfium\lib"
|
|
if os.path.isdir(_BUILD_LIB):
|
|
sys.path.insert(0, _BUILD_LIB)
|
|
sys.path.insert(1, str(ROOT / "gateway"))
|
|
import pdfengine
|
|
|
|
|
|
def _build_pdf() -> bytes:
|
|
|
|
content = (
|
|
b"BT /F1 14 Tf 72 720 Td (RESUME HEADING) Tj ET\n"
|
|
b"BT /F1 11 Tf 72 150 Td (The quick brown fox jumps over the lazy) Tj ET\n"
|
|
b"BT /F1 11 Tf 72 136 Td (dog near the river bank on a sunny) Tj ET\n"
|
|
b"BT /F1 11 Tf 72 122 Td (afternoon in early spring.) Tj ET\n"
|
|
b"BT /F1 9 Tf 72 40 Td (Page 1 of 1 FOOTER) 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 _page_text(model) -> str:
|
|
return " ".join("".join(r.text for r in ln.runs) for p in model.paragraphs for ln in p.lines)
|
|
|
|
WORDS = [f"w{n:03d}" for n in range(80)]
|
|
LONG_TEXT = "The quick brown fox " + " ".join(WORDS) + " end."
|
|
|
|
|
|
def _reflow_overflow():
|
|
doc = pdfengine.PdfDocument.load_from_memory(_build_pdf(), "")
|
|
m = doc.get_page(0).extract_document_model()
|
|
def ptext(p):
|
|
return " ".join("".join(r.text for r in ln.runs) for ln in p.lines)
|
|
para = next(p for p in m.paragraphs if "quick brown fox" in ptext(p))
|
|
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 len(bls) > 1 else 14.0
|
|
|
|
op = {"version": "1.0", "operations": [{
|
|
"id": "x1", "type": "reflow_paragraph", "pageIndex": 0, "data": {
|
|
"objectIndices": obj_idxs,
|
|
"runs": [{"text": LONG_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": "left"}}]}
|
|
doc.apply_edits(json.dumps(op))
|
|
out = doc.save_full()
|
|
reloaded = pdfengine.PdfDocument.load_from_memory(out, "")
|
|
return reloaded
|
|
|
|
|
|
def test_overflow_creates_second_page():
|
|
doc = _reflow_overflow()
|
|
assert doc.page_count == 2, f"expected a 2nd page to be created, got {doc.page_count} page(s)"
|
|
print(f" ok overflow created page 2 (page_count={doc.page_count})")
|
|
|
|
|
|
def test_text_split_across_pages_intact():
|
|
doc = _reflow_overflow()
|
|
p1 = _page_text(doc.get_page(0).extract_document_model())
|
|
p2 = _page_text(doc.get_page(1).extract_document_model())
|
|
assert "w000" in p1, "first body word should remain on page 1"
|
|
assert "w079" in p2 or "end." in p2, f"tail of paragraph should be on page 2 (got: ...{p2[-60:]!r})"
|
|
assert any(w in p2 for w in WORDS), "page 2 should contain readable marker words (font migrated intact)"
|
|
dup = [w for w in WORDS if w in p1 and w in p2]
|
|
assert not dup, f"words duplicated across pages (line moved+kept): {dup[:5]}"
|
|
print(f" ok text intact across the split; p1 has w000.., p2 has the tail (no dupes)")
|
|
|
|
|
|
def test_footer_stays_on_page_1():
|
|
doc = _reflow_overflow()
|
|
p1 = _page_text(doc.get_page(0).extract_document_model())
|
|
p2 = _page_text(doc.get_page(1).extract_document_model())
|
|
assert "FOOTER" in p1, "footer must stay anchored on page 1"
|
|
assert "FOOTER" not in p2, "footer must NOT be flowed to page 2"
|
|
assert "HEADING" in p1, "heading must stay on page 1"
|
|
print(" ok footer + heading stayed on page 1 (anchored, not flowed)")
|
|
|
|
|
|
def _build_two_page_pdf() -> bytes:
|
|
c1 = (
|
|
b"BT /F1 14 Tf 72 720 Td (RESUME HEADING) Tj ET\n"
|
|
b"BT /F1 11 Tf 72 150 Td (The quick brown fox jumps over the lazy) Tj ET\n"
|
|
b"BT /F1 11 Tf 72 136 Td (dog near the river bank on a sunny) Tj ET\n"
|
|
b"BT /F1 11 Tf 72 122 Td (afternoon in early spring.) Tj ET\n"
|
|
)
|
|
c2 = (
|
|
b"BT /F1 14 Tf 72 720 Td (SECOND PAGE TOP) Tj ET\n"
|
|
b"BT /F1 11 Tf 72 700 Td (existing second page body content here) Tj ET\n"
|
|
)
|
|
objs = [
|
|
b"<< /Type /Catalog /Pages 2 0 R >>",
|
|
b"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>",
|
|
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(c1) + c1 + b"endstream",
|
|
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
|
|
b"/Resources << /Font << /F1 5 0 R >> >> /Contents 7 0 R >>",
|
|
b"<< /Length %d >>\nstream\n" % len(c2) + c2 + b"endstream",
|
|
]
|
|
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 _baseline_of(model, needle):
|
|
for p in model.paragraphs:
|
|
for ln in p.lines:
|
|
if needle in "".join(r.text for r in ln.runs):
|
|
return ln.baseline_y
|
|
return None
|
|
|
|
|
|
def test_cascade_onto_existing_page_shifts_it_down():
|
|
doc = pdfengine.PdfDocument.load_from_memory(_build_two_page_pdf(), "")
|
|
m = doc.get_page(0).extract_document_model()
|
|
def ptext(p):
|
|
return " ".join("".join(r.text for r in ln.runs) for ln in p.lines)
|
|
para = next(p for p in m.paragraphs if "quick brown fox" in ptext(p))
|
|
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 len(bls) > 1 else 14.0
|
|
|
|
top_before = _baseline_of(doc.get_page(1).extract_document_model(), "SECOND PAGE TOP")
|
|
|
|
op = {"version": "1.0", "operations": [{
|
|
"id": "c1", "type": "reflow_paragraph", "pageIndex": 0, "data": {
|
|
"objectIndices": obj_idxs,
|
|
"runs": [{"text": LONG_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": "left"}}]}
|
|
doc.apply_edits(json.dumps(op))
|
|
out = doc.save_full()
|
|
r = pdfengine.PdfDocument.load_from_memory(out, "")
|
|
|
|
p2 = _page_text(r.get_page(1).extract_document_model())
|
|
assert "SECOND PAGE TOP" in p2, "pre-existing page-2 content must be preserved, not overwritten"
|
|
assert "existing second page body" in p2, "pre-existing page-2 body must be preserved"
|
|
top_after = _baseline_of(r.get_page(1).extract_document_model(), "SECOND PAGE TOP")
|
|
assert top_before is not None and top_after is not None and top_after < top_before - 5, \
|
|
f"page-2 existing content should shift DOWN to make room (was {top_before}, now {top_after})"
|
|
assert any(w in p2 for w in WORDS), "overflow body words should have flowed onto page 2"
|
|
print(f" ok cascade: page-2 content shifted down {top_before:.0f}->{top_after:.0f}, preserved + overflow flowed in")
|
|
|
|
|
|
def _build_full_page1_plus_page2() -> bytes:
|
|
lines1 = [b"BT /F1 14 Tf 72 720 Td (RESUME HEADING) Tj ET\n"]
|
|
for i in range(38):
|
|
y = 690 - i * 14
|
|
lines1.append(b"BT /F1 11 Tf 72 %d Td (filler line number %d of the tall body paragraph) Tj ET\n" % (y, i))
|
|
c1 = b"".join(lines1)
|
|
c2 = (
|
|
b"BT /F1 11 Tf 72 720 Td (PULLME001 second page content alpha) Tj ET\n"
|
|
b"BT /F1 11 Tf 72 706 Td (PULLME002 second page content beta) Tj ET\n"
|
|
)
|
|
objs = [
|
|
b"<< /Type /Catalog /Pages 2 0 R >>",
|
|
b"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>",
|
|
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(c1) + c1 + b"endstream",
|
|
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
|
|
b"/Resources << /Font << /F1 5 0 R >> >> /Contents 7 0 R >>",
|
|
b"<< /Length %d >>\nstream\n" % len(c2) + c2 + b"endstream",
|
|
]
|
|
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 test_shrink_leaves_other_content_in_place():
|
|
doc = pdfengine.PdfDocument.load_from_memory(_build_full_page1_plus_page2(), "")
|
|
m = doc.get_page(0).extract_document_model()
|
|
def ptext(p):
|
|
return " ".join("".join(r.text for r in ln.runs) for ln in p.lines)
|
|
para = max((p for p in m.paragraphs if "filler line" in ptext(p)), key=lambda p: len(p.lines))
|
|
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 len(bls) > 1 else 14.0
|
|
|
|
op = {"version": "1.0", "operations": [{
|
|
"id": "s1", "type": "reflow_paragraph", "pageIndex": 0, "data": {
|
|
"objectIndices": obj_idxs,
|
|
"runs": [{"text": "Short body now.", "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": "left"}}]}
|
|
doc.apply_edits(json.dumps(op))
|
|
out = doc.save_full()
|
|
r = pdfengine.PdfDocument.load_from_memory(out, "")
|
|
|
|
p1 = _page_text(r.get_page(0).extract_document_model())
|
|
p2 = _page_text(r.get_page(1).extract_document_model())
|
|
assert "Short body now." in p1, "the shrunk paragraph should be on page 1"
|
|
assert "PULLME001" in p2 and "PULLME002" in p2, \
|
|
f"unrelated page-2 content must STAY on page 2 (not pulled up). p2={p2[:80]!r}"
|
|
assert "PULLME001" not in p1, "page-2 content must NOT be sucked onto page 1"
|
|
assert r.page_count == 2, f"page 2 has content -> must NOT be removed, got {r.page_count}"
|
|
print(" ok conservative shrink: page-2 section stayed in place, nothing pulled/lost")
|
|
|
|
|
|
def main() -> int:
|
|
try:
|
|
test_overflow_creates_second_page()
|
|
test_text_split_across_pages_intact()
|
|
test_footer_stays_on_page_1()
|
|
test_cascade_onto_existing_page_shifts_it_down()
|
|
test_shrink_leaves_other_content_in_place()
|
|
except AssertionError as exc:
|
|
print(f" FAIL: {exc}")
|
|
return 1
|
|
print("\n5/5 passed.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|