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