Technical Article

Filled-Rectangle Table Borders as Rulings in PDFium Delphi

A table exported from Word or Google Docs can carry hundreds of border pieces on one page and not a single stroked line. Decompress the page with qpdf --qdf and the content stream reads like a column of re f pairs, each describing a box half a point tall. A table detector that only looks for stroked segments finds nothing, falls back to lining up words by the white space between them, and hands back a scatter of two- and three-row fragments where the author drew one clean grid. The borders are all there. They are painted with a fill, so the detector has to read filled rectangles as rulings and then pull nearly-touching edges onto shared grid lines

Why a Word table has no stroked lines

A word processor treats a cell border as a box with a width, and it paints that box with the fill operator. ISO 32000-1 §8.5.2.1 defines re as appending a closed rectangle subpath, and §8.5.3 splits the painting operators: S strokes the path using the current line width, f fills its interior. A 0.5-point border comes out as x y w 0.5 re f, and the stroke machinery (line width, joins, dash pattern) never runs. Cell shading is the same construction with a bigger box. The stroked form that a line-oriented detector expects, m, l, S, is the one office exports rarely use:

% 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 a stroke-only detector looks for
72 700 m 540 700 l S

If you were writing your own detector on the raw PDFium API, the first draft usually calls FPDFPath_GetDrawMode, keeps the path when the stroke flag is set, and moves on. Both filled boxes above fail that test. The words inside the cells reach whitespace detection, and on a compact export the columns sit maybe 6 points apart. In PDFium Component the default MinColumnGap is 12 points, so those columns do not register. Lowering MinColumnGap until they do lets the stretched word spacing of justified paragraphs register too, and the grid that the author drew never comes back, because the information that defines it lives in the filled boxes

How PDFium Component reads a filled box as a ruling

PDFium Component reads table geometry from every path object on the page, one subpath at a time, and since TPdfTableExtractionOptions.DetectFilledRulings defaults to True, a path whose fill mode is anything other than none is examined for rulings alongside stroked paths. The filter that follows is strict on purpose. A filled subpath qualifies only when it has between four and eight points, contains no curve segment, and every point sits on a corner of the subpath bounding box to within 0.05 point. A rectangle drawn with re passes, and so does one drawn as five explicit points with a repeated corner. A triangle, a chevron, an L-shaped outline or a rounded tab fails the test and contributes nothing

Diagram of how PDFium Component in Delphi classifies a closed filled subpath: curved outlines and polygons with points off the bounding-box corners are dropped, a box no thicker than MaxRulingThickness gives one ruling along its long axis, a shaded cell gives four edge rulings and a tiny square gives none
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

The surviving rectangle is classified by its bounding box against MaxRulingThickness, which defaults to 3 points. Width at or below the threshold with height above it gives one vertical ruling at the horizontal centre, running the full height of the box; the mirror case gives one horizontal ruling. When both dimensions exceed the threshold the box is treated as a shaded cell and contributes four rulings, one per edge. When both dimensions are at or below it, the box contributes nothing, so a 2-point square bullet in a list never turns into a stub of table grid. Fill color plays no part in any of this. A white box on a white page produces the same rulings as a black one, which is why a background panel the size of the table can show up as an outer frame

Stroked paths keep their own route. Every straight segment whose endpoints differ by no more than RulingTolerance (1 point by default) on one axis becomes a ruling along the other, curve segments are skipped, and the closing segment of a closed subpath counts too. A path painted with both fill and stroke contributes through both routes, and the overlapping pieces are collapsed later by the merge step. A stroked rounded rectangle still gives rulings for its straight sides; only the arcs at the corners drop out. The extraction call itself needs no special setup:

