Bài viết kỹ thuật

Content-aware: nối bảng qua trang PDF trong Delphi

PDFium Component phiên bản 3.117.0 nối một bảng bị cắt qua ranh giới trang khi hoặc cả hai mảnh đều chạm mép trang, hoặc không có body text nào nằm dưới mảnh đầu và trên mảnh sau, với header và footer đang chạy bị bỏ qua. ExtractDocumentTables áp phép kiểm tra theo nội dung đó như một lựa chọn thay cho phép kiểm tra theo lề trang cũ hơn, từ chối một mảnh ở trang kế tiếp có dòng đầu là một ô caption tràn hết chiều rộng, và giữ một dòng duy nhất lọt sang trang sau như một phần trong chuỗi nối của nó

Bài về phát hiện và trích xuất bảng trình bày continuation như bốn cổng nghiêm ngặt và coi “chạm mép trang” là một trong số đó. Mô tả đó đúng với bản phát hành mà nó nói tới, và cũng sai với phần lớn bảng mà người ta thật sự đưa vào component. Bài này là phần đính chính: những tài liệu nào mà phép kiểm tra theo lề không xử lý nổi, cái gì thay thế nó, và hai ca biên mà bản vá kéo theo

Vì sao phép kiểm tra theo lề trang thất bại với các bản export từ Word?

Phép kiểm tra theo lề trang thất bại vì trình soạn thảo dừng xếp dòng ở lề dưới chứ không ở mép giấy. Với ContinuationMargin mặc định là 36 point, quy tắc cũ đòi mép dưới của mảnh trước nằm trong 36 point tính từ đáy trang, và mép trên của mảnh sau nằm trong 36 point tính từ đỉnh trang. Một tài liệu export từ Word với lề một inch mặc định đặt dòng cuối cao hơn đáy trang ít nhất 72 point, xa hơn nữa nếu có footer, nên điều kiện đó chưa bao giờ đúng. Mọi bảng dài trong tài liệu kiểu đó quay về dưới dạng các mảnh rời rạc với ContinuationGroup bằng 0, và người gọi lại phải tự nối tay. Phép kiểm tra vẫn hợp lý với đúng thứ nó được thiết kế cho: các báo cáo sinh bởi layout engine lấp đầy trang tới một content box cố định rồi bắt đầu trang sau sát đỉnh. Nó không phải một quy tắc tồi, nó là một quy tắc chưa đủ, và đó là lý do phiên bản 3.117.0 giữ nó lại rồi thêm một đường thứ hai thay vì thay thế nó

Vậy phép kiểm tra theo nội dung kiểm tra điều gì?

Phép kiểm tra theo nội dung xem có thứ gì khác ngoài bảng chiếm khoảng giữa hai mảnh hay không, dùng word box của từng trang thay vì hình học trang. Trong lúc ExtractDocumentTables duyệt tài liệu, nó ghi lại theo từng trang mép dưới thấp nhất của mọi word có đỉnh nằm trên dải footer và mép trên cao nhất của mọi word có đáy nằm dưới dải header. Cả hai dải đều sâu ContinuationMargin point, nên cùng một option giờ làm hai việc: khoảng nhường ở mép trang và chiều cao của vùng header, footer đang chạy. Một cặp mảnh đạt yêu cầu khi mép dưới của mảnh trước nằm ngang hoặc thấp hơn body text thấp nhất trên trang của nó, và mép trên của mảnh sau nằm ngang hoặc cao hơn body text cao nhất trên trang kế tiếp, mỗi bên trong sai số AlignmentTolerance. Nói đơn giản: bảng là thứ cuối cùng trên trang N và là thứ đầu tiên trên trang N+1, và một số trang hay tiêu đề tài liệu nằm trong dải lề thì không tính. Việc loại trừ đó không tùy tiện. ISO 32000-1 §14.8.2.2 xếp header và footer đang chạy vào nhóm artifact phân trang, tức nội dung tồn tại vì chỗ ngắt trang chứ không phải bất chấp nó, và chính ý tưởng cho phép một reader có tag bỏ qua chúng cũng là thứ cho phép một bảng đi tiếp qua chúng. Bài về marked content trình bày cách các tệp có tag khai báo tường minh những artifact đó; ở đây việc phân loại được suy ra từ vị trí, vì phần lớn bảng được export không mang tag nào

