技術文章

Delphi 中的延遲 ObjStm 成員與 PDF 完整改寫

當 HotPDF Delphi Component 用 LoadFromFile 載入一份 PDF 1.5 檔案時,它不會剖析打包在 /Type /ObjStm 容器裡的物件。它記錄每個壓縮成員住在哪裡,只在有東西要它時才剖析。正是這條延遲不變式讓載入時間與您真正碰到的東西成正比,而它也是為什麼完整改寫在送出任何位元組之前必須多做一件事:展開每一個還沒被剖析的成員,因為改寫即將把那些成員所住的容器丟掉

促成這篇筆記的症狀很容易描述、卻很難除錯。載入一份字型、色彩空間與結構樹都住在物件串流裡的檔案,讓它走過 BeginDoc 與 EndDoc 這對產生流程,然後輸出開啟時毫無怨言。頁數正確,您抽查的頁面上文字看得見。然後同事打開第 40 頁,內文用替代字型渲染,或是「擷取文字」指令原本該回傳 ActualText 替換內容的地方回傳了垃圾。沒有任何東西當掉。寫入器只是把一個從未載入的物件序列化出去,而未載入的物件序列化之後就是空無

LoadFromFile 對一個壓縮物件實際保留了什麼

對每一筆 type-2 交叉參照項目,LoadFromFile 在 FCompactObjects 裡留一筆小紀錄:物件編號、所屬串流在容器表中的索引、該成員在串流裡的位置,以及一個起初為 nil 的 ParsedObject 指標。容器本身會被定位、在文件加密時被解密、並被 inflate,但成員主體仍以位元組形式留著。ISO 32000-1 §7.5.7 定義了讓這件事可行的容器佈局:一段由物件編號與位移配對組成的標頭,接著成員主體串接在 /First 之後,所以任何單一成員都可以在不碰鄰居的情況下被切出來

EnsureCompressedObjectLoaded 是把紀錄變成物件的唯一路徑。它依物件編號找到紀錄,如果 ParsedObject 已經設起,就回傳那個快取的物件並計一次快取命中。否則它會在容器被逐出時重新載入容器、從位移表算出成員的位元組範圍、交給剖析器一份該切片的零複製檢視,並把結果存回紀錄。從那時起這個物件就是間接的、帶著它真正的物件編號,並像任何從檔案主體剖析出來的物件一樣註冊在文件物件索引裡。catalog、info 字典、頁面樹根與頁面物件會在載入時走這條路,因為導覽需要它們。字型、色彩空間、ExtGState 字典與結構元素不會,它們維持紀錄狀態,直到某次頁面渲染或改寫碰到它們

HotPDF Delphi Component 如何存放一個尚未剖析的壓縮成員:FCompactObjects 紀錄保留物件編號、容器索引、成員索引與一個 nil 的 ParsedObject 指標,而 EnsureCompressedObjectLoaded 透過快取命中、容器重新載入、位移表切片與零複製剖析,把紀錄變成已註冊的物件
LoadFromFile 讓 /ObjStm 的成員主體保持位元組狀態,只在有讀取者要它時才剖析,所以載入時間跟著您碰到的東西走:catalog 與頁面樹早早到齊,而字型、色彩空間與結構元素則維持紀錄

您可以從外面觀察這件事。GetLoadedObjectStreamCacheInfo 會回報有多少個容器存在、多少成員被索引、其中又有多少已經被剖析過:

var
  Pdf: THotPDF;
  Info: THPDFObjectStreamCacheInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('tagged-report.pdf');
    if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
      Writeln(Format('%d containers, %d members indexed, %d parsed so far',
        [Info.ContainerCount, Info.IndexedObjectCount,
         Info.MaterializedObjectCount]));
  finally
    Pdf.Free;
  end;
end;

在結構很重的檔案上,載入剛完成時第三個數字只是第二個的一小部分。那個落差正是延遲載入的全部意義,而它同時也正是完整改寫必須回頭去拿的那一組物件

為什麼完整改寫會丟掉增量存檔留得住的字型

完整改寫會丟掉來源檔案的 /ObjStm 與 /XRef 容器,並從頭重新序列化物件圖,所以任何 ParsedObject 仍為 nil 的成員,在輸出裡就沒有任何表示留下。增量更新從來沒有這個問題,因為它把新物件附加在原始位元組之後,並讓舊容器留在原地供前一段交叉參照區段定址。差別不在兩種模式怎麼對待字型。差別在原來的容器是否存活下來、被下一個檢視器讀到

修法落在 SaveToStream 裡,也就是不管您設的是 FileName 還是 OutputStream,EndDoc 都會驅動的那個序列化器。在它分派到任何寫入器分支之前,它會走過 FCompactObjects 並對每一筆項目呼叫 EnsureCompressedObjectLoaded。如果某個成員載入不了,存檔會丟出例外而不是繼續,因為一份悄悄丟掉字型字典的改寫,比一份停下來的改寫更糟。這段展開必須坐在那個層級,在所有經典、打包與線性化分支之上,也在線性化路徑對重新載入之結構串流的剪除之上。較早的版本只在 SaveLoadedDocument 裡展開成員,那只涵蓋已載入文件的詞彙,完全漏掉產生流程的詞彙。LoadFromFile 之後接 BeginDoc、頁面編輯與 EndDoc,會直接走到寫入器,而每個未被觸動的成員都還沒被剖析

