技術文章

用 PDFium Component 把掃描影像合併成一份 PDF

某個理賠處理團隊把三十年的紙本檔案送進一台饋紙式掃描器。掃描器把每一頁吐成一個 JPEG 丟進資料夾,命名為 0001.jpg0002.jpg,依此類推。而封存庫真正需要的,是每個案件檔一份 PDF、頁序正確,好讓審閱者打開單一份文件,而不是在上百張影像縮圖之間點來點去。最後那個步驟,也就是把一堆編號的掃描檔變成一份有序的 PDF,正是這裡要做的事

PDFium Component 直接處理得來。除了呈現與文字擷取之外,這個元件還能從零建置一份 PDF:建立空白文件、加入一頁您想要的尺寸的空白頁、以使用者空間座標把影像放上那一頁,然後存檔。整條流水線都活在 TPdf 元件上,所以一支批次轉換程式,就是一個檔名迴圈加上少少幾次呼叫

Delphi 的批次流水線用 PDFium Component 的 AddPage、PageNumber、AddImage 與 SaveAs 呼叫,把一整個資料夾的編號掃描檔變成一份有序的 PDF
每張掃描檔都由 AddPage 產生一頁、由 PageNumber 瞄準,再由 AddImage 畫上去;SaveAs 則把完成的文件一次寫出

這趟轉換的形狀

每張掃描檔都得發生三件事。您決定頁面尺寸,把影像放進頁面內並留下邊界,然後前進到下一頁。PDFium Component 各給您一個方法:AddPage 以指定尺寸建立一頁空白頁,AddImage(若您手上已有 TPicture,則用 AddPicture)把點陣圖畫進目前頁面,而 PageNumber 告訴元件後續的繪製呼叫要瞄準哪一頁

唯一會絆倒人的細節是座標系統。PDF 使用者空間把原點放在頁面左下角,Y 值向上遞增,與 Delphi 開發者反射性伸手去拿的螢幕座標相反。您傳給 AddImageX, Y 是影像矩形的左下角,而 Width, Height 是以點為單位的放置尺寸,不是來源檔案的像素尺寸。搞反了,您的掃描檔就會落到頁面外,或是相對於您預期的位置上下顛倒

建立文件,並為每張掃描檔各建一頁

從一份空白文件開始。CreateDocument 會配置一份全新的 PDF,並讓元件保持在作用中,所以不需要另外一個開啟步驟。接下來您走過掃描檔清單,為每一份加一頁、把它設為目前頁,再放上影像。這裡的頁面尺寸是以點為單位的 A4(直式 595 × 842),也就是封存函件的標準紙張尺寸

procedure TArchiveForm.ScansToPdf(const Files: TStrings; const OutputPath: string);
const
  PageW = 595.0;   // A4 寬度,單位為點
  PageH = 842.0;   // A4 高度,單位為點
  Margin = 36.0;   // 每張掃描檔四周半英吋的邊界
var
  I: Integer;
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;                       // 全新、空白、已在作用中
    for I := 0 to Files.Count - 1 do
    begin
      Pdf.AddPage(I + 1, PageW, PageH);       // 頁索引以 1 為起點
      Pdf.PageNumber := I + 1;                // 把新頁設為目前頁
      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

A4 頁面示意圖,說明 PDFium Component 的 AddImage 如何在 Delphi 程式碼中以左下角原點把縮放後的掃描檔放進邊界之內
AddImage 取的是放置矩形的左下角,所以貼合與置中都是從原點起算、以頁面點為單位計算出來的
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);              // 透過 VCL 圖形單元支援 BMP、JPG、PNG 等格式

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

    // 在邊界內貼合,且不讓掃描檔變形。
    Scale := Min(AvailW / Pic.Width, AvailH / Pic.Height);
    DrawW := Pic.Width * Scale;
    DrawH := Pic.Height * Scale;

    // 置中:剩餘空間平均對分。Y 從頁面底部量起。
    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 直接從串流把原始的壓縮位元組內嵌進頁面,對高量批次而言,這既更快,視覺上也更乾淨

AddJpegImage 把原始 JPEG 位元組內嵌進 PDFium Component 的頁面,而 AddImage 會解碼、SaveAs 會在 Delphi 中重新編碼那些像素
AddJpegImage 原樣內嵌掃描器產出的壓縮位元組,避開 AddImage 與 SaveAs 會做的解碼與重新編碼兩趟處理
var
  Stream: TFileStream;
begin
  // ... 在目前頁面完成 AddPage 與 PageNumber 之後 ...
  Stream := TFileStream.Create(FileName, fmOpenRead);
  try
    // 原樣內嵌 JPEG 位元組;沒有解碼與重新編碼的循環。
    Pdf.AddJpegImage(Stream, X, Y, DrawW, DrawH);
  finally
    Stream.Free;
  end;
end;

您仍然要用同樣的方式算出 XYDrawWDrawH,因為縮放需要像素尺寸。請從檔案讀出它們,或快速剖析一下檔頭,再把原始串流交給 AddJpegImage。若掃描檔是 PNG 或 TIFF,AddImage 那條路才是對的;JPEG 這條捷徑就留給它真正適用的格式

為每一頁加上標示

當每一頁都帶著它的來源檔名時,封存的掃描檔就更容易稽核。AddText 會在使用者空間座標上畫出一段字串,所以說明文字就坐在影像正下方。別忘了倒過來的 Y 軸:要把標示放在掃描檔下方,您是從影像的下緣減去,而不是加上去

// 掃描檔下方的說明文字:Y 值愈往頁面底部愈小。
Pdf.AddText('File: ' + ExtractFileName(FileName), 'Helvetica', 9,
  X, Y - 14, clGray);

關於存檔的最後一點。SaveAs 是一個回傳 Boolean 的函式,所以在生產程式碼裡請檢查它的結果,而不是假設寫入成功了;否則磁碟滿了或輸出路徑被鎖住時,它會無聲地失敗。迴圈跑完、檔案寫出之後,您拿到的正是封存庫需要的東西:每個案件檔一份有序的 PDF、頁面都縮放到剛好貼合,在任何檢視器裡都能讀

同一組積木也能應付相關的工作。換掉逐頁的尺寸規則,您就得到一本每張紙一張照片的相片書;保留迴圈但改從多頁 TIFF 來源讀取,您就有了一支傳真封存轉換器。若您想看看以程式建置 PDF 的全貌,請參閱用 PDFium Component 從零建立 PDF 文件;若之後要把結果重新呈現到螢幕上,請參閱用 PDFium Component 把 PDF 頁面轉成 JPEG 影像

來自 loslab.com 的 PDFium Component 打包了本系列通篇使用的文件建立、呈現與文字 API