Vì sao continuation của bảng trong PDFium Component cần hai phép kiểm tra: với lề một inch của Word, phép kiểm tra theo lề đòi mép mảnh nằm trong cửa sổ 36 pt mà bố cục không bao giờ chạm tới, còn phép kiểm tra theo nội dung so các word box và nối khi bảng là nội dung body cuối cùng trên trang N và đầu tiên trên trang N+1, bỏ qua dải header và footer đang chạy
Một trong hai phép kiểm tra mở cổng, và chỉ khi đó các bước còn lại mới chạy: hai trang liền nhau, mảnh sau không có dòng caption tràn hết chiều rộng, và các biên cột khớp trong sai số gấp đôi AlignmentTolerance

Hai phép kiểm tra kết hợp bằng OR. Một báo cáo từ layout engine có bảng chạy tới mép giấy qua cổng thứ nhất; một bản export từ Word có bảng dừng ở lề qua cổng thứ hai; một tài liệu thỏa cả hai thì qua hai lần. Chỉ sau khi một trong hai thành công thì các cổng còn lại mới chạy, và chúng chạy theo thứ tự cố định: số trang phải liền nhau, mảnh sau không được mở đầu bằng một dòng caption, và các biên cột phải khớp trong sai số gấp đôi AlignmentTolerance, tức 6 point ở giá trị mặc định. Enum là TPdfTableContinuation với các giá trị ptcNone, ptcStart, ptcMiddle và ptcEnd. Một mảnh từng được đánh ptcEnd rồi nối tiếp sang một trang nữa sẽ được nâng thành ptcMiddle, nên một bảng ba trang đọc ra là start, middle, end theo thứ tự trang. Số nhóm bắt đầu từ 1 và 0 nghĩa là chưa nối, còn ToJson phát ra thông tin y hệt dưới các member continuation và continuationGroup, và đó là dạng nên dùng nếu một service hạ nguồn đảm nhiệm việc nối

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;

    Options := TPdfTableExtractionOptions.Default;
    Options.DetectContinuations := True;     // mặc định; hiển thị ở đây cho rõ
    Options.ContinuationMargin := 54;        // footer hai dòng, sâu khoảng 50 pt

    Tables := Pdf.ExtractDocumentTables(Options);
    for I := 0 to High(Tables) do
      case Tables[I].Continuation of
        ptcStart:
          Writeln(Format('group %d starts on page %d (%d rows)',
            [Tables[I].ContinuationGroup, Tables[I].PageNumber,
             Tables[I].RowCount]));
        ptcMiddle, ptcEnd:
          Writeln(Format('group %d continues on page %d (%d rows)',
            [Tables[I].ContinuationGroup, Tables[I].PageNumber,
             Tables[I].RowCount]));
      else
        Writeln(Format('standalone table on page %d (%d rows)',
          [Tables[I].PageNumber, Tables[I].RowCount]));
      end;
  finally
    Pdf.Free;
  end;
end;

Một dòng caption ngăn hai bảng dính vào nhau như thế nào?

Một mảnh ở trang kế tiếp có dòng đầu là một ô trải hết mọi cột được coi là bảng mới, không bao giờ là phần còn lại của bảng trước. Quy tắc này tồn tại vì phép kiểm tra theo nội dung, nếu đứng một mình, nối quá hăng. Ca làm lộ ra nó là một biểu mẫu kiểu bảng điểm: một bảng kết thúc gần đáy trang 1, một bảng thứ hai có độ rộng cột y hệt bắt đầu gần đỉnh trang 2, giữa chúng chỉ có footer, và các cột khớp đến từng point. Dưới phép kiểm tra theo lề, hai bảng đó không bao giờ gặp nhau vì không cái nào chạm mép; dưới phép kiểm tra theo nội dung, chúng nối ngay lập tức, và một biểu mẫu có nhiều mục biến thành một lưới vô nghĩa. Thứ phân biệt chúng thì nhìn thấy được trong cấu trúc ô. Bảng thứ hai mở đầu bằng một caption mục như “RECIPIENT INFORMATION” được dàn thành một ô merge duy nhất trải hết chiều rộng, còn một mảnh nối thật sự thì không bao giờ làm vậy, vì caption thuộc về bảng đã bắt đầu từ trang trước. TableStartsWithCaptionRow mã hóa đúng điều đó: mảnh phải có ít nhất hai cột và chứa một ô với RowIndex = 0, ColumnIndex = 0 và ColumnSpan = ColumnCount. Phép kiểm tra chỉ chạy trên mảnh sau, nên một bảng có dòng caption của chính nó nằm ở trang đầu không bị ảnh hưởng; caption nằm ở trang N, và chỉ mảnh ở trang N+1 bị soi

