Bài viết kỹ thuật

Hộp tô đầy thành ruling bảng trong PDFium Delphi

Trích xuất bảng của PDFium Component, kể từ phiên bản 3.117.0, coi một hình chữ nhật tô đầy mảnh là một ruling của bảng. Khi DetectFilledRulings được bật, tức mặc định, một hộp tô đầy thẳng trục dày không quá MaxRulingThickness (3 point) trở thành một ruling dọc theo trục dài của nó, một hộp tô đầy lớn hơn đóng góp bốn cạnh của nó, và mọi tọa độ ruling đều được snap trong RulingSnapTolerance (4 point) trước khi lưới được lắp. Nhờ đó các bảng export từ Word, Google Docs và trình duyệt đi tới bộ phát hiện bảng kẻ dòng như những lưới hoàn chỉnh thay vì rơi xuống phát hiện theo khoảng trắng dưới dạng các mảnh vụn

Bài trước về phát hiện và trích xuất bảng nói rằng phát hiện bảng kẻ dòng dùng các đường được vẽ và mỗi đoạn path được stroke đều được biến đổi sang tọa độ trang. Câu đó đúng và chưa đủ. Đếm các path object trên một tập 13 tài liệu mẫu thực tế cho thấy 9 trong số đó không chứa path nào được stroke, vậy mà mỗi trang của chúng mang hàng trăm hình chữ nhật tô đầy dày 0,5 tới 1 point. Bộ phát hiện chỉ nhìn vào stroke không thấy gì, mọi trang rơi xuống phát hiện theo khoảng trắng, và đầu ra là một mớ mảnh vụn nhỏ thay vì bảng. Preset compact-columns thêm ở 3.116.4 có làm dịu chuyện đó ở mức từng mảnh; nguyên nhân gốc là bộ phát hiện đang đọc sai toán tử vẽ

Vì sao một bảng export từ Word không có đường nào được stroke?

Trình soạn thảo không nghĩ về đường viền như một đường thẳng; nó nghĩ đó là một hộp có bề rộng, và nó tô cái hộp đó bằng một lệnh fill. ISO 32000-1 §8.5.2.1 định nghĩa toán tử re là nối thêm một subpath hình chữ nhật, còn §8.5.3 tách riêng các toán tử vẽ: S stroke path với bề rộng nét hiện hành, f tô phần bên trong. Một viền ô 0,5 point ra đời dưới dạng x y w 0.5 re f, và toàn bộ bộ máy stroke, kể cả bề rộng nét, join và dash pattern, không hề chạy. Tô nền ô cũng là cấu trúc y hệt với cái hộp to hơn. Một lưới được stroke bằng m, l và S là thứ bộ phát hiện ban đầu trông đợi, và cũng là thứ gần như không ứng dụng văn phòng nào sinh ra:

% một viền ô từ bản export của trình soạn thảo: một hộp tô đầy cao 0,5 pt
72 700 468 0.5 re f
% tô nền ô: một hộp tô đầy bằng kích thước ô
72 676 117 24 re f
% đường lưới được stroke mà bộ phát hiện ban đầu được viết cho
72 700 m 540 700 l S

Với một bộ phát hiện chỉ hỏi FPDFPath_GetDrawMode xem cờ stroke có được bật hay không, cả hai hộp tô đầy đều vô hình. Các từ bên trong ô sau đó đi tới phát hiện theo khoảng trắng, nơi các cột cách nhau một gutter 6 point nằm dưới MinColumnGap mặc định là 12 point, và thứ quay về là một tập con dòng nào đó tình cờ thẳng hàng đủ để qua MinRows. Đó là hành vi mảnh vụn, và có chỉnh tham số bao nhiêu cũng không biến nó thành cái lưới mà tác giả đã vẽ

PDFium Component biến một hộp tô đầy thành ruling như thế nào?

