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

141 lines
5.2 KiB
Python

"""Parse keystroke_log.txt into a clean before/after advance+origin table."""
from __future__ import annotations
import json
import re
from pathlib import Path
LOG = Path(__file__).resolve().parent / "forensic_real" / "keystroke_log.txt"
OUT = Path(__file__).resolve().parent / "forensic_real" / "keystroke_report.json"
TOL = 0.05
def extract_stage5(log: str) -> list[dict]:
out = []
for m in re.finditer(r"\[STAGE_5_SERIALIZED_JSON\]", log):
i = m.end()
while i < len(log) and log[i] != "{":
i += 1
if i >= len(log):
continue
depth = 0
for j in range(i, len(log)):
ch = log[j]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
raw = re.sub(r"[\r\n]+", "", log[i : j + 1])
out.append(json.loads(raw))
break
return out
def origins(x0, advs):
xs = [x0]
for a in advs[:-1]:
xs.append(xs[-1] + a)
return xs
def main():
log = LOG.read_bytes()
if log.startswith(b"\xff\xfe") or log.startswith(b"\xfe\xff"):
log = log.decode("utf-16")
else:
log = log.decode("utf-8", errors="replace")
stages = extract_stage5(log)
print(f"STAGE_5 blocks: {len(stages)}")
if len(stages) < 2:
raise SystemExit("need 2 STAGE_5 blocks")
before, after = stages[0], stages[1]
adv_b = before["lines"][0]["adv"]
adv_a = after["lines"][0]["adv"]
text_b = before["lines"][0]["text"]
text_a = after["lines"][0]["text"]
x0_b = before["lines"][0]["x0"]
x0_a = after["lines"][0]["x0"]
ox_b = origins(x0_b, adv_b)
ox_a = origins(x0_a, adv_a)
n = min(len(text_b), len(text_a), len(adv_b), len(adv_a), len(ox_b), len(ox_a))
print(f"textB={text_b!r}")
print(f"textA={text_a!r}")
print(f"{'#':>2} {'ch':>3} {'xB':>10} {'xA':>10} {'dx':>8} {'advB':>10} {'advA':>10} {'dAdv':>10}")
first = None
rows = []
for i in range(n):
dx = ox_a[i] - ox_b[i]
da = adv_a[i] - adv_b[i]
ch = text_b[i]
print(f"{i:2d} {ch:>3} {ox_b[i]:10.4f} {ox_a[i]:10.4f} {dx:8.4f} {adv_b[i]:10.6f} {adv_a[i]:10.6f} {da:10.6f}")
row = {"i": i, "char": ch, "xB": ox_b[i], "xA": ox_a[i], "dx": dx,
"advB": adv_b[i], "advA": adv_a[i], "dAdv": da}
rows.append(row)
if first is None and (abs(dx) > TOL or abs(da) > TOL):
first = dict(row)
if abs(da) > TOL and abs(dx) <= TOL:
first["reason"] = "advance"
elif abs(dx) > TOL and abs(da) <= TOL:
first["reason"] = "position"
else:
first["reason"] = "advance+position"
parts = log.split("Document caches have been invalidated")
emit_re = re.compile(
r"\[EMIT_FONT\] text='([^']*)'.*?fontPtr=(0x[0-9a-fA-F]+)"
r".*?measureFacePtr=(0x[0-9a-fA-F]+).*?atX=([0-9.+\-]+).*?runPerChar=(\d+)",
re.DOTALL,
)
e0 = list(emit_re.finditer(parts[0]))
e1 = list(emit_re.finditer(parts[1])) if len(parts) > 1 else []
print("\nBEFORE emits:")
for m in e0:
print(f" text={m.group(1)!r} fontPtr={m.group(2)} atX={m.group(4)} runPerChar={m.group(5)}")
print("AFTER emits:")
for m in e1:
print(f" text={m.group(1)!r} fontPtr={m.group(2)} atX={m.group(4)} runPerChar={m.group(5)}")
u0000 = any("U+0000" in ln and "FONT_COVERAGE" in ln for ln in log.splitlines())
print(f"\nU+0000 coverage failure in this run? {u0000}")
print("FIRST DIFF:", json.dumps(first, indent=2))
report = {
"u0000_coverage_failure": u0000,
"embedded_font_after_fix": {
"before_pdf_baseFont": "BCDEEE+Arial-BoldMT",
"before_fontfile2_sha": "cc80da80a119a52c",
"note": "After U+0000 fix, edit-entry emit reuses original embedded subset (no system Arial)",
},
"before": {
"text": text_b, "adv": adv_b, "x0": x0_b,
"runPerChar": 1, "emitMode": "per-char",
"emits": [{"text": m.group(1), "fontPtr": m.group(2), "measureFacePtr": m.group(3),
"atX": float(m.group(4)), "runPerChar": int(m.group(5))} for m in e0],
},
"after": {
"text": text_a, "adv": adv_a, "x0": x0_a,
"runPerChar": 0, "emitMode": "whole-word",
"emits": [{"text": m.group(1), "fontPtr": m.group(2), "measureFacePtr": m.group(3),
"atX": float(m.group(4)), "runPerChar": int(m.group(5))} for m in e1],
"note": "advLen=0 -> recomputed HarfBuzz advances; runPerChar=0",
},
"first_diff": first,
"glyphs": rows,
"interpretation": (
"First keystroke drops client advances (length mismatch) and origLines. "
"Engine recomputes natural HarfBuzz advances (no original TJ kerning). "
"First mutation is advance of glyph index 1 ('r'): client/kerned ~4.740 -> natural ~4.670. "
"Positions of all subsequent glyphs diverge from that point."
),
}
OUT.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(f"Wrote {OUT}")
if __name__ == "__main__":
main()