HotPDF กู้ตารางจาก PDF ที่มีอยู่แล้วผ่าน ExtractLoadedTypedTables ซึ่งเป็น Delphi API ที่ merge row fragment จาก layout pass สร้าง canonical column grid ต่อ table ต่อ table ข้าม page break เมื่อ geometry รองรับ และคืนทุก cell เป็น typed value ที่มี page provenance, column span และ bounds ExportLoadedTypedTables เขียนผลลัพธ์เดียวกันตรงเป็น CSV หรือ JSON สถานการณ์ที่ทำให้ต้องสร้างฟีเจอร์นี้น่าเบื่อแต่พบได้บ่อยมาก register invoice สี่สิบหน้าเป็น table เดียวในทางตรรกะ แต่มี header ซ้ำที่หัวทุก page หากใช้ naive reading-order pass จะได้ table สี่สิบตัว, header row ปลอมสามสิบเก้าตัว และ currency column ที่เลื่อนไปทางซ้ายหนึ่งตำแหน่งทุกแถวที่ cell ตรงกลางว่าง การทำความสะอาด downstream ใน calling application คือจุดที่ document-import project มักไปต่อไม่ได้
ทำไม PDF page จึงส่ง fragment มาแทน table
เพราะ PDF page ไม่มี table semantic เลย เว้นแต่ document จะถูก tag content stream มีเพียง text-showing operator กับ positioning matrix (ISO 32000-1 §9.4.3) ไม่มากกว่านั้น กล่องเส้นที่เห็นบนจอเป็น path painting ที่แยกออกไป และ extractor ไม่จำเป็นต้อง correlate กับ text structure element type Table, TR, TH และ TD อยู่เฉพาะใน logical structure hierarchy ของ tagged PDF (ISO 32000-1 §14.8.4) ขณะที่ business document ที่ใช้งานจริงส่วนใหญ่ไม่ได้ tag ทุกอย่างด้านล่างนี้จึงเป็น geometric recovery ไม่ใช่ parsing และควรพูดให้ชัดก่อนสร้าง reconciliation report บนผลลัพธ์นี้
ดังนั้น HotPDF จะรัน semantic layout analysis บน glyph ที่ extract ได้ก่อน ซึ่งเป็น pass เดียวกับที่อยู่เบื้องหลัง structure-order text extraction จาก loaded PDF และ structured HTML กับ XML export pass นี้ group baseline เป็น run ที่ cell align ในแนวตั้ง และจะ continue run ต่อเมื่อ row ที่ติดกันมีจำนวน cell เท่ากัน สำหรับ layout engine กฎนี้ถูกต้องและประหยัด แต่สำหรับ caller รูปทรงนี้ไม่ดี row เดียวที่ cell ด้านในว่างจะผ่า visual table เดียวออกเป็น source table สองตัว typed table layer อยู่เหนือ pass นี้เพื่อประกอบชิ้นส่วนกลับเข้าด้วยกัน
canonical column grid และ knob ColumnTolerance
ExtractLoadedTypedTables จะ merge fragment ที่อยู่ page เดียวกันก่อนทำอย่างอื่น และ merge จาก column geometry ไม่ใช่ row text source table สองตัวที่ติดกันบน page เดียวกันจะ join เมื่อทั้งคู่มีอย่างน้อยสอง column เมื่อ vertical gap ระหว่าง row สุดท้ายของตัวแรกกับ row แรกของตัวถัดไปอยู่ใน tolerance band และเมื่อ column start position align กัน column start ที่อยู่ห่างกันไม่เกิน ColumnTolerance จะ collapse เป็น canonical column เดียวแล้วหาค่าเฉลี่ยตอน merge ค่า tolerance เริ่มต้นคือ 12 user-space unit ซึ่งเหมาะกับ business typography ทั่วไป และควรเพิ่มเมื่อ layout มีการ track กว้างหรือย่อหน้าเข้าไปลึก
สิ่งที่เกิดกับ row ที่ขาดค่าตรงกลางคือส่วนสำคัญ HotPDF จะ snap cell แต่ละตัวไปยัง canonical column start ที่ใกล้ที่สุด แล้วตั้ง ColumnSpan เป็นระยะจาก column นั้นถึง column ถัดไปที่มี cell แทนการเลื่อน cell ที่เหลือไปทางซ้าย row สาม cell ใน grid ห้า column จึงเก็บ value ไว้ใต้ heading ที่ถูกต้องและบันทึกตำแหน่ง gap อย่างชัดเจน นี่คือความแตกต่างระหว่าง table ที่นำไป reconcile ได้กับ table ที่ค่อย ๆ ใส่จำนวนเงินให้ผิดคอลัมน์โดยไม่มีใครรู้
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
Options.MinimumTableConfidence := 0.55; // ต่ำกว่านี้จะทิ้ง table
Options.DateOrder := ttdoDMY; // 03/04/2026 คือ 3 April
Options.DecimalSeparator := ',';
Options.ThousandsSeparator := '.';
if Pdf.ExtractLoadedTypedTables([0, 1, 2, 3], Options, Tables, Info) then
// Info.TableCount เทียบกับ Info.SourceTableCount บอกว่ารวมไปมากแค่ไหน
ProcessTables(Tables)
else if Info.Status = ttesBudgetExceeded then
Log(string(Info.Diagnostic));
finally
Pdf.Free;
end;
end;
การ merge ข้ามหน้ารับประกันอะไรจริง
มันรับประกันความ conservative โดยตั้งใจ HotPDF จะ join table สองตัวข้าม page boundary เฉพาะเมื่อเปิด MergeAcrossPages เมื่อ table ตัวที่สองเริ่มที่ page index ถัดจาก page ที่ table แรกจบพอดี เมื่อทั้งคู่มีอย่างน้อยสอง column และเมื่อ canonical column start อย่างน้อยสองตำแหน่ง align กันภายใน ColumnTolerance เงื่อนไขเรื่อง page ที่ต่อกันคือสิ่งที่รับน้ำหนักที่สุด caller ส่ง PageIndices เป็น open array ในลำดับใดก็ได้ และหากไม่มี check นี้ request หน้า 3, 9 และ 14 อาจเชื่อม table ที่ไม่เกี่ยวกันสามตัวให้กลายเป็นผลลัพธ์เดียวที่ดูสมเหตุสมผล ต้นทุนคือ continuation จริงที่ข้าม page, appendix ที่แทรกกลาง หรือ duplex scan ที่มี verso ว่าง จะกลับมาเป็น table สองตัว และไม่มี option ใดทำให้กฎนี้ผ่อนคลายได้ การ join กลับกรณีเหล่านั้นเป็น policy ที่มีเพียง calling application เท่านั้นตัดสินใจได้ API จึงเปิดเผย FirstPageIndex, LastPageIndex, SourceTableCount และ PageIndex ต่อ row แล้วปล่อย decision ไว้ในจุดที่ควรอยู่
header ซ้ำถูกติดป้าย ไม่ใช่ถูกลบ
ExtractLoadedTypedTables ไม่เคยลบ repeated header row ออกจากผลลัพธ์ เมื่อ cross-page merge พบว่า table ที่เข้ามาเปิดด้วย header text เหมือนกับ table ที่สะสมอยู่ หลัง trim และ case folding แล้ว มันจะ mark row เหล่านั้นด้วย IsHeader และ IsRepeatedHeader แล้ว append ตาม source order อยู่ดี การลบเป็นการเลือกแบบสูญเสียและย้อนกลับไม่ได้ consumer แต่ละแบบต้องการผลต่างกัน CSV import อยากเอา row ซ้ำออก audit trail อยากให้มีพร้อม page number และ diffing tool อยากรักษา source order แบบ byte ต่อ byte ดังนั้นไลบรารีจึงรายงาน แล้วให้ caller ตัดสินใจ
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; // เก็บเฉพาะ header block แรก
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 และ separator ที่คุณต้องส่งให้
การ infer type ทำตามลำดับตายตัวเพื่อคลี่ความกำกวมไปในทิศทางที่สมเหตุสมผลที่สุด คือ boolean ก่อน แล้ว date, percentage, currency และ plain number ตามลำดับ ส่วนค่าที่ไม่ match ยังคงเป็น string ลำดับนี้ป้องกันไม่ให้ 2026 ใน date column ถูก number parser ตัดสินก่อน date parser จะได้เห็นมัน currency จะถูกตรวจจาก $, £, ¥ หรือ € นำหน้า หรือจาก ISO 4217 code สามตัวอักษรตามด้วย space และ code จะถูกเก็บไว้ใน CurrencyCode สิ่งสำคัญคือ HotPDF ไม่เดา locale ของคุณ DecimalSeparator, ThousandsSeparator และ DateOrder มาจาก options เพราะ 1.234 อาจเป็น number หนึ่งตัวหรือหนึ่งพันสองร้อยสามสิบสี่ ขึ้นกับข้อเท็จจริงที่ PDF ไม่ได้บันทึก raw Unicode Text ถูกเก็บไว้ในทุก cell ร่วมกับ typed value ดังนั้นหากเดาผิดก็ยังย้อนกลับไปดูได้โดยไม่ต้อง extract รอบที่สอง
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;
format export สองแบบตอบคนละคำถามและตั้งใจให้ไม่เท่ากัน CSV เขียน continuation column ของ merged span เป็น field ว่าง ซึ่งตรงกับสิ่งที่ spreadsheet หรือ bulk loader คาดไว้ JSON เก็บทุกอย่างที่ extraction รู้ ทั้ง typed value ตาม kind ของมัน columnSpan, confidence ต่อ cell และต่อ row, cell bound รวมถึง page และ source-table provenance ทั้งสอง format จะ stage document ทั้งหมดลงใน bounded in-memory buffer ก่อนค่อย publish ลง destination stream และจะ restore byte, length และ position เดิมหาก write ล้มเหลวกลางทาง ดังนั้น export ที่ fail จะไม่ทิ้งไฟล์ที่เขียนค้างครึ่งหนึ่ง budget ของ page, glyph ต่อ page, table, row, cell, character และ output byte ถูกนับแยกกันทั้งหมด และ row ถูกนับก่อน allocation เพราะ SetLength ต่อ row อาจกลายเป็นการ copy แบบ quadratic นานก่อนถึงเพดาน default หนึ่งล้าน row
จุดที่ geometric table recovery ยอมแพ้
การพูดถึง failure mode อย่างตรงไปตรงมามีประโยชน์กว่ารายการ feature เพราะแต่ละข้อคือจุดที่ caller ต้องมี policy ของตัวเอง ไม่ใช่เลือก option value ที่ดีกว่า
- ไม่มีการกู้ vertical merge HotPDF รายงาน
ColumnSpanสำหรับ horizontal span และปล่อยRowSpanเป็น 1 ดังนั้น cell ที่กินพื้นที่สาม row ใน table ที่พิมพ์ออกมาจะกลายเป็น cell หนึ่งตัวกับ gap สองช่อง - การตรวจ header ขับเคลื่อนด้วย data ไม่ใช่ visual header block คือ run ของ row ก่อน row แรกที่มี typed value ซึ่งไม่ใช่ string ดังนั้น table ที่ body เป็น text ทั้งหมดจะรายงาน
HeaderRowCountเป็นศูนย์ไม่ว่าจะแต่งหน้าตาอย่างไร - table ที่ต่ำกว่า
MinimumTableConfidenceจะถูกทิ้งจากผลลัพธ์โดยไม่เกิด error หากต้องรู้ว่ามีอะไรถูกทิ้ง ให้เทียบInfo.TableCountกับInfo.SourceTableCount - run ต้องมีอย่างน้อยสอง row และอย่างน้อยสอง column ก่อนที่ layout pass จะเรียกมันว่า table ดังนั้น pseudo-table บรรทัดเดียวหรือ layout สอง column ที่เป็น prose ยาวจึงไม่ถูกถือเป็น table อย่างถูกต้อง แม้จะไม่ช่วย caller ก็ตาม
- scanned page ไม่มี text operator ดังนั้นไม่มีอะไรให้กู้ด้วย geometry จนกว่าจะมี OCR text layer อยู่บน page
หาก PDF ของคุณออกมาจาก reporting stack ที่คุณควบคุมได้ วิธีแก้ที่ถูกและถูกที่สุดคือ upstream: emit tagged table หรือเก็บ source data ไว้ และใช้ extraction เป็น fallback สำหรับ document ที่คุณไม่ได้ผลิต สำหรับกรณีอื่น pipeline นี้ควรเรียนรู้ตามลำดับ เพราะแต่ละ layer สร้างบน layer ด้านล่าง เริ่มจาก plain text extraction จาก loaded PDF ขยับไป typed table API เมื่อจำเป็นต้องรักษา geometry และดู การ render data table เป็น PDF ใหม่ เมื่อคุณอยู่ฝั่งสร้างและเป็นคนกำหนดว่า output จะกู้กลับได้มากแค่ไหน
ExtractLoadedTypedTables และ ExportLoadedTypedTables อยู่ใน native HotPDF Delphi PDF Component สำหรับ Delphi และ C++Builder โดยไม่มี external DLL หรือ runtime dependency ส่วน product page มี reference ฉบับเต็มของ option, status และ record สำหรับ typed table API