技術記事

Delphiでpage breakをまたぐtyped table

HotPDFは、既存PDFからExtractLoadedTypedTablesを通じてtableを復元します。これはDelphi APIで、layout passが生成したrow fragmentをmergeし、tableごとにcanonical column gridを構築し、geometryが対応すればpage breakを越えてtableをcontinuationし、すべてのcellをtyped valueとして返します。各valueにはpage provenance、column span、boundsが付きます。ExportLoadedTypedTablesを使えば同じ結果をCSVまたはJSONへ直接書けます。この機能を作る価値がある状況は、地味ですが非常によくあります。40-pageのinvoice registerが論理的には1つのtableで、各pageの先頭にheaderがrepeatされています。naiveなreading-order passを実行すると、40個のtable、39個のspurious header row、そしてmiddle cellが空だったrowごとに1つ左へ滑るcurrency columnが得られます。これをcalling applicationの下流でclean upするところで、document-import projectは行き詰まります

PDF pageがtableではなくfragmentを渡す理由

tagged documentでない限り、PDF pageはtable semanticsをまったく持たないからです。content streamが持つのはtext-showing operatorとpositioning matrix(ISO 32000-1 §9.4.3)だけです。画面で見えるruled boxは、textと相関付ける義務をextractorに課さない無関係なpath paintingです。structure element typeのTableTRTHTDはtagged PDFのlogical structure hierarchy(ISO 32000-1 §14.8.4)にしかありません。流通しているbusiness documentの圧倒的多数にはtagがありません。以下で説明するのはすべてgeometric recoveryであり、parsingではありません。これを明言しておかないと、その上にreconciliation reportを構築する人が出てきます

そのためHotPDFは、最初にextracted glyphへsemantic layout analysisを行います。これはloaded PDFからのstructure-order text extractionやstructured HTML、XML exportを支えるものと同じpassです。このpassはbaselineを、cellがverticalにalignするrunへgroup化し、連続するrowのcell countが同じ間だけrunを続けます。layout engineにとっては正しく安価なruleです。しかしcallerにとっては形が違います。interior cellが1つ空のrowだけで、1つのvisual tableが2つのsource tableに分割されます。typed table layerはまさに、分かれたpieceを元へ戻すためにこのpassの上に置かれています

canonical column gridとColumnTolerance knob

ExtractLoadedTypedTablesは他のことをする前にsame-page fragmentをmergeし、row textではなくcolumn geometryでmergeします。2つのadjacent source tableは、両方に少なくとも2 columnがあり、最初のtableのlast rowと次のtableのfirst rowとのvertical gapがtolerance band内にあり、column start positionが揃う場合にjoinします。ColumnTolerance以内のcolumn startは1つのcanonical columnへcollapseし、merge時にaverageされます。default toleranceは12 user-space unitで、通常のbusiness typographyに合います。wide-trackedまたはdeeply indented layoutでは引き上げる必要があります

interior valueが欠けたrowをどう扱うかが重要です。HotPDFは各cellをnearest canonical column startへsnapし、残りのcellを左へshiftするのではなく、そのcolumnから次のoccupied columnまでの距離をColumnSpanに設定します。5-column gridの3-cell rowは、valueを正しいheadingの下に保ち、gapがどこにあるかを正確に記録します。tableをreconcileできるものと、moneyの帰属を静かに誤るものとの差はそこにあります

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 unit
    Options.MinimumTableConfidence := 0.55;  // これ未満のtableはdropする
    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でmerge量が分かる
      ProcessTables(Tables)
    else if Info.Status = ttesBudgetExceeded then
      Log(string(Info.Diagnostic));
  finally
    Pdf.Free;
  end;
end;

cross-page mergingが実際に保証するもの

意図的にconservativeであることを保証します。HotPDFが2つのtableをpage boundary越しにjoinするのは、MergeAcrossPagesがenableで、2つ目のtableが最初のtableの終端のpage indexのちょうど次に始まり、両方に少なくとも2 columnがあり、canonical column startの少なくとも2つがColumnTolerance内でalignする場合だけです。連続するpage条件がload-bearingです。callerはPageIndicesをopen arrayとして好きな順序で渡せます。これをcheckしないと、page 3、9、14のrequestが、無関係な3つのtableをもっともらしい1つのresultへ溶接してしまいます。その代償として、pageをskipする本当のcontinuation、interleaved appendix、blank versoを持つduplex scanは2つのtableとして返り、緩めるoptionはありません。それらを再joinするかはcalling applicationだけが決められるpolicyなので、APIはFirstPageIndexLastPageIndexSourceTableCount、rowごとのPageIndexを公開し、判断をあるべき場所に残します

