pdf_transcribe.py 4.46 KB
#!/usr/bin/env python3
"""S12/S13 PDF 文本层解码转写(benchmark_v2 附录 B 口径 3 / R-G5)。

对象扫描 → FlateDecode 解压 → /ToUnicode CMap 解析(beginbfchar/beginbfrange)
→ 逐 Page 取 /Resources /Font 名到 CMap 的映射,解其 /Contents 内容流,
把 <CID> Tj / [...] TJ 映射回 Unicode → 输出转写文本。

用法: pdf_transcribe.py <pdf>            # 全文转写到 stdout
      pdf_transcribe.py <pdf> <q1> <q2>  # 逐条报告空白不敏感命中数
"""
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}')