Files
PageScribe/app/src/main/java/com/bm/pagescribe/ocr/ParagraphReflow.kt
2026-10-18 19:01:01 -07:00

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,}"), " ")
}