Cổng caption-row trong PDFium Component: một mảnh nối thật sự mở đầu bằng các ô dữ liệu và nhập vào cùng ContinuationGroup, còn một mảnh sau có dòng số 0 là một ô merge duy nhất với RowIndex 0, ColumnIndex 0 và ColumnSpan bằng ColumnCount thì bị từ chối là continuation và được báo thành bảng mới
Việc soi chỉ chạm vào mảnh sau, nên một bảng có dòng caption của chính nó ở trang đầu không bị ảnh hưởng, và cổng này chạy sau khi một trong hai phép kiểm tra mép đã nối cặp đó lại

Phép so cột tiếp theo, TablesHaveMatchingColumns, nghiêm hơn “cùng số cột”. Nó dựng lại vị trí các biên của từng mảnh từ hình chữ nhật của các ô, nội suy những biên bị ô merge che mất, và từ chối cặp đó khi có biên nào lệch quá sai số. Hai bảng bốn cột với tỉ lệ khác nhau vì thế vẫn tách rời nhau kể cả khi mọi thứ khác khớp

Điều gì xảy ra với một dòng duy nhất lọt sang trang sau?

Một lưới có kẻ dòng đưa đúng một dòng sang trang sau giờ được phát hiện và nối, với điều kiện nó kết thúc trong một chuỗi nối; đứng một mình thì nó bị bỏ. MinRows mặc định là 2 tồn tại để một cặp đường kẻ lạc không bị báo thành bảng, nhưng một dòng cuối bị đẩy qua chỗ ngắt là một dòng thật mà ngưỡng cứng 2 âm thầm đánh rơi, khiến phần còn lại của bảng trông như đã đủ dù chưa đủ. Lượt quét cấp tài liệu xử lý chuyện đó trong ba bước. Khi cả DetectContinuations lẫn DetectRuledTables đều bật, lượt chạy theo từng trang chạy bộ phát hiện bảng kẻ dòng với ngưỡng số dòng tạm hạ xuống 1, và đó là lý do ExtractTables giờ chấp nhận MinRows bằng 1 cho lưới kẻ dòng trong khi phát hiện theo khoảng trắng vẫn giữ ngưỡng nội bộ là 2. Các continuation được đánh dấu trên toàn bộ kết quả. Sau đó mọi bảng ngắn hơn MinRows của người gọi và không thuộc chuỗi nào đều bị bỏ. Mảnh một dòng chỉ sống sót vì nó đã được nối, còn một lưới một dòng nằm giữa một trang bình thường thì vẫn bị lọc y như trước

Cách PDFium Component giữ một dòng có kẻ lọt qua chỗ ngắt trang: lượt phát hiện bảng kẻ dòng theo từng trang chạy với ngưỡng số dòng bằng một khi DetectContinuations và DetectRuledTables đều bật, các continuation được đánh dấu trên toàn bộ kết quả, và chỉ những mảnh ngắn hơn MinRows nằm ngoài mọi chuỗi mới bị bỏ
Dòng lọt sống sót vì chuỗi của nó nối nó lại, còn một lưới một dòng độc lập trên một trang bình thường vẫn bị lọc y như trước, và bảng phát hiện theo khoảng trắng giữ nguyên ngưỡng hai dòng không có nới lỏng nào
// Dựng lại mỗi chuỗi thành một CSV, bỏ các dòng header bị lặp
// trên các mảnh continuation
procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
  I, R: Integer;
  Lines: TStringList;
  Csv: TStringList;
