Technical Article

Filled-Rectangle Table Borders as Rulings in PDFium Delphi

PDFium Component table extraction, from version 3.117.0, treats a thin filled rectangle as a table ruling. With DetectFilledRulings enabled, which is the default, a filled axis-aligned box no thicker than MaxRulingThickness (3 points) becomes one ruling along its long axis, a larger filled box contributes its four edges, and every ruling coordinate is snapped within RulingSnapTolerance (4 points) before the grid is assembled. Tables exported from Word, Google Docs and browsers therefore reach the ruled detector as complete grids instead of falling through to whitespace detection as fragments

The earlier article on table detection and extraction stated that ruled detection uses the drawn lines and that each stroked path segment is transformed into page coordinates. That sentence was true and incomplete. Counting path objects across a set of 13 real-world sample documents showed that 9 of them contain no stroked path at all, yet each of their pages carries hundreds of filled rectangles 0.5 to 1 point thick. The stroke-only detector saw nothing, every page fell to whitespace detection, and the output was a scatter of small fragments rather than tables. The compact-columns preset added in 3.116.4 softened that at the fragment level; the root cause was that the detector was reading the wrong painting operator

Why does a Word-exported table have no stroked lines?

A word processor does not think of a border as a line; it thinks of it as a box with a width, and it paints that box with a fill. ISO 32000-1 §8.5.2.1 defines the re operator as appending a rectangle subpath, and §8.5.3 separates the painting operators: S strokes the path with the current line width, f fills its interior. A 0.5-point cell border comes out as x y w 0.5 re f, and the stroke machinery, line width, joins and dash pattern included, never runs. Cell shading is the same construction with a bigger box. A stroked grid drawn with m, l and S is what the original detector expected, and it is what nearly nothing exported from an office application produces:

% one cell border from a word-processor export: a 0.5 pt tall filled box
72 700 468 0.5 re f
% cell shading: a filled box the size of the cell
72 676 117 24 re f
% the stroked grid line the original detector was written for
72 700 m 540 700 l S

To a detector that asks FPDFPath_GetDrawMode only whether the stroke flag is set, both filled boxes are invisible. The words inside the cells then reach whitespace detection, where columns separated by a 6-point gutter sit below the default MinColumnGap of 12 points, and what comes back is whatever subset of rows happens to align well enough to pass MinRows. That is the fragment behaviour, and no amount of parameter tuning turns it into the grid the author drew

How does PDFium Component turn a filled box into a ruling?

TableCollectObjectRulings inspects every path object one subpath at a time. The draw mode comes from FPDFPath_GetDrawMode; a path counts as filled when DetectFilledRulings is on and the fill mode is not none. Each point is transformed through the object matrix and collected, up to MaxSubpathPoints (8) per subpath, and any curve segment marks the subpath as curved. When the subpath closes or a new MoveTo begins, FlushSubpath decides what it was: a curved subpath is discarded, and so is any closed polygon whose points do not all sit within PointTolerance (0.05 points) of the bounding box edges on at least one axis. A triangle, a chevron or a rounded tab never becomes a ruling, which is what keeps decorative artwork out of the grid

PDFium Component diagram of how TableCollectObjectRulings turns closed subpaths into table rulings in Delphi: FlushSubpath discards curved outlines and polygons off the bounding-box edges, MaxRulingThickness splits thin boxes into one ruling per long axis, shaded cells give four edge rulings and DetectFilledRulings keeps tiny squares out
A closed subpath survives only when it is axis-aligned, and the bounding box then decides whether it is one ruling, four edges of a shaded cell, or nothing at all

