Skip to content
Talk to our solutions team

The Document API

Rules read documents through bbox.Doc(), a curated facade designed for rule authors. It is the API to write against.

Document rules run through kis rules, with the document beside each input (--with-bbox) or named on the command line (-b page=invoice.bbox). rules.svc serves no documents today: a scope whose rules name bbox does not load there.

A rule reads a document with an entry, a scope, a verb, and a coercion. That is the whole shape:

bbox.Doc() .Top(10) .Has("Closing Disclosure")
// entry scope verb
out.Set("amount", bbox.Doc().Right("Loan Amount").Currency());
// entry verb coercion

Scopes narrow, verbs ask or fetch, coercions turn a hit into a typed value. Most rules need one of each.

Matching is fuzzy and case-insensitive by default

Section titled “Matching is fuzzy and case-insensitive by default”

You do not configure matching. It is loose out of the box.

It is easy to under-read this as a convenience. It is not. It removes a category of work.

bbox.Doc().Has("closing disclosure") // matches "CLOSING DISCLOSURE"
bbox.Doc().Has("SELLER'S TRANSACTION") // also matches "SELLERS TRANSACTION"

Case, spacing and the apostrophe/punctuation variants that OCR produces are absorbed. You write the phrase as a human would say it, once.

The bbox.Get methods made this the author’s problem. Every call carried tolerance numbers:

.FindIPhraseFuzzy("Loan Terms", 2, 4, 0, 0)
.FuzzyTextNear("Name", "right", 1, 2, 0.3)

Nobody can tell you what 2, 4, 0, 0 should be for a phrase they have not seen, and a matcher that does not absorb the apostrophe forces a rule to list both SELLER'S TRANSACTION and SELLERS TRANSACTION.

The facade derives the tolerance from the phrase instead. A surface you choose from can be used correctly by a person and by a small language model; a surface you tune cannot. That was a design constraint, not a nicety.

When you genuinely need strictness, a document code, an identifier, a legal string where a near-miss is wrong:

ModifierEffect
Exact()Turns off loose matching for that chain. Case is still ignored
MatchCase()Case-sensitive, and implies Exact()
bbox.Doc().Exact().Has("FORM-1099-B")
bbox.Doc().MatchCase().Has("SECTION A")

MatchCase() implies Exact() deliberately: case-sensitive-but-still-fuzzy is not a combination that means anything useful, so the facade does not offer it.

Reach for these when a false positive costs you something. Leave them off otherwise, the default is right far more often than not, and every Exact() is a phrase you have promised to keep spelled exactly that way.

bbox.Doc() // the document
bbox.Doc("page") // a specific loaded document, by name

The name is optional because it is almost always ceremony. With no argument, bbox.Doc() resolves the only loaded document, or the one named page when several are loaded. Supply the name only when a rule genuinely works across more than one loaded document.

Each returns a narrowed Doc, so they chain.

ScopeNarrows to
Top(pct) / Bottom(pct)A band measured from the top or bottom, as a percentage
Band(fromPct, toPct)An arbitrary horizontal band
Region(x0, y0, x1, y1)An explicit rectangle, in percent
Page(n)One page
Section(heading)From a heading to the end of its section
Between(start, end)Between two phrases
Until(phrase)From here until a phrase
Line() / Lines(n)The current line, or the next n
Exact() / MatchCase()Opt out of loose matching: see Matching

Top(10) reads better than a rectangle and is what most rules meant. There is no fixed “header”: choose the depth that fits the form.

VerbReturns
Has(phrase)bool
HasAll(phrases...)bool. Every phrase present
HasAny(phrases...)bool: at least one
HasPattern(pattern)bool
Count(phrase) / CountOf(pattern)int
Checked(label)bool: checkbox state

The four directional verbs read a value positioned relative to a label, and each returns a Value:

bbox.Doc().Right("Loan Amount")
bbox.Doc().Below("Total")
bbox.Doc().Left("Balance")
bbox.Doc().Above("Signature")

Extra arguments form a chain: each anchor is located below the one before it, and the read happens in the verb’s direction from the last.

bbox.Doc().Right("Date Issued", "Closing Date")
bbox.Doc().Below("Current name", "First name")

The chain replaces the string anchor paths of the bbox.Get methods, "Date Issued<below>|Closing Date" and "A |> B". Two things improve by making them arguments rather than syntax. Each label is separately checkable, so a failing chain reports which link broke instead of returning an empty string. And there is no string grammar to get subtly wrong.

A Value becomes typed by asking:

