Skip to content
Talk to our solutions team

Fields, forms and checkboxes

Every value you pull off a scanned page is located the same way: find a label, then read the text next to it in a direction, within a distance. This page covers the three surfaces built on that idea — per-call locator functions, declarative .schema form extraction, and checkbox mark detection — plus the always-on detectors that run during document analysis.

Entry pointCallReturnsReach for it when
Label lookupdoc.Field("Loan Number")query.FieldResultOne value, label is clean
Directional readdoc.FuzzyTextNearCapped(label, dir, …)stringYou want the raw text and nothing else
Scored directional readdoc.FieldNearCapped(label, dir, …)query.FieldResultYou need a confidence to threshold on
Declarative formdoc.ExtractForm("borrower")map[string]anyMany fields, defined once in a file
Scored declarative formdoc.ExtractFormScored("borrower")map[string]query.FieldResultCoverage and review routing matter; the only form call safe to index from a rule
Detected fieldsdoc.Fields, doc.GetField(label)[]field.Field, *field.FieldYou want whatever the page offered, unprompted
Detected checkboxesdoc.Checkboxes, doc.CheckboxGroups[]checkbox.Checkbox, []checkbox.CheckboxGroupStructural marks ([X], ) are present
Marker checkboxesdoc.CheckedOption(options…)stringScanned forms where a glyph precedes the label

Each one hangs off the document the plugin injects into the rule — see rules over documents for how the document gets there. A rule can only produce strings, whole numbers, floats and booleans, so any variant whose argument is a slice cannot be called from rule text at all; the variadic CheckedOption is the exception:

rule read_loan_amount "Read the loan amount" salience 100 {
when
bbox.Has("page")
then
out.Set("loan_amount",
bbox.Get("page").FuzzyTextNearCapped("Loan Amount", bbox.Dir("right"), 1, 2, 0.0, 200.0));
Retract("read_loan_amount");
}

A locator is a label match plus a direction plus a search extent. The value blocks are constrained to the label’s cross-axis span, so a read to the right stays on the label’s row and a read below stays in the label’s column.

FunctionSignature tailNotes
FuzzyTextNear(label, dir, maxDistPerWord, maxTotalDist, expand)Default extent
FuzzyTextNearCapped(…, expand, maxExtent)Explicit pixel cap
FuzzyTextNearChain(chain, valueDir, maxDistPerWord, maxTotalDist, expand)Navigates anchors first
FuzzyTextNearChainCapped(…, expand, maxExtent)Chain with a pixel cap
FieldNear / FieldNearCappedsame argumentsReturns a scored FieldResult
FieldNearChain / FieldNearChainCappedsame argumentsScored chain read

Directions come from bbox.Dir(name), called inline in the argument list.

ArgumentAcceptedBehaviour
dirright, left, above/up, below/downAn unrecognised string falls back to below — it does not error
maxDistPerWordedit distance per word0 means exact
maxTotalDistedit distance over the phrase0 means exact
expandmultiplierWidens the cross-axis by expand × label dimension; 0.0 is a strict column, 0.3 gives breathing room
maxExtentpixelsCaps the search along the direction axis; negative means the default

Default extents, when maxExtent is not supplied:

ReadBudget
Right or left2.5 × label width
Above or below2 × label height
Any direction after a chain walk5 × the final anchor's width (or height for up/down)

Chains navigate through intermediate anchors before reading the value. Each segment except the last carries a direction in angle brackets:

"Loan Information<below>|Loan Term"
"Section A<below>|Sub Header<right>|Field Label"

The first segment is found anywhere on the document; each hop narrows the search region around the previous anchor. Use a chain when a label repeats and its parent heading is what distinguishes the copy you want.

doc.Field(label) is the shortest path. It returns the first detected field whose label either equals the requested label case-insensitively or contains it; failing that it finds the first block containing the label and binds a value in this order:

  1. Inline — the text after the first : that follows the label inside the label’s own block.
  2. The nearest block to the right, within 300px.
  3. The nearest block below, within 300px.

The inline check runs first precisely because OCR merges Loan Number: 12345 into one block; a spatial probe would otherwise bind the next column’s label as the value.

