Skip to content
Talk to our solutions team

From hOCR to extracted data

This page runs a single scanned page all the way through: an hOCR file from a recogniser, converted to the document model, inspected, then queried by a rule set that writes two values out. Every command, file and output below is real. Follow it top to bottom and you get the stated result.

The main run involves five files, all of them shown in full. The failure and split sections later on add a few more, each shown where it is used:

FileRole
invoice-p1.hocrThe recogniser’s output — the only thing you supply
invoice-p1.bboxThe document model, produced by kis hocr2bbox
invoice-p1.jsonPage facts, paired with the .bbox by basename
rulesdir/invoice.grlThe rule set
invoice-p1-hist-audit.jsonThe history and audit sidecar, written for you
RequirementWhy
The kis CLIEvery command here
The rules plugin, installed separatelykis hocr2bbox, kis bbox and kis ocr are proxy commands
An OCR engine that emits hOCRProducing the input; nothing in this block performs OCR
A reachable rules.svc and a tenant JWTOnly for the HTTP half

invoice-p1.hocr — one page of a 300-DPI US Letter scan, 2550 × 3300 units, six words.

<html><body>
<div class="ocr_page" id="page_1" title='image "invoice-p1.png"; bbox 0 0 2550 3300; ppageno 0'>
<p class="ocr_par" title="bbox 300 240 1180 320">
<span class="ocr_line" title="bbox 300 240 1180 320">
<span class="ocrx_word" title="bbox 300 240 1180 320; x_wconf 96">INVOICE</span>
</span>
</p>
<p class="ocr_par" title="bbox 300 520 1660 680">
<span class="ocr_line" title="bbox 300 520 1660 580">
<span class="ocrx_word" title="bbox 300 520 620 580; x_wconf 95">Invoice</span>
<span class="ocrx_word" title="bbox 640 520 960 580; x_wconf 94">Number</span>
<span class="ocrx_word" title="bbox 1200 520 1660 580; x_wconf 93">INV-2026-0431</span>
</span>
<span class="ocr_line" title="bbox 300 620 1660 680">
<span class="ocrx_word" title="bbox 300 620 520 680; x_wconf 95">Total</span>
<span class="ocrx_word" title="bbox 1200 620 1610 680; x_wconf 91">$1,250.00</span>
</span>
</p>
</div>
</body></html>
Terminal window
kis hocr2bbox -i invoice-p1.hocr -l info
2026-07-27T17:34:12+05:30 INF successfully converted hocr to bbox app=kis.ai input=invoice-p1.hocr output=invoice-p1.bbox words=6

With no -o the output is the input path with its extension replaced, so this writes invoice-p1.bbox beside the input.

invoice-p1.bbox in full. This is the document model on disk: one page, six words with raw top-left-origin coordinates, three lines and two paragraphs.

