技術文章

在 Delphi 中使用 PDFium 元件將掃描影像合併為單一 PDF

理賠處理團隊擁有三十年的紙本檔案,需要透過饋紙式掃描器進行處理;掃描器將每頁輸出一個 JPEG 檔案到資料夾中,命名為 0001.jpg0002.jpg 等;而歸檔實際需要的是每個案卷一個 PDF 檔案,且頁面依序排列,以便審查人員可以開啟單一文件,而不用逐一按下一百個影像縮圖;這最後一個步驟,也就是將一堆有編號的掃描檔轉換為單一且依序排列的 PDF 檔案,正是本篇的任務

PDFium 元件可以直接處理此工作;除了轉譯和文字擷取之外,該元件還可以從頭建置 PDF:建立一個空文件、新增一個您所期望大小的空白頁面、在該頁面的使用者空間座標上放入影像,然後儲存;整個流程都包含在 TPdf 元件中,因此批次轉換器只需透過檔名的迴圈加上少數的呼叫即可完成

轉換的架構

每個掃描檔都必須進行三個步驟:決定頁面大小、在頁面中放置影像並保留邊距,然後前往下一頁;PDFium 元件為每個步驟各提供了一個方法:AddPage 用於建立指定大小的空白頁面,AddImage(或者如果您已持有 TPicture,則使用 AddPicture)用於將點陣圖繪製到當前頁面,而 PageNumber 則告訴元件後續的繪製呼叫所針對的頁面

最容易讓人混淆的細節是座標系統;PDF 使用者空間將原點設在頁面的左下角,Y 軸向上遞增,這與 Delphi 開發人員憑直覺使用的螢幕座標相反;您傳遞給 AddImageX, Y 是影像矩形的左下角,而 Width, Height 是以點(points)為單位的放置大小,而非來源檔案的像素大小;如果弄反了,掃描影像將會超出頁面範圍,或者與您預期的方向相反甚至上下顛倒

建立文件並為每個掃描檔新增頁面

首先從一個空文件開始;CreateDocument 會分配一個全新的 PDF 並使元件保持作用狀態,因此沒有獨立的開啟步驟;接著,您只需歷遍掃描檔案的列表,為每個檔案新增一個頁面、將其設為當前頁面並放置影像;此處的頁面尺寸採用以點為單位的 A4(直向為 595 × 842),這是歸檔信件的標準紙張大小

procedure TArchiveForm.ScansToPdf(const Files: TStrings; const OutputPath: string);
const
  PageW = 595.0;   // A4 width in points
  PageH = 842.0;   // A4 height in points
  Margin = 36.0;   // half-inch border around each scan
var
  I: Integer;
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;                       // new, empty, already active
    for I := 0 to Files.Count - 1 do
    begin
      Pdf.AddPage(I + 1, PageW, PageH);       // 1-based page index
      Pdf.PageNumber := I + 1;                // make the new page current
      PlaceScan(Pdf, Files[I], PageW, PageH, Margin);
    end;
    Pdf.SaveAs(OutputPath);
  finally
    Pdf.Free;
  end;
end;

每次迭代都會建立一個頁面,並立即將 PageNumber 設定為該頁面;這第二行程式碼至關重要:AddPage 雖然插入了頁面,但繪製方法是在當前頁面上執行,因此設定 PageNumber 才能將 AddImage 定位到您剛剛建立的頁面;如果忽略此步驟,您的影像將會重疊到之前剛好載入的頁面上

在該迴圈中隱含了一個假設:也就是 Files 的順序;掃描器會將頁面命名為 0001.jpg0100.jpg,但目錄列舉並不總是返回排序後的結果,當您遇到 page9.jpgpage10.jpg 時,單純的字串排序會將第 10 頁排在第 9 頁之前;因此在進入迴圈前應明確對清單進行排序,並在掃描時優先使用補零的名稱,以便字典順序能與頁面順序一致;頁面順序是審查人員會立即注意到的地方,也是預防成本最低的錯誤

放置掃描檔並維持其長寬比

掃描影像的形狀很少與頁面完全相同;如果您拉伸它以填滿版面,將會導致文字變形;如果以完整的像素大小放置,則會超出邊界;解決方法是依據寬度符合或高度符合這兩個比例中較小的一個進行縮放,並將剩餘的空間居中對齊;由於原點位於左下角,居中意味著將剩餘空間均分並同時加到 XY

