Skip to content
Talk to our solutions team

Tables and repeating structures

Repeating structure — tables, grid sections, line items — is recovered from geometry alone. There is one detection algorithm: text-alignment clustering over OCR blocks. Nothing reads ruled lines, cell borders or an embedded table model, so a borderless table and a ruled table are detected identically, and a table drawn entirely with rules but misaligned text is not detected at all.

Read The document model first for pages, blocks, lines and coordinates, and Extracting fields for the anchor and schema machinery that this page builds on.

Table extraction reaches both surfaces. The kis CLI runs it over local .bbox files; rules.svc runs it over a document sent with the execute-rule request. Detection, the returned shapes and the rule-facing calls are identical on both — a rule set that extracts a table under kis ocr extracts the same table over HTTP.

CapabilityRule (.grl)kis CLIrules.svc
Detected tables, from Analyze()yeskis bbox tablesyes
Schema-selected tables (x-table)yeskis ocryes
Column-aligned grid sectionsyeskis ocryes
Label-driven repeating rowsyeskis ocryes

Table detection runs during Analyze() on every document that loads, on both surfaces, and fills Document.Tables. There is no switch that turns it off. The detector walks the document’s lines in one pass and grows a region while the next line has a similar block count (within one) or aligns on column x-positions, and the vertical gap stays under the gap ceiling. A region that ends up with fewer than the minimum rows or columns is discarded.

SettingValue on the Analyze() path
Minimum rows2
Minimum columns2
Row-gap ceiling2.0 × the document’s median line gap
Column x-alignment tolerance10.0 px

Those four values are fixed. Analyze() builds the detector with them on every run, and neither the CLI nor the service exposes a flag, a config key or a rule call that changes any of them — a region under 2 rows or 2 columns is discarded on every document, on both surfaces.

The gap ceiling is what stops a table at a page break, and only incidentally — the detector never compares page numbers. Page-local coordinates usually make the gap between the last line of one page and the first line of the next enormous, so a table that continues onto the next page is detected as two tables, each carrying the page of its own first line. When a page’s last line sits high enough that the page-local gap stays under the ceiling, the region runs straight through the break and you get one table labelled with the earlier page. There is no table stitching either way.

TypeFields
TableRows, Columns, Box, HeaderRows, Page, Confidence, TableScore
RowIndex, Cells, Box, Height, Confidence, IsTotal, IsSeparator, IsRepeatedHeader
CellRow, Col, RowSpan, ColSpan, Content, Text, Box, IsHeader, Confidence, IsMerged
ColumnIndex, Header, Box, Width, Alignment, Confidence

Column.Alignment is one of AlignUnknown, AlignLeft, AlignRight, AlignCenter.

Get the document by the name it was loaded under, then take a table by index or by an anchor phrase. The Safe* entry points wrap a missing table instead of returning nil — all except SafeTablesBetween, which is a plain nil on a miss.

CallReturnsSelection
SafeTable(index)SafeTableResult0-based index into Document.Tables
SafeTableBelow(anchor)SafeTableResultnearest table below the anchor; falls back to the first table on a later page
SafeTableAbove(anchor)SafeTableResultnearest table above the anchor; falls back to the last table on a prior page
SafeTableBetween(start, end)SafeTableResultfirst table in reading order between two anchors
SafeTableBelowFuzzy(anchor, perWord, total)SafeTableResultas above, tolerating OCR damage in the anchor
SafeTableAboveFuzzy(anchor, perWord, total)SafeTableResult
SafeTableBetweenFuzzy(start, end, perWord, total)SafeTableResult
SafeTablesBetween(start, end)[]*TableResultevery table between two anchors — unusable from a rule, see below
TableCount() / HasTables()int64 / boolcount and presence

Anchor matching for all of these is case-insensitive and takes the first match of the phrase. SafeTableBetween and SafeTablesBetween return nothing when the two anchors resolve out of reading order.

Once you hold a SafeTableResult, these methods are nil-safe and callable from a rule:

MethodReturns
RowsAsMaps()[]map[string]string — one map per data row, keyed by column header
AsMap()map[string][]string — one entry per column
RowCount() / DataRowCount() / ColumnCount()int
HasData() / IsEmpty()bool

Headerless columns get the synthetic keys col_0, col_1, … in RowsAsMaps and AsMap. Empty cells are present as empty strings.

rule ExtractLineItems salience 100 {
when
bbox.Has("doc")
then
out.Set("line_items",
bbox.Get("doc").SafeTableBelow("Invoice Items").RowsAsMaps());
Retract("ExtractLineItems");
}

Guard on bbox.Has(name), not on the document itself. bbox.Get on a name that was never loaded returns nothing, and the next call in the chain dereferences it — that panic aborts the whole execution and discards all output.

A .schema file whose top-level type is array and which carries x-table is a table target. The filename without its extension is the name rules invoke. Register schemas with --schema or --schemas on kis ocr, or send them under bbox.__schemas on an execute-rule request to rules.svc, then call bbox.Get("doc").ExtractTable(name) — or SafeExtractTable(name), which returns an empty slice rather than nil.

KeyTypeMeaning
x-table.containingstringfirst table with a cell containing this text, case-insensitive
x-table.anchor_belowstringfirst table below this phrase
x-table.anchor_abovestringfirst table above this phrase
x-table.between[start, end]first table between two phrases; both must be non-empty
x-table.indexint64, 1-basedtable by ordinal
x-table.fuzzy.max_distanceint64per-word edit distance for anchor_below / anchor_above / between
x-table.fuzzy.max_totalint64total edit distance across the anchor phrase

Precedence is fixed and does not depend on file order: containing, then anchor_below, then anchor_above, then between, then index. Setting two locators means the lower one is ignored.

{
"type": "array",
"title": "Invoice line items",
"x-table": {
"anchor_below": "Invoice Items",
"fuzzy": { "max_distance": 1, "max_total": 2 }
},
"items": {
"type": "object",
"properties": {
"description": { "type": "string" },
"amount": { "type": "string", "x-anchor": "Amount Due" }
}
}
}

Rows come back as []map[string]any keyed by the items property names. A property maps to the detected column whose header equals the property name; declare x-anchor on the property when the printed header differs from the key you want. The header comparison is exact — no fuzz, no case folding. Properties that find no column, and properties whose cell is empty, are absent from the row map; a row with no matched properties is dropped. x-derive runs per row after the columns are mapped.

ExtractColumnAlignedSection is the extractor for columnar forms where the numbers line up under printed headings but the detector’s row clustering is too coarse — closing statements, fee schedules, premium breakdowns. It resolves column x-positions from header labels, takes the vertical band between two anchors, and assigns every block in the band to a column by nearest centre X.

Two call shapes reach the same code:

// Nil-safe wrapper — returns nil if no document is loaded under that name.
out.Set("charges", bbox.ExtractColumnAlignedSection(
"page",
"Origination Charges",
"Services Borrower Did Not Shop For",
"At Closing,Before Closing,Paid By Others"));
// Chained document method — equivalent, but bbox.Get must have matched.
out.Set("charges", bbox.Get("page").ExtractColumnAlignedSection(
"Origination Charges",
"Services Borrower Did Not Shop For",
"At Closing,Before Closing,Paid By Others"));

Pass "" as the section end to run to the bottom of the page.

columnHeaders is one comma-separated string. Each entry is either a column spec or an @ directive.

Column specEffect
Nametext mode: find the header by fuzzy phrase search, use its centre X
N:Nameposition mode: explicit centre X in pixels, box is N ± 25px
Name=>Aliassearch OCR for Name, emit the value under Alias
Name#Npick the Nth occurrence when the header text repeats (1-based)
Parent<dir>|Childchain: navigate from Parent in dir to find Child; dir is below, above, right or left

Chains take any number of hops (A<below>|B<right>|C), and a => rename attaches to the last segment. Chain and plain text specs mix freely.

DirectiveEffectDefault
@description=>Keyrename the built-in description output keydescription
@row_number=>Keyrename the built-in row-number output keyrow_number
@desc_split=>DELIM=>Fieldsplit the description on DELIM; the right-hand part becomes Fieldnone
@rogue=>charsstrip these characters from the description and every column valuenone

