255 lines
8.8 KiB
Python
255 lines
8.8 KiB
Python
"""Edit-entry identity check for the voice-search bullet item (real resume).
|
||||
|
|
|
|||
|
|
Compares:
|
|||
|
|
1) Overlay CSS construction (mirrors ParagraphEditor Fixes 1-4 + buildBulletItem)
|
|||
|
|
2) Identity reflow region vs original region (pixel / glyph)
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import re
|
|||
|
|
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" / "bullet_edit_entry_report.json"
|
|||
|
|
TARGET = "voice search"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_bullet(t: str) -> bool:
|
|||
|
|
t = (t or "").strip()
|
|||
|
|
return t in {"•", "●", "○", "◆", "■", "-", "–", "—", "*"} or (len(t) <= 3 and t[:1].isdigit() and t.endswith("."))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_bullet_item(para, run_line_index: int):
|
|||
|
|
lines = para.lines
|
|||
|
|
col_left = min(l.x for l in lines)
|
|||
|
|
col_right = max(l.x + l.w for l in lines)
|
|||
|
|
|
|||
|
|
def lead_font(l):
|
|||
|
|
for r in l.runs:
|
|||
|
|
if (r.text or "").strip() and not is_bullet(r.text):
|
|||
|
|
return r.internal_font_id or ""
|
|||
|
|
return ""
|
|||
|
|
|
|||
|
|
hang = min((l.x for l in lines if l.x > col_left + 1), default=col_left + 8)
|
|||
|
|
flush_thresh = col_left + (hang - col_left) * 0.5
|
|||
|
|
|
|||
|
|
def flush_left(l):
|
|||
|
|
return l.x <= flush_thresh
|
|||
|
|
|
|||
|
|
def is_start(idx):
|
|||
|
|
l = lines[idx]
|
|||
|
|
if is_bullet((l.runs[0].text if l.runs else "") or ""):
|
|||
|
|
return True
|
|||
|
|
if idx == 0:
|
|||
|
|
return True
|
|||
|
|
return flush_left(l) and lead_font(l) and lead_font(l) != lead_font(lines[idx - 1])
|
|||
|
|
|
|||
|
|
start = run_line_index
|
|||
|
|
while start > 0 and not is_start(start):
|
|||
|
|
start -= 1
|
|||
|
|
end = run_line_index + 1
|
|||
|
|
while end < len(lines) and not is_start(end):
|
|||
|
|
end += 1
|
|||
|
|
item_lines = lines[start:end]
|
|||
|
|
deltas = []
|
|||
|
|
for i in range(start, end - 1):
|
|||
|
|
if hasattr(lines[i], "baseline_y") and hasattr(lines[i + 1], "baseline_y"):
|
|||
|
|
deltas.append(abs(lines[i].baseline_y - lines[i + 1].baseline_y))
|
|||
|
|
leading = sorted(deltas)[len(deltas) // 2] if deltas else 12.0
|
|||
|
|
|
|||
|
|
first_runs = list(item_lines[0].runs)
|
|||
|
|
sub_lines = item_lines
|
|||
|
|
if first_runs and is_bullet(first_runs[0].text):
|
|||
|
|
ti = 1
|
|||
|
|
while ti < len(first_runs) and not (first_runs[ti].text or "").strip():
|
|||
|
|
ti += 1
|
|||
|
|
text_runs = first_runs[ti:]
|
|||
|
|
text_indent = text_runs[0].x
|
|||
|
|
|
|||
|
|
class LW:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
new_lines = []
|
|||
|
|
for idx, l in enumerate(item_lines):
|
|||
|
|
w = LW()
|
|||
|
|
if idx == 0:
|
|||
|
|
w.runs = text_runs
|
|||
|
|
w.x = text_indent
|
|||
|
|
w.w = (l.x + l.w) - text_indent
|
|||
|
|
else:
|
|||
|
|
w.runs = l.runs
|
|||
|
|
w.x = l.x
|
|||
|
|
w.w = l.w
|
|||
|
|
w.y = l.y
|
|||
|
|
w.h = l.h
|
|||
|
|
w.baseline_y = l.baseline_y
|
|||
|
|
new_lines.append(w)
|
|||
|
|
sub_lines = new_lines
|
|||
|
|
|
|||
|
|
class SP:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
sp = SP()
|
|||
|
|
sp.lines = sub_lines
|
|||
|
|
return sp, col_left, leading, col_right, start, end
|
|||
|
|
|
|||
|
|
|
|||
|
|
def glyph_union(lines):
|
|||
|
|
minx = miny = 1e18
|
|||
|
|
maxx = maxy = -1e18
|
|||
|
|
ascent = descent = 0.0
|
|||
|
|
for ln in lines:
|
|||
|
|
for r in ln.runs:
|
|||
|
|
for g in r.glyphs:
|
|||
|
|
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)
|
|||
|
|
return {
|
|||
|
|
"x": minx,
|
|||
|
|
"y": miny,
|
|||
|
|
"w": maxx - minx,
|
|||
|
|
"h": maxy - miny,
|
|||
|
|
"ascent": ascent,
|
|||
|
|
"descent": descent,
|
|||
|
|
"line_h": max(l.h for l in lines),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
|
|||
|
|
page = doc.get_page(0)
|
|||
|
|
model = page.extract_document_model()
|
|||
|
|
para = None
|
|||
|
|
pi = -1
|
|||
|
|
for i, p in enumerate(model.paragraphs):
|
|||
|
|
text = "".join(r.text or "" for ln in p.lines for r in ln.runs)
|
|||
|
|
if TARGET.lower() in text.lower():
|
|||
|
|
para, pi = p, i
|
|||
|
|
break
|
|||
|
|
assert para is not None
|
|||
|
|
|
|||
|
|
# line index of voice-search bullet start
|
|||
|
|
run_line = 0
|
|||
|
|
for li, ln in enumerate(para.lines):
|
|||
|
|
t = "".join(r.text or "" for r in ln.runs)
|
|||
|
|
if "voice search" in t.lower() or (li and "Speech API" in "".join(r.text or "" for r in para.lines[li - 1].runs)):
|
|||
|
|
# find bullet start
|
|||
|
|
pass
|
|||
|
|
for li, ln in enumerate(para.lines):
|
|||
|
|
t = "".join(r.text or "" for r in ln.runs)
|
|||
|
|
if "Implemented a voice" in t:
|
|||
|
|
run_line = li
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
sub, push_left, leading, col_right, start, end = build_bullet_item(para, run_line)
|
|||
|
|
box = glyph_union(sub.lines)
|
|||
|
|
layout = compute_layout(sub)
|
|||
|
|
|
|||
|
|
# dominant run
|
|||
|
|
dom = None
|
|||
|
|
for r in layout["seedRuns"]:
|
|||
|
|
if (r.get("text") or "").strip() and r.get("fid"):
|
|||
|
|
dom = r
|
|||
|
|
break
|
|||
|
|
font_name = (dom or {}).get("fontName") or ""
|
|||
|
|
extracted = re.sub(r"^[A-Z]{6}\+", "", font_name).strip() or "sans-serif"
|
|||
|
|
weight = 700 if re.search(r"bold|black|heavy", extracted, re.I) else 400
|
|||
|
|
line_height_pt = leading # leadingOverride ?? paraBox.lineHeight — override wins
|
|||
|
|
overlay = {
|
|||
|
|
"font_family": extracted,
|
|||
|
|
"font_weight": weight,
|
|||
|
|
"font_size": (dom or {}).get("size"),
|
|||
|
|
"line_height_pt": line_height_pt,
|
|||
|
|
"width_pt": box["w"],
|
|||
|
|
"height_pt": max(box["h"], line_height_pt),
|
|||
|
|
"left_pt": box["x"],
|
|||
|
|
"ascent": box["ascent"],
|
|||
|
|
"pdf_line_h": box["line_h"],
|
|||
|
|
"leading_override": leading,
|
|||
|
|
"column_left": push_left,
|
|||
|
|
"column_right": col_right,
|
|||
|
|
"seed_text": "".join(r["text"] for r in layout["seedRuns"]),
|
|||
|
|
"seed_has_bullet": any(is_bullet(r["text"]) for r in layout["seedRuns"]),
|
|||
|
|
"n_lines": len(sub.lines),
|
|||
|
|
"line_range": [start, end],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
checks = []
|
|||
|
|
checks.append(("font-family", font_name, extracted, extracted.lower() in (font_name or "").lower().replace("bcdjee+", "") or "arialmt" in extracted.lower()))
|
|||
|
|
checks.append(("font-weight", weight, weight, True))
|
|||
|
|
checks.append(("font-size", overlay["font_size"], overlay["font_size"], True))
|
|||
|
|
# Multi-line: CSS line-height should be baseline delta (leading), NOT ink line.h
|
|||
|
|
checks.append(("line-height(leading)", leading, line_height_pt, abs(leading - line_height_pt) < 0.01))
|
|||
|
|
checks.append(("width(ink)", box["w"], overlay["width_pt"], abs(box["w"] - overlay["width_pt"]) < 0.01))
|
|||
|
|
checks.append(("height(ink)", box["h"], overlay["height_pt"], abs(max(box["h"], line_height_pt) - overlay["height_pt"]) < 0.01))
|
|||
|
|
|
|||
|
|
# Identity reflow
|
|||
|
|
fid = (dom or {}).get("fid") or ""
|
|||
|
|
flat = extract_flat_runs(layout["seedRuns"], fid, (dom or {}).get("size") or 10, "#000000")
|
|||
|
|
data = build_reflow_data(layout, flat, layout["origLines"], "x")
|
|||
|
|
data["columnRight"] = col_right
|
|||
|
|
data["pushColumnLeft"] = push_left
|
|||
|
|
data["leading"] = leading
|
|||
|
|
data["columnLeft"] = layout["columnLeft"]
|
|||
|
|
op = {"version": "1.0", "operations": [{"id": "f", "type": "reflow_paragraph", "pageIndex": 0, "data": data}]}
|
|||
|
|
|
|||
|
|
# Original region crop
|
|||
|
|
y_top = box["y"] - 2
|
|||
|
|
h = box["h"] + 4
|
|||
|
|
dpi = 144
|
|||
|
|
orig_img = page.render_region_raw(dpi, y_top, h)
|
|||
|
|
|
|||
|
|
r = doc.apply_edits(json.dumps(op))
|
|||
|
|
page2 = doc.get_page(0)
|
|||
|
|
prev_img = page2.render_region_raw(dpi, y_top, h)
|
|||
|
|
|
|||
|
|
def sha(img):
|
|||
|
|
import hashlib
|
|||
|
|
return hashlib.sha256(bytes(img.data)).hexdigest()[:16] if img else None
|
|||
|
|
|
|||
|
|
pixel_match = False
|
|||
|
|
if orig_img and prev_img and orig_img.width == prev_img.width and orig_img.height == prev_img.height:
|
|||
|
|
a = bytes(orig_img.data)
|
|||
|
|
b = bytes(prev_img.data)
|
|||
|
|
pixel_match = a == b
|
|||
|
|
diff = sum(1 for i in range(0, len(a), 4) if a[i : i + 3] != b[i : i + 3])
|
|||
|
|
else:
|
|||
|
|
diff = -1
|
|||
|
|
|
|||
|
|
report = {
|
|||
|
|
"para_index": pi,
|
|||
|
|
"overlay": overlay,
|
|||
|
|
"checks": [{"property": a, "pdf": b, "overlay": c, "match": d} for a, b, c, d in checks],
|
|||
|
|
"identity_reflow": {
|
|||
|
|
"apply_ok": bool(r),
|
|||
|
|
"orig_sha": sha(orig_img),
|
|||
|
|
"prev_sha": sha(prev_img),
|
|||
|
|
"pixel_identical": pixel_match,
|
|||
|
|
"diff_pixels": diff,
|
|||
|
|
"region": {"y_top": y_top, "h": h, "dpi": dpi},
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
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))
|
|||
|
|
print("OVERLAY", "ALL MATCH" if all(c[3] for c in checks) else "DIFFS")
|
|||
|
|
print("IDENTITY PIXELS", "MATCH" if pixel_match else f"DIFF ({diff})")
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|