repeated headerは削除せずlabelを付ける

ExtractLoadedTypedTablesはresultからrepeated header rowを削除しません。cross-page mergeでincoming tableの冒頭に、trimとcase foldingの後でaccumulated tableと同じheader textを見つけると、そのrowをIsHeaderIsRepeatedHeaderでmarkし、source orderのままappendします。削除はlossyでirreversibleな選択です。consumerによって欲しい答えが違います。CSV importはrepeatを消したがり、audit trailはpage number付きで残したがり、diffing toolはsource orderを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 valueとcallerが指定すべきseparator

type inferenceは固定順で走り、ambiguityを唯一まともな方向へ解決します。boolean、date、percentage、currency、plain numberの順で、どれにも合わなければstringのままです。順序があるため、date columnの2026がdate parserより先にnumber parserで決まりません。currencyは先頭の$£¥、またはspaceで続く3文字のISO 4217 codeから認識し、codeはCurrencyCodeに保持します。重要なのは、HotPDFがlocaleを推測しないことです。DecimalSeparatorThousandsSeparatorDateOrderはoptionから来ます。1.234が1つのnumberなのか、1,234なのかはPDFに含まれない事実に依存するためです。raw Unicode Textはtyped valueと並べてすべてのcellに残るので、誤った推測もsecond extraction passなしでrecoverできます

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;

2つのexport formatは異なる質問に答えるもので、意図的にequivalentではありません。CSVはmerged spanのcontinuation columnをempty fieldとして書き、spreadsheetやbulk loaderの期待に合わせます。JSONはextractionが知っていたものをすべて保持します。own kindのtyped value、columnSpan、cellごととrowごとのconfidence、cell bounds、pageとsource-table provenanceです。両formatはdocument全体をbounded in-memory bufferへstageしてからdestination streamへpublishします。途中でwriteが失敗すればoriginal byte、length、positionをrestoreするため、失敗したexportがhalf-written fileを残すことはありません。page、glyph per page、table、row、cell、character、output byteのbudgetはすべて個別にaccountされ、million-row default ceilingよりかなり前にper-row SetLengthがquadratic copyingへ退化するため、rowはallocation前にcountされます

geometric table recoveryが諦める場所

failure modeを明示するほうがfeature listより役立ちます。これらはすべて、callerがbetter optionではなく自身のpolicyを必要とする場所だからです

  • vertical mergeはrecoverしません。HotPDFはhorizontal spanのColumnSpanを報告し、RowSpanは1のままにします。印刷されたtableで3 rowにまたがるcellは、1つのcellと2つのgapとして届きます
  • header detectionはvisualではなくdata-drivenです。header blockは、non-string typed valueを含む最初のrowの前にあるrow runなので、bodyがすべてtextのtableはどんなstyleでもHeaderRowCountが0になります
  • MinimumTableConfidence未満のtableはerrorなしでresultからdropされます。何かがdiscardされたか知りたい場合はInfo.TableCountInfo.SourceTableCountを比較してください
  • runがtableと呼ばれるには少なくとも2 rowと2 columnが必要です。そのため1行のpseudo-tableや、長いproseを2 columnに配置したlayoutは、正しくも不便にもtableになりません
  • scan pageにはtext operatorがないため、page上にOCR text layerができるまでgeometrically recoverするものがありません

PDFが自分のreporting stackから出てくるなら、すべての問題への最も安いfixはupstreamです。tagged tableをemitするか、source dataを保持し、extractionは自分で生成しなかったdocument用のfallbackとして扱います。それ以外では、pipelineを下のlayerから順に学ぶ価値があります。まずloaded PDFからのplain text extraction、geometryを保持する必要が出たらtyped table API、生成側でoutputのrecoverabilityを決められるならdata tableをnew PDFへrenderする処理へ進みます

ExtractLoadedTypedTablesExportLoadedTypedTablesは、DelphiとC++Builder向けnative HotPDF Delphi PDF Componentの一部として提供されます。外部DLLもruntime dependencyもありません。product pageにtyped table APIのoption、status、record referenceが掲載されています