PageScribe 0.1: camera capture, background OCR queue, page-numbered text assembly, image archiver

This commit is contained in:
2026-10-15 09:35:13 -07:00
commit 3dd15ffa9b
45 changed files with 3431 additions and 0 deletions

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
# Gradle
.gradle/
build/
app/build/
# Android Studio / IntelliJ
.idea/
*.iml
local.properties
captures/
.externalNativeBuild/
.cxx/
# Signing secrets (never commit)
keystore.properties
*.jks
*.keystore
# OS
.DS_Store

110
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,110 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
// Release signing credentials live in keystore.properties, which is kept out of
// version control. When it is missing the release APK is left unsigned so that
// `assembleDebug` and secret-less CI builds keep working.
val keystorePropsFile = rootProject.file("keystore.properties")
val keystoreProps = Properties().apply {
if (keystorePropsFile.exists()) {
FileInputStream(keystorePropsFile).use { load(it) }
}
}
android {
namespace = "com.bm.pagescribe"
compileSdk = 36
defaultConfig {
applicationId = "com.bm.pagescribe"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
if (keystorePropsFile.exists()) {
create("release") {
storeFile = rootProject.file(keystoreProps.getProperty("storeFile"))
storePassword = keystoreProps.getProperty("storePassword")
keyAlias = keystoreProps.getProperty("keyAlias")
keyPassword = keystoreProps.getProperty("keyPassword")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
if (keystorePropsFile.exists()) {
signingConfig = signingConfigs.getByName("release")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
testOptions {
unitTests {
// Robolectric needs the real Android resources (drawables are not
// used by the tests, but the framework classes are).
isIncludeAndroidResources = true
isReturnDefaultValues = true
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.16.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("androidx.recyclerview:recyclerview:1.4.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
implementation("com.google.android.material:material:1.14.0")
// Background OCR queue. WorkManager persists the queue across reboots.
implementation("androidx.work:work-runtime-ktx:2.12.0")
// CameraX: live preview + still capture of the book spread.
implementation("androidx.camera:camera-core:1.6.2")
implementation("androidx.camera:camera-camera2:1.6.2")
implementation("androidx.camera:camera-lifecycle:1.6.2")
implementation("androidx.camera:camera-view:1.6.2")
// EXIF orientation, so a photo taken in portrait is not OCR'd sideways.
implementation("androidx.exifinterface:exifinterface:1.4.2")
// Text recognition v2 (Latin), *bundled* variant: the ~18 MB model ships
// inside the APK, so OCR works with no network and no Google Play services.
implementation("com.google.mlkit:text-recognition:16.0.1")
// Task -> coroutine bridge for the ML Kit recognizer.
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.2")
testImplementation("junit:junit:4.13.2")
// Android graphics (Bitmap/Canvas/Bitmap.compress) on the JVM for the
// image-archiving tests.
testImplementation("org.robolectric:robolectric:4.16.1")
// android.* classes the framework ships but the plain JVM does not.
testImplementation("org.json:json:20250107")
}

3
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,3 @@
# Keep ML Kit's bundled recognizer classes; they are looked up by name.
-keep class com.google.mlkit.** { *; }
-dontwarn com.google.mlkit.**

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Camera for photographing the open book. -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Progress notifications for the background OCR queue. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- A camera is the whole point of the app, so refuse install without one. -->
<uses-feature
android:name="android.hardware.camera"
android:required="true" />
<application
android:name=".PageScribeApp"
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.PageScribe">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".CaptureActivity"
android:exported="false"
android:label="@string/title_capture" />
<activity
android:name=".BookActivity"
android:exported="false"
android:label="@string/title_book" />
</application>
</manifest>

View File

@@ -0,0 +1,197 @@
package com.bm.pagescribe
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
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.ocr.WorkScheduler
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.io.File
/**
* One book: what has been read so far, in page order, plus the exit routes —
* keep capturing, export the text file, or throw the book away.
*/
class BookActivity : AppCompatActivity() {
private lateinit var repository: BookRepository
private lateinit var adapter: PageAdapter
private lateinit var metaText: TextView
private lateinit var emptyText: TextView
private lateinit var logText: TextView
private lateinit var logScroll: View
private var bookKey: String = ""
private val handler = Handler(Looper.getMainLooper())
private val poll = object : Runnable {
override fun run() {
refresh()
handler.postDelayed(this, POLL_INTERVAL)
}
}
private val exportLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("text/plain")
) { uri -> uri?.let { writeExport(it) } }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_book)
bookKey = intent.getStringExtra(EXTRA_KEY).orEmpty()
repository = BookRepository(this)
metaText = findViewById(R.id.bookMeta)
emptyText = findViewById(R.id.emptyText)
logText = findViewById(R.id.logText)
logScroll = findViewById(R.id.logScroll)
adapter = PageAdapter()
findViewById<RecyclerView>(R.id.pageList).apply {
layoutManager = LinearLayoutManager(this@BookActivity)
adapter = this@BookActivity.adapter
}
findViewById<MaterialButton>(R.id.btnCapture).setOnClickListener {
book()?.let { startActivity(CaptureActivity.intent(this, it)) }
}
findViewById<MaterialButton>(R.id.btnExport).setOnClickListener { export() }
findViewById<MaterialButton>(R.id.btnDelete).setOnClickListener { confirmDelete() }
}
override fun onResume() {
super.onResume()
WorkScheduler.ensureRunning(this)
handler.removeCallbacks(poll)
handler.post(poll)
}
override fun onPause() {
super.onPause()
handler.removeCallbacks(poll)
}
private fun book(): Book? = repository.find(bookKey).also {
if (it == null) {
Toast.makeText(this, R.string.books_empty, Toast.LENGTH_SHORT).show()
finish()
}
}
private fun refresh() {
val book = repository.find(bookKey) ?: return
supportActionBar?.title = book.title
val pages = book.pageFiles()
val failed = book.failedJobs()
metaText.text = getString(R.string.book_pages_label, pages.size, book.queuedCount())
emptyText.visibility = if (pages.isEmpty() && failed.isEmpty()) View.VISIBLE else View.GONE
adapter.submit(pages, failed)
logText.text = book.logText().lines().takeLast(60).joinToString("\n")
logScroll.post { (logScroll as? android.widget.ScrollView)?.fullScroll(View.FOCUS_DOWN) }
}
private fun export() {
val book = repository.find(bookKey) ?: return
if (book.pageNumbers().isEmpty()) {
Toast.makeText(this, R.string.book_export_empty, Toast.LENGTH_SHORT).show()
return
}
book.assembleTextFile()
exportLauncher.launch(book.textFile.name)
}
private fun writeExport(uri: Uri) {
val book = repository.find(bookKey) ?: return
val text = book.assembleText()
runCatching {
contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) }
}.onSuccess {
Toast.makeText(this, getString(R.string.notif_done, book.title), Toast.LENGTH_SHORT).show()
}.onFailure { error ->
Toast.makeText(this, error.message ?: "export failed", Toast.LENGTH_LONG).show()
}
}
private fun confirmDelete() {
val book = repository.find(bookKey) ?: return
MaterialAlertDialogBuilder(this)
.setTitle(book.title)
.setMessage(R.string.book_delete_confirm)
.setNegativeButton(R.string.books_cancel, null)
.setPositiveButton(R.string.book_delete) { _, _ ->
repository.delete(book)
finish()
}
.show()
}
companion object {
private const val EXTRA_KEY = "bookKey"
private const val POLL_INTERVAL = 1500L
fun intent(context: Context, book: Book): Intent =
Intent(context, BookActivity::class.java).putExtra(EXTRA_KEY, book.key)
}
}
/** Page rows, plus any photo 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<Row>()
fun submit(pages: List<Pair<Int, File>>, failed: List<File>) {
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))
}
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)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder =
Holder(LayoutInflater.from(parent.context).inflate(R.layout.item_page, parent, false))
override fun getItemCount(): Int = rows.size
override fun onBindViewHolder(holder: Holder, position: Int) {
val row = rows[position]
holder.number.text = row.label
holder.number.alpha = if (row.primary) 1f else 0.5f
holder.text.text = row.detail
}
}

View File

@@ -0,0 +1,209 @@
package com.bm.pagescribe
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.ImageButton
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import com.bm.pagescribe.book.Book
import com.bm.pagescribe.book.BookRepository
import com.bm.pagescribe.ocr.WorkScheduler
import com.google.android.material.button.MaterialButton
import java.io.File
/**
* The camera: photograph one open spread, then the next. Each shot is written
* straight into the book's queue folder and handed to the background OCR worker,
* so shooting is never blocked by recognising the previous page.
*/
class CaptureActivity : AppCompatActivity() {
private lateinit var repository: BookRepository
private lateinit var previewView: PreviewView
private lateinit var statusText: TextView
private lateinit var shutter: ImageButton
private lateinit var flashButton: MaterialButton
private lateinit var permissionPanel: View
private var imageCapture: ImageCapture? = null
private var book: Book? = null
private var capturing = false
private var flashOn = false
private val handler = Handler(Looper.getMainLooper())
private val poll = object : Runnable {
override fun run() {
refreshStatus()
handler.postDelayed(this, POLL_INTERVAL)
}
}
private val cameraPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
permissionPanel.visibility = View.GONE
startCamera()
} else {
permissionPanel.visibility = View.VISIBLE
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_capture)
repository = BookRepository(this)
previewView = findViewById(R.id.previewView)
statusText = findViewById(R.id.captureStatus)
shutter = findViewById(R.id.btnShutter)
flashButton = findViewById(R.id.btnFlash)
permissionPanel = findViewById(R.id.permissionPanel)
val key = intent.getStringExtra(EXTRA_KEY).orEmpty()
book = repository.find(key)
if (book == null) {
Toast.makeText(this, R.string.books_empty, Toast.LENGTH_SHORT).show()
finish()
return
}
supportActionBar?.title = book?.title
shutter.setOnClickListener { shoot() }
flashButton.setOnClickListener { toggleFlash() }
findViewById<MaterialButton>(R.id.btnPages).setOnClickListener {
book?.let { startActivity(BookActivity.intent(this, it)) }
}
findViewById<MaterialButton>(R.id.btnPermission).setOnClickListener {
cameraPermission.launch(Manifest.permission.CAMERA)
}
if (hasCameraPermission()) {
permissionPanel.visibility = View.GONE
startCamera()
} else {
permissionPanel.visibility = View.VISIBLE
cameraPermission.launch(Manifest.permission.CAMERA)
}
}
override fun onResume() {
super.onResume()
WorkScheduler.ensureRunning(this)
handler.removeCallbacks(poll)
handler.post(poll)
}
override fun onPause() {
super.onPause()
handler.removeCallbacks(poll)
}
private fun hasCameraPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
private fun startCamera() {
val providerFuture = ProcessCameraProvider.getInstance(this)
providerFuture.addListener({
val provider = runCatching { providerFuture.get() }.getOrNull() ?: return@addListener
val preview = Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
val capture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
.build()
capture.targetRotation = previewView.display?.rotation ?: 0
runCatching {
provider.unbindAll()
provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, capture)
}.onFailure { error ->
Toast.makeText(this, error.message ?: "camera failed", Toast.LENGTH_LONG).show()
}
imageCapture = capture
applyFlash()
}, ContextCompat.getMainExecutor(this))
}
private fun toggleFlash() {
flashOn = !flashOn
applyFlash()
}
private fun applyFlash() {
imageCapture?.flashMode =
if (flashOn) ImageCapture.FLASH_MODE_ON else ImageCapture.FLASH_MODE_OFF
flashButton.setText(if (flashOn) R.string.capture_flash_on else R.string.capture_flash_off)
}
private fun shoot() {
val capture = imageCapture ?: return
val current = book ?: return
if (capturing) {
Toast.makeText(this, R.string.capture_busy, Toast.LENGTH_SHORT).show()
return
}
val target = File(current.queueDir, "page-${System.currentTimeMillis()}.jpg")
val options = ImageCapture.OutputFileOptions.Builder(target).build()
capturing = true
shutter.isEnabled = false
capture.takePicture(
options,
ContextCompat.getMainExecutor(this),
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
capturing = false
shutter.isEnabled = true
WorkScheduler.ensureRunning(this@CaptureActivity)
Toast.makeText(this@CaptureActivity, R.string.capture_saved, Toast.LENGTH_SHORT).show()
refreshStatus()
}
override fun onError(exception: ImageCaptureException) {
capturing = false
shutter.isEnabled = true
target.delete()
Toast.makeText(
this@CaptureActivity,
getString(R.string.capture_failed, exception.message ?: "unknown"),
Toast.LENGTH_LONG
).show()
}
}
)
}
private fun refreshStatus() {
val current = book ?: return
statusText.text = getString(
R.string.capture_status,
current.queuedCount(),
current.pageNumbers().size
)
}
companion object {
private const val EXTRA_KEY = "bookKey"
private const val POLL_INTERVAL = 1500L
fun intent(context: Context, book: Book): Intent =
Intent(context, CaptureActivity::class.java).putExtra(EXTRA_KEY, book.key)
}
}

