บทความเทคนิค

filled rectangle เป็นเส้นตารางใน PDFium Delphi

การดึงตารางของ PDFium Component ตั้งแต่เวอร์ชัน 3.117.0 มอง filled rectangle บาง ๆ เป็นเส้นตารางของตาราง เมื่อเปิด DetectFilledRulings ซึ่งเป็นค่าเริ่มต้น กล่องทึบที่เรียงตามแกนและหนาไม่เกิน MaxRulingThickness (3 point) จะกลายเป็นเส้นตารางหนึ่งเส้นตามแกนยาวของมัน กล่องทึบที่ใหญ่กว่านั้นให้เส้นขอบสี่ด้าน และพิกัดของเส้นตารางทุกเส้นจะถูก snap ภายใน RulingSnapTolerance (4 point) ก่อนที่กริดจะถูกประกอบขึ้น ตารางที่ export จาก Word, Google Docs และเบราว์เซอร์จึงไปถึงตัวตรวจจับแบบมีเส้นตารางในฐานะกริดที่ครบถ้วน แทนที่จะตกไปที่การตรวจจับแบบ whitespace ในฐานะชิ้นส่วนกระจัดกระจาย

บทความก่อนหน้าเรื่องการตรวจจับและดึงตารางระบุว่าการตรวจจับแบบมีเส้นตารางใช้เส้นที่วาดไว้ และแต่ละ segment ของ path ที่ถูก stroke จะถูก transform เป็นพิกัดหน้า ประโยคนั้นจริงแต่ไม่ครบ การนับ path object ในชุดเอกสารตัวอย่างจริง 13 ฉบับแสดงว่า 9 ฉบับไม่มี stroked path เลย แต่ทุกหน้าของพวกมันมี filled rectangle หนา 0.5 ถึง 1 point เป็นร้อย ๆ ตัว ตัวตรวจจับที่ดูแค่ stroke จึงไม่เห็นอะไร ทุกหน้าตกลงไปที่การตรวจจับแบบ whitespace และผลลัพธ์ก็เป็นเศษชิ้นเล็ก ๆ กระจัดกระจายแทนที่จะเป็นตาราง preset compact-columns ที่เพิ่มใน 3.116.4 บรรเทาเรื่องนี้ในระดับชิ้นส่วน แต่ต้นตอคือตัวตรวจจับอ่าน painting operator ผิดตัว

ทำไมตารางที่ export จาก Word ถึงไม่มีเส้นที่ถูก stroke

โปรแกรมประมวลคำไม่ได้คิดว่าขอบคือเส้น มันคิดว่าขอบคือกล่องที่มีความกว้าง แล้วทากล่องนั้นด้วยการ fill ISO 32000-1 §8.5.2.1 นิยามตัวดำเนินการ re ว่าต่อ rectangle subpath เข้าไป และ §8.5.3 แยกตัวดำเนินการทาออกเป็น: S stroke path ด้วยความกว้างเส้นปัจจุบัน ส่วน f fill พื้นที่ภายใน ขอบเซลล์หนา 0.5 point จึงออกมาเป็น x y w 0.5 re f และกลไกการ stroke ทั้งความกว้างเส้น, join และ dash pattern ไม่เคยถูกเรียกเลย การแรเงาเซลล์ก็เป็นโครงสร้างเดียวกันแต่กล่องใหญ่กว่า กริดที่ stroke ด้วย m, l และ S คือสิ่งที่ตัวตรวจจับเดิมคาดหวัง และมันคือสิ่งที่แทบไม่มีอะไรที่ export จากแอปพลิเคชันสำนักงานผลิตออกมา:

% ขอบเซลล์หนึ่งเส้นจากไฟล์ที่ export จากโปรแกรมประมวลคำ: กล่องทึบสูง 0.5 pt
72 700 468 0.5 re f
% การแรเงาเซลล์: กล่องทึบขนาดเท่าเซลล์
72 676 117 24 re f
% เส้นกริดแบบ stroke ที่ตัวตรวจจับเดิมถูกเขียนขึ้นมารองรับ
72 700 m 540 700 l S

