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

393 lines
16 KiB
Python

"""Forensic on REAL failing PDF: Professional Experience / Arial-BoldMT_TrueType_32.
Compares:
- original embedded font resource vs emitted font after edit-entry reflow
- font dictionaries / FontFile streams
- individual glyph crops for P,r,o,f from original vs preview
"""
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]
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
sys.path.insert(0, str(ROOT / "gateway"))
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"
TARGET = "Professional Experience"
FID = "Arial-BoldMT_TrueType_32"
GLYPHS = list("Prof") # P, r, o, f
def sha(b: bytes) -> str:
return hashlib.sha256(b).hexdigest()
def inflate_streams(pdf_bytes: bytes) -> list[tuple[dict, bytes]]:
"""Return list of (dict_header_textish, raw_or_inflated_stream)."""
out = []
parts = re.split(rb"(\d+)\s+0\s+obj", pdf_bytes)
i = 1
while i + 1 < len(parts):
body = parts[i + 1].split(b"endobj", 1)[0]
i += 2
if b"stream" not in body:
continue
hdr = body.split(b"stream")[0]
m = re.search(rb"stream\r?\n(.*?)\r?\nendstream", body, re.DOTALL)
if not m:
continue
raw = m.group(1)
data = raw
if b"/FlateDecode" in hdr:
try:
data = zlib.decompress(raw)
except Exception:
pass
out.append((hdr.decode("latin-1", "replace"), data))
return out
def dump_font_dicts(pdf_bytes: bytes) -> list[dict]:
fonts = []
# Compact and pretty forms
for m in re.finditer(
rb"<<[^>]*?/Type\s*/Font[^>]*?>>",
pdf_bytes,
re.DOTALL,
):
chunk = m.group(0).decode("latin-1", "replace")
fonts.append({"raw": chunk[:500]})
# Also objects containing BaseFont
parts = re.split(rb"(\d+)\s+0\s+obj", pdf_bytes)
i = 1
while i + 1 < len(parts):
num = parts[i].decode()
body = parts[i + 1].split(b"endobj", 1)[0]
i += 2
if b"/Font" not in body or b"/BaseFont" not in body:
continue
hdr = body.split(b"stream")[0] if b"stream" in body else body
text = hdr.decode("latin-1", "replace")
bf = re.search(r"/BaseFont\s*/([^\s/>]+)", text)
st = re.search(r"/Subtype\s*/(\w+)", text)
enc = re.search(r"/Encoding\s*/(\w+)", text)
ff = re.search(r"/FontFile[23]?\s+(\d+)\s+0\s+R", text)
fonts.append({
"obj": num,
"baseFont": bf.group(1) if bf else None,
"subtype": st.group(1) if st else None,
"encoding": enc.group(1) if enc else None,
"fontFileRef": ff.group(1) if ff else None,
"hasFontDescriptor": "/FontDescriptor" in text,
"dictPreview": " ".join(text.split())[:400],
})
return fonts
def extract_fontfile_streams(pdf_bytes: bytes) -> list[dict]:
"""Find FontFile/FontFile2/FontFile3 streams and hash their bytes."""
results = []
parts = re.split(rb"(\d+)\s+0\s+obj", pdf_bytes)
i = 1
while i + 1 < len(parts):
num = parts[i].decode()
body = parts[i + 1].split(b"endobj", 1)[0]
i += 2
if b"stream" not in body:
continue
hdr = body.split(b"stream")[0].decode("latin-1", "replace")
# Heuristic: Length + (often referenced as font program). Tag by nearby refs from font dicts later.
m = re.search(rb"stream\r?\n(.*?)\r?\nendstream", body, re.DOTALL)
if not m:
continue
raw = m.group(1)
data = raw
if "/FlateDecode" in hdr:
try:
data = zlib.decompress(raw)
except Exception:
pass
# Detect SFNT
kind = "unknown"
if data[:4] in (b"\x00\x01\x00\x00", b"OTTO", b"true", b"typ1"):
kind = "sfnt"
elif data[:2] == b"\x80\x01" or b"eexec" in data[:200]:
kind = "type1"
elif data[:4] == b"wOF2" or data[:4] == b"wOFF":
kind = "woff"
if kind == "unknown" and len(data) < 100:
continue
# Keep larger binary streams that look like fonts
if kind == "unknown" and not (len(data) > 1000 and data[:4].isascii() is False):
# still keep if Length suggests font and not content stream operators
if b"BT" in data[:50] or b"q\n" in data[:20]:
continue
if len(data) < 2000:
continue
kind = "binary_blob"
results.append({
"obj": num,
"kind": kind,
"rawLen": len(raw),
"dataLen": len(data),
"sha256": sha(data),
"head": data[:16].hex(),
})
return results
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(f"Paragraph containing {TARGET!r} not found")
def glyph_boxes(para):
"""Map character -> list of glyph bboxes (PDF space) for first matching chars."""
wanted = {c: None for c in GLYPHS}
for ln in para.lines:
for r in ln.runs:
for g in r.glyphs:
ch = g.text
if ch in wanted and wanted[ch] is None:
wanted[ch] = {
"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 render_glyph_crop(doc, box, dpi=288, pad=1.5) -> tuple[bytes, int, int, str]:
"""Render a tight crop around a glyph bbox; return PNG bytes via region raw → simple PPM-less save as raw hash + PNG via page render tile if available."""
page = doc.get_page(0)
# page coords: y grows up. render_region_raw uses y_top from top of page.
ph = page.height
x0 = box["bbox_x"] - pad
y0 = box["bbox_y"] - pad
x1 = box["bbox_x"] + box["bbox_w"] + pad
y1 = box["bbox_y"] + box["bbox_h"] + pad
# Convert to top-origin band
y_top = ph - y1
height = y1 - y0
# Full-width region then crop in python
w, h, raw = page.render_region_raw(dpi, y_top, height)
scale = dpi / 72.0
# region is full page width; crop x range
left = max(0, int(x0 * scale))
right = min(w, int(x1 * scale) + 1)
top = 0
bottom = h
# Extract RGBA crop
crop_w = max(1, right - left)
crop_h = bottom - top
crop = bytearray(crop_w * crop_h * 4)
for row in range(crop_h):
src = ((top + row) * w + left) * 4
dst = row * crop_w * 4
crop[dst:dst + crop_w * 4] = raw[src:src + crop_w * 4]
return bytes(crop), crop_w, crop_h, sha(bytes(crop))
def save_rgba_png(path: Path, rgba: bytes, w: int, h: int):
"""Minimal PNG writer (RGBA)."""
import zlib as Z
def chunk(tag: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", Z.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)
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", Z.compress(raw, 9)) + chunk(b"IEND", b"")
path.write_bytes(png)
def main():
OUT.mkdir(parents=True, exist_ok=True)
assert PDF.exists(), PDF
print(f"[engine] {pdfengine.__file__}")
print(f"[pdf] {PDF}")
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
pi, para, text = find_para(doc)
print(f"[para] page={pi} text={text!r}")
# Font info from engine
fonts = doc.get_fonts(0, 0)
target_fonts = [f for f in fonts if FID in (f.internal_font_id or "") or f.font_name == "Arial-BoldMT"]
print("[FontInfo]")
for f in target_fonts:
print(f" name={f.font_name} id={f.internal_font_id} emb={f.is_embedded} type={f.type} "
f"subset={getattr(f, 'is_subset', None)} ascent={f.ascent} descent={f.descent}")
# Extract font program bytes via API
font_bytes = doc.get_font_data(FID)
recon_bytes = doc.get_reconstructed_font_data(FID)
print(f"[get_font_data] len={len(font_bytes)} sha={sha(font_bytes) if font_bytes else None}")
print(f"[get_reconstructed_font_data] len={len(recon_bytes)} sha={sha(recon_bytes) if recon_bytes else None}")
if font_bytes:
(OUT / "orig_font_program.bin").write_bytes(font_bytes)
if recon_bytes:
(OUT / "recon_font_program.bin").write_bytes(recon_bytes)
# Build edit-entry reflow (unchanged text)
layout = compute_layout(para)
print("[layout]", {
"columnLeft": layout["columnLeft"], "columnRight": layout["columnRight"],
"firstBaselineY": layout["firstBaselineY"], "leading": layout["leading"],
"seedRuns": [(r["text"], r["fid"], r["fontName"], r["size"], len(r.get("advances") or []))
for r in layout["seedRuns"]],
"objectIndices": layout["objectIndices"],
})
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), FID)
dom = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
flat = extract_flat_runs(layout["seedRuns"], dominant_fid, dom["size"], dom["color"])
data = build_reflow_data(layout, flat, layout["origLines"], "forensic-real-profexp")
op = {"version": "1.0", "operations": [{
"id": "entry", "type": "reflow_paragraph", "pageIndex": 0, "data": data,
}]}
(OUT / "reflow_payload.json").write_text(json.dumps(op, indent=2), encoding="utf-8")
print("\n=== APPLY EDIT-ENTRY REFLOW (unchanged text) ===")
doc2 = pdfengine.PdfDocument.load_from_file(str(PDF), "")
doc2.apply_edits(json.dumps(op))
preview_bytes = doc2.save_full()
(OUT / "preview_entry.pdf").write_bytes(preview_bytes)
orig_bytes = PDF.read_bytes()
(OUT / "original.pdf").write_bytes(orig_bytes)
# Font dictionaries
print("\n=== FONT DICTIONARIES ORIGINAL ===")
orig_fonts = dump_font_dicts(orig_bytes)
for f in orig_fonts:
if f.get("baseFont") and ("Arial" in (f.get("baseFont") or "") or "Bold" in (f.get("baseFont") or "")):
print(json.dumps(f, indent=2))
print("\n=== FONT DICTIONARIES PREVIEW ===")
prev_fonts = dump_font_dicts(preview_bytes)
for f in prev_fonts:
if f.get("baseFont") and ("Arial" in (f.get("baseFont") or "") or "Helv" in (f.get("baseFont") or "") or "Bold" in (f.get("baseFont") or "")):
print(json.dumps(f, indent=2))
print("\n=== FONTFILE / EMBEDDED PROGRAM HASHES ===")
orig_ff = extract_fontfile_streams(orig_bytes)
prev_ff = extract_fontfile_streams(preview_bytes)
print(f"original font-like streams: {len(orig_ff)}")
for x in orig_ff:
if x["kind"] in ("sfnt", "type1", "binary_blob") and x["dataLen"] > 5000:
print(" ORIG", x)
print(f"preview font-like streams: {len(prev_ff)}")
for x in prev_ff:
if x["kind"] in ("sfnt", "type1", "binary_blob") and x["dataLen"] > 1000:
print(" PREV", x)
orig_shas = {x["sha256"] for x in orig_ff if x["dataLen"] > 5000}
prev_shas = {x["sha256"] for x in prev_ff if x["dataLen"] > 1000}
shared = orig_shas & prev_shas
print(f"shared embedded program SHAs: {len(shared)}")
print(f"orig-only programs: {len(orig_shas - prev_shas)}")
print(f"prev-only programs: {len(prev_shas - orig_shas)}")
if font_bytes:
print(f"API font_bytes in orig streams? {sha(font_bytes) in orig_shas or any(sha(font_bytes)==x['sha256'] for x in orig_ff)}")
print(f"API font_bytes in prev streams? {any(sha(font_bytes)==x['sha256'] for x in prev_ff)}")
# Re-extract preview paragraph fonts
docp = pdfengine.PdfDocument.load_from_memory(preview_bytes, "")
_, para_p, text_p = find_para(docp)
print(f"\n[preview para] text={text_p!r}")
for ln in para_p.lines:
for r in ln.runs:
if r.text.strip():
print(f" run text={r.text!r} font={r.font_name} id={r.internal_font_id} "
f"emb={r.is_embedded} type={r.type} size={r.font_size} w={r.w}")
# Glyph crops P,r,o,f
print("\n=== GLYPH CROPS P/r/o/f ===")
boxes_o = glyph_boxes(para)
boxes_p = glyph_boxes(para_p)
report = {"glyphs": {}, "font": {
"api_font_sha": sha(font_bytes) if font_bytes else None,
"api_recon_sha": sha(recon_bytes) if recon_bytes else None,
"shared_programs": list(shared),
"orig_programs": [x for x in orig_ff if x["dataLen"] > 5000],
"prev_programs": [x for x in prev_ff if x["dataLen"] > 1000],
"orig_font_dicts": [f for f in orig_fonts if f.get("baseFont")],
"prev_font_dicts": [f for f in prev_fonts if f.get("baseFont")],
}}
first_diff = None
for ch in GLYPHS:
bo, bp = boxes_o.get(ch), boxes_p.get(ch)
print(f"\n[{ch}] orig_box={bo}")
print(f"[{ch}] prev_box={bp}")
if not bo or not bp:
report["glyphs"][ch] = {"error": "missing box"}
if first_diff is None:
first_diff = f"glyph {ch!r}: missing box orig={bo is not None} prev={bp is not None}"
continue
try:
rgba_o, wo, ho, sho = render_glyph_crop(doc, bo)
rgba_p, wp, hp, shp = render_glyph_crop(docp, bp)
save_rgba_png(OUT / f"glyph_{ch}_orig.png", rgba_o, wo, ho)
save_rgba_png(OUT / f"glyph_{ch}_prev.png", rgba_p, wp, hp)
same = sho == shp
print(f"[{ch}] crop orig={wo}x{ho} sha={sho[:16]} prev={wp}x{hp} sha={shp[:16]} SAME={same}")
# Also compare dimensions / bbox metrics
metric_diff = {
"bbox_w": (bo["bbox_w"], bp["bbox_w"]),
"bbox_h": (bo["bbox_h"], bp["bbox_h"]),
"font_size": (bo["font_size"], bp["font_size"]),
"font_name": (bo["font_name"], bp["font_name"]),
}
report["glyphs"][ch] = {
"orig_box": bo, "prev_box": bp,
"orig_sha": sho, "prev_sha": shp,
"pixel_identical": same,
"metrics": metric_diff,
}
if not same and first_diff is None:
first_diff = f"glyph outline pixels differ for {ch!r}"
if bo["font_name"] != bp["font_name"] and first_diff is None:
first_diff = f"font_name differs at glyph {ch!r}: {bo['font_name']} -> {bp['font_name']}"
except Exception as e:
print(f"[{ch}] render failed: {e}")
report["glyphs"][ch] = {"error": str(e)}
# Full-page / paragraph region hash
print("\n=== REGION HASH (paragraph band) ===")
y_top = doc.get_page(0).height - (layout["firstBaselineY"] + layout["leading"])
hgt = layout["leading"] * 2.5
for label, d in [("ORIG", doc), ("PREV", docp)]:
w, h, raw = d.get_page(0).render_region_raw(144, max(0, y_top), hgt)
print(f" {label} region {w}x{h} sha={sha(raw)[:24]}")
report[f"region_{label}"] = {"w": w, "h": h, "sha": sha(raw)}
report["first_diff"] = first_diff
(OUT / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
print("\n=== FIRST DIFFERENCE ===")
print(first_diff)
print(f"Wrote artifacts to {OUT}")
if __name__ == "__main__":
main()