View File

@@ -0,0 +1,36 @@
package com.bm.pagescribe
/**
* Tunables that have no UI yet. They are constants on purpose: the job queue,
* the page files and the assembled text all survive a reinstall only if they
* keep their meaning, so these are not device settings but app behaviour.
*/
object Config {
/**
* Largest edge of the photo handed to the recogniser and to the archiver.
* Phone camera photos are ~4000 px; 3000 keeps a whole spread's text around
* 30-50 px per line, which is plenty, and keeps the bitmaps inside a sane
* heap budget.
*/
const val OCR_MAX_EDGE = 3000
/** A page-number line is removed from the body text when false. */
const val KEEP_PAGE_NUMBERS = false
/**
* Readability floor for the archived images, in pixels of body line height
* at the image's own resolution. 20 px is comfortable when the image is
* shown full width on a 1080p phone and still legible when zoomed.
*/
const val ARCHIVE_MIN_LINE_PX = 20
/** Extra headroom on the readability floor. */
const val ARCHIVE_LINE_MARGIN = 1.12
/** A queued photo is given up on after this many failed OCR attempts. */
const val MAX_JOB_ATTEMPTS = 3
/** Archived images may never be scaled below this fraction of the original. */
const val ARCHIVE_MIN_SCALE = 0.15
}

View File