TableCollectObjectRulings soi từng path object, mỗi lần một subpath. Draw mode lấy từ FPDFPath_GetDrawMode; một path được tính là tô đầy khi DetectFilledRulings bật và fill mode không phải none. Mỗi điểm được biến đổi qua object matrix và được thu lại, tối đa MaxSubpathPoints (8) cho mỗi subpath, và bất kỳ đoạn cong nào cũng đánh dấu subpath đó là cong. Khi subpath đóng lại hoặc một MoveTo mới bắt đầu, FlushSubpath quyết định nó là gì: một subpath cong bị bỏ, và mọi đa giác kín có các điểm không cùng nằm trong sai số PointTolerance (0,05 point) tính từ các cạnh của bounding box trên ít nhất một trục cũng bị bỏ. Một tam giác, một mũi nhọn hay một tab bo góc không bao giờ trở thành ruling, và đó là thứ giữ hình trang trí ra khỏi lưới

Sơ đồ PDFium Component về cách TableCollectObjectRulings biến các subpath kín thành ruling bảng trong Delphi: FlushSubpath bỏ những đường bao cong và những đa giác lệch khỏi cạnh bounding box, MaxRulingThickness tách hộp mảnh thành một ruling theo trục dài, ô được tô nền cho ra bốn ruling ở bốn cạnh và DetectFilledRulings giữ các ô vuông tí hon ở ngoài
Một subpath kín chỉ sống sót khi nó thẳng trục, và bounding box sau đó quyết định nó là một ruling, bốn cạnh của một ô tô nền, hay không là gì cả

Thứ sống sót là một hình chữ nhật thẳng trục, được phân loại theo bounding box. Bề rộng bằng hoặc dưới MaxRulingThickness với chiều cao lớn hơn cho ra một ruling dọc ở giữa theo chiều ngang, trải hết hộp từ dưới lên trên; trường hợp đối xứng cho ra một ruling ngang. Cả hai chiều đều trên ngưỡng nghĩa là một ô được tô nền, và cái hộp đóng góp bốn ruling, mỗi cạnh một cái. Cả hai chiều đều bằng hoặc dưới ngưỡng thì không đóng góp gì, nên một bullet vuông 2 point không bị nhầm thành một đường. Một path được stroke đi đường cũ qua AddLine, mỗi đoạn thẳng trục một ruling, nên một lưới vẽ bằng S được xử lý y như trước, còn một path vừa fill vừa stroke sinh ra các mảnh chồng lên nhau mà lượt merge sẽ gộp lại:

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  Mode: string;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // tính từ 1

    Options := TPdfTableExtractionOptions.Default;
    // đây là các giá trị mặc định của 3.117.0, ghi ra cho rõ
    Options.DetectFilledRulings := True;     // hộp tô đầy mảnh trở thành ruling
    Options.MaxRulingThickness := 3.0;       // point; hộp dày hơn bị tính là tô nền
    Options.RulingSnapTolerance := 4.0;      // point; 0 là tắt snap
    Options.IncludeFormXObjects := True;

    Tables := Pdf.ExtractTables(Options);
    for I := 0 to High(Tables) do
    begin
      if Tables[I].DetectionMode = ptdmRuled then
        Mode := 'ruled'
      else
        Mode := 'whitespace';
      Writeln(Format('%dx%d %s, confidence %.2f',
        [Tables[I].RowCount, Tables[I].ColumnCount, Mode,
         Tables[I].Confidence]));
    end;
  finally
    Pdf.Free;
  end;
end;

RulingSnapTolerance làm gì cho các bảng chỉ có tô nền?

RulingSnapTolerance chính là thứ khiến một bảng được dựng chỉ từ tô nền nối thành một lưới. Một số bản export không vẽ viền nào cả: mỗi ô là một hộp tô đầy theo màu của nó, và các hộp kề nhau cách nhau một gutter trắng 1 tới 3 point. Mỗi hộp cho ra bốn ruling ở bốn cạnh, nhưng cạnh phải của ô này và cạnh trái của ô kia cách nhau 2 point, còn phép kiểm tra liên thông dùng RulingTolerance với mặc định là 1 point. Không có snap, mỗi ô tạo thành một thành phần liên thông riêng gồm bốn ruling, không thành phần nào đạt MinRows, và trang báo không có gì. TableSnapRulings thu mọi tọa độ X đang tham gia (vị trí của từng ruling dọc cộng điểm đầu và điểm cuối của từng ruling ngang) và mọi tọa độ Y tương tự, sắp từng danh sách, gom cụm bằng cách nối chuỗi các giá trị mà giá trị kề nó lệch không quá sai số, thay mỗi cụm bằng giá trị trung bình của nó, rồi dịch mọi vị trí, điểm đầu và điểm cuối về tâm cụm gần nhất. Hai bên của một gutter trở thành cùng một đường, và tính liên thông được giữ

