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