234 lines
8.9 KiB
Python
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()
|