Fix CID Garbled Characters in PDF Text
Text extraction returns (cid:39)(cid:68)(cid:87)(cid:72) instead of Date, or a string of plausible-looking but meaningless letters such as 'DWH — while the page renders perfectly in every viewer. pdfplumber and pdfminer print the (cid:NN) form; PyMuPDF and pypdf tend to print replacement characters or shifted letters for the same file.
>>> import pdfplumber
>>> pdfplumber.open("in/supplier-invoice.pdf").pages[0].extract_text()[:48]
'(cid:44)(cid:81)(cid:89)(cid:82)(cid:76)(cid:70)(cid:72)'
Root Cause
Inside a PDF, text is a sequence of character codes, and each font maps codes to glyph shapes for drawing. Rendering only needs that code-to-glyph mapping, which every embedded font has. Extraction needs a different mapping — code to Unicode — which the PDF supplies through a /ToUnicode CMap on the font or through a standard encoding the extractor recognises. Subsetting tools, some PDF printers and many report generators embed fonts with custom encodings and no ToUnicode map, often numbering codes by glyph index. The viewer draws the right shapes; the extractor has codes with no meaning, and prints them as cid numbers or guesses a standard encoding and produces shifted letters. The characters were never stored as text in a recoverable form — so the fix is to find which fonts are affected, and then either recover the mapping or read the glyphs visually.
Minimal Diagnostic
List each font on the page, whether it carries a ToUnicode map, and how much of its text extracts as garbage. The font without the map is the culprit.
# pip install pymupdf
from pathlib import Path
import pymupdf
SOURCE = Path("in/supplier-invoice.pdf")
BAD = "\N{REPLACEMENT CHARACTER}"
def font_report(pdf_path: Path, page_index: int = 0) -> None:
try:
doc = pymupdf.open(pdf_path)
except (pymupdf.FileDataError, RuntimeError) as exc:
raise SystemExit(f"cannot open {pdf_path}: {exc}")
with doc:
page = doc[page_index]
spans_by_font: dict[str, list[str]] = {}
for block in page.get_text("dict")["blocks"]:
for line in block.get("lines", []):
for span in line["spans"]:
spans_by_font.setdefault(span["font"], []).append(span["text"])
for xref, ext, ftype, basefont, name, encoding, *_ in page.get_fonts(full=True):
key_type, key_val = doc.xref_get_key(xref, "ToUnicode")
short = basefont.split("+")[-1] # drop subset prefix 'ABCDEF+'
sample = "".join(spans_by_font.get(short, spans_by_font.get(basefont, [])))[:40]
bad = sample.count(BAD) + sample.count("(cid:")
print(f"{basefont:<32} type={ftype:<9} enc={encoding or '-':<16} "
f"ToUnicode={'yes' if key_type != 'null' else 'NO '} sample={sample!r} bad={bad}")
if __name__ == "__main__":
font_report(SOURCE)
BCDEEE+Calibri type=TrueType enc=WinAnsiEncoding ToUnicode=yes sample='Invoice number 22817' bad=0
BCDFEE+ArialNarrow-Bold type=Type0 enc=Identity-H ToUnicode=NO sample="'DWH" bad=0
Identity-H encoding with no ToUnicode is the classic signature: codes are glyph IDs, and nothing in the file says which glyph is which letter. A Type3 font type is a harder variant — glyphs are drawn as small pictures, and no mapping exists to recover at all.
Fix 1: Try an Extractor with Better Encoding Recovery
Libraries recover differently. PyMuPDF (built on MuPDF) reads more embedded CFF and TrueType cmap tables than pdfminer does, so it often recovers text that pdfplumber prints as cid codes. Try it before anything heavier:
# pip install pymupdf pdfplumber
from pathlib import Path
import pdfplumber
import pymupdf
SOURCE = Path("in/supplier-invoice.pdf")
BAD = "\N{REPLACEMENT CHARACTER}"
def garbage_share(text: str) -> float:
if not text:
return 1.0
return (text.count("(cid:") * 6 + text.count(BAD)) / len(text)
def best_extraction(pdf_path: Path, page_index: int) -> tuple[str, str]:
candidates = {}
try:
with pdfplumber.open(pdf_path) as pdf:
candidates["pdfplumber"] = pdf.pages[page_index].extract_text() or ""
except Exception as exc:
print(f"pdfplumber failed: {exc}")
try:
with pymupdf.open(pdf_path) as doc:
candidates["pymupdf"] = doc[page_index].get_text(sort=True) # changed: second engine
except Exception as exc:
print(f"pymupdf failed: {exc}")
if not candidates:
raise RuntimeError("no extractor could open the file")
name = min(candidates, key=lambda k: garbage_share(candidates[k])) # changed: pick the cleanest
return name, candidates[name]
if __name__ == "__main__":
engine, text = best_extraction(SOURCE, 0)
print(engine, repr(text[:80]))
Garbage share only measures cid tokens and replacement characters. Shifted-letter output ('DWH for Date) looks clean to it, so combine it with the common-word ratio from Extracting Text and Metadata from PDFs before trusting the winner.
Fix 2: Remap a Consistent Code Offset
Many subset fonts number glyphs in alphabetical order with a constant offset from ASCII, so every letter is shifted by the same amount: 'DWH is Date shifted down by 29. When one font on one generator produces this pattern, a translation table fixes it exactly. Derive the offset from a known word rather than guessing:
# pip install pymupdf
from pathlib import Path
import pymupdf
KNOWN_WORD = "Invoice" # a word you know appears in the broken font on this page
def find_offset(garbled: str, known: str, max_shift: int = 64) -> int | None:
for shift in range(-max_shift, max_shift + 1):
try:
candidate = "".join(chr(ord(c) + shift) for c in garbled)
except ValueError:
continue
if known in candidate:
return shift
return None
def fix_font_spans(page: pymupdf.Page, font_name: str, shift: int) -> str:
out = []
for block in page.get_text("dict", sort=True)["blocks"]:
for line in block.get("lines", []):
parts = []
for span in line["spans"]:
text = span["text"]
if span["font"].endswith(font_name):
text = "".join(chr(ord(c) + shift) if 32 <= ord(c) + shift < 0x250 else c
for c in text) # changed: shift only the broken font
parts.append(text)
out.append("".join(parts))
return "\n".join(out)
if __name__ == "__main__":
with pymupdf.open(Path("in/supplier-invoice.pdf")) as doc:
page = doc[0]
broken = "".join(s["text"] for b in page.get_text("dict")["blocks"]
for l in b.get("lines", []) for s in l["spans"]
if s["font"].endswith("ArialNarrow-Bold"))
shift = find_offset(broken, KNOWN_WORD)
if shift is None:
raise SystemExit("no constant offset — use OCR (Fix 3)")
print(f"offset {shift}")
print(fix_font_spans(page, "ArialNarrow-Bold", shift))
Apply a remap only per font and only after confirming the offset on a known word — other fonts on the same page are usually fine, and shifting them corrupts good text. The offset is a property of how one generator subsets one font; record it keyed by the producer string from the metadata and the font's base name, and re-verify when either changes. Offsets are not universal: fonts subsetted in glyph order rather than alphabet order produce scrambles no single shift can fix.
Fix 3: OCR Only the Broken Pages
When the mapping cannot be recovered — scrambled glyph order, Type3 fonts, outlines — read the glyphs visually. OCR is slower and introduces its own errors, so restrict it to pages whose extracted text fails quality checks, and keep native extraction for the rest.
# pip install pymupdf pytesseract pillow
import io
from pathlib import Path
import pymupdf
import pytesseract
from PIL import Image
BAD = "\N{REPLACEMENT CHARACTER}"
COMMON = {"the", "and", "of", "to", "for", "date", "total", "invoice", "number", "due"}
def looks_broken(text: str) -> bool:
words = [w.lower().strip(".,:;") for w in text.split()]
common = sum(w in COMMON for w in words) / (len(words) or 1)
return "(cid:" in text or BAD in text or (len(words) > 30 and common < 0.02)
def page_text_with_fallback(pdf_path: Path, dpi: int = 300) -> list[tuple[str, str]]:
results = []
with pymupdf.open(pdf_path) as doc:
for page in doc:
native = page.get_text(sort=True)
if not looks_broken(native):
results.append(("native", native))
continue
pix = page.get_pixmap(dpi=dpi) # changed: render broken page only
image = Image.open(io.BytesIO(pix.tobytes("png")))
try:
results.append(("ocr", pytesseract.image_to_string(image)))
except pytesseract.TesseractNotFoundError:
raise SystemExit("tesseract is not installed")
return results
if __name__ == "__main__":
for i, (source, text) in enumerate(page_text_with_fallback(Path("in/supplier-invoice.pdf")), 1):
print(f"page {i}: {source}, {len(text)} chars")
For whole-file conversion, ocrmypdf --force-ocr rasterises and re-OCRs every page, replacing the broken text layer — see make scanned PDFs searchable with OCRmyPDF. The per-page approach above is better inside extraction pipelines because it keeps exact native text wherever it exists.
Variant: Ligatures and Missing Letters
A milder form of the same problem drops or replaces specific letter pairs: finance extracts as nance, offer as o er. The font maps the fi and ff ligature glyphs to nothing or to a single private-use code. Normalise known ligatures after extraction and treat private-use characters as a quality signal:
# pip install pymupdf
import unicodedata
def normalise_ligatures(text: str) -> str:
text = unicodedata.normalize("NFKC", text) # 'fi' -> 'fi', 'ff' -> 'ff'
private = sum(0xE000 <= ord(c) <= 0xF8FF for c in text)
if private:
print(f"warning: {private} private-use character(s) remain")
return text
NFKC also folds full-width digits and some symbols; if exact characters matter — for instance in product codes — apply it only to prose fields. PyMuPDF preserves ligatures as single characters unless TEXT_PRESERVE_LIGATURES is turned off in the flags; the NFKC pass handles both cases.
Verification
Score each page's final text and fail the document if any page still looks broken. Store which method produced each page, so later quality issues can be traced to OCR or remapping rather than to the source document.
# pip install pymupdf pytesseract pillow
from pathlib import Path
def verify_pages(results: list[tuple[str, str]], min_chars: int = 40) -> None:
problems = []
for i, (source, text) in enumerate(results, 1):
if len(text.strip()) < min_chars:
problems.append(f"page {i} ({source}): almost no text")
elif "(cid:" in text or "\N{REPLACEMENT CHARACTER}" in text:
problems.append(f"page {i} ({source}): undecoded characters remain")
assert not problems, "; ".join(problems)
counts = {s: sum(1 for src, _ in results if src == s) for s in {src for src, _ in results}}
print(f"all {len(results)} pages decoded: {counts}")
if __name__ == "__main__":
from fix_cid import page_text_with_fallback # the Fix 3 function saved as fix_cid.py
verify_pages(page_text_with_fallback(Path("in/supplier-invoice.pdf")))
Spot-check a remapped or OCR'd page against the rendered page for numbers specifically. A shift remap that is off by one produces confident-looking wrong digits, and OCR confuses 1, 7 and l in narrow fonts — exactly the characters that matter on an invoice.
FAQ
Why do some viewers let me copy the text correctly when Python cannot? A few viewers ship heuristics or glyph-name lookups for common subset fonts. When copy-paste works in one viewer, try PyMuPDF first; it shares more of that recovery logic than pdfminer-based tools.
Can I fix the PDF itself so every tool extracts it? In principle, by adding a ToUnicode CMap to the font object. In practice it is fragile and specific to one font subset. Re-exporting from the source application with "embed full fonts" or "tagged PDF" enabled is far more reliable if you can reach the producer.
Does this affect table extraction too?
Yes. Camelot, tabula and pdfplumber all read the same codes, so broken cells show cid tokens. Fix the text layer (usually with OCR) before table extraction, as in how to extract tables from scanned PDFs.
Is redaction safe on these files? Search-based redaction cannot find values it cannot decode. Treat pages with broken fonts like scans — see redact scanned PDF regions with OCR.
Related
- Extracting Text and Metadata from PDFs with Python — profiling files and measuring text quality
- Fix PDF Text Extraction Missing Spaces — correctly decoded characters with wrong word breaks
- Fix pdfplumber Returns Empty Text — when there are no characters at all
- Scanning and OCR Processing with Python — Tesseract configuration for the fallback path