165 lines
6.1 KiB
Kotlin
165 lines
6.1 KiB
Kotlin
// Copyright 2026 Umbra Tools (umbratools.com)
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package com.bm.pagescribe.ocr
|
|
|
|
import kotlin.math.max
|
|
|
|
/**
|
|
* Turns a page's OCR lines into flowing text.
|
|
*
|
|
* OCR hands back one entry per *visual* line, but a book page is paragraphs.
|
|
* The join rules below are the standard ones:
|
|
*
|
|
* - a line indented from the left margin starts a paragraph,
|
|
* - a line that stops well short of the right margin ends one,
|
|
* - an unusually tall gap means a section break,
|
|
* - three or more consecutive short lines are verse (or a list) and keep their
|
|
* line breaks instead of being folded into one paragraph,
|
|
* - a word split with a hyphen at the line end is glued back together.
|
|
*/
|
|
object ParagraphReflow {
|
|
|
|
/** A line this much narrower than the body is not body text. */
|
|
private const val SHORT_LINE = 0.72
|
|
|
|
/** End-of-paragraph shortfall, as a fraction of the text block width. */
|
|
private const val SHORTFALL = 0.15
|
|
|
|
/** Gap that signals a break, as a multiple of the normal line gap. */
|
|
private const val SECTION_GAP = 2.2
|
|
|
|
/** First-line indent, as a fraction of the body line height. */
|
|
private const val INDENT_FACTOR = 0.7
|
|
|
|
/** Minimum verse run before line breaks are preserved. */
|
|
private const val VERSE_RUN = 3
|
|
|
|
private const val NON_BREAKING_SPACE = '\u00A0'
|
|
private const val FIGURE_SPACE = '\u2007'
|
|
private const val SOFT_HYPHEN = '\u00AD'
|
|
|
|
private val NO_SPACE_BEFORE = charArrayOf(',', '.', ';', ':', '!', '?', ')', ']')
|
|
|
|
fun toText(lines: List<OcrLine>): String {
|
|
if (lines.isEmpty()) return ""
|
|
val ordered = lines.sortedWith(compareBy({ it.centerY }, { it.left }))
|
|
if (ordered.size == 1) return normalize(ordered.first().text)
|
|
|
|
val bodyWidth = ordered.filter { it.trimmed.length >= 15 }.map { it.width }
|
|
.medianInt(ordered.map { it.width }.medianInt(1.0))
|
|
.coerceAtLeast(1.0)
|
|
val bodyHeight = ordered.map { it.height }.medianInt(1.0).coerceAtLeast(1.0)
|
|
val textLeft = ordered.minOf { it.left }
|
|
val textRight = ordered.maxOf { it.right }
|
|
val blockWidth = (textRight - textLeft).coerceAtLeast(1)
|
|
|
|
val gaps = ArrayList<Double>(ordered.size)
|
|
for (i in 1 until ordered.size) {
|
|
val gap = ordered[i].top - ordered[i - 1].bottom
|
|
if (gap > 0) gaps.add(gap.toDouble())
|
|
}
|
|
val medianGap = gaps.medianDouble(1.0).coerceAtLeast(1.0)
|
|
|
|
val indentThreshold = max(6.0, INDENT_FACTOR * bodyHeight)
|
|
val shortfallThreshold = SHORTFALL * blockWidth
|
|
val short = BooleanArray(ordered.size) { ordered[it].width <= SHORT_LINE * bodyWidth }
|
|
val verse = verseBlocks(short)
|
|
|
|
val out = StringBuilder()
|
|
for (i in ordered.indices) {
|
|
val line = ordered[i]
|
|
val text = normalize(line.text)
|
|
if (i == 0) {
|
|
out.append(text)
|
|
continue
|
|
}
|
|
val previousText = normalize(ordered[i - 1].text)
|
|
val previous = ordered[i - 1]
|
|
|
|
val separator = when {
|
|
verse[i] && verse[i - 1] -> "\n"
|
|
verse[i] || verse[i - 1] -> "\n\n"
|
|
line.left - textLeft >= indentThreshold -> "\n\n"
|
|
textRight - previous.right >= shortfallThreshold -> "\n\n"
|
|
line.top - previous.bottom >= SECTION_GAP * medianGap -> "\n\n"
|
|
else -> ""
|
|
}
|
|
|
|
if (separator.isNotEmpty()) {
|
|
out.append(separator).append(text)
|
|
continue
|
|
}
|
|
if (text.isEmpty()) continue
|
|
|
|
// The previous line is already in [out], so only the join is added:
|
|
// glue a hyphen (dropping the one already written), keep punctuation
|
|
// tight, otherwise insert a single space.
|
|
val lastChar = previousText.lastOrNull()
|
|
if (lastChar == '-' || lastChar?.code == SOFT_HYPHEN.code) {
|
|
if (text.first().isLowerCase()) {
|
|
out.setLength(out.length - 1)
|
|
out.append(text)
|
|
} else {
|
|
out.append(text)
|
|
}
|
|
} else if (text.first() in NO_SPACE_BEFORE) {
|
|
out.append(text)
|
|
} else {
|
|
out.append(' ').append(text)
|
|
}
|
|
}
|
|
return out.toString()
|
|
}
|
|
|
|
/**
|
|
* How to join page N with page N+1 when the whole book is assembled: prose
|
|
* that runs off the bottom of a page continues on the next without becoming
|
|
* a new paragraph.
|
|
*/
|
|
fun joinPages(previous: String, next: String): String {
|
|
val left = previous.trimEnd()
|
|
val right = next.trimStart()
|
|
if (left.isEmpty()) return right
|
|
if (right.isEmpty()) return left
|
|
|
|
val midSentence = left.lastOrNull()?.let { !it.isTerminal() } ?: false
|
|
val startsLower = right.first().isLowerCase()
|
|
if (!midSentence || !startsLower) return "$left\n\n$right"
|
|
|
|
if (left.endsWith('-')) return left.dropLast(1) + right
|
|
return "$left $right"
|
|
}
|
|
|
|
private fun Char.isTerminal(): Boolean =
|
|
this == '.' || this == '!' || this == '?' || this == ':' ||
|
|
this == '"' || this == '\'' || this == '\u2019' || this == '\u201D' ||
|
|
this == ')' || this == ']' || this == '\u2026'
|
|
|
|
/** Marks lines that sit inside a run of at least [VERSE_RUN] short lines. */
|
|
private fun verseBlocks(short: BooleanArray): BooleanArray {
|
|
val verse = BooleanArray(short.size)
|
|
var i = 0
|
|
while (i < short.size) {
|
|
if (!short[i]) {
|
|
i++
|
|
continue
|
|
}
|
|
var end = i
|
|
while (end < short.size && short[end]) end++
|
|
if (end - i >= VERSE_RUN) {
|
|
for (k in i until end) verse[k] = true
|
|
}
|
|
i = end
|
|
}
|
|
return verse
|
|
}
|
|
|
|
/** Collapses the stray double spaces and exotic spaces OCR likes to emit. */
|
|
private fun normalize(text: String): String =
|
|
text.replace(NON_BREAKING_SPACE, ' ')
|
|
.replace(FIGURE_SPACE, ' ')
|
|
.trim()
|
|
.replace(Regex(" {2,}"), " ")
|
|
}
|