技術文章

在文字編輯器中手動編輯 PDF,然後修復它

HotPDF 可將已載入的文件以專為人類閱讀與編輯而設計的診斷格式儲存,並在你編輯完成後修復該檔案。SaveLoadedDocumentDiagnostic 會以確定性的字典鍵順序寫出文件,每個物件都是一般的間接物件,不使用物件串流、不使用交互參照串流,也不進行線性化。RepairDiagnosticDocument 接著會重建被你的編輯破壞掉的位元組偏移量、串流長度、交互參照表與 trailer

這一組工具是為了單一工作流程而存在:理解或修正一個 PDF 內部實際存放的內容。每個曾經除錯渲染問題的開發者,都曾想過打開檔案、在某個字典中改一個數字,然後看看結果如何。在一般的 PDF 中,這是不可能做到的,因為內容被壓縮進物件串流中,而你插入的每個位元組都會使交互參照表精確記錄的偏移量全部偏移

診斷式儲存有何不同?

六項設定會一起改變。字典鍵以確定性順序寫出,因此同一份文件的兩次儲存結果可以互相比對。診斷輸出模式會為結構加上註記。強制進行完整重寫,因此不會有任何內容以未觸碰的增量修訂形式留存。線性化被關閉,因為線性化檔案是為網頁檢視器組織的,而不是為閱讀者組織的。交互參照串流被關閉,因此該表是純文字格式。物件串流也被關閉,因此不會有任何物件被隱藏在壓縮容器內

結果是一個檔案,其標頭在一般 %PDF- 簽章旁還帶有診斷標記,其本文可在任何編輯器中逐頁翻閱。物件依序逐一出現,字典清晰可讀,內容串流也位在你找得到的位置。修復工具會檢查該標記,並拒絕缺少此標記的檔案,因為一般 PDF 那種緊湊結構,並不是位元組範圍修復工具應該嘗試解讀的對象

示意圖:對照一般的正式環境 PDF 儲存與 HotPDF 診斷式儲存,後者寫出可直接閱讀的物件,以及可用任何文字編輯器開啟的純文字 xref 表
一般儲存把物件隱藏在壓縮串流內,診斷寫入器則把每個物件各自獨立排成一個區塊,並附上純文字交互參照表;輸出的檔案可在任何編輯器中逐頁翻閱與修改
uses
  HPDFDoc;

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('invoice.pdf') <= 0 then
      Exit;
    // 可讀格式,每個區塊一個物件,物件串流中沒有隱藏內容
    Pdf.SaveLoadedDocumentDiagnostic('invoice-diagnostic.pdf');
  finally
    Pdf.Free;
  end;
end;

為何編輯會破壞檔案,又有哪些內容必須重建?

PDF 中有三項內容會編碼位元組位置或位元組數量。交互參照表把每個物件編號對映到一個位元組偏移量。檔案結尾的 startxref 值指向該表。而每個串流字典都帶有一個 /Length,說明該串流主體佔用多少位元組

只要在檔案結尾之前的任何位置改動一個字元,其後的所有偏移量就全錯了。在內容串流中加一行,它的 /Length 也會跟著錯。大多數檢視器會直接拒絕這個檔案,而那些能救回檔案的檢視器,是靠掃描來還原,這只是掩蓋錯誤,而不是修正錯誤。修復功能會根據位元組目前的實際內容,重建這三項全部

示意圖:手動編輯造成位元組偏移量位移,使交互參照表、startxref 指標與串流長度全部失準,直到 RepairDiagnosticDocument 依實際版面配置重建這三項
新增位元組會使其後的每個偏移量都往後移,導致記錄下的位置、startxref 指標與宣告的串流長度不再與實際內容相符;修復功能會依編輯後檔案目前的實際內容,重建每一項數值
var
  Options: THPDFDiagnosticRepairOptions;
  Report: THPDFDiagnosticRepairReport;
begin
  Options := THPDFDiagnosticRepairOptions.Default;
  Options.VerifyOutput := True;         // 替換前先載入結果

  if THotPDF.RepairDiagnosticDocument('invoice-diagnostic.pdf',
       'invoice-fixed.pdf', Options, Report) then
    Writeln(Format('%d objects, %d streams, %d lengths corrected, ' +
      '%d xref rows rebuilt, %d pages verified',
      [Report.ObjectCount, Report.StreamCount,
       Report.RepairedStreamLengthCount, Report.RebuiltXRefEntryCount,
       Report.VerifiedPageCount]))
  else
    Writeln(Format('repair failed (%d): %s',
      [Ord(Report.Status), string(Report.Diagnostic)]));
end;

複製位元組,而不是重新序列化

讓這個工具值得信任的設計決策,在於它從不把文件整份載入後再重新寫出。它只複製已驗證物件的位元組範圍,只改動有誤的 /Length 值,並在這些內容周圍重建交互參照表、trailer 與 startxref