สำหรับตัวตรวจจับที่ถาม FPDFPath_GetDrawMode แค่ว่าธง stroke ถูกตั้งไหม กล่องทึบทั้งสองแบบก็มองไม่เห็น ข้อความในเซลล์ก็ไปถึงการตรวจจับแบบ whitespace ซึ่งคอลัมน์ที่คั่นด้วยช่องว่าง 6 point ต่ำกว่าค่าเริ่มต้นของ MinColumnGap ที่ 12 point และสิ่งที่ได้กลับมาคือแถวชุดย่อยใดก็ตามที่บังเอิญเรียงตรงกันพอจะผ่าน MinRows นั่นคือพฤติกรรมแบบชิ้นส่วน และการจูนพารามิเตอร์เท่าไรก็ไม่ทำให้มันกลายเป็นกริดที่คนเขียนเอกสารวาดไว้

PDFium Component เปลี่ยนกล่องทึบให้เป็นเส้นตารางได้อย่างไร

TableCollectObjectRulings ตรวจ path object ทุกตัวทีละ subpath โหมดการทามาจาก FPDFPath_GetDrawMode; path จะนับว่าเป็นแบบ fill เมื่อเปิด DetectFilledRulings และโหมด fill ไม่ใช่ none จุดทุกจุดถูก transform ผ่าน matrix ของออบเจ็กต์และเก็บไว้ สูงสุด MaxSubpathPoints (8) ต่อ subpath และ segment ที่เป็นเส้นโค้งใด ๆ จะทำให้ subpath นั้นถูกทำเครื่องหมายว่าโค้ง เมื่อ subpath ปิดหรือมี MoveTo ใหม่เริ่มขึ้น FlushSubpath จะตัดสินว่ามันคืออะไร: subpath ที่โค้งถูกทิ้ง และรูปหลายเหลี่ยมปิดที่มีจุดไม่ได้อยู่ภายใน PointTolerance (0.05 point) จากขอบของ bounding box บนอย่างน้อยหนึ่งแกนก็ถูกทิ้งด้วย สามเหลี่ยม, chevron หรือแท็บมุมโค้งไม่เคยกลายเป็นเส้นตาราง นั่นคือสิ่งที่กันงานศิลป์ประดับไม่ให้เข้าไปในกริด

แผนภาพของ PDFium Component ที่แสดงว่า TableCollectObjectRulings เปลี่ยน subpath ที่ปิดให้เป็นเส้นตารางได้อย่างไรใน Delphi: FlushSubpath ทิ้งโครงร่างที่โค้งและรูปหลายเหลี่ยมที่ไม่อยู่บนขอบ bounding box, MaxRulingThickness แยกกล่องบางออกเป็นหนึ่งเส้นตารางต่อแกนยาว, เซลล์ที่แรเงาให้เส้นตารางสี่เส้นตามขอบ และ DetectFilledRulings กันสี่เหลี่ยมจิ๋วไม่ให้เข้ามา
subpath ที่ปิดจะรอดก็ต่อเมื่อมันเรียงตามแกน และ bounding box ก็เป็นตัวตัดสินว่ามันเป็นเส้นตารางหนึ่งเส้น, ขอบสี่ด้านของเซลล์ที่แรเงา หรือไม่ใช่อะไรเลย

สิ่งที่รอดมาคือสี่เหลี่ยมที่เรียงตามแกน โดยจำแนกจาก bounding box ของมัน ความกว้างที่เท่ากับหรือต่ำกว่า MaxRulingThickness ขณะที่ความสูงสูงกว่านั้น ให้เส้นตารางแนวตั้งหนึ่งเส้นที่กึ่งกลางแนวนอน ครอบกล่องจากล่างขึ้นบน; เคสกลับกันให้เส้นตารางแนวนอนหนึ่งเส้น ถ้ามิติทั้งสองเกินเกณฑ์ก็หมายถึงเซลล์ที่ถูกแรเงา และกล่องนั้นให้เส้นตารางสี่เส้น หนึ่งเส้นต่อหนึ่งขอบ ถ้ามิติทั้งสองอยู่ที่หรือต่ำกว่าเกณฑ์ก็ไม่ให้อะไรเลย สัญลักษณ์หัวข้อย่อยสี่เหลี่ยมขนาด 2 point จึงไม่ถูกเข้าใจผิดว่าเป็นเส้น path ที่ stroke ใช้เส้นทางเดิมผ่าน AddLine หนึ่งเส้นตารางต่อหนึ่ง segment ที่เรียงตามแกน กริดที่วาดด้วย S จึงถูกจัดการเหมือนเดิมทุกประการ และ path ที่ทาทั้ง fill และ stroke จะให้ชิ้นส่วนที่ทับกันซึ่งรอบการ merge จะยุบรวมให้:

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;                     // เริ่มที่ 1

    Options := TPdfTableExtractionOptions.Default;
    // นี่คือค่าเริ่มต้นของ 3.117.0 เขียนออกมาให้เห็นชัด ๆ
    Options.DetectFilledRulings := True;     // กล่องทึบบางกลายเป็นเส้นตาราง
    Options.MaxRulingThickness := 3.0;       // point; กล่องที่หนากว่านับเป็นการแรเงา
    Options.RulingSnapTolerance := 4.0;      // point; 0 ปิดการ 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 ทำอะไรให้ตารางที่ใช้การแรเงาเซลล์