@@ -0,0 +1,160 @@
package com.bm.pagescribe
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
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.BookExistsException
import com.bm.pagescribe.book.BookRepository
import com.bm.pagescribe.ocr.WorkScheduler
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
/** The shelf: one row per book, and the way to start a new one. */
class MainActivity : AppCompatActivity() {
private lateinit var repository: BookRepository
private lateinit var adapter: BookAdapter
private lateinit var statusText: TextView
private lateinit var emptyText: TextView
private val handler = Handler(Looper.getMainLooper())
private val poll = object : Runnable {
override fun run() {
refresh()
handler.postDelayed(this, POLL_INTERVAL)
}
}
private val notificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
repository = BookRepository(this)
statusText = findViewById(R.id.ocrStatus)
emptyText = findViewById(R.id.emptyText)
adapter = BookAdapter { book -> startActivity(CaptureActivity.intent(this, book)) }
findViewById<RecyclerView>(R.id.bookList).apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = this@MainActivity.adapter
}
findViewById<FloatingActionButton>(R.id.btnNewBook).setOnClickListener { askForTitle() }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
override fun onResume() {
super.onResume()
WorkScheduler.ensureRunning(this)
handler.removeCallbacks(poll)
handler.post(poll)
}
override fun onPause() {
super.onPause()
handler.removeCallbacks(poll)
}
private fun refresh() {
val books = repository.list()
adapter.submit(books)
emptyText.visibility = if (books.isEmpty()) View.VISIBLE else View.GONE
val queued = repository.totalQueued()
val pages = repository.totalPages()
statusText.text = if (queued == 0 && pages == 0) {
getString(R.string.ocr_idle)
} else {
getString(R.string.ocr_running, queued, pages)
}
}
private fun askForTitle() {
val input = EditText(this).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
hint = getString(R.string.books_new_hint)
}
MaterialAlertDialogBuilder(this)
.setTitle(R.string.books_new_title)
.setView(input)
.setNegativeButton(R.string.books_cancel, null)
.setPositiveButton(R.string.books_create) { _, _ ->
val title = input.text.toString().trim()
if (title.isEmpty()) {
Toast.makeText(this, R.string.books_title_required, Toast.LENGTH_SHORT).show()
return@setPositiveButton
}
try {
val book = repository.create(title)
startActivity(CaptureActivity.intent(this, book))
} catch (exists: BookExistsException) {
Toast.makeText(this, R.string.books_exists, Toast.LENGTH_SHORT).show()
}
}
.show()
}
private companion object {
const val POLL_INTERVAL = 1500L
}
}
/** Row of the book list. */
private class BookAdapter(
private val onClick: (Book) -> Unit,
) : RecyclerView.Adapter<BookAdapter.Holder>() {
private val books = ArrayList<Book>()
fun submit(newBooks: List<Book>) {
books.clear()
books.addAll(newBooks)
notifyDataSetChanged()
}
class Holder(view: View) : RecyclerView.ViewHolder(view) {
val title: TextView = view.findViewById(R.id.bookTitle)
val subtitle: TextView = view.findViewById(R.id.bookSubtitle)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder =
Holder(LayoutInflater.from(parent.context).inflate(R.layout.item_book, parent, false))
override fun getItemCount(): Int = books.size
override fun onBindViewHolder(holder: Holder, position: Int) {
val book = books[position]
val pages = book.pageNumbers().size
val queued = book.queuedCount()
holder.title.text = book.title
holder.subtitle.text = holder.itemView.context.getString(R.string.book_pages_label, pages, queued)
holder.itemView.setOnClickListener { onClick(book) }
}
}

View File

@@ -0,0 +1,24 @@
package com.bm.pagescribe
import android.app.Application
import com.bm.pagescribe.book.BookRepository
import com.bm.pagescribe.ocr.WorkScheduler
import com.bm.pagescribe.util.Notifications
class PageScribeApp : Application() {
override fun onCreate() {
super.onCreate()
Notifications.ensureChannel(this)
// Photos left in `work/` by a crash or a killed process belong back in
// the queue. Two minutes of grace so a worker that started before this
// runs cannot have the photo it is holding pulled out from under it.
BookRepository(this).reclaimOrphans(minAgeMillis = 2 * 60_000L)
// WorkManager persists its own queue, but a photo can be written and its
// work request dropped if the process dies in between; resuming here
// closes that gap.
WorkScheduler.ensureRunning(this)
}
}

View File

@@ -0,0 +1,302 @@
package com.bm.pagescribe.book
import android.content.Context
import com.bm.pagescribe.ocr.ParagraphReflow
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.Properties
/**
* One book on disk:
*
* ```
* files/books/<title>/
* meta.properties the display title
* 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)
* images/0042.jpg the archived, shrunk page image (or .webp)
* failed/<id>.jpg photos that failed OCR repeatedly
* failures.properties per-photo attempt counter
* log.txt what happened, and every page-number guess
* <title>.txt the assembled book, rebuilt after every page
* ```
*
* The page *file name* is the ordering key, which is what makes the output
* correct even when a spread is photographed twice or scanned out of order.
*/
class Book private constructor(
val dir: File,
val title: String,
) {
val key: String get() = dir.name
/** The delivered text file: `<title>.txt`, as the title reads on the cover. */
val textFile: File get() = File(dir, sanitiseFileName(title) + ".txt")
val queueDir: File get() = dir.resolve(QUEUE).apply { mkdirs() }
val workDir: File get() = dir.resolve(WORK).apply { mkdirs() }
val pagesDir: File get() = dir.resolve(PAGES).apply { mkdirs() }
val imagesDir: File get() = dir.resolve(IMAGES).apply { mkdirs() }
val failedDir: File get() = dir.resolve(FAILED).apply { mkdirs() }
private val logFile: File get() = dir.resolve(LOG)
private val failureFile: File get() = dir.resolve(FAILURES)
// ---------------------------------------------------------------- jobs
/** Photos waiting for OCR, oldest first. */
fun pendingJobs(): List<File> {
val files = queueDir.listFiles() ?: return emptyList()
return files.filter { it.isFile && it.name.endsWith(".jpg", ignoreCase = true) }
.sortedBy { it.lastModified() }
}
fun queuedCount(): Int = pendingJobs().size + workJobs().size
private fun workJobs(): List<File> {
val files = workDir.listFiles() ?: return emptyList()
return files.filter { it.isFile && it.name.endsWith(".jpg", ignoreCase = true) }
}
/**
* Takes the oldest queued photo out of the queue and into `work/` so that
* two worker runs can never OCR the same photo and an interrupted run can be
* recovered (see [reclaimOrphans]).
*/
fun claimNextJob(): File? {
val next = pendingJobs().firstOrNull() ?: return null
val claimed = workDir.resolve(next.name)
return if (next.renameTo(claimed)) claimed else null
}
/** The photo was read successfully. */
fun completeJob(job: File) {
job.delete()
clearFailures(job.name)
}
/**
* OCR failed. The photo goes back to the queue, and after
* `Config.MAX_JOB_ATTEMPTS` it is parked in `failed/` so one bad photo
* cannot block the rest of the book.
*/
fun failJob(job: File, reason: String): Int {
val attempts = recordFailure(job.name)
return if (attempts >= MAX_ATTEMPTS) {
val parked = failedDir.resolve(job.name)
job.renameTo(parked)
appendLog("Gave up on ${job.name} after $attempts attempts: $reason")
attempts
} else {
job.renameTo(queueDir.resolve(job.name))
appendLog("Attempt $attempts failed for ${job.name}: $reason")
attempts
}
}
fun failedJobs(): List<File> =
failedDir.listFiles { f -> f.isFile }?.sortedBy { it.name } ?: emptyList()
/** Photos left in `work/` by a crashed run go back into the queue. */
fun reclaimOrphans(minAgeMillis: Long = 0L) {
val cutoff = System.currentTimeMillis() - minAgeMillis
for (job in workJobs()) {
if (minAgeMillis > 0 && job.lastModified() > cutoff) continue
job.renameTo(queueDir.resolve(job.name))
}
}
// --------------------------------------------------------------- pages
fun pageNumbers(): List<Int> = pageFiles().map { it.first }
fun pageFiles(): List<Pair<Int, File>> =
(pagesDir.listFiles { f -> f.isFile && f.extension == "txt" } ?: emptyArray())
.mapNotNull { file -> pageNumber(file)?.let { it to file } }
.sortedBy { it.first }
fun pageText(number: Int): String? = pageFile(number).takeIf { it.exists() }?.readText()
fun pageFile(number: Int): File = pagesDir.resolve(pageFileName(number))
fun writePage(number: Int, text: String) {
pageFile(number).writeText(text.trimEnd() + "\n")
}
/**
* Rebuilds `<title>.txt` from every page file, in page-number order.
* Prose that runs off the bottom of one page continues on the next.
*/
fun assembleTextFile(): File {
val pages = pageFiles().map { it.second.readText().trim() }.filter { it.isNotEmpty() }
val body = pages.reduceOrNull { acc, page -> ParagraphReflow.joinPages(acc, page) } ?: ""
writeAtomically(textFile, if (body.isEmpty()) "" else body + "\n")
return textFile
}
/** The assembled text of the whole book (not written to disk). */
fun assembleText(): String {
val pages = pageFiles().map { it.second.readText().trim() }.filter { it.isNotEmpty() }
return pages.reduceOrNull { acc, page -> ParagraphReflow.joinPages(acc, page) } ?: ""
}
// ----------------------------------------------------------------- log
fun appendLog(message: String) {
val stamp = SimpleDateFormat("HH:mm:ss", Locale.US).format(Date())
runCatching {
logFile.appendText("$stamp $message\n")
trimLog()
}
}
fun logText(): String = runCatching { logFile.readText() }.getOrDefault("")
private fun trimLog() {
if (logFile.length() < LOG_MAX_BYTES) return
val lines = logFile.readLines()
logFile.writeText(lines.takeLast(LOG_KEEP_LINES).joinToString("\n", postfix = "\n"))
}
// ----------------------------------------------------------- lifecycle
fun deleteAll() {
dir.deleteRecursively()
}
// --------------------------------------------------------------- meta
private fun failures(): Properties = Properties().apply {
if (failureFile.exists()) runCatching { failureFile.inputStream().use { load(it) } }
}
private fun recordFailure(jobName: String): Int {
val props = failures()
val count = (props.getProperty(jobName)?.toIntOrNull() ?: 0) + 1
props.setProperty(jobName, count.toString())
runCatching { failureFile.outputStream().use { props.store(it, null) } }
return count
}
private fun clearFailures(jobName: String) {
val props = failures()
if (props.remove(jobName) == null) return
runCatching { failureFile.outputStream().use { props.store(it, null) } }
}
companion object {
private const val QUEUE = "queue"
private const val WORK = "work"
private const val PAGES = "pages"
private const val IMAGES = "images"
private const val FAILED = "failed"
private const val LOG = "log.txt"
private const val FAILURES = "failures.properties"
internal const val META = "meta.properties"
private const val LOG_MAX_BYTES = 64 * 1024L
private const val LOG_KEEP_LINES = 300
private val MAX_ATTEMPTS: Int get() = com.bm.pagescribe.Config.MAX_JOB_ATTEMPTS
internal const val TITLE_KEY = "title"
internal const val CREATED_KEY = "createdAt"
/** Opens the book whose directory is [dir] (must already exist). */
fun open(dir: File): Book {
val props = Properties()
val meta = dir.resolve(META)
if (meta.exists()) runCatching { meta.inputStream().use { props.load(it) } }
val title = props.getProperty(TITLE_KEY)?.takeIf { it.isNotBlank() } ?: dir.name
return Book(dir, title)
}
/** Creates a new book directory named after the sanitised [rawTitle]. */
fun create(root: File, rawTitle: String): Book {
val title = rawTitle.trim()
require(title.isNotEmpty()) { "empty title" }
val dir = root.resolve(sanitiseFileName(title))
require(!dir.exists()) { "book already exists" }
require(dir.mkdirs()) { "could not create ${dir.absolutePath}" }
val props = Properties().apply {
setProperty(TITLE_KEY, title)
setProperty(CREATED_KEY, System.currentTimeMillis().toString())
}
dir.resolve(META).outputStream().use { props.store(it, "PageScribe book") }
return Book(dir, title)
}
internal fun pageFileName(number: Int): String =
String.format(Locale.US, "%04d.txt", number)
internal fun pageNumber(file: File): Int? =
file.nameWithoutExtension.toIntOrNull()
/**
* `"What If?: A Book"` -> `"What If A Book"`. Used for both the book
* directory and `<title>.txt`, so a title that differs only in illegal
* characters cannot collide with itself silently.
*/
internal fun sanitiseFileName(raw: String): String {
val cleaned = buildString(raw.length) {
for (c in raw) {
when {
c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' ||
c == '"' || c == '<' || c == '>' || c == '|' -> append(' ')
c.isISOControl() -> append(' ')
else -> append(c)
}
}
}
val collapsed = cleaned.replace(Regex("\\s+"), " ").trim().trim('.')
val limited = if (collapsed.length > 80) collapsed.take(80).trim() else collapsed
return limited.ifEmpty { "Untitled" }
}
internal fun writeAtomically(target: File, content: String) {
val temp = File(target.parentFile, target.name + ".tmp")
temp.writeText(content)
if (!temp.renameTo(target)) {
target.writeText(content)
temp.delete()
}
}
}
}
/** Raised when a book with the same sanitised title already exists. */
class BookExistsException(message: String) : IllegalStateException(message)
/** Finds and creates books under `files/books`. */
class BookRepository(private val context: Context) {
val root: File = File(context.filesDir, "books").apply { mkdirs() }
fun list(): List<Book> {
val files = root.listFiles() ?: return emptyList()
return files.filter { it.isDirectory && File(it, Book.META).exists() }
.map { Book.open(it) }
.sortedWith(compareBy { it.title.lowercase(Locale.US) })
}
fun find(key: String): Book? =
root.resolve(key).takeIf { it.isDirectory }?.let { Book.open(it) }
@Throws(BookExistsException::class)
fun create(title: String): Book {
val key = Book.sanitiseFileName(title)
if (root.resolve(key).exists()) throw BookExistsException(key)
return Book.create(root, title)
}
fun delete(book: Book) = book.deleteAll()
fun totalQueued(): Int = list().sumOf { it.queuedCount() }
fun totalPages(): Int = list().sumOf { it.pageNumbers().size }
/** Photos left in `work/` by a crash or a killed process go back to the queue. */
fun reclaimOrphans(minAgeMillis: Long = 0L) = list().forEach { it.reclaimOrphans(minAgeMillis) }
}

View File

@@ -0,0 +1,128 @@
package com.bm.pagescribe.ocr
import kotlin.math.max
/**
* Drops figure captions, i.e. the short lines that hang under a picture.
*
* A caption is identified by *what is above it* rather than by how it looks:
* a figure occupies the space between the preceding line of body text and the
* caption, so the gap above a caption is both very tall **and** full of
* picture content. A short isolated line over blank paper is not a caption —
* it is a short paragraph, a verse line or a heading, and stays.
*
* Coordinates are photo pixels; [gray] is used only to decide whether the gap
* above a candidate is picture or paper.
*/
object CaptionFilter {
/** Captions are short; long lines are body text. */
private const val MAX_CHARS = 80
/** Gap above a caption, as a multiple of the normal line gap. */
private const val ISOLATION = 2.0
/** Gap below a caption: there is always breathing room before body text. */
private const val GAP_BELOW = 1.1
/** Captions are never set larger than the body text. */
private const val HEIGHT_FACTOR = 1.25
/** ...and are narrower than a full body line. */
private const val WIDTH_FACTOR = 0.85
/** Trailing lines of the same caption block (artist credit, plate no.). */
private const val FOLLOW_UP_LINES = 2
private const val FOLLOW_UP_CHARS = 60
private const val FOLLOW_UP_GAP = 1.6
private const val FOLLOW_UP_WIDTH = 0.80
/** Below this many lines the statistics are not trustworthy. */
private const val MIN_LINES = 4
/** What must be visible in the gap for it to count as a picture. */
private const val MIN_DARK_FRACTION = 0.01
private const val MIN_DEVIATION = 6.0
fun filter(lines: List<OcrLine>, bounds: PageBounds, gray: GrayImage): List<OcrLine> {
if (lines.size < MIN_LINES) return lines
val ordered = lines.sortedWith(compareBy({ it.centerY }, { it.left }))
val bodyHeight = ordered.map { it.height }.medianInt(ordered.first().height.toDouble())
.coerceAtLeast(1.0)
val bodyWidth = ordered.filter { it.trimmed.length >= 15 }.map { it.width }
.medianInt(ordered.map { it.width }.medianInt(1.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())
}
val medianGap = gaps.medianDouble(1.0).coerceAtLeast(1.0)
val drop = HashSet<OcrLine>()
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 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
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
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++
}
}
return ordered.filterNot { it in drop }
}
private fun isCaptionCandidate(line: OcrLine, bodyHeight: Double, bodyWidth: Double): Boolean {
val text = line.trimmed
if (text.isEmpty() || text.length > MAX_CHARS) return false
if (line.height > HEIGHT_FACTOR * bodyHeight) return false
if (bodyWidth > 0 && line.width > WIDTH_FACTOR * bodyWidth) return false
return true
}
/** True when the strip of photo above a line contains something besides paper. */
private fun hasPicture(
gray: GrayImage,
bounds: PageBounds,
left: Int,
top: Int,
right: Int,
bottom: Int,
): Boolean {
if (bottom - top < 4) return false
val l = max(bounds.left, left)
val r = minOf(bounds.right, right)
if (r - l < 4) return false
val dark = gray.darkFraction(l, top, r, bottom)
if (dark < MIN_DARK_FRACTION) return false
return gray.deviation(l, top, r, bottom) >= MIN_DEVIATION
}
}