rule read_loan_header "Amount and rate off the header" salience 90 {
when
bbox.Has("page")
then
out.Set("amount", bbox.Get("page").Field("Loan Amount").Required().AsCurrency());
out.Set("rate", bbox.Get("page").Field("Interest Rate").OrDefault("0").AsPercent());
Retract("read_loan_header");
}

Convenience wrappers for rules, all built on Field:

CallReturns
FieldExists(label)bool — found and non-empty
FieldEquals(label, expected)bool, case-insensitive
FieldContains(label, substr)bool, case-insensitive
FieldValue(label)string ("" when absent)
FieldNumber(label)float64
FieldCurrency(label)float64
FieldCount()int64

Analyze() runs the field detector on every document it loads and fills Document.Fields. Three strategies run and their results are deduplicated:

StrategyPatternRelation recorded
Inline^([^:]+):\s*(.+)$ within one blockRelInline
Right-ofLabel and value on the same lineRelRightOf
BelowLabel line, value on the next lineRelBelow

Each field.Field carries Label, Value, LabelBox, ValueBox, Relation, Confidence, LabelConfidence, ValueConfidence, OverallScore, PatternType and Normalized.

The thresholds are compile-time constants, not configuration:

ConstantValueEffect
DefaultMaxLabelLength50Longer candidates are treated as prose, not labels
DefaultShortLabelLength20Below this, a label gets a confidence boost
DefaultGapToWidthMultiplier3.0Label-to-value gap may not exceed 3 × the label width
DefaultAlignmentToleranceRatio0.5Vertical alignment tolerance for right-of pairs
DefaultColonBoost0.3Boost for a label ending in :
DefaultShortLabelBoost0.1Boost for a short label
DefaultCapitalBoost0.1Boost for a leading capital
DefaultDigitPenalty0.2Penalty for digits in the label
DefaultPatternMatchConfidence0.95Confidence when the value matched a known pattern

To target a named field with your own anchors, tolerances and scope, declare it in a .schema file instead — that is the mechanism both surfaces expose.

A .schema file is JSON Schema plus x-* extraction keywords. One file is one named extraction target; the filename without its extension is the name rules call it by.

{
"title": "Borrower",
"type": "object",
"x-page": [1, 2],
"x-property-order": ["FullName", "FirstName", "LoanTerm", "Citizenship"],
"required": ["FullName"],
"properties": {
"FullName": {
"type": "string",
"x-anchors": ["Borrower Name", "Applicant Name"],
"x-locator": "right",
"x-fuzzy": { "max_distance": 2 },
"x-max-extent": 200
},
"FirstName": {
"type": "string",
"x-derive": { "from": "FullName", "rule": "first_token" }
},
"LoanTerm": {
"type": "string",
"x-anchor-chain": "Loan Information<below>|Loan Term",
"x-locator": "right"
},
"Citizenship": {
"type": "string",
"enum": ["U.S. Citizen", "Permanent Resident Alien"],
"x-checkbox": true
}
}
}

On the CLI, register schemas with --schema (a file, repeatable) or --schemas (a directory of *.schema, non-recursive, repeatable).

Terminal window
kis ocr page ./pages --rules ./rules --schemas ./schemas

On rules.svc, the same two inputs travel on the POST /rule body under the reserved bbox key, beside the facts: __docs maps a document name to its OCR JSON, __schemas maps a schema name to the schema itself. The document names are what a rule passes to bbox.Get, exactly as under the CLI.

{
"name": "borrower-checks",
"bbox": {
"__docs": { "page": { "pages": [] } },
"__schemas": { "borrower": { "title": "Borrower", "type": "object", "properties": {} } }
}
}

Send __docs alone when you have no schemas. Every other top-level key is still a plain fact, and the response is still the out collector — see the rules service for headers, auth and status codes.

Scope, declared at the top level:

KeyTypeDefaultMeaning
x-pagelist of intany pagePages the schema applies to; negative counts from the end (-1 is the last page)
x-region.page_range.first / .lastint1 / last pagePage window, 1-indexed inclusive. Overrides x-page
x-region.start_anchorstringnoneTop boundary, matched exactly; scope runs from that page to the end anchor’s page (or the last page)
x-region.end_anchorstringnoneBottom boundary on its page, matched exactly
x-property-orderlist of stringalphabeticalProperty order (JSON objects are unordered)

Per property:

KeyTypeDefaultMeaning
x-anchorstringnoneSingle label to read the value near
x-anchorslist of string or {text, max_distance}noneOrdered candidates; the first that yields a non-empty value wins
x-anchor-chainstringnone"A<dir>|B<dir>|C"; takes priority over x-anchor/x-anchors
x-locatorstringrightafter/right, below/down, above/up, left
x-fuzzy.max_distanceint2 per word / 4 totalPer-word edit budget; total is per-word × anchor word count
x-max-extentnumber (px)2.5 × label width, 2 × label heightCap on how far from the label the value may sit
x-checkboxtrue or objectnoneResolve an enum by mark detection; highest-priority strategy
x-derive{from, rule, args}noneCompute from another property in a second pass

Standard JSON Schema pattern, format, enum and type are validation and scoring inputs.

x-derive rules:

RuleResult
first_tokenFirst whitespace-separated token
middle_tokenMiddle token
last_token_excluding_suffixLast token, skipping trailing jr/sr/ii/iii/iv/v (override with args.suffixes)
suffixThe trailing suffix itself
regex_extractOne capture group of the first match; args.pattern required, args.group defaults to 1 (use 0 for the whole match)

An unknown rule yields "".

CallReturns on successReturns on failure
HasForm(name)truefalse
ExtractForm(name)map[string]anynil
ExtractFormOnPage(name, page)map[string]anynil
SafeExtractForm(name)map[string]anyempty non-nil map
SafeExtractFormOnPage(name, page)map[string]anyempty non-nil map
ExtractFormScored(name)map[string]query.FieldResultempty non-nil map
ExtractFormScoredOnPage(name, page)map[string]query.FieldResultempty non-nil map
DiagnoseForm(name)[]FieldDiagnosisnil

A missing name, a wrong kind (a table schema invoked through ExtractForm) and an extraction that found nothing all return the same nil. The Safe* variants return an empty map instead, which is what keeps a nil from reaching the next call in an expression.

Two load-time errors are worth knowing, and both abort the whole load — no schema is registered: duplicate schema name "<name>" (from <path> and <path>), because CLI schema names come from filenames and two identically named files in different directories collide, and compiling <path>: <error> when a file is not valid JSON Schema. The CLI exits. On the service the same failure lands before any rule runs and comes back as 400; duplicate names cannot occur there, since __schemas is keyed by name.

DiagnoseForm reports a reason per property instead of leaving you to guess:

ReasonMeaningFix
okValue extracted
derivedComputed from another field
no_anchor_definedThe schema declares no anchorAdd x-anchor
anchor_not_foundAnchor text is absent even at loose toleranceThe anchor string is wrong or the OCR garbled it
anchor_tight_or_scopeAnchor matches loosely somewhere, but not within the configured tolerance or scopeRaise x-fuzzy.max_distance, or correct x-page/x-region
value_not_near_anchorAnchor matched in scope, no value in the locator directionChange x-locator or raise x-max-extent

ExtractForm omits any property that extracted an empty string, so a caller cannot tell “not declared” from “declared but not found”. ExtractFormScored returns an entry for every declared property — misses come back with Found=false and confidence 0. Use it whenever coverage or review routing matters, and always from a rule, where indexing an absent key is fatal.

rule route_loan_amount "Read the loan amount and route on confidence" salience 90 {
when
bbox.Has("page")
then
out.Set("loan_amount",
bbox.Get("page").ExtractFormScored("loan")["LoanAmount"].AsCurrency());
out.Set("needs_review",
bbox.Get("page").ExtractFormScored("loan")["LoanAmount"].NeedsReview(0.90));
Retract("route_loan_amount");
}

On a clean field that reads $200,000 the result carries AsText() "$200,000", AsCurrency() 200000, a confidence around 0.9 and a provenance method of field.right_of, so NeedsReview(0.90) is false. A property the extraction missed comes back with Found false and confidence 0: AsText() yields "" and NeedsReview(0.90) is true.