begin
  Csv := TStringList.Create;
  Lines := TStringList.Create;
  try
    for I := 0 to High(Tables) do
    begin
      if Tables[I].Continuation in [ptcNone, ptcStart] then
        Csv.Clear;
      Lines.Text := string(Tables[I].ToCsv);
      if (Tables[I].Continuation in [ptcMiddle, ptcEnd]) and
         (Lines.Count > 1) and (Tables[I].RowCount > 1) then
        Lines.Delete(0);            // header bị trình soạn thảo lặp lại
      for R := 0 to Lines.Count - 1 do
        Csv.Add(Lines[R]);
      if Tables[I].Continuation in [ptcNone, ptcEnd] then
        Csv.SaveToFile(Format('%s\page%d-group%d.csv',
          [Folder, Tables[I].PageNumber, Tables[I].ContinuationGroup]));
    end;
  finally
    Lines.Free;
    Csv.Free;
  end;
end;

Hai chi tiết trong routine đó là cố ý. Dòng lọt một dòng không bao giờ bị bỏ, vì guard trên RowCount giữ nó lại, còn một trình soạn thảo lặp dòng header ở mỗi trang sẽ sinh ra mảnh có dòng đầu lại là header, nên bỏ dòng số 0 trên các mảnh middle và end là đúng cho ca đó và sai với một bộ sinh không lặp header. Hãy kiểm tra một tài liệu trước khi thả routine đó lên cả một thư mục

Các quy tắc vẫn dừng ở đâu

Phép kiểm tra theo nội dung chỉ tốt bằng đúng lớp text mà nó đọc. Trên một trang scan không có chút text nào, các cực trị body text đã ghi rơi về biên trang, điều kiện “không có gì ở giữa” được thỏa một cách rỗng tuếch, và chỉ còn lại cổng caption-row cùng cổng cột; một lưới kẻ dòng trên trang như vậy vẫn được tìm thấy như một bộ khung rỗng, nên chuỗi có thể nối đúng, nhưng không có gì về phần text xung quanh thật sự được kiểm chứng. Hãy thêm lớp text trước nếu điều đó quan trọng. Footer được render dưới dạng ảnh thay vì text thì vô hình với logic dải, và cũng vô hại vì đúng lý do đó

Hai dải chỉ là một con số. Một footer sâu hơn ContinuationMargin để các dòng dưới của nó nằm trong vùng body, khiến mảnh trước trông như có text đi kèm và chặn việc nối; hãy nâng option lên đúng độ sâu thật của dải, như ví dụ đầu tiên làm. Nâng quá cao thì một đoạn kết ngắn gần đáy trang sẽ lọt vào dải và bị bỏ qua, khiến bảng bị nối với bất cứ thứ gì theo sau nó. Quy tắc caption có một kiểu hỏng đối xứng: một bộ sinh ghi banner “continued” đã merge làm dòng đầu của mọi mảnh continuation sẽ khiến các mảnh đó bị từ chối như bảng mới, và cách chữa duy nhất hiện nay là tự nối theo ContinuationGroup mà không nới gì cả, vì quy tắc này không có công tắc

Bảng được phát hiện theo khoảng trắng không được hưởng chút nới lỏng nào cho dòng đơn. Chiến lược khoảng trắng cần hai dòng thẳng hàng mới thấy được một bảng, nên một bảng không kẻ dòng mà lọt một dòng vẫn bị báo thiếu đúng dòng đó. Khi gặp ca đó, các word box nằm sau khối structured text và thứ tự đọc cho bạn vị trí thô để lấy lại dòng ấy. Trên tập mẫu đã dẫn dắt công việc này, mười ba bản export từ trình soạn thảo và trình duyệt, năm tài liệu có bảng nhiều trang thật sự đều nối thành chuỗi đơn, còn biểu mẫu bảng điểm trước đây bị dính lại thì vẫn tách rời, và đó là mức mà bản phát hành được đo, không phải một lời hứa cho mọi kiểu bố cục

Việc đánh dấu continuation, quy tắc caption và lượt xử lý dòng đơn đều nằm trong đường cấp tài liệu được chia sẻ cho các bản dựng Delphi, C++Builder và Lazarus; toàn bộ API trích xuất bảng được trình bày trên trang PDFium Component cho Delphi