مقاله فنی

استخراج typed table از PDF در Delphi در چند page

HotPDF از یک PDF موجود با ExtractLoadedTypedTables جدول‌ها را بازیابی می‌کند؛ این API در Delphi، fragmentهای row حاصل از layout pass را merge می‌کند، برای هر table یک column grid canonical می‌سازد، وقتی geometry پشتیبانی کند table را از page break عبور می‌دهد و هر cell را به‌صورت value typed با provenance مربوط به page، column span و bounds برمی‌گرداند. ExportLoadedTypedTables همین نتیجه را مستقیم به CSV یا JSON می‌نویسد. سناریویی که ارزش ساختن این قابلیت را روشن می‌کند، کسل‌کننده و بسیار رایج است: register فاکتور چهل‌صفحه‌ای که از نظر منطقی یک table است و header آن بالای هر page تکرار می‌شود. یک reading-order pass ساده روی آن اجرا کنید و چهل table، سی‌ونه header row اضافی و column مربوط به currency را می‌گیرید که هرجا cell وسط خالی بوده، یک position به چپ لغزیده است. تمیز کردن این وضعیت downstream و داخل application فراخوان، همان جایی است که پروژه‌های document-import از بین می‌روند

چرا page مربوط به PDF به‌جای table، fragment تحویل می‌دهد؟

چون page در PDF اصلاً table semantic ندارد، مگر اینکه document tagged باشد. content stream فقط operatorهای text-showing و matrixهای position (ISO 32000-1 §9.4.3) را نگه می‌دارد و چیز دیگری ندارد؛ box خط‌کشی‌شده‌ای که روی screen می‌بینید، path painting مستقلی است که هیچ extractorی مجبور نیست آن را با text مرتبط کند. typeهای element ساختاری Table، TR، TH و TD فقط در hierarchy ساختار منطقی یک tagged PDF زندگی می‌کنند (ISO 32000-1 §14.8.4) و اکثریت قاطع business documentهای در گردش tagged نیستند. هر چیزی که در ادامه می‌آید recovery هندسی است، نه parsing، و بهتر است این موضوع پیش از ساختن reconciliation report بر پایه آن صریح گفته شود

بنابراین HotPDF ابتدا روی glyphهای استخراج‌شده semantic layout analysis اجرا می‌کند؛ همان passی که پشت text extraction با structure order از PDF بارگذاری‌شده و exportهای structured HTML و XML قرار دارد. آن pass، baselineها را به runهایی گروه‌بندی می‌کند که cellهایشان به‌صورت عمودی align هستند و فقط زمانی run را ادامه می‌دهد که rowهای متوالی تعداد cell یکسان داشته باشند. برای layout engine این rule درست و ارزان است، اما برای caller شکل نادرستی دارد: یک row با cell داخلی خالی، یک table بصری را به دو source table تقسیم می‌کند. typed table layer دقیقاً بالای همان pass قرار دارد تا این قطعه‌ها را دوباره کنار هم بگذارد

column gridهای canonical و knob مربوط به ColumnTolerance

ExtractLoadedTypedTables پیش از هر کار دیگری fragmentهای یک page را merge می‌کند و مبنا را geometry column می‌گذارد، نه متن row. دو source table مجاور در یک page زمانی join می‌شوند که هر دو دست‌کم دو column داشته باشند، فاصله عمودی بین آخرین row اولی و نخستین row دومی داخل tolerance band بماند و position شروع columnهایشان align باشد. startهای column که حداکثر به اندازه ColumnTolerance از هم فاصله داشته باشند، در یک column canonical ادغام و هنگام merge میانگین‌گیری می‌شوند. tolerance پیش‌فرض 12 واحد user-space است که برای typography معمول business مناسب است و برای layoutهای wide-tracked یا عمیقاً indented بهتر است افزایش یابد

