HotPDF възстановява tables от съществуващ PDF чрез ExtractLoadedTypedTables, Delphi API, който merge-ва row fragments, създадени от layout pass-а, изгражда една canonical column grid на table, продължава table през page break, когато geometry-ът го позволява, и връща всеки cell като typed value с page provenance, column span и bounds. ExportLoadedTypedTables записва същия result директно в CSV или JSON. Сценарият, който прави това полезно, е скучен и изключително често срещан. 40-page invoice register, една логическа table, отпечатана с header, повторен в началото на всяка page. Пуснете naive reading-order pass и получавате 40 tables, 39 spurious header rows и currency column, която се измества с една position наляво на всеки row, в който средният cell случайно е празен. Почистването downstream, вътре в calling application-а, е мястото, където document-import проектите отиват да умрат
Защо PDF page ви дава fragments вместо table?
Защото PDF page няма table semantics, освен ако document-ът не е tagged. Content stream-ът съдържа text-showing operators и positioning matrices (ISO 32000-1 §9.4.3) и нищо повече; ruled box-ът, който виждате на екрана, е unrelated path painting, което никой extractor не е длъжен да свърже с text-а. Structure element types Table, TR, TH и TD живеят само в logical structure hierarchy на tagged PDF (ISO 32000-1 §14.8.4), а огромното мнозинство от business document-и в обращение не са tagged. Всичко, описано по-долу, е geometric recovery, а не parsing, и си струва да го кажем на глас, преди някой да построи reconciliation report върху него
Затова HotPDF първо изпълнява semantic layout analysis върху extracted glyph-ове, същия pass, който стои зад structure-order text extraction от loaded PDF и structured HTML и XML exports. Този pass групира baselines в runs, чиито cells се подравняват vertical, и продължава run само докато consecutive rows имат един и същ cell count. За layout engine това правило е правилно и евтино. За caller-а е грешната форма: един row с празен interior cell разделя една visual table на две source tables. Typed table layer-ът стои над този pass точно за да събере парчетата обратно
Canonical column grids и ColumnTolerance knob
ExtractLoadedTypedTables merge-ва same-page fragments преди да направи каквото и да е друго и merge-ва по column geometry, а не по row text. Две съседни source tables на една page се join-ват, когато и двете имат поне две columns, vertical gap-ът между последния row на първата и първия row на втората е в tolerance band-а и column start positions се подреждат. Column starts, които са в рамките на ColumnTolerance една от друга, се collapse-ват до една canonical column и се осредняват при merge. Default tolerance е 12 user-space units, което е подходящо за обикновена business typography и обикновено трябва да се повиши при wide-tracked или дълбоко indented layouts
Какво се случва с row, който няма interior value, е частта, която има значение. HotPDF snap-ва всеки cell към най-близкия canonical column start и след това задава ColumnSpan на разстоянието от тази column до следващата occupied, вместо да измества останалите cells наляво. Three-cell row в five-column grid запазва values под правилните headings и записва точно къде са gaps. Това е разликата между 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 units
Options.MinimumTableConfidence := 0.55; // под това tables се отхвърлят
Options.DateOrder := ttdoDMY; // 03/04/2026 е 3 април
Options.DecimalSeparator := ',';
Options.ThousandsSeparator := '.';
if Pdf.ExtractLoadedTypedTables([0, 1, 2, 3], Options, Tables, Info) then
// Info.TableCount спрямо Info.SourceTableCount показва колко е merge-нато
ProcessTables(Tables)
else if Info.Status = ttesBudgetExceeded then
Log(string(Info.Diagnostic));
finally
Pdf.Free;
end;
end;
Какво всъщност гарантира cross-page merging?
То гарантира conservatism, нарочно. HotPDF join-ва две tables през page boundary само когато MergeAcrossPages е enabled, когато втората table започва точно на page index-а след края на първата, когато и двете имат поне две columns и когато поне два canonical column starts се подравняват в рамките на ColumnTolerance. Consecutive-page условието е носещото. Caller-ите подават PageIndices като open array в какъвто ред искат и без тази проверка заявка за pages 3, 9 и 14 би могла да зашие три несвързани tables в един напълно правдоподобен резултат. Цената е, че истинско продължение, което прескача page, interleaved appendix или duplex scan с празен verso, се връща като две tables и никоя option не го разхлабва. Повторното им съединяване е policy call, който само calling application може да направи, затова API-то expose-ва FirstPageIndex, LastPageIndex, SourceTableCount и per-row PageIndex и оставя решението там, където му е мястото
Повторените headers се маркират, никога не се изтриват
ExtractLoadedTypedTables никога не премахва repeated header row от result-а. Когато cross-page merge открие, че incoming table започва с header text, идентичен с този на натрупаната table, сравнен след trimming и case folding, той маркира тези rows с IsHeader и IsRepeatedHeader и все пак ги append-ва в source order. Deletion е lossy, irreversible choice, а различните consumers искат различни answers: CSV import иска repeat-ите да изчезнат, audit trail иска да са налични с page numbers, diffing tool иска source order да е запазен byte по byte. Затова library-то докладва, а 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 values и separators, които трябва да подадете
Type inference-ът върви във fixed order, който разрешава ambiguities в единствената разумна посока: първо boolean, после date, после percentage, после currency, после plain number, а всичко unmatched остава string. Именно order-ът спира 2026 в date column да бъде решено от number parser, преди date parser да го види. Currency се разпознава по водещи $, £, ¥ или €, или по three-letter ISO 4217 code, последван от space, като code-ът се запазва в CurrencyCode. Критичното е, че HotPDF не гадае за вашия locale. DecimalSeparator, ThousandsSeparator и DateOrder идват от options, защото 1.234 е или едно число, или хиляда двеста тридесет и четири според факт, който PDF не съдържа. Raw Unicode Text се запазва във всеки cell заедно с typed value, така че грешен guess винаги може да се възстанови без втори extraction pass
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;
Двата export format-а отговарят на различни въпроси и нарочно не са equivalent. CSV записва continuation columns на merged span като empty fields, което е точно това, което spreadsheet или bulk loader очаква. JSON пази всичко, което extraction е знаел: typed value под собствения му kind, columnSpan, per-cell и per-row confidence, cell bounds и page и source-table provenance. И двата format-а stage-ват целия document в bounded in-memory buffer и чак след това publish-ват към destination stream, като възстановяват оригиналните bytes, length и position, ако write-ът се провали по средата, така че failed export никога не оставя half-written file. Budgets за pages, glyphs per page, tables, rows, cells, characters и output bytes се отчитат отделно, а rows се броят преди allocation, защото per-row SetLength се изражда в quadratic copying много преди default ceiling-а от милион rows
Къде geometric table recovery се отказва
Да сте explicit за failure modes е по-полезно от feature list, защото всяко от тях е място, където caller-ът се нуждае от своя policy, а не от по-добра option value
- Vertical merges не се възстановяват. HotPDF докладва
ColumnSpanза horizontal spans и оставяRowSpanна 1, така че cell, който обхваща три rows в printed table, пристига като един cell плюс две gaps - Header detection е data-driven, не visual. Header block е run-ът от rows преди първия row, съдържащ non-string typed value, така че table, чието тяло е изцяло text, докладва
HeaderRowCountкато zero независимо от стила - Tables под
MinimumTableConfidenceсе изхвърлят от result-а без error. СравнетеInfo.TableCountсInfo.SourceTableCount, когато трябва да знаете, че нещо е било discarded - Run се нуждае от поне два rows и поне две columns, преди layout pass да го нарече table, така че едноредов pseudo-table или two-column layout с дълга prose е правилно, макар и неособено полезно, да не бъде table
- Scanned pages не съдържат text operators, така че няма нищо за geometric recovery, докато на page-а не съществува OCR text layer
Ако PDF-ите ви излизат от собствената reporting stack, най-евтиният fix за всичко това е upstream: emit-вайте tagged tables или пазете source data и третирайте extraction като fallback за document-и, които не сте произвели вие. За всичко останало pipeline-ът си струва да се учи в този ред, защото всеки layer се гради върху долния: започнете с plain text extraction от loaded PDF, преминете към typed table API, когато geometry трябва да бъде запазена, и разгледайте rendering на data table в нов PDF, когато сте от страната на generation и можете да решите колко recoverable ще бъде output-ът
ExtractLoadedTypedTables и ExportLoadedTypedTables са част от native HotPDF Delphi PDF Component за Delphi и C++Builder, без external DLL и без runtime dependency; продуктова страница съдържа пълния option, status и record reference за typed table API