PDFium Component 使用 BuildReflowDocument,把固定版面的 PDF 轉換成可重排的語意模型,再透過 ToHtml 把這個模型匯出為自成一體的 HTML。標題仍是標題,清單項目仍是清單項目,頁面上偵測到的表格會化為真正的表格標記,並保留標題儲存格與合併範圍。輸出結果不會參照任何外部指令碼或樣式表
之所以想要這項功能,是因為一個 PDF 頁面本質上是一組已定位的字符,這對手機螢幕、螢幕報讀器或搜尋索引來說剛好是最不適合的形式。任何靠擷取純文字來解決這個問題的嘗試,都會失去讓文件可讀的結構;任何靠把頁面轉成影像來解決的嘗試,則會完全失去文字。重排模型兩者兼顧:文字本身,以及文字之間的關係
語意資訊從何而來?
一切都始於 GetStructuredText,這是這個元件中文字與語意的唯一來源。當 PDF 帶有結構樹,也就是 ISO 32000-1 第 14.7 節定義的標記化 PDF 時,模型會遵循產生器記錄下來的邏輯階層。當文件沒有結構樹時(而現實中多數 PDF 都沒有),模型會退回到已經為了閱讀順序而算出的實體版面順序
這個選擇維持了一條硬性界線:不會為了回答既有引擎已能回答的問題,就再引入第二套 PDF 剖析器或第二套轉譯引擎。底層的閱讀順序機制說明於 結構化文字區塊與閱讀順序 一文,而重排模型是架在其上的一層語意層,而非取代它
每個節點都會記錄其資訊來源,因此使用端能區分「文件本身宣告的標題」與「版面配置啟發式推論出的標題」。對信心值敏感的流程,應該讀取這個欄位,而不是把所有節點都當成同等權威
一棵扁平樹,以及為何它不是物件樹
這個模型是一棵前序展平的樹:一個陣列,其中每個節點各帶有一個 ParentIndex 與一個 Depth,而不是遞迴式記錄或帶有擁有關係的物件圖。頁面、標題、段落、清單、清單項目、圖形、圖說、表格、列與儲存格全都存在於這同一個線性陣列中
這帶來兩項好處。使用端可以依序串流走訪這個陣列而不需要遞迴,這讓輸出 HTML、Markdown 或樹狀檢視都成了單純的迴圈。而且這種版面配置能跨 Delphi、C++Builder 與 Free Pascal 保持可攜性,這三者在如何處理跨 ABI 邊界的遞迴式受管理型別上各有不同。一個遞迴式的動態陣列記錄,正是那種在每個地方都能編譯通過、卻在每個地方行為都有些微差異的建構
uses
PDFium;
var
Pdf: TPdf;
Options: TPdfReflowOptions;
Doc: TPdfReflowDocument;
I: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'report.pdf';
Pdf.LoadDocument;
Options := TPdfReflowOptions.Default;
Options.FullDocument := True;
Options.DetectTables := True;
Options.IncludeCss := True; // 內嵌樣式區塊,沒有外部檔案
Options.MaxNodes := 200000; // 失敗即關閉的預算上限
Options.MaxCharacters := 4000000;
Doc := Pdf.BuildReflowDocument(Options);
for I := 0 to High(Doc.Nodes) do
case Doc.Nodes[I].Kind of
prnkHeading:
Writeln(Format('%sH%d: %s', [StringOfChar(' ', Doc.Nodes[I].Depth),
Doc.Nodes[I].HeadingLevel, Doc.Nodes[I].Text]));
prnkParagraph:
Writeln(Format('%sp: %s', [StringOfChar(' ', Doc.Nodes[I].Depth),
Copy(Doc.Nodes[I].Text, 1, 60)]));
prnkTable:
Writeln(Format('table on page %d', [Doc.Nodes[I].PageNumber]));
end;
Writeln(Format('%d node(s), %d table(s), %d character(s)',
[Length(Doc.Nodes), Doc.TableCount, Doc.CharacterCount]));
finally
Pdf.Free;
end;
end;
如何避免表格重複出現兩次?
表格偵測是在某一頁的結構化文字都已蒐集完畢之後才執行,這帶來一個明顯的隱憂:同一份儲存格內容,同時存在於文字區塊與偵測出的表格中。兩者都輸出,就會產生每張表格後面又跟著自己的內容、以鬆散段落形式重複一次的 HTML
解決這個問題的規則是幾何性的。當一張偵測出的表格覆蓋的面積超過某個文字區塊面積的一半時,該表格節點會取代那個區塊,而不是與它並存。列內的儲存格索引是靠分桶計數建構的,因此建置模型的成本只會隨儲存格數加上列數成線性成長,而不必為每一列重新掃描每個儲存格,這在單一頁面就能容納數百個儲存格的財務文件上很重要
被偵測出的結構對「這是偵測結果」這件事保持坦誠。有繪製格線的表格,比純靠留白對齊的表格更能被可靠識別,節點的信心值也反映出這一點。對於「一張錯誤的表格勝過完全沒有表格」的內容,保持偵測功能開啟;對於封存轉換這種「錯誤的表格比沒有表格更糟」的場景,則依信心值來把關
匯出保持自成一體的 HTML
ToHtml 走訪的是已經建置完成的模型,從不會回頭再次呼叫 PDFium,因此匯出兩次不會多花任何成本,也不可能對同一份模型產生不同的結果。文字與屬性值一律統一跳脫,標題層級會被限制在 HTML 實際定義的 h1 到 h6 範圍內,標題儲存格、RowSpan 與 ColumnSpan 則原樣傳遞
選用的 CSS 是一個純內嵌樣式區塊。沒有任何指令碼、沒有網頁字型、也沒有任何一種外部資源,這正是讓輸出結果能安全嵌入電子郵件、說明檢視器或沙箱瀏覽器控制項的原因:
var
Html: WideString;
Stream: TFileStream;
Bytes: TBytes;
begin
Options := TPdfReflowOptions.Default;
Options.FullDocument := True;
Options.IncludeCss := True;
Options.IncludePageSections := True; // 保留頁面邊界,讓它可見
Options.PreserveLineBreaks := False; // 讓瀏覽器自行換行段落
Html := Pdf.BuildReflowDocument(Options).ToHtml;
Bytes := TEncoding.UTF8.GetBytes(string(Html));
Stream := TFileStream.Create('report.html', fmCreate);
try
if Length(Bytes) > 0 then
Stream.WriteBuffer(Bytes[0], Length(Bytes));
finally
Stream.Free;
end;
end;
PreserveLineBreaks 是最值得思考的選項。PDF 的換行是針對固定頁寬所做的排版決策,若原樣保留在窄螢幕上,正好重現了重排原本要解決的問題。詩詞、程式碼列表與地址請保留換行;一般散文則應捨棄
預算、取消與頁面狀態
字元、節點、表格與儲存格各自都有上限,且都在配置記憶體之前檢查,而不是之後,因此一份格式錯誤或惡意的文件會乾淨地失敗,而不是一路耗用記憶體直到別的東西先出問題。取消權杖會在頁面、區塊、表格、列與儲存格的邊界處檢查,讓一份千頁文件的掃描在被取消時仍保持反應
有一項行為對 GUI 應用程式特別重要:整份文件掃描是在一個會還原作用中頁面的作用範圍內執行,因此成功、預算失敗與取消,都會讓呼叫端目前的頁面保持不變。讓使用者在檢視第 340 頁時匯出的檢視器,事後仍然停在第 340 頁
重排適合做什麼、不適合做什麼
重排輸出對搜尋索引、無障礙閱讀檢視、行動裝置顯示與內容遷移而言都是絕佳的輸入。它不是一個保真度優先的轉換器:絕對位置、精確字型、向量美術與精準頁面幾何,依設計就在它的目的之外。當工作需要頁面看起來一模一樣時,請轉譯它;當工作需要頁面在別處也能被閱讀時,請重排它
特別是對輔助科技而言,重排模型能與 建置無障礙閱讀器 一文所述的閱讀功能搭配使用,帶有真正結構樹的文件會產生明顯更好的模型,這也是為什麼應該在上游驗證標記正確性的好理由,說明於 PDF/UA 結構樹驗證 一文
重排、結構化文字、標記驗證與轉譯,在 Delphi、C++Builder 與 Lazarus 中共用同一個文件物件;完整 API 說明於 PDFium Component for Delphi 頁面