Sơ đồ PDFium Component về cách RulingSnapTolerance nối một bảng chỉ có tô nền trong Delphi: các ô kề nhau để lại một gutter 2 pt, các ruling ở cạnh chúng nằm ngoài RulingTolerance 1 pt, và TableSnapRulings nối hai giá trị X đó thành một tâm cụm để phép kiểm tra liên thông cuối cùng nhìn thấy một đường lưới chung
Snap chạy trước khi merge và trước bộ phát hiện bảng kẻ dòng, nên hai bên của một gutter trắng trở thành một đường và mỗi ô không còn là một hòn đảo gồm bốn ruling

Snap chạy trước TableMergeRulings, thứ sắp các ruling rồi nối các mảnh đồng tuyến chạm nhau hoặc chồng nhau trong RulingTolerance, và cả hai đều chạy trước khi TableDetectRuled nhìn thấy dữ liệu, nên phép kiểm tra liên thông theo từng cặp tỉ lệ với số đường lưới chứ không phải số mảnh của từng ô. Trên một lưới đã được stroke, các lượt này vô hại, vì những tọa độ vốn đã trùng nhau thì snap về chính nó. Một điều cần nhớ là việc gom cụm theo chuỗi không có giới hạn bề rộng của riêng nó: một dãy tọa độ cách nhau 3 point sẽ sụp thành một tâm duy nhất. Ở giá trị mặc định 4 point, điều đó chỉ ảnh hưởng tới các cột hẹp hơn một ký tự, nhưng nếu một tài liệu có gutter 3 point thật sự phải tách rời, hãy hạ sai số xuống hoặc đặt bằng 0 để tắt snap:

// Cô lập chiến lược kẻ dòng và so từng thiết lập nhìn thấy gì trên một trang
function CountRuledTables(Pdf: TPdf; FilledRulings: Boolean;
  SnapTolerance: Double): Integer;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.DetectWhitespaceTables := False;
  Options.DetectFilledRulings := FilledRulings;
  Options.RulingSnapTolerance := SnapTolerance;
  Result := Length(Pdf.ExtractTables(Options));
end;

// Một bản export từ Word thường báo 0, N rồi ít hơn N:
// chỉ stroke thì không thấy gì, snap nối các ô tô nền lại,
// và tắt snap thì mỗi ô tô nền thành một hòn đảo riêng
Writeln(CountRuledTables(Pdf, False, 4.0));
Writeln(CountRuledTables(Pdf, True, 4.0));
Writeln(CountRuledTables(Pdf, True, 0.0));

Ruling bên trong form XObject

Các công cụ dàn trang thường gói một bảng, hoặc cả phần thân trang, vào một form XObject rồi vẽ nó bằng Do. ISO 32000-1 §8.10.1 quy định rằng form matrix được ghép nối với ma trận biến đổi hiện hành khi form được vẽ, nên một hình chữ nhật bên trong form sống trong không gian form và chỉ đáp xuống trang sau hai hay nhiều phép biến đổi. TableCollectObjectRulings đệ quy vào các form object khi IncludeFormXObjects được bật: nó đọc object matrix, kết hợp với ma trận cha qua TableMultiplyMatrix, với thứ tự tham số nghĩa là “ánh xạ qua ma trận thứ nhất, rồi qua ma trận thứ hai”, và liệt kê các con bằng FPDFFormObj_CountObjects cùng FPDFFormObj_GetObject, truyền ma trận đã kết hợp xuống dưới. Việc lồng sâu hơn MaxFormDepth (8) bị bỏ qua âm thầm, đó là một biện pháp phòng những tệp bệnh hoạn chứ không phải một giới hạn mà bản export thật nào tới gần. Lý do thứ tự nhân quan trọng cũng là lý do đã bàn trong prepend và append ma trận: đổi chỗ hai toán hạng sẽ dịch số hạng translation, và một ruling lẽ ra nằm ở đỉnh trang lại nằm ở gốc tọa độ