{
"pages": [
{
"number": 1,
"width": 2550,
"height": 3300,
"words": [
{
"text": "INVOICE",
"box": { "x0": 300, "y0": 240, "x1": 1180, "y1": 320 },
"confidence": 0.96,
"page": 1,
"line_id": 1
},
{
"text": "Invoice",
"box": { "x0": 300, "y0": 520, "x1": 620, "y1": 580 },
"confidence": 0.95,
"page": 1,
"line_id": 2
},
{
"text": "Number",
"box": { "x0": 640, "y0": 520, "x1": 960, "y1": 580 },
"confidence": 0.94,
"page": 1,
"line_id": 2
},
{
"text": "INV-2026-0431",
"box": { "x0": 1200, "y0": 520, "x1": 1660, "y1": 580 },
"confidence": 0.93,
"page": 1,
"line_id": 2
},
{
"text": "Total",
"box": { "x0": 300, "y0": 620, "x1": 520, "y1": 680 },
"confidence": 0.95,
"page": 1,
"line_id": 3
},
{
"text": "$1,250.00",
"box": { "x0": 1200, "y0": 620, "x1": 1610, "y1": 680 },
"confidence": 0.91,
"page": 1,
"line_id": 3
}
],
"lines": [
{
"id": 1,
"text": "INVOICE",
"box": { "x0": 300, "y0": 240, "x1": 1180, "y1": 320 },
"confidence": 0.96,
"word_start": 0,
"word_end": 1
},
{
"id": 2,
"text": "Invoice Number INV-2026-0431",
"box": { "x0": 300, "y0": 520, "x1": 1660, "y1": 580 },
"confidence": 0.94,
"word_start": 1,
"word_end": 4
},
{
"id": 3,
"text": "Total $1,250.00",
"box": { "x0": 300, "y0": 620, "x1": 1610, "y1": 680 },
"confidence": 0.9299999999999999,
"word_start": 4,
"word_end": 6
}
],
"paragraphs": [
{
"id": 1,
"text": "INVOICE",
"box": { "x0": 300, "y0": 240, "x1": 1180, "y1": 320 },
"confidence": 0.96,
"line_start": 0,
"line_end": 1
},
{
"id": 2,
"text": "Invoice Number INV-2026-0431\nTotal $1,250.00",
"box": { "x0": 300, "y0": 520, "x1": 1660, "y1": 680 },
"confidence": 0.9349999999999999,
"line_start": 1,
"line_end": 3
}
]
}
]
}

The file itself puts each box field on its own line; the boxes are folded onto one line here and nothing else is changed.

Reading it against the hOCR:

In the fileCame fromNote
width / height 2550 × 3300The ocr_page bbox, ignoring the image "…" propertyppageno is never read; number is the position in document order
box on each wordThat word’s own bboxRaw OCR units, top-left origin, no normalisation
confidence 0.96x_wconf 96Divided by 100
line_idA per-page counter over ocr_line elements, stamped onto their wordsDense and 1-based per page; unrelated to any id in the hOCR
lines[].word_start / word_endHalf-open range into wordsLine 2 is words 1, 2 and 3
lines[].textMember words joined with a spaceNever re-derived later — see below
paragraphs[].line_start / line_endHalf-open range into linesParagraph 2 is lines 2 and 3
paragraphs[].textMember lines joined with a newline
confidence 0.9299999999999999The arithmetic mean of 0.95 and 0.91Binary floating point; compare with a tolerance, never with ==

The full type reference — every field, method and coordinate rule — is the document model and its API reference.

Step 3 — inspect before writing any rules

Section titled “Step 3 — inspect before writing any rules”

kis bbox loads a .bbox file, analyses it, prints what it found and runs nothing. Use it to confirm the anchors you are about to write rules against actually exist.

Terminal window
kis bbox phrase -p "Invoice Number" invoice-p1.bbox
Found 1 match(es) for phrase "Invoice Number"
=== Match 1 (Page 1) ===
Location: (300.0, 520.0) - (960.0, 580.0)
Line: 2
INVOICE
>> Invoice Number INV-2026-0431
Total $1,250.00

The match box (300,520)-(960,580) is the merged Invoice + Number block — exactly the anchor a rule will resolve. Matching is case-sensitive unless you pass --case-insensitive.

Terminal window
kis bbox snippets --snip "header|Invoice Number|Total" invoice-p1.bbox
{
"header": "Invoice Number INV-2026-0431"
}
Terminal window
kis bbox tables invoice-p1.bbox
=== Table 1 (Page 1) ===
Dimensions: 3 rows x 2 columns
Headers: INVOICE |
INVOICE |
---------------+--------------
Invoice Number | INV-2026-0431
Total | $1,250.00

Two label/value rows in a consistent left-and-right layout are enough for table detection to fire, and it pulls the page banner in as a header row. That is worth knowing before you reach for tables on a document that is not really tabular.

Step 4 — pull the values out with a rule set

Section titled “Step 4 — pull the values out with a rule set”