RulingSnapTolerance คือสิ่งที่ทำให้ตารางที่สร้างจากการแรเงาเพียงอย่างเดียวเชื่อมกันเป็นกริดเดียว ไฟล์ export บางตัวไม่วาดขอบเลย: ทุกเซลล์เป็นกล่องทึบในสีของตัวเอง และกล่องที่อยู่ข้างกันคั่นด้วยช่องว่างสีขาว 1 ถึง 3 point กล่องแต่ละใบให้เส้นตารางสี่เส้นตามขอบ แต่ขอบขวาของเซลล์หนึ่งกับขอบซ้ายของเซลล์ถัดไปห่างกัน 2 point และการทดสอบการเชื่อมต่อใช้ RulingTolerance ซึ่งมีค่าเริ่มต้นที่ 1 point ถ้าไม่ snap ทุกเซลล์จะกลายเป็น connected component ของเส้นตารางสี่เส้นของตัวเอง ไม่มี component ใดไปถึง MinRows และหน้านั้นก็รายงานอะไรไม่ออก TableSnapRulings รวบรวมพิกัด X ทุกตัวที่เกี่ยวข้อง (ตำแหน่งของเส้นตารางแนวตั้งแต่ละเส้น บวกจุดเริ่มและจุดจบของเส้นแนวนอนแต่ละเส้น) และพิกัด Y ในทำนองเดียวกัน เรียงแต่ละลิสต์ จับกลุ่มด้วยการเชื่อมค่าที่เพื่อนบ้านต่างกันไม่เกิน tolerance แทนแต่ละกลุ่มด้วยค่าเฉลี่ยของมัน แล้วเลื่อนทุกตำแหน่ง จุดเริ่ม และจุดจบไปยังศูนย์กลางกลุ่มที่ใกล้ที่สุด สองฝั่งของช่องว่างจึงกลายเป็นเส้นเดียวกัน และการเชื่อมต่อก็คงอยู่

แผนภาพของ PDFium Component ที่แสดง RulingSnapTolerance เชื่อมตารางที่ใช้การแรเงาเซลล์ใน Delphi: เซลล์ข้างกันทิ้งช่องว่าง 2 pt เส้นตารางตามขอบของพวกมันอยู่ไกลเกิน RulingTolerance ที่ 1 pt และ TableSnapRulings เชื่อมค่า X สองค่าเข้าเป็นค่าเฉลี่ยกลุ่มเดียว การทดสอบการเชื่อมต่อจึงเห็นเส้นกริดที่ใช้ร่วมกันได้เสียที
การ snap รันก่อนการ merge และก่อนตัวตรวจจับแบบมีเส้นตาราง สองฝั่งของช่องว่างสีขาวจึงกลายเป็นเส้นเดียว และทุกเซลล์ก็เลิกเป็นเกาะของเส้นตารางสี่เส้น

การ snap รันก่อน TableMergeRulings ซึ่งเรียงเส้นตารางและเชื่อมชิ้นที่เป็นแนวเส้นเดียวกันซึ่งแตะหรือทับกันภายใน RulingTolerance และทั้งสองอย่างรันก่อนที่ TableDetectRuled จะได้เห็นข้อมูล การตรวจการเชื่อมต่อแบบเป็นคู่จึงเป็นสัดส่วนกับจำนวนเส้นกริด ไม่ใช่จำนวนชิ้นส่วนต่อเซลล์ บนกริดที่ stroke รอบเหล่านี้ไม่เป็นอันตราย เพราะพิกัดที่เหมือนกันอยู่แล้วก็ snap เข้าหาตัวเอง สิ่งเดียวที่ควรระวังคือการจับกลุ่มแบบเชื่อมโซ่ไม่มีความจำกัดความกว้างของตัวเอง: พิกัดที่เรียงกันห่างกันละ 3 point จะยุบรวมเป็นศูนย์กลางเดียว ที่ค่าเริ่มต้น 4 point เรื่องนี้กระทบแค่คอลัมน์ที่แคบกว่าตัวอักษร แต่ถ้าเอกสารมีช่องว่าง 3 point จริง ๆ ที่ต้องแยกจากกัน ให้ลด tolerance ลงหรือตั้งเป็น 0 เพื่อปิดการ snap:

// แยกกลยุทธ์แบบมีเส้นตารางออกมา แล้วเทียบว่าค่าแต่ละแบบเห็นอะไรบนหน้าเดียว
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;

// ไฟล์ที่ export จาก Word ปกติจะรายงาน 0, N แล้วก็น้อยกว่า N:
// แบบดูแค่ stroke ไม่เห็นอะไร การ snap เชื่อมเซลล์ที่ถูกแรเงาเข้าด้วยกัน
// และการปิด snap ก็ทิ้งให้แต่ละเซลล์ที่แรเงาเป็นเกาะของตัวเอง
Writeln(CountRuledTables(Pdf, False, 4.0));
Writeln(CountRuledTables(Pdf, True, 4.0));
Writeln(CountRuledTables(Pdf, True, 0.0));

เส้นตารางภายใน form XObject

เครื่องมือจัดวางหน้ามักห่อตาราง หรือทั้งเนื้อหาของหน้า ไว้ใน form XObject แล้วทาด้วย Do ISO 32000-1 §8.10.1 ระบุว่า form matrix ถูก concatenate กับ current transformation matrix เมื่อ form ถูกทา สี่เหลี่ยมที่อยู่ภายใน form จึงอยู่ใน form space และลงมาบนหน้าได้ก็ต่อเมื่อผ่าน transform ตั้งแต่สองชั้นขึ้นไป TableCollectObjectRulings ลงไปข้างใน form object แบบเรียกซ้ำเมื่อตั้ง IncludeFormXObjects: มันอ่าน matrix ของออบเจ็กต์ รวมมันกับ matrix ของ parent ผ่าน TableMultiplyMatrix ซึ่งลำดับอาร์กิวเมนต์หมายความว่า "map ผ่าน matrix ตัวแรก แล้วจึงตัวที่สอง" และแจกแจงลูกด้วย FPDFFormObj_CountObjects กับ FPDFFormObj_GetObject โดยส่ง matrix ที่รวมแล้วลงไป การซ้อนลึกกว่า MaxFormDepth (8) จะถูกข้ามไปเงียบ ๆ ซึ่งเป็นเกราะกันไฟล์ที่ผิดปกติ ไม่ใช่ขีดจำกัดที่ไฟล์ export จริงเข้าใกล้ เหตุผลที่ลำดับการคูณสำคัญก็เป็นเหตุผลเดียวกับที่พูดถึงในการ prepend กับการ append matrix: การสลับ operand ทำให้พจน์ translation ขยับ แล้วเส้นตารางที่ควรลงบนหัวหน้าก็ไปลงที่จุดกำเนิดแทน

แผนภาพของ PDFium Component ที่แสดงเส้นตารางภายใน form XObject ใน Delphi: สี่เหลี่ยมบางที่เขียนไว้เป็น 72 700 468 0.5 re f อยู่ใน form space และลงมาบนหน้าได้ก็ต่อเมื่อ TableMultiplyMatrix รวม CTM ของ parent กับ form matrix เข้าด้วยกัน โดยเรียกซ้ำผ่าน FPDFFormObj_CountObjects จนถึง MaxFormDepth
สี่เหลี่ยมถูกเขียนไว้ใน form space และไปถึงหัวหน้าได้ก็ต่อเมื่อ matrix ถูกคูณกันในลำดับที่ทำให้พจน์ translation อยู่ตรงที่มันควรอยู่

ทำไมงบประมาณเส้นตารางถึงเพิ่มเป็นสี่เท่า

