跳到內容
getnextpdf.com

為什麼 PDF 裡的文字其實不是文字

當你閱讀一份 PDF 時,你看到的是字詞。檔案並不包含字詞。它包含的是在座標處繪製形狀的指令,而那些形狀恰好看起來像字母。一份 PDF 所繪製的內容與它所表達的意義之間的落差,正是一個頁面能看起來無懈可擊、卻仍然複製出來變成胡言亂語的原因。本頁談的就是那道落差,以及那份小而獨立、能彌合它的對應。

你對一份 PDF 所做、把它當作文字的每一件事——選取它、複製它、搜尋它、為了檢索而為它建索引、用螢幕閱讀器朗讀它——都仰賴復原檔案從未直接儲存的字元。如果這項復原失敗,失敗是看不見的。頁面照樣彩現。沒有人會察覺,直到某人把一個段落複製進電子郵件,得到的卻是 □□□□,或是在一份 400 頁的合約裡搜尋一個明明就在那裡的條款,卻一無所獲。

那是一類昂貴的錯誤,正因為它能逃過每一次視覺審查。沒有任何可看之處。文件看起來權威十足,而對機器用途而言,它是啞的。

  • 一份 PDF 繪製的是字形——由命中字型中的數字碼所選定的視覺形狀——而不是 Unicode 字元
  • 同一個形狀可以代表不同的字元,而同一個字元也可以由不同的字形繪製。外觀與意義在設計上就是解耦的。
  • 要把文字取回來,閱讀器會反向執行繪製路徑:從字元碼回到字元。字型的 /ToUnicode CMap 就是那個反向步驟的查找表(Spec: ISO 32000-2, §9.10)。
  • 沒有 /ToUnicode,或是用了錯誤的版本,就意味著複製貼上變亂碼、搜尋失敗——而頁面依然像素完美。
  • 這是意義問題。字形看起來對不對,是另一個外觀問題,由 字型:棘手之處 涵蓋。NextPDF 會輸出一份正確的 /ToUnicode,使這趟往返忠實無誤。

先從文字究竟是怎麼出現在頁面上開始。一段內容串流不會說「寫下file這個字」。它會選定一個字型,然後把一串字元碼交給一個文字繪製運算子(Spec: ISO 32000-2, §9)。每個字元碼都是一個索引——一個字型程式中的位置——而字型會把那個索引轉成一個字形輪廓並繪製它。字元碼 70 不是字元 F。它是「這個字型中的第 70 號槽位」,而第 70 號槽位恰好裝著一條 F 形的曲線。換一個不同的字型,第 70 號槽位可能就是一片雪花。

所以一個字元碼只有相對於它字型的編碼才有意義。對一個簡單字型而言,那個編碼大致是每個字形一個位元組。對那些需要數千個字形的書寫系統——CJK,或任何全 Unicode 文件——那不夠空間,於是 PDF 便求助於一個複合(Type 0)字型Spec: ISO 32000-2, §9.7)。一個複合字型會透過一個 CMap 讀取多位元組字元碼,把它們轉成 CID(字元識別碼),再把 CID 對應到字形。這是一種優雅的間接層,讓單一字型得以定址一個龐大的字形集。它也是「所顯示的內容」到「它所表達的意義」這條軌跡可能斷掉的又一個地方。

現在反過來執行它。要擷取文字,閱讀器會取出它在內容串流中找到的字元碼,並必須復原出字元(Spec: ISO 32000-2, §9.10)。當初繪製字形的編碼走的是字元到字形;而擷取需要的是字形碼到字元,且這個方向並不保證可逆。一個子集字型可能已重新編號了它的字形。一個複合字型的 CID 可能是私有的。頁面上的形狀本身並不攜帶任何固有的 Unicode 意義。

解方是把反向對應與字型一同附上。那份對應就是 /ToUnicode CMap:對於文件所用的每一個字元碼,它都記錄那個字元碼所代表的 Unicode 值(一個或多個)。有了它,擷取就是一次乾淨的查找。沒有它,閱讀器就只能從字型編碼與啟發法去猜測——而猜測正是 fi 變成一個問號的地方。

  1. Your charactersThe Unicode text you set, for example the word 'file'.
  2. Encoding → codesThe font's encoding turns characters into numeric codes; a composite font routes them through a CMap to CIDs.
  3. Codes → glyphsEach code selects a glyph outline, which is painted to the page. This is the part you see.
  4. Extraction reverses itA reader reads the codes back and looks each one up in /ToUnicode to recover Unicode.
  5. Text againWith a correct /ToUnicode, copy, search, indexing, and screen readers all get the original characters back.
PDF 文字的兩個方向。彩現走的是字元 → 字形,那是你所看到的。擷取走的是字元碼 → 字元,那是你能複製、搜尋與朗讀的——而它唯有在 /ToUnicode 對應閉合迴圈時才能運作。

NextPDF 會理所當然地寫出這份反向對應。當它內嵌一個複合字型時,會輸出 CIDFontType2 後代、Type0 父代,以及一份 /ToUnicode CMap,讓字元碼能解析回 Unicode——這正是 字型:棘手之處 所描述的編碼工作。這裡值得分開來談的重點是為什麼/ToUnicode 串流並不是為了讓頁面看起來正確。沒有它,那些字形就已經看起來正確了。它是讓文字維持可擷取的那唯一一件產物。