View File

@@ -0,0 +1,172 @@
package com.bm.pagescribe.ocr
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
/**
* The spine seam between the two photographed pages.
*
* A photo is rarely perfectly square to the book, so the seam is modelled as
* `x = intercept + slope * y` instead of a fixed column.
*/
data class Gutter(val slope: Double, val intercept: Double) {
fun xAt(y: Double): Double = intercept + slope * y
fun xAt(y: Int): Int = xAt(y.toDouble()).clampToInt(1_000_000)
companion object {
/** A seam that runs straight down the middle (or any fixed column). */
fun vertical(x: Int): Gutter = Gutter(0.0, x.toDouble())
}
}
/**
* Finds the gutter in a photograph of an open book.
*
* The seam is the one place where a *dark valley* has bright paper on **both**
* sides. Page edges against the table are bright/dark *steps*, not valleys, so
* they score ~0 and are ignored; the search window (`0.28w .. 0.72w`) keeps the
* outer edges of the photo out of the picture anyway.
*
* The search runs in several horizontal bands and fits a line through the
* results, which is what makes a tilted photo split correctly.
*/
object GutterDetector {
/** Bands the photo is divided into for seam sampling. */
private const val BANDS = 5
/** Seam must be this much darker (luma) than both shoulders. */
private const val MIN_VALLEY = 8.0
private const val SEARCH_FROM = 0.28
private const val SEARCH_TO = 0.72
fun find(gray: GrayImage): Gutter {
val w = gray.width
val h = gray.height
if (w < 48 || h < 48) return Gutter.vertical(w / 2)
val bandTop = (0.10 * h).toInt()
val bandBottom = (0.90 * h).toInt()
val bandHeight = max(1, (bandBottom - bandTop) / BANDS)
val means = DoubleArray(w)
val sampleY = ArrayList<Double>(BANDS)
val sampleX = ArrayList<Double>(BANDS)
for (band in 0 until BANDS) {
val top = bandTop + band * bandHeight
val bottom = if (band == BANDS - 1) bandBottom else top + bandHeight
if (bottom - top < 8) continue
columnMeans(gray, top, bottom, means)
smooth(means, 3)
val x = bestValley(means) ?: continue
sampleY.add((top + bottom) / 2.0)
sampleX.add(x)
}
return fit(sampleY, sampleX, w, h)
}
/** Mean luminance of every column over the row range `[top, bottom)`. */
private fun columnMeans(gray: GrayImage, top: Int, bottom: Int, out: DoubleArray) {
val rowStep = max(1, (bottom - top) / 48)
var rows = 0
for (y in top until bottom step rowStep) {
rows++
for (x in 0 until gray.width) {
out[x] += gray[x, y]
}
}
if (rows > 0) {
for (x in out.indices) out[x] /= rows
}
}
/** Box smoothing so photographic noise cannot win the argmax. */
private fun smooth(values: DoubleArray, radius: Int) {
val copy = values.copyOf()
for (i in values.indices) {
var sum = 0.0
var n = 0
for (j in (i - radius)..(i + radius)) {
if (j < 0 || j >= copy.size) continue
sum += copy[j]
n++
}
values[i] = sum / n
}
}
/** Column of the darkest valley that has brighter paper on both sides. */
private fun bestValley(means: DoubleArray): Double? {
val w = means.size
val from = (SEARCH_FROM * w).toInt().coerceIn(1, w - 2)
val to = (SEARCH_TO * w).toInt().coerceAtMost(w - 2)
val centreHalf = max(2, (0.005 * w).toInt())
val shoulderInner = max(centreHalf + 2, (0.012 * w).toInt())
val shoulderOuter = max(shoulderInner + 4, (0.040 * w).toInt())
var bestX = -1
var bestScore = MIN_VALLEY
for (x in from..to) {
if (x - shoulderOuter < 0 || x + shoulderOuter >= w) continue
val left = average(means, x - shoulderOuter, x - shoulderInner)
val right = average(means, x + shoulderInner, x + shoulderOuter)
val centre = average(means, x - centreHalf, x + centreHalf)
// Both shoulders must be brighter than the seam: a step (page edge)
// has one shoulder as dark as the centre and scores ~0.
val score = minOf(left, right) - centre
if (score > bestScore) {
bestScore = score
bestX = x
}
}
return if (bestX < 0) null else bestX.toDouble()
}
private fun average(values: DoubleArray, fromInclusive: Int, toExclusive: Int): Double {
val from = fromInclusive.coerceAtLeast(0)
val to = toExclusive.coerceAtMost(values.size)
if (to <= from) return 0.0
var sum = 0.0
for (i in from until to) sum += values[i]
return sum / (to - from)
}
/** Least-squares line through the per-band seam samples. */
private fun fit(sampleY: List<Double>, sampleX: List<Double>, width: Int, height: Int): Gutter {
when (sampleX.size) {
0 -> return Gutter.vertical(width / 2)
1 -> return Gutter.vertical(sampleX[0].roundToInt())
2 -> {
val median = sampleX.sorted()
return Gutter.vertical(((median[0] + median[1]) / 2.0).roundToInt())
}
}
val n = sampleX.size
val meanY = sampleY.average()
val meanX = sampleX.average()
var num = 0.0
var den = 0.0
for (i in 0 until n) {
val dy = sampleY[i] - meanY
num += dy * (sampleX[i] - meanX)
den += dy * dy
}
val slope = if (den < 1e-6) 0.0 else num / den
// A seam that would leave the photo is nonsense: fall back to vertical.
if (abs(slope) * height > 0.3 * width) {
val sorted = sampleX.sorted()
return Gutter.vertical(sorted[sorted.size / 2].roundToInt())
}
return Gutter(slope, meanX - slope * meanY)
}
}

View File

