273 lines
10 KiB
Python
273 lines
10 KiB
Python
"""Deep font-program + normalized glyph outline compare for real resume PDF."""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import struct
|
|
import sys
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
# Prefer rebuilt engine
|
|
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"
|
|
FID = "Arial-BoldMT_TrueType_32"
|
|
TARGET = "Professional Experience"
|
|
GLYPHS = list("Prof")
|
|
|
|
|
|
def sha(b: bytes) -> str:
|
|
return hashlib.sha256(b).hexdigest()
|
|
|
|
|
|
def chunk_png(path: Path, rgba: bytes, w: int, h: int):
|
|
def chunk(tag: bytes, data: bytes) -> bytes:
|
|
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
|
raw = b"".join(b"\x00" + rgba[y * w * 4:(y + 1) * w * 4] for y in range(h))
|
|
ihdr = struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)
|
|
path.write_bytes(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b""))
|
|
|
|
|
|
def find_para(doc):
|
|
page = doc.get_page(0)
|
|
model = page.extract_document_model()
|
|
for idx, para in enumerate(model.paragraphs):
|
|
text = " ".join("".join(r.text for r in ln.runs) for ln in para.lines)
|
|
if TARGET in text:
|
|
return idx, para, text
|
|
raise RuntimeError("not found")
|
|
|
|
|
|
def glyph_boxes(para):
|
|
wanted = {c: None for c in GLYPHS}
|
|
for ln in para.lines:
|
|
for r in ln.runs:
|
|
for g in r.glyphs:
|
|
if g.text in wanted and wanted[g.text] is None:
|
|
wanted[g.text] = dict(
|
|
origin_x=g.origin_x, origin_y=g.origin_y,
|
|
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, font_name=g.font_name,
|
|
)
|
|
return wanted
|
|
|
|
|
|
def crop_with_box(doc, box, dpi=288, pad=2.0):
|
|
page = doc.get_page(0)
|
|
ph = page.height
|
|
x0, y0 = box["bbox_x"] - pad, box["bbox_y"] - pad
|
|
x1, y1 = box["bbox_x"] + box["bbox_w"] + pad, box["bbox_y"] + box["bbox_h"] + pad
|
|
y_top = ph - y1
|
|
height = y1 - y0
|
|
w, h, raw = page.render_region_raw(dpi, y_top, height)
|
|
scale = dpi / 72.0
|
|
left = max(0, int(x0 * scale))
|
|
right = min(w, int(x1 * scale) + 1)
|
|
cw = max(1, right - left)
|
|
crop = bytearray(cw * h * 4)
|
|
for row in range(h):
|
|
src = (row * w + left) * 4
|
|
dst = row * cw * 4
|
|
crop[dst:dst + cw * 4] = raw[src:src + cw * 4]
|
|
return bytes(crop), cw, h
|
|
|
|
|
|
def parse_objects(pdf_bytes: bytes):
|
|
parts = re.split(rb"(\d+)\s+0\s+obj", pdf_bytes)
|
|
objs = {}
|
|
i = 1
|
|
while i + 1 < len(parts):
|
|
num = int(parts[i].decode())
|
|
body = parts[i + 1].split(b"endobj", 1)[0]
|
|
objs[num] = body
|
|
i += 2
|
|
return objs
|
|
|
|
|
|
def stream_data(body: bytes) -> bytes | None:
|
|
m = re.search(rb"stream\r?\n(.*?)\r?\nendstream", body, re.DOTALL)
|
|
if not m:
|
|
return None
|
|
raw = m.group(1)
|
|
hdr = body.split(b"stream")[0]
|
|
if b"/FlateDecode" in hdr:
|
|
try:
|
|
return zlib.decompress(raw)
|
|
except Exception:
|
|
return raw
|
|
return raw
|
|
|
|
|
|
def resolve_fontfile(objs: dict, font_obj_num: int) -> dict | None:
|
|
"""Follow Font -> FontDescriptor -> FontFile2/3 and return program info."""
|
|
body = objs.get(font_obj_num)
|
|
if not body:
|
|
return None
|
|
text = body.split(b"stream")[0].decode("latin-1", "replace")
|
|
bf = re.search(r"/BaseFont\s*/([^\s/>\[]+)", text)
|
|
# DescendantFonts [ N 0 R ]
|
|
desc_m = re.search(r"/DescendantFonts\s*\[\s*(\d+)\s+0\s+R", text)
|
|
target = font_obj_num
|
|
if desc_m:
|
|
target = int(desc_m.group(1))
|
|
text = objs[target].split(b"stream")[0].decode("latin-1", "replace")
|
|
fd_m = re.search(r"/FontDescriptor\s+(\d+)\s+0\s+R", text)
|
|
if not fd_m:
|
|
return {"baseFont": bf.group(1) if bf else None, "fontFile": None, "reason": "no FontDescriptor"}
|
|
fd_num = int(fd_m.group(1))
|
|
fd = objs[fd_num].split(b"stream")[0].decode("latin-1", "replace")
|
|
ff_m = re.search(r"/FontFile([23]?)\s+(\d+)\s+0\s+R", fd)
|
|
if not ff_m:
|
|
return {"baseFont": bf.group(1) if bf else None, "fontDescriptor": fd_num,
|
|
"fontFile": None, "descriptorPreview": " ".join(fd.split())[:300]}
|
|
kind, ff_num = ff_m.group(1) or "1", int(ff_m.group(2))
|
|
data = stream_data(objs[ff_num])
|
|
return {
|
|
"baseFont": bf.group(1) if bf else None,
|
|
"fontDescriptor": fd_num,
|
|
"fontFileKind": f"FontFile{kind}",
|
|
"fontFileObj": ff_num,
|
|
"programLen": len(data) if data else 0,
|
|
"programSha": sha(data) if data else None,
|
|
"programHead": data[:16].hex() if data else None,
|
|
"isSfnt": bool(data and data[:4] in (b"\x00\x01\x00\x00", b"OTTO", b"true")),
|
|
}
|
|
|
|
|
|
def find_fonts_by_base(objs: dict, needle: str) -> list[tuple[int, str]]:
|
|
hits = []
|
|
for num, body in objs.items():
|
|
hdr = body.split(b"stream")[0].decode("latin-1", "replace")
|
|
if "/BaseFont" in hdr and needle in hdr and "/Type" in hdr and "/Font" in hdr:
|
|
bf = re.search(r"/BaseFont\s*/([^\s/>\[]+)", hdr)
|
|
hits.append((num, bf.group(1) if bf else "?"))
|
|
return hits
|
|
|
|
|
|
def main():
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
print("[engine]", pdfengine.__file__)
|
|
orig_bytes = PDF.read_bytes()
|
|
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
|
|
_, para, text = find_para(doc)
|
|
print("[para]", repr(text))
|
|
|
|
api_font = doc.get_font_data(FID)
|
|
print(f"[API get_font_data] len={len(api_font)} sha={sha(api_font) if api_font else None}")
|
|
|
|
layout = compute_layout(para)
|
|
flat = extract_flat_runs(layout["seedRuns"], FID, layout["seedRuns"][0]["size"], layout["seedRuns"][0]["color"])
|
|
data = build_reflow_data(layout, flat, layout["origLines"], "deep-font")
|
|
op = {"version": "1.0", "operations": [{"id": "e", "type": "reflow_paragraph", "pageIndex": 0, "data": data}]}
|
|
|
|
print("\n=== REFLOW (listen for EMIT/LOAD logs) ===")
|
|
doc2 = pdfengine.PdfDocument.load_from_file(str(PDF), "")
|
|
doc2.apply_edits(json.dumps(op))
|
|
prev_bytes = doc2.save_full()
|
|
(OUT / "preview_entry.pdf").write_bytes(prev_bytes)
|
|
|
|
objs_o = parse_objects(orig_bytes)
|
|
objs_p = parse_objects(prev_bytes)
|
|
|
|
print("\n=== ORIGINAL fonts matching Arial-Bold ===")
|
|
for num, bf in find_fonts_by_base(objs_o, "Arial-Bold"):
|
|
info = resolve_fontfile(objs_o, num)
|
|
print(f" obj {num} BaseFont={bf}")
|
|
print(f" {json.dumps(info)}")
|
|
|
|
print("\n=== PREVIEW fonts matching Arial-Bold / Helvetica ===")
|
|
for needle in ("Arial-Bold", "Helvetica", "Arial"):
|
|
for num, bf in find_fonts_by_base(objs_p, needle):
|
|
info = resolve_fontfile(objs_p, num)
|
|
print(f" obj {num} BaseFont={bf}")
|
|
print(f" {json.dumps(info)}")
|
|
|
|
# Compare API font program to every FontFile2 in preview that is new
|
|
print("\n=== PROGRAM IDENTITY ===")
|
|
api_sha = sha(api_font) if api_font else None
|
|
print("API embedded program sha:", api_sha)
|
|
prev_programs = []
|
|
for num, bf in find_fonts_by_base(objs_p, "Arial"):
|
|
info = resolve_fontfile(objs_p, num)
|
|
if info and info.get("programSha"):
|
|
prev_programs.append((num, bf, info))
|
|
same = info["programSha"] == api_sha
|
|
print(f" preview font obj={num} BaseFont={bf} programSha={info['programSha'][:16]}... "
|
|
f"SAME_AS_EMBEDDED_API={same} len={info['programLen']}")
|
|
|
|
# Identify NEW BaseFont Arial-BoldMT (non-subset) introduced by emit
|
|
new_emit = [p for p in prev_programs if p[1] == "Arial-BoldMT"]
|
|
orig_subset = []
|
|
for num, bf in find_fonts_by_base(objs_o, "BCDEEE+Arial-BoldMT"):
|
|
info = resolve_fontfile(objs_o, num)
|
|
orig_subset.append((num, bf, info))
|
|
print(f" original subset obj={num} BaseFont={bf} programSha={(info or {}).get('programSha')}")
|
|
|
|
if new_emit and api_sha:
|
|
ne = new_emit[0][2]
|
|
print(f"\nFIRST FONT-PROGRAM DIVERGENCE:")
|
|
print(f" emitted BaseFont=Arial-BoldMT programSha={ne.get('programSha')}")
|
|
print(f" original API Arial-BoldMT_TrueType_32 sha={api_sha}")
|
|
print(f" identical programs? {ne.get('programSha') == api_sha}")
|
|
if ne.get("programSha") != api_sha:
|
|
print(" => EMITTED FONT USES A DIFFERENT FONT PROGRAM (system fallback), NOT THE EMBEDDED PDF FONT FILE")
|
|
|
|
# Normalized glyph crops: use ORIGINAL bbox for BOTH docs so dimensions match
|
|
print("\n=== NORMALIZED GLYPH OUTLINES (same crop box) ===")
|
|
boxes = glyph_boxes(para)
|
|
docp = pdfengine.PdfDocument.load_from_memory(prev_bytes, "")
|
|
first = None
|
|
results = {}
|
|
for ch in GLYPHS:
|
|
box = boxes[ch]
|
|
if not box:
|
|
continue
|
|
rgba_o, wo, ho = crop_with_box(doc, box)
|
|
rgba_p, wp, hp = crop_with_box(docp, box)
|
|
# Force same size: pad/truncate
|
|
h = min(ho, hp)
|
|
w = min(wo, wp)
|
|
def trim(rgba, W, H, tw, th):
|
|
out = bytearray(tw * th * 4)
|
|
for y in range(th):
|
|
out[y*tw*4:(y+1)*tw*4] = rgba[y*W*4:y*W*4 + tw*4]
|
|
return bytes(out)
|
|
to, tp = trim(rgba_o, wo, ho, w, h), trim(rgba_p, wp, hp, w, h)
|
|
same = to == tp
|
|
# pixel diff count
|
|
diff = sum(1 for a, b in zip(to, tp) if a != b)
|
|
chunk_png(OUT / f"norm_{ch}_orig.png", to, w, h)
|
|
chunk_png(OUT / f"norm_{ch}_prev.png", tp, w, h)
|
|
print(f" [{ch}] {w}x{h} identical={same} differing_bytes={diff} "
|
|
f"orig_font={box['font_name']} bbox_w={box['bbox_w']:.3f}")
|
|
results[ch] = {"identical": same, "diff_bytes": diff, "w": w, "h": h}
|
|
if not same and first is None:
|
|
first = ch
|
|
|
|
print("\n=== FIRST GLYPH OUTLINE DIFFERENCE ===")
|
|
print(first)
|
|
(OUT / "deep_report.json").write_text(json.dumps({
|
|
"api_font_sha": api_sha,
|
|
"emitted_programs": [
|
|
{"obj": n, "baseFont": bf, "sha": info.get("programSha"), "len": info.get("programLen"),
|
|
"same_as_api": info.get("programSha") == api_sha}
|
|
for n, bf, info in prev_programs
|
|
],
|
|
"glyphs": results,
|
|
"first_outline_diff_char": first,
|
|
}, indent=2), encoding="utf-8")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|