Document Structure¶
An OcrDocument is pages, lines and boxes. DocumentStructure.reconstruct turns that
geometry back into semantics: headings and paragraphs.
import io.github.andrewmalitchuk.uncial.structure.source.reconstruction.DocumentStructure
val blocks = DocumentStructure.reconstruct(document)
That is the whole API surface for the common case. blocks is a List<DocBlock> in reading
order, empty when the document has no non-blank text.
It is not run for you¶
reconstruct is a pure function the caller invokes explicitly. extract does not call it,
and OcrDocument does not carry blocks. That is deliberate: it lives in its own module
(uncial-structure), it is pure commonMain with no platform dependencies and no coroutines,
and a caller who only wants raw text never pays for it.
Run it off the main thread for anything large
Reconstruction walks every line of the document — twice, once to find repeating page furniture and once to build blocks. On a 300-page scan that is real work. It is a pure function with no suspension points of its own, so moving it off the main thread is the caller's job:
In Swift, a nonisolated static func on a @MainActor type runs on the concurrent
executor rather than inheriting the actor:
DocBlock¶
DocBlock is a sealed interface with a single shared property, text, and two
implementations today:
import io.github.andrewmalitchuk.uncial.model.source.structure.DocBlock
blocks.forEach { block ->
when (block) {
is DocBlock.Heading -> renderHeading(block.level, block.text)
is DocBlock.Paragraph -> renderParagraph(block.text)
else -> renderParagraph(block.text)
}
}
DocBlock.Heading(level, text) — level is 1 for a top-level heading and 2 for a
subheading, derived from how far the line's type size exceeds the document's median. It is not
read from any document outline; a scanned page has none. The constructor requires level >= 1.
DocBlock.Paragraph(text) — a body paragraph, reflowed from one or more recognized lines.
Lines are joined and end-of-line hyphenation is resolved, so сло- + во becomes слово.
Always give the when an else
This hierarchy is expected to grow — lists, tables and multi-column handling each add a
subtype in a minor release. An exhaustive when today is a broken build after a minor
upgrade, even where the compiler currently accepts it.
How headings are found¶
Heading detection is relative to the document's own median type size, not to any absolute point size. A technical manual set in 11 pt with 12 pt headings and a novel set in 10 pt with 18 pt headings are both handled by the same ratios.
The median is taken over every non-blank line in the document. A line is a heading when:
- its
fontSizeis at leastheadingRatio× the median, and - it is no longer than
maxHeadingWordswords — a whole paragraph set in large type is still a paragraph.
It is level 1 when the size also reaches h1Ratio × the median, level 2 otherwise.
Where fontSize comes from
For the digital text layer it is a real font metric. For OCR it is a proxy derived from the line's height. The heuristics only ever compare it against the document's own median, so the proxy is good enough — but it is not a number to show a user.
Lines whose size is 0 or not finite do not vote in the median and are never headings.
PDFBox reports 0 pt for Type3 fonts and degenerate text matrices, and letting those count
would drag the median below the body size and turn ordinary prose into headings. If a
document reports no usable type sizes at all, the median is 0 and everything is a
paragraph — which is the honest answer, not a failure.
Paragraph breaks¶
Within a page, a vertical gap larger than paragraphGapRatio × the line's own height starts a
new paragraph. Across a page break the previous line's position says nothing, so a paragraph is
assumed to continue — the right default for books, where paragraphs routinely straddle the page
turn.
Lines are processed in reading order (top to bottom, then left to right) rather than in the order the engine produced them; Vision in particular reorders by confidence.
Running headers, footers and page numbers¶
By default, page furniture is dropped. Two rules do it, and both answer to dropChrome:
- Repetition. The first and last line of each page are keyed by their text with digits
normalized away, so
Розділ 3 — с. 41andРозділ 3 — с. 42count as the same header. A key appearing on at leastchromeFrequencyof the pages — and at least twice regardless — is treated as chrome. Detection needs at leastminPagesForChromeDetectionpages before repetition means anything. - Lone page numbers. A line consisting only of digits and whitespace is a page number wherever it sits, with no page-count requirement.
A heading normally beats chrome, so a book whose pages open with Розділ 3, Розділ 4, … keeps
its chapter titles even though they collapse to one normalized key. The exception is a repeat
whose raw text is identical every time — ІСТОРІЯ УКРАЇНИ atop all 300 pages. That is a
running head, and setting it in display type does not make it content, so it is dropped rather
than emitted as a heading once per page.
To keep everything, use the preset:
import io.github.andrewmalitchuk.uncial.structure.source.options.StructureOptions
val blocks = DocumentStructure.reconstruct(document, StructureOptions.KeepChrome)
KeepChrome is StructureOptions(dropChrome = false). It switches off both rules — a
dictionary's guide words and a numbered index are content, and an option called Keep that
silently ate the page numbers would be a lie.
StructureOptions¶
val blocks = DocumentStructure.reconstruct(
document,
StructureOptions(headingRatio = 1.15f, maxHeadingWords = 12),
)
| Field | Type | Default | What it does |
|---|---|---|---|
headingRatio |
Float |
1.25f |
A line whose type size is at least this multiple of the document's median is a heading. |
h1Ratio |
Float |
1.6f |
A heading at least this large is level 1 rather than level 2. |
paragraphGapRatio |
Float |
1.2f |
A vertical gap larger than this multiple of the line's own height starts a new paragraph. |
chromeFrequency |
Float |
0.4f |
A repeating first/last line appearing on at least this fraction of pages is a running header or footer. |
maxHeadingWords |
Int |
20 |
A longer line is prose in large type, not a heading. |
minPagesForChromeDetection |
Int |
4 |
Chrome detection needs at least this many pages. |
dropChrome |
Boolean |
true |
Whether page furniture — running headers, footers and lone page numbers — is removed at all. |
Validation, checked in the constructor and thrown as IllegalArgumentException:
headingRatio > 1h1Ratio >= headingRatioparagraphGapRatio > 0chromeFrequencyin0f..1fmaxHeadingWords > 0
Two presets are provided: StructureOptions.Default (the defaults above, tuned on Ukrainian
scanned books) and StructureOptions.KeepChrome.
Tuning¶
Body text is coming back as headings — raise headingRatio. This is the usual symptom of a
document with an unusually flat type hierarchy.
Headings are being missed — lower headingRatio toward 1.1f. A manual with 11 pt body and
12 pt headings needs about 1.05–1.1; 1.25 will never fire.
Everything is level 2 — lower h1Ratio. It must stay at or above headingRatio.
Paragraphs run together — lower paragraphGapRatio. Every line is its own paragraph —
raise it; tightly leaded scans need more.
Running headers survive — lower chromeFrequency, or check that the document has at least
minPagesForChromeDetection pages. Real content is disappearing from page edges — raise
chromeFrequency, or switch to KeepChrome.
What it assumes¶
Single-column, reflowable prose. Multi-column layouts, tables and lists are not handled —
they are the reason DocBlock is expected to grow. Given a two-column scan, reconstruction
interleaves the columns, because it sorts by vertical position.