fix the issue
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user