uses
  System.SysUtils, PDFium;

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

    Options := TPdfTableExtractionOptions.Default;
    // the filled-ruling fields, spelled out at their default values
    Options.DetectFilledRulings := True;
    Options.MaxRulingThickness := 3.0;       // points; must be greater than 0
    Options.RulingSnapTolerance := 4.0;      // points; 0 switches snapping off
    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';
      WithText := 0;
      for J := 0 to High(Tables[I].Cells) do
        if Tables[I].Cells[J].Text <> '' then
          Inc(WithText);
      Writeln(Format('%dx%d %s, confidence %.2f, %d of %d cells hold text',
        [Tables[I].RowCount, Tables[I].ColumnCount, Mode,
         Tables[I].Confidence, WithText, Length(Tables[I].Cells)]));
    end;
  finally
    Pdf.Free;
  end;
end;

Setting MaxRulingThickness to 0 does not switch the feature off. ExtractTables validates the geometry options up front and raises EPdfError with "Table extraction geometry options are invalid" for a threshold of zero or below. To get the stroke-only behaviour, set DetectFilledRulings to False. Leave it on for anything that came out of an office suite or a browser print dialog; the strict rectangle filter is what keeps the cost low, since decorative fills almost never pass it

What does RulingSnapTolerance do for shaded-cell tables?

RulingSnapTolerance is what lets a table built from shading alone connect into one grid. Some exports draw no border at all: each cell is a filled box separated from its neighbours by a white gutter of 1 to 3 points. Each box yields its four edge rulings, but the right edge of one cell and the left edge of the next sit, say, 2 points apart, and the test that decides whether two rulings belong to the same grid allows only RulingTolerance, 1 point. Without snapping, every cell is an island of four rulings describing a 1x1 grid, nothing reaches MinColumns or MinRows (both 2 by default), and the ruled pass reports no table on a page that plainly has one

Diagram of RulingSnapTolerance in PDFium Component for Delphi: neighboring shaded cells leave a 2 pt gutter, their edge rulings at X 230.0 and 232.0 sit beyond the 1 pt RulingTolerance, and snapping clusters the two values to 231.0 so both cells share one 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

The snap step collects every X coordinate in play, meaning the position of each vertical ruling plus the start and end of each horizontal one, and every Y coordinate the same way. Each list is sorted and split into clusters wherever two neighbours differ by more than the tolerance; each cluster is replaced by its mean, and every position, start and end then moves to the nearest centre. Edges at 230.0 and 232.0 both become 231.0, the two cells share a line, and the horizontal rulings stretch with them so the corners meet

Collinear pieces that touch or overlap within RulingTolerance are merged into single lines. Only then does the grid detector run, comparing rulings pairwise; a 30-row table that arrived as 720 border pieces reaches that comparison as a few dozen lines

On a stroked grid the step changes nothing, because coordinates that already match snap to themselves. The catch is that chaining has no width limit of its own. A run of values each 3 points apart, at 100, 103, 106 and 109, collapses into one centre at 104.5 even though the outer values are 9 points apart. With the 4-point default this only bites on columns narrower than a character, so office output can keep the 4-point default. Lower it toward 1 or 2 when a form has deliberately narrow columns (tick boxes, digit-per-cell account number fields) and set it to 0 to switch snapping off. Running the ruled strategy alone under three settings shows which mechanism a page depends on:

// Ruled strategy only, so whitespace detection cannot mask the result
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;

// Expected for a table drawn only as shaded cells with 2 pt gutters:
//   stroke only            -> 0  (no stroked geometry at all)
//   filled boxes, snap 4   -> 1  (gutter edges share one line)
//   filled boxes, snap 0   -> 0  (every cell stays a 1x1 island)
Writeln('stroke only:          ', CountRuledTables(Pdf, False, 4.0));
Writeln('filled boxes, snap 4: ', CountRuledTables(Pdf, True, 4.0));
Writeln('filled boxes, snap 0: ', CountRuledTables(Pdf, True, 0.0));

When the third count drops below the second, the page relies on snapping, and that is the page to check by eye before lowering the tolerance

Rulings inside form XObjects