@desc_split is repeatable and directives apply in the order given. @rogue is applied first, before row-number extraction and description splitting.

Each returned row is a map[string]any with description, row_number, one key per column alias and one key per @desc_split field. Missing values are present as empty strings.

out.Set("charges", bbox.ExtractColumnAlignedSection("page",
"Origination Charges", "Services Borrower Did Not Shop For",
"@description=>FeeName,@desc_split=>to=>Vendor,Borrower-Paid<below>|At Closing=>BPAC,Seller-Paid<below>|At Closing=>SPAC,Paid By Others"));

Every failure returns nil. Call Toggledebug() on the document to see which stage failed — it prints the resolved column positions, the description boundary and the Y band to stderr.

SymptomCause
nil resultcolumnHeaders empty, no non-@ spec left, no column position resolved, the start anchor was not found, or no blocks fell in the band
Rows from the wrong part of the pagethe section end anchor was empty or unresolvable, so the band ran to the page bottom — there is no signal distinguishing the two
A column silently disappearedposition mode is decided by the first column spec alone (@ directives are stripped first); if it looks like N:Name, every spec is parsed that way and plain-text and chain specs are dropped
A stray mark appended to the last columnany block at or right of the description boundary goes to the nearest column, with no maximum distance and no unassigned bucket; values accumulate space-joined
row_number empty on numbered rowsthe split regex requires exactly two digits followed by whitespace — 01 FEE splits, 1 FEE and 100 FEE do not
@desc_split did nothingthe delimiter is matched with a space on each side; FEE to VENDOR splits, FEEtoVENDOR does not
@rogue stripped the wrong thingsthe value is used verbatim as a character set — write the actual characters, not escape sequences
#N picked an unexpected headeroccurrences are ordered top-to-bottom first, then left-to-right, so headers on different lines are not ordered left-to-right

bbox.PostprocessRows(rows, key, fn) applies one transform to the string at key in every row. It mutates in place and returns the same slice, so call it repeatedly to chain. Rows where the key is absent or not a string are skipped, and an fn the registry does not know returns the rows untouched.

fnEffect
trimstrip leading and trailing whitespace
upper / lowercase conversion
normalize_spacescollapse runs of whitespace
trim_currencystrip $ and ,
ExtractRegex("pattern", "defaultVal")joined non-empty capture groups, or the whole match when the pattern has no groups, or defaultVal on no match

ExtractRegex is one string containing quotes, so a rule has to escape them: "ExtractRegex(\"([0-9.]+)\", \"\")". An unparseable pattern returns the rows untouched.

Chain through memory, one in.MemSet per transform:

in.MemSet("charges", bbox.ExtractColumnAlignedSection("page", "Origination Charges", "", "At Closing"));
in.MemSet("charges",
bbox.PostprocessRows(in.MemGetRows("charges"), "At Closing", "trim_currency"));
out.Set("charges", in.MemGetRows("charges"));

RepeatingExtractingFields reads label-driven repeating records off the document’s lines. A record starts on every line containing the first label; each label’s value runs to the next label or to an end marker.

out.Set("borrowers", bbox.Get("doc").RepeatingExtractingFields(
"Borrower Name,Account,Balance", // labels, comma-separated
3, // max lines per record
4, // max words per value
"Total,Subtotal", // end markers, comma-separated
"Account:1", // per-label word caps, label:n
"Balance")); // labels whose value sits below, comma-separated
ParameterMeaning
labelscomma-separated, ordered; the first is the record-start marker
max lineslines a record may span; 0 makes every record a single line
max wordsdefault word cap per value; 0 means no cap
end markerscomma-separated phrases that terminate a value
per-label word capslabel:n pairs, comma-separated
vertical labelscomma-separated labels whose value is the nearest block below, not to the right

Records come back as []map[string]any keyed by label. A record is kept only when at least half its labels produced a value; that ratio is fixed and not exposed. Label matching is a case-sensitive substring test against the joined line text, so Account also matches Account Number.

