#!/usr/bin/env python3
"""Build a minimal PDF whose embedded font lies about its glyphs.
GID 0=.notdef, 3=space("space"), 12=comma("uni000C"), 14=period("uni000E"),
31=?("uni001F"), 33..58=A..Z outlines named after the StandardEncoding glyph
at that code point (A="exclam", I="parenright", T="four", W="seven", ...).
No ToUnicode CMap. Run pdftotext on the result to observe the damage.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import subprocess
import sys
DECOY_NAMES = {
33: "exclam", 34: "quotedbl", 35: "numbersign", 36: "dollar",
37: "percent", 38: "ampersand", 39: "quoteright", 40: "parenleft",
41: "parenright", 42: "asterisk", 43: "plus", 44: "comma",
45: "hyphen", 46: "period", 47: "slash", 48: "zero", 49: "one",
50: "two", 51: "three", 52: "four", 53: "five", 54: "six",
55: "seven", 56: "eight", 57: "nine", 58: "colon",
}
AGL = {name: chr(code) for code, name in DECOY_NAMES.items()}
AGL.update({"space": " ", "uni000C": "\x0c", "uni000E": "\x0e", "uni001F": "\x1f"})
SPECIAL_CIDS = {3: ("space", "space"), 12: ("uni000C", "comma"),
14: ("uni000E", "period"), 31: ("uni001F", "question")}
LINES = ["WHAT IS THE PURPOSE?", "READ, STUDY, ASK AGAIN."]
SOURCE_FONTS = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf",
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/liberation/LiberationSerif-Regular.ttf",
]
def cid_for(char: str) -> int:
if char == " ":
return 3
if char == ",":
return 12
if char == ".":
return 14
if char == "?":
return 31
if "A" <= char <= "Z":
return ord(char) - 32
raise ValueError(f"unsupported character: {char!r}")
def build_font(path: Path) -> None:
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.ttLib import TTFont
source = next((p for p in SOURCE_FONTS if Path(p).exists()), None)
if source is None:
sys.exit("no source outline font found in: " + ", ".join(SOURCE_FONTS))
src = TTFont(source)
src_glyphs = src.getGlyphSet()
layout: dict[int, tuple[str, str]] = {0: (".notdef", ".notdef")}
for cid, (name, drawn) in SPECIAL_CIDS.items():
layout[cid] = (name, {"space": "space", "comma": "comma",
"period": "period", "question": "question"}[drawn])
for code, name in DECOY_NAMES.items():
layout[code] = (name, chr(64 + code - 32))
order = [layout[gid][0] if gid in layout else f"pad{gid}"
for gid in range(max(layout) + 1)]
for gid in range(max(layout) + 1):
layout.setdefault(gid, (f"pad{gid}", ".notdef"))
fb = FontBuilder(2048, isTTF=True)
fb.setupGlyphOrder(order)
fb.setupCharacterMap({}) # no cmap at all: forces glyph-name fallback
glyphs, metrics = {}, {}
for name, drawn in layout.values():
pen = TTGlyphPen(src_glyphs)
src_glyphs[drawn].draw(pen)
glyphs[name] = pen.glyph()
metrics[name] = (getattr(src_glyphs[drawn], "width", 600), 0)
fb.setupGlyf(glyphs)
fb.setupHorizontalMetrics(metrics)
fb.setupHorizontalHeader(ascent=800, descent=-200)
fb.setupOS2(sTypoAscender=800, sTypoDescender=-200,
usWinAscent=800, usWinDescent=200)
fb.setupNameTable({"familyName": "LieSans", "styleName": "Regular",
"uniqueFontIdentifier": "LieSans Regular",
"fullName": "LieSans", "psName": "LieSans-Regular",
"version": "Version 0.1"})
fb.setupPost() # format 2.0: persists the decoy glyph names
fb.save(str(path))
def build_pdf(path: Path, font_path: Path) -> None:
font = font_path.read_bytes()
text_ops = ["BT", "/F1 16 Tf", "72 700 Td"]
for line in LINES:
hex_cids = "".join(f"{cid_for(c):04X}" for c in line)
text_ops += [f"<{hex_cids}> Tj", "0 -28 Td"]
text_ops.append("ET")
content = "\n".join(text_ops).encode("ascii")
objects: list[bytes] = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
b"/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
b"<< /Type /Font /Subtype /Type0 /BaseFont /LieSans-Regular "
b"/Encoding /Identity-H /DescendantFonts [6 0 R] >>",
b"<< /Length %d >>\nstream\n%s\nendstream" % (len(content), content),
b"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /LieSans-Regular "
b"/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> "
b"/FontDescriptor 7 0 R /DW 600 /CIDToGIDMap /Identity >>",
b"<< /Type /FontDescriptor /FontName /LieSans-Regular /Flags 4 "
b"/FontBBox [0 -200 1000 900] /ItalicAngle 0 /Ascent 800 /Descent -200 "
b"/CapHeight 700 /StemV 80 /FontFile2 8 0 R >>",
b"<< /Length %d /Length1 %d >>\nstream\n%s\nendstream"
% (len(font), len(font), font),
]
out = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
offsets = [0]
for number, body in enumerate(objects, start=1):
offsets.append(len(out))
out += b"%d 0 obj\n%s\nendobj\n" % (number, body)
xref_at = len(out)
out += b"xref\n0 %d\n" % (len(objects) + 1)
out += b"0000000000 65535 f \n"
for offset in offsets[1:]:
out += b"%010d 00000 n \n" % offset
out += (b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n"
% (len(objects) + 1, xref_at))
path.write_bytes(bytes(out))
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--out-dir", default=".")
args = ap.parse_args()
out_dir = Path(args.out_dir).expanduser().absolute()
out_dir.mkdir(parents=True, exist_ok=True)
font_path, pdf_path = out_dir / "lying_font.ttf", out_dir / "lying_font.pdf"
build_font(font_path)
build_pdf(pdf_path, font_path)
print(f"wrote {pdf_path} ({pdf_path.stat().st_size} bytes)")
actual = subprocess.run(["pdftotext", "-layout", str(pdf_path), "-"],
capture_output=True, text=True, check=True).stdout
print(f"pdftotext says:\n {actual!r}")
decoy_hits = sum(1 for name in DECOY_NAMES.values() if AGL[name] in actual)
control_hits = sum(1 for byte in ("\x0c", "\x1f") if byte in actual)
print(f"decoy glyph chars present: {decoy_hits}/26, control bytes: {control_hits}/2")
if decoy_hits >= 10 or control_hits:
print("CONFIRMED: lying glyph names produce garbled/control-char text layer")
else:
print("NOT CONFIRMED by this poppler build; still valid for pdfium/marker runs")
sys.exit(1)
if __name__ == "__main__":
main()
Text from no-ToUnicode CID fonts with lying glyph names bypasses flag_bad_blocks; garbled text and control bytes ship in markdown
What do you want to change?
When a PDF contains a Type0 / Identity-H subset font without a ToUnicode CMap, whose
embedded glyph names disagree with the drawn outlines (a glyph named
exclamactuallydraws small-cap
A), marker 2.0.0 keeps the embedded text and emits:7HAT IS THE PURPOSEforWHAT IS THE PURPOSE(glyph IDs one ASCII band down: GID 33 draws
Abut is namedexclam, GID 52 isfourand draws
T, GID 55 issevenand drawsW, …);U+000C, a periodas
U+000E,?asU+001F(invisible in every viewer, but they corrupt downstreamsearch/diff/tooling);
stray
s.Real-world example (2008 booklet, 68 pages; one embedded small-caps companion font
damaged ~15 passages):
Why?
builders/line.py::flag_bad_blocksdecides "garbled" through the prose-trained OCRerror model. Substitution-garbled text is out of its distribution: it is ~90% real
English words in the correct order, so it does not look like OCR noise and the block is
never routed to
surya.mapping, so there is no deterministic signal that the text layer is untrustworthy — even
though pdfium exposes it, and pdfminer-style extractors surface the same condition as
(cid:NN)output.U+000Cfromthe glyph fallback lands byte-for-byte in the final document.
Observed with marker 2.0.0 at commit
947d768, surya 0.22.1, Linux, LLM features off(
llm_request_count: 0for every page in the run metadata).How? (optional)
Tiered proposal:
without a usable ToUnicode mapping; in
flag_bad_blocks, flag any block containingsuch characters for re-OCR, bypassing the learned model. Cheap, precise, no training
data needed.
\t,\n,\r) in the markdown renderer. Unconditional bug fix.(
Reading Reflectively) came out asReading Selectively— presumably a re-OCRmisread replacing correct embedded text. If maintainers are interested we can try to
minimize a second reproducer once the primary fix lands.
Reproducer (synthetic, no third-party content): the script below builds a one-page
PDF from scratch whose A–Z outlines sit at GIDs 33–58 under StandardEncoding decoy names,
with comma/period/question-mark glyphs named
uni000C/uni000E/uni001F, and noToUnicode CMap. With poppler 26.08,
WHAT IS THE PURPOSE?extracts as7(!4 )3 4(% 0520/3%\x1fandREAD, STUDY, ASK AGAIN.as2%!$\x0c 345$9\x0c ....make_lying_font_pdf.py (run:
uv run --with fonttools ./make_lying_font_pdf.py --out-dir /tmp/lying-font)Suggested regression assertions once fixed:
text_extraction_method == "surya";[\x00-\x08\x0b\x0c\x0e-\x1f\x7f].