Two files, both named for the same prefix. kis ocr page pairs <prefix>.json (the facts) with <prefix>.bbox (the document) and requires both.

invoice-p1.json:

{
"source": "scan-2026-07-27",
"supplier_id": "SUP-118"
}

rulesdir/invoice.grl:

rule ReadInvoiceHeader "Invoice number and total from the scanned page" salience 100 {
when
bbox.Has("page")
then
out.Set("invoice_number", bbox.Get("page").TextRightOf("Invoice Number"));
out.Set("total", bbox.Get("page").TextRightOf("Total"));
out.Set("pages", bbox.Get("page").PageCount());
out.Set("lines", bbox.Get("page").LineCount());
audit.Log("ReadInvoiceHeader", "read the invoice header from page");
Retract("ReadInvoiceHeader");
}
rule FlagMissingInvoiceNumber "The invoice number anchor produced nothing" salience 90 {
when
bbox.Has("page") && bbox.Get("page").TextRightOf("Invoice Number") == ""
then
out.Set("status", "invoice_number_missing");
audit.Log("FlagMissingInvoiceNumber", "no text to the right of the Invoice Number anchor");
Retract("FlagMissingInvoiceNumber");
}
rule MarkComplete "Both header values were read" salience 80 {
when
bbox.Has("page") && bbox.Get("page").TextRightOf("Invoice Number") != "" && bbox.Get("page").TextRightOf("Total") != ""
then
out.Set("status", "complete");
audit.Log("MarkComplete", "invoice number and total both present");
Retract("MarkComplete");
}

Four things in that file are load-bearing:

Written this wayBecause
bbox.Has("page") in every whenbbox.Get returns nil for an unknown name and a method call on nil aborts the whole run
bbox.Get("page").TextRightOf(...) chained in one expressionbbox.Get hands back the analysed document, so the call reads straight through it. Inside a then you may bind it first — d = bbox.Get("page"); — and call methods on d; a when has to chain
Salience 100, 90, 80 — all distinctEqual salience gives a different firing order run to run
Retract on the last line of every thenWithout it the rule re-fires until the 5000-cycle cap trips and the whole run is discarded

page is the document name because that is what kis ocr page injects under. Change it with --bbox-name; kis ocr doc uses doc and kis rules -b defaults to bbox.

Run it:

Terminal window
kis ocr page -r rulesdir/ --history --audit invoice-p1
--- History for /work/invoice-p1.json ---
1. [ReadInvoiceHeader] invoice_number = "INV-2026-0431"
2. [ReadInvoiceHeader] total = "$1,250.00"
3. [ReadInvoiceHeader] pages = 1
4. [ReadInvoiceHeader] lines = 3
5. [MarkComplete] status = "complete"
--- Audit for /work/invoice-p1.json ---
1. [ReadInvoiceHeader] executed
2. [ReadInvoiceHeader] read the invoice header from page
3. [MarkComplete] executed
4. [MarkComplete] invoice number and total both present
Result for /work/invoice-p1.json:
{
"invoice_number": "INV-2026-0431",
"lines": 3,
"pages": 1,
"status": "complete",
"total": "$1,250.00"
}
Processed 1/1 prefix(es) successfully

FlagMissingInvoiceNumber was evaluated and did not match, so it contributed nothing. The two executed entries are added by the engine for every rule that fires; the other two are the rules’ own audit.Log calls.

TextRightOf("Invoice Number") resolved the merged anchor block at (300,520)-(960,580) and returned the nearest block to its right — INV-2026-0431 at (1200,520)-(1660,580). The same lookup against Total returned $1,250.00. Anchor resolution, direction, distance limits and the fuzzy variants are covered in extraction and matching.

The run also wrote invoice-p1-hist-audit.json, whether or not you passed --history or --audit:

{
"history": [
{ "rule_id": "ReadInvoiceHeader", "key": "invoice_number", "value": "INV-2026-0431" },
{ "rule_id": "ReadInvoiceHeader", "key": "total", "value": "$1,250.00" },
{ "rule_id": "ReadInvoiceHeader", "key": "pages", "value": 1 },
{ "rule_id": "ReadInvoiceHeader", "key": "lines", "value": 3 },
{ "rule_id": "MarkComplete", "key": "status", "value": "complete" }
],
"audit": [
{ "rule_id": "ReadInvoiceHeader", "message": "executed" },
{ "rule_id": "ReadInvoiceHeader", "message": "read the invoice header from page" },
{ "rule_id": "MarkComplete", "message": "executed" },
{ "rule_id": "MarkComplete", "message": "invoice number and total both present" }
]
}

Each entry is folded onto one line above; the file itself indents every field. Each run appends rather than replacing, so a second kis ocr page over the same prefix leaves eighteen entries where the first left nine. Send them somewhere else with --history-audit ./audit/.

POST /rule takes this rule set unchanged. The rule text does not move a character; only the input does — the OCR JSON travels inline in the request instead of being read from disk.

The service parses and analyses every entry under bbox before the first rule is evaluated, with the same library the CLI drives, then binds the analysed documents under bbox for that execution.

Terminal window
curl -X POST https://rules.example.com/rule -H 'Content-Type: application/json' -H 'Authorization: Bearer <tenant-jwt>' -H 'X-Customer: acme' -H 'X-Product: erp' -H 'X-Env: prod' -H 'X-Tenant: eu1' -d @request.json

request.jsonname selects the rule set, the reserved bbox object carries the same JSON kis hocr2bbox wrote, keyed by the name the rules address it by, and every other top-level key is a fact.

{
"name": "invoice-extract",
"source": "scan-2026-07-27",
"supplier_id": "SUP-118",
"bbox": {
"page": {
"pages": [
{
"number": 1,
"width": 2550,
"height": 3300,
"words": [
{
"text": "Invoice",
"box": { "x0": 300, "y0": 520, "x1": 620, "y1": 580 },
"confidence": 0.95,
"page": 1,
"line_id": 2
},
{
"text": "INV-2026-0431",
"box": { "x0": 1200, "y0": 520, "x1": 1660, "y1": 580 },
"confidence": 0.93,
"page": 1,
"line_id": 2
}
]
}
]
}
}
}

With the full six-word document, the response is the out map — the same pairs the CLI printed under Result for …. Key order is not stable; the service rebuilds the object from an unordered Go map on the way out, so do not match on ordering.

{
"invoice_number": "INV-2026-0431",
"lines": 3,
"pages": 1,
"status": "complete",
"total": "$1,250.00"
}
CLIHTTP
invoice-p1.json top-level keysTop-level keys of the request body
invoice-p1.bbox on diskThe matching entry under bbox
--bbox-name pageThe key page under bbox
-r rulesdir/name, resolved against rule sets published to the metadata plane
Result for … blockThe 200 response body
--history / --audit outputThe same history and audit entries the engine records for the run

Differences that bite:

  • Nested facts read the same on both surfaces. A nested object arrives as a map fact and needs bracket access — header["total"], never header.total, which aborts the execution. bbox is not a fact at all: it is read as document input before the fact map is built.
  • name is also a fact. Do not use name, bbox, out, audit, in, or any util namespace, as a fact of your own.
  • An empty result is ambiguous. A rule set that matched nothing and one that matched and wrote nothing both return {} with 200.
  • Documents travel inline only. A string under bbox is treated as a filesystem path, and the service has no access to yours.

Sending the whole document on every call is not the only shape. Extraction and the decision it feeds are separable, and splitting them keeps the request small when the decision needs five values rather than five thousand words. Extract once — on the CLI here, or with an earlier POST /rule against the extraction rule set — and post the values to a second rule set that names no document.

