diff --git a/CMakeLists.txt b/CMakeLists.txt
index 66a5a17f..034b1eb1 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -183,6 +183,7 @@ set(ODR_SOURCE_FILES
"src/odr/internal/pdf/pdf_afm.cpp"
"src/odr/internal/pdf/pdf_afm_data.cpp"
"src/odr/internal/pdf/pdf_cid.cpp"
+ "src/odr/internal/pdf/pdf_cid_data.cpp"
"src/odr/internal/pdf/pdf_cmap.cpp"
"src/odr/internal/pdf/pdf_cmap_parser.cpp"
"src/odr/internal/pdf/pdf_color.cpp"
diff --git a/docs/design/README.md b/docs/design/README.md
index 0de00021..2e2094a9 100644
--- a/docs/design/README.md
+++ b/docs/design/README.md
@@ -1,5 +1,11 @@
# Design
+## Diagrams
+
+- [PDF CJK text: the `code → CID → Unicode` path](pdf-cjk-code-cid-unicode.html)
+ — why composite (Type0) fonts need the legacy-CMap tables and how the
+ `pdf_cid_data` lookup works (open in a browser).
+
## Motivation
- non-intrusive read/write access to documents
diff --git a/docs/design/pdf-cjk-code-cid-unicode.html b/docs/design/pdf-cjk-code-cid-unicode.html
new file mode 100644
index 00000000..706fbe58
--- /dev/null
+++ b/docs/design/pdf-cjk-code-cid-unicode.html
@@ -0,0 +1,383 @@
+
+
+
+
+
+PDF CJK text — the code → CID → Unicode path
+
+
+
+
+
+
In-house PDF engine · composite fonts
+
Recovering CJK text: the code → CID → Unicode path
+
A Type0 font paints Japanese/Chinese/Korean text as a raw byte string with no Unicode in sight. When there's no /ToUnicode, these tables turn those bytes back into selectable text.
+
+
+
+
Why it's needed. CJK is a pre-Unicode mess: each locale had several competing byte encodings (Shift-JIS, EUC, GBK, Big5, EUC-KR…). A PDF font names one of them in its /Encoding and paints bytes in it. Those bytes aren't text and aren't Unicode — they index Adobe's unified glyph numbering.
+
The CID (Character ID) is the pivot. The font uses it to pick the glyph outline to draw; we map it onward to Unicode to recover the text. Getting there is a chain of three lookups.
+
+
+
+
the path Four stages, one worked example
+
Font /Encoding = 90ms-RKSJ-H (Shift-JIS, collection Adobe-Japan1), painting the two bytes 92 86.
+
+
+
+ bytes
+ 92 86
+ A raw string from a Tj/TJ show.
+
+
+ code
+ 0x9286
+ First byte 0x92 is a 2-byte lead → one 2-byte code.
+
+
+ CID
+ 2980
+ An index into the Adobe-Japan1 glyph collection.
+
+
+ unicode
+ 中 U+4E2D
+ The text we emit into the selectable layer.
+
+
+
+
+
+
splitby codespace
+
code → CIDinterned ranges
+
CID → Unicodecollection bitmap
+
+
+
◆The same CID 2980 also drives rendering — it selects the glyph outline in the embedded font program. That's why display was never broken even before this landed: only selectability was missing.
+
+
+
+
the tables What's stored, and how each step reads it
+
Generated into pdf_cid_data.{hpp,cpp} by generate_cid_data.py — ~0.58 MB, no compression, directly indexable. The path splits cleanly into two halves.
+
+
+
+
+
code → CID — many CMaps, one per encoding
+
+
+
predefined_cmaps[130] by name
+
One record per legacy encoding (90ms-RKSJ-H…). Binary-searched by name; points at its codespace, its range-index slice, and its owning collection.
Step 1 — split. Declares code widths. Matched on the first byte: 0x92 lands in a 2-byte lead range, so the code is 0x9286. (A byte like 0xA1 would be a 1-byte code — mixed widths stay aligned.)
Step 2 — code → CID. Each CMap owns a slice of uint16 indices (sorted by width, code_low) into a shared pool of ranges. Binary-search finds the range containing 0x9286 → CID 2980. Interning dedups 73% of ranges (-H/-V pairs, shared families).
Adobe-Japan1 / -GB1 / -CNS1 / -Korea1 / -KR. Every legacy CMap in a family targets the same CID space, so all share one dense CID → Unicode table. Chosen by the CMap's collection, or (for Identity-H) by /CIDSystemInfo.
Step 3 — CID → Unicode. The bitmap says whether the CID maps at all; rank(cid) — words-before + popcount-within — gives its slot in the dense values array. The CID space is 90%+ full, so a bitmap beats storing keys.
Values are uint16 to stay small, but ~0.86% of code points are > U+FFFF. Those store a 0xFFFF sentinel in values and their real code point here, found by binary search on the value slot.
The rank trick, concretely. To read values for CID 2980 without storing 30 000 keys, count how many CIDs before it are present — that count is the array index.
The CID → Unicode half is always the same; what differs is who supplies the CID and the collection.
+
+
+
Named legacy CMap
+ /Encoding = 90ms-RKSJ-H
+
The CMap is self-contained: it splits the bytes, maps code → CID through its own ranges, and names its collection. This is the full 4-stage path above.
+ translate_predefined_cmap(name, codes)
+
+
+
Identity-H / embedded stream
+ /Encoding = Identity-H
+
Here code == CID already (or an embedded CMap stream yields it). Only CID → Unicode remains — and the collection comes from the descendant CIDFont's /CIDSystemInfo.
+ cid_to_unicode(registry, ordering, cid)
+
+
+
◆Both are tried inside Font::to_unicode, after /ToUnicode (still the common case) and before the embedded-font reverse map. A named CMap we ship no tables for is not misread as identity CIDs — it just falls through.