Bài viết kỹ thuật

Trích xuất bảng PDF có kiểu qua ngắt trang trong Delphi

HotPDF khôi phục table từ PDF hiện có qua ExtractLoadedTypedTables, một Delphi API merge các row fragment mà layout pass tạo ra, dựng một canonical column grid cho mỗi table, tiếp tục table qua page break khi geometry cho phép và trả về từng cell dưới dạng typed value mang page provenance, column span cùng bounds. ExportLoadedTypedTables ghi thẳng kết quả đó ra CSV hoặc JSON. Tình huống khiến tính năng này đáng xây khá buồn tẻ nhưng cực kỳ phổ biến. Một invoice register bốn mươi page, về logic chỉ là một table, được print với header lặp ở đầu mỗi page. Chạy reading-order pass ngây thơ sẽ cho bạn bốn mươi table, ba mươi chín header row giả và một currency column trượt sang trái một vị trí ở mọi row có cell giữa bị bỏ trống. Dọn hậu quả đó ở downstream, trong calling application, là nơi các dự án document import thường chết dần

Vì sao page PDF đưa cho bạn fragment thay vì table?

Vì page PDF hoàn toàn không mang table semantic nếu document không được tag. Content stream chỉ chứa text-showing operator và positioning matrix (ISO 32000-1 §9.4.3), không có gì hơn; ruled box bạn thấy trên màn hình là path painting không liên quan mà extractor không bị buộc phải liên hệ với text. Type của structure element là Table, TR, THTD chỉ nằm trong logical structure hierarchy của tagged PDF (ISO 32000-1 §14.8.4), trong khi phần lớn business document đang lưu hành không được tag. Mọi thứ mô tả dưới đây là geometric recovery chứ không phải parsing, nên cần nói rõ trước khi ai đó dựng reconciliation report trên nó

Vì vậy HotPDF trước hết chạy semantic layout analysis trên glyph đã extract, cùng pass làm nền cho structure-order text extraction từ PDF đã load và structured HTML/XML export. Pass đó nhóm baseline thành run có cell align theo chiều dọc, và chỉ tiếp tục run khi các row liên tiếp có cùng số cell. Với layout engine, quy tắc này đúng và rẻ. Với caller, shape này sai: một row có cell bên trong trống sẽ tách một visual table thành hai source table. Typed table layer nằm trên pass đó chính xác để ghép các mảnh trở lại

Canonical column grid và nút ColumnTolerance

ExtractLoadedTypedTables merge fragment cùng page trước khi làm bất kỳ việc gì khác, và merge theo column geometry chứ không theo row text. Hai source table kề nhau trên một page được nối khi cả hai có ít nhất hai column, vertical gap giữa row cuối của table đầu và row đầu của table sau nằm trong tolerance band, và column start position align. Column start cách nhau trong phạm vi ColumnTolerance sẽ collapse thành một canonical column rồi được average khi merge. Tolerance mặc định là 12 user-space unit, phù hợp typography business thông thường và nên tăng cho layout tracking rộng hoặc indent sâu

Điều xảy ra với row thiếu value ở giữa mới là phần quan trọng. HotPDF snap từng cell vào canonical column start gần nhất rồi đặt ColumnSpan bằng khoảng cách từ column đó tới column kế tiếp có dữ liệu, thay vì đẩy các cell còn lại sang trái. Row ba cell trong grid năm column vẫn giữ value dưới đúng heading và ghi rõ vị trí gap. Đó là khác biệt giữa table có thể reconcile và table âm thầm gán nhầm tiền

var
  Pdf: THotPDF;
  Options: THPDFTypedTableExtractionOptions;
  Tables: THPDFTypedTables;
  Info: THPDFTypedTableExtractionInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('register.pdf', '') <= 0 then
      Exit;
    Options := THPDFTypedTableExtractionOptions.Default;
    Options.ColumnTolerance := 12;           // user-space unit
    Options.MinimumTableConfidence := 0.55;  // dưới mức này table bị loại
    Options.DateOrder := ttdoDMY;            // 03/04/2026 là 3 April
    Options.DecimalSeparator := ',';
    Options.ThousandsSeparator := '.';
    if Pdf.ExtractLoadedTypedTables([0, 1, 2, 3], Options, Tables, Info) then
      // Info.TableCount so với Info.SourceTableCount cho biết mức merge
      ProcessTables(Tables)
    else if Info.Status = ttesBudgetExceeded then
      Log(string(Info.Diagnostic));
  finally
    Pdf.Free;
  end;
end;

Cross-page merging thực sự bảo đảm gì?

Nó bảo đảm tính conservative, có chủ ý. HotPDF chỉ join hai table qua boundary page khi MergeAcrossPages được bật, table thứ hai bắt đầu đúng page index ngay sau page cuối của table thứ nhất, cả hai có ít nhất hai column và ít nhất hai canonical column start align trong phạm vi ColumnTolerance. Điều kiện consecutive-page là phần chịu tải chính. Caller truyền PageIndices dưới dạng open array theo bất kỳ thứ tự nào, và không có check đó, request cho page 3, 9 và 14 có thể hàn ba table không liên quan thành một kết quả trông hoàn toàn hợp lý. Cái giá là continuation thật sự bỏ qua một page, appendix xen giữa hoặc duplex scan có verso trống sẽ trở về thành hai table và không option nào nới lỏng được. Ghép lại chúng là policy call chỉ calling application có thể quyết định, nên API expose FirstPageIndex, LastPageIndex, SourceTableCount cùng PageIndex theo từng row rồi để quyết định ở đúng nơi

Header lặp được gắn nhãn, không bị xóa