@@ -0,0 +1,266 @@
package com.bm.pagescribe.ocr
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import com.bm.pagescribe.Config
import com.bm.pagescribe.util.Bitmaps
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
/**
* Shrinks a photographed spread to the smallest file whose text is still
* readable, for long-term storage.
*
* Two knobs are turned, in this order:
*
* 1. **Resolution** — the image is downscaled until the shortest body line of
* the page is [minLinePx] tall (≈ 20 px), with a little headroom. Text
* smaller than that stops being comfortable to read when zoomed on a phone.
* Phone photos of a spread have 40-70 px lines, so this alone is a 2-4x win.
* 2. **Encoding** — the page is stored as grayscale (colour is pointless for
* paper) and the lowest JPEG and WebP quality that keeps the *body text*
* within [TEXT_MAE] of the lossless downscale is chosen, whichever file is
* smaller. Measuring the error where the text actually is — not over the
* whole page — is what allows a quality far lower than a photo would take.
*/
class ImageArchiver(
private val minLinePx: Int = Config.ARCHIVE_MIN_LINE_PX,
private val lineMargin: Double = Config.ARCHIVE_LINE_MARGIN,
private val minScale: Double = Config.ARCHIVE_MIN_SCALE,
) {
data class ArchivedPage(
val number: Int,
val file: File,
val width: Int,
val height: Int,
val bytes: Long,
)
/**
* Writes one archived image per page of the spread.
*
* @param source the spread as decoded for OCR
* @param gutterX the x of the spine in [source]
* @param lines every OCR line of the spread (photo coordinates)
* @param pageNumbers the resolved folio of the left and right page
*/
fun archive(
source: Bitmap,
gutterX: Int,
lines: List<OcrLine>,
pageNumbers: Pair<Int, Int>,
imagesDir: File,
): List<ArchivedPage> {
imagesDir.mkdirs()
val written = ArrayList<ArchivedPage>(2)
for (side in PageSide.entries) {
val number = if (side == PageSide.LEFT) pageNumbers.first else pageNumbers.second
val x0 = if (side == PageSide.LEFT) 0 else gutterX
val x1 = if (side == PageSide.LEFT) gutterX else source.width
val width = x1 - x0
if (width < 32) continue
val sideLines = lines.filter {
if (side == PageSide.LEFT) it.centerX < gutterX else it.centerX >= gutterX
}
val scale = archiveScale(sideLines, source.height)
val targetW = max(64, (width * scale).roundToInt())
val targetH = max(64, (source.height * scale).roundToInt())
val cropped = try {
Bitmap.createBitmap(source, x0, 0, width, source.height)
} catch (e: IllegalArgumentException) {
continue
}
val scaled = if (scale < 0.995) Bitmaps.downscale(cropped, targetW, targetH) else cropped
val gray = Bitmaps.toGrayBitmap(scaled)
try {
val encoded = chooseEncoding(gray, textRects(sideLines, x0, scale, gray.width, gray.height))
// Re-shooting a page can change the format (jpg <-> webp).
deleteExisting(imagesDir, number)
val file = File(imagesDir, pageFileName(number, encoded.extension))
file.writeBytes(encoded.bytes)
written.add(ArchivedPage(number, file, gray.width, gray.height, encoded.bytes.size.toLong()))
} finally {
gray.recycle()
if (scaled !== cropped) scaled.recycle()
cropped.recycle()
}
}
return written
}
/**
* Downscale factor that leaves the smallest body line of the page at
* [minLinePx] (plus [lineMargin] headroom). Never upscales.
*/
internal fun archiveScale(linesInHalf: List<OcrLine>, imageHeight: Int): Double {
val bodyLines = linesInHalf.filter { it.trimmed.length >= 3 && it.height > 1 }
val reference = bodyLines.minOfOrNull { it.height } ?: return DEFAULT_SCALE_WHEN_NO_TEXT
val scale = (minLinePx * lineMargin) / reference.toDouble()
return scale.coerceIn(minScale, 1.0)
}
// ------------------------------------------------------------ encoding
private data class IntRect(val left: Int, val top: Int, val right: Int, val bottom: Int)
private data class Encoded(
val format: Bitmap.CompressFormat,
val quality: Int,
val bytes: ByteArray,
val extension: String,
)
/** Lowest-quality encoding of either format that keeps the text faithful. */
private fun chooseEncoding(bitmap: Bitmap, rects: List<IntRect>): Encoded {
val reference = IntArray(bitmap.width * bitmap.height)
bitmap.getPixels(reference, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
val jpeg = bestFor(Bitmap.CompressFormat.JPEG, "jpg", bitmap, reference, rects)
val webp = bestFor(webpFormat(), "webp", bitmap, reference, rects)
val best = if (webp != null && (jpeg == null || webp.bytes.size < jpeg.bytes.size)) webp else jpeg
return best ?: throw IOException("could not encode a ${bitmap.width}x${bitmap.height} page")
}
private fun bestFor(
format: Bitmap.CompressFormat,
extension: String,
bitmap: Bitmap,
reference: IntArray,
rects: List<IntRect>,
): Encoded? {
var last: Encoded? = null
for (quality in QUALITY_LADDER) {
val bytes = encode(bitmap, format, quality) ?: return null
val candidate = Encoded(format, quality, bytes, extension)
if (isFaithful(bitmap, reference, rects, candidate)) return candidate
last = candidate
}
// Nothing on the ladder passed: keep the best quality we produced.
return last
}
private fun encode(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int): ByteArray? = runCatching {
ByteArrayOutputStream().use { out ->
bitmap.compress(format, quality, out)
out.toByteArray()
}
}.getOrNull()?.takeIf { it.isNotEmpty() }
/** Decodes [candidate] again and compares it with the lossless downscale. */
private fun isFaithful(
bitmap: Bitmap,
reference: IntArray,
rects: List<IntRect>,
candidate: Encoded,
): Boolean {
val decoded = BitmapFactory.decodeByteArray(candidate.bytes, 0, candidate.bytes.size) ?: return false
return try {
if (decoded.width != bitmap.width || decoded.height != bitmap.height) return false
val pixels = IntArray(decoded.width * decoded.height)
decoded.getPixels(pixels, 0, decoded.width, 0, 0, decoded.width, decoded.height)
val textError = meanError(reference, pixels, decoded.width, rects)
if (textError > TEXT_MAE) return false
meanError(reference, pixels, decoded.width, null) <= GLOBAL_MAE
} finally {
decoded.recycle()
}
}
/** Mean absolute luminance error, either inside [rects] or over the page. */
private fun meanError(
reference: IntArray,
candidate: IntArray,
width: Int,
rects: List<IntRect>?,
): Double {
if (rects == null) {
var sum = 0L
var count = 0
var i = 0
while (i < reference.size) {
sum += abs(luma(reference[i]) - luma(candidate[i]))
count++
i += 2
}
return if (count == 0) 0.0 else sum.toDouble() / count
}
var sum = 0L
var count = 0
for (rect in rects) {
for (y in rect.top until rect.bottom) {
var x = rect.left
val row = y * width
while (x < rect.right) {
sum += abs(luma(reference[row + x]) - luma(candidate[row + x]))
count++
x++
}
}
}
return if (count == 0) Double.MAX_VALUE else sum.toDouble() / count
}
private fun luma(argb: Int): Int {
val r = (argb shr 16) and 0xFF
val g = (argb shr 8) and 0xFF
val b = argb and 0xFF
return (r * 77 + g * 150 + b * 29) shr 8
}
/** OCR line boxes mapped into the coordinates of the archived image. */
private fun textRects(
lines: List<OcrLine>,
xOffset: Int,
scale: Double,
width: Int,
height: Int,
): List<IntRect> = lines.mapNotNull { line ->
val left = ((line.left - xOffset) * scale).roundToInt().coerceIn(0, width - 1)
val top = (line.top * scale).roundToInt().coerceIn(0, height - 1)
val right = ((line.right - xOffset) * scale).roundToInt().coerceIn(left + 1, width)
val bottom = (line.bottom * scale).roundToInt().coerceIn(top + 1, height)
IntRect(left, top, right, bottom)
}
private fun deleteExisting(dir: File, number: Int) {
val prefix = String.format(java.util.Locale.US, "%04d.", number)
dir.listFiles { f -> f.isFile && f.name.startsWith(prefix) }?.forEach { it.delete() }
}
private fun webpFormat(): Bitmap.CompressFormat =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Bitmap.CompressFormat.WEBP_LOSSY
} else {
@Suppress("DEPRECATION")
Bitmap.CompressFormat.WEBP
}
companion object {
/** Ascending quality ladder: the first acceptable rung wins. */
private val QUALITY_LADDER = intArrayOf(30, 38, 46, 55, 65, 75, 85)
/** Mean absolute luminance error allowed inside the text boxes. */
private const val TEXT_MAE = 3.0
/** ...and over the whole page (figures, paper texture). */
private const val GLOBAL_MAE = 6.0
/** Used when a page half has no recognised text at all. */
private const val DEFAULT_SCALE_WHEN_NO_TEXT = 0.35
internal fun pageFileName(number: Int, extension: String): String =
String.format(java.util.Locale.US, "%04d.%s", number, extension)
}
}

View File

@@ -0,0 +1,130 @@
package com.bm.pagescribe.ocr
import kotlin.math.roundToInt
/**
* One recognised text line, in pixel coordinates of the photo that was OCR'd.
*
* Deliberately free of any `android.*` type so the layout, ordering and
* filtering logic can be unit tested on the JVM.
*/
data class OcrLine(
val text: String,
val left: Int,
val top: Int,
val right: Int,
val bottom: Int,
) {
val width: Int get() = (right - left).coerceAtLeast(0)
val height: Int get() = (bottom - top).coerceAtLeast(0)
val centerX: Int get() = (left + right) / 2
val centerY: Int get() = (top + bottom) / 2
val trimmed: String get() = text.trim()
}
/** Which half of an open spread a line was photographed on. */
enum class PageSide { LEFT, RIGHT }
/**
* The rectangle of one half of the spread in photo coordinates. The vertical
* range is the whole photo; the horizontal range is image edge -> gutter (LEFT)
* or gutter -> image edge (RIGHT).
*/
data class PageBounds(
val left: Int,
val top: Int,
val right: Int,
val bottom: Int,
) {
val width: Int get() = (right - left).coerceAtLeast(1)
val height: Int get() = (bottom - top).coerceAtLeast(1)
fun clampTo(imageWidth: Int, imageHeight: Int): PageBounds = PageBounds(
left.coerceIn(0, imageWidth),
top.coerceIn(0, imageHeight),
right.coerceIn(0, imageWidth),
bottom.coerceIn(0, imageHeight),
)
}
/**
* Grayscale view of a photo: 0 = black, 255 = white. Coordinates outside the
* image clamp to the edge so callers never have to bounds-check.
*/
class GrayImage(private val pixels: ByteArray, val width: Int, val height: Int) {
init {
require(pixels.size >= width * height) {
"pixel buffer ${pixels.size} is smaller than ${width}x${height}"
}
}
operator fun get(x: Int, y: Int): Int {
val cx = if (x < 0) 0 else if (x >= width) width - 1 else x
val cy = if (y < 0) 0 else if (y >= height) height - 1 else y
return pixels[cy * width + cx].toInt() and 0xFF
}
/** Fraction of pixels in the box that are clearly not paper white. */
fun darkFraction(left: Int, top: Int, right: Int, bottom: Int): Double = stats(left, top, right, bottom).dark
/** Standard deviation of luminance in the box: ~0 for blank paper. */
fun deviation(left: Int, top: Int, right: Int, bottom: Int): Double = stats(left, top, right, bottom).std
private data class Stats(val dark: Double, val std: Double)
private fun stats(left: Int, top: Int, right: Int, bottom: Int): Stats {
val l = left.coerceAtMost(right)
val r = right.coerceAtLeast(left)
val t = top.coerceAtMost(bottom)
val b = bottom.coerceAtLeast(top)
if (r - l < 3 || b - t < 3) return Stats(0.0, 0.0)
// Sample at most ~48x24 points: enough for a stable estimate and cheap.
val stepX = ((r - l) / 48).coerceAtLeast(1)
val stepY = ((b - t) / 24).coerceAtLeast(1)
var count = 0
var sum = 0.0
var sumSq = 0.0
var dark = 0
var y = t
while (y < b) {
var x = l
while (x < r) {
val v = get(x, y)
count++
sum += v
sumSq += v.toDouble() * v
if (v < 200) dark++
x += stepX
}
y += stepY
}
if (count == 0) return Stats(0.0, 0.0)
val mean = sum / count
val variance = (sumSq / count) - (mean * mean)
return Stats(dark.toDouble() / count, kotlin.math.sqrt(variance.coerceAtLeast(0.0)))
}
}
/** Median of an int list; `fallback` when the list is empty. */
internal fun List<Int>.medianInt(fallback: Double = 0.0): Double {
if (isEmpty()) return fallback
val sorted = sorted()
val mid = size / 2
return if (size % 2 == 1) sorted[mid].toDouble() else (sorted[mid - 1] + sorted[mid]) / 2.0
}
/** Median of a double list; `fallback` when the list is empty. */
internal fun List<Double>.medianDouble(fallback: Double = 0.0): Double {
if (isEmpty()) return fallback
val sorted = sorted()
val mid = size / 2
return if (size % 2 == 1) sorted[mid] else (sorted[mid - 1] + sorted[mid]) / 2.0
}
/** Rounds a double to an int, clamped into `[0, max]`. */
internal fun Double.clampToInt(max: Int): Int {
val v = roundToInt()
return if (v < 0) 0 else if (v > max) max else v
}