Coordinates are page-local, so every Y band exists on every page. Each extractor resolves that differently, and nothing stitches a table, a grid section or a repeating row across the break.

ExtractorPage behaviour
Table detectionnever checks the page; the large page-local gap at a break usually ends the region, so a continuing table becomes two tables — but a small gap merges them into one
ExtractColumnAlignedSectionsingle page by design: blocks are filtered to the start anchor’s page. Rows continuing onto the next page are not returned
Checkbox detectionpage-scoped: identical page-local boxes on different pages stay distinct; overlapping boxes on one page collapse to the higher-confidence one
RepeatingExtractingFields vertical labelsno page check — can pull a value from another page

To work across pages at all, load the pages as one document. kis ocr doc merges every input .bbox into a single multi-page document and renumbers pages sequentially from 1; kis ocr page runs each page as its own document. Over HTTP the choice is yours at the call site: one pages array carrying every page is one document, and one entry per page is one document each.

Terminal window
kis bbox tables invoice.bbox -f json
kis bbox table -a "Invoice Items" -H "Item" -H "Price" invoice.bbox
kis ocr doc -r ./rules ./pages --schemas ./schemas --bbox-name doc
CommandPurpose
kis bbox tablesdetect and print every table; -i/--input repeatable, -f/--format text|json|csv, -o/--output
kis bbox tableprint the table nearest an anchor; -a/--anchor required, -H/--header repeatable
kis ocr page / kis ocr docrun rules over pages or a merged document; -r/--rules required, --schema, --schemas, --bbox-name

kis bbox table -H fails the command when an expected header is missing, which makes it a usable regression check on a fixture set. Header comparison is case-insensitive and ignores surrounding whitespace. It reports table has no headers when the detector found no header row, and missing headers: <list> (found: <list>) otherwise. When the anchor matches nothing it prints No table found near anchor: <anchor> and still exits 0.

rules.svc runs the same extraction on the same route. The execute-rule request carries the document alongside the facts under the reserved bbox key: __docs maps each name a rule passes to bbox.Get to that document’s word-box JSON, and __schemas carries x-table schemas by the name a rule passes to ExtractTable. Everything else about the call is unchanged — name selects the rule set, every other top-level key is a fact, and the response is what the rules wrote to out.

{
"name": "invoice-tables",
"bbox": {
"__docs": {
"doc": {
"pages": [
{
"number": 1,
"width": 612,
"height": 792,
"words": [
{ "text": "Invoice", "box": {"x0": 72, "y0": 100, "x1": 130, "y1": 115}, "confidence": 0.98, "page": 1 }
]
}
]
}
},
"__schemas": {
"line_items": {
"type": "array",
"x-table": { "anchor_below": "Invoice Items" },
"items": { "type": "object", "properties": { "description": { "type": "string" } } }
}
}
},
"currency": "EUR"
}

Drop __schemas and send the document map directly under bbox when no schema is in play.

The document names are the ones the rule already uses, so the rule text is the same one you run under kis ocr doc --bbox-name doc:

rule ExtractLineItems salience 100 {
when
bbox.Has("doc")
then
out.Set("line_items",
bbox.Get("doc").SafeTableBelow("Invoice Items").RowsAsMaps());
Retract("ExtractLineItems");
}

RowsAsMaps() lands in the response under line_items. The word-box JSON is the format described in OCR ingestion — run the recogniser and any .bbox conversion before the call; the service ingests OCR output, it does not produce it. Headers, tenancy and the error envelope are on the service page.

Every confidence on this page is a hand-tuned heuristic, not a calibrated probability. Table.Confidence is the mean of two factors — how little the per-row cell count varies, and a step function of the table’s size (0.5 under 3 rows, 0.8 from 3×2, 1.0 from 5×3). Column alignment is detected but does not feed it. There are no cell, row or column confidences, and grid rows carry none at all. Only ExtractFormScored reports per-field confidence and coverage, and it does not apply to tables, grid sections or repeating rows. Route review on your own thresholds, and treat a score as an ordering, not a probability.