A query.FieldResult carries Value, Label, Box, LabelBox, Conf, Prov, Found and Page, with converters (AsText, AsNumber, AsCurrency, AsPercent, AsInt, AsDate, AsBool), predicates (Matches, Contains, Equals, GreaterThan, Between, OneOf, After, Before) and chaining (Required, Optional, OrDefault, Or). Explain() renders the provenance line — method, label, value, confidence, page, distance and the scoring factors — or "<anchor>": NOT FOUND when nothing was extracted.

Provenance methods you will see: field.inline, field.right_of, field.below, field.chain, schema.checkbox, schema.derive, spatial.first.

The score is adjusted against the property’s declared constraint after extraction — the first of enum, pattern, format: email, format: date, numeric type that the property declares. A value that fits gains a type_fit factor and closes 10% of the gap between its score and 1.0; a value that violates it takes a type_mismatch factor at weight 1.5 and has its score cut by 60%. A pattern that fails to compile counts as no constraint at all. Unconstrained strings are untouched, and a derived property inherits its source field’s score. This is what pushes an over-captured value — a ZIP field that read at Current Address — into review.

How the factors combine, and how to pick a review threshold from labelled outcomes rather than guessing one, is on matching and confidence.

Two mechanisms exist and they see different things.

MechanismDetectsPopulated byUse for
Marker readingA glyph immediately left of the option labelCalled on demandScanned forms — the common case
Structural detection[X], (x), , , small square boxesAnalyze()Pages where the control itself survived OCR

Scanners routinely OCR a filled checkbox as a character glued to the left of the label — @U.S. Citizen checked, OPermanent Resident unchecked. That is what the CheckedOption family reads.

CallScope
CheckedOption(options…)Whole document, default markers
CheckedOptionMarkers(options, checkedChars)Whole document, custom markers
CheckedOptionOnPage(options, page)One page
CheckedOptionNear(anchor, dir, extent, options)Text near an anchor — the yes/no question pattern
CheckedOptionNearMarkers(anchor, dir, extent, options, checkedChars)As above, custom markers

CheckedOption is the only variadic one, and the only one a rule can call directly: the rest take an []string of options, which rule text cannot construct. Reach those variants declaratively with x-checkbox in a schema, described below.

CheckedOption and CheckedOptionMarkers are two-phase. First a positive scan: return the first option carrying a checked marker within 30px to its left. If none does, elimination: when at least one option carries an unchecked marker, return the first option that is present with a clean left side. CheckedOptionOnPage runs the positive scan only.

The default marker sets are fixed; override them per property with x-checkbox.markers:

SetCharacters
DefaultCheckedMarkers@ ® © ■ • ✓ ✔ ☑ █
DefaultUncheckedMarkersO ○ □ ( )

The same machinery backs the label-oriented helpers, which fall back to marker reading when the structural detector finds nothing:

CallReturns
IsCheckboxChecked(label)bool
GetCheckboxValue(label)"Yes", "No", or "" when the option is not on the page
IsYesNoChecked(groupName)bool — group has Yes selected
GetCheckedValue(groupName)[]string of selected labels
doc.IsCheckboxChecked("U.S. Citizen") // true — preceded by "@"
doc.IsCheckboxChecked("Permanent Resident") // false — preceded by "O"
doc.GetCheckboxValue("Permanent Resident") // "No"
doc.GetCheckboxValue("Nonexistent Option") // ""

In a schema, x-checkbox does the same thing declaratively and returns the option’s declared value:

"CreditType": {
"type": "string",
"x-checkbox": {
"options": [
{ "label": "am applying for individual credit", "value": "Individual" },
{ "label": "am applying for joint credit", "value": "Joint" }
]
}
}
KeyDefaultMeaning
x-checkbox: trueUse the property’s enum as both labels and values
x-checkbox.optionsPlain strings, or {label, value} pairs
x-checkbox.markersDefaultCheckedMarkersCharacters that count as checked for this property
x-checkbox.near.anchorScope option detection to text near this anchor
x-checkbox.near.locatorrightright, left, above, below
x-checkbox.near.extent400Pixels from the anchor

