Skip to content
Talk to our solutions team

The document model

Every document feature in the Rules block — field extraction, tables, checkboxes, snippets, fuzzy matching, the rule-callable document functions — reads one spatial model: a stack of rectangles carrying text. This page is the type reference for that stack. Get it right and the rest of the document pages are mechanical.

Two facts govern everything below.

  1. Coordinates are raw OCR units with a top-left origin. Nothing is normalised to 0..1, nothing is rescaled per page. A 300-DPI letter scan is 2550 × 3300 units.
  2. Nothing is populated until analysis runs. Loading a document copies the pages and nothing else; every query answers empty — not an error — until analysis has filled the rest in. Both surfaces analyse for you, once, before the first rule is evaluated.
LayerTypesProduced byHolds
GeometryBox, Pointa rule, or any layer abovepure rectangles, no text
OCRWord, Line, Paragraph, Page, Documentingest (hOCR, JSON, converters)what the OCR engine emitted, unchanged
Text reconstructionBlock, Line, DocStatsAnalyze()words glued back into blocks and lines
AnalysisDocumentAnalyze()blocks, lines, tables, fields, checkboxes, layouts, fonts, spatial index

Each layer is derived from the one below it. There is no glyph or character level: Word is the atom.

type Box struct {
X0 float64 `json:"x0"` // top-left X
Y0 float64 `json:"y0"` // top-left Y
X1 float64 `json:"x1"` // bottom-right X
Y1 float64 `json:"y1"` // bottom-right Y
}
type Point struct{ X, Y float64 }

A rule names a rectangle through the bbox.Box and bbox.GeoBox factories, which take the four corners as float64. The rule language has no struct literals, so those factories are the only way to construct one — see Documents in rules.

Every word, block, line, table, region and extraction result carries exactly one of these. The box has no type tag, no text and no page number; those live on the types that embed it.

GroupMethods
SizeWidth(), Height(), Area()
CentresCenter() Point, CenterX(), CenterY(), Baseline()
CornersTopLeft(), TopRight(), BottomLeft(), BottomRight()
EdgesLeft(), Right(), Top(), Bottom()
ContainmentContains(Point), ContainsBox(Box), Intersects(Box)
CombinationIntersection(Box), Union(Box), Expand(margin)
ValidityIsZero(), IsValid()

Package-level: UnionAll([]Box) Box, Distance(Point, Point) float64, BoxDistance(Box, Box) float64.

FunctionReturns
HorizontalOverlap(a, b) / VerticalOverlap(a, b)overlap extent in units
HorizontalGap(a, b) / VerticalGap(a, b)gap in units (0 when overlapping)
IsHorizontallyAligned(a, b, tolerancePct)same visual row
IsVerticallyAligned(a, b, AlignType, tolerancePct)shared edge or centre
IsSameLine(a, b, baselineTolerance, maxGapMultiplier, medianCharWidth)same line and close enough
RelativePosition(a, b) Directiondominant direction of b from a
IsDirectlyRight / IsDirectlyLeft / IsDirectlyBelow / IsDirectlyAbovedirection with alignment
OverlapRatio(a, b), IoU(a, b)0..1 overlap fractions

AlignType is one of AlignLeft, AlignRight, AlignCenterH, AlignTop, AlignBottom, AlignCenterV, AlignBaseline.

IsHorizontallyAligned is the workhorse: tolerance is a fraction of the smaller box height, and the test passes on either a baseline match or a vertical-centre match. Word merging, line formation and same-row detection all funnel through it.

PropertyValue
Origintop-left of the page
Y directionincreases downward
Unitswhatever the producer emitted — in practice image pixels at scan resolution
Normalisationnone
Page numbering1-indexed, assigned in document order

Because units are resolution-dependent, a hard-coded pixel threshold in a rule breaks the moment the scan resolution changes. Two mechanisms exist to avoid that:

  • Percentage regions. InRegionPct(x0Pct, y0Pct, x1Pct, y1Pct) takes 0..100 and multiplies by Stats.PageWidth / Stats.PageHeight. doc.InRegionPct(0, 0, 40, 8) is the top-left 40% wide, top 8% tall band. InRegion(x0, y0, x1, y1) takes raw units.
  • Median-relative thresholds. Every internal threshold is a multiple of a DocStats median (character width, line height, word gap, line gap) rather than an absolute count.

