Track page coverage from folios and flag pages to re-shoot
The printed page number is read anyway for ordering, so use it to keep count of what has actually been captured. PageCoverage turns the folios in pages/ into a span [low, high] with one state per page: captured, needs re-capture (a page file that exists but is blank - the folio was read, the body was not), or missing (no page file at all). The shelf, book and capture screens surface it, and the book list interleaves the gaps in folio order, so a page that was skipped or came out blank sits exactly where the reader would look for it. A photo holding no text at all has no folio to attribute; it stays in failed/ and is listed separately as unreadable. A one- or two-line half is not a text block, so PageNumberDetector's top/bottom band test could never recognise a folio on the very page this feature cares about - a body that failed to OCR, leaving only its page number. Sparse halves now accept an isolated bare number sitting in the outer margin. 98 unit tests, 0 failures (was 83). Verified end to end on the Pixel 7: "Pages 42-51 - 7 of 10 captured - 3 to shoot", with page 47 flagged for re-capture and 48/49 reported missing.
This commit is contained in:
39
README.md
39
README.md
@@ -18,6 +18,13 @@ one is still being read.
|
||||
- **A text file per book.** Pages are ordered by the folio (printed page
|
||||
number) they carry, not by the order you happened to photograph them. The
|
||||
finished file is `<book title>.txt`.
|
||||
- **Coverage you can see.** Because the folios are read anyway, the app knows
|
||||
which pages have been captured. The book screen shows `Pages 42–51 · 7 of 10
|
||||
captured · 3 to shoot`, listing every folio in order — the ones already read
|
||||
with their opening lines, the ones never photographed as *not captured yet*,
|
||||
and the ones whose photo came out blank as *no readable text — photograph this
|
||||
page again*. The page numbers steer the process but are never written into
|
||||
the text.
|
||||
- **Figures and captions are dropped.** Isolated short lines that sit under
|
||||
picture content (with their one or two credit lines) are filtered out, while a
|
||||
short line over blank paper is kept.
|
||||
@@ -49,7 +56,9 @@ Each queued photo goes through `SpreadProcessor`:
|
||||
3. Run ML Kit text recognition over the whole spread (bundled Latin model, so
|
||||
it works offline).
|
||||
4. Split the recognised lines by the gutter and read each half's **folio** — the
|
||||
bare page number near the outer edge, top or bottom.
|
||||
bare page number near the outer edge, top or bottom. A page whose body failed
|
||||
to OCR often leaves only its folio readable, so even a near-empty half is
|
||||
searched for one.
|
||||
5. Drop **captions**: short, narrow lines isolated below picture content.
|
||||
6. **Reflow** the body into paragraphs, gluing hyphenated line breaks and
|
||||
trimming running heads.
|
||||
@@ -58,6 +67,25 @@ Each queued photo goes through `SpreadProcessor`:
|
||||
At the end, `assembleTextFile` sorts the page files by folio, joins each page to
|
||||
the next when a sentence runs across the spread, and writes `<title>.txt`.
|
||||
|
||||
### Tracking coverage and knowing what to re-shoot
|
||||
|
||||
Every stored page file is named after its folio, so the set of folios in
|
||||
`pages/` is a record of which pages have been seen. `PageCoverage` turns that
|
||||
into, for the span `[low, high]` of folios seen so far:
|
||||
|
||||
- **captured** — a page file with body text;
|
||||
- **needs re-capture** — a page file that exists but is blank (the folio was
|
||||
read, the body was not), written by `SpreadProcessor` as the one-byte file
|
||||
`"\n"`;
|
||||
- **missing** — a folio in the span with no page file at all.
|
||||
|
||||
The book screen lists every folio in that span in order and marks the two kinds
|
||||
of gap, so a page that was never shot and a page that came out blank are both
|
||||
pointed at by number. A book can never reveal how long it really is, so pages
|
||||
beyond the highest folio seen are unknown rather than missing. A photo that
|
||||
contains no text at all has no folio to attribute, so it is parked in `failed/`
|
||||
and listed separately as *could not be read*.
|
||||
|
||||
### Storage layout
|
||||
|
||||
```
|
||||
@@ -81,7 +109,7 @@ retried up to `MAX_JOB_ATTEMPTS` before being parked in `failed/`.
|
||||
|
||||
```bash
|
||||
./gradlew :app:assembleDebug # APK at app/build/outputs/apk/debug/
|
||||
./gradlew :app:testDebugUnitTest # 83 unit tests (JVM + Robolectric)
|
||||
./gradlew :app:testDebugUnitTest # 98 unit tests (JVM + Robolectric)
|
||||
```
|
||||
|
||||
Install on a connected device:
|
||||
@@ -108,20 +136,21 @@ AppCompat action bar is no longer inset correctly, so each screen uses a
|
||||
|
||||
## Tests
|
||||
|
||||
`app/src/test/` holds 83 JVM/Robolectric tests covering the parts that do not
|
||||
`app/src/test/` holds 98 JVM/Robolectric tests covering the parts that do not
|
||||
need a camera:
|
||||
|
||||
| Suite | What it covers |
|
||||
| --- | --- |
|
||||
| `PageCoverageTest` | folio spans, missing pages vs pages that need re-shooting |
|
||||
| `GutterDetectorTest` | tilted-gutter fit and centre fallback |
|
||||
| `PageNumberDetectorTest` | folio geometry and margin isolation |
|
||||
| `PageNumberDetectorTest` | folio geometry, margin isolation, sparse pages |
|
||||
| `PageNumberResolverTest` | choosing the right folio per half |
|
||||
| `CaptionFilterTest` | dropping captions under figures, keeping orphans |
|
||||
| `ParagraphReflowTest` | hyphen gluing, running heads, across-page join |
|
||||
| `BitmapsTest` | EXIF-aware downsample and scaling |
|
||||
| `ImageArchiverTest` | archive scale and JPEG/WebP quality ladder |
|
||||
| `BookRepositoryTest` | create/list/delete and navigation |
|
||||
| `BookTest` | queue claim/fail/complete and text assembly |
|
||||
| `BookTest` | queue claim/fail/complete, page files, text assembly, coverage |
|
||||
|
||||
## Known limitations
|
||||
|
||||
|
||||
@@ -16,10 +16,13 @@ import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bm.pagescribe.book.Book
|
||||
import com.bm.pagescribe.book.BookRepository
|
||||
import com.bm.pagescribe.book.Coverage
|
||||
import com.bm.pagescribe.book.PageState
|
||||
import com.bm.pagescribe.ocr.WorkScheduler
|
||||
import com.bm.pagescribe.util.EdgeToEdge
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
@@ -112,14 +115,72 @@ class BookActivity : AppCompatActivity() {
|
||||
|
||||
val pages = book.pageFiles()
|
||||
val failed = book.failedJobs()
|
||||
metaText.text = getString(R.string.book_pages_label, pages.size, book.queuedCount())
|
||||
val coverage = book.coverage()
|
||||
|
||||
metaText.text = buildString {
|
||||
append(
|
||||
when {
|
||||
coverage == null -> getString(R.string.book_no_pages)
|
||||
coverage.complete -> getString(
|
||||
R.string.book_coverage_complete, coverage.low, coverage.high, coverage.captured,
|
||||
)
|
||||
else -> getString(
|
||||
R.string.book_coverage_todo,
|
||||
coverage.low, coverage.high, coverage.captured, coverage.expected, coverage.toShoot,
|
||||
)
|
||||
}
|
||||
)
|
||||
if (failed.isNotEmpty()) {
|
||||
append(" · ").append(getString(R.string.book_unreadable, failed.size))
|
||||
}
|
||||
}
|
||||
emptyText.visibility = if (pages.isEmpty() && failed.isEmpty()) View.VISIBLE else View.GONE
|
||||
|
||||
adapter.submit(pages, failed)
|
||||
adapter.submit(bookRows(coverage, pages, failed))
|
||||
logText.text = book.logText().lines().takeLast(60).joinToString("\n")
|
||||
logScroll.post { (logScroll as? android.widget.ScrollView)?.fullScroll(View.FOCUS_DOWN) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The page list: every folio in the book's range in order, so a page that
|
||||
* was never shot or needs another shot appears exactly where the reader
|
||||
* would look for it. Photos that could not be read at all come last, since
|
||||
* their page number is unknown.
|
||||
*/
|
||||
private fun bookRows(
|
||||
coverage: Coverage?,
|
||||
pages: List<Pair<Int, File>>,
|
||||
failed: List<File>,
|
||||
): List<PageRow> {
|
||||
val rows = ArrayList<PageRow>()
|
||||
val byNumber = pages.toMap()
|
||||
if (coverage != null) {
|
||||
for (slot in coverage.slots) {
|
||||
when (slot.state) {
|
||||
PageState.CAPTURED ->
|
||||
rows.add(PageRow(slot.number.toString(), snippet(byNumber.getValue(slot.number)), alert = false))
|
||||
PageState.NEEDS_RECAPTURE ->
|
||||
rows.add(PageRow(slot.number.toString(), getString(R.string.page_need_recapture), alert = true))
|
||||
PageState.MISSING ->
|
||||
rows.add(PageRow(slot.number.toString(), getString(R.string.page_missing), alert = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
for (file in failed) {
|
||||
rows.add(PageRow("?", getString(R.string.page_unreadable, file.name), alert = true))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
private fun snippet(file: File): String = runCatching {
|
||||
file.readText().lineSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.take(2)
|
||||
.joinToString(" ")
|
||||
.let { if (it.length > 160) it.take(160) + "…" else it }
|
||||
}.getOrDefault("")
|
||||
|
||||
private fun export() {
|
||||
val book = repository.find(bookKey) ?: return
|
||||
if (book.pageNumbers().isEmpty()) {
|
||||
@@ -164,33 +225,20 @@ class BookActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Page rows, plus any photo that gave up. */
|
||||
/** A row of the page list: a folio and either its opening text or a warning. */
|
||||
private data class PageRow(val label: String, val detail: String, val alert: Boolean)
|
||||
|
||||
/** Page rows, including the folios still missing and photos that gave up. */
|
||||
private class PageAdapter : RecyclerView.Adapter<PageAdapter.Holder>() {
|
||||
|
||||
private data class Row(val label: String, val detail: String, val primary: Boolean)
|
||||
private val rows = ArrayList<PageRow>()
|
||||
|
||||
private val rows = ArrayList<Row>()
|
||||
|
||||
fun submit(pages: List<Pair<Int, File>>, failed: List<File>) {
|
||||
fun submit(newRows: List<PageRow>) {
|
||||
rows.clear()
|
||||
for (file in failed) {
|
||||
rows.add(Row("!", file.name, primary = false))
|
||||
}
|
||||
for ((number, file) in pages) {
|
||||
rows.add(Row(number.toString(), snippet(file), primary = true))
|
||||
}
|
||||
rows.addAll(newRows)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun snippet(file: File): String = runCatching {
|
||||
file.readText().lineSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.take(2)
|
||||
.joinToString(" ")
|
||||
.let { if (it.length > 160) it.take(160) + "…" else it }
|
||||
}.getOrDefault("")
|
||||
|
||||
class Holder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val number: TextView = view.findViewById(R.id.pageNumber)
|
||||
val text: TextView = view.findViewById(R.id.pageSnippet)
|
||||
@@ -203,8 +251,13 @@ private class PageAdapter : RecyclerView.Adapter<PageAdapter.Holder>() {
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val row = rows[position]
|
||||
val color = ContextCompat.getColor(
|
||||
holder.itemView.context,
|
||||
if (row.alert) R.color.warn else R.color.ink,
|
||||
)
|
||||
holder.number.text = row.label
|
||||
holder.number.alpha = if (row.primary) 1f else 0.5f
|
||||
holder.number.setTextColor(color)
|
||||
holder.text.text = row.detail
|
||||
holder.text.setTextColor(color)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,11 +205,15 @@ class CaptureActivity : AppCompatActivity() {
|
||||
|
||||
private fun refreshStatus() {
|
||||
val current = book ?: return
|
||||
statusText.text = getString(
|
||||
R.string.capture_status,
|
||||
current.queuedCount(),
|
||||
current.pageNumbers().size
|
||||
)
|
||||
val coverage = current.coverage()
|
||||
val done = coverage?.captured ?: 0
|
||||
val queued = current.queuedCount()
|
||||
val todo = coverage?.toShoot ?: 0
|
||||
statusText.text = if (todo > 0) {
|
||||
getString(R.string.capture_status_todo, queued, done, todo)
|
||||
} else {
|
||||
getString(R.string.capture_status, queued, done)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -165,8 +165,13 @@ private class BookAdapter(
|
||||
val book = books[position]
|
||||
val pages = book.pageNumbers().size
|
||||
val queued = book.queuedCount()
|
||||
val todo = book.coverage()?.toShoot ?: 0
|
||||
holder.title.text = book.title
|
||||
holder.subtitle.text = holder.itemView.context.getString(R.string.book_pages_label, pages, queued)
|
||||
holder.subtitle.text = if (todo > 0) {
|
||||
holder.itemView.context.getString(R.string.book_pages_todo, pages, queued, todo)
|
||||
} else {
|
||||
holder.itemView.context.getString(R.string.book_pages_label, pages, queued)
|
||||
}
|
||||
holder.itemView.setOnClickListener { onClick(book) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import java.util.Properties
|
||||
* queue/<id>.jpg photos waiting for OCR (id keeps capture order)
|
||||
* work/<id>.jpg the one photo currently being read
|
||||
* pages/0042.txt the text of page 42 (file name = the printed folio)
|
||||
* a one-byte file ("\n") means page 42 was seen but its
|
||||
* body could not be read, so it should be re-shot
|
||||
* images/0042.jpg the archived, shrunk page image (or .webp)
|
||||
* failed/<id>.jpg photos that failed OCR repeatedly
|
||||
* failures.properties per-photo attempt counter
|
||||
@@ -121,6 +123,20 @@ class Book private constructor(
|
||||
|
||||
fun pageText(number: Int): String? = pageFile(number).takeIf { it.exists() }?.readText()
|
||||
|
||||
/**
|
||||
* folio -> whether its page file holds body text.
|
||||
*
|
||||
* [writePage] stores `text.trimEnd() + "\n"`, so a page whose whole body
|
||||
* failed to OCR is the one-byte file `"\n"`. Checking the length is enough
|
||||
* to tell a read page from a blank one without parsing every page again on
|
||||
* each refresh.
|
||||
*/
|
||||
fun pageHasText(): Map<Int, Boolean> =
|
||||
pageFiles().associate { (number, file) -> number to (file.length() > 1L) }
|
||||
|
||||
/** Which folios are captured, missing, or need re-shooting. */
|
||||
fun coverage(): Coverage? = PageCoverage.of(pageHasText())
|
||||
|
||||
fun pageFile(number: Int): File = pagesDir.resolve(pageFileName(number))
|
||||
|
||||
fun writePage(number: Int, text: String) {
|
||||
|
||||
76
app/src/main/java/com/bm/pagescribe/book/PageCoverage.kt
Normal file
76
app/src/main/java/com/bm/pagescribe/book/PageCoverage.kt
Normal file
@@ -0,0 +1,76 @@
|
||||
package com.bm.pagescribe.book
|
||||
|
||||
/**
|
||||
* The state of one page within a book's folio range.
|
||||
*
|
||||
* The printed page number is the one piece of a photographed page that is both
|
||||
* easy to read and stable, so it is used to keep track of which pages are still
|
||||
* missing — even though it is never written into the text itself.
|
||||
*/
|
||||
enum class PageState {
|
||||
/** The page was photographed and its body text stored. */
|
||||
CAPTURED,
|
||||
|
||||
/** The folio was read but the page body was not, so the picture should be retaken. */
|
||||
NEEDS_RECAPTURE,
|
||||
|
||||
/** No picture covers this folio at all. */
|
||||
MISSING,
|
||||
}
|
||||
|
||||
/** One folio and how it is doing. */
|
||||
data class PageSlot(val number: Int, val state: PageState)
|
||||
|
||||
/**
|
||||
* How complete a book is.
|
||||
*
|
||||
* A photo of a book cannot reveal how long the book is, so "every page has been
|
||||
* captured" can only mean every page between the lowest and highest folio seen
|
||||
* so far. Pages outside that span are unknown rather than missing.
|
||||
*/
|
||||
data class Coverage(
|
||||
val low: Int,
|
||||
val high: Int,
|
||||
val slots: List<PageSlot>,
|
||||
) {
|
||||
/** How many pages the span `[low, high]` holds. */
|
||||
val expected: Int get() = high - low + 1
|
||||
|
||||
/** Pages whose text was actually read. */
|
||||
val captured: Int get() = slots.count { it.state == PageState.CAPTURED }
|
||||
|
||||
/** Folios inside the span with no picture. */
|
||||
val missing: List<Int> get() = slots.filter { it.state == PageState.MISSING }.map { it.number }
|
||||
|
||||
/** Folios that were photographed but produced no text. */
|
||||
val recapture: List<Int> get() = slots.filter { it.state == PageState.NEEDS_RECAPTURE }.map { it.number }
|
||||
|
||||
/** Pages that still need a usable picture: either type of gap. */
|
||||
val toShoot: Int get() = missing.size + recapture.size
|
||||
|
||||
val complete: Boolean get() = toShoot == 0
|
||||
}
|
||||
|
||||
object PageCoverage {
|
||||
|
||||
/**
|
||||
* @param pageHasText folio -> whether its page file holds body text. A folio
|
||||
* that is present but blank *was* read (the number is known) yet the body
|
||||
* could not be transcribed, which is exactly the "re-shoot this page" case.
|
||||
*/
|
||||
fun of(pageHasText: Map<Int, Boolean>): Coverage? {
|
||||
if (pageHasText.isEmpty()) return null
|
||||
val low = pageHasText.keys.min()
|
||||
val high = pageHasText.keys.max()
|
||||
val slots = ArrayList<PageSlot>(high - low + 1)
|
||||
for (number in low..high) {
|
||||
val state = when (pageHasText[number]) {
|
||||
null -> PageState.MISSING
|
||||
false -> PageState.NEEDS_RECAPTURE
|
||||
true -> PageState.CAPTURED
|
||||
}
|
||||
slots.add(PageSlot(number, state))
|
||||
}
|
||||
return Coverage(low, high, slots)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,12 @@ object PageNumberDetector {
|
||||
val bodyGap = medianGap(ordered)
|
||||
val pageWidth = bounds.width
|
||||
|
||||
// A page whose body failed to OCR often leaves only its folio readable.
|
||||
// One or two lines are not a text block, so the top/bottom bands cannot
|
||||
// be measured against them; any isolated bare number in the outer margin
|
||||
// is taken for the folio instead.
|
||||
val sparse = ordered.size <= 2
|
||||
|
||||
var best: Detected? = null
|
||||
var bestScore = Double.NEGATIVE_INFINITY
|
||||
|
||||
@@ -51,9 +57,16 @@ object PageNumberDetector {
|
||||
if (line.height > MAX_HEIGHT_FACTOR * bodyHeight) continue
|
||||
|
||||
val cy = line.centerY
|
||||
val inBottom = cy >= textBottom - BOTTOM_BAND * span
|
||||
val inTop = cy <= textTop + TOP_BAND * span
|
||||
if (!inBottom && !inTop) continue
|
||||
val inBottom: Boolean
|
||||
val inTop: Boolean
|
||||
if (sparse) {
|
||||
inBottom = cy >= (bounds.top + bounds.bottom) / 2
|
||||
inTop = !inBottom
|
||||
} else {
|
||||
inBottom = cy >= textBottom - BOTTOM_BAND * span
|
||||
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.
|
||||
|
||||
@@ -94,7 +94,14 @@ class SpreadProcessor(context: Context) {
|
||||
if (captionsDropped > 0) notes.add("Ignored $captionsDropped caption line(s).")
|
||||
|
||||
for ((number, text) in pageTexts) {
|
||||
if (number >= 1) book.writePage(number, text)
|
||||
if (number < 1) continue
|
||||
// The page file is written even when the body is empty: it records
|
||||
// the folio, which is what tells the book screen that this page was
|
||||
// seen but needs another photo.
|
||||
book.writePage(number, text)
|
||||
if (text.isBlank()) {
|
||||
book.appendLog("Page $number has a folio but no readable text; it should be re-shot.")
|
||||
}
|
||||
}
|
||||
book.assembleTextFile()
|
||||
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
<color name="accent">#5B8DEF</color>
|
||||
<color name="paper">#FBF7EF</color>
|
||||
<color name="ink">#1B1B1B</color>
|
||||
<color name="warn">#B3261E</color>
|
||||
</resources>
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
<string name="title_book">Book</string>
|
||||
<string name="book_no_pages">No pages yet. Tap Capture to photograph the first spread.</string>
|
||||
<string name="book_pages_label">%1$d pages · %2$d queued</string>
|
||||
<string name="book_pages_todo">%1$d pages · %2$d queued · %3$d to shoot</string>
|
||||
<string name="book_coverage_complete">Pages %1$d–%2$d · all %3$d captured</string>
|
||||
<string name="book_coverage_todo">Pages %1$d–%2$d · %3$d of %4$d captured · %5$d to shoot</string>
|
||||
<string name="page_missing">not captured yet — photograph this spread</string>
|
||||
<string name="page_need_recapture">no readable text — photograph this page again</string>
|
||||
<string name="page_unreadable">could not be read (%1$s) — photograph this spread again</string>
|
||||
<string name="book_unreadable">%1$d unreadable</string>
|
||||
<string name="book_capture">Capture</string>
|
||||
<string name="book_export">Export text</string>
|
||||
<string name="book_delete">Delete book</string>
|
||||
@@ -40,6 +47,7 @@
|
||||
<string name="capture_busy">Still saving the previous picture…</string>
|
||||
<string name="capture_pages">Pages</string>
|
||||
<string name="capture_status">Queued: %1$d · Done: %2$d</string>
|
||||
<string name="capture_status_todo">Queued: %1$d · Done: %2$d · To shoot: %3$d</string>
|
||||
<string name="capture_hint">Photograph the whole open spread, square to the page.</string>
|
||||
|
||||
<!-- Notification -->
|
||||
|
||||
@@ -215,6 +215,58 @@ class BookTest {
|
||||
assertEquals("Second attempt, clean.\n", book.pageText(12))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ coverage
|
||||
|
||||
@Test
|
||||
fun `a blank page is recorded as read but without text`() {
|
||||
val book = Book.create(root, "Blank")
|
||||
book.writePage(20, "")
|
||||
book.writePage(21, "Real text.")
|
||||
|
||||
assertEquals(mapOf(20 to false, 21 to true), book.pageHasText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `coverage flags blank pages for re-shooting and gaps as missing`() {
|
||||
val book = Book.create(root, "Coverage")
|
||||
book.writePage(40, "Page forty.")
|
||||
book.writePage(41, "") // folio read, body blank -> re-shoot
|
||||
book.writePage(44, "Page forty-four.")
|
||||
|
||||
val coverage = book.coverage()!!
|
||||
|
||||
assertEquals(40, coverage.low)
|
||||
assertEquals(44, coverage.high)
|
||||
assertEquals(listOf(42, 43), coverage.missing)
|
||||
assertEquals(listOf(41), coverage.recapture)
|
||||
assertEquals(2, coverage.captured)
|
||||
assertEquals(5, coverage.expected)
|
||||
assertEquals(3, coverage.toShoot)
|
||||
assertFalse(coverage.complete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank page is left out of the assembled text`() {
|
||||
val book = Book.create(root, "GapText")
|
||||
book.writePage(4, "The start.")
|
||||
book.writePage(5, "")
|
||||
book.writePage(6, "The end.")
|
||||
|
||||
assertEquals("The start.\n\nThe end.\n", book.assembleTextFile().readText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-shooting a blank page clears the recapture flag`() {
|
||||
val book = Book.create(root, "FixBlank")
|
||||
book.writePage(9, "")
|
||||
assertEquals(listOf(9), book.coverage()!!.recapture)
|
||||
|
||||
// A clean second attempt replaces the blank file.
|
||||
book.writePage(9, "Now it reads properly.")
|
||||
assertEquals(emptyList<Int>(), book.coverage()!!.recapture)
|
||||
assertTrue(book.coverage()!!.complete)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- misc
|
||||
|
||||
@Test
|
||||
|
||||
101
app/src/test/java/com/bm/pagescribe/book/PageCoverageTest.kt
Normal file
101
app/src/test/java/com/bm/pagescribe/book/PageCoverageTest.kt
Normal file
@@ -0,0 +1,101 @@
|
||||
package com.bm.pagescribe.book
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PageCoverageTest {
|
||||
|
||||
@Test
|
||||
fun `a book with no pages has no coverage`() {
|
||||
assertNull(PageCoverage.of(emptyMap()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single captured page is a complete one-page range`() {
|
||||
val coverage = PageCoverage.of(mapOf(7 to true))!!
|
||||
assertEquals(7, coverage.low)
|
||||
assertEquals(7, coverage.high)
|
||||
assertEquals(1, coverage.expected)
|
||||
assertEquals(1, coverage.captured)
|
||||
assertTrue(coverage.complete)
|
||||
assertEquals(emptyList<Int>(), coverage.missing)
|
||||
assertEquals(emptyList<Int>(), coverage.recapture)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a contiguous run is complete`() {
|
||||
val coverage = PageCoverage.of(mapOf(42 to true, 43 to true, 44 to true, 45 to true))!!
|
||||
assertEquals(42, coverage.low)
|
||||
assertEquals(45, coverage.high)
|
||||
assertEquals(4, coverage.expected)
|
||||
assertEquals(4, coverage.captured)
|
||||
assertEquals(0, coverage.toShoot)
|
||||
assertTrue(coverage.complete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `folios skipped between two spreads are reported missing`() {
|
||||
// The user shot 42/43 then 46/47 and flipped past two pages.
|
||||
val coverage = PageCoverage.of(
|
||||
mapOf(42 to true, 43 to true, 46 to true, 47 to true),
|
||||
)!!
|
||||
assertEquals(listOf(44, 45), coverage.missing)
|
||||
assertEquals(emptyList<Int>(), coverage.recapture)
|
||||
assertEquals(2, coverage.toShoot)
|
||||
assertFalse(coverage.complete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a folio with no text needs to be re-shot, not marked missing`() {
|
||||
// Page 44's number was read but its body came out blank.
|
||||
val coverage = PageCoverage.of(
|
||||
mapOf(42 to true, 43 to true, 44 to false, 45 to true),
|
||||
)!!
|
||||
assertEquals(emptyList<Int>(), coverage.missing)
|
||||
assertEquals(listOf(44), coverage.recapture)
|
||||
assertEquals(3, coverage.captured)
|
||||
assertEquals(4, coverage.expected)
|
||||
assertEquals(1, coverage.toShoot)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank final page still widens the range`() {
|
||||
// The last spread produced only a folio on page 47.
|
||||
val coverage = PageCoverage.of(mapOf(46 to true, 47 to false))!!
|
||||
assertEquals(46, coverage.low)
|
||||
assertEquals(47, coverage.high)
|
||||
assertEquals(2, coverage.expected)
|
||||
assertEquals(listOf(47), coverage.recapture)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing and blank pages are both counted as to shoot`() {
|
||||
val coverage = PageCoverage.of(
|
||||
mapOf(10 to true, 11 to false, 13 to true, 14 to false),
|
||||
)!!
|
||||
// 12 was never photographed; 11 and 14 were, but blank.
|
||||
assertEquals(listOf(12), coverage.missing)
|
||||
assertEquals(listOf(11, 14), coverage.recapture)
|
||||
assertEquals(2, coverage.captured)
|
||||
assertEquals(5, coverage.expected)
|
||||
assertEquals(3, coverage.toShoot)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slots run in folio order and carry their state`() {
|
||||
val coverage = PageCoverage.of(mapOf(3 to true, 4 to false, 6 to true))!!
|
||||
assertEquals(listOf(3, 4, 5, 6), coverage.slots.map { it.number })
|
||||
assertEquals(
|
||||
listOf(
|
||||
PageState.CAPTURED,
|
||||
PageState.NEEDS_RECAPTURE,
|
||||
PageState.MISSING,
|
||||
PageState.CAPTURED,
|
||||
),
|
||||
coverage.slots.map { it.state },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -100,4 +100,23 @@ class PageNumberDetectorTest {
|
||||
assertEquals(42, PageNumberDetector.folioNumber(" 42 "))
|
||||
assertEquals(7, PageNumberDetector.folioNumber("7."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lone folio on an otherwise blank page is still read`() {
|
||||
// The body failed to OCR and only the printed number came out; it is
|
||||
// still the key that says which page this was.
|
||||
val detected = PageNumberDetector.detect(listOf(folio(350, "47", 30, 62)), leftBounds, PageSide.LEFT)
|
||||
assertEquals(47, detected?.number)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lone line of prose on a sparse page is not a folio`() {
|
||||
val lone = OcrLine("The end of the chapter", 30, 350, 300, 376)
|
||||
assertNull(PageNumberDetector.detect(listOf(lone), leftBounds, PageSide.LEFT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sparse page still rejects a number in the inner margin`() {
|
||||
assertNull(PageNumberDetector.detect(listOf(folio(350, "47", 340, 370)), leftBounds, PageSide.LEFT))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user