Sơ đồ PDFium Component về ruling bên trong một form XObject trong Delphi: một hình chữ nhật mảnh được viết là 72 700 468 0.5 re f sống trong không gian form và chỉ đáp xuống trang sau khi TableMultiplyMatrix kết hợp CTM của cha với form matrix, đệ quy qua FPDFFormObj_CountObjects tới tận MaxFormDepth
Hình chữ nhật được viết trong không gian form và chỉ tới được đỉnh trang sau khi các ma trận được nhân theo một thứ tự giữ số hạng translation đúng chỗ

Vì sao ngân sách ruling tăng gấp bốn?

MaxRulingSegments mặc định tăng từ 4096 lên 16384 ở 3.117.0 vì viền theo từng ô đến với số lượng lớn hơn hẳn các đường lưới được stroke. Một bảng 30 dòng, 6 cột vẽ bằng stroke là 38 đoạn thẳng. Cùng bảng đó export thành các hộp tô đầy thì lên tới bốn viền mỗi ô, 720 mảnh trước khi merge, và một biểu mẫu có ô tô nền thì gấp đôi con số đó. Hai bảng như vậy trên một trang sẽ đốt hết ngân sách cũ. Ngân sách được cưỡng chế trong TableAppendRuling qua Check, thứ ném EPdfError với thông báo “Table ruling-segment budget exceeded”; không có kết quả suy giảm, không có lưới một phần, và lượt phát hiện theo khoảng trắng cũng không chạy. Nếu bạn tự đặt một ngân sách chặt hơn cho đầu vào không đáng tin, hãy bắt exception rồi tự quyết, thay vì đọc một kết quả rỗng thành “không có bảng nào”:

Options := TPdfTableExtractionOptions.Default;
Options.MaxRulingSegments := 2048;        // cố tình chặt cho đầu vào không đáng tin
try
  Tables := Pdf.ExtractTables(Options);
except
  on E: EPdfError do
  begin
    Log(E.Message);                       // 'Table ruling-segment budget exceeded'
    Options.MaxRulingSegments := 16384;   // mặc định của 3.117.0
    Tables := Pdf.ExtractTables(Options);
  end;
end;

Kết quả đo được và chỗ mà cách làm này dừng

Trên cùng 13 tài liệu mẫu đó, việc trích xuất đi từ 43 bảng, trong đó 9 bảng được phát hiện theo kẻ dòng và 34 là mảnh vụn theo khoảng trắng hay dương tính giả, xuống còn 41 bảng kẻ dòng và không còn dương tính giả nào theo khoảng trắng. Một phần của việc dọn dẹp đó thuộc về hai thay đổi đi kèm ở 3.117.0: các word đã được một lưới kẻ dòng nhận trước sẽ bị loại bỏ trước khi lượt phát hiện theo khoảng trắng chạy, nên một bảng không bao giờ bị báo hai lần, và biên cột theo khoảng trắng giờ phải là một hành lang không có text xuyên suốt mọi dòng mà nó phân tách, đó là thứ ngăn các đoạn văn căn đều hai bên bị tính thành bảng 5x4. Còn bộ đọc hình chữ nhật tô đầy mới là thứ chuyển chính các bảng từ cột mảnh vụn sang cột kẻ dòng

Nói thẳng các ranh giới cũng đáng. Một trang không có lớp text vẫn cho ra bộ khung lưới với mọi ô rỗng, vì ruling đến từ hình học còn text đến từ text page; trang scan cần OCR trước. Các hình tô đầy có đường cong, góc bo tròn hay đường bao không phải hình chữ nhật đều bị bỏ hoàn toàn, nên một bảng có viền được vẽ dưới dạng đường bao chữ nhật bo góc vẫn cần phát hiện theo khoảng trắng như trước. Một bảng không có cả viền lẫn tô nền thì không bị gì trong chuyện này thay đổi và vẫn thuộc về chiến lược khoảng trắng được trình bày trong bài về trích xuất bảng; khi ngay cả cách đó cũng chưa đủ, các word box và block từ structured text và thứ tự đọc là nguyên liệu thô cho một bộ đọc riêng theo miền nghiệp vụ. Demo TableExtractionLab đi kèm component có phơi DetectFilledRulings trong panel options của nó, đó là cách nhanh nhất để xem một bản export cho ra sao khi bật và khi tắt tùy chọn này; toàn bộ API được trình bày trên trang PDFium Component cho Delphi