Files
pdf/tests/edits/test_cross_page_reflow.py

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())