What survives is an axis-aligned rectangle, classified by its bounding box. Width at or below MaxRulingThickness with height above it yields one vertical ruling at the horizontal centre, spanning the box from bottom to top; the mirror case yields one horizontal ruling. Both dimensions above the threshold means a shaded cell, and the box contributes four rulings, one per edge. Both dimensions at or below the threshold contribute nothing, so a 2-point square bullet is not mistaken for a line. A stroked path takes the older route through AddLine, one ruling per axis-aligned segment, so a grid drawn with S is handled exactly as before, and a path painted with both fill and stroke produces overlapping pieces that the merge pass collapses:

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  Mode: string;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // 1-based

    Options := TPdfTableExtractionOptions.Default;
    // these are the 3.117.0 defaults, spelled out for clarity
    Options.DetectFilledRulings := True;     // thin filled boxes become rulings
    Options.MaxRulingThickness := 3.0;       // points; thicker boxes count as shading
    Options.RulingSnapTolerance := 4.0;      // points; 0 disables snapping
    Options.IncludeFormXObjects := True;

    Tables := Pdf.ExtractTables(Options);
    for I := 0 to High(Tables) do
    begin
      if Tables[I].DetectionMode = ptdmRuled then
        Mode := 'ruled'
      else
        Mode := 'whitespace';
      Writeln(Format('%dx%d %s, confidence %.2f',
        [Tables[I].RowCount, Tables[I].ColumnCount, Mode,
         Tables[I].Confidence]));
    end;
  finally
    Pdf.Free;
  end;
end;

What does RulingSnapTolerance do for shaded-cell tables?

RulingSnapTolerance is what makes a table built from shading alone connect into one grid. Some exports draw no border at all: every cell is a filled box in its own colour, and neighbouring boxes are separated by a 1 to 3 point gutter of white. Each box yields four edge rulings, but the right edge of one cell and the left edge of the next sit 2 points apart, and the connectivity test uses RulingTolerance, which defaults to 1 point. Without snapping, every cell forms its own connected component of four rulings, no component reaches MinRows, and the page reports nothing. TableSnapRulings gathers every X coordinate in play (the position of each vertical ruling plus the start and end of each horizontal one) and every Y coordinate likewise, sorts each list, clusters it by chaining values whose neighbour differs by no more than the tolerance, replaces each cluster by its mean, and then moves every position, start and end to the nearest cluster centre. The two sides of a gutter become the same line, and connectivity holds

PDFium Component diagram of RulingSnapTolerance connecting a shaded-cell table in Delphi: neighbouring cells leave a 2 pt gutter, their edge rulings sit beyond the 1 pt RulingTolerance, and TableSnapRulings chains the two X values into one cluster mean so the connectivity test finally sees a shared grid line
Snapping runs before merging and before the ruled detector, so two sides of a white gutter become one line and every cell stops being an island of four rulings

Snapping runs before TableMergeRulings, which sorts the rulings and joins collinear pieces that touch or overlap within RulingTolerance, and both run before TableDetectRuled ever sees the data, so the pairwise connectivity check is proportional to the number of grid lines rather than the number of per-cell fragments. On a stroked grid the passes are harmless, because coordinates that were already identical snap to themselves. The one thing to keep in mind is that chain clustering has no width limit of its own: a run of coordinates each 3 points apart collapses into a single centre. At the 4-point default that only affects columns narrower than a character, but if a document has real 3-point gutters that must stay separate, lower the tolerance or set it to 0 to switch snapping off:

// Isolate the ruled strategy and compare what each setting sees on one page
function CountRuledTables(Pdf: TPdf; FilledRulings: Boolean;
  SnapTolerance: Double): Integer;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.DetectWhitespaceTables := False;
  Options.DetectFilledRulings := FilledRulings;
  Options.RulingSnapTolerance := SnapTolerance;
  Result := Length(Pdf.ExtractTables(Options));
end;

// A Word export typically reports 0, N and then fewer than N:
// stroke-only sees nothing, snapping connects the shaded cells,
// and disabling the snap leaves each shaded cell as its own island
Writeln(CountRuledTables(Pdf, False, 4.0));
Writeln(CountRuledTables(Pdf, True, 4.0));
Writeln(CountRuledTables(Pdf, True, 0.0));