رفتار rowی که یک value داخلی ندارد، بخش مهم ماجراست. HotPDF هر cell را به نزدیک‌ترین start از column canonical snap می‌کند و سپس ColumnSpan را برابر فاصله از آن column تا column اشغال‌شده بعدی می‌گذارد، به‌جای اینکه cellهای باقی‌مانده را به چپ shift کند. row سه‌سلولی در grid پنج‌ستونی valueهایش را زیر headingهای درست نگه می‌دارد و دقیقاً ثبت می‌کند gapها کجا هستند. تفاوت بین table قابل reconciliation و tableای که بی‌سروصدا پول را به column اشتباه نسبت می‌دهد همین است

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 آوریل
    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 واقعاً چه چیزی را تضمین می‌کند؟

عمداً محافظه‌کاری را تضمین می‌کند. HotPDF فقط وقتی دو table را از مرز page join می‌کند که MergeAcrossPages فعال باشد، table دوم دقیقاً در page index بلافاصله پس از پایان table اول شروع شود، هر دو دست‌کم دو column داشته باشند و حداقل دو start از columnهای canonical در محدوده ColumnTolerance align شوند. شرط consecutive page همان بخش حیاتی است. callerها PageIndices را به‌صورت open array و با هر ترتیبی که بخواهند می‌فرستند و بدون این check، درخواست برای pageهای 3، 9 و 14 می‌تواند سه table نامرتبط را به یک نتیجه کاملاً باورپذیر جوش بدهد. هزینه این تصمیم آن است که continuation واقعی که یک page را skip کند، appendix درهم‌تنیده یا scan دورو با verso خالی، به‌صورت دو table برمی‌گردد و هیچ optionی این شرط را شل نمی‌کند. اتصال دوباره آن‌ها یک policy call است که فقط calling application می‌تواند بگیرد؛ بنابراین API، FirstPageIndex، LastPageIndex، SourceTableCount و PageIndex برای هر row را expose می‌کند و تصمیم را جایی می‌گذارد که باید باشد

headerهای تکراری label می‌شوند، هرگز حذف نمی‌شوند

ExtractLoadedTypedTables هیچ repeated header rowی را از result حذف نمی‌کند. وقتی cross-page merge متوجه شود table ورودی با header textی شروع شده که با table جمع‌شده یکسان است، پس از trim و case folding، آن rowها را با IsHeader و IsRepeatedHeader mark می‌کند و به هر حال آن‌ها را با source order append می‌کند. حذف، انتخابی lossful و برگشت‌ناپذیر است و consumerهای مختلف جواب‌های مختلف می‌خواهند: CSV import تکرارها را نمی‌خواهد، audit trail آن‌ها را همراه page number می‌خواهد و diffing tool می‌خواهد source order byte به byte حفظ شود. پس library report می‌دهد و تصمیم را به 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;                    // فقط نخستین block مربوط به header را نگه دار
      for C := 0 to High(Row.Cells) do
        if Row.Cells[C].ValueKind = ttvkCurrency then
          Total := Total + Row.Cells[C].NumberValue;
    end;
end;

valueهای typed و separatorهایی که باید فراهم کنید

type inference در ترتیب ثابتی اجرا می‌شود که ambiguityها را در تنها جهت منطقی حل می‌کند: ابتدا boolean، سپس date، بعد percentage، بعد currency و سپس plain number؛ هر چیزی که match نشود string باقی می‌ماند. همین order مانع آن می‌شود که 2026 در یک date column پیش از رسیدن date parser توسط number parser تعیین شود. currency از $، £، ¥ یا ابتدایی یا از یک code سه‌حرفی ISO 4217 که بعدش space آمده باشد شناخته می‌شود و code در CurrencyCode حفظ می‌شود. نکته مهم اینکه HotPDF locale شما را حدس نمی‌زند. DecimalSeparator، ThousandsSeparator و DateOrder از optionها می‌آیند، چون 1.234 بر اساس factی که PDF در خود ندارد، یا یک number است یا هزار و دویست‌وسی‌وچهار. Unicode خام Text در هر cell کنار value typed نگه داشته می‌شود، بنابراین یک حدس اشتباه همیشه بدون 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;