View File

@@ -0,0 +1,89 @@
package com.bm.pagescribe.ocr
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.bm.pagescribe.R
import com.bm.pagescribe.book.Book
import com.bm.pagescribe.book.BookRepository
import com.bm.pagescribe.util.Notifications
import kotlinx.coroutines.CancellationException
import java.io.File
/**
* Drains the queue: every `queue/<id>.jpg` of every book is OCR'd, turned into
* page files and archived, one photo at a time, in capture order.
*
* A photo that keeps failing goes back to the queue a few times (see
* [Book.failJob]) and is then parked in `failed/` so it cannot block the rest
* of the book. Failures make the worker ask for a retry; everything else is
* handled in-process so one bad photo does not stop the batch.
*/
class OcrWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val repository = BookRepository(applicationContext)
Notifications.ensureChannel(applicationContext)
var processed = 0
var failures = 0
var lastBook: Book? = null
while (!isStopped) {
val claim = claimNext(repository) ?: break
val (book, job) = claim
lastBook = book
try {
Notifications.progress(
applicationContext,
applicationContext.getString(R.string.notif_running, book.title),
applicationContext.getString(R.string.book_pages_label, book.pageNumbers().size, book.queuedCount()),
)
SpreadProcessor(applicationContext).process(book, job)
book.completeJob(job)
processed++
} catch (cancelled: CancellationException) {
// Put the photo back so the next run picks it up.
book.failJob(job, "cancelled")
throw cancelled
} catch (error: Exception) {
failures++
book.failJob(job, error.message ?: error::class.java.simpleName)
}
}
val remaining = repository.totalQueued()
val book = lastBook
if (book != null) {
if (processed > 0) {
Notifications.finished(
applicationContext,
applicationContext.getString(R.string.app_name),
applicationContext.getString(R.string.notif_done, book.title),
)
} else if (failures > 0 && remaining == 0) {
Notifications.finished(
applicationContext,
applicationContext.getString(R.string.app_name),
applicationContext.getString(R.string.notif_failed, book.title),
)
}
}
if (processed == 0 && failures == 0) Notifications.clear(applicationContext)
return if (failures > 0 && remaining > 0) Result.retry() else Result.success()
}
/** Oldest queued photo of any book, moved into that book's `work/` folder. */
private fun claimNext(repository: BookRepository): Pair<Book, File>? {
for (book in repository.list()) {
val job = book.claimNextJob()
if (job != null) return book to job
}
return null
}
}

View File

@@ -0,0 +1,79 @@
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
* also proves which half of the photo is which page.
*
* The line is returned as well as the number, because the caller removes it
* from the body text.
*/
object PageNumberDetector {
/** A folio sits within this fraction of the page height of the edge. */
private const val TOP_BAND = 0.10
private const val BOTTOM_BAND = 0.12
/** ...and within this fraction of the page width of the *outer* edge. */
private const val OUTER_MARGIN = 0.45
/** Anything taller than this multiple of the body line is a chapter number. */
private const val MAX_HEIGHT_FACTOR = 1.8
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 span = (textBottom - textTop).coerceAtLeast(1)
val bodyHeight = lines.map { it.height }.medianInt(1.0).coerceAtLeast(1.0)
val pageWidth = bounds.width
var best: Detected? = null
var bestScore = Double.NEGATIVE_INFINITY
for (line in lines) {
val number = folioNumber(line.text) ?: continue
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 distanceFromOuter = when (side) {
PageSide.LEFT -> line.centerX - bounds.left
PageSide.RIGHT -> bounds.right - line.centerX
}
if (distanceFromOuter > OUTER_MARGIN * pageWidth) continue
// Bottom beats top (folios are usually printed below the text),
// then the tightest outer margin, then the extreme of the band.
val bandRank = if (inBottom) 2 else 1
val edgeDistance = if (inBottom) textBottom - cy else cy - textTop
val score = bandRank * 1e9 - distanceFromOuter * 1e3 - edgeDistance
if (score > bestScore) {
bestScore = score
best = Detected(number, line)
}
}
return best
}
/**
* `" 42 "` / `"· 42 ·"` / `"42."` -> `42`, everything else (prose, chapter
* titles, roman numerals) -> `null`.
*/
internal fun folioNumber(raw: String): Int? {
val cleaned = raw.trim()
.trim('·', '•', '-', '–', '—', '.', ',', ':', '|', '/', '(', ')', '[', ']', '*', ' ')
if (cleaned.isEmpty() || cleaned.length > 4) return null
if (!cleaned.all { it in '0'..'9' }) return null
return cleaned.toIntOrNull()?.takeIf { it > 0 }
}
}

View File

@@ -0,0 +1,82 @@
package com.bm.pagescribe.ocr
/**
* Decides which pages a photographed spread holds.
*
* The folio printed on the page is the primary key for ordering, so it is used
* whenever OCR found it. Everything else is a documented fallback, and every
* fallback is recorded in the book's log so a guess can always be spotted and
* re-shot.
*/
object PageNumberResolver {
data class Resolution(
val left: Int,
val right: Int,
val derived: Boolean,
val notes: List<String>,
)
/**
* @param existing folios already stored in the book
* @param detectedLeft folio read from the left page, if any
* @param detectedRight folio read from the right page, if any
*/
fun resolve(existing: Collection<Int>, detectedLeft: Int?, detectedRight: Int?): Resolution {
val notes = ArrayList<String>(2)
val left: Int
val right: Int
var derived = false
when {
detectedLeft != null && detectedRight != null -> {
left = detectedLeft
right = detectedRight
if (right != left + 1) {
notes.add("Folios $left and $right are not consecutive; both were kept as printed.")
}
}
detectedLeft != null -> {
left = detectedLeft
right = detectedLeft + 1
derived = true
notes.add("No folio on the right page; used ${right}.")
}
detectedRight != null -> {
right = detectedRight
left = detectedRight - 1
derived = true
notes.add("No folio on the left page; used ${left}.")
}
else -> {
val highest = existing.maxOrNull()
left = when {
highest == null -> 1
highest % 2 == 0 -> highest + 2
else -> highest + 1
}
right = left + 1
derived = true
notes.add(
if (highest == null) {
"No folios found anywhere in the spread; assumed the first pages, $left and $right."
} else {
"No folios found; placed after the last page, $left and $right."
}
)
}
}
val low = minOf(left, right)
val high = maxOf(left, right)
if (low < 1) notes.add("Computed folio $low is not a real page number.")
if (existing.contains(low) || existing.contains(high)) {
notes.add("Page $low or $high was photographed before; the new picture replaced it.")
}
if (left % 2 != 0) {
notes.add("Left page carries the odd folio $left (spreads normally start on an even page).")
}
return Resolution(left, right, derived, notes)
}
}

View File

