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:
@@ -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 }
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
241
app/src/test/java/com/bm/pagescribe/book/BookTest.kt
Normal file
241
app/src/test/java/com/bm/pagescribe/book/BookTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
104
app/src/test/java/com/bm/pagescribe/ocr/CaptionFilterTest.kt
Normal file
104
app/src/test/java/com/bm/pagescribe/ocr/CaptionFilterTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
166
app/src/test/java/com/bm/pagescribe/ocr/ImageArchiverTest.kt
Normal file
166
app/src/test/java/com/bm/pagescribe/ocr/ImageArchiverTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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."))
|
||||
}
|
||||
}
|
||||
@@ -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") })
|
||||
}
|
||||
}
|
||||
162
app/src/test/java/com/bm/pagescribe/ocr/ParagraphReflowTest.kt
Normal file
162
app/src/test/java/com/bm/pagescribe/ocr/ParagraphReflowTest.kt
Normal 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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
101
app/src/test/java/com/bm/pagescribe/util/BitmapsTest.kt
Normal file
101
app/src/test/java/com/bm/pagescribe/util/BitmapsTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user