ExtractLoadedTypedTables không bao giờ xóa repeated header row khỏi kết quả. Khi cross-page merge thấy table mới mở đầu bằng header text giống hệt table đã tích lũy, sau khi trim và case fold, nó đánh dấu các row đó là IsHeaderIsRepeatedHeader rồi vẫn append theo source order. Xóa là lựa chọn mất dữ liệu và không thể đảo ngược, trong khi consumer khác nhau cần câu trả lời khác nhau: CSV import muốn bỏ repeat, audit trail muốn giữ cùng page number, diffing tool muốn giữ source order byte-by-byte. Vì vậy library báo cáo, caller quyết định

var
  T, R, C: Integer;
  Row: THPDFTypedTableRow;
  Total: Double;
begin
  Total := 0;
  for T := 0 to High(Tables) do
    for R := 0 to High(Tables[T].Rows) do
    begin
      Row := Tables[T].Rows[R];
      if Row.IsRepeatedHeader then
        Continue;                    // chỉ giữ block header đầu tiên
      for C := 0 to High(Row.Cells) do
        if Row.Cells[C].ValueKind = ttvkCurrency then
          Total := Total + Row.Cells[C].NumberValue;
    end;
end;

Typed value và các separator bạn phải cung cấp

Type inference chạy theo thứ tự cố định để giải quyết ambiguity theo hướng hợp lý duy nhất: boolean trước, date tiếp theo, percentage, currency, plain number, còn mọi thứ không match vẫn là string. Thứ tự này ngăn 2026 trong date column bị number parser quyết định trước khi date parser nhìn thấy nó. Currency được nhận diện từ prefix $, £, ¥ hoặc , hoặc từ ISO 4217 code ba chữ cái theo sau bởi một space, còn code được giữ trong CurrencyCode. Quan trọng là HotPDF không đoán locale của bạn. DecimalSeparator, ThousandsSeparatorDateOrder đến từ option, vì 1.234 là một số hoặc một nghìn hai trăm ba mươi tư tùy một sự thật mà PDF không chứa. Raw Unicode Text được giữ trên mọi cell cạnh typed value, nên guess sai luôn có thể khôi phục mà không cần extraction pass thứ hai

var
  Stream: TFileStream;
  Info: THPDFTypedTableExtractionInfo;
begin
  Stream := TFileStream.Create('tables.json', fmCreate);
  try
    if not Pdf.ExportLoadedTypedTables([0, 1, 2], ttefJSON,
      Stream, Options, Info) then
      case Info.Status of
        ttesInvalidOptions:   ReportBadConfiguration;
        ttesBudgetExceeded:   ReportOversizedDocument;
        ttesCancelled:        ReportUserCancelled;
        ttesWriteFailed:      ReportDestinationProblem;
      else
        ReportExtractionFailure;
      end;
  finally
    Stream.Free;
  end;
end;

Hai format export trả lời hai câu hỏi khác nhau và cố ý không tương đương. CSV ghi các continuation column của span đã merge thành field rỗng, đúng điều spreadsheet hoặc bulk loader mong đợi. JSON giữ mọi thứ extraction biết: typed value theo kind riêng, columnSpan, confidence theo cell và row, cell bounds, page cùng source-table provenance. Cả hai format đều stage toàn bộ document vào bounded in-memory buffer rồi mới publish ra destination stream, khôi phục byte, length và position ban đầu nếu write fail giữa chừng, nên export thất bại không để lại file ghi nửa chừng. Budget cho page, glyph mỗi page, table, row, cell, character và output byte đều được account riêng, còn row được đếm trước allocation vì SetLength theo từng row sẽ suy biến thành copy quadratic từ lâu trước ceiling mặc định một triệu row

Geometric table recovery bỏ cuộc ở đâu

Nói rõ failure mode hữu ích hơn feature list, vì mỗi điểm dưới đây là nơi caller cần policy riêng chứ không phải một option value tốt hơn

  • Không khôi phục vertical merge. HotPDF báo ColumnSpan cho span ngang và để RowSpan bằng 1, nên cell trải ba row trong table in ra sẽ thành một cell cộng hai gap
  • Header detection dựa trên data chứ không dựa trên visual. Header block là run của các row trước row đầu tiên chứa typed value không phải string, nên table có body toàn text báo HeaderRowCount bằng 0 dù style ra sao
  • Table dưới MinimumTableConfidence bị loại khỏi result mà không có error. So sánh Info.TableCount với Info.SourceTableCount khi cần biết có thứ gì bị discard
  • Một run cần ít nhất hai row và hai column trước khi layout pass gọi nó là table, nên pseudo-table một dòng hoặc layout hai column của prose dài được coi là không phải table, đúng nhưng không hữu ích
  • Scanned page không có text operator, nên không có gì để khôi phục theo geometry cho tới khi page có OCR text layer

Nếu PDF đến từ reporting stack của chính bạn, cách sửa rẻ nhất cho tất cả vấn đề này là upstream: emit tagged table hoặc giữ source data, coi extraction là fallback cho document bạn không tạo. Với phần còn lại, nên học pipeline theo thứ tự này vì mỗi layer xây trên layer dưới: bắt đầu với plain text extraction từ PDF đã load, lên typed table API khi cần giữ geometry, rồi xem render data table thành PDF mới khi bạn ở phía generating và được quyết định output sẽ recoverable tới đâu

ExtractLoadedTypedTablesExportLoadedTypedTables là một phần của native HotPDF Delphi PDF Component cho Delphi và C++Builder, không cần external DLL hay runtime dependency; product page có full option, status và record reference cho typed table API