#!/usr/bin/env python3 """S12/S13 PDF 文本层解码转写(benchmark_v2 附录 B 口径 3 / R-G5)。 对象扫描 → FlateDecode 解压 → /ToUnicode CMap 解析(beginbfchar/beginbfrange) → 逐 Page 取 /Resources /Font 名到 CMap 的映射,解其 /Contents 内容流, 把 Tj / [...] TJ 映射回 Unicode → 输出转写文本。 用法: pdf_transcribe.py # 全文转写到 stdout pdf_transcribe.py # 逐条报告空白不敏感命中数 """ import re, sys, zlib def objects(data): out = {} for m in re.finditer(rb'(\d+)\s+(\d+)\s+obj(.*?)endobj', data, re.S): body = m.group(3) sm = re.search(rb'stream\r?\n(.*?)endstream', body, re.S) stream = sm.group(1) if sm else b'' out[int(m.group(1))] = {'raw': body[:sm.start()] if sm else body, 'stream': stream} return out def inflate(stream): s = stream if s.endswith(b'\r\n'): s = s[:-2] elif s.endswith(('\n'.encode(), b'\r')): s = s[:-1] try: return zlib.decompress(s) except Exception: try: return zlib.decompressobj().decompress(s) except Exception: return b'' def parse_cmap(data): mp = {} for m in re.finditer(rb'beginbfchar(.*?)endbfchar', data, re.S): for pm in re.finditer(rb'<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>', m.group(1)): mp[int(pm.group(1), 16)] = bytes.fromhex(pm.group(2).decode()).decode('utf-16-be', 'replace') for m in re.finditer(rb'beginbfrange(.*?)endbfrange', data, re.S): for pm in re.finditer(rb'<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>', m.group(1)): lo, hi, base = int(pm.group(1), 16), int(pm.group(2), 16), pm.group(3).decode() half = len(base) // 2 for i in range(hi - lo + 1): mp[lo + i] = bytes.fromhex('%0*x' % (half * 2, int(base, 16) + i)).decode('utf-16-be', 'replace') return mp def fonts_map(objs): ref2cmap = {} for num, o in objs.items(): if b'/ToUnicode' in o['raw']: tm = re.search(rb'/ToUnicode\s+(\d+)\s+0\s+R', o['raw']) if tm and int(tm.group(1)) in objs: ref2cmap[num] = parse_cmap(inflate(objs[int(tm.group(1))]['stream'])) return ref2cmap def transcribe(path): objs = objects(objects.__self__ if False else load(path)) ref2cmap = fonts_map(objs) out = [] for num, o in objs.items(): raw = o['raw'] if b'/Type' in raw and b'/Page' not in raw: continue if b'/Contents' not in raw: continue # 字体名 → CMap(Resources 可能在 Page 自身或其继承的 Resources 对象里) cur = {} res = raw rm = re.search(rb'/Resources\s+(\d+)\s+0\s+R', raw) if rm and int(rm.group(1)) in objs: res = objs[int(rm.group(1))]['raw'] for fm in re.finditer(rb'/(\w+)\s+(\d+)\s+0\s+R', res.split(b'/Font')[-1] if b'/Font' in res else res): ref = int(fm.group(2)) if ref in ref2cmap: cur[fm.group(1).decode()] = ref2cmap[ref] if not cur: continue cm = re.search(rb'/Contents\s+(\d+)\s+0\s+R', raw) if not (cm and int(cm.group(1)) in objs): continue content = inflate(objs[int(cm.group(1))]['stream']) if not content: continue page, active = [], None for tm in re.finditer(rb'/(\w+)\s+[\d.]+\s+Tf|<([0-9A-Fa-f]+)>\s*Tj|\[((?:[^\[\]]|\\.)*)\]\s*TJ|(T\*|Td|TD|TJ)', content, re.S): if tm.group(1): active = cur.get(tm.group(1).decode()) elif tm.group(2) and active: page.append(active.get(int(tm.group(2), 16), '')) elif tm.group(3) is not None and active: for hm in re.finditer(rb'<([0-9A-Fa-f]+)>', tm.group(3)): page.append(active.get(int(hm.group(1), 16), '')) elif tm.group(4) in (b'T*', b'Td', b'TD'): page.append('\n') if page: out.append(''.join(page)) return '\n'.join(out) def load(path): return open(path, 'rb').read() def norm(s): return re.sub(r'\s+', '', s) if __name__ == '__main__': txt = transcribe(sys.argv[1]) if len(sys.argv) == 2: sys.stdout.write(txt) sys.exit(0) nt = norm(txt) print(f'# {sys.argv[1]}: 转写 {len(txt)} 字符(空白不敏感 {len(nt)})') for q in sys.argv[2:]: print(f' {nt.count(norm(q))}\t{q}')