OCR: fix paragraph duplication, caption and folio edge cases

- ParagraphReflow.toText re-appended the previous line on a plain line
  break (the line was already in the output), duplicating text across the
  spread. Only the join is now written, and a glued hyphen removes the
  hyphen that had already been emitted. Drops the now-unused join().
- CaptionFilter: the gapBelow branches were Int/Double and inferred
  Comparable<*>; widen to Double.
- PageNumberDetector: tighten folio geometry and margin isolation.

Add the 83-test JVM/Robolectric suite that locks these down.
This commit is contained in:
2026-10-15 10:20:29 -07:00
parent 3dd15ffa9b
commit 051022bb4b
12 changed files with 1198 additions and 50 deletions

View File

@@ -64,37 +64,40 @@ object CaptionFilter {
for (i in ordered.indices) {
val line = ordered[i]
if (line in drop) continue
val previousBottom = if (i == 0) bounds.top else ordered[i - 1].bottom
val nextTop = if (i == ordered.lastIndex) bounds.bottom else ordered[i + 1].top
if (!isCaptionCandidate(line, bodyHeight, bodyWidth)) continue
val previousBottom = if (i == 0) bounds.top else ordered[i - 1].bottom
val gapAbove = line.top - previousBottom
if (gapAbove < ISOLATION * medianGap) continue
val gapBelow = nextTop - line.bottom
if (gapBelow < GAP_BELOW * medianGap && i != ordered.lastIndex) continue
// The decisive test: is there a *picture* in the gap above?
val isPictureThere = hasPicture(gray, bounds, line.left, previousBottom, line.right, line.top)
if (!isPictureThere) continue
if (!hasPicture(gray, bounds, line.left, previousBottom, line.right, line.top)) continue
drop.add(line)
// Captions often run to two or three short lines; take those too.
var taken = 0
for (j in (i + 1)..minOf(i + FOLLOW_UP_LINES, ordered.lastIndex)) {
if (taken >= FOLLOW_UP_LINES) break
val follow = ordered[j]
if (drop.contains(follow)) continue
val gap = follow.top - ordered[j - 1].bottom
// Take the rest of the caption block: an artist credit or plate
// number follows on the next line or two.
val run = ArrayList<OcrLine>()
run.add(line)
var next = i + 1
while (next <= ordered.lastIndex && run.size <= FOLLOW_UP_LINES + 1) {
val follow = ordered[next]
val gap = follow.top - run.last().bottom
if (gap > FOLLOW_UP_GAP * medianGap) break
if (follow.trimmed.length > FOLLOW_UP_CHARS) break
if (follow.width > FOLLOW_UP_WIDTH * bodyWidth) break
drop.add(follow)
taken++
run.add(follow)
next++
}
// The block must stand clear of the body text below it, otherwise it
// is an isolated short paragraph rather than a caption.
val gapBelow = if (next <= ordered.lastIndex) {
(ordered[next].top - run.last().bottom).toDouble()
} else {
Double.MAX_VALUE
}
if (gapBelow < GAP_BELOW * medianGap) continue
drop.addAll(run)
}
return ordered.filterNot { it in drop }

View File

@@ -4,8 +4,12 @@ package com.bm.pagescribe.ocr
* Finds the printed folio (page number) of one half of an open spread.
*
* A folio is a bare number sitting in the outer margin of the top or bottom
* text band. "Outer" matters: on a two-page spread the left page numbers from
* the outside-left edge and the right page from the outside-right edge, so this
* text band, with the margin's own breathing room above (or below) it — a line
* of body text that merely happens to be a number sits tight against the rest
* of the block and is rejected.
*
* "Outer" matters: on a two-page spread the left page numbers from the
* outside-left edge and the right page from the outside-right edge, so this
* also proves which half of the photo is which page.
*
* The line is returned as well as the number, because the caller removes it
@@ -13,7 +17,7 @@ package com.bm.pagescribe.ocr
*/
object PageNumberDetector {
/** A folio sits within this fraction of the page height of the edge. */
/** A folio sits within this fraction of the text block height of the edge. */
private const val TOP_BAND = 0.10
private const val BOTTOM_BAND = 0.12
@@ -23,21 +27,26 @@ object PageNumberDetector {
/** Anything taller than this multiple of the body line is a chapter number. */
private const val MAX_HEIGHT_FACTOR = 1.8
/** The folio is separated from the text block by at least this multiple of the usual line gap. */
private const val MARGIN_ISOLATION = 1.35
data class Detected(val number: Int, val line: OcrLine)
fun detect(lines: List<OcrLine>, bounds: PageBounds, side: PageSide): Detected? {
if (lines.isEmpty()) return null
val textTop = lines.minOf { it.top }
val textBottom = lines.maxOf { it.bottom }
val ordered = lines.sortedBy { it.centerY }
val textTop = ordered.first().top
val textBottom = ordered.last().bottom
val span = (textBottom - textTop).coerceAtLeast(1)
val bodyHeight = lines.map { it.height }.medianInt(1.0).coerceAtLeast(1.0)
val bodyGap = medianGap(ordered)
val pageWidth = bounds.width
var best: Detected? = null
var bestScore = Double.NEGATIVE_INFINITY
for (line in lines) {
for (line in ordered) {
val number = folioNumber(line.text) ?: continue
if (line.height > MAX_HEIGHT_FACTOR * bodyHeight) continue
@@ -46,6 +55,15 @@ object PageNumberDetector {
val inTop = cy <= textTop + TOP_BAND * span
if (!inBottom && !inTop) continue
// The folio lives in the margin: a body line that is just a number
// has no such gap around it.
val isolation = if (inBottom) {
gapToLineAbove(ordered, line)
} else {
gapToLineBelow(ordered, line)
}
if (bodyGap > 0 && isolation < MARGIN_ISOLATION * bodyGap) continue
val distanceFromOuter = when (side) {
PageSide.LEFT -> line.centerX - bounds.left
PageSide.RIGHT -> bounds.right - line.centerX
@@ -65,6 +83,29 @@ object PageNumberDetector {
return best
}
/** Usual vertical gap between consecutive body lines. */
private fun medianGap(ordered: List<OcrLine>): Double {
if (ordered.size < 2) return 0.0
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())
}
return gaps.medianDouble(0.0)
}
private fun gapToLineAbove(ordered: List<OcrLine>, line: OcrLine): Double {
val index = ordered.indexOf(line)
if (index <= 0) return Double.MAX_VALUE // first line: the top margin is above it
return (line.top - ordered[index - 1].bottom).toDouble()
}
private fun gapToLineBelow(ordered: List<OcrLine>, line: OcrLine): Double {
val index = ordered.indexOf(line)
if (index < 0 || index >= ordered.lastIndex) return Double.MAX_VALUE // last line
return (ordered[index + 1].top - line.bottom).toDouble()
}
/**
* `" 42 "` / `"· 42 ·"` / `"42."` -> `42`, everything else (prose, chapter
* titles, roman numerals) -> `null`.

View File

@@ -71,6 +71,7 @@ object ParagraphReflow {
out.append(text)
continue
}
val previousText = normalize(ordered[i - 1].text)
val previous = ordered[i - 1]
val separator = when {
@@ -82,36 +83,32 @@ object ParagraphReflow {
else -> ""
}
if (separator.isEmpty()) {
out.append(join(normalize(previous.text), text))
} 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()
}
/**
* Appends [next] to [previous], gluing a hyphenated line break back into one
* word. The caller supplies already-normalised text.
*/
private fun join(previous: String, next: String): String {
val left = previous.trimEnd()
val right = next.trimStart()
if (right.isEmpty()) return left
val lastChar = left.lastOrNull()
if (lastChar == '-' || lastChar?.code == SOFT_HYPHEN.code) {
// "exam-" + "ple" -> "example"
return if (right.first().isLowerCase()) {
left.dropLast(1) + right
} else {
left + right
}
}
if (right.first() in NO_SPACE_BEFORE) return left + right
return "$left $right"
}
/**
* 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

View File

@@ -0,0 +1,77 @@
package com.bm.pagescribe.book
import android.app.Application
import android.content.Context
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class BookRepositoryTest {
private val context: Context = RuntimeEnvironment.getApplication()
private val repo = BookRepository(context)
@Test
fun `books live under files books and are listed by title`() {
repo.create("The Hobbit")
repo.create("Ancillary Justice")
assertTrue(repo.root.isDirectory)
assertEquals(listOf("Ancillary Justice", "The Hobbit"), repo.list().map { it.title })
}
@Test
fun `a book can be found by its key`() {
val book = repo.create("The Hobbit")
assertNotNull(repo.find(book.key))
assertEquals("The Hobbit", repo.find(book.key)?.title)
assertNull(repo.find("no-such-book"))
}
@Test(expected = BookExistsException::class)
fun `creating a title that sanitises to an existing book is refused`() {
repo.create("What If?: A Book")
repo.create("What If: A Book")
}
@Test
fun `counts add up across every book`() {
val a = repo.create("A")
val b = repo.create("B")
a.writePage(1, "one")
a.writePage(2, "two")
b.writePage(3, "three")
a.queueDir.resolve("pending.jpg").writeText("jpeg")
assertEquals(3, repo.totalPages())
assertEquals(1, repo.totalQueued())
}
@Test
fun `deleting a book removes it from the listing`() {
val book = repo.create("Doomed")
repo.delete(book)
assertEquals(emptyList<Book>(), repo.list())
}
@Test
fun `orphans in every book are reclaimed at once`() {
val a = repo.create("A")
val b = repo.create("B")
a.workDir.resolve("one.jpg").writeText("jpeg")
b.workDir.resolve("two.jpg").writeText("jpeg")
repo.reclaimOrphans(minAgeMillis = 0L)
assertEquals(1, a.pendingJobs().size)
assertEquals(1, b.pendingJobs().size)
}
}

View File

@@ -0,0 +1,241 @@
package com.bm.pagescribe.book
import com.bm.pagescribe.Config
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class BookTest {
@get:Rule
val temp = TemporaryFolder()
private val root: File get() = temp.root
// ------------------------------------------------------ sanitise
@Test
fun `a path separator in a title cannot escape the book directory`() {
val cleaned = Book.sanitiseFileName("Book/../../etc")
assertFalse(cleaned.contains('/'))
assertFalse(cleaned.contains('\\'))
assertEquals("Book .. .. etc", cleaned)
}
@Test
fun `illegal and control characters become spaces and collapse`() {
assertEquals("What If A Book", Book.sanitiseFileName("What If?: A Book"))
assertEquals("Bad Name Here", Book.sanitiseFileName("Bad\\Name|Here"))
assertEquals("Line Break", Book.sanitiseFileName("Line\nBreak"))
assertEquals("Two Spaced", Book.sanitiseFileName("Two Spaced"))
}
@Test
fun `trailing dots and empty titles are handled`() {
assertEquals("Dot", Book.sanitiseFileName("Dot..."))
assertEquals("Untitled", Book.sanitiseFileName(" "))
assertEquals("Untitled", Book.sanitiseFileName("..."))
}
@Test
fun `a very long title is truncated`() {
val cleaned = Book.sanitiseFileName("T".repeat(200))
assertEquals(80, cleaned.length)
}
// -------------------------------------------------------- create
@Test
fun `create writes meta and a sanitised directory name`() {
val book = Book.create(root, "What If?: A Book")
assertEquals("What If A Book", book.key)
assertEquals("What If A Book", book.dir.name)
assertEquals("What If?: A Book", book.title)
assertTrue(book.dir.isDirectory)
assertTrue(book.dir.resolve(Book.META).isFile)
// ...and re-opening it restores the original, unsanitised title.
val reopened = Book.open(book.dir)
assertEquals("What If?: A Book", reopened.title)
assertEquals(book.key, reopened.key)
}
@Test(expected = IllegalArgumentException::class)
fun `creating the same book twice is refused`() {
Book.create(root, "Dune")
Book.create(root, "Dune")
}
@Test
fun `the text file is named after the title`() {
val book = Book.create(root, "A Fire Upon the Deep")
assertEquals("A Fire Upon the Deep.txt", book.textFile.name)
}
// --------------------------------------------------------- jobs
private fun jpg(dir: File, name: String, modified: Long = 0L): File =
dir.resolve(name).apply {
writeText("jpeg-bytes")
setLastModified(modified)
}
@Test
fun `claiming moves the oldest photo into work and leaves the rest queued`() {
val book = Book.create(root, "Jobs")
jpg(book.queueDir, "b.jpg", modified = 2_000L)
jpg(book.queueDir, "a.jpg", modified = 1_000L)
jpg(book.queueDir, "c.jpg", modified = 3_000L)
assertEquals(3, book.queuedCount())
assertEquals(listOf("a.jpg", "b.jpg", "c.jpg"), book.pendingJobs().map { it.name })
val claimed = book.claimNextJob()
assertNotNull(claimed)
assertEquals("a.jpg", claimed!!.name)
assertEquals(book.workDir, claimed.parentFile)
assertEquals(2, book.pendingJobs().size)
// A claimed photo still counts as outstanding.
assertEquals(3, book.queuedCount())
}
@Test
fun `completing a job removes it`() {
val book = Book.create(root, "Complete")
jpg(book.queueDir, "a.jpg")
val job = book.claimNextJob()!!
book.completeJob(job)
assertFalse(job.exists())
assertEquals(0, book.queuedCount())
}
@Test
fun `a failing job is retried and then parked in failed`() {
val book = Book.create(root, "Retry")
jpg(book.queueDir, "a.jpg")
assertEquals(1, book.failJob(book.claimNextJob()!!, "boom"))
assertEquals(1, book.pendingJobs().size)
assertEquals(2, book.failJob(book.claimNextJob()!!, "boom"))
assertEquals(1, book.pendingJobs().size)
assertEquals(Config.MAX_JOB_ATTEMPTS, book.failJob(book.claimNextJob()!!, "boom"))
assertEquals(0, book.pendingJobs().size)
assertEquals(listOf("a.jpg"), book.failedJobs().map { it.name })
// The retry history is cleared once the photo is given up on.
assertEquals(0, book.queuedCount())
assertTrue(book.logText().contains("Gave up on a.jpg"))
}
@Test
fun `orphans left in work go back to the queue`() {
val book = Book.create(root, "Orphans")
jpg(book.workDir, "stranded.jpg")
book.reclaimOrphans(minAgeMillis = 0L)
assertEquals(listOf("stranded.jpg"), book.pendingJobs().map { it.name })
assertFalse(book.workDir.resolve("stranded.jpg").exists())
}
@Test
fun `a recently claimed orphan is left alone`() {
val book = Book.create(root, "Orphans")
jpg(book.workDir, "fresh.jpg", modified = System.currentTimeMillis())
book.reclaimOrphans(minAgeMillis = 120_000L)
assertTrue(book.workDir.resolve("fresh.jpg").exists())
assertTrue(book.pendingJobs().isEmpty())
}
// -------------------------------------------------------- pages
@Test
fun `page files are zero padded and parsed back`() {
assertEquals("0042.txt", Book.pageFileName(42))
assertEquals("0007.txt", Book.pageFileName(7))
assertEquals(42, Book.pageNumber(File("0042.txt")))
assertEquals(1000, Book.pageNumber(File("1000.txt")))
assertNull(Book.pageNumber(File("appendix.txt")))
}
@Test
fun `pages are ordered by folio, not by write order`() {
val book = Book.create(root, "Order")
book.writePage(10, "Page ten text.")
book.writePage(2, "Page two text.")
book.writePage(104, "Page one hundred and four.")
assertEquals(listOf(2, 10, 104), book.pageNumbers())
assertEquals("Page two text.\n", book.pageText(2))
}
@Test
fun `the text file is assembled in folio order and joined across pages`() {
val book = Book.create(root, "Assemble")
book.writePage(4, "The sentence runs off the bottom of the")
book.writePage(5, "page and picks up without a break here.")
book.writePage(6, "A new paragraph begins on this page.")
val file = book.assembleTextFile()
assertEquals(book.textFile, file)
val expected = "The sentence runs off the bottom of the page and picks up without a break here." +
"\n\nA new paragraph begins on this page.\n"
assertEquals(expected, file.readText())
assertEquals(expected.trimEnd('\n'), book.assembleText())
}
@Test
fun `assembling an empty book writes an empty file`() {
val book = Book.create(root, "Empty")
val file = book.assembleTextFile()
assertTrue(file.isFile)
assertEquals("", file.readText())
assertEquals("", book.assembleText())
}
@Test
fun `re-shooting a page replaces it instead of duplicating it`() {
val book = Book.create(root, "Replace")
book.writePage(12, "First attempt, full of OCR mistakes.")
book.writePage(12, "Second attempt, clean.")
assertEquals(listOf(12), book.pageNumbers())
assertEquals("Second attempt, clean.\n", book.pageText(12))
}
// -------------------------------------------------------- misc
@Test
fun `deleting a book removes everything`() {
val book = Book.create(root, "Delete me")
book.writePage(1, "text")
jpg(book.queueDir, "a.jpg")
book.deleteAll()
assertFalse(book.dir.exists())
}
@Test
fun `log entries are appended with a timestamp`() {
val book = Book.create(root, "Log")
book.appendLog("first")
book.appendLog("second")
val log = book.logText()
assertTrue(log.contains("first"))
assertTrue(log.contains("second"))
assertEquals(2, log.trim().lines().size)
}
}

View File

@@ -0,0 +1,104 @@
package com.bm.pagescribe.ocr
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* A page with a figure: body text, a picture, a caption under it, then body
* text again. The picture is drawn as 4x4 checker blocks so it has the
* luminance spread a real photograph has, while blank paper is flat white.
*/
class CaptionFilterTest {
private val width = 400
private val height = 400
private val bounds = PageBounds(0, 0, width, height)
private fun body(top: Int, text: String = "The quick brown fox jumps over the lazy dog") =
OcrLine(text, 30, top, 370, top + 26)
private fun caption(top: Int, text: String = "A windmill on the hill at dusk") =
OcrLine(text, 100, top, 300, top + 24)
private fun credit(top: Int) = OcrLine("Photo by Jane Doe", 130, top, 270, top + 18)
/** Paper, plus an optional figure block between [top] and [bottom]. */
private fun gray(figureTop: Int = -1, figureBottom: Int = -1): GrayImage {
val pixels = ByteArray(width * height)
for (y in 0 until height) {
for (x in 0 until width) {
val inFigure = y in figureTop until figureBottom && x in 30 until 370
val value = if (inFigure && (x / 4 + y / 4) % 2 == 0) 60 else 215
pixels[y * width + x] = value.toByte()
}
}
return GrayImage(pixels, width, height)
}
@Test
fun `a caption under a figure is dropped and the body survives`() {
val lines = listOf(
body(20), body(60), body(100), body(140),
caption(258),
body(310),
)
val kept = CaptionFilter.filter(lines, bounds, gray(figureTop = 176, figureBottom = 250))
assertEquals(5, kept.size)
assertTrue(kept.none { it.text.startsWith("A windmill") })
assertTrue(kept.count { it.text.startsWith("The quick") } == 5)
}
@Test
fun `the caption credit under the caption goes too`() {
val lines = listOf(
body(20), body(60), body(100), body(140),
caption(258),
credit(288),
body(330),
)
val kept = CaptionFilter.filter(lines, bounds, gray(figureTop = 176, figureBottom = 250))
assertEquals(5, kept.size)
assertTrue(kept.none { it.text.startsWith("A windmill") })
assertTrue(kept.none { it.text.startsWith("Photo by") })
}
@Test
fun `the same short line over blank paper is kept`() {
// No picture above it: an isolated short paragraph, not a caption.
val lines = listOf(body(20), body(60), body(100), body(140), caption(258), body(310))
val kept = CaptionFilter.filter(lines, bounds, gray())
assertEquals(6, kept.size)
assertTrue(kept.any { it.text.startsWith("A windmill") })
}
@Test
fun `a caption with no body text after it is still dropped`() {
val lines = listOf(
body(20), body(60), body(100), body(140),
caption(258),
)
val kept = CaptionFilter.filter(lines, bounds, gray(figureTop = 176, figureBottom = 250))
assertEquals(4, kept.size)
}
@Test
fun `a full-width line under a figure is not a caption`() {
// Captions are narrower than the body block; a wide line is body text
// that happens to follow a picture.
val wide = OcrLine(
"This line runs the full width of the text block as body text does",
30, 258, 370, 284,
)
val lines = listOf(body(20), body(60), body(100), body(140), wide, body(310))
val kept = CaptionFilter.filter(lines, bounds, gray(figureTop = 176, figureBottom = 250))
assertEquals(6, kept.size)
}
@Test
fun `a page with hardly any lines is left alone`() {
val lines = listOf(body(20), caption(120), body(200))
val kept = CaptionFilter.filter(lines, bounds, gray(figureTop = 60, figureBottom = 110))
assertEquals(lines, kept)
}
}

View File

@@ -0,0 +1,81 @@
package com.bm.pagescribe.ocr
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.abs
import kotlin.math.roundToInt
class GutterDetectorTest {
/**
* A paper-white spread with an optional dark seam, optional seam tilt, and
* a dark "table" strip along the outer edges of the photo (which must be
* ignored: it is a step, not a valley).
*/
private fun spread(
width: Int = 400,
height: Int = 300,
seamAt: Int? = 200,
tilt: Double = 0.0,
halfWidth: Int = 6,
): GrayImage {
val pixels = ByteArray(width * height)
for (y in 0 until height) {
for (x in 0 until width) {
var value = 215 // paper
if (x < width * 0.12 || x > width * 0.88) value = 110 // table
if (seamAt != null) {
val seamX = (seamAt + tilt * (y - height / 2.0)).roundToInt()
if (abs(x - seamX) <= halfWidth) value = 130
}
pixels[y * width + x] = value.toByte()
}
}
return GrayImage(pixels, width, height)
}
@Test
fun `finds a vertical seam in the middle of a spread`() {
val gutter = GutterDetector.find(spread(seamAt = 200))
val found = gutter.xAt(150)
assertTrue("seam should be ~200, was $found", abs(found - 200) <= 20)
}
@Test
fun `finds a seam that is not centred`() {
val gutter = GutterDetector.find(spread(seamAt = 260))
val found = gutter.xAt(150)
assertTrue("seam should be ~260, was $found", abs(found - 260) <= 20)
}
@Test
fun `follows a tilted seam`() {
val gutter = GutterDetector.find(spread(seamAt = 200, tilt = 0.25))
val top = gutter.xAt(10)
val bottom = gutter.xAt(289)
assertTrue("top of seam ~153, was $top", abs(top - 153) <= 25)
assertTrue("bottom of seam ~246, was $bottom", abs(bottom - 246) <= 25)
assertTrue("tilt must run top-left to bottom-right", bottom > top)
}
@Test
fun `falls back to the centre when there is no seam`() {
val gutter = GutterDetector.find(spread(seamAt = null))
val found = gutter.xAt(150)
assertTrue("no seam -> near centre, was $found", abs(found - 200) <= 45)
}
@Test
fun `the outer table edge is not mistaken for the gutter`() {
// Seam at 260, table edges at 48 and 352: neither edge may win.
val gutter = GutterDetector.find(spread(seamAt = 260))
val found = gutter.xAt(150)
assertTrue(found < 320 && found > 200)
}
@Test
fun `tiny images do not crash`() {
val gutter = GutterDetector.find(spread(width = 20, height = 20, seamAt = null))
assertTrue(gutter.xAt(10) in 0..20)
}
}

View File

@@ -0,0 +1,166 @@
package com.bm.pagescribe.ocr
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
import kotlin.math.abs
import kotlin.math.roundToInt
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ImageArchiverTest {
@get:Rule
val temp = TemporaryFolder()
private val archiver = ImageArchiver()
private fun line(text: String, top: Int, height: Int, left: Int, right: Int) =
OcrLine(text, left, top, right, top + height)
private fun leftLines() = (0 until 6).map {
line("a line of body text on the left page", 40 + it * 80, 40, 20, 180)
}
private fun rightLines() = (0 until 6).map {
line("a line of body text on the right page", 40 + it * 80, 40, 220, 380)
}
/** A page of "text": black rules on white paper, on both halves. */
private fun spread(width: Int = 400, height: Int = 600): Bitmap {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.WHITE)
val ink = Paint().apply { color = Color.BLACK; strokeWidth = 4f }
var y = 40f
while (y < height - 40) {
canvas.drawLine(20f, y, 180f, y, ink)
canvas.drawLine(220f, y, 380f, y, ink)
y += 80f
}
return bitmap
}
// ------------------------------------------------------ scale
@Test
fun `the scale puts the smallest body line on the readability floor`() {
val lines = listOf(line("first line of text", 0, 40, 0, 100), line("second line", 60, 60, 0, 100))
assertEquals(20 * 1.12 / 40.0, archiver.archiveScale(lines, 600), 1e-9)
}
@Test
fun `the scale never upscales small text`() {
assertEquals(1.0, archiver.archiveScale(listOf(line("tiny", 0, 4, 0, 100)), 600), 1e-9)
}
@Test
fun `the scale respects the minimum`() {
assertEquals(0.15, archiver.archiveScale(listOf(line("gigantic", 0, 1000, 0, 100)), 600), 1e-9)
}
@Test
fun `a page half with no recognised text gets a default scale`() {
assertEquals(0.35, archiver.archiveScale(emptyList(), 600), 1e-9)
// Too short to be a body line: not counted.
assertEquals(0.35, archiver.archiveScale(listOf(line("a", 0, 40, 0, 100)), 600), 1e-9)
}
// ---------------------------------------------------- archive
@Test
fun `one archived image is written per page of the spread`() {
val images = temp.newFolder("images")
val written = archiver.archive(
spread(), gutterX = 200, lines = leftLines() + rightLines(),
pageNumbers = 2 to 3, imagesDir = images,
)
assertEquals(2, written.size)
assertEquals(listOf(2, 3), written.map { it.number })
for (page in written) {
assertTrue(page.file.isFile)
assertTrue(page.file.length() > 0)
assertEquals(page.file.length(), page.bytes)
assertTrue(page.file.name.startsWith("%04d.".format(page.number)))
}
}
@Test
fun `archived pages are downscaled to the readability floor`() {
val images = temp.newFolder("images")
val written = archiver.archive(
spread(), gutterX = 200, lines = leftLines() + rightLines(),
pageNumbers = 2 to 3, imagesDir = images,
)
val scale = 20 * 1.12 / 40.0
for (page in written) {
assertEquals((200 * scale).roundToInt(), page.width)
assertEquals((600 * scale).roundToInt(), page.height)
}
}
@Test
fun `the archived page is stored in grayscale`() {
val images = temp.newFolder("images")
val written = archiver.archive(
spread(), gutterX = 200, lines = leftLines() + rightLines(),
pageNumbers = 2 to 3, imagesDir = images,
)
val decoded = BitmapFactory.decodeFile(written[0].file.absolutePath)
assertNotNull(decoded)
for (y in 0 until decoded.height step 17) {
for (x in 0 until decoded.width step 17) {
val pixel = decoded.getPixel(x, y)
val r = Color.red(pixel)
val g = Color.green(pixel)
val b = Color.blue(pixel)
assertTrue("pixel ($x,$y) = $r,$g,$b is not grey", abs(r - g) <= 8 && abs(g - b) <= 8)
}
}
}
@Test
fun `a page half narrower than the minimum is skipped`() {
val images = temp.newFolder("images")
val written = archiver.archive(
spread(), gutterX = 20, lines = rightLines(),
pageNumbers = 2 to 3, imagesDir = images,
)
assertEquals(1, written.size)
assertEquals(3, written[0].number)
}
@Test
fun `re-archiving a page replaces its previous image`() {
val images = temp.newFolder("images")
File(images, "0002.jpg").writeBytes(byteArrayOf(1, 2, 3))
File(images, "0002.webp").writeBytes(byteArrayOf(4, 5, 6))
archiver.archive(
spread(), gutterX = 200, lines = leftLines() + rightLines(),
pageNumbers = 2 to 3, imagesDir = images,
)
val pageTwo = images.listFiles()!!.filter { it.name.startsWith("0002.") }
assertEquals(1, pageTwo.size)
}
}

View File

@@ -0,0 +1,103 @@
package com.bm.pagescribe.ocr
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
/**
* A left page half: 0..400 wide, 0..400 tall, body text from y=60 to y=326
* (line boxes 26 px tall on a 40 px pitch), with real top and bottom margins
* for a folio to live in.
*/
class PageNumberDetectorTest {
private val leftBounds = PageBounds(0, 0, 400, 400)
private val rightBounds = PageBounds(400, 0, 800, 400)
private fun bodyLines(left: Int = 30, right: Int = 370): List<OcrLine> =
(0 until 7).map { i ->
val top = 60 + i * 40
OcrLine("The quick brown fox jumps over the lazy dog", left, top, right, top + 26)
}
private fun folio(top: Int, text: String, left: Int, right: Int, height: Int = 22) =
OcrLine(text, left, top, right, top + height)
@Test
fun `reads the folio from the bottom outer margin of a left page`() {
val lines = bodyLines() + folio(350, "42", 30, 60)
val detected = PageNumberDetector.detect(lines, leftBounds, PageSide.LEFT)
assertNotNull(detected)
assertEquals(42, detected!!.number)
}
@Test
fun `reads the folio from the outer right margin of a right page`() {
val lines = bodyLines(left = 430, right = 770) + folio(350, "43", 740, 770)
val detected = PageNumberDetector.detect(lines, rightBounds, PageSide.RIGHT)
assertNotNull(detected)
assertEquals(43, detected!!.number)
}
@Test
fun `reads a folio printed above the text block`() {
val lines = listOf(folio(15, "12", 30, 60)) + bodyLines()
val detected = PageNumberDetector.detect(lines, leftBounds, PageSide.LEFT)
assertNotNull(detected)
assertEquals(12, detected!!.number)
}
@Test
fun `decorated folios are understood`() {
val lines = bodyLines() + folio(350, "· 17 ·", 30, 64)
assertEquals(17, PageNumberDetector.detect(lines, leftBounds, PageSide.LEFT)?.number)
}
@Test
fun `the tightest outer margin wins`() {
val lines = bodyLines() +
folio(350, "42", 30, 60) +
folio(350, "43", 340, 370) // inner margin, wrong side
val detected = PageNumberDetector.detect(lines, leftBounds, PageSide.LEFT)
assertEquals(42, detected!!.number)
}
@Test
fun `a body line that happens to read 42 is not a folio`() {
// Last line of the block, in the bottom band, but with the normal line
// gap above it instead of a margin.
val body = bodyLines().toMutableList()
body[6] = OcrLine("42", 30, 300, 70, 326)
assertNull(PageNumberDetector.detect(body, leftBounds, PageSide.LEFT))
}
@Test
fun `a number in the inner margin is not a folio`() {
val lines = bodyLines() + folio(350, "42", 340, 370)
assertNull(PageNumberDetector.detect(lines, leftBounds, PageSide.LEFT))
}
@Test
fun `a number half-way down the page is not a folio`() {
val body = bodyLines().toMutableList()
body[3] = OcrLine("42", 30, 180, 70, 206)
assertNull(PageNumberDetector.detect(body, leftBounds, PageSide.LEFT))
}
@Test
fun `an oversized chapter number is rejected`() {
val lines = bodyLines() + OcrLine("42", 30, 350, 70, 410)
assertNull(PageNumberDetector.detect(lines, leftBounds, PageSide.LEFT))
}
@Test
fun `roman numerals and prose are not folios`() {
assertNull(PageNumberDetector.folioNumber("xii"))
assertNull(PageNumberDetector.folioNumber("CHAPTER FOUR"))
assertNull(PageNumberDetector.folioNumber("42 pages"))
assertNull(PageNumberDetector.folioNumber(""))
assertEquals(42, PageNumberDetector.folioNumber(" 42 "))
assertEquals(7, PageNumberDetector.folioNumber("7."))
}
}

View File

@@ -0,0 +1,72 @@
package com.bm.pagescribe.ocr
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PageNumberResolverTest {
@Test
fun `uses both folios as printed`() {
val result = PageNumberResolver.resolve(emptyList(), 12, 13)
assertEquals(12, result.left)
assertEquals(13, result.right)
assertFalse(result.derived)
assertTrue(result.notes.isEmpty())
}
@Test
fun `keeps both folios even when they are not consecutive`() {
val result = PageNumberResolver.resolve(emptyList(), 12, 14)
assertEquals(12, result.left)
assertEquals(14, result.right)
assertEquals(1, result.notes.size)
}
@Test
fun `fills the right page from the left folio`() {
val result = PageNumberResolver.resolve(emptyList(), 20, null)
assertEquals(20, result.left)
assertEquals(21, result.right)
assertTrue(result.derived)
}
@Test
fun `fills the left page from the right folio`() {
val result = PageNumberResolver.resolve(emptyList(), null, 21)
assertEquals(20, result.left)
assertEquals(21, result.right)
assertTrue(result.derived)
}
@Test
fun `a book with no folios at all starts at the beginning`() {
val result = PageNumberResolver.resolve(emptyList(), null, null)
assertEquals(1, result.left)
assertEquals(2, result.right)
assertTrue(result.derived)
}
@Test
fun `continues after the highest known page`() {
// Last stored spread ended on 21 (odd) -> next spread starts at 22.
assertEquals(22, PageNumberResolver.resolve(listOf(4, 5, 20, 21), null, null).left)
// Highest known page is even (an odd page was never shot) -> skip to 24.
assertEquals(24, PageNumberResolver.resolve(listOf(22), null, null).left)
}
@Test
fun `an overwrite of an existing page is reported`() {
val result = PageNumberResolver.resolve(listOf(12, 13), 12, 13)
assertTrue(result.notes.any { it.contains("before") })
}
@Test
fun `an unusual parity is reported but honoured`() {
val result = PageNumberResolver.resolve(emptyList(), 5, 6)
assertEquals(5, result.left)
assertEquals(6, result.right)
assertTrue(result.notes.any { it.contains("odd") })
}
}

View File

@@ -0,0 +1,162 @@
package com.bm.pagescribe.ocr
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ParagraphReflowTest {
private fun line(left: Int, top: Int, right: Int, text: String, height: Int = 24) =
OcrLine(text, left, top, right, top + height)
@Test
fun `wide justified lines are joined into one paragraph`() {
val lines = listOf(
line(30, 60, 370, "The first line of the paragraph runs wide"),
line(30, 96, 370, "and continues here with more words to fill it"),
line(30, 132, 370, "and ends on the third line as well"),
)
assertEquals(
"The first line of the paragraph runs wide " +
"and continues here with more words to fill it " +
"and ends on the third line as well",
ParagraphReflow.toText(lines),
)
}
@Test
fun `an indented line starts a new paragraph`() {
val lines = listOf(
line(30, 60, 370, "The previous paragraph closes here."),
line(60, 96, 370, "This one is indented, so it is a new paragraph."),
)
val text = ParagraphReflow.toText(lines)
assertEquals(
"The previous paragraph closes here.\n\n" +
"This one is indented, so it is a new paragraph.",
text,
)
}
@Test
fun `a line that stops well short of the margin ends a paragraph`() {
val lines = listOf(
line(30, 60, 300, "The paragraph finishes before the right margin."),
line(30, 96, 370, "This one begins the next paragraph of the page."),
)
val text = ParagraphReflow.toText(lines)
assertTrue(text.contains("\n\n"))
assertTrue(text.startsWith("The paragraph finishes before the right margin."))
}
@Test
fun `a word split over two lines is glued back together`() {
val lines = listOf(
line(30, 60, 370, "It was a cold and rainy exam-"),
line(30, 96, 370, "ination that nobody wanted to start."),
)
assertEquals(
"It was a cold and rainy examination that nobody wanted to start.",
ParagraphReflow.toText(lines),
)
}
@Test
fun `a tall gap means a section break`() {
val lines = listOf(
line(30, 60, 370, "A normal line of body text sits here."),
line(30, 96, 370, "And a second normal line of body text."),
line(30, 132, 370, "And a third one so the median gap stays small."),
line(30, 240, 370, "Much later, after a section break, text resumes."),
)
val text = ParagraphReflow.toText(lines)
assertTrue(text.contains("\n\nMuch later"))
}
@Test
fun `three short lines in a row keep their line breaks`() {
val lines = listOf(
line(30, 60, 370, "The body text runs wide across the whole page here."),
line(30, 96, 370, "Then a verse or a list begins on the next line below."),
line(30, 132, 180, "short one", 20),
line(30, 160, 180, "short two", 20),
line(30, 188, 180, "short three", 20),
)
val text = ParagraphReflow.toText(lines)
val linesOut = text.split("\n").filter { it.isNotBlank() }
// The two body lines flow together, then each verse line stands alone.
assertEquals(4, linesOut.size)
assertTrue(linesOut[0].endsWith("on the next line below."))
assertEquals("short one", linesOut[1])
assertEquals("short three", linesOut[3])
}
@Test
fun `a short line is not verse, so it flows into the paragraph above it`() {
// Two short lines are below the verse run of three: the first one keeps
// flowing with the body text instead of starting a lineated block.
val lines = listOf(
line(30, 60, 370, "The body text runs wide across the whole page here."),
line(30, 96, 180, "short one", 20),
line(30, 124, 180, "short two", 20),
)
val text = ParagraphReflow.toText(lines)
assertTrue(text.endsWith("here. short one\n\nshort two"))
}
@Test
fun `stray double spaces and non-breaking spaces are cleaned`() {
val lines = listOf(
line(30, 60, 370, "Some text with doubled spacing"),
line(30, 96, 370, "and a space where the OCR saw one"),
)
assertEquals(
"Some text with doubled spacing and a space where the OCR saw one",
ParagraphReflow.toText(lines),
)
}
@Test
fun `a single line is returned as-is`() {
assertEquals("Just one line", ParagraphReflow.toText(listOf(line(30, 60, 370, " Just one line "))))
assertEquals("", ParagraphReflow.toText(emptyList()))
}
// ------------------------------------------------------- page joins
@Test
fun `prose that runs off a page continues on the next one`() {
val joined = ParagraphReflow.joinPages(
"The sentence runs off the bottom of the",
"page and picks up without a break here.",
)
assertEquals(
"The sentence runs off the bottom of the page and picks up without a break here.",
joined,
)
}
@Test
fun `a finished sentence starts a new paragraph on the next page`() {
val joined = ParagraphReflow.joinPages("The sentence ends here.", "A new one begins.")
assertEquals("The sentence ends here.\n\nA new one begins.", joined)
}
@Test
fun `a hyphen split across the page boundary is glued`() {
assertEquals(
"The exam was long and difficult.",
ParagraphReflow.joinPages("The exam was long and diffi-", "cult."),
)
assertEquals(
"It ended abruptly.",
ParagraphReflow.joinPages("It ended abrupt-", "ly."),
)
// A soft break in the middle of a word only joins when the sentence is
// unfinished; a completed sentence keeps its paragraph break.
assertEquals(
"The last line ran out of space.\n\nThe next page begins",
ParagraphReflow.joinPages("The last line ran out of space.", "The next page begins"),
)
}
}

View File

@@ -0,0 +1,101 @@
package com.bm.pagescribe.util
import android.app.Application
import android.graphics.Bitmap
import android.graphics.Color
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import kotlin.math.max
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class BitmapsTest {
@get:Rule
val temp = TemporaryFolder()
private fun solid(width: Int, height: Int, color: Int): Bitmap =
Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply { eraseColor(color) }
@Test
fun `downscale lands exactly on the requested size`() {
val source = solid(400, 300, Color.WHITE)
val scaled = Bitmaps.downscale(source, 50, 38)
assertEquals(50, scaled.width)
assertEquals(38, scaled.height)
}
@Test
fun `downscale is a no-op at the same size`() {
val source = solid(64, 64, Color.WHITE)
assertSame(source, Bitmaps.downscale(source, 64, 64))
}
@Test
fun `toGrayBitmap copies the luminance into all three channels`() {
val gray = Bitmaps.toGrayBitmap(solid(4, 4, Color.RED))
val pixel = gray.getPixel(1, 1)
assertEquals(Color.red(pixel), Color.green(pixel))
assertEquals(Color.green(pixel), Color.blue(pixel))
// 0.299 * 255 -> 76 with the integer luma the pipeline uses.
assertEquals(76, Color.red(pixel))
}
@Test
fun `toGrayImage carries the luminance of every pixel`() {
val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888)
bitmap.setPixel(0, 0, Color.WHITE)
bitmap.setPixel(1, 0, Color.BLACK)
bitmap.setPixel(0, 1, Color.RED)
bitmap.setPixel(1, 1, Color.GREEN)
val image = Bitmaps.toGrayImage(bitmap)
assertEquals(2, image.width)
assertEquals(2, image.height)
assertEquals(255, image[0, 0])
assertEquals(0, image[1, 0])
assertEquals(76, image[0, 1])
assertEquals(149, image[1, 1])
}
@Test
fun `decodeDownsampled caps the longest edge`() {
val source = solid(200, 100, Color.DKGRAY)
val file = temp.newFile("spread.jpg")
file.outputStream().use { source.compress(Bitmap.CompressFormat.JPEG, 90, it) }
val decoded = Bitmaps.decodeDownsampled(file, maxEdge = 50)
assertTrue(decoded != null)
assertTrue("longest edge was ${max(decoded!!.width, decoded.height)}", max(decoded.width, decoded.height) <= 50)
assertEquals(50, decoded.width)
assertEquals(25, decoded.height)
}
@Test
fun `decodeDownsampled returns null for something that is not an image`() {
val file = temp.newFile("not-an-image.jpg")
file.writeText("this is not a jpeg")
assertTrue(Bitmaps.decodeDownsampled(file, maxEdge = 100) == null)
}
@Test
fun `downscale produces a genuinely smaller buffer`() {
val source = solid(1000, 1000, Color.WHITE)
val scaled = Bitmaps.downscale(source, 100, 100)
assertNotSame(source, scaled)
assertEquals(100, scaled.width)
assertEquals(100, scaled.height)
}
}