HotPDF 完整改寫的展開放在哪裡:SaveToStream 在分派到經典、打包或線性化寫入器之前,先走過每一筆 FCompactObjects 項目並呼叫 EnsureCompressedObjectLoaded,所以 SaveLoadedDocument 詞彙與 LoadFromFile 加 BeginDoc 加 EndDoc 詞彙都會序列化完全剖析過的物件,而不是 nil 紀錄
增量更新附加在原始位元組之後,並讓舊容器保持可讀,而完整改寫會把它們丟掉,所以坐在所有寫入器分支之上的一趟展開,就是防止未載入的字型或結構元素被序列化成空無的東西
// 現在兩種改寫詞彙都會在任何寫入器執行之前展開壓縮成員。
// 已載入文件路徑:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');

// 在已載入檔案上走產生流程:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc;   // SaveToStream 會先把每一筆 FCompactObjects 實體化

快取的成員會保留您對它們做過的任何事。一個在存檔前被剖析、編輯、標記為 dirty 的物件,會連同它的編輯一起從快取回傳,而您刪掉的成員在反覆存檔之間保持它的刪除狀態。這趟展開在結構上就是幂等的:它只會填補 nil 的槽位

為什麼在三個頁面上做像素比對會漏掉 ActualText 這個案例

結構元素是這個 bug 躲最久的地方。標記內容序列上的 ActualText 項目由 ISO 32000-1 §14.9.4 定義,它為抽取與無障礙替換字符,但不影響渲染。如果那個結構元素住在物件串流裡而改寫把它弄丟了,頁面依然畫得正確,第一頁、中間頁與最後一頁與來源逐像素相符,而這個回歸只有在有人跑文字抽取或螢幕閱讀器時才顯現。只渲染頁面的改寫測試不是標記 PDF 的改寫測試。把抽取出的文字與結構樹也拿來 diff

空的使用者密碼如何改變載入

空的使用者密碼仍然代表檔案是加密的,而這類檔案裡的物件串流在檔案金鑰被還原之前都是密文。ISO 32000-1 §7.6.3.4 Algorithm 2 從密碼、/O 項目、/P 與第一個文件識別碼推導出那把金鑰,而 HotPDF 必須對空字串跑過它,type-2 那一趟才能 inflate 任何一個容器。這就是為什麼對一份已載入的加密文件呼叫 BeginDoc 時,會在一切之前先用空密碼呼叫 DecryptLoadedDocument:物件圖必須先被驗證並解密,改寫才能開始,不管呼叫端是否打算保護輸出。輸出加密是另一個獨立決定,由呼叫端的保護設定驅動,而 BeginDoc 會在解密那一趟之後還原那些設定,讓加密的輸入不會悄悄變成加密的輸出

容器政策會在任何密碼被嘗試之前先從 /Encrypt 字典讀出。對 /V 為 1 與 2 的情況,每個串流都用檔案金鑰加密。至於 crypt filter,HotPDF 透過 /CF 解析 /StmF:Identity 篩選器或 None 的 /CFM 代表明文容器,而 V2 與 AESV2 代表加密容器。答案落在 FReloadObjectStreamsEncrypted,而它在一個特定情況下要緊。當容器是明文而字串不是時,成員帶著必須逐一解密的加密字串,所以 MaterializeMembersOfPlaintextObjectStreams 會在逐物件解密那一趟之前先展開每一個壓縮成員。當政策還不知道時它什麼都不做,當容器本身是加密的時候它也什麼都不做,因為加密容器的成員已經跟著容器一起被解密過,絕不能再解密第二次

當一個容器無法被解密時會怎樣

解密失敗的容器會被隔離,而不是致命。type-2 那一趟會在 FObjStmQuarantine 裡記錄一筆 THPDFObjStmQuarantineInfo,帶著容器的物件編號、一個 THPDFObjStmQuarantineReason、一段診斷字串,以及交叉參照原本導向該容器的成員物件編號清單。osqrDecryptFailed 會在四種不同情況下被提出:沒有任何 crypt filter 可解析、AES-256 或 AES-GCM 解密丟出例外、舊式 RC4 或 AES-128 解密丟出例外,或根本沒有可用的檔案金鑰。獨立的容器會繼續載入,所以一份有一個損壞容器的文件仍然開得起來,也仍然能把不依賴它的每一頁都渲染出來

