393 lines
16 KiB
Python
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()
|