HotPDF Component 能夠在 Delphi 與 C++Builder 中搜尋並取代現有 PDF 內的文字。SearchLoadedPageText 與 SearchLoadedDocumentText 能以字形層級的精確度定位出字串的每一次出現,而 ReplaceLoadedPageText 與 ReplaceLoadedDocumentText 則會原地改寫匹配的位元組——前提是每一個取代字元都能夠透過原始字型重新編碼,這是一個實體限制,本文將誠實探討此問題,而不是將其隱藏在註腳中
這項功能背後的需求往往非常日常。例如公司更名,而三千份封存的發票仍標示著舊名稱;隨產品發布的合約範本上寫著去年的到期日;或是產品代碼已淘汰,所有提到它的規格表都需要換成後續代碼。在文書處理軟體中,這些都是三十秒就能解決的工作。但在 PDF 中,這卻是一個真正困難的問題,了解其原因能讓您清楚知道該如何正確使用 API,而不是提交一份實際上是在引述規格的錯誤回報 (bug report)
為什麼在 PDF 中取代文字這麼困難?
在 PDF 中取代文字之所以困難,是因為 PDF 頁面並不包含可編輯的文字——它包含的是已定位的字形 (glyphs)。根據 ISO 32000-1 §9.4 的文字顯示模型,內容串流驅動諸如 Tj 與 TJ 等運算子,它們會在由文字矩陣建立的座標處,繪製字元代碼序列。這些代碼並非 Unicode;它們是指向該頁面字型所宣告之任何編碼的索引,而對應回可讀字元的映射可能存在於 /ToUnicode CMap、編碼差異陣列,或 CID 映射鏈中。這裡沒有段落物件、沒有文字流,也無法保證視覺上的一個單字會被儲存為單一個字串
「取代」在「解碼」之上又增加了第二層困難:您必須確切知道原始串流的哪些位元組產生了每一個字形,這樣您才能將新的位元組精準拼接到該跨度 (span) 中,而不影響其他內容。一個文字提取器一旦取出了 Unicode,就可以負擔得起丟棄位元組位置的代價;但取代器卻不行。這就是為什麼 HotPDF 將這項工作拆分到兩個版本中——v2.251.0 建立了偏移量追蹤與搜尋層,而 v2.252.0 則在此之上建立了改寫層
尋找文字:具備位元組偏移量追蹤的字形層級搜尋
HotPDF 的 SearchLoadedDocumentText 是透過對比每一頁解碼後的 Unicode 字形序列來尋找目標的每一次出現,而不是對比原始串流位元組,因此無論字型如何編碼,只要命中就是命中。底層的基礎設施在 v2.251.0 引入:內容串流分詞器 (tokenizer) 會為每一個字串運算元——包含其 ( ) 或 < > 分隔符號——記錄一個 StartOfs/EndOfs 位元組跨度,而且每一個解碼的字形都帶有 TokenIndex/ItemIndex/ByteOffset 三元組,指向確切的運算元、TJ 陣列項目,以及產生它的代碼單元。同樣的字形直譯器也驅動了在 Delphi 中從已載入的 PDF 提取文字文章中所描述的提取 API;搜尋功能只是保留了提取時會丟棄的出處資訊而已
每個匹配項都會以一個 THPDFTextMatch 記錄回傳,其中攜帶了頁面索引、包含的字形範圍、命中的使用者空間 X/Y 原點與寬度、來源標記與項目索引,以及匹配到的文字本身。這足以驅動反白覆疊 (highlight overlay)、審閱使用者介面 (UI),或執行取代步驟。找不到結果的搜尋會回傳一個空陣列而非失敗,這讓呼叫模式保持簡單
var
Pdf: THotPDF;
Matches: THPDFTextMatchArray;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('invoices-2025.pdf') > 0 then
begin
if Pdf.SearchLoadedDocumentText('Acme Corp', False, Matches) then
for I := 0 to Length(Matches) - 1 do
WriteLn(Format('page %d at (%.1f, %.1f): "%s"',
[Matches[I].PageIndex, Matches[I].X, Matches[I].Y,
Matches[I].Text]));
end;
finally
Pdf.Free;
end;
end;
其中一個刻意為之的設計選擇值得注意。在設計上,當 CaseSensitive 為 False 時,僅對 ASCII 字元進行大小寫折疊 (case folding) 比較:在 HotPDF 所支援的 Delphi 5 到 XE 版本的工具鏈中,完整的 Unicode 大小寫折疊行為有所不同,如果一個搜尋 API 會因為編譯您應用程式的編譯器不同而找到不同的匹配項,那還不如一個具有記錄文件、行為可預測限制的 API。對於拉丁商業文字——名稱、代碼、日期——ASCII 折疊已能涵蓋實務上的情況
取代文字:反向編碼與精準拼接
在 HotPDF v2.252.0 中新增的 ReplaceLoadedDocumentText,透過反向執行解碼機制來改寫每一次出現的目標文字。HPDFEncodeUnicode 函式是字元代碼解碼器的反向操作:它反向走過相同的策略鏈——/ToUnicode bfchar 與 bfrange 查找、編碼串流 CID 映射、Type0 恆等 (identity) 映射,以及預先定義的 WinAnsi 與 MacRoman 表格——將每一個取代字元變回原始字型所預期的字元代碼位元組。重新編碼後的位元組接著會被序列化為格式正確的字串常值 (string literal) 或十六進位字串,這反映了分詞器自己的跳脫 (escaping) 規則,從而讓解析 → 重新序列化的往返過程保持穩定
拼接本身是精準的外科手術式操作,而非全盤覆寫。只有被匹配涵蓋到的代碼位元組範圍才會在字串運算元內被取代;同一個運算元中未匹配的位元組、標記之間的空白,以及所有周圍的運算子,都會原封不動地逐位元組保留。在 abcabc 中將 bca 取代掉,會產生 a + 取代內容 + bc,而不是一個被破壞的運算元。取代內容可以比目標字串更短或更長——常值會被重新序列化,且串流的 /Length 會被更新——而多串流頁面的每個 /Contents 串流都是獨立處理的,因此頁面會保持格式正確
var
Pdf: THotPDF;
ReplaceCount: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('contract-draft.pdf') > 0 then
begin
if Pdf.ReplaceLoadedDocumentText('2025-12-31', '2026-12-31',
True, ReplaceCount) then
WriteLn(Format('%d operand rewrites performed', [ReplaceCount]));
Pdf.SaveLoadedDocument('contract-final.pdf');
end;
finally
Pdf.Free;
end;
end;
請注意該 API 未做的事情:它不會重新排版頁面。PDF 沒有文字重排功能,所以一個在視覺上比原本更寬的取代內容,只會佔用更多的水平空間,並且可能會擠壓到繪製在其右側的任何東西。長度相同或相近的替換——日期、版本字串、零件料號、名稱更正——是最適合的應用場景。全盤的改寫應在原始文件中進行,而不是在 PDF 中
為什麼不能使用字型子集從未包含的字元來取代文字?
您無法使用內嵌字型子集從未包含的字元來取代文字,因為能選擇該字元的位元組序列根本不存在於該字型的映射表中。當 PDF 產生器內嵌了一個子集字型時,它的 /ToUnicode CMap 與編碼結構只會涵蓋原始文件實際使用到的字形。HPDFEncodeUnicode 只能反轉存在於其中的映射:如果該文件在該字型中從未包含過字母 E,那麼就沒有可以反轉為 E 的字元代碼。這是檔案的實體屬性,而非任何特定程式庫的限制——沒有任何工具能憑空變出從未被內嵌過的字形映射
HotPDF 採取保守的方式來處理這類失敗。如果取代內容中任何一個單一字元無法被重新編碼,那麼該次目標文字的出現將會被跳過——不會有例外,不會有部分亂碼文字,且該次出現將不會計算在 ReplaceCount 中。實務上的結果是:將 ReplaceCount 與先前搜尋的匹配總數進行比對,並將短缺視為一個訊號。在上述的日期範例中,數字 6 必須出現在該文件相同字型文字中的某個地方,改寫才會成功——在發票中這很有可能,但在一般情況下則無法保證。當您需要的字元根本不存在,而您的目標是移除敏感文字而非改寫它時,真正的內容移除才是更佳的工具;有關該路徑的作法,請參閱在 Delphi 中對已載入的 PDF 進行遮蔽與重組
var
Matches: THPDFTextMatchArray;
Expected, Replaced: Integer;
begin
Pdf.SearchLoadedDocumentText('Acme Corp', True, Matches);
Expected := Length(Matches);
Pdf.ReplaceLoadedDocumentText('Acme Corp', 'Apex Corp', True, Replaced);
if Replaced < Expected then
WriteLn(Format('%d occurrence(s) skipped: characters missing ' +
'from the font subset, or match spans multiple operands',
[Expected - Replaced]));
end;
該訊息中的第二個跳過條件是另一個有記錄的邊界情況:跨越多個字串運算元的目標字串——例如,Hello 被拆分在 [(He)(llo)] TJ 項目中——搜尋能找到它,因為搜尋是對比解碼後的字形序列;但取代會跳過它,因為跨越運算元邊界進行改寫將需要合併相鄰的位元組跨度。先搜尋後驗證的模式能讓這兩個限制變得可見,而不是默默發生
儲存時檔案內會有什麼改變?
被取代的 /Contents 串流在儲存時是不壓縮的。經 FlateDecode 壓縮的串流會為了編輯而被解壓縮,而當 HotPDF 寫入重建後的位元組時,它會捨棄串流的 /Filter 條目並更新 /Length,而不是重新壓縮。產生的 PDF 完全有效,並能在主流的檢視器中正常渲染;其代價是每個被編輯串流的檔案大小都會變大。對於處理數千份文件的批次管線,請為此增長做預算,或在下游執行獨立的壓縮程序。改寫後的物件如何在儲存時與文件的交叉參考 (cross-reference) 結構互動則是另一個主題,詳見HotPDF 中的物件串流與增量更新
檔案中的其他所有東西都會保持原樣。未被碰觸的串流會保留其壓縮狀態,字型與圖片不會被改寫,而運算元層級的拼接意味著,即便是被編輯過的串流,也只有在匹配落下的地方會與原始內容不同。這種保守是刻意為之的:一個程式庫改寫已載入文件的內容越多,就越有可能破壞它未預料到的產生器怪癖 (producer quirk)
文字的搜尋與取代加入了 HotPDF 已載入文件工具集中的提取、遮蔽以及頁面渲染功能,這些全由同一個內容串流直譯器驅動,並且在沒有外部相依性的情況下,相容從 Delphi 5 到目前的 RAD Studio 版本。完整的 API 參考與試用版下載都在 HotPDF Component 產品頁面上