procedure TArchiveForm.PlaceScan(Pdf: TPdf; const FileName: string;
  PageW, PageH, Margin: Double);
var
  Pic: TPicture;
  AvailW, AvailH, Scale, DrawW, DrawH, X, Y: Double;
begin
  Pic := TPicture.Create;
  try
    Pic.LoadFromFile(FileName);              // BMP, JPG, PNG, etc. via the VCL graphics units

    AvailW := PageW - 2 * Margin;
    AvailH := PageH - 2 * Margin;

    // Fit inside the margins without distorting the scan.
    Scale := Min(AvailW / Pic.Width, AvailH / Pic.Height);
    DrawW := Pic.Width * Scale;
    DrawH := Pic.Height * Scale;

    // Center: leftover space split evenly. Y measured from the page bottom.
    X := (PageW - DrawW) / 2;
    Y := (PageH - DrawH) / 2;

    Pdf.AddImage(FileName, X, Y, DrawW, DrawH);
  finally
    Pic.Free;
  end;
end;

這會載入檔案一次以讀取其像素尺寸,計算出單一且一致的比例,並將放置矩形傳遞給 AddImageAddImage 直接接受檔案路徑,並透過與 AddPicture 相同的影像流程進行處理,因此 VCL 圖形單元可識別的任何格式均可運作,無需進行特殊處理;如果您已經在預覽窗格中將影像解碼為 TPicture,只需呼叫具有相同矩形的 AddPicture(Pic, X, Y, DrawW, DrawH),即可跳過第二次的檔案讀取

跳過 JPEG 掃描影像的解碼步驟

掃描器幾乎總是輸出 JPEG 格式;將 JPEG 載入到 TPicture 會將其解碼為點陣圖,然後 PDFium 在儲存時再次對其進行重新編碼,這造成了兩次不需要的有損轉換;AddJpegImage 則可以將原始壓縮位元組從資料流中直接內嵌到頁面中,這對於大批量的轉換來說,速度更快且視覺效果更清晰

var
  Stream: TFileStream;
begin
  // ... after AddPage + PageNumber for the current page ...
  Stream := TFileStream.Create(FileName, fmOpenRead);
  try
    // Embeds the JPEG bytes as-is; no decode/re-encode cycle.
    Pdf.AddJpegImage(Stream, X, Y, DrawW, DrawH);
  finally
    Stream.Free;
  end;
end;

您仍然需要以相同的方式計算 XYDrawWDrawH,因為您需要像素尺寸來進行縮放;您可以從檔案讀取這些資訊,或是進行快速的標頭解析,然後將原始資料流交給 AddJpegImage;對於 PNG 或 TIFF 掃描影像,AddImage 路徑才是正確的選擇,請將 JPEG 捷徑保留給實際適用的格式

標記每個頁面

當每個頁面都帶有其來源檔名時,歸檔的掃描影像會更容易進行稽核;AddText 在使用者空間座標上繪製字串,因此說明文字會剛好位於影像的下方;請記住反轉的 Y 軸:要在掃描影像下方放置標籤,您必須減去影像的底邊,而不是加上它

// Caption below the scan: Y decreases toward the page bottom.
Pdf.AddText('File: ' + ExtractFileName(FileName), 'Helvetica', 9,
  X, Y - 14, clGray);

關於儲存的最後一點

SaveAs 是一個傳回布林值(Boolean)的函式,因此在生產環境的程式碼中,請檢查其傳回結果,而不是直接假設寫入已成功;否則磁碟空間不足或受鎖定的輸出路徑會導致失敗而無任何提示;一旦迴圈結束且檔案寫入完成,您就擁有了歸檔所需的精確成果:每個案卷一個依序排列的 PDF 檔案,且頁面已縮放以符合大小,隨時可以在任何檢視器中閱讀

相同的建置區塊也適用於相關的工作;更換每頁的大小調整規則,您就可以得到每張紙一張影像的相簿;保留迴圈但從 TIFF 多頁來源讀取,您就擁有了一個傳真存檔轉換器;如果您想了解以程式化方式建置 PDF 的更廣泛概念,請參閱在 Delphi 中使用 PDFium 元件從頭建立 PDF 文件;若要在日後將結果轉譯回螢幕上,請參閱在 Delphi 中使用 PDFium 元件將 PDF 頁面轉換為 JPEG 影像

來自 loslab.com 的 PDFium 元件 整合了本系列中使用的文件建立、轉譯和文字 API