commit 3dd15ffa9b8da9b2a7e32294a7d22781a87502d4 Author: PageScribe Date: Thu Oct 15 09:35:13 2026 -0700 PageScribe 0.1: camera capture, background OCR queue, page-numbered text assembly, image archiver diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da83111 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..03c497e --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..2e1226e --- /dev/null +++ b/app/proguard-rules.pro @@ -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.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..712651b --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/bm/pagescribe/BookActivity.kt b/app/src/main/java/com/bm/pagescribe/BookActivity.kt new file mode 100644 index 0000000..2d1b4a4 --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/BookActivity.kt @@ -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(R.id.pageList).apply { + layoutManager = LinearLayoutManager(this@BookActivity) + adapter = this@BookActivity.adapter + } + + findViewById(R.id.btnCapture).setOnClickListener { + book()?.let { startActivity(CaptureActivity.intent(this, it)) } + } + findViewById(R.id.btnExport).setOnClickListener { export() } + findViewById(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() { + + private data class Row(val label: String, val detail: String, val primary: Boolean) + + private val rows = ArrayList() + + fun submit(pages: List>, failed: List) { + 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 + } +} diff --git a/app/src/main/java/com/bm/pagescribe/CaptureActivity.kt b/app/src/main/java/com/bm/pagescribe/CaptureActivity.kt new file mode 100644 index 0000000..162650a --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/CaptureActivity.kt @@ -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(R.id.btnPages).setOnClickListener { + book?.let { startActivity(BookActivity.intent(this, it)) } + } + findViewById(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) + } +} diff --git a/app/src/main/java/com/bm/pagescribe/Config.kt b/app/src/main/java/com/bm/pagescribe/Config.kt new file mode 100644 index 0000000..355ae26 --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/Config.kt @@ -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 +} diff --git a/app/src/main/java/com/bm/pagescribe/MainActivity.kt b/app/src/main/java/com/bm/pagescribe/MainActivity.kt new file mode 100644 index 0000000..59ab4cc --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/MainActivity.kt @@ -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(R.id.bookList).apply { + layoutManager = LinearLayoutManager(this@MainActivity) + adapter = this@MainActivity.adapter + } + + findViewById(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() { + + private val books = ArrayList() + + fun submit(newBooks: List) { + 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) } + } +} diff --git a/app/src/main/java/com/bm/pagescribe/PageScribeApp.kt b/app/src/main/java/com/bm/pagescribe/PageScribeApp.kt new file mode 100644 index 0000000..e029cc6 --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/PageScribeApp.kt @@ -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) + } +} diff --git a/app/src/main/java/com/bm/pagescribe/book/Book.kt b/app/src/main/java/com/bm/pagescribe/book/Book.kt new file mode 100644 index 0000000..7df47ce --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/book/Book.kt @@ -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// + * 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) } +} diff --git a/app/src/main/java/com/bm/pagescribe/ocr/CaptionFilter.kt b/app/src/main/java/com/bm/pagescribe/ocr/CaptionFilter.kt new file mode 100644 index 0000000..a9981ce --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/ocr/CaptionFilter.kt @@ -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 + } +} diff --git a/app/src/main/java/com/bm/pagescribe/ocr/GutterDetector.kt b/app/src/main/java/com/bm/pagescribe/ocr/GutterDetector.kt new file mode 100644 index 0000000..a6b6401 --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/ocr/GutterDetector.kt @@ -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) + } +} diff --git a/app/src/main/java/com/bm/pagescribe/ocr/ImageArchiver.kt b/app/src/main/java/com/bm/pagescribe/ocr/ImageArchiver.kt new file mode 100644 index 0000000..cea36cf --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/ocr/ImageArchiver.kt @@ -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) + } +} diff --git a/app/src/main/java/com/bm/pagescribe/ocr/OcrModels.kt b/app/src/main/java/com/bm/pagescribe/ocr/OcrModels.kt new file mode 100644 index 0000000..458c0f8 --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/ocr/OcrModels.kt @@ -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 +} diff --git a/app/src/main/java/com/bm/pagescribe/ocr/OcrWorker.kt b/app/src/main/java/com/bm/pagescribe/ocr/OcrWorker.kt new file mode 100644 index 0000000..9da562f --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/ocr/OcrWorker.kt @@ -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 + } +} diff --git a/app/src/main/java/com/bm/pagescribe/ocr/PageNumberDetector.kt b/app/src/main/java/com/bm/pagescribe/ocr/PageNumberDetector.kt new file mode 100644 index 0000000..4a3f7f7 --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/ocr/PageNumberDetector.kt @@ -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 } + } +} diff --git a/app/src/main/java/com/bm/pagescribe/ocr/PageNumberResolver.kt b/app/src/main/java/com/bm/pagescribe/ocr/PageNumberResolver.kt new file mode 100644 index 0000000..04c7141 --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/ocr/PageNumberResolver.kt @@ -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) + } +} diff --git a/app/src/main/java/com/bm/pagescribe/ocr/ParagraphReflow.kt b/app/src/main/java/com/bm/pagescribe/ocr/ParagraphReflow.kt new file mode 100644 index 0000000..e9a0611 --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/ocr/ParagraphReflow.kt @@ -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,}"), " ") +} diff --git a/app/src/main/java/com/bm/pagescribe/ocr/SpreadProcessor.kt b/app/src/main/java/com/bm/pagescribe/ocr/SpreadProcessor.kt new file mode 100644 index 0000000..8e85e7f --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/ocr/SpreadProcessor.kt @@ -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) + } +} diff --git a/app/src/main/java/com/bm/pagescribe/ocr/WorkScheduler.kt b/app/src/main/java/com/bm/pagescribe/ocr/WorkScheduler.kt new file mode 100644 index 0000000..0169b55 --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/ocr/WorkScheduler.kt @@ -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) + } +} diff --git a/app/src/main/java/com/bm/pagescribe/util/Bitmaps.kt b/app/src/main/java/com/bm/pagescribe/util/Bitmaps.kt new file mode 100644 index 0000000..b6ed5b6 --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/util/Bitmaps.kt @@ -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 + } +} diff --git a/app/src/main/java/com/bm/pagescribe/util/Notifications.kt b/app/src/main/java/com/bm/pagescribe/util/Notifications.kt new file mode 100644 index 0000000..29d7a60 --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/util/Notifications.kt @@ -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) + } + } +} diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..54da215 --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -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> diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000..4c958da --- /dev/null +++ b/app/src/main/res/drawable/ic_camera.xml @@ -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> diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..2c0afcc --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -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> diff --git a/app/src/main/res/drawable/ic_flash.xml b/app/src/main/res/drawable/ic_flash.xml new file mode 100644 index 0000000..b3e8784 --- /dev/null +++ b/app/src/main/res/drawable/ic_flash.xml @@ -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> diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..f0af549 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -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> diff --git a/app/src/main/res/drawable/ic_pages.xml b/app/src/main/res/drawable/ic_pages.xml new file mode 100644 index 0000000..e99ead9 --- /dev/null +++ b/app/src/main/res/drawable/ic_pages.xml @@ -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> diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..5002dcc --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -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> diff --git a/app/src/main/res/drawable/shutter_bg.xml b/app/src/main/res/drawable/shutter_bg.xml new file mode 100644 index 0000000..fe306e5 --- /dev/null +++ b/app/src/main/res/drawable/shutter_bg.xml @@ -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> diff --git a/app/src/main/res/layout/activity_book.xml b/app/src/main/res/layout/activity_book.xml new file mode 100644 index 0000000..5f77a66 --- /dev/null +++ b/app/src/main/res/layout/activity_book.xml @@ -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> diff --git a/app/src/main/res/layout/activity_capture.xml b/app/src/main/res/layout/activity_capture.xml new file mode 100644 index 0000000..95a952c --- /dev/null +++ b/app/src/main/res/layout/activity_capture.xml @@ -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> diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..509bc20 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -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> diff --git a/app/src/main/res/layout/item_book.xml b/app/src/main/res/layout/item_book.xml new file mode 100644 index 0000000..19019d2 --- /dev/null +++ b/app/src/main/res/layout/item_book.xml @@ -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> diff --git a/app/src/main/res/layout/item_page.xml b/app/src/main/res/layout/item_page.xml new file mode 100644 index 0000000..8b662b4 --- /dev/null +++ b/app/src/main/res/layout/item_page.xml @@ -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> diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..4e79e4d --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -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> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7297218 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -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> diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..950ede4 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -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> diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..005d3f5 --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..0252058 --- /dev/null +++ b/gradle.properties @@ -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 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..aaaabb3 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..8825fc6 --- /dev/null +++ b/settings.gradle.kts @@ -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")