Page-layout tools often wrap a table, or the whole page body, in a form XObject and paint it with Do. ISO 32000-1 §8.10.1 says the form matrix is concatenated with the current transformation matrix when the form is painted, so a rectangle inside the form is written in form space and reaches page space only after two or more transforms. With IncludeFormXObjects set, which is the default, PDFium Component walks into each form object, combines the object matrix with the matrix of its parent, and hands the combined matrix down to every child, so a border drawn as 72 700 468 0.5 re f inside a translated form lands where the viewer shows it. The walk stops at eight levels of nested forms

Diagram of rulings inside a form XObject in PDFium Component for Delphi: a thin rectangle authored as 72 700 468 0.5 re f lives in form space and lands at the top of the page only after the form matrix and the parent transformation are combined in the right order
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

The order of that combination matters. A point inside the form goes through the object matrix first and the parent matrix second; swap the operands and the translation term is applied in the wrong space, so a ruling that belongs at the top of the page ends up near the origin or off the page. The same trap comes up whenever you build matrices yourself, and the article on matrix prepend versus append walks through it with a rotation pivot

Why is the default ruling budget 16384?

The default MaxRulingSegments of 16384 reflects how many pieces a per-cell border export produces. A stroked 30-row, 6-column table needs 38 line segments: 31 horizontal and 7 vertical. The same table exported as filled boxes can carry four border pieces per cell, 720 before merging, and shading every cell adds another four edge rulings per cell, 1440 in all. Two such tables on one page already need close to 3000

Exceeding the budget is an error, and it surfaces as one. ExtractTables raises EPdfError with the message "Table ruling-segment budget exceeded", returns no partial grid, and the whitespace pass for that page does not run either. A tight budget is reasonable for untrusted input, where a crafted page could hold millions of tiny boxes, but then the caller has to tell "too complex for this budget" apart from "no tables on this page":

function TryExtractTables(Pdf: TPdf; Budget: Integer;
  out Tables: TPdfTables): Boolean;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.MaxRulingSegments := Budget;     // 16384 by default
  try
    Tables := Pdf.ExtractTables(Options);
    Result := True;
  except
    on E: EPdfError do
    begin
      // LogWarning is your own logging routine, not part of the library
      LogWarning(Format('Page %d: %s', [Pdf.PageNumber, E.Message]));
      Tables := nil;
      Result := False;                     // over budget or invalid options
    end;
  end;
end;

// A False result means "not decided", which is different from an empty array
if not TryExtractTables(Pdf, 4096, Tables) then
  QueueForReview(Pdf.FileName, Pdf.PageNumber);  // also your own routine

The same exception type also covers the word budget and invalid options, so the logged message is what tells them apart in a review queue

Where the ruled path hands over

Ruled detection runs first, whitespace detection second, and two rules keep them from stepping on each other. Words whose centres fall inside a grid that the ruled pass already reported are removed before whitespace detection starts, so a table is never reported twice. A whitespace column boundary also has to be a text-free vertical corridor across every row it separates, which keeps justified paragraphs from scoring as tables; the article on whitespace table false positives covers that rule. Reading filled rectangles moves office-exported tables out of the whitespace path and into the ruled one

Because rulings come from path objects and text comes from the text page, a page with no text layer still produces the grid skeleton with every cell empty. The confidence of a ruled grid is 0.88 plus 0.12 times the fraction of cells that hold text, so an empty skeleton reports exactly 0.88 and the cell count in the first listing shows 0 cells with text. That combination is the signal to run OCR first, as described in adding a searchable text layer with OCR. Filled shapes with curves or non-rectangular outlines contribute nothing, so a table whose cells are filled rounded rectangles falls to whitespace detection. A table with neither borders nor shading belongs to the whitespace strategy described in the table detection and extraction article, and when even that is not enough, the word boxes and blocks from structured text and reading order are the raw material for a reader written for one document family

The TableExtractionLab demo that ships with the component has a check box for DetectFilledRulings, so you can load an export and see the grid appear and disappear without writing any code. Options, record types and the rest of the extraction API are listed on the PDFium Component for Delphi product page