載入後重新序列化的實作方式會簡單得多,但也會悄悄改變許多內容。未知的物件語法會被正規化,字串跳脫字元會被改寫成寫入器偏好的形式,已編碼的串流內容也可能被重新編碼。對一個除錯工具而言,這是不可接受的:你只改了一個數字,差異比對就應該只顯示一個數字,而不是一千個無關緊要的格式變動。若你想知道兩個檔案之間到底改變了什麼,那屬於比對問題,涵蓋於依結構與像素比較兩份 PDF 檔案一文

示意圖:對照 HotPDF 修復逐位元組複製已驗證範圍,與載入後重新序列化路徑會正規化語法、改寫字串跳脫字元並重新編碼串流內容
修復功能逐位元組複製已驗證的範圍,只修補有誤的 /Length 值,因此你手動做出的差異比對能完整保留;重新載入並序列化則會悄悄改寫你從未動過的語法、跳脫字元與編碼方式

避免產生虛假物件的兩條剖析規則

位元組範圍掃描器面臨一個明顯的陷阱:看起來像物件標頭的文字,可能出現在串流內容或字串內部。天真的掃描方式會產生一筆指向內容串流中間的交互參照列,導致產生的檔案以一種新的、更令人困惑的方式損壞

HotPDF 的做法是跳過每個已驗證物件的完整位元組範圍,使用其記錄的結束偏移量,因此物件本體內部絕不會被視為候選項目。在物件與物件之間,只允許 PDF 空白字元與註解;其餘任何內容都代表這個區域無法被理解,其位元組不得被納入輸出

串流長度同樣需要謹慎處理。真正的內容長度不包含寫入器在 endstream 之前插入的行尾分隔符。當宣告的長度恰好落在該標記上時,該宣告會被接受。若不是,修復工具會在有限範圍內搜尋 endstream 與 endobj 這一對標記,並精確扣除一個分隔符——CR、LF 或 CRLF。這一步只要錯一個位元組,就會產生一個能打開、能渲染頁面,卻在內容串流結尾多出一個雜散運算子的檔案

trailer 允許保留哪些內容

重建後的 trailer 並不是舊 trailer 的複製品。/Size、/Prev 與 /XRefStm 都描述的是先前的檔案配置,因此會被重新計算或直接捨棄。只有目標已通過驗證的項目才會保留:/Info、/ID 與 /Encrypt。而 /Root 必須恰好解析到一個 Catalog 物件——不能是零個,也不能是多個,而多個 Catalog 正是靠複製貼上物件區塊拼湊出的檔案常見的結果

輸出安全性的處理方式,正是一個作用於使用者重視之檔案的工具所應有的做法。結果會先寫入目標目錄中的暫存檔,再透過關閉交互參照復原功能的一般路徑載入以進行驗證,因此一個只能靠復原機制才打得開的檔案會被視為失敗。只有在頁面樹通過驗證之後,暫存檔才會以原子方式取代目標檔案。修復失敗絕不會覆寫既有的輸出

額度與誠實的失敗代碼

三項限制約束了這項工作:輸入位元組數、候選物件數量與最大物件編號,預設分別為 8 GiB、兩百萬與兩百萬。物件編號上限的重要性超乎表面看起來的樣子,因為若有一份稀疏表,其中少數物件的編號高達數十億,若不加限制,重建後的交互參照表就會被灌到極其龐大。傳統交互參照表也會拒絕需要超過十位十進位數字表示的偏移量,這是格式本身的限制,而非實作上的選擇

失敗時會回報具體狀態碼,而不是單純的布林值:引數無效、來源檔案過大、不是診斷檔案、物件數量上限已超出、物件無效、物件重複、缺少 Catalog、trailer 無效、輸出錯誤,或驗證失敗。每一種都對應不同的修正方式。物件重複代表你把某個物件區塊貼了兩次。缺少 Catalog 通常代表持有 /Type /Catalog 的物件已損壞。驗證失敗代表結構目前已經一致,但頁面樹無法載入,這通常是 /Kids 陣列被編輯過,卻與其 /Count 不同步所致

還有兩個實務上的重點要收尾。請保留診斷檔案,而不是只留下修復後的檔案,這樣下一次編輯才能從一個可讀的基礎開始。也不要把診斷輸出交付給終端使用者:由於沒有任何內容被壓縮進物件串流,它會比一般儲存的檔案更大,正式環境要產出精簡檔案,應使用物件串流與增量更新一文所述的一般儲存路徑。若目標是找出問題所在而非修改它,自動化預檢報告一文所述的自動化路徑會比逐位元組閱讀快得多

診斷式儲存、修復、預檢與比對,在 Delphi 與 C++Builder 中都作用於同一個已載入的文件模型;完整功能清單請見HotPDF Delphi PDF 元件頁面