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