CoercionProduces
Str()The text, as a string
Num()A number. Separators and a leading $ are stripped; 0 when the text is not a number
Text()The text, as a Value
Number()The first number in the text, as a Value
Currency()The amount with symbols and separators removed, as a Value; accounting negatives such as (1,234.56) keep their sign
Date()The date as YYYY-MM-DD; a miss when it does not parse
Percent()The percentage as a bare number
YesNo()Yes or No from affirmative or negative text, including Y and N
TitleCase()Title-cased text
Match(pattern)The first substring matching the pattern

out.Set takes a Value directly, out.Set("amount", bbox.Doc().Right("Loan Amount").Currency()), and carries its confidence along as an attribute. A read that missed writes nothing: the key is absent from the output.

And for inspecting the read itself:

MethodUse
Found() / Missing()Whether anything matched
Or(fallback)A default when missing
Confidence()The match confidence, 0 on a miss
Why()On a hit, what matched as the label, on which page, at what distance; on a miss, why it missed
Reason()Why a read missed, such as label not found: Loan Amount; empty on a hit

Why() is the one to reach for when a rule silently produces nothing. A Value that did not match knows which anchor in its chain failed.

EntryReturnsFor
Table() / TableWith(text)TableThe document’s table, or one containing text
Form(name) / HasForm(name)FormA named form
Rows(startHeading, endHeading)RowsA column-aligned section between two headings
Cell(rowLabel, colLabel)ValueOne cell by its labels

Table offers Row, RowWith, Column, Cell, Headers, Records, RowCount, ColumnCount. Rows, for column-aligned sections that are not real tables, offers Col, ColNth, ColRightOf, ColUnder, SplitOn, Strip, Records, Count.

Records() on either gives you the rows as maps, which is usually what you want to hand to out.Set.

MethodUse
Title() / IsTitle(phrase)The document title
Headings() / IsHeading(phrase) / HeadingCount()Heading structure
IsBigText(phrase)Whether text is visually prominent
FontSize(phrase) / BodyFontSize()Type sizes
LineWith(phrase) / FirstLine() / LineCount()Lines
PageCount() / TableCount()Counts
AllText()Everything, as one value

Typography signals are how you distinguish a heading that happens to read like body text from one that is actually set as a heading.

The same classification rule, before and after:

// With bbox.Get: six lines, twelve magic numbers, two presence idioms
bbox.Get("page").InRegionPct(0.0, 0.0, 60.0, 10.0)
.ContainingIPhraseFuzzy("Closing Disclosure", 2, 4).Count() > 0 &&
bbox.Get("page").FindIPhraseFuzzy("Loan Terms", 2, 4, 0, 0).Len() > 0 &&
bbox.Get("page").FindIPhraseFuzzy("Projected Payments", 2, 4, 0, 0).Len() > 0 &&
bbox.Get("page").FindIPhraseFuzzy("SELLER'S TRANSACTION", 2, 4, 0, 0).Len() == 0 &&
bbox.Get("page").FindIPhraseFuzzy("SELLERS TRANSACTION", 2, 4, 0, 0).Len() == 0
// With bbox.Doc()
bbox.Doc().Top(10).Has("Closing Disclosure") &&
bbox.Doc().HasAll("Loan Terms", "Projected Payments") &&
!bbox.Doc().Has("SELLER'S TRANSACTION")

The second negative check disappears entirely: loose matching absorbs the apostrophe variant, so there is nothing to enumerate.

The tuning numbers are gone too, and that is the deeper point. 2, 4, 0, 0 were fuzz thresholds an author had to tune; the facade derives them. A surface you choose from is one both a person and a small model can use correctly, a surface you tune is not.

Instead ofWrite
bbox.Get("page")bbox.Doc()
.InRegionPct(0,0,100,20).Top(20)
.InRegionPct(0,0,100,100)nothing. It was a no-op
.ContainingIPhraseFuzzy(p, 2, 4).Count() > 0.Has(p)
.FindIPhraseFuzzy(p, 2, 4, 0, 0).Len() > 0.Has(p)
.FuzzyTextNear(l, "right", …).Right(l)
.FuzzyTextNearCapped(l, …).Right(l)
.GetOCRTextSimple("A |> B").Below("A", "B")
"A<below>|B" chains.Below("A", "B")
.IsCheckboxChecked(l).Checked(l)
.ExtractColumnAlignedSection(…).Rows(start, end)
.SafeExtractForm(n) / .HasForm(n).Form(n) / .HasForm(n)

Existing rules do not need rewriting to keep working. Migrate when you touch a rule for another reason.

The facade fixes what a rule reads. On the writing side, a rule leaves the agenda once it fires, so it needs no Retract; a later rule sees an earlier rule’s out write, so it needs no Changed; and dotted output keys nest. Together they are why the example above is three lines rather than fourteen. See Writing rules.