@@ -0,0 +1,164 @@
package com.bm.pagescribe.ocr
import kotlin.math.max
/**
* Turns a page's OCR lines into flowing text.
*
* OCR hands back one entry per *visual* line, but a book page is paragraphs.
* The join rules below are the standard ones:
*
* - a line indented from the left margin starts a paragraph,
* - a line that stops well short of the right margin ends one,
* - an unusually tall gap means a section break,
* - three or more consecutive short lines are verse (or a list) and keep their
* line breaks instead of being folded into one paragraph,
* - a word split with a hyphen at the line end is glued back together.
*/
object ParagraphReflow {
/** A line this much narrower than the body is not body text. */
private const val SHORT_LINE = 0.72
/** End-of-paragraph shortfall, as a fraction of the text block width. */
private const val SHORTFALL = 0.15
/** Gap that signals a break, as a multiple of the normal line gap. */
private const val SECTION_GAP = 2.2
/** First-line indent, as a fraction of the body line height. */
private const val INDENT_FACTOR = 0.7
/** Minimum verse run before line breaks are preserved. */
private const val VERSE_RUN = 3
private const val NON_BREAKING_SPACE = '\u00A0'
private const val FIGURE_SPACE = '\u2007'
private const val SOFT_HYPHEN = '\u00AD'
private val NO_SPACE_BEFORE = charArrayOf(',', '.', ';', ':', '!', '?', ')', ']')
fun toText(lines: List<OcrLine>): String {
if (lines.isEmpty()) return ""
val ordered = lines.sortedWith(compareBy({ it.centerY }, { it.left }))
if (ordered.size == 1) return normalize(ordered.first().text)
val bodyWidth = ordered.filter { it.trimmed.length >= 15 }.map { it.width }
.medianInt(ordered.map { it.width }.medianInt(1.0))
.coerceAtLeast(1.0)
val bodyHeight = ordered.map { it.height }.medianInt(1.0).coerceAtLeast(1.0)
val textLeft = ordered.minOf { it.left }
val textRight = ordered.maxOf { it.right }
val blockWidth = (textRight - textLeft).coerceAtLeast(1)
val gaps = ArrayList<Double>(ordered.size)
for (i in 1 until ordered.size) {
val gap = ordered[i].top - ordered[i - 1].bottom
if (gap > 0) gaps.add(gap.toDouble())
}
val medianGap = gaps.medianDouble(1.0).coerceAtLeast(1.0)
val indentThreshold = max(6.0, INDENT_FACTOR * bodyHeight)
val shortfallThreshold = SHORTFALL * blockWidth
val short = BooleanArray(ordered.size) { ordered[it].width <= SHORT_LINE * bodyWidth }
val verse = verseBlocks(short)
val out = StringBuilder()
for (i in ordered.indices) {
val line = ordered[i]
val text = normalize(line.text)
if (i == 0) {
out.append(text)
continue
}
val previous = ordered[i - 1]
val separator = when {
verse[i] && verse[i - 1] -> "\n"
verse[i] || verse[i - 1] -> "\n\n"
line.left - textLeft >= indentThreshold -> "\n\n"
textRight - previous.right >= shortfallThreshold -> "\n\n"
line.top - previous.bottom >= SECTION_GAP * medianGap -> "\n\n"
else -> ""
}
if (separator.isEmpty()) {
out.append(join(normalize(previous.text), text))
} else {
out.append(separator).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
* a new paragraph.
*/
fun joinPages(previous: String, next: String): String {
val left = previous.trimEnd()
val right = next.trimStart()
if (left.isEmpty()) return right
if (right.isEmpty()) return left
val midSentence = left.lastOrNull()?.let { !it.isTerminal() } ?: false
val startsLower = right.first().isLowerCase()
if (!midSentence || !startsLower) return "$left\n\n$right"
if (left.endsWith('-')) return left.dropLast(1) + right
return "$left $right"
}
private fun Char.isTerminal(): Boolean =
this == '.' || this == '!' || this == '?' || this == ':' ||
this == '"' || this == '\'' || this == '\u2019' || this == '\u201D' ||
this == ')' || this == ']' || this == '\u2026'
/** Marks lines that sit inside a run of at least [VERSE_RUN] short lines. */
private fun verseBlocks(short: BooleanArray): BooleanArray {
val verse = BooleanArray(short.size)
var i = 0
while (i < short.size) {
if (!short[i]) {
i++
continue
}
var end = i
while (end < short.size && short[end]) end++
if (end - i >= VERSE_RUN) {
for (k in i until end) verse[k] = true
}
i = end
}
return verse
}
/** Collapses the stray double spaces and exotic spaces OCR likes to emit. */
private fun normalize(text: String): String =
text.replace(NON_BREAKING_SPACE, ' ')
.replace(FIGURE_SPACE, ' ')
.trim()
.replace(Regex(" {2,}"), " ")
}

View File

@@ -0,0 +1,136 @@
package com.bm.pagescribe.ocr
import android.content.Context
import android.graphics.Bitmap
import com.bm.pagescribe.Config
import com.bm.pagescribe.book.Book
import com.bm.pagescribe.util.Bitmaps
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.Text
import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
import kotlinx.coroutines.tasks.await
import java.io.File
import java.io.IOException
/**
* Turns one photographed spread into up to two page files.
*
* The pipeline, in order:
*
* 1. decode the photo (EXIF-aware, capped at [Config.OCR_MAX_EDGE]),
* 2. find the spine so the spread can be split into a left and a right page,
* 3. recognise all text in one pass,
* 4. read the printed folios and let [PageNumberResolver] fill in the gaps,
* 5. drop the folio lines and any figure captions ([CaptionFilter]),
* 6. re-flow each page into paragraphs ([ParagraphReflow]) and store it as
* `pages/<folio>.txt`,
* 7. shrink the picture into the long-term archive ([ImageArchiver]).
*
* Steps 4-6 are what make the assembled book ordered correctly even when the
* spreads are photographed out of order or a page is re-shot.
*/
class SpreadProcessor(context: Context) {
private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
data class Outcome(
val pages: List<Int>,
val derived: Boolean,
val notes: List<String>,
val archivedBytes: Long,
)
suspend fun process(book: Book, photo: File): Outcome {
val bitmap = Bitmaps.decodeDownsampled(photo, Config.OCR_MAX_EDGE)
?: throw IOException("could not decode ${photo.name}")
try {
return processBitmap(book, bitmap)
} finally {
bitmap.recycle()
}
}
private suspend fun processBitmap(book: Book, bitmap: Bitmap): Outcome {
val gray = Bitmaps.toGrayImage(bitmap)
val gutter = GutterDetector.find(gray)
val gutterX = gutter.xAt(bitmap.height / 2).coerceIn(1, bitmap.width - 1)
val recognised = recognizer.process(InputImage.fromBitmap(bitmap, 0)).await()
val lines = recognised.textBlocks.flatMap { it.lines }.mapNotNull { it.toOcrLine() }
if (lines.isEmpty()) throw IOException("no readable text in this photo")
val leftLines = lines.filter { it.centerX < gutterX }
val rightLines = lines.filter { it.centerX >= gutterX }
val leftBounds = PageBounds(0, 0, gutterX, bitmap.height)
val rightBounds = PageBounds(gutterX, 0, bitmap.width, bitmap.height)
val leftFolio = PageNumberDetector.detect(leftLines, leftBounds, PageSide.LEFT)
val rightFolio = PageNumberDetector.detect(rightLines, rightBounds, PageSide.RIGHT)
val resolution = PageNumberResolver.resolve(book.pageNumbers(), leftFolio?.number, rightFolio?.number)
val notes = ArrayList(resolution.notes)
val perPage = listOf(
Triple(PageSide.LEFT, leftFolio?.line, leftLines),
Triple(PageSide.RIGHT, rightFolio?.line, rightLines),
)
var captionsDropped = 0
val pageTexts = ArrayList<Pair<Int, String>>(2)
for ((side, folioLine, sideLines) in perPage) {
val number = if (side == PageSide.LEFT) resolution.left else resolution.right
val bounds = if (side == PageSide.LEFT) leftBounds else rightBounds
var kept = sideLines
if (!Config.KEEP_PAGE_NUMBERS && folioLine != null) {
kept = kept.filterNot { it === folioLine }
}
val filtered = CaptionFilter.filter(kept, bounds, gray)
captionsDropped += kept.size - filtered.size
pageTexts.add(number to ParagraphReflow.toText(filtered))
}
if (captionsDropped > 0) notes.add("Ignored $captionsDropped caption line(s).")
for ((number, text) in pageTexts) {
if (number >= 1) book.writePage(number, text)
}
book.assembleTextFile()
val archived = archiveImages(book, bitmap, gutterX, lines, resolution)
for (note in notes) book.appendLog(note)
val description = pageTexts.joinToString(", ") { "${it.first} (${it.second.length} chars)" }
book.appendLog("Read pages $description from the photo.")
return Outcome(
pages = pageTexts.map { it.first },
derived = resolution.derived,
notes = notes,
archivedBytes = archived,
)
}
private fun archiveImages(
book: Book,
bitmap: Bitmap,
gutterX: Int,
lines: List<OcrLine>,
resolution: PageNumberResolver.Resolution,
): Long = runCatching {
val numbers = resolution.left to resolution.right
ImageArchiver()
.archive(bitmap, gutterX, lines, numbers, book.imagesDir)
.sumOf { it.bytes }
}.getOrElse { error ->
book.appendLog("Could not archive the images: ${error.message}")
0L
}
private fun Text.Line.toOcrLine(): OcrLine? {
val box = boundingBox ?: return null
if (text.isBlank()) return null
return OcrLine(text, box.left, box.top, box.right, box.bottom)
}
}

View File

@@ -0,0 +1,31 @@
package com.bm.pagescribe.ocr
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
/**
* Starts the background OCR queue.
*
* Every captured photo appends to the *same* unique chain, which is what makes
* the queue safe: work appended to a running chain is guaranteed to run after
* it, so a photo queued in the middle of a long OCR pass can never be lost (the
* worker itself also drains everything it can see, so most appended requests
* are cheap no-ops).
*/
object WorkScheduler {
private const val UNIQUE_NAME = "pagescribe-ocr"
const val TAG = "ocr-queue"
fun ensureRunning(context: Context) {
val request = OneTimeWorkRequestBuilder<OcrWorker>()
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(UNIQUE_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, request)
}
}

View File

@@ -0,0 +1,152 @@
package com.bm.pagescribe.util
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.exifinterface.media.ExifInterface
import com.bm.pagescribe.ocr.GrayImage
import java.io.File
import kotlin.math.max
import kotlin.math.roundToInt
/** Bitmap plumbing shared by the OCR pipeline and the image archiver. */
object Bitmaps {
/**
* Decodes [file] so that its longest edge is at most [maxEdge], honouring the
* EXIF rotation the camera wrote into the JPEG.
*
* The sample size is chosen to *undershoot* [maxEdge] (decode at ~2x, then
* scale exactly), which keeps peak memory around 50 MB for a 12 MP phone
* photo instead of decoding it full size first.
*/
fun decodeDownsampled(file: File, maxEdge: Int): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
var sample = 1
while (bounds.outWidth / (sample * 2) >= maxEdge && bounds.outHeight / (sample * 2) >= maxEdge) {
sample *= 2
}
val options = BitmapFactory.Options().apply {
inSampleSize = sample
inPreferredConfig = Bitmap.Config.ARGB_8888
}
val decoded = BitmapFactory.decodeFile(file.absolutePath, options) ?: return null
val longEdge = max(decoded.width, decoded.height)
val scaled = if (longEdge > maxEdge) {
val factor = maxEdge.toFloat() / longEdge
val w = (decoded.width * factor).roundToInt().coerceAtLeast(1)
val h = (decoded.height * factor).roundToInt().coerceAtLeast(1)
Bitmap.createScaledBitmap(decoded, w, h, true).also {
if (it !== decoded) decoded.recycle()
}
} else {
decoded
}
val orientation = readOrientation(file)
return if (orientation == ExifInterface.ORIENTATION_NORMAL) {
scaled
} else {
applyOrientation(scaled, orientation)
}
}
private fun readOrientation(file: File): Int = runCatching {
ExifInterface(file.absolutePath)
.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
private fun applyOrientation(source: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.setScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.setRotate(90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.setRotate(-90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f)
else -> return source
}
return runCatching {
Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true)
}.getOrDefault(source).also { rotated ->
if (rotated !== source) source.recycle()
}
}
/**
* Grayscale copy of [source] in a single [ByteArray] (row by row, so the
* temporary int buffer stays one row wide).
*/
fun toGrayImage(source: Bitmap): GrayImage {
val w = source.width
val h = source.height
val gray = ByteArray(w * h)
val row = IntArray(w)
for (y in 0 until h) {
source.getPixels(row, 0, w, 0, y, w, 1)
val base = y * w
for (x in 0 until w) {
val p = row[x]
val r = (p shr 16) and 0xFF
val g = (p shr 8) and 0xFF
val b = p and 0xFF
gray[base + x] = ((r * 77 + g * 150 + b * 29) shr 8).toByte()
}
}
return GrayImage(gray, w, h)
}
/** An ARGB_8888 bitmap whose R, G and B channels all hold the luminance. */
fun toGrayBitmap(source: Bitmap): Bitmap {
val w = source.width
val h = source.height
val pixels = IntArray(w * h)
source.getPixels(pixels, 0, w, 0, 0, w, h)
for (i in pixels.indices) {
val p = pixels[i]
val r = (p shr 16) and 0xFF
val g = (p shr 8) and 0xFF
val b = p and 0xFF
val luma = (r * 77 + g * 150 + b * 29) shr 8
pixels[i] = (0xFF shl 24) or (luma shl 16) or (luma shl 8) or luma
}
return Bitmap.createBitmap(pixels, w, h, Bitmap.Config.ARGB_8888)
}
/**
* Scales [source] down to [targetW] x [targetH] by repeated halving, then a
* final bilinear step. Halving in stages avoids the aliasing that a single
* large downscale smears across small text.
*/
fun downscale(source: Bitmap, targetW: Int, targetH: Int): Bitmap {
if (source.width == targetW && source.height == targetH) return source
var current = source
var w = source.width
var h = source.height
while (w / 2 >= targetW && h / 2 >= targetH) {
val next = Bitmap.createScaledBitmap(current, w / 2, h / 2, true)
if (current !== source) current.recycle()
current = next
w /= 2
h /= 2
}
if (w == targetW && h == targetH) return current
val result = Bitmap.createScaledBitmap(current, targetW, targetH, true)
if (current !== source && current !== result) current.recycle()
return result
}
}

View File

@@ -0,0 +1,63 @@
package com.bm.pagescribe.util
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.bm.pagescribe.R
/**
* One low-key notification that follows the background OCR queue. It is *not* a
* foreground service: Android 12+ will not let a background worker start one,
* and it is not needed — WorkManager's own execution window is enough.
*/
object Notifications {
private const val CHANNEL_ID = "ocr"
private const val NOTIFICATION_ID = 1001
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.notif_channel_ocr),
NotificationManager.IMPORTANCE_LOW
).apply {
description = context.getString(R.string.notif_channel_ocr_desc)
setShowBadge(false)
}
)
}
fun progress(context: Context, title: String, text: String) {
post(context, title, text, ongoing = true)
}
fun finished(context: Context, title: String, text: String) {
post(context, title, text, ongoing = false)
}
fun clear(context: Context) {
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
}
private fun post(context: Context, title: String, text: String, ongoing: Boolean) {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_pages)
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(ongoing)
.setOnlyAlertOnce(true)
.setSilent(true)
.build()
runCatching {
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification)
}
}
}

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</vector>

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,15.2m-3.2,0a3.2,3.2 0,1 1,6.4 0a3.2,3.2 0,1 1,-6.4 0" />
<path
android:fillColor="@android:color/white"
android:pathData="M9,2L7.17,4L4,4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2L9,2zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z" />
</vector>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3E5C9A"
android:pathData="M0,0h108v108h-108z" />
<!-- Open book -->
<path
android:fillColor="#FBF7EF"
android:pathData="M18,30 h30 c6,0 10,4 10,10 v40 c0,-6 -4,-10 -10,-10 h-30 z" />
<path
android:fillColor="#FBF7EF"
android:pathData="M90,30 h-30 c-6,0 -10,4 -10,10 v40 c0,-6 4,-10 10,-10 h30 z" />
<path
android:fillColor="#5B8DEF"
android:pathData="M52,42 h4 v38 h-4 z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6zM20,2H8c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM20,16H8V4h12v12z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@android:color/white" />
<stroke
android:width="4dp"
android:color="#80FFFFFF" />
<size
android:width="84dp"
android:height="84dp" />
</shape>

