編輯 PDF 文字的原理:內容串流、字型編碼,以及為什麼一次只能改一行
PDF 頁面裡沒有段落或文字方塊,只有「用某個字型在某個位置畫出這些字形」的指令。改一行文字就是刪掉舊指令、在同一位置寫入新指令,可編輯與不可編輯的範圍都由此決定。

PDF 頁面裡沒有段落,也沒有文字方塊,只有一串指令:在這個位置,用這個字型和大小,畫出這些字形。改文字就是刪掉畫那一行的指令,再用相同的字型、大小和顏色,在同一位置寫入新的指令。這就是 編輯 PDF 一次只改一行、也不能自動換行的原因:它是文字儲存方式的直接結果。
一行文字在檔案裡長什麼樣子
頁面內容存放在內容串流裡,那是一段由運算子組成的小程式。文字主要靠四個運算子:BT 與 ET 圍出一個文字區塊,Tf 選字型和大小,Td 移到下一個位置,Tj 畫出一個字串。用標準字型時,最簡單的情況長這樣:
BT /F1 18 Tf 20 150 Td (Hello World) Tj ET
意思是:選字型 F1、18 點,移到 x 20、y 150,畫出 Hello World。Chrome 列印出來的 PDF 通常不一樣:括號裡放的不再是字元,而是字型裡的字形編號,位置則由 Tm 一次設定。下面是 Chrome 匯出頁面中的 Hello World from Georgia 這一行,每四個十六進位數字是一個字形編號:
BT /F4 24 Tf 1 0 0 -1 30 52 Tm <002b0048004f004f00520003003a00520055004f0047000300490055005200500003002a004800520055004a004c0044> Tj ET
002b 是 H,0048 是 e,004f 是 l,0003 是空格,003a 是 W,依此類推。這些編號只在這一個字型裡有意義,不是 Unicode。複製文字時,檢視器靠檔案別處存放的對照表把編號轉回字元。
由此可得兩點。第一,內容串流裡沒有任何地方寫「這是一個段落」:換行只是另一個把位置移到下一行的 Td 或 Tm,一個段落有幾行,只能從位置推斷。第二,要把新字元寫進一行,就得知道這個字元在該字型中的編號,而字型往往只含有檔案寫入時用到的字形,這是字型子集化的結果。
可以用 qpdf 把串流展開:
qpdf --qdf --object-streams=disable in.pdf out.pdf
用文字編輯器打開 out.pdf,搜尋 Tj,就能看到頁面上畫出的每一個字串。想知道 PDF 還包含什麼,請見PDF 裡面有什麼。
編輯器如何從字形重建一「行」
編輯器首先要把零散的字形歸成使用者可以點選的一行。字型、大小、顏色相同,且與前一個字形的間距不超過一個字型大小的字形,屬於同一個文字片段;換行、換字型或換顏色就結束這個片段。由右向左書寫或直排的文字不會被歸組,也不能開啟編輯。
有兩個結果可以直接觀察到。混用兩種字型的一行,例如中文裡夾一個英文單字,會被拆成相鄰的兩個片段,要分別點選。編輯之後也一樣:如果新文字用了另一種字型,這一行就變成兩個片段,改其中一個,另一個保持不變。
改一行:先刪舊文字,再寫新文字
刪除用的是只處理文字的塗黑:該行區域內的字形會從內容串流中移除,影像與線條不動,也不會在舊文字上蓋一個有色方塊,所以複製和搜尋都找不到它了。它只改頁面內容;如果同樣的文字還出現在註解或文件屬性裡,那些地方不受影響。
寫入則是在頁面內容串流的末尾附加一段新的文字指令,從原本那一行的基線位置開始,沿用它的大小和顏色。把上面那一行改成 Hello World 2 之後,附加在頁面末尾的內容是:
q 1 0 0 1 22.5 148.92 cm 0 0 0 rg
/Span <</ActualText <feff00480065006c006c006f00200057006f0072006c006400200032>>> BDC
BT /F4 18 Tf 0 0 Td <002b0048004f004f00520003003a00520055004f00470003> Tj ET
BT /F9 18 Tf 102.18 0 Td (2) Tj ET
EMC
Q
第一個 BT 用原本的字型 F4 和原本的字形編號,所以 Hello World 與原來完全一樣。「2」不在原本的字型子集裡,所以第二個 BT 另外啟用標準 Helvetica 字型(F9)來畫它。q 與 Q 把這一段的座標變換和顏色限制在自己範圍內;編輯器也會用一對 q/Q 包住原本的內容,這樣原頁末尾沒有成對結束的狀態就不會影響新文字。這是一個文字片段的樣子,數字會隨檔案而不同。
附加在末尾有三個後果:
- 新文字最後繪製,疊在頁面上其他所有內容之上。原本在影像或色塊底下的文字,編輯後會出現在它上面。
- 按內容串流順序抽取文字的工具(例如
pdftotext -raw)會把編輯過的那一行讀成該頁的最後一行,而按位置抽取(pdftotext -layout)看不出變化。頁面看起來一樣,閱讀順序卻變了。 - 編輯器會替新文字附上 ActualText,告訴檢視器這些字形代表哪些真正的字元。一個字形可以對應多個字元,只靠字形編號反查有時會得到相容字元(例如把「文」讀成康熙部首「⽂」);ActualText 確保複製出來的就是你輸入的。
新文字用哪個字型畫
沿用原本的字型最穩妥,因為字形、粗細和寬度都與旁邊的文字一致。能不能沿用,取決於編輯器能不能確定一個字元在該字型中的編碼:
- 像上面那樣的複合字型(Type0、兩位元組字形編號、Identity-H 編碼):到字型裡查這個字元的字形編號,找得到就用;找不到,就表示這個字元不在子集裡。
- 單位元組的簡單字型(TrueType、Type1)搭配 WinAnsi 或 MacRoman 編碼:字元到位元組的對應是固定的,寬度取自檔案中的寬度表。
- 自訂編碼(Differences)或依賴字型內建編碼的字型:沒有可靠的對應,編輯器不會猜,而是改用後備字型,否則文字會變成亂碼。
原本的字型沒有這個字元時,就使用後備字型。純拉丁字母會用 Helvetica、Times、Courier 中最接近的一個,這些是每個檢視器都有的標準字型,不需要內嵌。其餘的字元,例如漢字、假名、諺文、泰文、帶重音的拉丁字母、西里爾字母和希臘字母,使用開源的 Noto 字型:伺服器取出請求中的字元,用開源的 HarfBuzz 子集化函式庫,從完整字型中只切出這些字形,通常只有幾 KB,再由瀏覽器內嵌進你的檔案。漢字的字形樣式隨介面語言而定:簡體、繁體、日文或韓文。
之所以內嵌字形而不是只寫字型名稱,是因為只用名稱參照的字型會被各個檢視器換成各自挑的後備字型,粗細也可能因檢視器而異;內嵌之後,每個檢視器顯示的字形都一樣。
為什麼一次只能改一行
編輯器沒有排版引擎。檔案裡每個字形的位置都是固定的;新文字只是依字型的寬度,從起點往右排,沒有「到邊界換行」的邏輯,它後面的內容也不會被移動。把一行改長,多出來的字元就繼續往右延伸,超出頁面邊緣的部分看不到。重寫一個段落,或在表格裡新增列,需要重新排版,也就是回到製作這份 PDF 的原始檔。
寬度同樣只是字型為每個字形定的前進寬度(排完一個字形後,位置往右移動的距離),沒有字距調整(kerning),也沒有字形替換。不需要文字塑形(依上下文把字元換成合適的字形並決定其位置)的文字(拉丁字母、西里爾字母、希臘字母和漢字)可以正確排版。泰文的聲調符號需要字形定位才能落在正確的位置,編輯器不做這件事,所以可能會偏位。阿拉伯文需要依上下文變化的字形和由右向左的排版,希伯來文需要由右向左的排版,印度系文字需要把子音和母音符號組合成字形;這些都需要完整的文字塑形,編輯器目前不做,所以輸入這些文字時會被拒絕,而不是產生錯誤的字元。
還有兩個限制來自檔案本身。掃描頁的內容是一張影像,沒有文字運算子,所以沒有可刪可改的東西。有數位簽章的檔案經過編輯後,簽章所保護的原始內容已經改變,簽章就不再有效。
自己驗證
下載編輯後的檔案,用三個指令就能看到上面描述的變化:
pdftotext -raw edited.pdf - # 按內容串流順序抽取:舊文字不見了,編輯過的那一行排在最後
pdffonts edited.pdf # 字型清單:被沿用的原字型,以及新內嵌的字型子集
qpdf --qdf --object-streams=disable edited.pdf edited.qdf # 展開內容串流,附加的 BT ... ET 區塊在最後
延伸閱讀
- 為什麼字型子集化會弄壞編輯,卻不弄壞閱讀:原字型為什麼會缺字元,缺了會怎樣。
- PDF 裡面有什麼:物件、串流與交叉參照表:內容串流、物件與檔案結構的全貌。
- 線上編輯 PDF 裡的文字:沒有原始檔也能改:怎麼使用、適合什麼情況、什麼時候該回到原始檔。