Step 4 wrote invoice_number, total, pages, lines and status. Post the two the decision needs, alongside the supplier the page already carried:

Terminal window
curl -X POST https://rules.example.com/rule -H 'Content-Type: application/json' -H 'Authorization: Bearer <tenant-jwt>' -H 'X-Customer: acme' -H 'X-Product: erp' -H 'X-Env: prod' -H 'X-Tenant: eu1' -d '{"name":"invoice-accept","invoice_number":"INV-2026-0431","total":"$1,250.00","supplier_id":"SUP-118"}'

acceptdir/invoice-accept.grl names no document, so the run needs no bbox on either surface. Keep it in its own directory — -r loads every .grl it finds into one rule set, so leaving it beside invoice.grl would pull the document rules of Step 4 into this run and abort it on bbox:

rule RequireInvoiceNumber "the request carried no invoice number" salience 100 {
when
in.Has("invoice_number") == false
then
out.Set("status", "invoice_number_missing");
audit.Log("RequireInvoiceNumber", "no invoice_number fact on the request");
Retract("RequireInvoiceNumber");
}
rule AcceptInvoice "both header values arrived" salience 90 {
when
in.Has("invoice_number") && in.Has("total")
then
out.Set("status", "accepted");
out.Set("invoice_number", invoice_number);
audit.Log("AcceptInvoice", "invoice number and total both present");
Retract("AcceptInvoice");
}
{
"invoice_number": "INV-2026-0431",
"status": "accepted"
}

The same rule set runs from the CLI over a file holding the same keys — no -b, no --with-bbox, because it names no document. extracted.json:

{
"name": "invoice-accept",
"invoice_number": "INV-2026-0431",
"total": "$1,250.00",
"supplier_id": "SUP-118"
}
Terminal window
kis rules -r acceptdir/ -i . -p "extracted.json"
Result for /work/extracted.json:
{
"invoice_number": "INV-2026-0431",
"status": "accepted"
}
Processed 1/1 file(s) successfully

in.Has reads the fact map and works; the typed getters next to it — in.GetStr, GetInt, GetFloat, GetBool, GetMap — return the zero value for every fact, present or not, so an assertion built on them silently never holds. A bare top-level fact name (invoice_number above) reads through directly.

Headers, status codes, the error envelope and how rule sets reach the service are on running the service and in the API reference.

The run above records its outcome twice: a machine-readable status on out, which is what the caller sees, and a human-readable line on audit, which is what an operator reads. That is the working pattern today and it is what FlagMissingInvoiceNumber and MarkComplete do above.

When the conversion produces nothing useful

Section titled “When the conversion produces nothing useful”

The hOCR parser accepts almost anything and reports success. Every failure below exits 0. These are the four you will actually meet; the first and third are reproduced in full underneath.

Symptom in .bboxCauseWhat to check
One word holding the whole file, zero box, confidence 0The input is not hOCRwords=1 in the info log; orphan_text warning
Several words, all with zero boxes and confidence 0HTML with no ocr classesorphan_text warnings, width and height both 0
width and height are 0, words are fineThe ocr_page has no bbox, or a malformed onemissing_bbox warning on ocr_page
A cluster of words sharing one identical oversized boxWord elements have no bboxmissing_bbox warnings on ocrx_word; confidence is forced to 0

Run the conversion with -v -l info to see any of it:

Terminal window
kis hocr2bbox -i notreally.hocr -v -l info

A plain text file fed to the converter. notreally.hocr is two lines with no markup at all:

