import re, zlib, sys, io, argparse
from fontTools.ttLib import TTFont
def pdf_objects(pdf):
objs = {}
for m in re.finditer(rb'(\d+) 0 obj(.*?)endobj', pdf, re.DOTALL):
num, body = int(m.group(1)), m.group(2)
if b'stream' in body:
head, rest = body.split(b'stream', 1)
data = rest.lstrip(b'\r\n').rsplit(b'endstream', 1)[0]
if b'FlateDecode' in head:
try:
data = zlib.decompress(data.strip(b'\r\n'))
except Exception:
pass
objs[num] = (head, data)
else:
objs[num] = (body, None)
return objs
def parse_w_array(dict_bytes):
m = re.search(rb'/W\s*\[(.*)', dict_bytes, re.DOTALL)
if not m:
return {}
s = m.group(1)
depth = 1
out = []
for ch in s:
c = bytes([ch])
if c == b'[':
depth += 1
elif c == b']':
depth -= 1
if depth == 0:
break
out.append(ch)
inner = bytes(out).decode('latin1')
widths = {}
i = 0
toks = re.findall(r'\[|\]|-?\d+', inner)
idx = 0
while idx < len(toks):
t = toks[idx]
if t == '[' or t == ']':
idx += 1
continue
start = int(t)
if idx + 1 < len(toks) and toks[idx + 1] == '[':
idx += 2
code = start
while idx < len(toks) and toks[idx] != ']':
widths[code] = int(toks[idx])
code += 1
idx += 1
idx += 1 else:
c1, c2, w = start, int(toks[idx + 1]), int(toks[idx + 2])
for code in range(c1, c2 + 1):
widths[code] = w
idx += 3
return widths
def parse_tounicode(data):
out = {}
for m in re.finditer(rb'beginbfchar(.*?)endbfchar', data, re.DOTALL):
for mm in re.finditer(rb'<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>', m.group(1)):
code = int(mm.group(1), 16)
hexstr = mm.group(2).decode()
u = bytes.fromhex(hexstr).decode('utf-16-be', errors='replace')
out[code] = u
for m in re.finditer(rb'beginbfrange(.*?)endbfrange', data, re.DOTALL):
body = m.group(1)
for mm in re.finditer(rb'<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>', body):
lo, hi = int(mm.group(1), 16), int(mm.group(2), 16)
base = bytes.fromhex(mm.group(3).decode())
baseu = int.from_bytes(base, 'big')
nb = len(base)
for i, code in enumerate(range(lo, hi + 1)):
u = (baseu + i).to_bytes(nb, 'big').decode('utf-16-be', errors='replace')
out[code] = u
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument('pdf')
ap.add_argument('--expect', help='file with the exact text lines shown, one per Tj', default=None)
ap.add_argument('--source-font', default=None,
help='original face; supplies the cmap ground truth when the '
'embedded program is a bare CFF (which has no cmap)')
args = ap.parse_args()
pdf = open(args.pdf, 'rb').read()
objs = pdf_objects(pdf)
font_bytes = None
font_dict = None
tounicode = None
content = None
for num, (head, data) in objs.items():
if data and data[:4] in (b'OTTO', b'\x00\x01\x00\x00', b'true', b'ttcf'):
font_bytes = data
elif data and head and b'CIDFontType0C' in head and data[:2] == b'\x01\x00':
font_bytes = data
elif head and b'/W' in head and b'/DW' in head and b'Font' in head:
font_dict = head
elif data and (b'bfchar' in data or b'bfrange' in data):
tounicode = parse_tounicode(data)
elif data and (b' Tj' in data or b' TJ' in data):
content = data
assert font_bytes is not None, 'no embedded font program found'
assert font_dict is not None, 'no CID font dict with /W found'
assert content is not None, 'no content stream found'
assert tounicode is not None, 'no ToUnicode CMap found'
is_sfnt = font_bytes[:4] in (b'OTTO', b'\x00\x01\x00\x00', b'true', b'ttcf')
if is_sfnt:
f = TTFont(io.BytesIO(font_bytes))
upm = f['head'].unitsPerEm
order = f.getGlyphOrder()
num_glyphs = f['maxp'].numGlyphs
hmtx = f['hmtx']
td = None
if 'CFF ' in f:
cff = f['CFF '].cff
td = cff[cff.fontNames[0]]
cmap = f.getBestCmap() if 'cmap' in f else {}
else:
from fontTools.cffLib import CFFFontSet
cffs = CFFFontSet()
cffs.decompile(io.BytesIO(font_bytes), None)
td = cffs[cffs.fontNames[0]]
order = list(td.charset)
num_glyphs = len(order)
fm = td.rawDict.get('FontMatrix', [0.001, 0, 0, 0.001, 0, 0])
upm = round(1 / fm[0]) if fm[0] else 1000
hmtx = None
cmap = {}
if args.source_font:
src = TTFont(args.source_font)
src_cmap = src.getBestCmap()
cmap = dict(cmap)
cmap.update(src_cmap)
if td is not None and hasattr(td, 'ROS'):
cid_to_gid = {}
for gid, name in enumerate(td.charset):
if name == '.notdef':
cid_to_gid[0] = gid if gid == 0 else cid_to_gid.get(0, 0)
continue
assert name.startswith('cid'), f'unexpected charset name {name}'
cid_to_gid[int(name[3:])] = gid
else:
cid_to_gid = None
if is_sfnt and cid_to_gid is not None \
and any(cid != gid for cid, gid in cid_to_gid.items()):
print('FAIL: OTTO-wrapped CID-keyed CFF with a NON-IDENTITY charset.')
print(' Acrobat/Preview resolve Identity-H codes through the charset;')
print(' PDFium (Chrome) and poppler use them directly as glyph indices.')
print(' No code assignment satisfies both. Embed the bare CFF table as')
print(' /FontFile3 /Subtype /CIDFontType0C, or rewrite the charset to identity.')
sys.exit(1)
def resolve(cid):
if cid_to_gid is None:
return cid if cid < num_glyphs else None
return cid_to_gid.get(cid)
def glyph_advance(gname):
if hmtx is not None:
return hmtx[gname][0]
from fontTools.pens.basePen import NullPen
cs = td.CharStrings[gname]
cs.draw(NullPen())
return cs.width
widths = parse_w_array(font_dict)
shown = [bytes.fromhex(m.group(1).decode()) for m in
re.finditer(rb'<([0-9A-Fa-f]+)>\s*T[jJ]', content)]
expect_lines = None
if args.expect:
expect_lines = [l for l in open(args.expect, encoding='utf-8').read().splitlines()]
assert len(expect_lines) == len(shown), \
f'{len(shown)} shown strings vs {len(expect_lines)} expected lines'
errors = []
for li, s in enumerate(shown):
codes = [int.from_bytes(s[i:i+2], 'big') for i in range(0, len(s), 2)]
line_chars = []
for ci, code in enumerate(codes):
gid = resolve(code)
if gid is None:
errors.append(f'line {li}: code {code} resolves to NO glyph (viewer shows .notdef)')
continue
u = tounicode.get(code)
if u is None:
errors.append(f'line {li}: code {code} missing from ToUnicode')
else:
line_chars.append(u)
cp = ord(u[0]) if len(u) >= 1 else None
if cp is not None and cp in cmap:
expected_glyph = cmap[cp]
if order[gid] != expected_glyph:
errors.append(
f'line {li}: code {code} -> gid {gid} ({order[gid]}) but char '
f'{u!r} maps to glyph {expected_glyph} — WRONG GLYPH in viewer')
w = widths.get(code)
if w is None:
continue adv = glyph_advance(order[gid])
expected_w = int(adv * 1000 / upm)
if abs(w - expected_w) > 1:
errors.append(
f'line {li}: code {code}: /W {w} != hmtx {expected_w} (gid {gid})')
if expect_lines is not None:
got = ''.join(line_chars)
want = expect_lines[li]
if got != want:
errors.append(f'line {li}: ToUnicode text {got!r} != expected {want!r}')
if errors:
print(f'FAIL: {len(errors)} error(s)')
for e in errors[:40]:
print(' ' + e)
sys.exit(1)
print(f'OK: {sum(len(s)//2 for s in shown)} codes across {len(shown)} strings verified '
f'(charset={"CID-keyed CFF" if cid_to_gid else "identity"}, '
f'{len(widths)} /W entries, {len(tounicode)} ToUnicode entries)')
if __name__ == '__main__':
main()