دو format مربوط به export به پرسش‌های متفاوت پاسخ می‌دهند و عمداً equivalent نیستند. CSV، columnهای continuation یک span ادغام‌شده را به‌صورت field خالی می‌نویسد؛ همان چیزی که spreadsheet یا bulk loader انتظار دارد. JSON هر چیزی را که extraction می‌دانسته حفظ می‌کند: value typed را زیر kind خودش، columnSpan، confidence برای هر cell و هر row، bounds cell و provenance مربوط به page و source table. هر دو format کل document را در یک buffer محدود داخل memory stage می‌کنند و تنها بعد آن را در destination stream publish می‌کنند؛ اگر write در میانه fail شود، byteها، length و position اصلی را restore می‌کنند تا export ناموفق هرگز file نیمه‌نوشته باقی نگذارد. budget مربوط به page، glyph در هر page، table، row، cell، character و output byte جداگانه account می‌شود و rowها پیش از allocation شمرده می‌شوند، چون SetLength به‌ازای هر row خیلی پیش از سقف پیش‌فرض یک میلیون row به copy کردن quadratic تبدیل می‌شود

recovery هندسی table کجا تسلیم می‌شود؟

صریح بودن درباره failure modeها از feature list مفیدتر است، چون هرکدام جایی است که caller به policy خودش نیاز دارد، نه option value بهتر

  • merge عمودی بازیابی نمی‌شود. HotPDF برای spanهای افقی ColumnSpan را report می‌کند و RowSpan را روی 1 نگه می‌دارد؛ بنابراین cellی که در table چاپ‌شده سه row را پوشش می‌دهد، به‌صورت یک cell به‌علاوه دو gap می‌رسد
  • تشخیص header داده‌محور است، نه بصری. header block، run مربوط به rowهای پیش از نخستین row دارای value typed غیر-string است؛ بنابراین tableی که body آن کاملاً text است، صرف‌نظر از style آن، HeaderRowCount را صفر گزارش می‌کند
  • tableهای پایین‌تر از MinimumTableConfidence بدون error از result حذف می‌شوند. وقتی لازم است بدانید چیزی کنار گذاشته شده، Info.TableCount را با Info.SourceTableCount مقایسه کنید
  • run پیش از آنکه layout pass اصلاً آن را table بنامد، به دست‌کم دو row و دست‌کم دو column نیاز دارد؛ بنابراین pseudo-table یک‌خطی یا layout دوستونی از prose طولانی، درست اما نه چندان helpful، table محسوب نمی‌شود
  • pageهای scan‌شده هیچ text operatorی ندارند و تا وقتی text layer مربوط به OCR روی page نباشد، چیزی برای recovery هندسی وجود ندارد

اگر PDFهای شما از reporting stack خودتان بیرون می‌آیند، ارزان‌ترین fix برای همه این موارد upstream است: tableهای tagged تولید کنید یا source data را نگه دارید و extraction را fallback برای documentهایی بدانید که خودتان تولید نکرده‌اید. برای بقیه، ارزش دارد pipeline را به این ترتیب یاد بگیرید، چون هر لایه روی لایه پایین‌تر بنا می‌شود: با plain text extraction از PDF بارگذاری‌شده شروع کنید، وقتی باید geometry حفظ شود به typed table API بروید و اگر در سمت generate هستید و می‌توانید تصمیم بگیرید output چقدر قابل recovery باشد، رندر کردن data table در یک PDF جدید را ببینید

ExtractLoadedTypedTables و ExportLoadedTypedTables بخشی از native HotPDF Delphi PDF Component برای Delphi و C++Builder هستند؛ بدون DLL خارجی و بدون runtime dependency. صفحه محصول، مرجع کامل option، status و record مربوط به typed table API را در اختیار می‌گذارد