發票編號中只錯了一個字元,但手邊唯一的編輯原語卻要重寫整段文字。PDF Library for Delphi 填補了這個缺口:GetTextBlockCharContentLocation 把每個擷取出的 UTF-16 位置映射回產生它的內容串流指令、運算元和編碼位元組範圍,而 ReplaceTextBlockCharSourceBytes 只覆蓋這個範圍。文字擷取通常會丟掉完成這件事所需的一切。你得到 Unicode、寬度和幾何資訊,來源關係卻消失了,於是第 3 個文字區塊中的第 7 個字元就只是一個字元。它來自哪個串流、哪個指令、哪個運算元、那個運算元中的哪個位元組:全都沒了。建立在這之上的每一種點編輯策略都只能猜,通常是搜尋解碼後的內容來找子字串,並希望它剛好只出現一次。但在真實頁面上並不會這樣
為什麼重寫整段文字會破壞頁面
因為文字段不只是文字。ISO 32000-1 §9.4.3 的文字顯示運算子包括 TJ,它的運算元是一個在字串和數值調整之間交錯的陣列,而這些數字就是排版資訊。按 [(AB) -120 (CD)] TJ 排版的行,在兩個字串之間攜帶千分之一 em 的 120 單位字距調整。重新發出一個把文字串接起來的 Tj 後,字距調整消失,整行會細微重排,在表單中則可能讓值飄出其框體。字型也有同樣問題:運算元位元組是 Tf 選取的編碼中的代碼,而不是 Unicode;對於複合字型,它們可能是與擷取字元無關的雙位元組 CID。重新產生文字段時,你必須同時正確處理字型編碼、/ToUnicode 映射和字形涵蓋範圍。點編輯完全不離開位元組域,因此避開了所有這些問題
GetTextBlockCharContentLocation 回傳什麼
這個方法把一個字元解析成九個欄位的記錄,而且每個欄位表示的都是位址而不是值。ContentLayer 是頁面 /Contents 陣列中的從 1 開始索引;如果字元來自巢狀內容,則為 0。StreamObjectNumber 和 StreamGeneration 識別包含它的串流。InstructionIndex 是解碼後內容程式中的從 0 開始位置,OperandIndex 是文字字串運算元,ArrayElementIndex 是 TJ 陣列中的元素;對於直接字串運算元則為 -1。最後,SourceByteOffset 和 SourceByteLength 指出解碼字串內部的位元組範圍
Var
Lib: TPDFlib;
ListID, Block, CharPos: Integer;
ContentLayer, StreamObjectNumber, StreamGeneration: Integer;
InstructionIndex, OperandIndex, ArrayElementIndex: Integer;
SourceByteOffset, SourceByteLength, Flags: Integer;
Begin
Lib:= TPDFlib.Create;
Try
Lib.LoadFromFile('invoice.pdf', '');
Lib.SelectPage(1);
ListID:= Lib.ExtractPageTextBlocks(3);
Try
// Block 和 CharPos 來自你對 GetTextBlockText 的自行掃描
If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
ContentLayer, StreamObjectNumber, StreamGeneration,
InstructionIndex, OperandIndex, ArrayElementIndex,
SourceByteOffset, SourceByteLength, Flags)= 1 Then
Begin
// ContentLayer = 0 表示字形位於巢狀 Form XObject 中
// ArrayElementIndex = -1 表示普通 Tj 運算元,而不是 TJ 陣列
End;
Finally
Lib.ReleaseTextBlocks(ListID);
End;
Finally
Lib.Free;
End;
End;
查詢時不需要額外付出解析成本。渲染器解碼每個內容層時,會登記自己正在遍歷的邏輯範圍,因此位置查詢是對有序範圍清單執行二分搜尋,而不是針對每個字元線性掃描所有內容區間。查詢時不會重新解析任何內容;映射是在已經支付過成本的擷取階段建立的。如果你已經在使用回傳命中座標的 PDF 文字搜尋列舉命中,為每個命中增加內容位置幾乎沒有額外成本
編輯位元組,而不是 Unicode
ReplaceTextBlockCharSourceBytes 接受目前 PDF 字型編碼中的原始替換位元組 AnsiString。這就是整個設計,而且是有意如此。它不會轉碼、重新編碼,也不會猜測字型。程式庫會把你的位元組串接到目標字串指定的範圍上,然後重新發出包含它的內容層。同一個 TJ 陣列中的相鄰字串及其之間的數字字距保持逐位元組不變。以剛才的版面為例:定位 [(AB) -120 (CD)] TJ 中的 B 會得到 ArrayElementIndex 0、SourceByteOffset 1 和 SourceByteLength 1。用 Z 替換它後,產生的內容包含 (AZ),後面仍然是 -120 和 (CD),兩者都沒有改動。回歸套件正是這樣斷言的,因為「保留了字距」這種說法很容易在不知不覺中變得不再成立
Function EditableHere(Flags: Integer): Boolean;
Begin
Result:= ((Flags and PDF_TEXT_CHAR_CONTENT_LOCATION_VALID)<> 0)and
((Flags and (PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED or
PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT or
PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED or
PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER or
PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED))= 0);
End;
// ...
If EditableHere(Flags) Then
Begin
If Lib.ReplaceTextBlockCharSourceBytes(ListID, Block, CharPos, 'Z')= 1 Then
Begin
// 舊清單中的每個位置現在都已過期,重新擷取
Lib.ReleaseTextBlocks(ListID);
ListID:= Lib.ExtractPageTextBlocks(3);
End
Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
// 自擷取以來,該層在我們下方發生了變化
Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
// 我們漏檢了一個旗標,或後續版本新增了一個旗標
End;
有兩個操作細節值得記住。呼叫會暫時切換到擷取文字清單的頁面,而且無論成功還是失敗都會恢復先前選取的頁面,因此不會悄悄移動你的游標。成功後還會清除頁面元素快照,使你從先前列舉階段持有的任何控制代碼失效
哪些字元不能編輯
共有六類,程式庫會在 Flags 位元遮罩中逐一命名它們,而不是含糊地失敗。這一點比成功路徑更重要,因為在真實文件中無法映射的情況很常見,而且每一類背後的原因都不同
PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE:多個擷取出的 UTF-16 位置由一個來源字形展開。/ToUnicode中把一個代碼映射到fi時,兩個字元會共用一個位元組範圍,因此應把它們視為一個來源字形,只編輯一次該範圍PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED:字元是在版面配置過程中合成的。推斷出的單字空格是常見情況,它們根本沒有來源位元組,因此SourceByteOffset回傳 -1,SourceByteLength回傳 0PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT:你讀到的文字來自/ActualText替換。替換字串與來源位元組之間沒有唯一的反向映射,因此該位置只能用於診斷PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED:字形位於 Form XObject 內。位元組可以定位,但 Form 可能由多個頁面繪製,因此透過高階 API 編輯它會變成一次你沒有要求的全域編輯PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED:運算元是攜帶 UTF-16BE 位元組順序標記的十六進位字串,現有擷取路徑會在字型映射前先對它解碼。解碼結果中的偏移不再指向原始位元組,因此有效旗標會被清除PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER:字串運算元和其文字顯示運算子位於兩個不同的串流中
最後一類值得單獨說一句,因為工程師經常以為它不可能發生。ISO 32000-1 §7.8.2 規定,頁面 /Contents 陣列中的串流會被串接,串流之間的分界只需要位於詞法邊界即可。因此,一個串流中出現 BT /F1 16 Tf 220 340 Td (CrossLayer),而下一個串流中出現 Tj ET,完全是合法頁面。映射會保留診斷位置,但將其標記為唯讀,因為運算子的指令索引屬於與運算元不同的層,使用其中一個去尋址另一個會破壞檔案
程式庫如何知道映射仍然有效
靠指紋,而且在寫入前立即檢查。每個擷取清單都會記錄來源頁面,以及每個內容層的層長度和兩個獨立的滾動雜湊:FNV-1a 雜湊和 DJB2 風格的 XOR 雜湊。在 ReplaceTextBlockCharSourceBytes 做任何解析前,它會重新讀取目標層並比較這三個值。該層任何位置發生任何位元組變化,都會回傳 PDFLIB_ERROR_TEXT_LOCATION_STALE,寫入也不會發生。這是有意採取的保守策略:檢查粒度是每層而不是每條指令,因此同一內容串流中其他位置的不相關編輯也會使位置失效。這是正確取捨:一個即使只前移一個位元組的串流偏移,不是「差一點」,而是靜默損壞。包括CTM 與裁剪狀態的內容串流狀態追蹤器在內,其他編輯介面也遵守相同紀律。每次成功替換後,都應丟棄清單並重新擷取
透過 Direct Access 進行唯讀映射
DAGetTextBlockCharContentLocation 會為透過 Direct Access 路徑開啟的頁面提供完全相同的記錄和完全相同的旗標詞彙。它從設計上就是診斷介面:ReplaceTextBlockCharSourceBytes 操作的是選取的可編輯文件,而 Direct Access 是讀取路徑。檔案控制代碼關閉後,位置資料仍保留在文字區塊清單中,因此可以用於離線稽核
FileHandle:= Lib.DAOpenFileReadOnly('audit.pdf', '');
Try
PageRef:= Lib.DAFindPage(FileHandle, 1);
DirectList:= Lib.DAExtractPageTextBlocks(FileHandle, PageRef, 3);
Try
Lib.DAGetTextBlockCharContentLocation(DirectList, Block, 1,
ContentLayer, StreamObjectNumber, StreamGeneration,
InstructionIndex, OperandIndex, ArrayElementIndex,
SourceByteOffset, SourceByteLength, Flags);
// DACloseFile 之後位置仍然可讀
Finally
Lib.DAReleaseTextBlocks(DirectList);
End;
Finally
Lib.DACloseFile(FileHandle);
End;
用它來回答問題,而不是用它來改變內容。哪些頁面帶有永遠無法就地編輯的文字?這個語料庫中有多少內容帶有 /ActualText 覆蓋?哪家廠商的輸出會把運算子拆到多個內容層中?一旦每個字元都有位址,這些查詢的成本都很低,而在投入修正流程前執行它們很值得
點編輯的邊界
點編輯是一把手術刀,不是文字引擎。它就地改變位元組,因此比原文更寬或更窄的替換文字不會重排、不會換行,也不會更新周圍的字距。在等寬欄位中用一個數字替換另一個數字很合適,重新輸入一段文字則不合適。而且它絕對不是安全工具:覆蓋字形位元組後,原始位元組仍可從檔案的修訂歷史中復原,因此任何有保密要求的內容都屬於真正的塗黑,也就是移除內容而不是遮蓋內容。換來的好處是誠實。每個字元要麼有一個可以操作的位元組位址,要麼有一個明確旗標說明它為什麼沒有位址,指紋檢查還會把過期映射變成硬錯誤,而不是損壞頁面。字元到內容位元組的映射以及就地來源位元組替換,都是PDF Library for Delphi文字擷取和內容編輯介面的一部分;該程式庫是面向 Delphi、C++Builder 和 Lazarus 的原生 Object Pascal PDF 程式庫