HotPDF 在已載入 PDF 上的解密隔離如何運作:解密丟出例外的容器會被記錄成一筆帶 osqrDecryptFailed 原因與其成員物件編號的 THPDFObjStmQuarantineInfo,獨立容器繼續載入,而 BeginDoc 會在改寫來得及回報成功之前先對第一筆失敗項目丟出例外
隔離紀錄能在剖析器後備路徑之後存活,而 BeginDoc 是按名字而不是按加密旗標檢查它們,所以有一份損壞容器的文件仍然開得起來,而改寫路徑則停下來,不會寫出空物件

隔離清單能在剖析器後備路徑之後存活。如果主要的交叉參照載入失敗,而 HotPDF 靠掃描檔案重建物件表,第一次嘗試得到的加密旗標可能無法在那次重建中存活,但隔離紀錄可以。這就是為什麼 BeginDoc 檢查的是隔離清單而不是加密旗標:對一份已載入的文件,它走過 FObjStmQuarantine 並在第一筆 osqrDecryptFailed 項目上丟出例外,指名那個容器並要求用有效密碼重新載入。越過那一點繼續的改寫,會把該容器本應持有的成員寫成空物件,然後回報成功。您可以透過公開存取器自己更早跑同樣的檢查,並套用自己的政策:

var
  Info: THPDFObjStmQuarantineInfo;
  I: Integer;
begin
  Pdf.LoadFromFile('vendor-form.pdf');   // 空的使用者密碼
  for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
    if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
       (Info.Reason = osqrDecryptFailed) then
      raise Exception.CreateFmt(
        'Object stream %d is unreadable (%s); %d members unresolved',
        [Info.ContainerObjNum, String(Info.Diagnostic),
         Length(Info.MemberObjNums)]);
  // 到這裡才安全,可以改寫
end;

其他隔離原因涵蓋非加密類的失敗:容器不是串流、字典缺失、/N 或 /First 無效、串流大小超出可接受範圍、解壓縮失敗、/First 指到資料之外,或成員主體解碼了卻剖析不了。這些都值得在收件時記錄下來,因為每一個都指名了您之後會缺哪些成員

為什麼改寫需要原始數值 token

HotPDF 把每個數值物件存成 Single,而 Single 無法重現一個實數的來源文字。ISO 32000-1 §7.3.3 允許寫入器對同一個值送出 0.750000、.75 或 0.75,而這些在經過 24 位元二進位與通用格式化器的一趟來回之後,沒有一個能原樣存活。更糟的是,像 0.7 這樣的值在 Single 裡根本無法表示;它會剖析成最接近的浮點數,而重新格式化那個浮點數可能產出 0.69999999 或某個四捨五入後的鄰居,取決於數位迴圈。落在填色或 /CA 透明度常數上,那就是 8 位元通道裡的一格差異,足以讓與來源的像素比對失敗,而在漸層邊界上,足以讓人看得出來

THPDFNumericObject.RememberSourceToken 為未修改的情況解決了這件事。剖析器在指定 Value 之後立刻用原始 token 呼叫它;這個方法只接受由數字、最多一個小數點與一個選用的前置正負號組成的 token,並把該 token 連同它對應的值一起存進 FSourceValue。SourceToken 屬性只在 Value 仍然等於 FSourceValue 時才回傳存下的文字。改動那個數字,token 就蒸發了,所以被修改的值永遠走既有的格式化路徑,絕不會送出過期的文字。SaveNumericObject 先檢查 SourceToken,存在就逐字寫出,只有在數字是在記憶體中建立或編輯過時,才落到整數、色彩空間參照與分數分支

這條不變式很小,值得直說:您沒碰過的數字,用它被讀進來時的位元組寫出;您碰過的數字,由 HotPDF 自己的格式化器寫出。壓縮成員與檔案主體物件一樣受益,因為 EnsureCompressedObjectLoaded 對成員切片跑的是同一個剖析器。數字格式化本身,以及它對行程 locale 的獨立性,記在HotPDF 中不受 locale 影響的 PDF 數字格式化這篇

針對物件串流測試改寫路徑

三項檢查就能抓到上述每一種失效,而且都不需要 Acrobat。第一,在存檔之後比對 IndexedObjectCount 與 MaterializedObjectCount;完整改寫時兩者必須相等,任何落差就是一個被丟掉的成員。第二,對兩個檔案都做文字抽取與結構樹列舉,不是只渲染,這樣弄丟的 ActualText 或弄丟的結構元素就會以 diff 的形式現形。第三,用全新的實例載入輸出,並斷言 GetLoadedQuarantinedObjStmCount 為零,這也證明了寫入器沒有產出讀取器打不開的容器。決定 FReloadObjectStreamsEncrypted 的 crypt filter 組合,整理在StmF、StrF 與 EFF 政策這篇。這個故事的寫入端,也就是如何送出物件串流、以及什麼時候該偏好增量更新而不是改寫,在物件串流與增量更新指南裡

延遲成員載入、寫入器之前的展開、解密隔離與來源 token 保留,全都在 HotPDF Delphi Component 裡出貨,支援 Delphi 與 C++Builder。如果您想拿 GetLoadedObjectStreamCacheInfo 與那些隔離存取器對照自己的收件流程,產品頁有 API 參考的連結