ค่าเริ่มต้นของ MaxRulingSegments เพิ่มจาก 4096 เป็น 16384 ใน 3.117.0 เพราะขอบรายเซลล์มามากกว่าเส้นกริดแบบ stroke หลายเท่า ตารางขนาด 30 แถว 6 คอลัมน์ที่ stroke คือ 38 segment ตารางเดียวกันที่ export ออกมาเป็นกล่องทึบคือขอบได้ถึงสี่ด้านต่อเซลล์ รวมเป็น 720 ชิ้นก่อน merge และฟอร์มที่มีเซลล์แรเงาก็เพิ่มเป็นสองเท่าของนั้น ตารางแบบนี้สองตัวบนหน้าเดียวก็จะใช้งบเดิมจนหมดแล้ว งบประมาณนี้ถูกบังคับใช้ใน TableAppendRuling ผ่าน Check ซึ่ง raise EPdfError ด้วยข้อความ "Table ruling-segment budget exceeded"; ไม่มีผลลัพธ์แบบเสื่อมคุณภาพ ไม่มีกริดบางส่วน และรอบ whitespace ก็ไม่รันด้วย ถ้าคุณตั้งงบที่แน่นกว่าเองสำหรับ input ที่ไม่น่าเชื่อถือ ให้ดัก exception แล้วตัดสินใจ แทนที่จะอ่านผลลัพธ์ว่างเปล่าว่า "ไม่มีตาราง":

Options := TPdfTableExtractionOptions.Default;
Options.MaxRulingSegments := 2048;        // ตั้งให้แน่นโดยตั้งใจสำหรับ input ที่ไม่น่าเชื่อถือ
try
  Tables := Pdf.ExtractTables(Options);
except
  on E: EPdfError do
  begin
    Log(E.Message);                       // 'Table ruling-segment budget exceeded'
    Options.MaxRulingSegments := 16384;   // ค่าเริ่มต้นของ 3.117.0
    Tables := Pdf.ExtractTables(Options);
  end;
end;

ผลลัพธ์ที่วัดได้ และวิธีนี้หยุดอยู่ตรงไหน

บนเอกสารตัวอย่าง 13 ฉบับเดียวกัน การดึงข้อมูลเปลี่ยนจาก 43 ตาราง โดย 9 ตัวเป็นแบบมีเส้นตารางและ 34 ตัวเป็นชิ้นส่วน whitespace หรือ false positive มาเป็น 41 ตารางแบบมีเส้นตารางและไม่มี false positive จาก whitespace เลย ส่วนหนึ่งของการเก็บกวาดนั้นมาจากการเปลี่ยนแปลงควบคู่สองอย่างใน 3.117.0: คำที่ถูกกริดแบบมีเส้นตารางจับไปแล้วจะถูกลบออกก่อนที่การตรวจจับแบบ whitespace จะรัน ตารางจึงไม่ถูกรายงานซ้ำสองครั้ง และขอบเขตคอลัมน์แบบ whitespace ตอนนี้ต้องเป็นทางเดินที่ไม่มีข้อความตลอดทุกแถวที่มันคั่น ซึ่งเป็นสิ่งที่หยุดไม่ให้ย่อหน้าแบบ justified ถูกให้คะแนนเป็นตาราง 5x4 ตัวอ่าน filled rectangle คือสิ่งที่ย้ายตัวตารางเองจากคอลัมน์ชิ้นส่วนไปยังคอลัมน์แบบมีเส้นตาราง

ขอบเขตของมันคุ้มที่จะบอกให้ชัด หน้าที่ไม่มี text layer ก็ยังให้โครงกริดออกมา โดยทุกเซลล์ว่างเปล่า เพราะเส้นตารางมาจากเรขาคณิตและข้อความมาจาก text page; หน้าที่สแกนมาต้องผ่าน OCR ก่อน รูปทรงทึบที่มีเส้นโค้ง, มุมโค้ง หรือโครงร่างที่ไม่ใช่สี่เหลี่ยมจะถูกทิ้งทั้งหมด ตารางที่ขอบของมันถูกวาดเป็นโครงร่างสี่เหลี่ยมมุมโค้งจึงยังต้องใช้การตรวจจับแบบ whitespace เหมือนเดิม ตารางที่ไม่มีทั้งขอบและการแรเงาไม่ถูกกระทบโดยเรื่องทั้งหมดนี้ และยังเป็นอาณาเขตของกลยุทธ์ whitespace ที่อธิบายไว้ในบทความเรื่องการดึงตาราง; เมื่อแค่นั้นยังไม่พอ word box กับ block จากstructured text และลำดับการอ่านก็เป็นวัตถุดิบสำหรับตัวอ่านเฉพาะทาง TableExtractionLab demo ที่ออกมาพร้อมคอมโพเนนต์เปิดให้ใช้ DetectFilledRulings ในแผงออปชันของมัน ซึ่งเป็นวิธีเร็วที่สุดที่จะเห็นว่าไฟล์ export ตัวหนึ่งหน้าตาเป็นอย่างไรเมื่อเปิดและเมื่อปิดมัน; API แบบเต็มอธิบายไว้ที่หน้า PDFium Component for Delphi