A faithful record of the producer’s output. These are the types the on-disk document JSON serialises to, and the types every ingest path produces.

type Word struct {
Text string `json:"text"`
Box geometry.Box `json:"box"`
Confidence float64 `json:"confidence"` // normalised to 0..1
Page int `json:"page"`
LineID int `json:"line_id,omitempty"`
}
type Line struct {
ID int `json:"id"`
Text string `json:"text"`
Box geometry.Box `json:"box"`
Confidence float64 `json:"confidence,omitempty"`
WordStart int `json:"word_start"` // half-open range into Page.Words
WordEnd int `json:"word_end"`
}
type Paragraph struct {
ID int `json:"id"`
Text string `json:"text"`
Box geometry.Box `json:"box"`
Confidence float64 `json:"confidence,omitempty"`
LineStart int `json:"line_start"` // half-open range into Page.Lines
LineEnd int `json:"line_end"`
}
type Page struct {
Number int `json:"number"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Words []Word `json:"words"`
Lines []Line `json:"lines,omitempty"`
Paragraphs []Paragraph `json:"paragraphs,omitempty"`
}
type Document struct {
Pages []Page `json:"pages"`
}

Line.Text and Paragraph.Text are the joined member text (words by space, lines by newline), the box is the union of member boxes, and confidence is the arithmetic mean of member confidences. NewWord(text, x0, y0, x1, y1, confidence, page) constructs a word; Word.EstimateCharWidth() returns Box.Width() / len(Text).

Document methods at this layer: GetPage(pageNum int) []Word, AllWords() []Word, PageCount() int, UnescapeText(), CleanDocument(), CleanDocumentWithOptions(opts).

{
"pages": [
{
"number": 1,
"width": 2550,
"height": 3300,
"words": [
{
"text": "HEADER",
"box": { "x0": 100, "y0": 100, "x1": 400, "y1": 160 },
"confidence": 0.95,
"page": 1,
"line_id": 1
}
],
"lines": [
{
"id": 1,
"text": "HEADER",
"box": { "x0": 100, "y0": 100, "x1": 400, "y1": 160 },
"confidence": 0.95,
"word_start": 0,
"word_end": 1
}
]
}
]
}

Field names are exactly the struct JSON tags. lines and paragraphs are omitted when empty. This is the shape written to .bbox files, read back by every rule-execution command, and sent inline as document input on POST /rule.

Analyze() rebuilds words into blocks and blocks into lines.

type Block struct {
Text string
Box geometry.Box
Words []ocr.Word // the source words, retained for provenance
Confidence float64
Page int
LineID int // 0 when the producer supplied no line grouping
IsMerged bool
MergeScore confidence.Score // confidence that the merge was correct
}
type Line struct {
Blocks []Block
Box geometry.Box
CachedText string
Page int
IsWrapped bool
Confidence float64
FormationScore confidence.Score
}
TypeMethods
BlockGetPage(), FontSizePixels() (box height), FontSizePoints(dpi), CharWidthPixels()
LineGetPage(), FontSizePixels() (median of member blocks), FontSizePoints(dpi), Text(), AverageConfidence()

FontSizePoints converts as height * 72 / dpi, divided by a fixed 0.85 ascender ratio. A non-positive DPI falls back to 300.

MergeWords sorts words into reading order, then walks adjacent pairs. Two words join the same block when they are horizontally aligned and the horizontal gap is below MedianCharWidth * GapThresholdMultiplier (default 0.5; a 5.0-unit fallback applies when the statistic is unusable). With pattern merging enabled, a gap up to three times the threshold still merges when the concatenation looks like a partial pattern — a split social-security or phone number, for example.

This is why block text can differ from the raw OCR tokens, and why Block.Words is retained.

The word sort is deliberately band-anchored: words are ordered by centre Y, then partitioned into line bands by testing each word against the band’s first word only, then sorted by band and left edge. A naive “same line → by X, else by Y” comparator is not a strict weak ordering (line alignment is not transitive on skewed scans) and produced different block segmentation run to run for the same page.

If any block carries a non-zero LineID, blocks are grouped by (page, LineID) and the resulting lines sorted by page then centre Y. Otherwise lines are formed geometrically: sort by centre Y, then chain blocks into a line while each is horizontally aligned with the previous block at 0.5 tolerance. Members are then sorted left-to-right, boxes unioned, text joined with spaces, and a formation confidence computed from OCR confidence, vertical-alignment spread and gap variance.

IsWrapped marks a visual continuation of the previous line. It is set when the line is indented past twice the median character width or the vertical gap is under 0.7 of the median line gap, and the line does not look like a form label.

type DocStats struct {
MedianCharWidth float64
MedianLineHeight float64
MedianWordGap float64
MedianLineGap float64
PageWidth float64
PageHeight float64
PageDimsDerived bool
}

The four medians are computed from the words alone. Page dimensions are seeded from the page declaration.

When a page declares no width and height, they are derived from the words’ bounding extent and PageDimsDerived is set to true.

{
"pages": [
{
"number": 1,
"words": [
{ "text": "HEADER", "box": { "x0": 100, "y0": 100, "x1": 400, "y1": 160 }, "page": 1 },
{ "text": "body", "box": { "x0": 100, "y0": 1600, "x1": 300, "y1": 1660 }, "page": 1 },
{ "text": "corner", "box": { "x0": 2000, "y0": 3000, "x1": 2400, "y1": 3060 }, "page": 1 }
]
}
]
}

That page declares no width and no height, so analysis derives them: Stats.PageWidth is 2400 (the largest x1 of any word), Stats.PageHeight is 3060 (the largest y1), and Stats.PageDimsDerived is true.

Derived dimensions under-estimate the physical page — the margins past the last word are lost — so percentage regions are slightly over-scaled. That is a bounded approximation instead of every region query matching nothing. Inputs that reach this state: document JSON that omits width and height, hOCR without a page bbox, and malformed page titles.

type Document struct {
Pages []ocr.Page // raw input
Stats text.DocStats // computed statistics
Blocks []text.Block // reconstructed text
Lines []text.Line
Tables []table.Table
Fields []field.Field
Checkboxes []checkbox.Checkbox
CheckboxGroups []checkbox.CheckboxGroup
PageLayouts []*region.PageLayout
SpecialRegions []special.Region
Signatures []special.Region
Handwriting []special.Region
Barcodes []special.Region
FontAnalysis *font.FontAnalysis
// unexported: spatial index, font estimator, reading cache, config, schemas
}

Loading a document fills Pages and nothing else. Every other field is populated by analysis, which runs once, before the first rule is evaluated.

Analyze() resets all derived state, then runs a fixed order. Later stages consume earlier ones.

per page:
ComputeStats → the four medians from this page's words
SeedPageDims → page width/height, with extent fallback
MergeWords → words into blocks
FormLines → blocks into lines
DetectWrappedLines → IsWrapped flags
document-wide:
aggregate statistics
build the spatial index over all blocks
detect tables → Tables
detect fields → Fields
detect checkboxes → Checkboxes, CheckboxGroups
detect page regions → PageLayouts
analyse fonts → FontAnalysis
detect special regions → SpecialRegions, Signatures, Handwriting, Barcodes

Analysis is not configurable — neither the CLI nor rules.svc exposes a flag, a bootstrap key or a request field for any of it. Every document is analysed with these fixed values. They are listed because they explain the results you get, not because you can change them.

SettingFixed valueEffect
Word mergingonAdjacent words are glued into blocks.
Table detectiononPopulates Tables.
Field detectiononPopulates Fields.
Checkbox detectiononPopulates Checkboxes and CheckboxGroups.
Region detectiononPopulates PageLayouts (header, footer, main content, margins, column count).
Special-region detectiononPopulates SpecialRegions and its projections.
Font analysisonCalibrates the font-size estimator; populates FontAnalysis.
Spatial indexonBuilds the R-tree over all blocks that the nearest-block queries read.
DPI300Scan resolution for point-size conversion.
Merge gap threshold0.5Merge gap as a multiple of MedianCharWidth.
Pattern mergingonAllows a merge up to 3× the threshold for partial patterns.

The fixed DPI is what the pipeline’s own point-size conversion uses. FontSizePoints(dpi) takes its own value and falls back to 300 when that value is non-positive, so pass the resolution your scans were produced at: 72 (PDF), 96 (screen), 150 (low-resolution scan), 300 (high-resolution scan).

Reading order is computed lazily on first use, cached on the document, and invalidated by Analyze(). It classifies every block, detects columns, and then sorts.

RegionRule
RegionHeaderblock centre above 12% of page height
RegionFooterblock centre below 90% of page height
RegionLeftMarginblock ends inside the left 10%
RegionRightMarginblock starts past the right 10%
RegionBodyeverything else

The ratio is the block height divided by the document’s median line height. There is no bold detection, no font-name signal and no indentation signal — heading hierarchy comes from box height alone.

ConditionResult
ratio ≥ 1.5 and centre in the top 35% of the pageContentTitle, level 1
ratio ≥ 1.5 elsewhereContentHeading, level 1
ratio ≥ 1.2ContentHeading, level 2
starts with -, a bullet, * or a digit, under 200 charactersContentListItem
ratio < 0.8, below the footer boundaryContentFootnote
ratio < 0.8, elsewhereContentCaption
anything elseContentBody

Blocks under 3 characters, or less than 50% alphanumeric, are forced to ContentBody.

Collect every block’s X0 and X1, sort them, and look for gaps of at least three times the median character width. A gap qualifies only when fewer than 10% of blocks straddle its midpoint. The largest qualifying gaps — at most MaxColumns - 1, with MaxColumns defaulting to 4 — become column boundaries, and each block takes the column whose span contains its centre X.

That 10% straddle tolerance is why a two-column page with one full-width banner still splits correctly, and why a page with a wide table often reports a single column.

ReadingBlock embeds text.Block and adds ContentType, ReadingOrder (0-indexed position in the final sequence), Level (1 = H1, 2 = H2, 0 = body), Column (0-indexed left to right, -1 for single column) and RegionType.

The comparator is a stable sort on, in order:

  1. page number ascending;
  2. region priority — header (0) < body (1) < left/right margin (2) < footer (3);
  3. inside header and footer only, content type ascending (title < heading < body);
  4. column index ascending, when more than one column was detected;
  5. centre Y ascending, when the two boxes are not in the same vertical band;
  6. centre X ascending otherwise.

Two boxes are in the same vertical band when their estimated baselines are within 30% of the smaller box height. This is a comparator, not a layout engine: there is no XY-cut, no whitespace analysis and no Z-order.

The practical consequence is that any multi-line captured value joins row by row, not by left edge:

// label "Address" at (100,100)-(240,130), then a two-line value:
// line 1 indented at x=200: "123" "Main" "St" (y 160-190)
// line 2 starting further LEFT at x=100: "Apt" "4B" (y 200-230)
// capturing below the label yields:
"123 Main St Apt 4B"
// a naive sort by left edge would have produced "Apt 4B 123 Main St"

Title candidates are blocks on the lowest-numbered page whose centre is in the top 35% of the page and which pass the noise filter. Each is scored on normalised height (0.40), a content score (0.30), normalised width (0.10), word count (0.10), character count (0.05) and position (0.05). The winner is then extended left-to-right with every block on the same visual line that is at least 60% as tall, and those texts are joined with spaces.

OutlineEntry{Text, Level, Box, Page, Children, ContentStart, ContentEnd} is built by walking blocks in reading order and pushing or popping a level stack on every title or heading. Section{Heading, Level, Blocks, Subsections, Box, Page} is built in the same pass; content appearing before the first heading lands in an untitled level-0 section. Section.Text() and Section.TextWithOptions(includeHeading, lineSeparator) flatten a section and all of its subsections.

The whole analysis is held in one ReadingResult{Blocks, Outline, Sections, Columns, Title, PageCount}; the document API projects from it.

You never load or analyse a document by hand. You hand one to a rule execution, and it is read, wrapped and analysed before the first rule is evaluated. Both surfaces do it, and both bind the result under a name a rule passes to bbox.Get.

From the CLI, the document is a file beside the facts:

Terminal window
kis ocr page -r classify.grl -i pages/ page1
kis rules -r classify.grl -i pages/ -b invoice=pages/page1.bbox

From rules.svc, the document travels on the same execute-rule request as the facts, under the reserved bbox key — document name to document JSON:

{
"name": "invoice-checks",
"currency": "EUR",
"bbox": {
"invoice": {
"pages": [
{
"number": 1,
"width": 2550,
"height": 3300,
"words": [
{ "text": "HEADER", "box": { "x0": 100, "y0": 100, "x1": 400, "y1": 160 }, "page": 1 },
{ "text": "body", "box": { "x0": 100, "y0": 1600, "x1": 300, "y1": 1660 }, "page": 1 }
]
},
{
"number": 2,
"width": 2550,
"height": 3300,
"words": [
{ "text": "SECOND", "box": { "x0": 100, "y0": 100, "x1": 400, "y1": 160 }, "page": 2 }
]
}
]
}
}
}

name selects the rule set and bbox carries the documents; every other top-level key is a fact. The inner keys of bbox are the document names, so this payload answers bbox.Get("invoice") — the same name -b invoice=path produces on the CLI. Analysis, the bindings and the failure behaviour are identical on both surfaces; the request headers, status codes and error envelope are on the service page.

Whichever surface you use, set page on every word, not just the page’s number — page filtering reads the word’s own field. With the two-page document above, InRegionPct(0, 0, 100, 10) returns the top 10% band (HEADER, not body) and InRegionPct(0, 0, 100, 100) returns the whole page.

The wrapper layer is the stable, serialisable surface: flat structs with no internal package types, safe to hand to rules.

TypeFields
BoundingBoxX0, Y0, X1, Y1 plus Width/Height/Area/CenterX/CenterY/Left/Right/Top/Bottom/IsZero/Contains(x,y)
PointX, Y
TextBlockText, Box, Confidence, Page
TextLineText, Box, Confidence, Page, IsWrapped
ConfidenceValue, Level plus IsHigh/IsMedium/IsLow/Percent
DocumentStatsthe six DocStats numbers
DirectionDirRight, DirLeft, DirAbove, DirBelow
PageLayoutResultPage, Width, Height, ColumnCount, HasHeader, HasFooter, Margins
MarginsResultTop, Bottom, Left, Right
RegionResultType, Box, Page, Text, Confidence
GroupMethods
Blocks and linesGetBlocks(), GetLines(), GetPageBlocks(page), GetPageTextLines(page)
Text searchFindTextBlocks(s), FindAnchorBlocks(s), FindFuzzyBlocks(s, maxDist)
SpatialBlocksInBox(box), BlocksIntersectingBox(box), NearestBlocks(point, k), NearestBlockIn(from, dir, maxDist, margin), BlocksNearPoint(center, radius)
LayoutGetLayout(page), GetHeaderRegion(page), GetFooterRegion(page), GetMainContentRegion(page)
TypographyGetHeadingTextBlocks(), GetBlocksByFontSizeRange(minPt, maxPt)
StatisticsGetDocumentStats()
CheckboxesGetCheckbox(label), GetAllCheckboxes(), GetCheckboxGroupResult(name), GetAllCheckboxGroups()
Special regionsGetSignatures(page), GetAllSignatures(), GetAllHandwriting(), GetAllBarcodes(), GetAllSpecialRegions()

Internal-typed accessors return the analysis types instead of the wrappers: GetPage(page) []text.Block, GetPageLines(page) []text.Line, GetField(label), GetTable(containing), GetPageLayout(page), GetHeader/GetFooter/GetMainContent(page), GetCheckedValue(groupName), Summary().

After(anchor), Below(anchor), Above(anchor), RightOf(anchor), LeftOf(anchor), Near(anchor), QueryBetween(start, end), InRegion(...) and InRegionPct(...) all return a chainable query result. After tries right-of first, then below. This is the anchor-relative half of the model: find a known label, then read what sits in a given direction from it. See Extracting fields.

GroupMethods
TextReadAll(), ReadPage(page), MainContent(), BodyText(), TextUnder(heading), TextBetween(start, end)
ContainmentContains(s), ContainsAll(...), ContainsAny(...), InHeader(s), InFooter(s)
TitleTitle(), HasTitle(s), TitleContains(s), TitleMatches(regex)
StructureOutline(), Headings(level), AllHeadings(), Sections(), Section(heading)
ColumnsColumns(), Column(index), ColumnCount(), IsMultiColumn()
RegionsHeaderText(page), FooterText(page)
ShapePageCount(), IsMultiPage()
CacheInvalidateReadingCache()

Page 0 means “all pages” for HeaderText and FooterText. Headings(0) returns every level. Section(name) falls back to a case-insensitive substring match and recurses into subsections.

Public method parameters and return values on the analysis layer use int64 and float64 exclusively — never int, int32, uint or float32. The rule engine dispatches by reflection and cannot handle other numeric widths. That is why PageCount() int64, Headings(level int64) and NearestBlocks(point, k int64) look over-specified, and why a rule must match every numeric literal to the parameter type — a whole number is int64, a decimal is float64, and nothing converts between them.

FormatAccepted asNotes
Document JSON (.bbox, .json)file path, or inline JSON on the requestThe native format both surfaces consume
hOCR (.hocr, .html, .htm)file pathParsed on load; kis hocr2bbox converts it to .bbox up front
PaddleOCR, Tesseract, Docling outputNo parser reads these. Emit document JSON from your OCR step

Detection is by extension and, for HTML, by content: .json parses as JSON; .hocr parses as hOCR; .html and .htm are sniffed for ocr_page, ocrx_word or ocr_word and parsed as hOCR, else tried as JSON; anything else — .bbox included — is tried as JSON and then as hOCR. Inline document input on POST /rule is already parsed JSON and skips detection entirely.

Text cleanup happens at conversion time, not at load time: kis hocr2bbox --clean normalises unicode, quotes, dashes and ligatures and strips control characters, and --clean-form additionally normalises checkbox and radio indicators. See the flag table below.

LevelRecognised classes
Pageocr_page
Content areaocr_carea, ocr_photo, ocr_graphic, ocr_separator, ocr_noise
Blockocr_par, ocr_textfloat, ocr_caption, ocr_header, ocr_footer
Lineocr_line, ocrx_line, ocr_textline, ocr_header_line
Wordocrx_word, ocr_word
Characterocrx_cinfo, ocr_cinfo

Ingest is a real HTML parse, not a regular expression. Geometry comes from the bbox property of the title attribute; confidence from x_wconf (0–100, divided by 100) or x_conf (0–1, or divided by 100 when greater than 1). Three passes run on every page — paragraphs first (consuming their line and word subtrees), then bare lines outside any block, then bare words outside both — so a page that mixes structure levels loses nothing. Elements with no bbox inherit the nearest ancestor’s box.

<html><body>
<div class="ocr_page" id="page_1" title='image "page_1.png"; bbox 0 0 2550 3300; ppageno 0'>
<p class="ocr_par" title="bbox 100 100 400 160">
<span class="ocr_line" title="bbox 100 100 400 160">
<span class="ocrx_word" title="bbox 100 100 400 160; x_wconf 95">HEADER</span>
</span>
</p>
</div>
</body></html>

Page dimensions come from the width and height of the page bbox — 2550 × 3300 is US Letter at 300 DPI. x_wconf 95 becomes confidence 0.95.

The title attribute is split on ; honouring double-quoted values (an image path may contain a semicolon), and bbox must match as a whole token followed by whitespace. A malformed bbox yields no box rather than scavenging digits from elsewhere in the title:

bbox 100 100 200 200 -> {100,100,200,200}
image "/tmp/scan.png"; bbox 0 0 2550 3300; ppageno 0 -> {0,0,2550,3300}
x_bboxes 10 5 10 10 20 5 30 10; bbox 100 100 200 200 -> {100,100,200,200}
image "a;bbox 9 9 9 9.png"; bbox 1 2 3 4 -> {1,2,3,4}
image "/tmp/page.png"; ppageno 0 -> no box
bboxes 1 2 3 4 -> no box
bbox a b c d; ppageno 7 -> no box

Lossy ingest is silent by default. Warnings are collected only in verbose mode, and the non-verbose loaders discard the parse result. See OCR ingest for the warning types and the verbose validation pass.

Terminal window
kis hocr2bbox -i page1.hocr
kis hocr2bbox -i page1.hocr -o page1.bbox -v
kis hocr2bbox -i ./hocr -o ./bbox -w 8 --clean-form
FlagDefaultMeaning
--input, -isource hOCR file, or a directory (globs *.hocr)
--output, -oinput path with the extension replaced by .bboxdestination file or directory
--workers, -w4parallel workers in directory mode
--verbose, -vfalselog every parse warning plus a missed-content summary
--cleanfalsenormalise unicode, quotes, dashes, ligatures; strip garbage and control characters; fix common misreads
--clean-formfalse--clean plus normalising checkbox and radio indicators to ASCII [ ] [x] ( ) (x); wins over --clean

Multi-file loads concatenate all pages into one document and renumber pages sequentially from 1, updating each word’s page field. A page number is therefore a position in the merge order, never a source-document page identifier.

A rule reaches documents through a named registry: every document supplied with the execution — by the CLI from a file, by rules.svc from the request — is loaded and analysed before the rules run, and rules fetch them by name. Because a rule cannot construct a struct, the document bindings supply value factories for boxes, points and directions. The full surface, the naming rules and the per-execution isolation guarantees are on Documents in rules.