Files
pdf/tests/edits/forensic_real_deep.py
2026-07-31 10:50:37 +05:30

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()