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