Files
pdf/tests/edits/forensic_first_keystroke_mutation.py
T
2026-07-31 10:50:37 +05:30

234 lines
8.9 KiB
Python

"""First-property mutation on first keystroke (typing/reflow only).
Compares ORIGINAL extracted paragraph vs after typing one char at end.
Mirrors ParagraphEditor when editedRef=true:
- no lines[] payload (origLines dropped)
- lineX / lineBaselineY still sent from layout
- advances + advanceSeedText for the edited run
Prints the first property that diverges.
"""
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"))
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
import pdfengine # type: ignore
sys.path.insert(0, str(Path(__file__).resolve().parent))
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
PDF = Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf")
OUT = Path(__file__).resolve().parent / "forensic_real" / "first_keystroke_mutation.json"
TARGET = "Professional Experience"
TYPED = TARGET + "x"
TOL = 0.05
def para_metrics(para, prefix: str) -> dict:
glyphs = []
fonts = set()
sizes = set()
minx = miny = 1e18
maxx = maxy = -1e18
ascent = descent = 0.0
baselines = []
line_hs = []
for ln in para.lines:
baselines.append(ln.baseline_y)
line_hs.append(ln.h)
for r in ln.runs:
fonts.add(r.font_name or "")
sizes.add(r.font_size or 0)
text = r.text or ""
gs = list(r.glyphs)
for i, g in enumerate(gs):
ch = text[i] if i < len(text) else "?"
adv = (gs[i + 1].origin_x - g.origin_x) if i + 1 < len(gs) else (g.bbox_w or (r.font_size or 12) * 0.5)
glyphs.append({
"char": ch,
"origin_x": g.origin_x,
"origin_y": g.origin_y,
"advance": adv,
"bbox_x": g.bbox_x,
"bbox_y": g.bbox_y,
"bbox_w": g.bbox_w,
"bbox_h": g.bbox_h,
"font_size": g.font_size or r.font_size,
"font_name": g.font_name or r.font_name,
"fid": r.internal_font_id,
})
minx = min(minx, g.bbox_x)
miny = min(miny, g.bbox_y)
maxx = max(maxx, g.bbox_x + g.bbox_w)
maxy = max(maxy, g.bbox_y + g.bbox_h)
gbl = g.origin_y if g.origin_y else ln.baseline_y
ascent = max(ascent, (g.bbox_y + g.bbox_h) - gbl)
descent = max(descent, gbl - g.bbox_y)
joined = "".join(g["char"] for g in glyphs)
start = joined.find(prefix[: len(TARGET)])
if start < 0:
start = 0
shared = glyphs[start : start + len(TARGET)]
leading = None
if len(baselines) >= 2:
leading = abs(baselines[0] - baselines[1])
return {
"text": joined,
"font_family": sorted(fonts),
"font_size": max(sizes) if sizes else None,
"line_height": max(line_hs) if line_hs else None,
"leading": leading,
"ascent": ascent,
"descent": descent,
"paragraph_width": (maxx - minx) if maxx > minx else 0,
"paragraph_height": (maxy - miny) if maxy > miny else 0,
"n_lines": len(para.lines),
"baselines": baselines,
"shared_glyphs": shared,
"bbox": {"x": minx, "y": miny, "w": maxx - minx, "h": maxy - miny},
}
def first_diff(before: dict, after: dict) -> dict | None:
checks = []
def add(name, b, a, ok):
checks.append({"property": name, "before": b, "after": a, "match": ok})
add("font_family", before["font_family"], after["font_family"],
before["font_family"] == after["font_family"])
add("font_size", before["font_size"], after["font_size"],
before["font_size"] is not None and abs((before["font_size"] or 0) - (after["font_size"] or 0)) < TOL)
add("line_height", before["line_height"], after["line_height"],
before["line_height"] is not None and abs((before["line_height"] or 0) - (after["line_height"] or 0)) < TOL)
add("ascent", before["ascent"], after["ascent"], abs(before["ascent"] - after["ascent"]) < TOL)
add("descent", before["descent"], after["descent"], abs(before["descent"] - after["descent"]) < TOL)
# Width/height: after includes +x so width may grow at the end — compare shared-prefix ink only below.
bg, ag = before["shared_glyphs"], after["shared_glyphs"]
n = min(len(bg), len(ag), len(TARGET))
# First glyph-level mutation among UNCHANGED chars
glyph_first = None
for i in range(n):
b, a = bg[i], ag[i]
reasons = []
if b["char"] != a["char"]:
reasons.append("char")
if abs(b["advance"] - a["advance"]) > TOL:
reasons.append("advance")
if abs(b["origin_x"] - a["origin_x"]) > TOL:
reasons.append("origin_x")
if abs(b["origin_y"] - a["origin_y"]) > TOL:
reasons.append("origin_y")
if (b.get("font_name") or "") != (a.get("font_name") or ""):
reasons.append("font_name")
if abs((b.get("font_size") or 0) - (a.get("font_size") or 0)) > TOL:
reasons.append("font_size")
if reasons:
glyph_first = {
"index": i,
"char": b["char"],
"reasons": reasons,
"before": b,
"after": a,
}
break
add("unchanged_glyph_identity", "all match", glyph_first or "all match", glyph_first is None)
# Prefix span width (should be identical if advances+positions preserved)
if n:
bw = (bg[n - 1]["origin_x"] + bg[n - 1]["advance"]) - bg[0]["origin_x"]
aw = (ag[n - 1]["origin_x"] + ag[n - 1]["advance"]) - ag[0]["origin_x"]
add("prefix_width", bw, aw, abs(bw - aw) < TOL)
add("prefix_start_x", bg[0]["origin_x"], ag[0]["origin_x"], abs(bg[0]["origin_x"] - ag[0]["origin_x"]) < TOL)
first = next((c for c in checks if not c["match"]), None)
return {"checks": checks, "first_property_that_changes": first, "first_unchanged_glyph_mutation": glyph_first}
def main():
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
page = doc.get_page(0)
model = page.extract_document_model()
para = None
for p in model.paragraphs:
t = "".join(r.text or "" for ln in p.lines for r in ln.runs)
if TARGET in t:
para = p
break
assert para is not None
before = para_metrics(para, TARGET)
layout = compute_layout(para)
flat = extract_flat_runs(layout["seedRuns"], layout["seedRuns"][0]["fid"], layout["seedRuns"][0]["size"], "#000")
seed_adv = None
for r in flat:
if r.get("text") == TARGET and r.get("advances") and len(r["advances"]) == len(TARGET):
seed_adv = list(r["advances"])
break
typed = []
for r in flat:
nr = dict(r)
if nr.get("text") == TARGET:
nr["text"] = TYPED
if seed_adv is not None:
nr["advances"] = seed_adv
nr["advanceSeedText"] = TARGET
else:
nr.pop("advances", None)
typed.append(nr)
data = build_reflow_data(layout, typed, None, "key-after")
data.pop("lines", None) # editedRef drops lines
# keep lineX/lineBaselineY as frontend does
print("PAYLOAD keys:", sorted(data.keys()))
print("run:", [(r.get("text"), len(r.get("advances") or []), r.get("advanceSeedText")) for r in typed])
op = {"version": "1.0", "operations": [{"id": "t", "type": "reflow_paragraph", "pageIndex": 0, "data": data}]}
doc.apply_edits(json.dumps(op))
para_a = None
model_a = doc.get_page(0).extract_document_model()
for p in model_a.paragraphs:
t = "".join(r.text or "" for ln in p.lines for r in ln.runs)
if TARGET in t or TYPED in t or "Professional" in t:
para_a = p
break
assert para_a is not None
after = para_metrics(para_a, TYPED)
result = first_diff(before, after)
report = {
"target": TARGET,
"typed": TYPED,
"seed_adv_len": len(seed_adv or []),
"before": {k: v for k, v in before.items() if k != "shared_glyphs"},
"after": {k: v for k, v in after.items() if k != "shared_glyphs"},
"before_shared_adv_sample": [g["advance"] for g in before["shared_glyphs"][:8]],
"after_shared_adv_sample": [g["advance"] for g in after["shared_glyphs"][:8]],
**result,
}
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(json.dumps(report, indent=2))
first = result["first_property_that_changes"]
print("\n=== FIRST PROPERTY THAT CHANGES ===")
print(json.dumps(first, indent=2))
print("\n=== FIRST UNCHANGED GLYPH MUTATION ===")
print(json.dumps(result["first_unchanged_glyph_mutation"], indent=2))
if __name__ == "__main__":
main()