PDFium Component version 3.117.0 links a table that breaks across a page boundary when either both fragments touch the page edges or no body text sits below the first fragment and above the second, with running headers and footers ignored. ExtractDocumentTables applies that content-aware test as an alternative to the older page-margin test, refuses a next-page fragment whose first row is a single full-width caption cell, and keeps a single row that spills onto the following page as part of its continuation chain
The table detection and extraction article presented continuation as four strict gates and treated "touches the page edge" as one of them. That description was accurate for the release it covered, and it was also wrong for most tables people actually feed the component. This article is the correction: which documents the margin test cannot handle, what replaced it, and the two side cases the fix dragged in with it
Why does the page-margin test fail on Word exports?
The page-margin test fails because a word processor stops laying out rows at the bottom margin, not at the paper edge. With the default ContinuationMargin of 36 points, the original rule required the earlier fragment's bottom edge to lie within 36 points of the page bottom and the later fragment's top edge within 36 points of the page top. A document exported from Word with its default one-inch margins puts the last row at least 72 points above the page bottom, further if a footer is present, so the condition never held. Every long table in such a document came back as independent fragments with ContinuationGroup at zero, and the caller was back to stitching by hand. The test still makes sense for what it was designed around: reports generated by layout engines that fill a page to a fixed content box and start the next page flush at the top. It is not a bad rule, it is an incomplete one, which is why version 3.117.0 kept it and added a second path instead of replacing it
What does the content-aware test check instead?
The content-aware test checks whether anything other than the table occupies the space between the two fragments, using the word boxes of each page rather than the page geometry. While ExtractDocumentTables walks the document it records, per page, the lowest bottom edge of any word whose top lies above the footer band and the highest top edge of any word whose bottom lies below the header band. Both bands are ContinuationMargin points deep, so the same option now does double duty as page-edge slack and as the height of the running header and footer zones. A pair of fragments passes when the earlier one's bottom edge is at or below the lowest body text on its page and the later one's top edge is at or above the highest body text on the next page, each within AlignmentTolerance. In plain terms: the table was the last thing on page N and the first thing on page N+1, and a page number or a document title in the margin band does not count. That exclusion is not arbitrary. ISO 32000-1 §14.8.2.2 classifies running headers and footers as pagination artefacts, content that exists because of the page break rather than in spite of it, and the same idea that lets a tagged reader skip them is what lets a table continue past them. The marked-content article covers how tagged files declare those artefacts explicitly; here the classification is inferred from position, because most exported tables carry no tags at all
The two tests combine with OR. A layout-engine report whose tables run to the paper edge passes the first; a Word export whose tables stop at the margin passes the second; a document that does both passes twice. Only after one of them succeeds do the remaining gates run, and they run in a fixed order: the page numbers must be adjacent, the later fragment must not open with a caption row, and the column boundaries must match within twice AlignmentTolerance, which is 6 points at the defaults. The enumeration is TPdfTableContinuation with the values ptcNone, ptcStart, ptcMiddle and ptcEnd. A fragment that was marked ptcEnd and then links onward to yet another page is promoted to ptcMiddle, so a three-page table reads start, middle, end in page order. Group numbers start at 1 and 0 means unlinked, and ToJson emits the same information as the continuation and continuationGroup members, which is the form to prefer if a downstream service does the stitching
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; // default; shown for clarity
Options.ContinuationMargin := 54; // two-line footer, ~50 pt deep
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;
How does a caption row stop two tables from fusing?
A next-page fragment whose first row is one cell spanning every column is treated as a new table, never as the rest of the previous one. This rule exists because the content-aware test, on its own, links too eagerly. The case that exposed it was a transcript-style form: a table ends near the bottom of page 1, a second table with identical column widths starts near the top of page 2, nothing but the footer sits between them, and the columns match to the point. Under the margin test the two never met because neither touched an edge; under the content test they linked immediately, and a form with sections became one incoherent grid. What separates them is visible in the cell structure. The second table opens with a section caption such as "RECIPIENT INFORMATION" laid out as a single merged cell across the full width, and a genuine continuation never does that, because the caption belongs to the table that already started on the previous page. TableStartsWithCaptionRow encodes exactly that: the fragment has at least two columns and contains a cell with RowIndex = 0, ColumnIndex = 0 and ColumnSpan = ColumnCount. The check runs only on the later fragment, so a table whose own caption row sits on its first page is unaffected; the caption is on page N, and only the page N+1 fragment is inspected
The column comparison that follows, TablesHaveMatchingColumns, is stricter than "same column count". It rebuilds the boundary positions of each fragment from the cell rectangles, interpolates boundaries that merged cells hide, and rejects the pair when any boundary drifts by more than the tolerance. Two four-column tables with different proportions therefore stay apart even when everything else lines up
What happens to a single row that spills onto the next page?
A ruled grid that carries one row onto the following page is now detected and linked, provided it ends up in a continuation chain; on its own it is discarded. The default MinRows of 2 exists to keep a stray pair of lines from being reported as a table, but a last row pushed over the break is a real row that a hard floor of 2 silently dropped, and the rest of the table looked complete when it was not. The document-level scan handles it in three steps. When DetectContinuations and DetectRuledTables are both set, the per-page pass runs the ruled detector with the row floor temporarily lowered to 1, which is why ExtractTables now accepts MinRows of 1 for ruled grids while whitespace detection keeps an internal floor of 2. Continuations are marked over the full result. Then every table that is shorter than the caller's MinRows and not part of any chain is removed. The single-row fragment survives only because it was linked, and a one-row grid in the middle of an otherwise ordinary page is filtered out exactly as before
// Rebuild each chain as one CSV, dropping repeated header rows
// on the continuation fragments
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 repeated by the word processor
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;
Two details in that routine are deliberate. The one-row spill is never stripped, because the guard on RowCount keeps it, and a word processor that repeats the header row on each page produces a fragment whose first line is the header again, so dropping line zero on middle and end fragments is right for that case and wrong for a generator that does not repeat headers. Check one document before turning the routine loose on a folder
Where the rules still stop
The content-aware test is only as good as the text layer it reads. On a scanned page with no text at all, the recorded body-text extremes fall back to the page bounds, the "nothing between" condition is satisfied vacuously, and only the caption-row and column gates remain; a ruled grid on such a page is still found as an empty skeleton, so the chain may link correctly, but nothing about the surrounding text was actually verified. Add a text layer first if that matters. Footers rendered as images rather than text are invisible to the band logic and harmless for the same reason
The bands are one number. A footer deeper than ContinuationMargin leaves its lower lines inside the body zone, which makes the earlier fragment look followed by text and blocks the link; raise the option to the real band depth, as the first example does. Raise it too far and a short closing paragraph near the bottom of the page slips into the band and is ignored, which links a table to whatever follows it. The caption rule has a mirror-image failure: a generator that writes a merged "continued" banner as the first row of every continuation fragment will have those fragments rejected as new tables, and the only remedy today is to stitch by ContinuationGroup yourself after loosening nothing, because the rule has no switch
Whitespace-detected tables get none of the single-row relief. The whitespace strategy needs two aligned rows to see a table at all, so an unruled table that spills one row is still reported short by that row. When you hit that, the word boxes behind structured text blocks and reading order give you the raw positions to recover it. On the sample set that drove this work, thirteen word-processor and browser exports, the five documents with genuine multi-page tables all linked into single chains and the transcript form that previously fused stayed apart, which is the bar the release was measured against, not a promise about every layout
Continuation marking, the caption rule and the single-row pass all live in the document-level path shared by Delphi, C++Builder and Lazarus builds; the full table extraction API is described on the PDFium Component for Delphi page