104 lines
3.8 KiB
Python
104 lines
3.8 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""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 # noqa: E402
|
||
|
|
|
||
|
|
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():
|
||
|
|
# Replacing the first run must not destroy text on other lines.
|
||
|
|
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())
|