這件事最著名的浮現之處,是 fi 連字。許多字型會把 fi 繪製成一個結合在一起的字形,因為 i 的點會與 f 的鉤撞在一起。在頁面上它是一個形狀,由一個字元碼繪製。問題是當你複製它時會發生什麼。

% A /ToUnicode entry that maps the single ligature code to TWO characters,
% so selecting the 'fi' glyph copies out as 'f' then 'i' — not one mystery box.
1 beginbfchar
<0085> <00660069> % code 0x85 -> U+0066 'f' U+0069 'i'
endbfchar

那一條項目,正是搜尋「file」會命中,與搜尋「file」卻悄悄錯過每一處以連字彩現的出現之間的差別。把連字碼對應到雙字元序列 f + i,這個字就可搜尋、可複製。讓它維持未對應,頁面照樣完美顯示file,而文字卻悄悄地對機器無話可說。

這就是為什麼「fi」可以擷取為一個字元或兩個字元,以及為什麼這個差別並非隨機——它取決於 /ToUnicode 對應被告知要說什麼。一份正確的對應會把連字分解回它的字元。NextPDF 輸出的正是那種項目,因此頁面上的一個連字,到了剪貼簿上就是兩個普通字母。

那個陷阱,把它點名:**「文字有彩現,所以文字沒問題。」**彩現與擷取是讀取不同資料的不同機制。彩現遵循字元碼到字形的路徑,那是你的眼睛在檢查的。擷取遵循透過 /ToUnicode 的字元碼到字元路徑,那是每一個機器消費端在檢查的。一份文件可以在前者表現出色,卻在後者完全失敗,因為依其構造,根本沒有任何視覺上的東西能揭露它。

第二個更微妙的陷阱:假設一個字形「顯然」知道它是哪個字元。它並不知道。一個字形是一個帶索引的形狀。回到字元的對應是產生者必須提供的額外資訊。如果產生者從未寫下它,沒有任何閱讀器能忠實地復原它——只能猜測。

Faithful text extraction from documents NextPDF writes — edition availability
EditionAvailability
CoreNextPDF emits a correct /ToUnicode CMap for the fonts it embeds, including the ligature and composite-font cases, so the documents it produces are searchable and copyable.
ProNot in this edition
EnterpriseNot in this edition

一份 /ToUnicode 對應只能攜帶來源字型實際揭露的資訊。在一個字形沒有可判定字元之處——一個純裝飾性的記號、一個沒有 Unicode 指派的私有用途符號——沒有任何對應能無中生有地造出從未存在過的意義。NextPDF 輸出它所能推導出的真實對應;它不會為了填補一道缺口而捏造字元。

本頁談的是 NextPDF 所寫的文件。它不是針對任意傳入、其 /ToUnicode 已經缺失或錯誤的 PDF 的修復工具;從那些檔案中復原文字是另一個獨立、屬於啟發式的問題。而忠實的擷取對於無障礙而言雖屬必要,卻不是它的全部——閱讀順序、標籤與替代文字,由 是什麼讓 PDF 具備無障礙性 涵蓋。

為什麼同一份 PDF 在某個閱讀器裡複製得很好、在另一個裡卻很糟?/ToUnicode 缺失時,不同的閱讀器會以不同方式退而求其次。有些會從字型內建的編碼去猜測,並在常見的拉丁文字上碰巧猜對;有些則不會。一份正確的 /ToUnicode 消除了這場樂透——每一個符合規範的閱讀器都會得到同樣的字元。

這跟字型沒有內嵌是同一回事嗎? 不是。內嵌關乎外觀——在未安裝字型的情況下字形能否繪製。/ToUnicode 關乎意義——字元碼能否對應回一個字元。一個字型可以被完美內嵌,卻仍然沒有可用的 /ToUnicode,那就是「無法搜尋卻很漂亮」的失敗。

擷取曾經需要一個字元碼對應到不只一個字元嗎? 是的——連字的情況正是如此:一個字元碼對應到兩個字元。一條 /ToUnicode 項目可以把單一字元碼對應到一個短序列,這就是連字以及某些組合形式如何以其構成字母的形式回來的原因。

  • 字型:棘手之處——談外觀那一面的姊妹篇:內嵌、子集化,以及編碼如何構建。本頁是同一枚硬幣意義的那一面。
  • 是什麼讓 PDF 具備無障礙性——忠實的文字是其中一項要素;閱讀順序、標籤與替代文字是其餘部分。
  • PDF 究竟是什麼——字型、編碼與 /ToUnicode 串流所棲身的物件模型。
  • 字形——字型中的一個視覺形狀(一條輪廓)。PDF 實際繪製的東西。一個字形有索引,卻沒有固有的字元意義。
  • 字元——書寫語言的一個單位,由一個 Unicode 碼位識別。你所表達的意義,也是擷取試圖復原的東西。
  • 字元碼——內容串流中那個從目前字型選定一個字形的數值。不是 Unicode 字元;只有相對於該字型才有意義。
  • CID——複合字型用以在一個大型集合內定址某個字形的字元識別碼;介於字元碼與字形之間的中間步驟。
  • 複合(Type 0)字型——一種透過 CMap 讀取多位元組字元碼以抵達 CID 與字形的字型,在單一字型必須定址數千個字形時使用。
  • /ToUnicode——那份把文件所用字元碼對應回 Unicode 值的 CMap 串流;讓 PDF 文字可搜尋、可複製、可存取的關鍵。
  • 連字——把兩個或更多字母繪製成單一形狀的單一字形(例如 fi);它的 /ToUnicode 項目會把它分解回其字元。