PDFiumPas 會把頁面文字以結構化形式回傳,而不是一整串字串。GetStructuredText 會產生一個 TPdfStructuredTextPage,內含區塊,每個區塊內含多行,每一行內含樣式化片段,每一層都附有頁面座標邊界,並保留原始字元索引,讓任何片段都能對應回底層的文字頁面
多數程式碼一開始都會用到的扁平字串擷取方式依然存在,對它原本的用途而言依然正確。但只要你需要知道哪些文字屬於標題、哪些屬於左欄、或是某個相符結果究竟在頁面上的什麼位置,這種方式就不再夠用了
為什麼扁平字串對多數工作來說是錯誤的輸出?
因為人們對擷取文字提出的問題,幾乎從來不是「這一頁上有哪些字元」。他們問的是「標題是什麼」「這是不是一個表格」「這個段落是不是屬於第 4 節」「這個標記該畫在哪裡」。單一字串一個問題都答不了,而你從它重建出來的每一個答案,都是一套你得自己維護的推測
雙欄版面正好能說明這一點。把一篇雙欄文章擷取成字串,依產生器寫入內容串流的方式不同,你可能會得到「第一欄接著第二欄」,也可能得到「第一欄第一行、第二欄第一行、第一欄第二行」這樣一路往下的順序。兩者都可能出自一份合規的 PDF。兩者在格式層面都沒有錯,因為 PDF 描述的是頁面上的標記,不是文件大綱。以區塊為基礎的模型,能讓擷取器把排序決策明確做出來,並告訴你它做了哪一種決定
依內容順序,還是依實體版面?
TPdfStructuredTextOptions.ReadingOrder 可在 roContentOrder 與 roPhysicalLayout 之間選擇,正確答案取決於你更信任誰:產生器,還是幾何位置
內容順序會依內容串流繪製的先後順序回傳文字。這樣做速度快,而且對於行為良好的產生器所產生的文件而言,通常就是預期中的閱讀順序。實體版面則會忽略串流順序,改依字元實際所在位置重建順序,先聚合成行,再聚合成欄。這正是掃描後經過 OCR 的頁面、依字型順序而非閱讀順序輸出文字的工具,以及任何只能依靠視覺結果來判斷的情況所需要的方式
uses
PDFium;
var
Pdf: TPdf;
Options: TPdfStructuredTextOptions;
Page: TPdfStructuredTextPage;
B, L: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'article.pdf';
Pdf.LoadDocument;
Pdf.PageNumber := 1; // 以 1 起算
Options := TPdfStructuredTextOptions.Default;
Options.ReadingOrder := roPhysicalLayout;
Options.IncludeFontInfo := True;
Options.IncludeSemantics := True;
Options.MaxCharacters := 200000; // 失效即中止的預算
Page := Pdf.GetStructuredText(Options);
for B := 0 to High(Page.Blocks) do
begin
if Page.Blocks[B].Kind = cfHeading then
Emit(Format('H%d: %s',
[Page.Blocks[B].HeadingLevel, Page.Blocks[B].Text]))
else
for L := 0 to High(Page.Blocks[B].Lines) do
Emit(Page.Blocks[B].Lines[L].Text);
end;
finally
Pdf.Free;
end;
end;
標記能提供幾何位置給不了的什麼?
意圖。啟用 IncludeSemantics 後,來自已標記 PDF 的區塊會攜帶一個取自結構樹的 Kind,因此一個標題之所以是標題,是因為產生器這麼宣告的,而不是因為它的字型比平均值大。這些類型涵蓋了對重複使用而言重要的形狀:cfParagraph、帶有 HeadingLevel 的 cfHeading、cfListItem、cfTableCell、cfCaption、cfFigure,以及未標記時的預設值 cfPlain
Source 欄位記錄每一項分類的來源,結構樹來源記為 rosStructure,推斷來源記為 rosHeuristic,這正是在判斷一套擷取管線在整批文件上該有多大信任度時,該去查看的欄位。圖片是一個值得留意的特例:對於 cfFigure 區塊,文字來自替代描述,而不是來自任何字型符號,因為一張圖片本身沒有字元。沒有比對到的替代文字仍然會被呈現出來,而不是被捨棄,這正是讓無障礙稽核能看出「頁面上什麼都沒畫、卻確實存在一段描述」的關鍵。標記模型本身涵蓋在 PDF/UA 結構樹驗證 中
片段承載樣式與出處資訊
每一個 TPdfStructuredTextSpan 都帶有自身文字、頁面座標邊界、FontName、FontSize、FontWeight 與 Angle,加上 SourceStartIndex 與 SourceCharacterCount。片段會在樣式改變處斷開,因此一句含有三個粗體字詞的句子會變成三個片段,要在 HTML 或 Markdown 中重建強調樣式,只需要讀取屬性,而不必從字型名稱去猜測
那兩個原始索引欄位,正是把「擷取」變成一項功能、而不只是一份報告的關鍵。它們指回頁面的字元序列,代表你在搜尋中比對到的一個區塊,不必再花第二趟、順序又不同的掃描,就能轉換成字元層級的選取幾何或標記矩形;其中的機制說明見 以字元框進行視覺化文字選取。Angle 欄位比看起來更重要:印章或浮水印中旋轉的文字,會落在與內文相同的座標空間中,一套忽略角度的管線,很可能把一個斜著印的「DRAFT」硬生生合併進一段內文的正中間
預算,以及兩個品質計數器
MaxCharacters 是一個失效即中止的預算,不是截斷設定:超出它的頁面會直接中止,而不會靜默回傳部分內容。在不受信任的接收路徑上,這正是你想要的行為,因為一頁多達百萬字元,要麼是機器產生的異常內容,要麼就是有人想讓你的擷取器變成系統中最慢的一環
回傳的頁面上有兩個計數器,能直接反映擷取品質。UnmappedCharacterCount 計算的是沒有可用 Unicode 對映的字元,這是子集字型內嵌時未附 /ToUnicode CMap 的經典症狀;這種文字渲染起來完全正常,擷取出來卻毫無用處。GeometryFailureCount 計算的是無法判斷邊界框的字元,這會削弱實體版面排序的準確度。兩者都應該記錄下來。一批文件若這兩個數字持續接近零,就可以放心索引;若不是,就代表管線中有些產生器需要先處理,任何下游結果才值得信任
var
Page: TPdfStructuredTextPage;
B, S, L: Integer;
Emphasised: Boolean;
begin
Page := Pdf.GetStructuredText(Options);
if Page.UnmappedCharacterCount > 0 then
Log(Format('page %d: %d characters without a Unicode mapping',
[Page.PageNumber, Page.UnmappedCharacterCount]));
if Page.GeometryFailureCount > 0 then
Log(Format('page %d: %d characters without geometry',
[Page.PageNumber, Page.GeometryFailureCount]));
for B := 0 to High(Page.Blocks) do
for L := 0 to High(Page.Blocks[B].Lines) do
for S := 0 to High(Page.Blocks[B].Lines[L].Spans) do
begin
Emphasised := Page.Blocks[B].Lines[L].Spans[S].FontWeight >= 600;
AppendRun(Page.Blocks[B].Lines[L].Spans[S].Text, Emphasised,
Page.Blocks[B].Lines[L].Spans[S].SourceStartIndex);
end;
end;
在真實頁面上的效能表現
實體版面擷取是成本較高的模式,而這套實作是為真正龐大的頁面打造的:字元排序以 O(n log n) 而非重複掃描的方式執行,行與片段緩衝區以倍增方式成長、而不是逐字元重新配置,Unicode 文字在緩衝區中組建、而不是靠字串串接,相鄰文字物件的字型查詢也會被快取。這些組合,正是讓一頁 5,000 字元的密集內容維持可預期效能、而不是變成平方級成長的關鍵
對於頁數龐大的工作而言,在可行之處選用成本較低的模式仍然值得。對於你信任的已標記文件,使用啟用語意的 roContentOrder;把 roPhysicalLayout 保留給掃描與舊式素材,因為在那些情況下,幾何位置是唯一可靠的訊號。如果你只需要純文字字串,從 PDF 文件擷取文字 中描述的簡化 API 仍是速度更快的路徑;而當你需要把文字追溯回標記內容識別碼時,讀寫 BDC 與 MCID 標記內容 涵蓋了那個層次
這套區塊模型也能乾淨地對應到檢索管線所需要的東西:一個帶有段落的標題就是一個有標題的區塊,邊界資訊則讓引用能指向頁面上的一個位置,而不只是指向一份文件。PDFiumPas 是圍繞 PDFium 引擎打造的 Delphi 與 Lazarus 元件,並附有範例文件,請參見 PDFium Delphi 元件頁面