PageScribe 0.1: camera capture, background OCR queue, page-numbered text assembly, image archiver
This commit is contained in:
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal 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
110
app/build.gradle.kts
Normal 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
3
app/proguard-rules.pro
vendored
Normal 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.**
|
||||||
44
app/src/main/AndroidManifest.xml
Normal file
44
app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
197
app/src/main/java/com/bm/pagescribe/BookActivity.kt
Normal file
197
app/src/main/java/com/bm/pagescribe/BookActivity.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
209
app/src/main/java/com/bm/pagescribe/CaptureActivity.kt
Normal file
209
app/src/main/java/com/bm/pagescribe/CaptureActivity.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/src/main/java/com/bm/pagescribe/Config.kt
Normal file
36
app/src/main/java/com/bm/pagescribe/Config.kt
Normal 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
|
||||||
|
}
|
||||||
160
app/src/main/java/com/bm/pagescribe/MainActivity.kt
Normal file
160
app/src/main/java/com/bm/pagescribe/MainActivity.kt
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/src/main/java/com/bm/pagescribe/PageScribeApp.kt
Normal file
24
app/src/main/java/com/bm/pagescribe/PageScribeApp.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
302
app/src/main/java/com/bm/pagescribe/book/Book.kt
Normal file
302
app/src/main/java/com/bm/pagescribe/book/Book.kt
Normal 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) }
|
||||||
|
}
|
||||||
128
app/src/main/java/com/bm/pagescribe/ocr/CaptionFilter.kt
Normal file
128
app/src/main/java/com/bm/pagescribe/ocr/CaptionFilter.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
172
app/src/main/java/com/bm/pagescribe/ocr/GutterDetector.kt
Normal file
172
app/src/main/java/com/bm/pagescribe/ocr/GutterDetector.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
266
app/src/main/java/com/bm/pagescribe/ocr/ImageArchiver.kt
Normal file
266
app/src/main/java/com/bm/pagescribe/ocr/ImageArchiver.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
130
app/src/main/java/com/bm/pagescribe/ocr/OcrModels.kt
Normal file
130
app/src/main/java/com/bm/pagescribe/ocr/OcrModels.kt
Normal 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
|
||||||
|
}
|
||||||
89
app/src/main/java/com/bm/pagescribe/ocr/OcrWorker.kt
Normal file
89
app/src/main/java/com/bm/pagescribe/ocr/OcrWorker.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
164
app/src/main/java/com/bm/pagescribe/ocr/ParagraphReflow.kt
Normal file
164
app/src/main/java/com/bm/pagescribe/ocr/ParagraphReflow.kt
Normal 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,}"), " ")
|
||||||
|
}
|
||||||
136
app/src/main/java/com/bm/pagescribe/ocr/SpreadProcessor.kt
Normal file
136
app/src/main/java/com/bm/pagescribe/ocr/SpreadProcessor.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/src/main/java/com/bm/pagescribe/ocr/WorkScheduler.kt
Normal file
31
app/src/main/java/com/bm/pagescribe/ocr/WorkScheduler.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
152
app/src/main/java/com/bm/pagescribe/util/Bitmaps.kt
Normal file
152
app/src/main/java/com/bm/pagescribe/util/Bitmaps.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/src/main/java/com/bm/pagescribe/util/Notifications.kt
Normal file
63
app/src/main/java/com/bm/pagescribe/util/Notifications.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/src/main/res/drawable/ic_add.xml
Normal file
10
app/src/main/res/drawable/ic_add.xml
Normal 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>
|
||||||
13
app/src/main/res/drawable/ic_camera.xml
Normal file
13
app/src/main/res/drawable/ic_camera.xml
Normal 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>
|
||||||
10
app/src/main/res/drawable/ic_delete.xml
Normal file
10
app/src/main/res/drawable/ic_delete.xml
Normal 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>
|
||||||
10
app/src/main/res/drawable/ic_flash.xml
Normal file
10
app/src/main/res/drawable/ic_flash.xml
Normal 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>
|
||||||
19
app/src/main/res/drawable/ic_launcher.xml
Normal file
19
app/src/main/res/drawable/ic_launcher.xml
Normal 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>
|
||||||
10
app/src/main/res/drawable/ic_pages.xml
Normal file
10
app/src/main/res/drawable/ic_pages.xml
Normal 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>
|
||||||
10
app/src/main/res/drawable/ic_share.xml
Normal file
10
app/src/main/res/drawable/ic_share.xml
Normal 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>
|
||||||
11
app/src/main/res/drawable/shutter_bg.xml
Normal file
11
app/src/main/res/drawable/shutter_bg.xml
Normal 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>
|
||||||
85
app/src/main/res/layout/activity_book.xml
Normal file
85
app/src/main/res/layout/activity_book.xml
Normal 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>
|
||||||
103
app/src/main/res/layout/activity_capture.xml
Normal file
103
app/src/main/res/layout/activity_capture.xml
Normal 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>
|
||||||
49
app/src/main/res/layout/activity_main.xml
Normal file
49
app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||||
25
app/src/main/res/layout/item_book.xml
Normal file
25
app/src/main/res/layout/item_book.xml
Normal 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>
|
||||||
30
app/src/main/res/layout/item_page.xml
Normal file
30
app/src/main/res/layout/item_page.xml
Normal 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>
|
||||||
7
app/src/main/res/values/colors.xml
Normal file
7
app/src/main/res/values/colors.xml
Normal 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>
|
||||||
51
app/src/main/res/values/strings.xml
Normal file
51
app/src/main/res/values/strings.xml
Normal 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>
|
||||||
8
app/src/main/res/values/themes.xml
Normal file
8
app/src/main/res/values/themes.xml
Normal 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
5
build.gradle.kts
Normal 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
8
gradle.properties
Normal 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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
251
gradlew
vendored
Executable 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
94
gradlew.bat
vendored
Normal 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
18
settings.gradle.kts
Normal 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")
|
||||||
Reference in New Issue
Block a user