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 points
Section titled “Entry points”| Entry point | Call | Returns | Reach for it when |
|---|---|---|---|
| Label lookup | doc.Field("Loan Number") | query.FieldResult | One value, label is clean |
| Directional read | doc.FuzzyTextNearCapped(label, dir, …) | string | You want the raw text and nothing else |
| Scored directional read | doc.FieldNearCapped(label, dir, …) | query.FieldResult | You need a confidence to threshold on |
| Declarative form | doc.ExtractForm("borrower") | map[string]any | Many fields, defined once in a file |
| Scored declarative form | doc.ExtractFormScored("borrower") | map[string]query.FieldResult | Coverage and review routing matter; the only form call safe to index from a rule |
| Detected fields | doc.Fields, doc.GetField(label) | []field.Field, *field.Field | You want whatever the page offered, unprompted |
| Detected checkboxes | doc.Checkboxes, doc.CheckboxGroups | []checkbox.Checkbox, []checkbox.CheckboxGroup | Structural marks ([X], ☑) are present |
| Marker checkboxes | doc.CheckedOption(options…) | string | Scanned 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");}The locator model
Section titled “The locator model”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.
| Function | Signature tail | Notes |
|---|---|---|
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 / FieldNearCapped | same arguments | Returns a scored FieldResult |
FieldNearChain / FieldNearChainCapped | same arguments | Scored chain read |
Directions come from bbox.Dir(name), called inline in the argument list.
| Argument | Accepted | Behaviour |
|---|---|---|
dir | right, left, above/up, below/down | An unrecognised string falls back to below — it does not error |
maxDistPerWord | edit distance per word | 0 means exact |
maxTotalDist | edit distance over the phrase | 0 means exact |
expand | multiplier | Widens the cross-axis by expand × label dimension; 0.0 is a strict column, 0.3 gives breathing room |
maxExtent | pixels | Caps the search along the direction axis; negative means the default |
Default extents, when maxExtent is not supplied:
| Read | Budget |
|---|---|
| Right or left | 2.5 × label width |
| Above or below | 2 × label height |
| Any direction after a chain walk | 5 × 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.
Reading one field by label
Section titled “Reading one field by label”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:
- Inline — the text after the first
:that follows the label inside the label’s own block. - The nearest block to the right, within 300px.
- 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:
| Call | Returns |
|---|---|
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 |
Detected fields
Section titled “Detected fields”Analyze() runs the field detector on every document it loads and fills Document.Fields.
Three strategies run and their results are deduplicated:
| Strategy | Pattern | Relation recorded |
|---|---|---|
| Inline | ^([^:]+):\s*(.+)$ within one block | RelInline |
| Right-of | Label and value on the same line | RelRightOf |
| Below | Label line, value on the next line | RelBelow |
Each field.Field carries Label, Value, LabelBox, ValueBox, Relation, Confidence,
LabelConfidence, ValueConfidence, OverallScore, PatternType and Normalized.
The thresholds are compile-time constants, not configuration:
| Constant | Value | Effect |
|---|---|---|
DefaultMaxLabelLength | 50 | Longer candidates are treated as prose, not labels |
DefaultShortLabelLength | 20 | Below this, a label gets a confidence boost |
DefaultGapToWidthMultiplier | 3.0 | Label-to-value gap may not exceed 3 × the label width |
DefaultAlignmentToleranceRatio | 0.5 | Vertical alignment tolerance for right-of pairs |
DefaultColonBoost | 0.3 | Boost for a label ending in : |
DefaultShortLabelBoost | 0.1 | Boost for a short label |
DefaultCapitalBoost | 0.1 | Boost for a leading capital |
DefaultDigitPenalty | 0.2 | Penalty for digits in the label |
DefaultPatternMatchConfidence | 0.95 | Confidence 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.
Form schemas
Section titled “Form schemas”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).
kis ocr page ./pages --rules ./rules --schemas ./schemasOn 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.
Schema keywords
Section titled “Schema keywords”Scope, declared at the top level:
| Key | Type | Default | Meaning |
|---|---|---|---|
x-page | list of int | any page | Pages the schema applies to; negative counts from the end (-1 is the last page) |
x-region.page_range.first / .last | int | 1 / last page | Page window, 1-indexed inclusive. Overrides x-page |
x-region.start_anchor | string | none | Top boundary, matched exactly; scope runs from that page to the end anchor’s page (or the last page) |
x-region.end_anchor | string | none | Bottom boundary on its page, matched exactly |
x-property-order | list of string | alphabetical | Property order (JSON objects are unordered) |
Per property:
| Key | Type | Default | Meaning |
|---|---|---|---|
x-anchor | string | none | Single label to read the value near |
x-anchors | list of string or {text, max_distance} | none | Ordered candidates; the first that yields a non-empty value wins |
x-anchor-chain | string | none | "A<dir>|B<dir>|C"; takes priority over x-anchor/x-anchors |
x-locator | string | right | after/right, below/down, above/up, left |
x-fuzzy.max_distance | int | 2 per word / 4 total | Per-word edit budget; total is per-word × anchor word count |
x-max-extent | number (px) | 2.5 × label width, 2 × label height | Cap on how far from the label the value may sit |
x-checkbox | true or object | none | Resolve an enum by mark detection; highest-priority strategy |
x-derive | {from, rule, args} | none | Compute from another property in a second pass |
Standard JSON Schema pattern, format, enum and type are validation and scoring inputs.
x-derive rules:
| Rule | Result |
|---|---|
first_token | First whitespace-separated token |
middle_token | Middle token |
last_token_excluding_suffix | Last token, skipping trailing jr/sr/ii/iii/iv/v (override with args.suffixes) |
suffix | The trailing suffix itself |
regex_extract | One capture group of the first match; args.pattern required, args.group defaults to 1 (use 0 for the whole match) |
An unknown rule yields "".
Extraction calls
Section titled “Extraction calls”| Call | Returns on success | Returns on failure |
|---|---|---|
HasForm(name) | true | false |
ExtractForm(name) | map[string]any | nil |
ExtractFormOnPage(name, page) | map[string]any | nil |
SafeExtractForm(name) | map[string]any | empty non-nil map |
SafeExtractFormOnPage(name, page) | map[string]any | empty non-nil map |
ExtractFormScored(name) | map[string]query.FieldResult | empty non-nil map |
ExtractFormScoredOnPage(name, page) | map[string]query.FieldResult | empty non-nil map |
DiagnoseForm(name) | []FieldDiagnosis | nil |
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.
Why a field came back empty
Section titled “Why a field came back empty”DiagnoseForm reports a reason per property instead of leaving you to guess:
| Reason | Meaning | Fix |
|---|---|---|
ok | Value extracted | — |
derived | Computed from another field | — |
no_anchor_defined | The schema declares no anchor | Add x-anchor |
anchor_not_found | Anchor text is absent even at loose tolerance | The anchor string is wrong or the OCR garbled it |
anchor_tight_or_scope | Anchor matches loosely somewhere, but not within the configured tolerance or scope | Raise x-fuzzy.max_distance, or correct x-page/x-region |
value_not_near_anchor | Anchor matched in scope, no value in the locator direction | Change x-locator or raise x-max-extent |
Scored results
Section titled “Scored results”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.
Checkboxes and marks
Section titled “Checkboxes and marks”Two mechanisms exist and they see different things.
| Mechanism | Detects | Populated by | Use for |
|---|---|---|---|
| Marker reading | A glyph immediately left of the option label | Called on demand | Scanned forms — the common case |
| Structural detection | [X], (x), ☑, ●, small square boxes | Analyze() | Pages where the control itself survived OCR |
Marker reading
Section titled “Marker reading”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.
| Call | Scope |
|---|---|
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:
| Set | Characters |
|---|---|
DefaultCheckedMarkers | @ ® © ■ • ✓ ✔ ☑ █ |
DefaultUncheckedMarkers | O ○ □ ( ) |
The same machinery backs the label-oriented helpers, which fall back to marker reading when the structural detector finds nothing:
| Call | Returns |
|---|---|
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" } ] }}| Key | Default | Meaning |
|---|---|---|
x-checkbox: true | — | Use the property’s enum as both labels and values |
x-checkbox.options | — | Plain strings, or {label, value} pairs |
x-checkbox.markers | DefaultCheckedMarkers | Characters that count as checked for this property |
x-checkbox.near.anchor | — | Scope option detection to text near this anchor |
x-checkbox.near.locator | right | right, left, above, below |
x-checkbox.near.extent | 400 | Pixels from the anchor |
Which of three code paths runs depends on how the property is scoped, and they are not equally OCR-tolerant:
| Condition | Path |
|---|---|
x-checkbox.near.anchor set | Anchor matched with edit tolerance 2/4, then options resolved inside the text it pulled |
| Scope resolves to exactly one page | Options matched with phrase fuzz; a marker merged into the option’s first token (@Uus. Citizen) counts as well as one in a separate block |
| Neither | The 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.
Structural detection
Section titled “Structural detection”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:
| Method | What it matches | Confidence |
|---|---|---|
| Unicode symbols | ☑ ☒ ■ ▣ ☐ □ ▢ ● ◉ ○ ◯ and friends | 0.95 |
| Regex patterns | [X], [ ], (x), ( ), _X_, ___ | 0.85 |
| Geometric, marked | A small square block whose whole text is X or x | 0.7 |
| Geometric, empty | A small empty square block | 0.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
Type — GroupMultiple 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.
| Field | Default | Meaning |
|---|---|---|
MaxLabelDistance | 200 | Furthest a block may be and still be adopted as the label |
LabelPosition | LabelRight | LabelRight, LabelLeft, LabelBoth, LabelAny |
GroupByProximity | true | When false, each checkbox is its own group |
GroupMaxDistance | 100 | Proximity budget for grouping |
CommonGroupLabels | Yes, No, N/A, Married, Single, Divorced, Widowed, Separated, Own, Rent, Primary, Secondary, Investment, Purchase, Refinance, True, False | Only checkboxes with one of these labels are eligible for grouping |
BracketPatterns / ParenPatterns / UnderscorePatterns | see above | Regex families for pattern detection |
LabelRight, LabelLeft and LabelBoth also require rough horizontal alignment; LabelAny
uses Manhattan distance in any direction and skips that requirement.
Accuracy characteristics
Section titled “Accuracy characteristics”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”.
| Source | Value |
|---|---|
| Field pattern match | 0.95 |
| Below-relation prior | 0.75 (weight 0.6) |
| Checkbox, Unicode symbol | 0.95 |
| Checkbox, regex pattern | 0.85 |
| Checkbox, geometric mark | 0.7 |
| Checkbox, geometric empty | 0.6 |
| Checkbox in a scored schema | flat 0.85 checkbox_marker factor |
| Type fit | closes 10% of the gap to 1.0 |
| Type mismatch | score × 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.
Continue with
Section titled “Continue with”- The document model — pages, blocks, lines,
Analyze() - Tables and grid sections — rows, columns and repeating records
- Matching and confidence — fuzzy search, scoring, calibration
- Rules over documents — how a document reaches a rule
- OCR ingestion — where the pages come from
- CLI —
kis ocrandkis bbox - The rules service — the same extraction over
POST /rule