Files
pdf/tests/edits/test_replace_text.py
2026-06-22 15:18:47 +05:30

102 lines
3.7 KiB
Python

"""Round-trip tests for the `replace_text` op (line-level text rewriting).
`replace_text` targets stable page-object indices (from the document model's
run.object_indices) rather than coordinates, copies the original styling, and
reflows the rest of the line by the width delta. Verifies that an edit actually
replaces the text and round-trips through save/reload.
Run: gateway/.venv/Scripts/python.exe tests/edits/test_replace_text.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
CORPUS = ROOT / "corpus" / "fonts" / "utf-8.pdf"
def _load():
return pdfengine.PdfDocument.load_from_memory(CORPUS.read_bytes(), "")
def _runs(model):
return [r for p in model.paragraphs for l in p.lines for r in l.runs]
def _alltext(model):
return " ".join(r.text for r in _runs(model))
def _first_editable(model):
for r in _runs(model):
if list(r.object_indices):
return r
return None
def _op(run, new_text):
return {"version": "1.0", "operations": [{
"id": "r1", "type": "replace_text", "pageIndex": 0,
"data": {"objectIndices": list(run.object_indices), "text": new_text,
"internalFontId": run.internal_font_id, "fontSize": run.font_size},
}]}
def test_roundtrip_replaces_text():
doc = _load()
run = _first_editable(doc.get_page(0).extract_document_model())
assert run is not None, "no run with object_indices to edit"
original_fragment = run.text.strip().split()[0]
doc.apply_edits(json.dumps(_op(run, "REPLACED LINE")))
out = doc.save_full()
text2 = _alltext(pdfengine.PdfDocument.load_from_memory(out, "").get_page(0).extract_document_model())
assert "REPLACED LINE" in text2, "new text not found after round-trip"
assert original_fragment not in text2, f"original text '{original_fragment}' should be gone"
print(f" ok roundtrip: replaced run (objs {list(run.object_indices)}) -> 'REPLACED LINE'")
def test_reflow_keeps_other_text():
doc = _load()
model = doc.get_page(0).extract_document_model()
run = _first_editable(model)
other_lines = [r.text for r in _runs(model) if list(r.object_indices) != list(run.object_indices)]
doc.apply_edits(json.dumps(_op(run, "X")))
text2 = _alltext(pdfengine.PdfDocument.load_from_memory(doc.save_full(), "").get_page(0).extract_document_model())
survived = sum(1 for t in other_lines if t.strip() and t.strip() in text2)
assert survived >= max(1, len(other_lines) // 2), "too much surrounding text lost"
print(f" ok reflow: {survived}/{len(other_lines)} other runs preserved")
def test_empty_indices_is_noop():
doc = _load()
before = _alltext(doc.get_page(0).extract_document_model())
doc.apply_edits(json.dumps({"version": "1.0", "operations": [{
"id": "r1", "type": "replace_text", "pageIndex": 0,
"data": {"objectIndices": [], "text": "X", "internalFontId": "", "fontSize": 12.0}}]}))
after = _alltext(pdfengine.PdfDocument.load_from_memory(doc.save_full(), "").get_page(0).extract_document_model())
assert before == after, "empty objectIndices should be a no-op"
print(" ok empty objectIndices is a safe no-op")
def main() -> int:
tests = [test_roundtrip_replaces_text, test_reflow_keeps_other_text, test_empty_indices_is_noop]
failed = 0
for t in tests:
try:
t()
except AssertionError as exc:
print(f" FAIL {t.__name__}: {exc}")
failed += 1
print(f"\n{len(tests) - failed}/{len(tests)} passed.")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())