194 lines
7.4 KiB
Python
194 lines
7.4 KiB
Python
"""Edit-entry overlay vs extracted PDF paragraph (no reflow / no typing).
|
|
|
|
Mirrors ParagraphEditor computeLayout + style construction for the real resume
|
|
heading \"Professional Experience\" and ranks the first property that diverges.
|
|
"""
|
|
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
|
|
|
|
PDF = Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf")
|
|
OUT = Path(__file__).resolve().parent / "forensic_real" / "edit_entry_overlay_report.json"
|
|
TARGET = "Professional Experience"
|
|
ZOOM = 1.0 # property ratios are zoom-invariant in pt space
|
|
|
|
|
|
def measure_family(font_name: str) -> str:
|
|
if re.search(r"times|serif", font_name, re.I):
|
|
return "Times New Roman, serif"
|
|
if re.search(r"courier|mono", font_name, re.I):
|
|
return "Courier New, monospace"
|
|
return "Arial, sans-serif"
|
|
|
|
|
|
def page_content_right(model) -> float:
|
|
right = float("-inf")
|
|
for p in model.paragraphs:
|
|
for ln in p.lines:
|
|
if any((r.text or "").strip() for r in ln.runs):
|
|
right = max(right, ln.x + ln.w)
|
|
return right if right != float("-inf") else 0.0
|
|
|
|
|
|
def main():
|
|
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
|
|
page = doc.get_page(0)
|
|
model = page.extract_document_model()
|
|
height_pts = page.height
|
|
|
|
para = None
|
|
for p in model.paragraphs:
|
|
text = "".join(r.text or "" for ln in p.lines for r in ln.runs)
|
|
if TARGET in text:
|
|
para = p
|
|
break
|
|
if para is None:
|
|
raise SystemExit("paragraph not found")
|
|
|
|
# --- Extracted PDF metrics ---
|
|
line = para.lines[0]
|
|
run = line.runs[0]
|
|
glyphs = list(run.glyphs)
|
|
min_x = min(g.bbox_x for g in glyphs)
|
|
min_y = min(g.bbox_y for g in glyphs)
|
|
max_x = max(g.bbox_x + g.bbox_w for g in glyphs)
|
|
max_y = max(g.bbox_y + g.bbox_h for g in glyphs)
|
|
baseline = glyphs[0].origin_y
|
|
ascent = max_y - baseline
|
|
descent = baseline - min_y
|
|
pdf = {
|
|
"text": "".join(r.text or "" for ln in para.lines for r in ln.runs),
|
|
"bbox": {"x": min_x, "y": min_y, "w": max_x - min_x, "h": max_y - min_y},
|
|
"line_x": line.x,
|
|
"line_y": line.y,
|
|
"line_w": line.w,
|
|
"line_h": line.h,
|
|
"baseline_y": line.baseline_y,
|
|
"ascent_pt": ascent,
|
|
"descent_pt": descent,
|
|
"width_pt": max_x - min_x,
|
|
"height_pt": max_y - min_y,
|
|
"font_name": run.font_name,
|
|
"font_size_pt": run.font_size,
|
|
"run_h": run.h,
|
|
"internal_font_id": run.internal_font_id,
|
|
"expected_font_weight": 700 if re.search(r"bold|black|heavy", run.font_name or "", re.I) else 400,
|
|
}
|
|
|
|
# --- Overlay construction (mirrors ParagraphEditor + TextEditLayer openEditor) ---
|
|
dom_size = max(run.font_size or 0, run.h or 0) or 12
|
|
column_left = line.x
|
|
para_right = line.x + line.w
|
|
pr = page_content_right(model)
|
|
column_right = max(pr, para_right) # TextEditLayer left-align path
|
|
# single-line leading default in computeLayout:
|
|
leading = dom_size * 1.2
|
|
first_baseline_y = line.baseline_y
|
|
font_name = run.font_name or ""
|
|
family = measure_family(font_name)
|
|
font_px = dom_size * ZOOM
|
|
leading_px = leading * ZOOM
|
|
col_left_px = column_left * ZOOM
|
|
col_width_px = (column_right - column_left) * ZOOM
|
|
col_height_px = 1 * leading_px # oldLineCount=1
|
|
first_baseline_screen = (height_pts - first_baseline_y) * ZOOM
|
|
editor_top = first_baseline_screen - font_px * 0.8
|
|
ascent_heuristic = dom_size * 0.8
|
|
|
|
overlay = {
|
|
"font_family": family,
|
|
"font_size_pt": dom_size,
|
|
"font_size_px": font_px,
|
|
"font_weight": "(not set; browser default 400)",
|
|
"line_height_pt": leading,
|
|
"line_height_px": leading_px,
|
|
"letter_spacing": "(not set; normal)",
|
|
"width_pt": column_right - column_left,
|
|
"width_px": col_width_px,
|
|
"height_pt": leading, # oldLineCount * leading
|
|
"height_px": col_height_px,
|
|
"left_px": col_left_px,
|
|
"top_px": editor_top,
|
|
"ascent_heuristic_pt": ascent_heuristic,
|
|
"transform": "none",
|
|
"column_left_pt": column_left,
|
|
"column_right_pt": column_right,
|
|
"page_content_right_pt": pr,
|
|
"style_source": {
|
|
"measureFamily": "Arial, sans-serif because name matches neither times|serif nor courier|mono",
|
|
"leading": "domSize * 1.2 (single-line; no baseline deltas)",
|
|
"editorTop": "baselineScreen - fontPx * 0.8",
|
|
"height": "oldLineCount * leadingPx",
|
|
"width": "columnRightOverride - columnLeft (page content right, not text bbox)",
|
|
},
|
|
}
|
|
|
|
# Ordered property checks (construction order / visual identity order)
|
|
checks = []
|
|
|
|
def add(prop, pdf_v, ov_v, match):
|
|
checks.append({"property": prop, "pdf": pdf_v, "overlay": ov_v, "match": match})
|
|
|
|
bold = pdf["expected_font_weight"] >= 700
|
|
add(
|
|
"font-family",
|
|
pdf["font_name"],
|
|
family,
|
|
(not bold) and ("arial" in (pdf["font_name"] or "").lower()),
|
|
)
|
|
add(
|
|
"font-weight",
|
|
pdf["expected_font_weight"],
|
|
400,
|
|
(not bold) or False, # BoldMT → no font-weight set → mismatch
|
|
)
|
|
add("font-size (pt)", pdf["font_size_pt"], overlay["font_size_pt"], abs(pdf["font_size_pt"] - overlay["font_size_pt"]) < 0.05)
|
|
add("line-height / leading (pt)", pdf["line_h"], overlay["line_height_pt"], abs(pdf["line_h"] - overlay["line_height_pt"]) < 0.5)
|
|
add("ascent (pt)", pdf["ascent_pt"], overlay["ascent_heuristic_pt"], abs(pdf["ascent_pt"] - overlay["ascent_heuristic_pt"]) < 0.5)
|
|
add("descent (pt)", pdf["descent_pt"], "(not represented in overlay CSS)", False)
|
|
add("height (pt)", pdf["height_pt"], overlay["height_pt"], abs(pdf["height_pt"] - overlay["height_pt"]) < 0.5)
|
|
add("width (pt)", pdf["width_pt"], overlay["width_pt"], abs(pdf["width_pt"] - overlay["width_pt"]) < 1.0)
|
|
add("letter-spacing", 0, "normal", True)
|
|
add("transform", "none", "none", True)
|
|
|
|
first = next((c for c in checks if not c["match"]), None)
|
|
|
|
print("=== PDF paragraph ===")
|
|
print(json.dumps(pdf, indent=2))
|
|
print("\n=== Overlay applied (edit-entry) ===")
|
|
print(json.dumps(overlay, indent=2))
|
|
print("\n=== Property checks (order) ===")
|
|
for c in checks:
|
|
flag = "OK " if c["match"] else "DIFF"
|
|
print(f" [{flag}] {c['property']}: pdf={c['pdf']!r} overlay={c['overlay']!r}")
|
|
print("\n=== FIRST PROPERTY THAT CHANGES ON EDIT-ENTRY ===")
|
|
print(json.dumps(first, indent=2))
|
|
|
|
report = {
|
|
"scope": "edit-entry only (mouse click before typing); no reflow/typing",
|
|
"pdf": pdf,
|
|
"overlay": overlay,
|
|
"checks": checks,
|
|
"firstPropertyThatChanges": first,
|
|
"notes": [
|
|
"measureFamily maps Arial-BoldMT → 'Arial, sans-serif' and never sets font-weight:700",
|
|
"That is the first identity/style mutation when the contentEditable overlay is created",
|
|
"Subsequent geometric diffs: leading 11.09→14.4, ascent 8.74→9.6, height 11.09→14.4, width text→page column",
|
|
],
|
|
}
|
|
OUT.parent.mkdir(parents=True, exist_ok=True)
|
|
OUT.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
|
print(f"\nWrote {OUT}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|