View File

@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/bookMeta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="13sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCapture"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:icon="@drawable/ic_camera"
android:text="@string/book_capture" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnExport"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:icon="@drawable/ic_share"
android:text="@string/book_export" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDelete"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:icon="@drawable/ic_delete"
android:text="@string/book_delete" />
<TextView
android:id="@+id/emptyText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center"
android:text="@string/book_no_pages"
android:textSize="15sp"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/pageList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/book_log"
android:textSize="12sp"
android:textStyle="bold" />
<ScrollView
android:id="@+id/logScroll"
android:layout_width="match_parent"
android:layout_height="96dp"
android:fadeScrollbars="false">
<TextView
android:id="@+id/logText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textIsSelectable="true"
android:textSize="12sp" />
</ScrollView>
</LinearLayout>

View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
<androidx.camera.view.PreviewView
android:id="@+id/previewView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:background="#66000000"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/captureStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@android:color/white"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/capture_hint"
android:textColor="#DDFFFFFF"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="#66000000"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="20dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnFlash"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:icon="@drawable/ic_flash"
android:text="@string/capture_flash_off"
android:textColor="@android:color/white"
app:strokeColor="@android:color/white" />
<ImageButton
android:id="@+id/btnShutter"
android:layout_width="84dp"
android:layout_height="84dp"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:background="@drawable/shutter_bg"
android:contentDescription="@string/capture_shutter"
android:src="@drawable/ic_camera"
app:tint="@color/ink" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPages"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:icon="@drawable/ic_pages"
android:text="@string/capture_pages"
android:textColor="@android:color/white"
app:strokeColor="@android:color/white" />
</LinearLayout>
<LinearLayout
android:id="@+id/permissionPanel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="#CC000000"
android:orientation="vertical"
android:padding="24dp"
android:visibility="gone">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/capture_permission"
android:textColor="@android:color/white" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPermission"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="12dp"
android:text="@string/capture_grant" />
</LinearLayout>
</FrameLayout>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/ocrStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/ocr_idle"
android:textSize="13sp" />
<TextView
android:id="@+id/emptyText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:gravity="center"
android:text="@string/books_empty"
android:textSize="16sp"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/bookList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:clipToPadding="false"
android:paddingBottom="88dp" />
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btnNewBook"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="20dp"
android:contentDescription="@string/books_new"
android:src="@drawable/ic_add"
app:tint="@android:color/white" />
</FrameLayout>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:orientation="vertical"
android:paddingStart="4dp"
android:paddingTop="14dp"
android:paddingEnd="4dp"
android:paddingBottom="14dp">
<TextView
android:id="@+id/bookTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/bookSubtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textSize="13sp" />
</LinearLayout>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="4dp"
android:paddingTop="10dp"
android:paddingEnd="4dp"
android:paddingBottom="10dp">
<TextView
android:id="@+id/pageNumber"
android:layout_width="56dp"
android:layout_height="wrap_content"
android:textColor="@color/ink"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/pageSnippet"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="2"
android:textColor="@color/ink"
android:textSize="14sp" />
</LinearLayout>

View File

@@ -0,0 +1,7 @@
<resources>
<color name="primary">#3E5C9A</color>
<color name="primary_dark">#26375C</color>
<color name="accent">#5B8DEF</color>
<color name="paper">#FBF7EF</color>
<color name="ink">#1B1B1B</color>
</resources>

View File

@@ -0,0 +1,51 @@
<resources>
<string name="app_name">PageScribe</string>
<!-- Book list -->
<string name="books_empty">No books yet. Tap + to start one.</string>
<string name="books_new">New book</string>
<string name="books_new_title">Book title</string>
<string name="books_new_hint">e.g. The Lord of the Rings</string>
<string name="books_create">Create</string>
<string name="books_cancel">Cancel</string>
<string name="books_title_required">Enter a title for the book.</string>
<string name="books_exists">A book with that title already exists.</string>
<string name="ocr_idle">OCR queue idle.</string>
<string name="ocr_running">OCR running: %1$d queued, %2$d pages done.</string>
<!-- Book detail -->
<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_capture">Capture</string>
<string name="book_export">Export text</string>
<string name="book_delete">Delete book</string>
<string name="book_delete_confirm">Delete this book, its text file and its archived images?</string>
<string name="book_export_empty">Nothing to share yet — no pages have been OCR\'d.</string>
<string name="book_log">Log</string>
<string name="page_queued">queued</string>
<string name="page_processing">processing</string>
<string name="page_failed">failed</string>
<!-- Capture -->
<string name="title_capture">Capture</string>
<string name="capture_permission">Camera access is needed to photograph pages.</string>
<string name="capture_grant">Grant camera access</string>
<string name="capture_shutter">Take picture</string>
<string name="capture_flash">Flash</string>
<string name="capture_flash_on">Flash on</string>
<string name="capture_flash_off">Flash off</string>
<string name="capture_saved">Queued for OCR</string>
<string name="capture_failed">Could not save that picture: %1$s</string>
<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_hint">Photograph the whole open spread, square to the page.</string>
<!-- Notification -->
<string name="notif_channel_ocr">Book OCR</string>
<string name="notif_channel_ocr_desc">Progress while pages are being turned into text</string>
<string name="notif_running">Reading pages of %1$s</string>
<string name="notif_done">Finished: %1$s</string>
<string name="notif_failed">OCR failed for %1$s</string>
</resources>

View File

@@ -0,0 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.PageScribe" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorSecondary">@color/accent</item>
<item name="android:windowBackground">@color/paper</item>
</style>
</resources>

5
build.gradle.kts Normal file
View File

@@ -0,0 +1,5 @@
// Top-level build file. Plugin versions are declared here and applied in :app.
plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}

8
gradle.properties Normal file
View File

@@ -0,0 +1,8 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.parallel=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

18
settings.gradle.kts Normal file
View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "PageScribe"
include(":app")