PDFium Component เวอร์ชัน 3.117.0 เชื่อมตารางที่ขาดออกจากกันตรงขอบหน้าเมื่อเข้าเงื่อนไขใดเงื่อนไขหนึ่ง: ทั้งสองส่วนแตะขอบกระดาษ หรือไม่มีข้อความเนื้อหาวางอยู่ใต้ส่วนแรกและเหนือส่วนที่สองเลย โดยไม่นับ running header กับ footer ExtractDocumentTables ใช้การตรวจแบบดูเนื้อหานั้นเป็นทางเลือกแทนการตรวจด้วยขอบกระดาษแบบเก่า ปฏิเสธส่วนที่อยู่หน้าถัดไปซึ่งแถวแรกเป็นเซลล์ caption ที่กว้างเต็มความกว้างทั้งแถวเพียงเซลล์เดียว และเก็บแถวเดียวที่ล้นไปหน้าถัดไปไว้เป็นส่วนหนึ่งของ continuation chain ของมัน
บทความเรื่องการตรวจจับและดึงตารางนำเสนอการต่อตารางเป็นประตูเข้มงวดสี่บาน และนับ "แตะขอบกระดาษ" เป็นหนึ่งในนั้น คำอธิบายนั้นถูกต้องสำหรับรุ่นที่มันครอบอยู่ และมันก็ผิดสำหรับตารางส่วนใหญ่ที่คนจริง ๆ ป้อนให้คอมโพเนนต์ บทความนี้คือการแก้ให้ถูก: เอกสารแบบไหนที่การตรวจด้วยขอบกระดาษจัดการไม่ได้, อะไรเข้ามาแทน และเคสข้างเคียงสองเคสที่การแก้ลากเข้ามาด้วย
ทำไมการตรวจด้วยขอบกระดาษถึงล้มเหลวกับไฟล์ที่ export จาก Word
การตรวจด้วยขอบกระดาษล้มเหลวเพราะโปรแกรมประมวลคำหยุดวางแถวที่ขอบล่างของเนื้อหา ไม่ใช่ที่ขอบกระดาษ ด้วยค่าเริ่มต้นของ ContinuationMargin ที่ 36 point กฎเดิมกำหนดให้ขอบล่างของส่วนแรกอยู่ภายใน 36 point จากก้นหน้า และขอบบนของส่วนหลังอยู่ภายใน 36 point จากหัวหน้า เอกสารที่ export จาก Word ด้วยขอบหนึ่งนิ้วตามค่าเริ่มต้นวางแถวสุดท้ายไว้สูงจากก้นหน้าอย่างน้อย 72 point และสูงกว่านั้นอีกถ้ามี footer เงื่อนไขนั้นจึงไม่เคยเป็นจริง ตารางยาวทุกตัวในเอกสารแบบนั้นกลับมาเป็นส่วนที่แยกจากกันโดยมี ContinuationGroup เป็นศูนย์ และผู้เรียกก็กลับไปเย็บต่อด้วยมือ การตรวจนี้ยังสมเหตุสมผลกับสิ่งที่มันถูกออกแบบมาสำหรับ: รายงานที่สร้างโดย layout engine ซึ่งเติมหน้าให้เต็มกล่องเนื้อหาขนาดคงที่แล้วเริ่มหน้าใหม่ชิดขอบบน มันไม่ใช่กฎที่แย่ แต่มันเป็นกฎที่ไม่ครบ นั่นคือเหตุผลที่เวอร์ชัน 3.117.0 เก็บมันไว้แล้วเพิ่มเส้นทางที่สองเข้ามาแทนที่จะแทนที่มัน
การตรวจแบบดูเนื้อหาตรวจอะไรแทน
การตรวจแบบดูเนื้อหาตรวจว่ามีอะไรอื่นนอกจากตารางอยู่ในช่องว่างระหว่างสองส่วนไหม โดยใช้ word box ของแต่ละหน้าแทนเรขาคณิตของหน้า ขณะที่ ExtractDocumentTables เดินผ่านเอกสาร มันบันทึกไว้เป็นรายหน้าว่าขอบล่างต่ำสุดของคำใดก็ตามที่ขอบบนอยู่เหนือแถบ footer และขอบบนสูงสุดของคำใดก็ตามที่ขอบล่างอยู่ใต้แถบ header เป็นเท่าไร แถบทั้งสองลึกเท่ากับ ContinuationMargin point ตัวออปชันเดียวกันจึงทำหน้าที่สองอย่างไปด้วย: เป็นความเผื่อที่ขอบหน้า และเป็นความสูงของโซน running header กับ footer ส่วนคู่หนึ่งผ่านเมื่อขอบล่างของส่วนแรกอยู่ที่หรือต่ำกว่าข้อความเนื้อหาต่ำสุดบนหน้าของมัน และขอบบนของส่วนหลังอยู่ที่หรือสูงกว่าข้อความเนื้อหาสูงสุดบนหน้าถัดไป โดยแต่ละอันอยู่ในช่วง AlignmentTolerance พูดง่าย ๆ คือ: ตารางเป็นสิ่งสุดท้ายบนหน้า N และเป็นสิ่งแรกบนหน้า N+1 และเลขหน้ากับชื่อเอกสารในแถบขอบกระดาษไม่นับ การกันออกนั้นไม่ใช่เรื่องตามอำเภอใจ ISO 32000-1 §14.8.2.2 จัด running header กับ footer เป็น pagination artifact คือเนื้อหาที่มีอยู่เพราะการขึ้นหน้าใหม่ ไม่ใช่ทั้งที่มีมัน และแนวคิดเดียวกับที่ให้ reader ที่อ่าน tag ข้ามมันได้ก็คือแนวคิดที่ให้ตารางต่อผ่านมันไปได้ บทความเรื่อง marked content ครอบคลุมว่าไฟล์ที่มี tag ประกาศ artifact เหล่านั้นอย่างชัดเจนได้อย่างไร ตรงนี้การจัดประเภทอนุมานจากตำแหน่ง เพราะตารางที่ export ออกมาส่วนใหญ่ไม่มี tag เลย
การตรวจทั้งสองรวมกันแบบ OR รายงานจาก layout engine ที่ตารางวิ่งไปถึงขอบกระดาษผ่านเงื่อนไขแรก ส่วนไฟล์ที่ export จาก Word ซึ่งตารางหยุดที่ขอบเนื้อหาผ่านเงื่อนไขที่สอง และเอกสารที่เป็นทั้งสองอย่างก็ผ่านสองครั้ง ประตูที่เหลือจะรันก็ต่อเมื่อมีอันใดอันหนึ่งผ่านแล้วเท่านั้น และมันรันตามลำดับตายตัว: เลขหน้าต้องติดกัน, ส่วนหลังต้องไม่เปิดด้วยแถว caption และขอบเขตคอลัมน์ต้องตรงกันภายในสองเท่าของ AlignmentTolerance ซึ่งคือ 6 point ที่ค่าเริ่มต้น enum ที่ใช้คือ TPdfTableContinuation ที่มีค่า ptcNone, ptcStart, ptcMiddle และ ptcEnd ส่วนที่ถูกทำเครื่องหมายเป็น ptcEnd แล้วเชื่อมต่อไปยังอีกหน้าหนึ่งจะถูกเลื่อนขึ้นเป็น ptcMiddle ตารางสามหน้าจึงอ่านได้เป็น start, middle, end ตามลำดับหน้า หมายเลขกลุ่มเริ่มที่ 1 และ 0 หมายถึงไม่ได้เชื่อม และ ToJson ส่งข้อมูลเดียวกันออกมาเป็นสมาชิก continuation กับ continuationGroup ซึ่งเป็นรูปแบบที่ควรใช้ถ้าบริการปลายทางเป็นตัวเย็บต่อ
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; // ค่าเริ่มต้น; แสดงไว้เพื่อความชัดเจน
Options.ContinuationMargin := 54; // footer สองบรรทัด ลึก ~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;
แถว caption หยุดไม่ให้ตารางสองตัวหลอมรวมกันได้อย่างไร
ส่วนที่อยู่หน้าถัดไปซึ่งแถวแรกเป็นเซลล์เดียวที่คร่อมทุกคอลัมน์จะถูกมองเป็นตารางใหม่ ไม่เคยถูกมองเป็นส่วนที่เหลือของตารางก่อนหน้า กฎนี้มีอยู่เพราะการตรวจแบบดูเนื้อหา ถ้าปล่อยไว้ลำพัง จะเชื่อมต่อเร็วเกินไป เคสที่ทำให้มันโผล่คือฟอร์มสไตล์ใบรับรอง: ตารางหนึ่งจบใกล้ก้นหน้า 1 ตารางที่สองที่มีความกว้างคอลัมน์เหมือนกันเป๊ะเริ่มใกล้หัวหน้า 2 ระหว่างสองตารางมีแค่ footer และคอลัมน์ก็ตรงกันถึงหลัก point ภายใต้การตรวจด้วยขอบกระดาษทั้งสองไม่เคยเจอกันเพราะไม่มีตัวไหนแตะขอบ แต่ภายใต้การตรวจแบบดูเนื้อหามันเชื่อมกันทันที แล้วฟอร์มที่มีหลายส่วนก็กลายเป็นกริดก้อนเดียวที่ไม่สมเหตุสมผล สิ่งที่แยกสองตัวออกจากกันมองเห็นได้ในโครงสร้างเซลล์ ตารางที่สองเปิดด้วย caption ประจำส่วนอย่าง "RECIPIENT INFORMATION" ที่จัดวางเป็นเซลล์ merged เซลล์เดียวคร่อมความกว้างทั้งหมด และส่วนที่ต่อจากตารางจริง ๆ ไม่เคยทำแบบนั้น เพราะ caption เป็นของตารางที่เริ่มไปแล้วบนหน้าก่อน TableStartsWithCaptionRow เข้ารหัสสิ่งนั้นไว้ตรง ๆ: ส่วนนั้นมีอย่างน้อยสองคอลัมน์และมีเซลล์ที่มี RowIndex = 0, ColumnIndex = 0 และ ColumnSpan = ColumnCount การตรวจนี้รันเฉพาะกับส่วนที่อยู่ทีหลัง ตารางที่แถว caption ของตัวเองอยู่บนหน้าแรกจึงไม่ได้รับผลกระทบ; caption อยู่บนหน้า N และมีแค่ส่วนของหน้า N+1 ที่ถูกตรวจจับ
การเทียบคอลัมน์ที่ตามมา TablesHaveMatchingColumns เข้มกว่า "จำนวนคอลัมน์เท่ากัน" มันสร้างตำแหน่งขอบเขตของแต่ละส่วนขึ้นใหม่จากสี่เหลี่ยมของเซลล์, ประมาณค่าขอบเขตที่เซลล์ merged บังอยู่ และปฏิเสธคู่เมื่อมีขอบเขตใดเพี้ยนเกินกว่า tolerance ตารางสี่คอลัมน์สองตัวที่มีสัดส่วนต่างกันจึงแยกกันอยู่ แม้ทุกอย่างอื่นจะเรียงตรงกัน
เกิดอะไรกับแถวเดียวที่ล้นไปหน้าถัดไป
กริดที่มีเส้นตารางซึ่งพาแถวหนึ่งไปหน้าถัดไปตอนนี้ถูกตรวจจับและเชื่อม ถ้ามันไปลงเอยใน continuation chain; ถ้าอยู่ลำพังก็ถูกทิ้ง MinRows ค่าเริ่มต้นที่ 2 มีอยู่เพื่อกันไม่ให้เส้นสองเส้นที่หลงเหลือถูกมารายงานเป็นตาราง แต่แถวสุดท้ายที่ถูกดันข้ามรอยแบ่งเป็นแถวจริงที่พื้นขั้นต่ำแข็ง ๆ ที่ 2 ทิ้งไปเงียบ ๆ และส่วนที่เหลือของตารางก็ดูครบถ้วนทั้งที่มันไม่ครบ การสแกนระดับเอกสารจัดการเรื่องนี้ในสามขั้น เมื่อตั้งทั้ง DetectContinuations และ DetectRuledTables รอบตรวจรายหน้าจะรันตัวตรวจจับแบบมีเส้นตารางโดยลดพื้นขั้นต่ำของแถวลงชั่วคราวเป็น 1 นั่นคือเหตุผลที่ ExtractTables ตอนนี้รับ MinRows เป็น 1 สำหรับกริดที่มีเส้นตาราง ขณะที่การตรวจจับแบบ whitespace ยังคงพื้นขั้นต่ำภายในที่ 2 จากนั้นทำเครื่องหมาย continuation บนผลลัพธ์ทั้งหมด แล้วลบทุกตารางที่สั้นกว่า MinRows ของผู้เรียกและไม่ได้เป็นส่วนหนึ่งของโซ่ใด ส่วนที่เป็นแถวเดียวจะรอดได้ก็เพราะมันถูกเชื่อมไว้เท่านั้น และกริดแถวเดียวที่อยู่กลางหน้าปกติก็ถูกกรองออกเหมือนเดิมทุกประการ
// สร้างแต่ละโซ่ใหม่เป็น CSV ไฟล์เดียว โดยทิ้งแถว header ที่ซ้ำ
// บนส่วนที่เป็น 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 ที่โปรแกรมประมวลคำพิมพ์ซ้ำ
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;
มีสองรายละเอียดในรูทีนนั้นที่ตั้งใจ ตัวที่ล้นมาแถวเดียวไม่เคยถูกตัดออก เพราะ guard บน RowCount เก็บมันไว้ และโปรแกรมประมวลคำที่พิมพ์แถว header ซ้ำทุกหน้าจะสร้างส่วนที่บรรทัดแรกเป็น header อีกครั้ง การทิ้งบรรทัดที่ศูนย์บนส่วน middle และ end จึงถูกสำหรับเคสนั้นและผิดสำหรับตัวสร้างไฟล์ที่ไม่พิมพ์ header ซ้ำ ลองกับเอกสารสักหนึ่งฉบับก่อนปล่อยรูทีนนี้ไปไล่ทั้งโฟลเดอร์
กฎยังหยุดอยู่ตรงไหน
การตรวจแบบดูเนื้อหาดีได้เท่ากับ text layer ที่มันอ่าน บนหน้าที่สแกนมาและไม่มีข้อความเลย ค่าสุดโต่งของข้อความเนื้อหาที่บันทึกไว้จะ fallback ไปที่ขอบเขตของหน้า เงื่อนไข "ไม่มีอะไรอยู่ระหว่าง" ก็เป็นจริงแบบกลวง ๆ และเหลือแค่ประตู caption row กับประตูคอลัมน์ กริดที่มีเส้นตารางบนหน้าแบบนั้นก็ยังถูกพบเป็นโครงเปล่า ๆ โซ่อาจเชื่อมถูกต้อง แต่ไม่มีอะไรเกี่ยวกับข้อความรอบ ๆ ที่ถูกตรวจสอบจริง ๆ เติม text layer ก่อนถ้าเรื่องนั้นสำคัญ footer ที่ถูก render เป็นรูปภาพแทนที่จะเป็นข้อความก็มองไม่เห็นด้วยตรรกะของแถบ และไม่เป็นอันตรายด้วยเหตุผลเดียวกัน
แถบทั้งสองเป็นตัวเลขค่าเดียว footer ที่ลึกกว่า ContinuationMargin จะทิ้งบรรทัดล่าง ๆ ของมันไว้ในโซนเนื้อหา ซึ่งทำให้ส่วนแรกดูเหมือนมีข้อความตามหลังและปิดกั้นการเชื่อม; ให้เพิ่มค่าออปชันขึ้นเป็นความลึกจริงของแถบอย่างที่ตัวอย่างแรกทำ ถ้าเพิ่มไกลเกินไป ย่อหน้าปิดท้ายสั้น ๆ ใกล้ก้นหน้าก็จะเลื่อนเข้าไปในแถบและถูกเพิกเฉย ซึ่งจะเชื่อมตารางเข้ากับอะไรก็ตามที่ตามหลังมัน กฎ caption มีความล้มเหลวแบบกระจกเงา: ตัวสร้างไฟล์ที่เขียนแบนเนอร์ merged ว่า "continued" เป็นแถวแรกของทุกส่วน continuation จะทำให้ส่วนเหล่านั้นถูกปฏิเสธว่าเป็นตารางใหม่ และทางแก้เดียวที่มีตอนนี้คือเย็บต่อด้วย ContinuationGroup เองโดยไม่ต้องไปคลายอะไร เพราะกฎนี้ไม่มีสวิตช์
ตารางที่ตรวจจับด้วย whitespace ไม่ได้บรรเทาแบบแถวเดียวเลย กลยุทธ์ whitespace ต้องมีสองแถวที่เรียงตรงกันจึงจะเห็นตารางได้ ตารางที่ไม่มีเส้นตารางและล้นไปแถวเดียวจึงยังถูกรายงานขาดไปแถวนั้น เมื่อเจอเข้าจริง word box ที่อยู่เบื้องหลังstructured text block และลำดับการอ่านให้ตำแหน่งดิบที่คุณใช้กู้มันกลับมาได้ บนชุดตัวอย่างที่ขับเคลื่อนงานนี้ ซึ่งเป็นไฟล์ export จากโปรแกรมประมวลคำและเบราว์เซอร์สิบสามไฟล์ เอกสารห้าฉบับที่มีตารางหลายหน้าจริงเชื่อมเป็นโซ่เดียวทั้งหมด และฟอร์มใบรับรองที่เคยหลอมรวมกันก็แยกออกจากกัน นั่นคือเกณฑ์ที่รุ่นนี้ถูกวัดด้วย ไม่ใช่คำสัญญาเกี่ยวกับทุกเลย์เอาต์
การทำเครื่องหมาย continuation, กฎ caption และรอบตรวจแถวเดียว ล้วนอยู่ในเส้นทางระดับเอกสารที่บิลด์ Delphi, C++Builder และ Lazarus ใช้ร่วมกัน; API การดึงตารางแบบเต็มอธิบายไว้ที่หน้า PDFium Component for Delphi