Which of three code paths runs depends on how the property is scoped, and they are not equally OCR-tolerant:

ConditionPath
x-checkbox.near.anchor setAnchor matched with edit tolerance 2/4, then options resolved inside the text it pulled
Scope resolves to exactly one pageOptions matched with phrase fuzz; a marker merged into the option’s first token (@Uus. Citizen) counts as well as one in a separate block
NeitherThe same whole-document CheckedOptionMarkers scan a rule would run by hand — exact option text, marker in a separate block only

Reach the tolerant middle path by scoping the schema with x-page, x-region.page_range or ExtractFormOnPage.

Analyze() runs the checkbox detector on every document it loads, filling Document.Checkboxes and Document.CheckboxGroups. Three methods all run, then overlapping detections are collapsed per page — highest confidence wins — and labelled:

MethodWhat it matchesConfidence
Unicode symbols☑ ☒ ■ ▣ ☐ □ ▢ ● ◉ ○ ◯ and friends0.95
Regex patterns[X], [ ], (x), ( ), _X_, ___0.85
Geometric, markedA small square block whose whole text is X or x0.7
Geometric, emptyA small empty square block0.6

A block qualifies as a geometric checkbox only when its width and height are both between 5 and 30 pixels and its aspect ratio is between 0.7 and 1.4. At unusual scan resolutions this detector finds nothing at all. Because confidence decides the collapse, a Unicode symbol always beats a bracket pattern and both always beat a geometric guess at the same spot.

Each Checkbox carries Box, Checked, Label, LabelBox, GroupName, Confidence, Type (TypeCheckbox, TypeRadio, TypeBracket, TypeParen, TypeUnderscore) and Page. A CheckboxGroup carries Name, Checkboxes, Selected (the labels of checked members) and TypeGroupMultiple only when more than one member is checked. Rules see the same data through GetCheckbox(label), GetAllCheckboxes(), GetCheckboxGroupResult(name) and GetAllCheckboxGroups(), whose group results add SelectedValue() and SelectedValues().

Analyze() always builds the detector with the defaults below and offers no hook to replace them. These values are fixed: no CLI flag, schema keyword or service configuration key changes them.

FieldDefaultMeaning
MaxLabelDistance200Furthest a block may be and still be adopted as the label
LabelPositionLabelRightLabelRight, LabelLeft, LabelBoth, LabelAny
GroupByProximitytrueWhen false, each checkbox is its own group
GroupMaxDistance100Proximity budget for grouping
CommonGroupLabelsYes, No, N/A, Married, Single, Divorced, Widowed, Separated, Own, Rent, Primary, Secondary, Investment, Purchase, Refinance, True, FalseOnly checkboxes with one of these labels are eligible for grouping
BracketPatterns / ParenPatterns / UnderscorePatternssee aboveRegex families for pattern detection

LabelRight, LabelLeft and LabelBoth also require rough horizontal alignment; LabelAny uses Manhattan distance in any direction and skips that requirement.

Confidences are hand-tuned constants and factor blends. They order results usefully; they are not probabilities, and a raw 0.8 does not mean “80% likely correct”.

SourceValue
Field pattern match0.95
Below-relation prior0.75 (weight 0.6)
Checkbox, Unicode symbol0.95
Checkbox, regex pattern0.85
Checkbox, geometric mark0.7
Checkbox, geometric empty0.6
Checkbox in a scored schemaflat 0.85 checkbox_marker factor
Type fitcloses 10% of the gap to 1.0
Type mismatchscore × 0.4, factor weight 1.5

Two further limits worth planning around. Value trimming makes a scored value shorter than the string the equivalent FuzzyTextNear* call returns: the scored path keeps the contiguous same-line run, breaks at the first gap wider than 3.5 × the median word gap, cuts at a column separator artefact, and takes the minimum OCR confidence across the surviving words — one bad word lowers the whole field. And a value read below the label keeps only the line nearest the label.

Turn the raw numbers into an operating point with the calibration tooling described on matching and confidence.