Rulings inside form XObjects

Page-layout tools frequently wrap a table, or the whole page body, in a form XObject and paint it with Do. ISO 32000-1 §8.10.1 specifies that the form matrix is concatenated with the current transformation matrix when the form is painted, so a rectangle inside the form lives in form space and only lands on the page after two or more transforms. TableCollectObjectRulings recurses into form objects when IncludeFormXObjects is set: it reads the object matrix, combines it with the parent matrix through TableMultiplyMatrix, whose argument order means "map through the first matrix, then the second", and enumerates the children with FPDFFormObj_CountObjects and FPDFFormObj_GetObject, passing the combined matrix down. Nesting deeper than MaxFormDepth (8) is skipped silently, which is a guard against pathological files rather than a limit any real export approaches. The reason the multiplication order matters is the same one discussed in matrix prepend versus append: swapping the operands moves the translation term, and a ruling that should land at the top of the page lands at the origin instead

PDFium Component diagram of rulings inside a form XObject in Delphi: a thin rectangle authored as 72 700 468 0.5 re f lives in form space and only lands on the page after TableMultiplyMatrix combines the parent CTM with the form matrix, recursing through FPDFFormObj_CountObjects up to MaxFormDepth
The rectangle is authored in form space and reaches the top of the page only after the matrices are multiplied in an order that keeps the translation term where it belongs

Why did the ruling budget quadruple?

The default MaxRulingSegments rose from 4096 to 16384 in 3.117.0 because per-cell borders arrive in far greater numbers than stroked grid lines. A stroked 30-row, 6-column table is 38 line segments. The same table exported as filled boxes is up to four borders per cell, 720 pieces before merging, and a form with shaded cells doubles that. Two such tables on a page would have exhausted the old budget. The budget is enforced in TableAppendRuling through Check, which raises EPdfError with the message "Table ruling-segment budget exceeded"; there is no degraded result, no partial grid, and the whitespace pass does not run either. If you set a tighter budget of your own for untrusted input, catch the exception and decide, rather than reading an empty result as "no tables":

Options := TPdfTableExtractionOptions.Default;
Options.MaxRulingSegments := 2048;        // deliberately tight for untrusted input
try
  Tables := Pdf.ExtractTables(Options);
except
  on E: EPdfError do
  begin
    Log(E.Message);                       // 'Table ruling-segment budget exceeded'
    Options.MaxRulingSegments := 16384;   // the 3.117.0 default
    Tables := Pdf.ExtractTables(Options);
  end;
end;

Measured results and where the approach stops

On the same 13 sample documents, extraction went from 43 tables, 9 of them ruled and 34 whitespace fragments or false positives, to 41 ruled tables and no whitespace false positives. Part of that cleanup belongs to two companion changes in 3.117.0: words already claimed by a ruled grid are removed before whitespace detection runs, so a table is never reported twice, and a whitespace column boundary must now be a text-free corridor across every row it separates, which is what stopped justified paragraphs from scoring as 5x4 tables. The filled-rectangle reader is what moved the tables themselves from the fragment column to the ruled column

The boundaries are worth stating plainly. A page with no text layer still yields the grid skeleton, every cell empty, because rulings come from geometry and text comes from the text page; scanned pages need OCR first. Filled shapes with curves, rounded corners or non-rectangular outlines are dropped entirely, so a table whose borders are drawn as rounded-rectangle outlines needs whitespace detection as before. A table with neither borders nor shading is unchanged by any of this and remains the province of the whitespace strategy described in the table extraction article; when even that is not enough, the word boxes and blocks from structured text and reading order are the raw material for a domain-specific reader. The TableExtractionLab demo that ships with the component exposes DetectFilledRulings in its options panel, which is the quickest way to see what a given export looks like with and without it; the full API is described on the PDFium Component for Delphi page