Invoice Number INV-2026-0431
Total $1,250.00
2026-07-27T17:34:42+05:30 WRN parsing completed with warnings app=kis.ai input=notreally.hocr warningCount=2
2026-07-27T17:34:42+05:30 WRN hocr parsing warning app=kis.ai context="text not in word element, creating fallback word" element=text_node text="Invoice Number INV-2026-0431\nTotal $1,250.00" type=orphan_text
2026-07-27T17:34:42+05:30 WRN hocr parsing warning app=kis.ai context="no bbox available, using zero box" element=word text="Invoice Number INV-2026-0431\nTotal $1,250.00" type=no_bbox_fallback
2026-07-27T17:34:42+05:30 INF successfully converted hocr to bbox app=kis.ai input=notreally.hocr output=notreally.bbox words=1
{
"pages": [
{
"number": 1,
"width": 0,
"height": 0,
"words": [
{
"text": "Invoice Number INV-2026-0431\nTotal $1,250.00",
"box": { "x0": 0, "y0": 0, "x1": 0, "y1": 0 },
"confidence": 0,
"page": 1
}
]
}
]
}

A page whose ocr_page title carries no bbox, with the words themselves intact. nopage.hocr is the second line of the invoice with the page bbox deleted:

<html><body>
<div class="ocr_page" id="page_1" title='image "nopage.png"; ppageno 0'>
<span class="ocr_line" title="bbox 300 620 1610 680">
<span class="ocrx_word" title="bbox 300 620 520 680; x_wconf 95">Total</span>
<span class="ocrx_word" title="bbox 1200 620 1610 680; x_wconf 91">$1,250.00</span>
</span>
</div>
</body></html>
Terminal window
kis hocr2bbox -i nopage.hocr -v -l info
2026-07-27T17:34:50+05:30 WRN parsing completed with warnings app=kis.ai input=nopage.hocr warningCount=1
2026-07-27T17:34:50+05:30 WRN hocr parsing warning app=kis.ai context="page element has no bbox" element=ocr_page text= type=missing_bbox
2026-07-27T17:34:50+05:30 INF successfully converted hocr to bbox app=kis.ai input=nopage.hocr output=nopage.bbox words=2

width and height come out 0. Anchor queries still work, because analysis derives the page size from the words’ own extent — but that under-estimates the physical page, so every percentage region is over-scaled, and region and layout detection substitutes a fixed 612 × 792 instead. Fix the recogniser output rather than compensating in rules.

  1. words=N in the conversion log. Zero or one on a real page means the parse failed open. Add -v -l info and read the warnings.
  2. width and height on each page. Zero means the page declaration was missing or malformed.
  3. kis bbox phrase -p "<your anchor>". If the anchor is not found, no rule using it can work. Try --case-insensitive and --fuzzy before changing the rule set.
  4. The match box in that output. Word merging may have glued your anchor to a neighbour, or split it; the box tells you which block a rule will actually resolve.
  5. kis ocr page --history --audit. History shows which rule wrote which value; audit shows which rules fired at all.
What you seeCauseFix
no prefixes resolved (...)A .json exists without its .bbox, or the other way roundBoth files must share a basename
... the when raised an error. got non existent key bboxThe run carried no documentkis ocr always injects one; kis rules needs -b or --with-bbox, and an HTTP request needs a bbox object
... got rule engine execute panic on rule <name> ! recovered : runtime error: invalid memory address or nil pointer dereferenceA method was called on bbox.Get("<wrong name>") in a thenMatch the document name exactly, and guard with bbox.Has
... error while evaluating rule <name> ! recovered : runtime error: invalid memory address or nil pointer dereferenceThe same nil call, but in a whenSame fix; only the wording differs by clause
An empty result and no error at allEvery when was guarded with bbox.Has and the document name was wrongThe guard is false, so no rule fires — check the name against --bbox-name
parsing <prefix>.bbox: invalid character ...The .bbox is not JSON — a failed or truncated conversionRe-run the conversion and check the word count in its info log

Hold the nil-pointer rows and the empty-result row together: guarding with bbox.Has turns a crash into silence. A run that returns {} with no error is the expected shape of a wrong document name, not evidence that the document was empty.

One failed rule aborts the entire execution and discards everything already collected, so a single unguarded call destroys the output of every rule that fired before it. Troubleshooting walks the rest.