]> git.otsuka.systems Git - alexandria/commitdiff
initial commit
authorCameron Otsuka <cameron@otsuka.haus>
Tue, 11 Aug 2026 15:46:09 +0000 (08:46 -0700)
committerCameron Otsuka <cameron@otsuka.haus>
Tue, 11 Aug 2026 15:46:09 +0000 (08:46 -0700)
54 files changed:
.gitignore [new file with mode: 0644]
AGENTS.md [new file with mode: 0644]
README.md [new file with mode: 0644]
app/build.gradle.kts [new file with mode: 0644]
app/src/main/AndroidManifest.xml [new file with mode: 0644]
app/src/main/assets/fonts/Atkinson.otf [new file with mode: 0644]
app/src/main/assets/fonts/LibertinusSerif-Bold.otf [new file with mode: 0644]
app/src/main/assets/fonts/LibertinusSerif-BoldItalic.otf [new file with mode: 0644]
app/src/main/assets/fonts/LibertinusSerif-Italic.otf [new file with mode: 0644]
app/src/main/assets/fonts/LibertinusSerif-Regular.otf [new file with mode: 0644]
app/src/main/assets/fonts/NotoSans-Bold.ttf [new file with mode: 0644]
app/src/main/assets/fonts/NotoSans-Regular.ttf [new file with mode: 0644]
app/src/main/assets/fonts/SourceCode-Regular.otf [new file with mode: 0644]
app/src/main/java/com/alexandria/reader/EpubParser.kt [new file with mode: 0644]
app/src/main/java/com/alexandria/reader/LibraryRepository.kt [new file with mode: 0644]
app/src/main/java/com/alexandria/reader/LibraryScreen.kt [new file with mode: 0644]
app/src/main/java/com/alexandria/reader/MainActivity.kt [new file with mode: 0644]
app/src/main/java/com/alexandria/reader/Models.kt [new file with mode: 0644]
app/src/main/java/com/alexandria/reader/ReaderScreen.kt [new file with mode: 0644]
app/src/main/java/com/alexandria/reader/ReaderWebView.kt [new file with mode: 0644]
app/src/main/res/drawable/ic_launcher.xml [new file with mode: 0644]
app/src/main/res/font/atkinson.otf [new file with mode: 0644]
app/src/main/res/values/styles.xml [new file with mode: 0644]
build.gradle.kts [new file with mode: 0644]
gradle.properties [new file with mode: 0644]
gradle/wrapper/gradle-wrapper.properties [new file with mode: 0644]
gradlew [new file with mode: 0644]
gradlew.bat [new file with mode: 0644]
local.properties [new file with mode: 0644]
scripts/test.ps1 [new file with mode: 0644]
scripts/verify-fixed-page-tools.ps1 [new file with mode: 0644]
scripts/verify-running.ps1 [new file with mode: 0644]
scripts/verify-search-history.ps1 [new file with mode: 0644]
settings.gradle.kts [new file with mode: 0644]
tests/fixtures/fixed-layout-src/META-INF/container.xml [new file with mode: 0644]
tests/fixtures/fixed-layout-src/OEBPS/fixed.css [new file with mode: 0644]
tests/fixtures/fixed-layout-src/OEBPS/nav.xhtml [new file with mode: 0644]
tests/fixtures/fixed-layout-src/OEBPS/package.opf [new file with mode: 0644]
tests/fixtures/fixed-layout-src/OEBPS/page1.xhtml [new file with mode: 0644]
tests/fixtures/fixed-layout-src/OEBPS/page2.xhtml [new file with mode: 0644]
tests/fixtures/fixed-layout-src/OEBPS/page3.xhtml [new file with mode: 0644]
tests/fixtures/fixed-layout-src/OEBPS/page4.xhtml [new file with mode: 0644]
tests/fixtures/fixed-layout-src/mimetype [new file with mode: 0644]
tests/fixtures/fixed-layout.epub [new file with mode: 0644]
tests/fixtures/navigation-src/META-INF/container.xml [new file with mode: 0644]
tests/fixtures/navigation-src/OEBPS/chapter1.xhtml [new file with mode: 0644]
tests/fixtures/navigation-src/OEBPS/chapter2.xhtml [new file with mode: 0644]
tests/fixtures/navigation-src/OEBPS/chapter3.xhtml [new file with mode: 0644]
tests/fixtures/navigation-src/OEBPS/chapter4.xhtml [new file with mode: 0644]
tests/fixtures/navigation-src/OEBPS/nav.xhtml [new file with mode: 0644]
tests/fixtures/navigation-src/OEBPS/package.opf [new file with mode: 0644]
tests/fixtures/navigation-src/OEBPS/style.css [new file with mode: 0644]
tests/fixtures/navigation-src/mimetype [new file with mode: 0644]
tests/fixtures/navigation.epub [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..4130000
--- /dev/null
@@ -0,0 +1,49 @@
+.gradle
+**/build/
+!**/src/**/build/
+
+# Ignore Gradle GUI config
+gradle-app.setting
+
+# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
+!gradle-wrapper.jar
+
+# Avoid ignore Gradle wrapper properties
+!gradle-wrapper.properties
+
+# Cache of project
+.gradletasknamecache
+
+# Eclipse Gradle plugin generated files
+# Eclipse Core
+.project
+# JDT-specific (Eclipse Java Development Tools)
+.classpath
+
+# Compiled class file
+*.class
+
+# Log file
+*.log
+
+# BlueJ files
+*.ctxt
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.jar
+*.war
+*.nar
+*.ear
+*.zip
+*.tar.gz
+*.rar
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+replay_pid*
+
+# Kotlin Gradle plugin data, see https://kotlinlang.org/docs/whatsnew20.html#new-directory-for-kotlin-data-in-gradle-projects
+.kotlin/
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644 (file)
index 0000000..43699cc
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1 @@
+Android Studio should already be installed. Use it and Android virtual devices to test the application from end-to-end. Use screenshots to verify functionality works as intended.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..1306213
--- /dev/null
+++ b/README.md
@@ -0,0 +1,64 @@
+# Alexandria
+
+Alexandria is an Android 11 EPUB library and reader written in Kotlin. Its design follows Plato's library and reader workflows.
+
+The app requests no network or shared-storage permission. It imports each selected book through Android's Storage Access Framework and keeps a private copy.
+
+## EPUB support
+
+- EPUB 2 and EPUB 3 package, spine, metadata, cover, NCX, and navigation documents
+- Publisher CSS, inline styles, imported stylesheets, embedded fonts, SVG, images, tables, lists, links, and page-break rules
+- Accurate reflow with browser-grade shaping, bidirectional text, hyphenation, and accessibility text
+- Paginated and continuous reading modes
+- Pre-paginated EPUB detection through rendition metadata, legacy display options, numeric viewports, and SVG pages
+- Right-to-left page progression
+
+## Reader features
+
+- Plato-style touch regions, page swipes, chapter navigation, and non-linear location history
+- Persistent reading position and progress
+- Table of contents and internal links with previous-location return
+- Forward or backward full-book search from the current text location, with previous and next results
+- Bookmarks
+- Text selection, highlights, notes, annotation editing, and annotation export
+- System dictionary and text-processing integration
+- Typeface, text size, line height, margin, alignment, publisher-style, and hyphenation controls
+- Light, sepia, and dark themes; contrast and screen-brightness controls
+- Fixed-page fit-to-page, fit-to-width page streams with line-preserving cuts, and 50–400% custom zoom and bounded pan
+- Shared or separate even/odd crop margins with a visual crop editor
+- Plato-compatible contrast exponent and gray-point controls, plus 16-level and black-and-white spatial dithering
+- Portrait and landscape layouts
+- Searchable and sortable library with covers and reading states
+
+## Build
+
+```powershell
+.\gradlew.bat :app:assembleDebug
+```
+
+The APK is written to `app/build/outputs/apk/debug/app-debug.apk`.
+
+## End-to-end verification
+
+Start the supplied Android virtual device, then run:
+
+```powershell
+.\scripts\verify-running.ps1
+```
+
+The script performs a clean install, imports `tests/fixtures/odyssey.epub`, waits for a measured page count, checks Logcat for crashes, and writes `verification/odyssey-page-1.png`.
+
+Run the focused reader tests separately:
+
+```powershell
+.\scripts\verify-fixed-page-tools.ps1
+.\scripts\verify-search-history.ps1
+```
+
+The fixed-page test verifies fit modes, continuous line cuts, even/odd crops, custom pan persistence, gray-point adjustment, and dithering. The search/history test verifies forward and backward searches from page two, result-jump history, an internal-link jump, and previous-location returns. Both write screenshots under `verification/`.
+
+To start a clean AVD before manual testing, use:
+
+```powershell
+.\scripts\test.ps1 -AvdName "Onyx_Boox_Note_Air_2" -KeepEmulator
+```
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644 (file)
index 0000000..6f64da9
--- /dev/null
@@ -0,0 +1,23 @@
+plugins { id("com.android.application"); id("org.jetbrains.kotlin.android") }
+
+android {
+    namespace = "com.alexandria.reader"
+    compileSdk = 35
+
+    defaultConfig {
+        applicationId = "com.alexandria.reader"
+        minSdk = 30
+        targetSdk = 35
+        versionCode = 1
+        versionName = "1.0"
+    }
+
+    buildFeatures { buildConfig = true }
+    testOptions { unitTests.isIncludeAndroidResources = true }
+}
+
+dependencies {
+    implementation("org.jsoup:jsoup:1.18.3")
+}
+
+kotlin { jvmToolchain(17) }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644 (file)
index 0000000..c3ee819
--- /dev/null
@@ -0,0 +1,50 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <!-- Alexandria intentionally requests no network or shared-storage permission. -->
+    <queries>
+        <intent>
+            <action android:name="android.intent.action.PROCESS_TEXT"/>
+            <data android:mimeType="text/plain"/>
+        </intent>
+    </queries>
+    <application
+        android:theme="@style/AppTheme"
+        android:label="Alexandria"
+        android:icon="@drawable/ic_launcher"
+        android:roundIcon="@drawable/ic_launcher"
+        android:allowBackup="false"
+        android:usesCleartextTraffic="false"
+        android:hardwareAccelerated="true"
+        android:supportsRtl="true">
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:launchMode="singleTask"
+            android:configChanges="orientation|screenSize|keyboardHidden"
+            android:windowSoftInputMode="adjustNothing">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN"/>
+                <category android:name="android.intent.category.LAUNCHER"/>
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW"/>
+                <category android:name="android.intent.category.DEFAULT"/>
+                <category android:name="android.intent.category.BROWSABLE"/>
+                <data android:mimeType="application/epub+zip"/>
+                <data android:mimeType="application/epub"/>
+                <data android:scheme="content"/>
+                <data android:scheme="file"/>
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.SEND"/>
+                <category android:name="android.intent.category.DEFAULT"/>
+                <data android:mimeType="application/epub+zip"/>
+                <data android:mimeType="application/epub"/>
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.SEND_MULTIPLE"/>
+                <category android:name="android.intent.category.DEFAULT"/>
+                <data android:mimeType="application/epub+zip"/>
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/app/src/main/assets/fonts/Atkinson.otf b/app/src/main/assets/fonts/Atkinson.otf
new file mode 100644 (file)
index 0000000..ba345b5
Binary files /dev/null and b/app/src/main/assets/fonts/Atkinson.otf differ
diff --git a/app/src/main/assets/fonts/LibertinusSerif-Bold.otf b/app/src/main/assets/fonts/LibertinusSerif-Bold.otf
new file mode 100644 (file)
index 0000000..39a8ee5
Binary files /dev/null and b/app/src/main/assets/fonts/LibertinusSerif-Bold.otf differ
diff --git a/app/src/main/assets/fonts/LibertinusSerif-BoldItalic.otf b/app/src/main/assets/fonts/LibertinusSerif-BoldItalic.otf
new file mode 100644 (file)
index 0000000..d63f36e
Binary files /dev/null and b/app/src/main/assets/fonts/LibertinusSerif-BoldItalic.otf differ
diff --git a/app/src/main/assets/fonts/LibertinusSerif-Italic.otf b/app/src/main/assets/fonts/LibertinusSerif-Italic.otf
new file mode 100644 (file)
index 0000000..ddf1996
Binary files /dev/null and b/app/src/main/assets/fonts/LibertinusSerif-Italic.otf differ
diff --git a/app/src/main/assets/fonts/LibertinusSerif-Regular.otf b/app/src/main/assets/fonts/LibertinusSerif-Regular.otf
new file mode 100644 (file)
index 0000000..e42f25c
Binary files /dev/null and b/app/src/main/assets/fonts/LibertinusSerif-Regular.otf differ
diff --git a/app/src/main/assets/fonts/NotoSans-Bold.ttf b/app/src/main/assets/fonts/NotoSans-Bold.ttf
new file mode 100644 (file)
index 0000000..1db7886
Binary files /dev/null and b/app/src/main/assets/fonts/NotoSans-Bold.ttf differ
diff --git a/app/src/main/assets/fonts/NotoSans-Regular.ttf b/app/src/main/assets/fonts/NotoSans-Regular.ttf
new file mode 100644 (file)
index 0000000..0a01a06
Binary files /dev/null and b/app/src/main/assets/fonts/NotoSans-Regular.ttf differ
diff --git a/app/src/main/assets/fonts/SourceCode-Regular.otf b/app/src/main/assets/fonts/SourceCode-Regular.otf
new file mode 100644 (file)
index 0000000..3004698
Binary files /dev/null and b/app/src/main/assets/fonts/SourceCode-Regular.otf differ
diff --git a/app/src/main/java/com/alexandria/reader/EpubParser.kt b/app/src/main/java/com/alexandria/reader/EpubParser.kt
new file mode 100644 (file)
index 0000000..c275f0c
--- /dev/null
@@ -0,0 +1,1316 @@
+package com.alexandria.reader
+
+import android.net.Uri
+import org.json.JSONObject
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Comment
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import org.jsoup.parser.Parser
+import java.io.File
+import java.io.FileOutputStream
+import java.util.Locale
+import java.util.zip.ZipFile
+import kotlin.math.roundToInt
+
+/** EPUB 2/3 package reader. It keeps the original resources intact for standards-based rendering. */
+object EpubParser {
+    private data class ManifestItem(val id: String, val href: String, val mediaType: String, val properties: String)
+
+    fun parse(source: File, output: File, book: LibraryBook, forceExtract: Boolean): EpubPublication {
+        if (forceExtract || !File(output, ".complete").isFile) {
+            output.deleteRecursively()
+            output.mkdirs()
+            extract(source, output)
+            File(output, ".complete").writeText("ok")
+        }
+
+        val containerFile = File(output, "META-INF/container.xml")
+        require(containerFile.isFile) { "This file has no EPUB container document." }
+        val container = parseXml(containerFile)
+        val rootfile = container.allByLocalName("rootfile").firstOrNull()
+            ?: error("The EPUB container does not identify a package document.")
+        val packagePath = normalizePath("", rootfile.attr("full-path"))
+        require(packagePath.isNotBlank()) { "The EPUB package path is empty." }
+        val packageFile = safeFile(output, packagePath)
+        require(packageFile.isFile) { "The EPUB package document is missing." }
+        val opf = parseXml(packageFile)
+        val packageDirectory = packagePath.substringBeforeLast('/', "")
+
+        val metadata = opf.allByLocalName("metadata").firstOrNull()
+        metadata?.firstText("title")?.takeIf(String::isNotBlank)?.let { book.title = it }
+        metadata?.allByLocalName("creator")?.map { it.text().trim() }?.filter(String::isNotBlank)
+            ?.distinct()?.takeIf(List<String>::isNotEmpty)?.joinToString(", ")?.let { book.author = it }
+        metadata?.firstText("language")?.let { book.language = it }
+        metadata?.firstText("publisher")?.let { book.publisher = it }
+        metadata?.firstText("description")?.let { book.description = it }
+        val packageViewport = metadata?.allByLocalName("meta")?.firstNotNullOfOrNull { meta ->
+            val name = meta.attr("name").lowercase(Locale.ROOT)
+            val property = meta.attr("property").lowercase(Locale.ROOT)
+            if (name != "original-resolution" && property != "rendition:viewport") return@firstNotNullOfOrNull null
+            val value = meta.text().ifBlank { meta.attr("content") }
+            val dimensions = Regex("(?:width\\s*=\\s*)?([0-9]+)\\s*(?:x|[,;]\\s*height\\s*=)\\s*([0-9]+)", RegexOption.IGNORE_CASE).find(value)
+                ?: return@firstNotNullOfOrNull null
+            val width = dimensions.groupValues[1].toIntOrNull() ?: return@firstNotNullOfOrNull null
+            val height = dimensions.groupValues[2].toIntOrNull() ?: return@firstNotNullOfOrNull null
+            (width to height).takeIf { width > 0 && height > 0 }
+        } ?: (1200 to 1600)
+        val appleDisplayOptions = File(output, "META-INF/com.apple.ibooks.display-options.xml")
+        val legacyFixedLayout = appleDisplayOptions.takeIf(File::isFile)?.let { file ->
+            runCatching {
+                parseXml(file).getAllElements().any { element ->
+                    element.attr("name").equals("fixed-layout", true) && element.text().trim().equals("true", true)
+                }
+            }.getOrDefault(false)
+        } == true
+        val packageFixedLayout = legacyFixedLayout || metadata?.allByLocalName("meta")?.any { meta ->
+            val property = meta.attr("property").lowercase(Locale.ROOT)
+            val name = meta.attr("name").lowercase(Locale.ROOT)
+            val value = meta.text().ifBlank { meta.attr("content") }.trim().lowercase(Locale.ROOT)
+            (property == "rendition:layout" && value == "pre-paginated") ||
+                (name in setOf("fixed-layout", "fixed_layout") && value in setOf("true", "yes", "pre-paginated")) ||
+                name == "original-resolution"
+        } == true
+
+        val manifest = opf.allByLocalName("manifest").firstOrNull()?.children()
+            ?.filter { it.localName() == "item" }
+            ?.mapNotNull { element ->
+                val id = element.attr("id")
+                val href = element.attr("href")
+                if (id.isBlank() || href.isBlank()) null else ManifestItem(
+                    id,
+                    normalizePath(packageDirectory, href.substringBefore('#').substringBefore('?')),
+                    element.attr("media-type").lowercase(Locale.ROOT),
+                    element.attr("properties"),
+                )
+            }?.associateBy { it.id }.orEmpty()
+
+        val spineElement = opf.allByLocalName("spine").firstOrNull()
+        val spine = spineElement?.children()?.filter { it.localName() == "itemref" }?.mapNotNull { reference ->
+            val item = manifest[reference.attr("idref")] ?: return@mapNotNull null
+            if (!isHtml(item.mediaType, item.href)) return@mapNotNull null
+            val chapterFile = safeFile(output, item.href)
+            if (!chapterFile.isFile) return@mapNotNull null
+            val layoutProperties = "${item.properties} ${reference.attr("properties")}".lowercase(Locale.ROOT)
+            SpineItem(
+                href = item.href,
+                mediaType = item.mediaType,
+                title = chapterTitle(chapterFile),
+                linear = !reference.attr("linear").equals("no", ignoreCase = true),
+                fixedLayout = packageFixedLayout || "rendition:layout-pre-paginated" in layoutProperties ||
+                    item.mediaType == "image/svg+xml" || looksFixedLayout(chapterFile),
+            )
+        }.orEmpty().ifEmpty {
+            manifest.values.filter { isHtml(it.mediaType, it.href) && safeFile(output, it.href).isFile }
+                .map {
+                    SpineItem(
+                        it.href,
+                        it.mediaType,
+                        chapterTitle(safeFile(output, it.href)),
+                        fixedLayout = packageFixedLayout || "rendition:layout-pre-paginated" in it.properties.lowercase(Locale.ROOT) ||
+                            it.mediaType == "image/svg+xml" || looksFixedLayout(safeFile(output, it.href)),
+                    )
+                }
+        }
+        require(spine.isNotEmpty()) { "The EPUB reading order is empty." }
+
+        val coverId = metadata?.allByLocalName("meta")?.firstOrNull {
+            it.attr("name").equals("cover", true)
+        }?.attr("content")
+        val cover = manifest.values.firstOrNull { "cover-image" in it.properties.split(Regex("\\s+")) }
+            ?: coverId?.let(manifest::get)
+            ?: opf.allByLocalName("reference").firstOrNull { "cover" in it.attr("type").lowercase() }
+                ?.attr("href")?.takeIf(String::isNotBlank)?.let { href ->
+                    val path = normalizePath(packageDirectory, href.substringBefore('#'))
+                    manifest.values.firstOrNull { it.href == path }
+                }
+        val coverItem = cover ?: manifest.values.firstOrNull { it.properties.contains("cover", true) }
+        coverItem?.let { item ->
+            val candidate = safeFile(output, item.href)
+            when {
+                candidate.isFile && item.mediaType.startsWith("image/") -> book.coverPath = candidate.absolutePath
+                candidate.isFile && isHtml(item.mediaType, item.href) -> {
+                    val coverDocument = runCatching { parseXml(candidate) }.getOrNull()
+                    val image = coverDocument?.getAllElements()?.firstOrNull {
+                        it.tagName().substringAfter(':').lowercase() in setOf("img", "image")
+                    }
+                    val sourceValue = image?.attr("src").orEmpty().ifBlank {
+                        image?.attributes()?.asList()?.firstOrNull { it.key.endsWith(":href") }?.value.orEmpty()
+                    }
+                    if (sourceValue.isNotBlank()) {
+                        val imagePath = normalizePath(item.href.substringBeforeLast('/', ""), sourceValue.substringBefore('#'))
+                        safeFile(output, imagePath).takeIf(File::isFile)?.let { book.coverPath = it.absolutePath }
+                    }
+                }
+            }
+        }
+
+        val navItem = manifest.values.firstOrNull { "nav" in it.properties.split(Regex("\\s+")) }
+        val ncxId = spineElement?.attr("toc")
+        val ncxItem = ncxId?.let(manifest::get)
+            ?: manifest.values.firstOrNull { it.mediaType == "application/x-dtbncx+xml" }
+        var toc = navItem?.let { parseHtmlToc(output, it.href, spine) }.orEmpty()
+        if (toc.isEmpty()) toc = ncxItem?.let { parseNcxToc(output, it.href, spine) }.orEmpty()
+        if (toc.isEmpty()) toc = spine.mapIndexed { index, item ->
+            TocEntry(item.title.ifBlank { "Section ${index + 1}" }, item.href, 0, index)
+        }
+
+        val direction = spineElement?.attr("page-progression-direction")?.lowercase(Locale.ROOT)
+            ?.takeIf { it == "rtl" || it == "ltr" } ?: "ltr"
+        val stylesheets = manifest.values.filter { it.mediaType == "text/css" }.map { it.href }
+
+        return EpubPublication(
+            book, output, packagePath, spine, toc, direction, stylesheets,
+            packageFixedLayout || spine.filter(SpineItem::linear).let { it.isNotEmpty() && it.all(SpineItem::fixedLayout) }, packageViewport.first, packageViewport.second,
+        )
+    }
+
+    private fun extract(source: File, output: File) {
+        var expanded = 0L
+        ZipFile(source).use { zip ->
+            val entries = zip.entries()
+            while (entries.hasMoreElements()) {
+                val entry = entries.nextElement()
+                if (entry.isDirectory) continue
+                val name = normalizePath("", entry.name)
+                require(name.isNotBlank() && !entry.name.replace('\\', '/').startsWith('/')) {
+                    "The EPUB contains an unsafe resource path."
+                }
+                val target = safeFile(output, name)
+                target.parentFile?.mkdirs()
+                zip.getInputStream(entry).use { input ->
+                    FileOutputStream(target).use { outputStream ->
+                        val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+                        while (true) {
+                            val count = input.read(buffer)
+                            if (count < 0) break
+                            expanded += count
+                            require(expanded <= MAX_EXPANDED_BYTES) { "The EPUB expands beyond the 2 GB safety limit." }
+                            outputStream.write(buffer, 0, count)
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private fun parseHtmlToc(root: File, path: String, spine: List<SpineItem>): List<TocEntry> {
+        val file = safeFile(root, path)
+        if (!file.isFile) return emptyList()
+        val document = parseXml(file)
+        val nav = document.allByLocalName("nav").firstOrNull { element ->
+            element.attributes().asList().any { attribute ->
+                attribute.key.substringAfter(':').equals("type", true) &&
+                    "toc" in attribute.value.lowercase().split(Regex("\\s+"))
+            }
+        } ?: document.allByLocalName("nav").firstOrNull() ?: return emptyList()
+        val result = mutableListOf<TocEntry>()
+        val navDirectory = path.substringBeforeLast('/', "")
+
+        fun walk(container: Element, depth: Int) {
+            container.children().filter { it.localName() == "li" }.forEach { item ->
+                val label = item.children().firstOrNull { it.localName() in setOf("a", "span") }
+                val href = label?.attr("href").orEmpty()
+                if (label != null && href.isNotBlank()) {
+                    val resolved = resolveReference(navDirectory, href)
+                    result += TocEntry(label.text().trim().ifBlank { "Untitled section" }, resolved, depth, spineIndex(spine, resolved))
+                }
+                item.children().filter { it.localName() in setOf("ol", "ul") }.forEach { walk(it, depth + 1) }
+            }
+        }
+        nav.children().filter { it.localName() in setOf("ol", "ul") }.forEach { walk(it, 0) }
+        return result
+    }
+
+    private fun parseNcxToc(root: File, path: String, spine: List<SpineItem>): List<TocEntry> {
+        val file = safeFile(root, path)
+        if (!file.isFile) return emptyList()
+        val document = parseXml(file)
+        val result = mutableListOf<TocEntry>()
+        val base = path.substringBeforeLast('/', "")
+        fun walk(parent: Element, depth: Int) {
+            parent.children().filter { it.localName() == "navpoint" }.forEach { point ->
+                val label = point.allByLocalName("navlabel").firstOrNull()?.allByLocalName("text")?.firstOrNull()?.text()
+                    ?.trim().orEmpty().ifBlank { "Untitled section" }
+                val href = point.allByLocalName("content").firstOrNull()?.attr("src").orEmpty()
+                if (href.isNotBlank()) {
+                    val resolved = resolveReference(base, href)
+                    result += TocEntry(label, resolved, depth, spineIndex(spine, resolved))
+                }
+                walk(point, depth + 1)
+            }
+        }
+        document.allByLocalName("navmap").firstOrNull()?.let { walk(it, 0) }
+        return result
+    }
+
+    private fun spineIndex(spine: List<SpineItem>, reference: String): Int {
+        val path = reference.substringBefore('#')
+        return spine.indexOfFirst { it.href == path }.coerceAtLeast(0)
+    }
+
+    private fun looksFixedLayout(file: File): Boolean = runCatching {
+        val document = parseXml(file)
+        document.getAllElements().any { element ->
+            val name = element.attr("name").lowercase(Locale.ROOT)
+            val content = element.attr("content")
+            (element.localName() == "meta" && name == "viewport" &&
+                Regex("(?:^|[,;\\s])width\\s*=\\s*[0-9]", RegexOption.IGNORE_CASE).containsMatchIn(content) &&
+                Regex("(?:^|[,;\\s])height\\s*=\\s*[0-9]", RegexOption.IGNORE_CASE).containsMatchIn(content)) ||
+                (element.localName() == "svg" && (element.hasAttr("viewBox") || element.hasAttr("viewbox")))
+        }
+    }.getOrDefault(false)
+
+    private fun chapterTitle(file: File): String = runCatching {
+        val document = parseXml(file)
+        document.allByLocalName("h1").firstOrNull()?.text()?.trim()
+            ?: document.allByLocalName("h2").firstOrNull()?.text()?.trim()
+            ?: document.allByLocalName("title").firstOrNull()?.text()?.trim()
+            ?: file.nameWithoutExtension
+    }.getOrDefault(file.nameWithoutExtension)
+
+    internal fun normalizePath(base: String, value: String): String {
+        val decoded = Uri.decode(value.trim()).replace('\\', '/').substringBefore('?')
+        val source = if (base.isBlank()) decoded else "$base/$decoded"
+        val parts = ArrayDeque<String>()
+        source.split('/').forEach { part ->
+            when (part) {
+                "", "." -> Unit
+                ".." -> if (parts.isNotEmpty()) parts.removeLast()
+                else -> parts.addLast(part)
+            }
+        }
+        return parts.joinToString("/")
+    }
+
+    internal fun resolveReference(base: String, href: String): String {
+        val fragment = href.substringAfter('#', "")
+        val relative = href.substringBefore('#')
+        val path = if (relative.isBlank()) normalizePath("", base) else normalizePath(base, relative)
+        return if (fragment.isBlank()) path else "$path#${Uri.decode(fragment)}"
+    }
+
+    internal fun safeFile(root: File, path: String): File {
+        val file = File(root, path)
+        val canonicalRoot = root.canonicalFile
+        val canonical = file.canonicalFile
+        require(canonical.path == canonicalRoot.path || canonical.path.startsWith(canonicalRoot.path + File.separator)) {
+            "Resource path leaves the EPUB container."
+        }
+        return canonical
+    }
+
+    private fun parseXml(file: File): Document = file.inputStream().use { stream ->
+        Jsoup.parse(stream, null, file.toURI().toString(), Parser.xmlParser())
+    }
+
+    private fun isHtml(mediaType: String, path: String): Boolean =
+        mediaType in setOf("application/xhtml+xml", "text/html", "image/svg+xml") ||
+            path.endsWith(".xhtml", true) || path.endsWith(".html", true) || path.endsWith(".htm", true)
+
+    private fun Element.localName(): String = tagName().substringAfter(':').lowercase(Locale.ROOT)
+    private fun Element.allByLocalName(name: String): List<Element> =
+        getAllElements().filter { it.localName() == name.lowercase(Locale.ROOT) }
+    private fun Element.firstText(name: String): String? = allByLocalName(name).firstOrNull()?.text()?.trim()
+
+    private const val MAX_EXPANDED_BYTES = 2_147_483_648L
+}
+
+/** Builds one secure, paginated HTML document from the complete EPUB spine. */
+object EpubHtmlBuilder {
+    private data class ChapterDocument(
+        val index: Int,
+        val item: SpineItem,
+        val document: Document,
+        val body: Element,
+        val directory: String,
+        val viewportWidth: Int,
+        val viewportHeight: Int,
+        val lineBoxes: String,
+    )
+
+    fun write(publication: EpubPublication, output: File): File {
+        val chapters = publication.spine.mapIndexedNotNull { index, item ->
+            val file = EpubParser.safeFile(publication.rootDirectory, item.href)
+            if (!file.isFile) return@mapIndexedNotNull null
+            runCatching {
+                val document = file.inputStream().use { Jsoup.parse(it, null, file.toURI().toString(), Parser.xmlParser()) }
+                // The spine is parsed as XML, but it is embedded in an HTML document.
+                // HTML serialization expands empty non-void tags such as <a/> to
+                // <a></a>; otherwise the browser treats the rest of a chapter as a link.
+                document.outputSettings().syntax(Document.OutputSettings.Syntax.html).prettyPrint(false)
+                val body = document.getAllElements().firstOrNull { it.tagName().substringAfter(':').equals("body", true) }
+                    ?: document
+                val viewport = fixedViewport(document, body, publication.defaultPageWidth, publication.defaultPageHeight)
+                ChapterDocument(
+                    index, item, document, body, item.href.substringBeforeLast('/', ""), viewport.first, viewport.second,
+                    if (publication.fixedLayout) {
+                        runCatching { fixedLineBoxes(document, item.href.substringBeforeLast('/', ""), publication.rootDirectory) }
+                            .onFailure { android.util.Log.w("AlexandriaRenderer", "Could not derive fixed-page line boxes for ${item.href}", it) }
+                            .getOrDefault("")
+                    } else "",
+                )
+            }.getOrNull()
+        }
+        require(chapters.isNotEmpty()) { "No readable spine document was found." }
+
+        val targetIds = mutableMapOf<String, String>()
+        chapters.forEach { chapter ->
+            targetIds[chapter.item.href] = "alex-chapter-${chapter.index}"
+            chapter.document.getAllElements().forEach { element ->
+                val oldId = element.id()
+                if (oldId.isNotBlank()) {
+                    val generated = generatedId(chapter.index, oldId)
+                    targetIds["${chapter.item.href}#$oldId"] = generated
+                    element.attr("id", generated)
+                }
+            }
+        }
+
+        val cssPaths = linkedSetOf<String>()
+        cssPaths.addAll(publication.stylesheets)
+        val inlineStyles = mutableListOf<Pair<String, String>>()
+        chapters.forEach { chapter ->
+            chapter.document.getAllElements().filter { it.tagName().substringAfter(':').equals("link", true) }.forEach { link ->
+                if ("stylesheet" in link.attr("rel").lowercase()) {
+                    val href = link.attr("href")
+                    if (href.isNotBlank() && !isExternal(href)) cssPaths += EpubParser.normalizePath(chapter.directory, href.substringBefore('#'))
+                }
+            }
+            chapter.document.getAllElements().filter { it.tagName().substringAfter(':').equals("style", true) }.forEach { style ->
+                inlineStyles += chapter.directory to style.data().ifBlank { style.html() }
+            }
+        }
+        val publisherCss = buildString {
+            val loadedStylesheets = mutableSetOf<String>()
+            cssPaths.forEach { path ->
+                val css = loadStylesheet(path, publication.rootDirectory, loadedStylesheets)
+                if (css.isNotBlank()) {
+                    append("\n/* ").append(path.replace("*/", "")).append(" */\n")
+                    append(scopeCss(css))
+                }
+            }
+            inlineStyles.forEach { (directory, css) ->
+                val expanded = expandCssImports(css, directory, publication.rootDirectory, loadedStylesheets)
+                append('\n').append(scopeCss(rewriteCssUrls(expanded, directory, publication.rootDirectory)))
+            }
+        }
+
+        val chapterHtml = buildString {
+            chapters.forEach { chapter ->
+                chapter.body.select("script, iframe, frame, object, embed").remove()
+                // XML permits <a/>, while HTML interprets it as an unclosed link.
+                // A comment forces Jsoup to emit an explicit closing tag without
+                // adding visible or measurable content.
+                chapter.body.getAllElements().filter {
+                    it.tagName().substringAfter(':').equals("a", true) && it.childNodeSize() == 0
+                }.forEach { it.appendChild(Comment("alex-empty")) }
+                chapter.body.getAllElements().forEach { element ->
+                    element.attributes().asList().toList().forEach { attribute ->
+                        val key = attribute.key.lowercase(Locale.ROOT)
+                        val value = attribute.value
+                        when {
+                            key.startsWith("on") -> element.removeAttr(attribute.key)
+                            key == "style" -> element.attr(attribute.key, rewriteCssUrls(value, chapter.directory, publication.rootDirectory))
+                            key == "srcset" -> element.attr(attribute.key, rewriteSrcSet(value, chapter.directory, publication.rootDirectory))
+                            key in setOf("src", "poster", "data") || key.endsWith(":href") -> {
+                                val lower = value.trim().lowercase(Locale.ROOT)
+                                if (lower.startsWith("file:") || lower.startsWith("content:") || lower.startsWith("javascript:")) {
+                                    element.removeAttr(attribute.key)
+                                } else if (value.isNotBlank() && !isExternal(value) && !value.startsWith('#')) {
+                                    resourceUrl(chapter.directory, value, publication.rootDirectory)?.let { element.attr(attribute.key, it) }
+                                        ?: element.removeAttr(attribute.key)
+                                }
+                            }
+                            key == "href" -> {
+                                if (value.isBlank()) Unit
+                                else if (isExternal(value)) {
+                                    element.attr("data-external-href", value)
+                                    element.attr("href", "#")
+                                } else {
+                                    val reference = if (value.startsWith('#')) chapter.item.href + value
+                                        else EpubParser.resolveReference(chapter.directory, value)
+                                    val target = targetIds[reference]
+                                        ?: targetIds[reference.substringBefore('#')]
+                                        ?: "alex-chapter-${publication.spine.indexOfFirst { it.href == reference.substringBefore('#') }.coerceAtLeast(0)}"
+                                    element.attr("href", "#$target")
+                                    element.attr("data-alex-ref", reference)
+                                }
+                            }
+                        }
+                    }
+                }
+                val classes = chapter.body.classNames().joinToString(" ") { escapeAttribute(it) }
+                val bodyStyle = chapter.body.attr("style").takeIf(String::isNotBlank).orEmpty()
+                val language = chapter.body.attr("lang").ifBlank { chapter.document.attr("lang") }
+                val direction = chapter.body.attr("dir")
+                append("<section class=\"alex-chapter ").append(classes).append("\" id=\"alex-chapter-")
+                    .append(chapter.index).append("\" data-spine=\"").append(chapter.index).append("\" data-href=\"")
+                    .append(escapeAttribute(chapter.item.href)).append("\" data-page-width=\"")
+                    .append(chapter.viewportWidth).append("\" data-page-height=\"").append(chapter.viewportHeight)
+                    .append("\" data-line-boxes=\"").append(chapter.lineBoxes).append('"')
+                if (bodyStyle.isNotBlank()) append(" style=\"").append(escapeAttribute(bodyStyle)).append('"')
+                if (language.isNotBlank()) append(" lang=\"").append(escapeAttribute(language)).append('"')
+                if (direction.isNotBlank()) append(" dir=\"").append(escapeAttribute(direction)).append('"')
+                append("><span class=\"alex-chapter-start\" aria-hidden=\"true\"></span>")
+                append(chapter.body.html()).append("</section>\n")
+            }
+        }
+
+        val references = JSONObject()
+        targetIds.forEach { (key, value) -> references.put(key, value) }
+        output.parentFile?.mkdirs()
+        output.writeText(buildHtml(publication, publisherCss, chapterHtml, references.toString()))
+        return output
+    }
+
+    private fun fixedLineBoxes(document: Document, directory: String, root: File): String {
+        data class Metrics(var top: Float? = null, var height: Float? = null, var fontSize: Float? = null, var lineHeight: Float? = null)
+        fun number(value: String): Float? = Regex("-?[0-9]+(?:\\.[0-9]+)?").find(value)
+            ?.value?.toFloatOrNull()
+        fun property(css: String, name: String): String? = Regex("(?:^|;)\\s*${Regex.escape(name)}\\s*:\\s*([^;]+)", RegexOption.IGNORE_CASE)
+            .find(css)?.groupValues?.getOrNull(1)?.trim()
+        fun apply(css: String, value: Metrics) {
+            property(css, "top")?.let(::number)?.let { value.top = it }
+            property(css, "height")?.let(::number)?.let { value.height = it }
+            val shorthand = property(css, "font")
+            val shorthandSize = shorthand?.let { Regex("([0-9]+(?:\\.[0-9]+)?)(?:px|pt)(?:\\s*/\\s*([0-9]+(?:\\.[0-9]+)?)(?:px|pt)?)?", RegexOption.IGNORE_CASE).find(it) }
+            val fontSize = property(css, "font-size")?.let(::number)
+                ?: shorthandSize?.groupValues?.getOrNull(1)?.toFloatOrNull()
+            if (fontSize != null) value.fontSize = fontSize
+            val rawLineHeight = property(css, "line-height")
+            val lineNumber = rawLineHeight?.let(::number)
+                ?: shorthandSize?.groupValues?.getOrNull(2)?.toFloatOrNull()
+            if (lineNumber != null) {
+                val absolute = rawLineHeight?.contains(Regex("px|pt", RegexOption.IGNORE_CASE)) == true
+                value.lineHeight = if (absolute || lineNumber > 4f) lineNumber else lineNumber * (value.fontSize ?: 16f)
+            }
+        }
+
+        val metrics = mutableMapOf<Element, Metrics>()
+        val css = buildString {
+            val loaded = mutableSetOf<String>()
+            document.getAllElements().filter { it.tagName().substringAfter(':').equals("link", true) }.forEach { link ->
+                if ("stylesheet" in link.attr("rel").lowercase(Locale.ROOT)) {
+                    val href = link.attr("href")
+                    if (href.isNotBlank() && !isExternal(href)) {
+                        append(loadStylesheet(EpubParser.normalizePath(directory, href.substringBefore('#')), root, loaded)).append('\n')
+                    }
+                }
+            }
+            document.getAllElements().filter { it.tagName().substringAfter(':').equals("style", true) }
+                .forEach { append(it.data().ifBlank { it.html() }).append('\n') }
+        }
+        Regex("([^{}]+)\\{([^{}]*)\\}", RegexOption.DOT_MATCHES_ALL).findAll(css).forEach { rule ->
+            if (rule.groupValues[1].trimStart().startsWith('@')) return@forEach
+            rule.groupValues[1].split(',').forEach { rawSelector ->
+                val selector = rawSelector.trim().replace(Regex("::?[A-Za-z-]+(?:\\([^)]*\\))?"), "")
+                if (selector.isBlank()) return@forEach
+                runCatching { document.select(selector) }.getOrDefault(emptyList()).forEach { element ->
+                    apply(rule.groupValues[2], metrics.getOrPut(element) { Metrics() })
+                }
+            }
+        }
+        document.getAllElements().forEach { element ->
+            element.attr("style").takeIf(String::isNotBlank)?.let { apply(it, metrics.getOrPut(element) { Metrics() }) }
+        }
+
+        val boxes = linkedSetOf<Pair<Int, Int>>()
+        document.getAllElements().forEach { element ->
+            val explicitTop = number(element.attr("data-line-top"))
+            val explicitBottom = number(element.attr("data-line-bottom"))
+            if (explicitTop != null && explicitBottom != null && explicitBottom > explicitTop) {
+                boxes += explicitTop.roundToInt() to explicitBottom.roundToInt()
+                return@forEach
+            }
+            val localName = element.tagName().substringAfter(':').lowercase(Locale.ROOT)
+            if (localName in setOf("text", "tspan")) {
+                val baseline = number(element.attr("y")) ?: return@forEach
+                val fontSize = number(element.attr("font-size")) ?: metrics[element]?.fontSize ?: 16f
+                boxes += (baseline - fontSize).roundToInt() to (baseline + fontSize * .3f).roundToInt()
+                return@forEach
+            }
+            if (localName !in setOf("p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "dt", "dd", "figcaption", "caption")) return@forEach
+            val own = metrics[element] ?: return@forEach
+            var top = own.top ?: return@forEach
+            var ancestor = element.parent()
+            while (ancestor != null && ancestor !== document) {
+                metrics[ancestor]?.top?.let { top += it }
+                ancestor = ancestor.parent()
+            }
+            val height = own.height ?: own.lineHeight ?: own.fontSize?.times(1.3f) ?: return@forEach
+            if (height > 1f) boxes += top.roundToInt() to (top + height).roundToInt()
+        }
+        return boxes.joinToString(",") { "${it.first}:${it.second}" }
+    }
+
+    private fun fixedViewport(document: Document, body: Element, defaultWidth: Int, defaultHeight: Int): Pair<Int, Int> {
+        val viewport = document.getAllElements().firstOrNull {
+            it.tagName().substringAfter(':').equals("meta", true) && it.attr("name").equals("viewport", true)
+        }?.attr("content").orEmpty()
+        fun component(name: String): Int? = Regex("(?:^|[,;\\s])$name\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)", RegexOption.IGNORE_CASE)
+            .find(viewport)?.groupValues?.getOrNull(1)?.toDoubleOrNull()?.roundToInt()?.takeIf { it > 0 }
+        val viewportWidth = component("width")
+        val viewportHeight = component("height")
+        if (viewportWidth != null && viewportHeight != null) return viewportWidth to viewportHeight
+
+        val svg = document.getAllElements().firstOrNull { it.tagName().substringAfter(':').equals("svg", true) }
+        val viewBox = svg?.attributes()?.asList()?.firstOrNull { it.key.substringAfter(':').equals("viewBox", true) }?.value
+            ?.trim()?.split(Regex("[\\s,]+"))?.mapNotNull(String::toDoubleOrNull)
+        if (viewBox != null && viewBox.size >= 4 && viewBox[2] > 0 && viewBox[3] > 0) {
+            return viewBox[2].roundToInt() to viewBox[3].roundToInt()
+        }
+        fun dimension(value: String): Int? = Regex("([0-9]+(?:\\.[0-9]+)?)").find(value)
+            ?.groupValues?.getOrNull(1)?.toDoubleOrNull()?.roundToInt()?.takeIf { it > 0 }
+        val svgWidth = dimension(svg?.attr("width").orEmpty())
+        val svgHeight = dimension(svg?.attr("height").orEmpty())
+        if (svgWidth != null && svgHeight != null) return svgWidth to svgHeight
+
+        val style = body.attr("style")
+        val cssWidth = Regex("(?:^|;)\\s*width\\s*:\\s*([0-9]+(?:\\.[0-9]+)?)px", RegexOption.IGNORE_CASE)
+            .find(style)?.groupValues?.getOrNull(1)?.toDoubleOrNull()?.roundToInt()
+        val cssHeight = Regex("(?:^|;)\\s*height\\s*:\\s*([0-9]+(?:\\.[0-9]+)?)px", RegexOption.IGNORE_CASE)
+            .find(style)?.groupValues?.getOrNull(1)?.toDoubleOrNull()?.roundToInt()
+        return (cssWidth?.takeIf { it > 0 } ?: defaultWidth) to (cssHeight?.takeIf { it > 0 } ?: defaultHeight)
+    }
+
+    private fun buildHtml(publication: EpubPublication, publisherCss: String, content: String, references: String): String =
+        """<!doctype html>
+<html id="alex-book" lang="${escapeAttribute(publication.book.language.ifBlank { "en" })}">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
+<meta http-equiv="Content-Security-Policy" content="default-src 'self' file: data: blob:; img-src file: data: blob:; media-src file: data: blob:; font-src file: data:; style-src 'unsafe-inline' file:; script-src 'unsafe-inline'; connect-src 'none'; frame-src 'none'; object-src 'none'">
+<title>${escapeHtml(publication.book.title)}</title>
+<style id="alex-publisher-styles">
+$publisherCss
+</style>
+<style id="alex-reader-styles">
+@font-face { font-family:'Alexandria Serif'; src:url('file:///android_asset/fonts/LibertinusSerif-Regular.otf'); font-style:normal; font-weight:400; }
+@font-face { font-family:'Alexandria Serif'; src:url('file:///android_asset/fonts/LibertinusSerif-Bold.otf'); font-style:normal; font-weight:700; }
+@font-face { font-family:'Alexandria Serif'; src:url('file:///android_asset/fonts/LibertinusSerif-Italic.otf'); font-style:italic; font-weight:400; }
+@font-face { font-family:'Alexandria Serif'; src:url('file:///android_asset/fonts/LibertinusSerif-BoldItalic.otf'); font-style:italic; font-weight:700; }
+@font-face { font-family:'Alexandria Sans'; src:url('file:///android_asset/fonts/NotoSans-Regular.ttf'); font-style:normal; font-weight:400; }
+@font-face { font-family:'Alexandria Sans'; src:url('file:///android_asset/fonts/NotoSans-Bold.ttf'); font-style:normal; font-weight:700; }
+@font-face { font-family:'Atkinson Hyperlegible'; src:url('file:///android_asset/fonts/Atkinson.otf'); }
+@font-face { font-family:'Alexandria Mono'; src:url('file:///android_asset/fonts/SourceCode-Regular.otf'); }
+:root { --alex-side:32px; --alex-v:22px; --alex-font-size:20px; --alex-line-height:1.32; --alex-font:'Alexandria Serif'; --alex-paper:#fff; --alex-ink:#171717; --alex-link:#202020; }
+html { margin:0 !important; padding:0 !important; width:100%; height:100%; overflow:hidden; background:var(--alex-paper); color:var(--alex-ink); }
+body { margin:0 !important; width:100%; color:var(--alex-ink); background:var(--alex-paper); font-family:var(--alex-font), serif; font-size:var(--alex-font-size); line-height:var(--alex-line-height); text-rendering:optimizeLegibility; -webkit-font-smoothing:antialiased; }
+body.alex-paged { box-sizing:border-box; height:100vh; padding:var(--alex-v) var(--alex-side) !important; column-width:calc(100vw - (2 * var(--alex-side))); column-gap:calc(2 * var(--alex-side)); column-fill:auto; overflow:visible !important; }
+body.alex-continuous { box-sizing:border-box; min-height:100%; height:auto; padding:var(--alex-v) var(--alex-side) !important; overflow:visible !important; }
+html.alex-continuous { overflow-x:hidden; overflow-y:auto; }
+.alex-chapter { position:relative; max-width:100%; }
+body.alex-fixed { box-sizing:border-box; width:100vw; height:100vh; padding:0 !important; overflow:hidden !important; touch-action:none; }
+body.alex-fixed .alex-chapter { display:none !important; position:absolute !important; left:0 !important; top:0 !important; max-width:none !important; max-height:none !important; margin:0 !important; padding:0 !important; overflow:visible !important; transform-origin:0 0; box-sizing:border-box; background-color:var(--alex-paper); }
+body.alex-fixed .alex-chapter.alex-current-page, body.alex-fixed .alex-chapter.alex-stream-next { display:block !important; }
+body.alex-fixed .alex-chapter-start { display:none !important; }
+#alex-cut-mask { display:none; position:fixed; z-index:2147483000; left:0; right:0; bottom:0; height:0; background:var(--alex-paper); pointer-events:none; }
+body.alex-paged .alex-chapter { page-break-before:always; break-before:column !important; }
+body.alex-paged .alex-chapter:first-of-type { page-break-before:auto; break-before:auto !important; }
+body.alex-continuous .alex-chapter { break-before:auto !important; page-break-before:auto !important; margin-bottom:3em; }
+.alex-chapter-start { display:block; position:absolute; left:0; top:0; width:0; height:0; overflow:hidden; }
+body:not(.alex-fixed) img, body:not(.alex-fixed) svg, body:not(.alex-fixed) video, body:not(.alex-fixed) canvas { max-width:100% !important; height:auto; object-fit:contain; break-inside:avoid; page-break-inside:avoid; }
+body.alex-fixed .alex-chapter > svg:only-child { display:block; width:100%; height:100%; max-width:none !important; max-height:none !important; }
+body.alex-paged .x-ebookmaker-cover { height:calc(100vh - (2 * var(--alex-v))) !important; max-height:calc(100vh - (2 * var(--alex-v))) !important; overflow:hidden; }
+svg[height="100%"], .x-ebookmaker-cover svg { display:block; height:calc(100vh - (2 * var(--alex-v))) !important; max-height:calc(100vh - (2 * var(--alex-v))) !important; }
+table { max-width:100%; border-collapse:collapse; }
+pre, code { font-family:'Alexandria Mono', monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
+a { color:var(--alex-link); }
+body.alex-hyphenate { -webkit-hyphens:auto; hyphens:auto; }
+body.alex-no-hyphenate { -webkit-hyphens:none !important; hyphens:none !important; }
+body.alex-font-override, body.alex-font-override p, body.alex-font-override div, body.alex-font-override h1, body.alex-font-override h2, body.alex-font-override h3, body.alex-font-override h4, body.alex-font-override h5, body.alex-font-override h6 { font-family:var(--alex-font), serif !important; }
+body.alex-align-left .alex-chapter, body.alex-align-left p { text-align:left !important; }
+body.alex-align-right .alex-chapter, body.alex-align-right p { text-align:right !important; }
+body.alex-align-center .alex-chapter, body.alex-align-center p { text-align:center !important; }
+body.alex-align-justify .alex-chapter, body.alex-align-justify p { text-align:justify !important; }
+body.alex-theme-sepia { --alex-paper:#f3ead7; --alex-ink:#2d261c; --alex-link:#2d261c; }
+body.alex-theme-dark { --alex-paper:#171717; --alex-ink:#ededed; --alex-link:#ededed; }
+body:not(.alex-fixed).alex-theme-sepia .alex-chapter, body:not(.alex-fixed).alex-theme-dark .alex-chapter, body:not(.alex-fixed).alex-theme-sepia .alex-chapter *, body:not(.alex-fixed).alex-theme-dark .alex-chapter * { color:var(--alex-ink) !important; background-color:transparent !important; border-color:currentColor !important; }
+mark.alex-search { color:inherit !important; background:#bdbdbd !important; border-bottom:2px solid #111; }
+mark.alex-search-current { background:#7d7d7d !important; color:#fff !important; }
+mark.alex-annotation { color:inherit !important; background:rgba(125,125,125,.34) !important; border-bottom:2px solid currentColor; cursor:pointer; }
+</style>
+</head>
+<body class="${if (publication.fixedLayout) "alex-fixed" else "alex-paged"} alex-hyphenate" data-direction="${publication.readingDirection}">
+<svg width="0" height="0" aria-hidden="true" style="position:absolute"><defs><filter id="alex-page-filter" x="-10%" y="-10%" width="120%" height="120%" color-interpolation-filters="sRGB"></filter></defs></svg>
+$content
+<div id="alex-cut-mask" aria-hidden="true"></div>
+<script>
+window.ALEX_REFERENCES = $references;
+window.ALEX_DIRECTION = '${publication.readingDirection}';
+window.ALEX_FIXED = ${publication.fixedLayout};
+${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
+</script>
+</body>
+</html>"""
+
+    private fun readerScript(): String = """
+(function () {
+  'use strict';
+  var state = { page:0, total:1, mode:'paged', chapterStarts:[], searchPages:[], searchIndex:-1, searchToken:0, settings:null };
+  function filterNode(name,attributes){var node=document.createElementNS('http://www.w3.org/2000/svg',name);Object.keys(attributes||{}).forEach(function(key){node.setAttribute(key,String(attributes[key]));});return node;}
+  function filterFunctions(parent,type,values){['R','G','B'].forEach(function(channel){parent.appendChild(filterNode('feFunc'+channel,{type:type,tableValues:values}));});parent.appendChild(filterNode('feFuncA',{type:'table',tableValues:'0 1'}));}
+  function applyToneFilter(settings){
+    var filter=document.getElementById('alex-page-filter');while(filter&&filter.firstChild)filter.removeChild(filter.firstChild);
+    var exponent=Math.max(1,Math.min(5,Number(settings.contrastExponent)||1)),gray=Math.max(16,Math.min(255,Number(settings.grayPoint)||255)),dither=settings.dithering||'off';
+    document.body.style.filter='none';if(!filter||(Math.abs(exponent-1)<.001&&dither==='off'))return;var source='SourceGraphic';
+    if(Math.abs(exponent-1)>=.001){var values=[],g=gray/255,rem=1-g;for(var i=0;i<=64;i++){var c=i/64,out=c<g?g*Math.pow(c/g,exponent):(c>g&&rem>0?g+rem*Math.pow((c-g)/rem,1/exponent):g);values.push(Math.max(0,Math.min(1,out)).toFixed(4));}var tone=filterNode('feComponentTransfer',{in:source,result:'alex-tone'});filterFunctions(tone,'table',values.join(' '));filter.appendChild(tone);source='alex-tone';}
+    if(dither!=='off'){filter.appendChild(filterNode('feColorMatrix',{in:source,type:'matrix',result:'alex-gray',values:'0.2126 0.7152 0.0722 0 0  0.2126 0.7152 0.0722 0 0  0.2126 0.7152 0.0722 0 0  0 0 0 1 0'}));filter.appendChild(filterNode('feTurbulence',{type:'fractalNoise',baseFrequency:'.47',numOctaves:'1',seed:'37',stitchTiles:'stitch',result:'alex-noise'}));var amplitude=dither==='g2' ? 0.96 : 0.065,offset=-amplitude/2;filter.appendChild(filterNode('feColorMatrix',{in:'alex-noise',type:'matrix',result:'alex-drift',values:amplitude+' 0 0 0 '+offset+'  '+amplitude+' 0 0 0 '+offset+'  '+amplitude+' 0 0 0 '+offset+'  0 0 0 1 0'}));filter.appendChild(filterNode('feComposite',{in:'alex-gray',in2:'alex-drift',operator:'arithmetic',k1:'0',k2:'1',k3:'1',k4:'0',result:'alex-noisy'}));var levels=[];if(dither==='g2')levels=['0','1'];else for(var level=0;level<16;level++)levels.push((level/15).toFixed(4));var quantized=filterNode('feComponentTransfer',{in:'alex-noisy',result:'alex-quantized'});filterFunctions(quantized,'discrete',levels.join(' '));filter.appendChild(quantized);filter.appendChild(filterNode('feComposite',{in:'alex-quantized',in2:'SourceGraphic',operator:'in'}));}
+    document.body.style.filter='url(#alex-page-filter)';
+  }
+  var scrolling = function () { return document.scrollingElement || document.documentElement; };
+  var pageExtent = function () { return state.mode === 'paged' ? window.innerWidth : window.innerHeight; };
+  var scrollPosition = function () { return state.mode === 'paged' ? scrolling().scrollLeft : scrolling().scrollTop; };
+  function documentExtent() {
+    var root = scrolling();
+    return state.mode === 'paged' ? Math.max(root.scrollWidth, document.body.scrollWidth) : Math.max(root.scrollHeight, document.body.scrollHeight);
+  }
+  function pageForElement(element) {
+    if (!element) return 0;
+    var rects = element.getClientRects();
+    var rect = rects.length ? rects[0] : element.getBoundingClientRect();
+    var absolute = state.mode === 'paged' ? rect.left + scrolling().scrollLeft : rect.top + scrolling().scrollTop;
+    return Math.max(0, Math.floor((absolute + 2) / Math.max(1, pageExtent())));
+  }
+  function recalculate() {
+    var extent = documentExtent();
+    state.total = Math.max(1, Math.ceil((extent - 1) / Math.max(1, pageExtent())));
+    state.page = Math.max(0, Math.min(state.total - 1, Math.round(scrollPosition() / Math.max(1, pageExtent()))));
+    state.chapterStarts = Array.prototype.map.call(document.querySelectorAll('.alex-chapter-start'), function (element) { return pageForElement(element); });
+    notifyPage(false);
+    return { page:state.page, total:state.total, chapters:state.chapterStarts, mode:state.mode };
+  }
+  function chapterAt(page) {
+    var result = 0;
+    state.chapterStarts.forEach(function (start, index) { if (start <= page) result = index; });
+    return result;
+  }
+  function textOffsetAtPoint(chapter) {
+    var range = null;
+    var x = Math.max(4, Math.min(window.innerWidth - 4, parseInt(getComputedStyle(document.documentElement).getPropertyValue('--alex-side')) + 5 || 20));
+    for (var y = 8; y < window.innerHeight - 8 && !range; y += 24) {
+      if (document.caretRangeFromPoint) range = document.caretRangeFromPoint(x, y);
+    }
+    if (!range || !chapter.contains(range.startContainer)) return 0;
+    var before = document.createRange(); before.selectNodeContents(chapter); before.setEnd(range.startContainer, range.startOffset);
+    return before.toString().length;
+  }
+  function locator() {
+    var chapterIndex = chapterAt(state.page);
+    var chapter = document.querySelector('.alex-chapter[data-spine="' + chapterIndex + '"]') || document.querySelectorAll('.alex-chapter')[chapterIndex];
+    return { page:state.page, totalPages:state.total, chapter:chapterIndex, offset:chapter ? textOffsetAtPoint(chapter) : 0, progress:state.total <= 1 ? 0 : state.page / (state.total - 1) };
+  }
+  function notifyPage(force) {
+    if (!window.Android || !Android.onPageChanged) return;
+    var value = locator(); value.force = !!force;
+    Android.onPageChanged(JSON.stringify(value));
+  }
+  function scrollToPage(page, report) {
+    state.page = Math.max(0, Math.min(state.total - 1, Math.round(Number(page) || 0)));
+    var position = state.page * pageExtent();
+    if (state.mode === 'paged') scrolling().scrollTo(position, 0); else scrolling().scrollTo(0, position);
+    if (report !== false) notifyPage(true);
+    return JSON.stringify(locator());
+  }
+  function nodeAtOffset(chapter, offset) {
+    var walker = document.createTreeWalker(chapter, NodeFilter.SHOW_TEXT, { acceptNode:function (node) {
+      return /^(SCRIPT|STYLE)$/.test(node.parentElement ? node.parentElement.tagName : '') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
+    }});
+    var node, count = 0, last = null;
+    while ((node = walker.nextNode())) { last = node; if (count + node.data.length >= offset) return {node:node, offset:Math.max(0, offset-count)}; count += node.data.length; }
+    return last ? {node:last, offset:last.data.length} : null;
+  }
+  function goToLocator(value) {
+    if (!value) return scrollToPage(0, true);
+    var chapter = document.querySelector('.alex-chapter[data-spine="' + (value.chapter || 0) + '"]');
+    var point = chapter ? nodeAtOffset(chapter, Math.max(0, value.offset || 0)) : null;
+    if (point) {
+      var range = document.createRange(); range.setStart(point.node, Math.min(point.offset, point.node.length)); range.collapse(true);
+      var rects = range.getClientRects(), rect = rects.length ? rects[0] : null;
+      if (rect && isFinite(rect.left) && isFinite(rect.top) && (rect.width > 0 || rect.height > 0)) {
+        var absolute = state.mode === 'paged' ? rect.left + scrolling().scrollLeft : rect.top + scrolling().scrollTop;
+        return scrollToPage(Math.floor((absolute + 2) / pageExtent()), true);
+      }
+    }
+    if (Number(value.totalPages) === state.total && typeof value.page === 'number') return scrollToPage(value.page, true);
+    if (typeof value.progress === 'number') return scrollToPage(Math.round(value.progress * Math.max(0, state.total - 1)), true);
+    return scrollToPage(value.page || 0, true);
+  }
+  function applySettings(settings, keep) {
+    state.settings = settings || {};
+    var root = document.documentElement, body = document.body;
+    var family = settings.fontFamily || 'Alexandria Serif';
+    root.style.setProperty('--alex-font', "'" + family.replace(/'/g, '') + "'");
+    root.style.setProperty('--alex-font-size', Math.max(12, Math.min(38, settings.fontSize || 20)) + 'px');
+    root.style.setProperty('--alex-line-height', Math.max(1, Math.min(2, settings.lineHeight || 1.32)));
+    root.style.setProperty('--alex-side', Math.max(0, Math.min(96, settings.margin == null ? 32 : settings.margin)) + 'px');
+    state.mode = settings.mode === 'continuous' ? 'continuous' : 'paged';
+    body.classList.toggle('alex-paged', state.mode === 'paged'); body.classList.toggle('alex-continuous', state.mode !== 'paged');
+    root.classList.toggle('alex-continuous', state.mode !== 'paged');
+    body.classList.toggle('alex-hyphenate', settings.hyphenation !== false); body.classList.toggle('alex-no-hyphenate', settings.hyphenation === false);
+    body.classList.toggle('alex-font-override', family !== 'Publisher');
+    ['left','right','center','justify'].forEach(function (name) { body.classList.toggle('alex-align-' + name, settings.textAlign === name); });
+    body.classList.remove('alex-theme-light','alex-theme-sepia','alex-theme-dark'); body.classList.add('alex-theme-' + (settings.theme || 'light'));
+    document.getElementById('alex-publisher-styles').disabled = settings.publisherStyles === false;
+    applyToneFilter(settings);
+    setTimeout(function () { recalculate(); if (keep) goToLocator(keep); }, 80);
+    return true;
+  }
+  function recordNavigation(targetPage, origin) {
+    var from = origin || locator();
+    var target = Math.max(0, Math.min(state.total - 1, Math.round(Number(targetPage) || 0)));
+    var fromPage = Math.max(0, Math.min(state.total - 1, Math.round(Number(from.page) || 0)));
+    if (target === fromPage || !window.Android || !Android.onNavigationJump) return;
+    Android.onNavigationJump(JSON.stringify(from));
+  }
+  function goToReference(reference, record) {
+    var id = window.ALEX_REFERENCES[reference] || window.ALEX_REFERENCES[String(reference).split('#')[0]] || reference;
+    if (id && id.charAt(0) === '#') id = id.substring(1);
+    var target = document.getElementById(id);
+    if (!target) return false;
+    var page = pageForElement(target); if (record !== false) recordNavigation(page); scrollToPage(page, true); return true;
+  }
+  function clearSearch() {
+    state.searchToken++;
+    Array.prototype.forEach.call(document.querySelectorAll('mark.alex-search'), function (mark) { mark.replaceWith(document.createTextNode(mark.textContent)); });
+    document.body.normalize(); state.searchPages = []; state.searchIndex = -1; recalculate();
+  }
+  function search(query, direction) {
+    var origin = locator(); clearSearch(); var token = state.searchToken;
+    query = String(query || '').trim(); direction = direction === 'backward' ? 'backward' : 'forward';
+    if (!query) { if (window.Android) Android.onSearchResults('[]'); return 0; }
+    var needle = query.toLocaleLowerCase(), counts = {}, walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {acceptNode:function(node){
+      var parent = node.parentElement, chapter = parent && parent.closest ? parent.closest('.alex-chapter') : null;
+      return !chapter || /^(SCRIPT|STYLE|TEXTAREA)$/.test(parent.tagName) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
+    }}), nodes = [], node;
+    while ((node = walker.nextNode())) {
+      var chapter = node.parentElement.closest('.alex-chapter'), chapterIndex = parseInt(chapter.getAttribute('data-spine') || '0');
+      var base = counts[chapterIndex] || 0; nodes.push({node:node,chapter:chapterIndex,offset:base}); counts[chapterIndex] = base + node.data.length;
+    }
+    var marks = [];
+    nodes.forEach(function (part) {
+      var textNode = part.node, lower = textNode.data.toLocaleLowerCase(), positions = [], from = 0, found;
+      while ((found = lower.indexOf(needle, from)) >= 0) { positions.push(found); from = found + Math.max(1, needle.length); }
+      for (var i = positions.length - 1; i >= 0; i--) {
+        var after = textNode.splitText(positions[i]); after.splitText(query.length);
+        var mark = document.createElement('mark'); mark.className = 'alex-search'; mark.dataset.chapter = String(part.chapter);
+        mark.dataset.offset = String(part.offset + positions[i]); after.parentNode.replaceChild(mark, after); mark.appendChild(after); marks.push(mark);
+      }
+    });
+    marks = Array.prototype.slice.call(document.querySelectorAll('mark.alex-search'));
+    setTimeout(function () {
+      if (token !== state.searchToken) return;
+      recalculate(); var results = marks.map(function(mark,index){
+        var page = pageForElement(mark), text = mark.parentElement ? mark.parentElement.textContent.replace(/\s+/g,' ').trim() : mark.textContent;
+        return {index:index,page:page,chapter:parseInt(mark.dataset.chapter || '0'),offset:parseInt(mark.dataset.offset || '0'),snippet:text.substring(0,180)};
+      });
+      var selected = -1;
+      if (direction === 'backward') {
+        for (var i = results.length - 1; i >= 0; i--) if (results[i].chapter < origin.chapter || (results[i].chapter === origin.chapter && results[i].offset <= origin.offset)) { selected = i; break; }
+        if (selected < 0 && results.length) selected = results.length - 1;
+      } else {
+        for (var j = 0; j < results.length; j++) if (results[j].chapter > origin.chapter || (results[j].chapter === origin.chapter && results[j].offset >= origin.offset)) { selected = j; break; }
+        if (selected < 0 && results.length) selected = 0;
+      }
+      results.forEach(function(result,index){result.current = index === selected;});
+      state.searchPages = results.map(function(r){return r.page;}); state.searchIndex = selected;
+      if (selected >= 0) { marks[selected].classList.add('alex-search-current'); recordNavigation(results[selected].page, origin); scrollToPage(results[selected].page, true); }
+      if (window.Android) Android.onSearchResults(JSON.stringify(results));
+    }, 40);
+    return marks.length;
+  }
+  function moveSearch(delta) {
+    var marks = document.querySelectorAll('mark.alex-search'); if (!marks.length) return -1;
+    if (state.searchIndex >= 0 && marks[state.searchIndex]) marks[state.searchIndex].classList.remove('alex-search-current');
+    state.searchIndex = (state.searchIndex + delta + marks.length) % marks.length;
+    var page = pageForElement(marks[state.searchIndex]); recordNavigation(page); marks[state.searchIndex].classList.add('alex-search-current'); scrollToPage(page, true); return state.searchIndex;
+  }
+  function captureSelection() {
+    var selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
+    var range = selection.getRangeAt(0), chapter = range.commonAncestorContainer.nodeType === 1 ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement;
+    chapter = chapter && chapter.closest ? chapter.closest('.alex-chapter') : null; if (!chapter) return null;
+    var before = document.createRange(); before.selectNodeContents(chapter); before.setEnd(range.startContainer, range.startOffset);
+    var start = before.toString().length, quote = range.toString();
+    return { chapter:parseInt(chapter.getAttribute('data-spine') || '0'), start:start, end:start+quote.length, quote:quote.substring(0,10000), page:state.page };
+  }
+  function wrapAnnotation(annotation) {
+    var chapter = document.querySelector('.alex-chapter[data-spine="' + annotation.chapter + '"]'); if (!chapter || annotation.end <= annotation.start) return false;
+    var walker = document.createTreeWalker(chapter, NodeFilter.SHOW_TEXT, {acceptNode:function(node){ return node.parentElement && node.parentElement.closest('mark.alex-annotation') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; }});
+    var nodes=[], node, count=0; while ((node=walker.nextNode())) { nodes.push({node:node,start:count,end:count+node.data.length}); count += node.data.length; }
+    nodes.reverse().forEach(function(part){
+      var from=Math.max(annotation.start,part.start)-part.start, to=Math.min(annotation.end,part.end)-part.start;
+      if (to>from) { var selected=part.node; if (to<selected.length) selected.splitText(to); if (from>0) selected=selected.splitText(from); var mark=document.createElement('mark'); mark.className='alex-annotation'; mark.dataset.annotation=annotation.id; selected.parentNode.replaceChild(mark,selected); mark.appendChild(selected); }
+    }); return true;
+  }
+  function applyAnnotations(values) { (values || []).slice().sort(function(a,b){return b.start-a.start;}).forEach(wrapAnnotation); setTimeout(recalculate,20); }
+  function removeAnnotation(id) { Array.prototype.forEach.call(document.querySelectorAll('mark[data-annotation="' + id + '"]'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));}); document.body.normalize(); recalculate(); }
+  document.addEventListener('click', function (event) {
+    var mark=event.target.closest && event.target.closest('mark.alex-annotation'); if(mark && window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;}
+    var link=event.target.closest && event.target.closest('a'); if(!link)return;
+    var external=link.getAttribute('data-external-href'); if(external){event.preventDefault();if(window.Android)Android.onExternalLink(external);return;}
+    var reference=link.getAttribute('data-alex-ref'); if(reference){event.preventDefault();goToReference(reference,true);}
+  }, true);
+  var scrollTimer=0;
+  function handleScroll(){
+    clearTimeout(scrollTimer); scrollTimer=setTimeout(function(){
+      var next=Math.max(0,Math.min(state.total-1,Math.round(scrollPosition()/Math.max(1,pageExtent()))));
+      if(next!==state.page){state.page=next;notifyPage(false);}
+    },80);
+  }
+  scrolling().addEventListener('scroll',handleScroll,{passive:true});
+  window.addEventListener('scroll',handleScroll,{passive:true});
+  var resizeTimer=0; window.addEventListener('resize',function(){clearTimeout(resizeTimer);resizeTimer=setTimeout(function(){var keep=locator();recalculate();goToLocator(keep);},120);});
+  window.Alex = { recalculate:recalculate, setPage:scrollToPage, next:function(){return scrollToPage(state.page+1,true);}, previous:function(){return scrollToPage(state.page-1,true);},
+    locator:locator, goToLocator:goToLocator, applySettings:applySettings, goToReference:goToReference, search:search, clearSearch:clearSearch,
+    moveSearch:moveSearch, captureSelection:captureSelection, applyAnnotations:applyAnnotations, wrapAnnotation:wrapAnnotation, removeAnnotation:removeAnnotation,
+    chapterPage:function(index){return state.chapterStarts[Math.max(0,Math.min(state.chapterStarts.length-1,index))]||0;}, metrics:function(){return state;} };
+  function ready() { var value=recalculate(); if(window.Android&&Android.onReaderReady)Android.onReaderReady(JSON.stringify(value)); }
+  if (document.fonts && document.fonts.ready) document.fonts.ready.then(function(){setTimeout(ready,30);}); else window.addEventListener('load',ready);
+  window.addEventListener('load',function(){setTimeout(ready,120);});
+})();
+"""
+
+    private fun fixedReaderScript(): String = """
+(function () {
+  'use strict';
+  var pages = Array.prototype.slice.call(document.querySelectorAll('.alex-chapter'));
+  var state = {
+    page:0, total:Math.max(1,pages.length), settings:{}, offsetX:NaN, offsetY:NaN,
+    scale:1, crop:{x:0,y:0,width:1,height:1}, pageWidth:1, pageHeight:1, nextVisible:0,
+    searchIndex:-1, searchPages:[], searchToken:0, lineCache:[]
+  };
+  function number(value, fallback) { value=Number(value); return isFinite(value)?value:fallback; }
+  function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }
+  function pageElement() { return pages[clamp(state.page,0,pages.length-1)] || null; }
+  function pageSize(page) {
+    return { width:Math.max(1,number(page && page.getAttribute('data-page-width'),1200)),
+             height:Math.max(1,number(page && page.getAttribute('data-page-height'),1600)) };
+  }
+  function normalizedMargins(value) {
+    value=value||{};
+    var left=clamp(number(value.left,0),0,90), right=clamp(number(value.right,0),0,90);
+    var top=clamp(number(value.top,0),0,90), bottom=clamp(number(value.bottom,0),0,90);
+    if(left+right>95){var hx=95/(left+right);left*=hx;right*=hx;}
+    if(top+bottom>95){var hy=95/(top+bottom);top*=hy;bottom*=hy;}
+    return {left:left,top:top,right:right,bottom:bottom};
+  }
+  function marginsForPage(index) {
+    var settings=state.settings||{}, scheme=settings.cropScheme||'none';
+    if(scheme==='none') return normalizedMargins(null);
+    if(scheme==='even-odd') return normalizedMargins(((index+1)%2===0)?settings.cropEven:settings.cropOdd);
+    return normalizedMargins(settings.cropAny);
+  }
+  function sourceCrop(page,index) {
+    var size=pageSize(page), margins=marginsForPage(index);
+    var x=size.width*margins.left/100, y=size.height*margins.top/100;
+    return {x:x,y:y,width:Math.max(1,size.width*(100-margins.left-margins.right)/100),
+            height:Math.max(1,size.height*(100-margins.top-margins.bottom)/100),size:size};
+  }
+  function centeredOffset(start,length,visible) {
+    return length<=visible ? start-(visible-length)/2 : start;
+  }
+  function clampOffset(value,start,length,visible) {
+    if(length<=visible) return centeredOffset(start,length,visible);
+    return clamp(number(value,start),start,start+length-visible);
+  }
+  function svgNode(name, attributes) {
+    var node=document.createElementNS('http://www.w3.org/2000/svg',name);
+    Object.keys(attributes||{}).forEach(function(key){node.setAttribute(key,String(attributes[key]));});
+    return node;
+  }
+  function appendFunctions(parent, type, values) {
+    ['R','G','B'].forEach(function(channel){parent.appendChild(svgNode('feFunc'+channel,{type:type,tableValues:values}));});
+    parent.appendChild(svgNode('feFuncA',{type:'table',tableValues:'0 1'}));
+  }
+  function toneValues(exponent, gray) {
+    var values=[], g=clamp(gray/255,0.001,1), remaining=1-g;
+    for(var i=0;i<=64;i++){
+      var c=i/64, out;
+      if(c<g) out=g*Math.pow(c/g,exponent);
+      else if(c>g && remaining>0) out=g+remaining*Math.pow((c-g)/remaining,1/exponent);
+      else out=g;
+      values.push(clamp(out,0,1).toFixed(4));
+    }
+    return values.join(' ');
+  }
+  function updateFilter(page) {
+    if(!page) return;
+    var settings=state.settings||{}, exponent=clamp(number(settings.contrastExponent,1),1,5);
+    var gray=clamp(number(settings.grayPoint,255),16,255), dither=settings.dithering||'off';
+    var filter=document.getElementById('alex-page-filter');
+    while(filter && filter.firstChild) filter.removeChild(filter.firstChild);
+    pages.forEach(function(other){other.style.filter='none';});
+    if(!filter || (Math.abs(exponent-1)<0.001 && dither==='off')) return;
+    var source='SourceGraphic';
+    if(Math.abs(exponent-1)>=0.001){
+      var tone=svgNode('feComponentTransfer',{in:source,result:'alex-tone'});
+      appendFunctions(tone,'table',toneValues(exponent,gray)); filter.appendChild(tone); source='alex-tone';
+    }
+    if(dither!=='off'){
+      filter.appendChild(svgNode('feColorMatrix',{in:source,type:'matrix',result:'alex-gray',
+        values:'0.2126 0.7152 0.0722 0 0  0.2126 0.7152 0.0722 0 0  0.2126 0.7152 0.0722 0 0  0 0 0 1 0'}));
+      filter.appendChild(svgNode('feTurbulence',{type:'fractalNoise',baseFrequency:'0.47',numOctaves:'1',seed:'37',stitchTiles:'stitch',result:'alex-noise'}));
+      var amplitude=dither==='g2'?0.96:0.065, offset=-amplitude/2;
+      filter.appendChild(svgNode('feColorMatrix',{in:'alex-noise',type:'matrix',result:'alex-drift',
+        values:amplitude+' 0 0 0 '+offset+'  '+amplitude+' 0 0 0 '+offset+'  '+amplitude+' 0 0 0 '+offset+'  0 0 0 1 0'}));
+      filter.appendChild(svgNode('feComposite',{in:'alex-gray',in2:'alex-drift',operator:'arithmetic',k1:'0',k2:'1',k3:'1',k4:'0',result:'alex-noisy'}));
+      var levels=[]; if(dither==='g2') levels=['0','1']; else for(var level=0;level<16;level++) levels.push((level/15).toFixed(4));
+      var quantized=svgNode('feComponentTransfer',{in:'alex-noisy',result:'alex-quantized'});
+      appendFunctions(quantized,'discrete',levels.join(' ')); filter.appendChild(quantized);
+      filter.appendChild(svgNode('feComposite',{in:'alex-quantized',in2:'SourceGraphic',operator:'in'}));
+    }
+    page.style.filter='url(#alex-page-filter)';
+  }
+  function layoutPage() {
+    if(!pages.length) return;
+    showCutMask(0,1);
+    state.page=clamp(Math.round(number(state.page,0)),0,pages.length-1);
+    pages.forEach(function(page,index){page.classList.toggle('alex-current-page',index===state.page);page.classList.remove('alex-stream-next');});
+    var page=pageElement(), crop=sourceCrop(page,state.page), size=crop.size;
+    page.style.width=size.width+'px'; page.style.height=size.height+'px';
+    var availableWidth=Math.max(1,window.innerWidth), availableHeight=Math.max(1,window.innerHeight);
+    var fitPage=Math.min(availableWidth/crop.width,availableHeight/crop.height);
+    var fitWidth=availableWidth/crop.width, mode=state.settings.zoomMode||'fit-page';
+    var scale=mode==='fit-width'?fitWidth:(mode==='custom'?fitPage*clamp(number(state.settings.customZoom,100),50,400)/100:fitPage);
+    scale=Math.max(0.01,scale); var visibleWidth=availableWidth/scale, visibleHeight=availableHeight/scale;
+    if(mode==='fit-page'){
+      state.offsetX=centeredOffset(crop.x,crop.width,visibleWidth);
+      state.offsetY=centeredOffset(crop.y,crop.height,visibleHeight);
+    } else if(mode==='fit-width') {
+      state.offsetX=clampOffset(state.offsetX,crop.x,crop.width,visibleWidth);
+      state.offsetY=clamp(number(state.offsetY,crop.y),crop.y,crop.y+crop.height-1);
+    } else {
+      state.offsetX=clampOffset(state.offsetX,crop.x,crop.width,visibleWidth);
+      state.offsetY=clampOffset(state.offsetY,crop.y,crop.height,visibleHeight);
+    }
+    page.style.transform='translate('+(-state.offsetX*scale)+'px,'+(-state.offsetY*scale)+'px) scale('+scale+')';
+    state.scale=scale; state.crop=crop; state.pageWidth=size.width; state.pageHeight=size.height; state.nextVisible=0;
+    updateFilter(page);
+    if(mode==='fit-width' && (state.settings.scrollMode||'screen')==='screen'){
+      var currentTarget=state.offsetY+visibleHeight,currentEnd=crop.y+crop.height;
+      if(currentTarget<currentEnd-1){
+        var currentCut=lineCutOnPage(page,currentTarget,1,scale,state.offsetY);
+        if(currentCut<currentTarget-1)showCutMask(currentTarget-currentCut,scale);
+      }
+    }
+    if(mode==='fit-width' && (state.settings.scrollMode||'screen')==='screen' && state.page<pages.length-1){
+      var remaining=Math.max(0,(crop.y+crop.height-state.offsetY)*scale);
+      if(remaining<availableHeight-1){
+        var next=pages[state.page+1],nextCrop=sourceCrop(next,state.page+1),nextSize=nextCrop.size,nextScale=availableWidth/nextCrop.width;
+        next.classList.add('alex-stream-next');next.style.width=nextSize.width+'px';next.style.height=nextSize.height+'px';
+        next.style.transform='translate('+(-nextCrop.x*nextScale)+'px,'+(remaining-nextCrop.y*nextScale)+'px) scale('+nextScale+')';
+        next.style.filter=page.style.filter;state.nextVisible=Math.min(nextCrop.height,(availableHeight-remaining)/nextScale);
+        var nextTarget=nextCrop.y+state.nextVisible,nextCut=lineCutOnPage(next,nextTarget,1,nextScale,nextCrop.y);
+        if(nextCut<nextTarget-1){state.nextVisible=Math.max(0,nextCut-nextCrop.y);showCutMask(nextTarget-nextCut,nextScale);}
+      }
+    }
+  }
+  function locator() {
+    return {page:state.page,totalPages:state.total,chapter:state.page,offset:0,
+      progress:state.total<=1?0:state.page/(state.total-1),
+      viewportX:isFinite(state.offsetX)?state.offsetX:0,viewportY:isFinite(state.offsetY)?state.offsetY:0};
+  }
+  function notifyPage(force) {
+    if(!window.Android||!Android.onPageChanged)return;
+    var value=locator();value.force=!!force;Android.onPageChanged(JSON.stringify(value));
+  }
+  function recalculate() {
+    state.total=Math.max(1,pages.length);layoutPage();notifyPage(false);
+    return {page:state.page,total:state.total,chapters:pages.map(function(_,index){return index;}),mode:'fixed'};
+  }
+  function setPage(page,report) {
+    var next=clamp(Math.round(number(page,0)),0,state.total-1), changed=next!==state.page;
+    state.page=next;if(changed){state.offsetX=NaN;state.offsetY=NaN;}layoutPage();if(report!==false)notifyPage(true);return JSON.stringify(locator());
+  }
+  function pageForElement(element) {
+    var chapter=element&&element.closest?element.closest('.alex-chapter'):null;
+    return chapter?clamp(number(chapter.getAttribute('data-spine'),0),0,state.total-1):0;
+  }
+  function measuredLines(page) {
+    var index=number(page.getAttribute('data-spine'),0);
+    if(state.lineCache[index])return state.lineCache[index];
+    var lines=String(page.getAttribute('data-line-boxes')||'').split(',').map(function(value){
+      var pair=value.split(':');return{top:number(pair[0],0),bottom:number(pair[1],0)};
+    }).filter(function(line){return line.bottom>line.top;});
+    state.lineCache[index]=lines;return lines;
+  }
+  function lineCutOnPage(page,target,direction,scale,offset) {
+    if((state.settings.scrollMode||'screen')!=='screen') return target;
+    var lines=measuredLines(page);
+    for(var i=0;i<lines.length;i){
+      var line=lines[i];
+      if(target>line.top-2&&target<line.bottom+2){
+        if(direction>0) return line.top>offset+8?line.top:line.bottom;
+        return line.top;
+      }
+    }
+    return target;
+  }
+  function linePreservingCut(target,direction) {return lineCutOnPage(pageElement(),target,direction,state.scale,state.offsetY);}
+  function showCutMask(sourceHeight,scale) {
+    var mask=document.getElementById('alex-cut-mask'),height=Math.max(0,sourceHeight*scale);
+    if(!mask||height<1){if(mask)mask.style.display='none';return;}
+    mask.style.height=Math.ceil(height)+'px';mask.style.display='block';
+  }
+  function advanceSlice(direction) {
+    if((state.settings.zoomMode||'fit-page')!=='fit-width') return setPage(state.page+direction,true);
+    var availableHeight=window.innerHeight,visibleHeight=availableHeight/state.scale,crop=state.crop,start=crop.y,end=crop.y+crop.height;
+    if(direction>0){
+      if(state.offsetY+visibleHeight<end-1){
+        var target=linePreservingCut(Math.min(end-1,state.offsetY+visibleHeight),1);
+        state.offsetY=clamp(target,state.offsetY+Math.min(8,visibleHeight/10),end-1);layoutPage();notifyPage(true);return JSON.stringify(locator());
+      }
+      if(state.page<state.total-1){
+        var alreadyShown=(state.settings.scrollMode||'screen')==='screen'?state.nextVisible:0;
+        state.page+=1;state.offsetX=NaN;state.offsetY=NaN;layoutPage();
+        if(alreadyShown>0){state.offsetY=state.crop.y+alreadyShown;layoutPage();}
+        notifyPage(true);return JSON.stringify(locator());
+      }
+      return JSON.stringify(locator());
+    }
+    var precedingScreen=(state.offsetY-start)*state.scale;
+    if(precedingScreen>=availableHeight-1){
+      state.offsetY=clamp(linePreservingCut(Math.max(start,state.offsetY-visibleHeight),-1),start,state.offsetY-1);layoutPage();notifyPage(true);return JSON.stringify(locator());
+    }
+    if(state.page>0){
+      var currentPreceding=Math.max(0,precedingScreen),needed=(state.settings.scrollMode||'screen')==='screen'?availableHeight-currentPreceding:availableHeight;
+      state.page-=1;state.offsetX=NaN;state.offsetY=NaN;layoutPage();
+      state.offsetY=clamp(linePreservingCut(state.crop.y+state.crop.height-needed/state.scale,-1),state.crop.y,state.crop.y+state.crop.height-1);
+      layoutPage();notifyPage(true);return JSON.stringify(locator());
+    }
+    if(state.offsetY>start){state.offsetY=start;layoutPage();notifyPage(true);}return JSON.stringify(locator());
+  }
+  function goToLocator(value) {
+    if(!value)return setPage(0,true);
+    state.page=clamp(Math.round(number(value.chapter,value.page||0)),0,state.total-1);
+    state.offsetX=number(value.viewportX,NaN);state.offsetY=number(value.viewportY,NaN);layoutPage();notifyPage(true);return JSON.stringify(locator());
+  }
+  function applySettings(settings,keep) {
+    state.settings=settings||{};
+    var body=document.body,theme=state.settings.theme||'light';
+    body.classList.remove('alex-theme-light','alex-theme-sepia','alex-theme-dark');body.classList.add('alex-theme-'+theme);
+    // Fixed-layout coordinates depend on the publisher stylesheet.
+    document.getElementById('alex-publisher-styles').disabled=false;
+    if(keep){state.page=clamp(Math.round(number(keep.chapter,keep.page||0)),0,state.total-1);state.offsetX=number(keep.viewportX,NaN);state.offsetY=number(keep.viewportY,NaN);}
+    layoutPage();notifyPage(true);return true;
+  }
+  function recordNavigation(targetPage,origin) {
+    var from=origin||locator(),target=clamp(Math.round(number(targetPage,0)),0,state.total-1),fromPage=clamp(Math.round(number(from.page,0)),0,state.total-1);
+    if(target===fromPage||!window.Android||!Android.onNavigationJump)return;Android.onNavigationJump(JSON.stringify(from));
+  }
+  function goToReference(reference,record) {
+    var id=window.ALEX_REFERENCES[reference]||window.ALEX_REFERENCES[String(reference).split('#')[0]]||reference;
+    if(id&&id.charAt(0)==='#')id=id.substring(1);var target=document.getElementById(id);if(!target)return false;
+    var page=pageForElement(target);if(record!==false)recordNavigation(page);setPage(page,true);return true;
+  }
+  function clearSearch() {
+    state.searchToken++;
+    Array.prototype.forEach.call(document.querySelectorAll('mark.alex-search'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));});
+    document.body.normalize();state.searchPages=[];state.searchIndex=-1;
+  }
+  function search(query,direction) {
+    var origin=locator();clearSearch();query=String(query||'').trim();direction=direction==='backward'?'backward':'forward';
+    if(!query){if(window.Android)Android.onSearchResults('[]');return 0;}
+    var needle=query.toLocaleLowerCase(),walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,{acceptNode:function(node){
+      var parent=node.parentElement,chapter=parent&&parent.closest?parent.closest('.alex-chapter'):null;
+      return !chapter||/^(SCRIPT|STYLE|TEXTAREA)$/.test(parent.tagName)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT;
+    }}),nodes=[],node;while((node=walker.nextNode()))nodes.push(node);var marks=[];
+    nodes.forEach(function(textNode){var lower=textNode.data.toLocaleLowerCase(),positions=[],from=0,found;
+      while((found=lower.indexOf(needle,from))>=0){positions.push(found);from=found+Math.max(1,needle.length);}
+      for(var i=positions.length-1;i>=0;i--){var after=textNode.splitText(positions[i]);after.splitText(query.length);var mark=document.createElement('mark');mark.className='alex-search';after.parentNode.replaceChild(mark,after);mark.appendChild(after);marks.push(mark);}
+    });marks=Array.prototype.slice.call(document.querySelectorAll('mark.alex-search'));
+    var results=marks.map(function(mark,index){var page=pageForElement(mark),text=mark.parentElement?mark.parentElement.textContent.replace(/\s+/g,' ').trim():mark.textContent;return{index:index,page:page,chapter:page,offset:0,snippet:text.substring(0,180)};});
+    var selected=-1;if(direction==='backward'){for(var back=results.length-1;back>=0;back--)if(results[back].page<=origin.page){selected=back;break;}if(selected<0&&results.length)selected=results.length-1;}
+    else{for(var forward=0;forward<results.length;forward++)if(results[forward].page>=origin.page){selected=forward;break;}if(selected<0&&results.length)selected=0;}
+    results.forEach(function(result,index){result.current=index===selected;});state.searchPages=results.map(function(result){return result.page;});state.searchIndex=selected;
+    if(selected>=0){marks[selected].classList.add('alex-search-current');recordNavigation(results[selected].page,origin);setPage(results[selected].page,true);}
+    if(window.Android)Android.onSearchResults(JSON.stringify(results));return marks.length;
+  }
+  function moveSearch(delta) {
+    var marks=document.querySelectorAll('mark.alex-search');if(!marks.length)return-1;
+    if(state.searchIndex>=0&&marks[state.searchIndex])marks[state.searchIndex].classList.remove('alex-search-current');
+    state.searchIndex=(state.searchIndex+delta+marks.length)%marks.length;var page=pageForElement(marks[state.searchIndex]);recordNavigation(page);marks[state.searchIndex].classList.add('alex-search-current');setPage(page,true);return state.searchIndex;
+  }
+  function captureSelection() {
+    var selection=window.getSelection();if(!selection||selection.rangeCount===0||selection.isCollapsed)return null;
+    var range=selection.getRangeAt(0),chapter=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;
+    chapter=chapter&&chapter.closest?chapter.closest('.alex-chapter'):null;if(!chapter)return null;
+    var before=document.createRange();before.selectNodeContents(chapter);before.setEnd(range.startContainer,range.startOffset);var start=before.toString().length,quote=range.toString();
+    return{chapter:parseInt(chapter.getAttribute('data-spine')||'0'),start:start,end:start+quote.length,quote:quote.substring(0,10000),page:state.page};
+  }
+  function nodeAtOffset(chapter,offset){var walker=document.createTreeWalker(chapter,NodeFilter.SHOW_TEXT),node,count=0,last=null;while((node=walker.nextNode())){last=node;if(count+node.data.length>=offset)return{node:node,offset:Math.max(0,offset-count)};count+=node.data.length;}return last?{node:last,offset:last.data.length}:null;}
+  function wrapAnnotation(annotation) {
+    var chapter=document.querySelector('.alex-chapter[data-spine="'+annotation.chapter+'"]');if(!chapter||annotation.end<=annotation.start)return false;
+    var walker=document.createTreeWalker(chapter,NodeFilter.SHOW_TEXT,{acceptNode:function(node){return node.parentElement&&node.parentElement.closest('mark.alex-annotation')?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT;}}),nodes=[],node,count=0;
+    while((node=walker.nextNode())){nodes.push({node:node,start:count,end:count+node.data.length});count+=node.data.length;}nodes.reverse().forEach(function(part){var from=Math.max(annotation.start,part.start)-part.start,to=Math.min(annotation.end,part.end)-part.start;if(to>from){var selected=part.node;if(to<selected.length)selected.splitText(to);if(from>0)selected=selected.splitText(from);var mark=document.createElement('mark');mark.className='alex-annotation';mark.dataset.annotation=annotation.id;selected.parentNode.replaceChild(mark,selected);mark.appendChild(selected);}});return true;
+  }
+  function applyAnnotations(values){(values||[]).slice().sort(function(a,b){return b.start-a.start;}).forEach(wrapAnnotation);}
+  function removeAnnotation(id){Array.prototype.forEach.call(document.querySelectorAll('mark[data-annotation="'+id+'"]'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));});document.body.normalize();}
+  function panBy(dx,dy){
+    if((state.settings.zoomMode||'fit-page')!=='custom')return false;state.offsetX-=number(dx,0)/state.scale;state.offsetY-=number(dy,0)/state.scale;layoutPage();return true;
+  }
+  function zoomBy(factor){
+    factor=clamp(number(factor,1),0.75,1.33);var crop=state.crop,fitPage=Math.min(window.innerWidth/crop.width,window.innerHeight/crop.height);
+    if((state.settings.zoomMode||'fit-page')!=='custom')state.settings.customZoom=clamp(Math.round(100*state.scale/fitPage),50,400);
+    var centerX=state.offsetX+window.innerWidth/(2*state.scale),centerY=state.offsetY+window.innerHeight/(2*state.scale);
+    state.settings.zoomMode='custom';state.settings.customZoom=clamp(number(state.settings.customZoom,100)*factor,50,400);
+    var nextScale=fitPage*state.settings.customZoom/100;state.offsetX=centerX-window.innerWidth/(2*nextScale);state.offsetY=centerY-window.innerHeight/(2*nextScale);layoutPage();return state.settings.customZoom;
+  }
+  function finishViewportGesture(){notifyPage(true);if(window.Android&&Android.onViewportChanged)Android.onViewportChanged(JSON.stringify({zoomMode:state.settings.zoomMode||'fit-page',customZoom:Math.round(number(state.settings.customZoom,100))}));return JSON.stringify(locator());}
+  document.addEventListener('click',function(event){
+    var mark=event.target.closest&&event.target.closest('mark.alex-annotation');if(mark&&window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;}
+    var link=event.target.closest&&event.target.closest('a');if(!link)return;var external=link.getAttribute('data-external-href');if(external){event.preventDefault();if(window.Android)Android.onExternalLink(external);return;}
+    var reference=link.getAttribute('data-alex-ref');if(reference){event.preventDefault();goToReference(reference,true);}
+  },true);
+  var resizeTimer=0;window.addEventListener('resize',function(){clearTimeout(resizeTimer);resizeTimer=setTimeout(function(){layoutPage();notifyPage(true);},120);});
+  window.Alex={recalculate:recalculate,setPage:setPage,next:function(){return advanceSlice(1);},previous:function(){return advanceSlice(-1);},locator:locator,goToLocator:goToLocator,
+    applySettings:applySettings,goToReference:goToReference,search:search,clearSearch:clearSearch,moveSearch:moveSearch,captureSelection:captureSelection,
+    applyAnnotations:applyAnnotations,wrapAnnotation:wrapAnnotation,removeAnnotation:removeAnnotation,chapterPage:function(index){return clamp(index,0,state.total-1);},metrics:function(){return state;},
+    panBy:panBy,zoomBy:zoomBy,finishViewportGesture:finishViewportGesture};
+  function ready(){var value=recalculate();if(window.Android&&Android.onReaderReady)Android.onReaderReady(JSON.stringify(value));}
+  if(document.fonts&&document.fonts.ready)document.fonts.ready.then(function(){setTimeout(ready,30);});else window.addEventListener('load',ready);
+  window.addEventListener('load',function(){setTimeout(ready,120);});
+})();
+"""
+
+    private fun loadStylesheet(path: String, root: File, loaded: MutableSet<String>): String {
+        val normalized = EpubParser.normalizePath("", path)
+        if (!loaded.add(normalized)) return ""
+        val file = runCatching { EpubParser.safeFile(root, normalized) }.getOrNull()
+        if (file?.isFile != true) return ""
+        val base = normalized.substringBeforeLast('/', "")
+        val expanded = expandCssImports(file.readText(), base, root, loaded)
+        return rewriteCssUrls(expanded, base, root)
+    }
+
+    private fun expandCssImports(css: String, base: String, root: File, loaded: MutableSet<String>): String {
+        val importPattern = Regex(
+            "@import\\s+(?:url\\(\\s*)?(?:\\\"([^\\\"]+)\\\"|'([^']+)'|([^\\s;)]+))\\s*\\)?[^;]*;",
+            setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL),
+        )
+        return importPattern.replace(css) { match ->
+            val href = match.groupValues.drop(1).firstOrNull { it.isNotBlank() }.orEmpty()
+            if (href.isBlank() || isExternal(href)) ""
+            else loadStylesheet(EpubParser.normalizePath(base, href.substringBefore('#').substringBefore('?')), root, loaded)
+        }
+    }
+
+    private fun rewriteCssUrls(css: String, base: String, root: File): String {
+        var rewritten = Regex("url\\(\\s*(['\"]?)(.*?)\\1\\s*\\)", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)).replace(css) { match ->
+            val value = match.groupValues[2].trim()
+            val lower = value.lowercase(Locale.ROOT)
+            val target = resourceUrl(base, value, root)
+            when {
+                lower.startsWith("file:") || lower.startsWith("content:") || lower.startsWith("javascript:") -> "url('')"
+                target == null -> match.value
+                else -> "url('$target')"
+            }
+        }
+        rewritten = Regex("@import\\s+(['\"])(.*?)\\1", RegexOption.IGNORE_CASE).replace(rewritten) { match ->
+            val target = resourceUrl(base, match.groupValues[2], root)
+            if (target == null) match.value else "@import url('$target')"
+        }
+        return rewritten.replace(Regex("@charset\\s+[^;]+;", RegexOption.IGNORE_CASE), "")
+    }
+
+    private fun rewriteSrcSet(value: String, base: String, root: File): String = value.split(',').joinToString(", ") { candidate ->
+        val bits = candidate.trim().split(Regex("\\s+"), limit = 2)
+        val url = resourceUrl(base, bits.firstOrNull().orEmpty(), root) ?: bits.firstOrNull().orEmpty()
+        if (bits.size == 2) "$url ${bits[1]}" else url
+    }
+
+    private fun resourceUrl(base: String, value: String, root: File): String? {
+        if (value.isBlank() || isExternal(value) || value.startsWith('#')) return null
+        val path = EpubParser.normalizePath(base, value.substringBefore('#').substringBefore('?'))
+        return runCatching { EpubParser.safeFile(root, path) }.getOrNull()?.takeIf(File::isFile)?.toURI()?.toASCIIString()
+    }
+
+    /** Re-targets root/body selectors to each embedded spine section. */
+    private fun scopeCss(css: String): String {
+        fun transformSelector(value: String): String = value.split(',').joinToString(",") { selector ->
+            selector
+                .replace(Regex("(?<![-\\w])html\\s+body(?=([.#:\\[]|\\s|>|\\+|~|$))", RegexOption.IGNORE_CASE), ".alex-chapter")
+                .replace(Regex("(?<![-\\w])body(?=([.#:\\[]|\\s|>|\\+|~|$))", RegexOption.IGNORE_CASE), ".alex-chapter")
+                .replace(Regex("(?<![-\\w])html(?=([.#:\\[]|\\s|>|\\+|~|$))", RegexOption.IGNORE_CASE), "#alex-book")
+                .replace(Regex("(?<![-\\w]):root(?![-\\w])", RegexOption.IGNORE_CASE), "#alex-book")
+        }
+        fun process(source: String): String {
+            val out = StringBuilder(); var index = 0
+            while (index < source.length) {
+                val open = findCssDelimiter(source, index, '{')
+                if (open < 0) { out.append(source.substring(index)); break }
+                val prelude = source.substring(index, open)
+                val close = matchingBrace(source, open)
+                if (close < 0) { out.append(source.substring(index)); break }
+                val body = source.substring(open + 1, close)
+                val trimmed = prelude.trimStart()
+                fun appendRule(rulePrelude: String, declarations: String) {
+                    val pagedBody = declarations
+                        .replace(Regex("page-break-before\\s*:\\s*always\\s*;?", RegexOption.IGNORE_CASE)) { "page-break-before:always;break-before:column;" }
+                        .replace(Regex("page-break-after\\s*:\\s*always\\s*;?", RegexOption.IGNORE_CASE)) { "page-break-after:always;break-after:column;" }
+                        .replace(Regex("page-break-before\\s*:\\s*avoid\\s*;?", RegexOption.IGNORE_CASE)) { "page-break-before:avoid;break-before:avoid-column;" }
+                        .replace(Regex("page-break-after\\s*:\\s*avoid\\s*;?", RegexOption.IGNORE_CASE)) { "page-break-after:avoid;break-after:avoid-column;" }
+                        .replace(Regex("page-break-inside\\s*:\\s*avoid\\s*;?", RegexOption.IGNORE_CASE)) { "page-break-inside:avoid;break-inside:avoid-column;" }
+                    out.append(transformSelector(rulePrelude)).append('{').append(pagedBody).append('}')
+                }
+                when {
+                    trimmed.startsWith("@media", true) || trimmed.startsWith("@supports", true) || trimmed.startsWith("@layer", true) ->
+                        out.append(prelude).append('{').append(process(body)).append('}')
+                    // Preserve semicolon at-rules such as @namespace, then scope
+                    // the ordinary selector that follows them.
+                    trimmed.startsWith('@') && prelude.lastIndexOf(';') >= 0 -> {
+                        val split = prelude.lastIndexOf(';') + 1
+                        out.append(prelude.substring(0, split))
+                        appendRule(prelude.substring(split), body)
+                    }
+                    trimmed.startsWith('@') -> out.append(prelude).append('{').append(body).append('}')
+                    else -> appendRule(prelude, body)
+                }
+                index = close + 1
+            }
+            return out.toString()
+        }
+        return process(css)
+    }
+
+    private fun findCssDelimiter(value: String, start: Int, delimiter: Char): Int {
+        var quote = '\u0000'; var comment = false; var index = start
+        while (index < value.length) {
+            if (comment) { if (index + 1 < value.length && value[index] == '*' && value[index + 1] == '/') { comment = false; index++ } }
+            else if (quote != '\u0000') { if (value[index] == '\\') index++ else if (value[index] == quote) quote = '\u0000' }
+            else if (index + 1 < value.length && value[index] == '/' && value[index + 1] == '*') { comment = true; index++ }
+            else if (value[index] == '\'' || value[index] == '"') quote = value[index]
+            else if (value[index] == delimiter) return index
+            index++
+        }
+        return -1
+    }
+
+    private fun matchingBrace(value: String, open: Int): Int {
+        var depth = 1; var quote = '\u0000'; var comment = false; var index = open + 1
+        while (index < value.length) {
+            if (comment) { if (index + 1 < value.length && value[index] == '*' && value[index + 1] == '/') { comment = false; index++ } }
+            else if (quote != '\u0000') { if (value[index] == '\\') index++ else if (value[index] == quote) quote = '\u0000' }
+            else if (index + 1 < value.length && value[index] == '/' && value[index + 1] == '*') { comment = true; index++ }
+            else if (value[index] == '\'' || value[index] == '"') quote = value[index]
+            else if (value[index] == '{') depth++
+            else if (value[index] == '}' && --depth == 0) return index
+            index++
+        }
+        return -1
+    }
+
+    private fun generatedId(chapter: Int, original: String): String =
+        "alex-$chapter-${original.replace(Regex("[^A-Za-z0-9_-]"), "-")}-${original.hashCode().toUInt().toString(16)}"
+
+    private fun isExternal(value: String): Boolean {
+        val lower = value.trim().lowercase(Locale.ROOT)
+        return lower.startsWith("http:") || lower.startsWith("https:") || lower.startsWith("mailto:") ||
+            lower.startsWith("tel:") || lower.startsWith("data:") || lower.startsWith("javascript:") || lower.startsWith("blob:") ||
+            lower.startsWith("file:") || lower.startsWith("content:") || lower.startsWith("about:")
+    }
+
+    private fun escapeHtml(value: String): String = value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
+    private fun escapeAttribute(value: String): String = escapeHtml(value).replace("\"", "&quot;")
+}
diff --git a/app/src/main/java/com/alexandria/reader/LibraryRepository.kt b/app/src/main/java/com/alexandria/reader/LibraryRepository.kt
new file mode 100644 (file)
index 0000000..d4b813f
--- /dev/null
@@ -0,0 +1,247 @@
+package com.alexandria.reader
+
+import android.content.ContentResolver
+import android.content.Context
+import android.net.Uri
+import android.provider.OpenableColumns
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.security.MessageDigest
+import java.util.UUID
+
+/** Owns imported files and all reading state. No broad storage permission is required. */
+class LibraryRepository(private val context: Context) {
+    private val libraryDirectory = File(context.filesDir, "library").apply { mkdirs() }
+    private val indexFile = File(libraryDirectory, "index.json")
+    private val preferences = context.getSharedPreferences("alexandria-reader", Context.MODE_PRIVATE)
+    private val lock = Any()
+    private var books = loadIndex().toMutableList()
+
+    enum class SortOrder { RECENT, ADDED, TITLE, AUTHOR, PROGRESS }
+
+    fun books(order: SortOrder = sortOrder(), query: String = ""): List<LibraryBook> = synchronized(lock) {
+        val terms = query.trim().lowercase()
+        val filtered = if (terms.isBlank()) books else books.filter { book ->
+            val selected = when {
+                terms.startsWith("'t") -> book.title to terms.drop(2).trim()
+                terms.startsWith("'a") -> book.author to terms.drop(2).trim()
+                terms == "'n" -> book.state.name to "new"
+                terms == "'r" -> book.state.name to "reading"
+                terms == "'f" -> book.state.name to "finished"
+                else -> "${book.title}\n${book.author}\n${book.publisher}\n${book.fileName}" to terms
+            }
+            selected.first.lowercase().contains(selected.second)
+        }
+        when (order) {
+            SortOrder.RECENT -> filtered.sortedWith(compareByDescending<LibraryBook> { it.lastOpenedAt }.thenBy { it.title.lowercase() })
+            SortOrder.ADDED -> filtered.sortedByDescending { it.addedAt }
+            SortOrder.TITLE -> filtered.sortedBy { it.title.lowercase() }
+            SortOrder.AUTHOR -> filtered.sortedWith(compareBy<LibraryBook> { it.author.lowercase() }.thenBy { it.title.lowercase() })
+            SortOrder.PROGRESS -> filtered.sortedByDescending { it.progress }
+        }
+    }
+
+    fun sortOrder(): SortOrder = runCatching {
+        SortOrder.valueOf(preferences.getString("library-sort", SortOrder.RECENT.name)!!)
+    }.getOrDefault(SortOrder.RECENT)
+
+    fun setSortOrder(value: SortOrder) {
+        preferences.edit().putString("library-sort", value.name).apply()
+    }
+
+    /** Copies a SAF document into private storage, verifies it, and extracts its EPUB package. */
+    fun import(uri: Uri): EpubPublication {
+        val displayName = displayName(context.contentResolver, uri) ?: "book.epub"
+        val temporary = File(context.cacheDir, "import-${UUID.randomUUID()}.epub")
+        val digest = MessageDigest.getInstance("SHA-256")
+        try {
+            context.contentResolver.openInputStream(uri)?.use { input ->
+                FileOutputStream(temporary).use { output ->
+                    val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+                    var total = 0L
+                    while (true) {
+                        val count = input.read(buffer)
+                        if (count < 0) break
+                        total += count
+                        require(total <= MAX_IMPORT_BYTES) { "This book is larger than the 1 GB import limit." }
+                        digest.update(buffer, 0, count)
+                        output.write(buffer, 0, count)
+                    }
+                }
+            } ?: error("The selected document could not be read.")
+            require(temporary.length() > 0) { "The selected document is empty." }
+            val id = digest.digest().joinToString("") { "%02x".format(it) }.take(24)
+            synchronized(lock) {
+                val existing = books.firstOrNull { it.id == id }
+                if (existing != null) return open(existing)
+            }
+
+            val bookDirectory = directoryFor(id).apply { mkdirs() }
+            val source = File(bookDirectory, "source.epub")
+            if (!temporary.renameTo(source)) {
+                FileInputStream(temporary).use { input -> FileOutputStream(source).use(input::copyTo) }
+            }
+            val safeName = displayName.replace(Regex("[\\/\\u0000-\\u001f]"), "_").take(160)
+            val book = LibraryBook(
+                id = id,
+                title = safeName.substringBeforeLast('.').ifBlank { "Untitled" },
+                author = "Unknown author",
+                fileName = safeName,
+            )
+            val publication = try {
+                EpubParser.parse(source, File(bookDirectory, "content"), book, forceExtract = true)
+            } catch (error: Throwable) {
+                bookDirectory.deleteRecursively()
+                throw error
+            }
+            synchronized(lock) {
+                books.removeAll { it.id == id }
+                books.add(publication.book)
+                saveIndex()
+            }
+            return publication
+        } finally {
+            temporary.delete()
+        }
+    }
+
+    fun open(book: LibraryBook): EpubPublication {
+        val canonical = synchronized(lock) { books.firstOrNull { it.id == book.id } ?: book }
+        val directory = directoryFor(canonical.id)
+        val source = File(directory, "source.epub")
+        require(source.isFile) { "The imported EPUB is missing." }
+        return EpubParser.parse(source, File(directory, "content"), canonical, forceExtract = false)
+    }
+
+    fun readerFile(publication: EpubPublication): File =
+        EpubHtmlBuilder.write(publication, File(directoryFor(publication.book.id), "reader.html"))
+
+    fun updateProgress(bookId: String, location: ReaderLocation) = synchronized(lock) {
+        books.firstOrNull { it.id == bookId }?.let { book ->
+            book.currentPage = location.page
+            book.totalPages = location.totalPages.coerceAtLeast(1)
+            book.progress = location.progress.coerceIn(0f, 1f)
+            book.lastOpenedAt = System.currentTimeMillis()
+            book.state = when {
+                book.progress >= .985f -> LibraryBook.ReadingState.FINISHED
+                book.progress > 0f -> LibraryBook.ReadingState.READING
+                else -> book.state
+            }
+            saveIndex()
+        }
+        saveLocation(bookId, location)
+    }
+
+    fun markOpened(bookId: String) = synchronized(lock) {
+        books.firstOrNull { it.id == bookId }?.let {
+            it.lastOpenedAt = System.currentTimeMillis()
+            if (it.state == LibraryBook.ReadingState.NEW) it.state = LibraryBook.ReadingState.READING
+            saveIndex()
+        }
+    }
+
+    fun setState(bookId: String, state: LibraryBook.ReadingState) = synchronized(lock) {
+        books.firstOrNull { it.id == bookId }?.let {
+            it.state = state
+            if (state == LibraryBook.ReadingState.FINISHED) it.progress = 1f
+            saveIndex()
+        }
+    }
+
+    fun rename(bookId: String, title: String) = synchronized(lock) {
+        books.firstOrNull { it.id == bookId }?.let {
+            it.title = title.trim().ifBlank { it.title }
+            saveIndex()
+        }
+    }
+
+    fun remove(bookId: String) = synchronized(lock) {
+        books.removeAll { it.id == bookId }
+        saveIndex()
+        directoryFor(bookId).deleteRecursively()
+    }
+
+    fun location(bookId: String): ReaderLocation {
+        val file = File(directoryFor(bookId), "location.json")
+        return runCatching { ReaderLocation.fromJson(JSONObject(file.readText())) }.getOrElse {
+            val book = synchronized(lock) { books.firstOrNull { it.id == bookId } }
+            ReaderLocation(book?.currentPage ?: 0, book?.totalPages ?: 1, progress = book?.progress ?: 0f)
+        }
+    }
+
+    private fun saveLocation(bookId: String, location: ReaderLocation) {
+        writeAtomically(File(directoryFor(bookId), "location.json"), location.toJson().toString())
+    }
+
+    fun settings(bookId: String): ReaderSettings {
+        val perBook = File(directoryFor(bookId), "settings.json")
+        val value = runCatching { JSONObject(perBook.readText()) }.getOrNull()
+            ?: preferences.getString("default-settings", null)?.let { runCatching { JSONObject(it) }.getOrNull() }
+        return value?.let(ReaderSettings::fromJson) ?: ReaderSettings()
+    }
+
+    fun saveSettings(bookId: String, settings: ReaderSettings, makeDefault: Boolean = false) {
+        val json = settings.toJson().toString()
+        writeAtomically(File(directoryFor(bookId), "settings.json"), json)
+        if (makeDefault) preferences.edit().putString("default-settings", json).apply()
+    }
+
+    fun bookmarks(bookId: String): MutableList<Bookmark> = readArray(bookId, "bookmarks.json", Bookmark::fromJson)
+    fun annotations(bookId: String): MutableList<Annotation> = readArray(bookId, "annotations.json", Annotation::fromJson)
+
+    fun saveBookmarks(bookId: String, values: List<Bookmark>) =
+        writeArray(bookId, "bookmarks.json", values.map(Bookmark::toJson))
+
+    fun saveAnnotations(bookId: String, values: List<Annotation>) =
+        writeArray(bookId, "annotations.json", values.map(Annotation::toJson))
+
+    private fun <T> readArray(bookId: String, name: String, convert: (JSONObject) -> T): MutableList<T> =
+        runCatching { JSONArray(File(directoryFor(bookId), name).readText()).mapObjects(convert).toMutableList() }
+            .getOrDefault(mutableListOf())
+
+    private fun writeArray(bookId: String, name: String, values: List<JSONObject>) {
+        val array = JSONArray()
+        values.forEach(array::put)
+        writeAtomically(File(directoryFor(bookId), name), array.toString())
+    }
+
+    private fun directoryFor(id: String): File = File(libraryDirectory, id)
+
+    private fun loadIndex(): List<LibraryBook> = runCatching {
+        JSONArray(indexFile.readText()).mapObjects(LibraryBook::fromJson)
+            .filter { File(libraryDirectory, "${it.id}/source.epub").isFile }
+    }.getOrDefault(emptyList())
+
+    private fun saveIndex() {
+        val array = JSONArray()
+        books.forEach { array.put(it.toJson()) }
+        writeAtomically(indexFile, array.toString())
+    }
+
+    private fun writeAtomically(target: File, value: String) {
+        target.parentFile?.mkdirs()
+        val temporary = File(target.parentFile, ".${target.name}.tmp")
+        temporary.writeText(value)
+        if (target.exists()) target.delete()
+        if (!temporary.renameTo(target)) {
+            target.writeText(value)
+            temporary.delete()
+        }
+    }
+
+    companion object {
+        private const val MAX_IMPORT_BYTES = 1_073_741_824L
+
+        private fun displayName(resolver: ContentResolver, uri: Uri): String? {
+            if (uri.scheme == "file") return uri.lastPathSegment
+            return runCatching {
+                resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
+                    if (cursor.moveToFirst()) cursor.getString(0) else null
+                }
+            }.getOrNull()
+        }
+    }
+}
diff --git a/app/src/main/java/com/alexandria/reader/LibraryScreen.kt b/app/src/main/java/com/alexandria/reader/LibraryScreen.kt
new file mode 100644 (file)
index 0000000..a35a044
--- /dev/null
@@ -0,0 +1,293 @@
+package com.alexandria.reader
+
+import android.app.Activity
+import android.app.AlertDialog
+import android.graphics.BitmapFactory
+import android.graphics.Color
+import android.graphics.Typeface
+import android.graphics.drawable.ColorDrawable
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.BaseAdapter
+import android.widget.EditText
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.ListView
+import android.widget.PopupMenu
+import android.widget.TextView
+import android.widget.Toast
+import java.text.DateFormat
+import java.util.Date
+import kotlin.math.roundToInt
+
+/** Plato-style searchable library shelf backed by Alexandria's private imports. */
+class LibraryScreen(
+    private val activity: Activity,
+    private val repository: LibraryRepository,
+    private val openBook: (LibraryBook) -> Unit,
+    private val importBooks: () -> Unit,
+) : FrameLayout(activity) {
+    private val list = ListView(activity)
+    private val adapter = BookAdapter()
+    private val toolbar = LinearLayout(activity)
+    private var searchField: EditText? = null
+    private var query = ""
+    private var sort = repository.sortOrder()
+
+    init {
+        setBackgroundColor(Color.rgb(250, 250, 248))
+        build()
+        refresh()
+    }
+
+    private fun build() {
+        val toolbarHeight = dp(64)
+        toolbar.orientation = LinearLayout.HORIZONTAL
+        toolbar.gravity = Gravity.CENTER_VERTICAL
+        toolbar.setPadding(dp(18), 0, dp(8), 0)
+        toolbar.setBackgroundColor(Color.rgb(250, 250, 248))
+        toolbar.elevation = dp(2).toFloat()
+        addView(toolbar, LayoutParams(LayoutParams.MATCH_PARENT, toolbarHeight, Gravity.TOP))
+
+        toolbar.addView(TextView(activity).apply {
+            text = "ALEXANDRIA"
+            textSize = 19f
+            typeface = Typeface.create("sans-serif", Typeface.BOLD)
+            letterSpacing = .11f
+            setTextColor(Color.rgb(20, 20, 20))
+            contentDescription = "Alexandria library"
+        }, LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { gravity = Gravity.CENTER_VERTICAL })
+        toolbar.addView(button("⌕", "Search library") { _ -> toggleSearch() })
+        toolbar.addView(button("⇅", "Sort library") { view -> showSortMenu(view) })
+        toolbar.addView(button("+", "Import EPUB") { _ -> importBooks() })
+
+        list.apply {
+            divider = ColorDrawable(Color.rgb(190, 190, 187))
+            dividerHeight = 1
+            setBackgroundColor(Color.rgb(250, 250, 248))
+            this.adapter = this@LibraryScreen.adapter
+            emptyView = buildEmptyView()
+            setOnItemClickListener { _, _, position, _ -> openBook(this@LibraryScreen.adapter.value(position)) }
+            setOnItemLongClickListener { view, _, position, _ -> showBookMenu(view, this@LibraryScreen.adapter.value(position)); true }
+            contentDescription = "Imported books"
+        }
+        addView(list, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT).apply { topMargin = toolbarHeight })
+    }
+
+    private fun buildEmptyView(): View {
+        val empty = LinearLayout(activity).apply {
+            orientation = LinearLayout.VERTICAL
+            gravity = Gravity.CENTER
+            setPadding(dp(32), dp(32), dp(32), dp(32))
+            setBackgroundColor(Color.rgb(250, 250, 248))
+            addView(TextView(activity).apply {
+                text = "Your library is empty"
+                textSize = 28f
+                typeface = Typeface.create("serif", Typeface.ITALIC)
+                gravity = Gravity.CENTER
+                setTextColor(Color.rgb(25, 25, 25))
+            })
+            addView(TextView(activity).apply {
+                text = "Import an EPUB to start reading. Alexandria keeps its own private copy and requests no storage or network permission."
+                textSize = 16f
+                gravity = Gravity.CENTER
+                setTextColor(Color.DKGRAY)
+                setPadding(0, dp(16), 0, dp(24))
+            })
+            addView(TextView(activity).apply {
+                text = "Open EPUB"
+                textSize = 17f
+                gravity = Gravity.CENTER
+                setTextColor(Color.WHITE)
+                setBackgroundColor(Color.rgb(35, 35, 35))
+                setPadding(dp(30), dp(13), dp(30), dp(13))
+                contentDescription = "Open EPUB file picker"
+                setOnClickListener { importBooks() }
+            }, LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT))
+        }
+        addView(empty, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT).apply { topMargin = dp(64) })
+        return empty
+    }
+
+    private fun button(label: String, description: String, action: (View) -> Unit): TextView = TextView(activity).apply {
+        text = label
+        textSize = 23f
+        gravity = Gravity.CENTER
+        setTextColor(Color.rgb(25, 25, 25))
+        contentDescription = description
+        isClickable = true
+        isFocusable = true
+        setOnClickListener(action)
+        layoutParams = LinearLayout.LayoutParams(dp(52), LayoutParams.MATCH_PARENT)
+    }
+
+    private fun toggleSearch() {
+        if (searchField != null) {
+            query = ""
+            searchField = null
+            toolbar.removeViewAt(0)
+            toolbar.addView(titleView(), 0, LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f))
+            refresh()
+            return
+        }
+        toolbar.removeViewAt(0)
+        val input = EditText(activity).apply {
+            hint = "Search title or author"
+            isSingleLine = true
+            textSize = 17f
+            contentDescription = "Library search"
+            addTextChangedListener(object : TextWatcher {
+                override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
+                override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { query = s?.toString().orEmpty(); refresh() }
+                override fun afterTextChanged(s: Editable?) = Unit
+            })
+        }
+        searchField = input
+        toolbar.addView(input, 0, LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f))
+        input.requestFocus()
+        (activity.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager)
+            .showSoftInput(input, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
+    }
+
+    private fun titleView(): TextView = TextView(activity).apply {
+        text = "ALEXANDRIA"; textSize = 19f; typeface = Typeface.DEFAULT_BOLD; letterSpacing = .11f
+        gravity = Gravity.CENTER_VERTICAL; setTextColor(Color.rgb(20, 20, 20))
+    }
+
+    private fun showSortMenu(anchor: View) {
+        PopupMenu(activity, anchor).apply {
+            LibraryRepository.SortOrder.entries.forEach { value ->
+                val label = when (value) {
+                    LibraryRepository.SortOrder.RECENT -> "Date opened"
+                    LibraryRepository.SortOrder.ADDED -> "Date added"
+                    LibraryRepository.SortOrder.TITLE -> "Title"
+                    LibraryRepository.SortOrder.AUTHOR -> "Author"
+                    LibraryRepository.SortOrder.PROGRESS -> "Progress"
+                }
+                menu.add(if (value == sort) "✓ $label" else label).setOnMenuItemClickListener {
+                    sort = value; repository.setSortOrder(value); refresh(); true
+                }
+            }
+            show()
+        }
+    }
+
+    private fun showBookMenu(anchor: View, book: LibraryBook) {
+        PopupMenu(activity, anchor).apply {
+            menu.add("Open").setOnMenuItemClickListener { openBook(book); true }
+            menu.add("Mark as new").setOnMenuItemClickListener { repository.setState(book.id, LibraryBook.ReadingState.NEW); refresh(); true }
+            menu.add("Mark as reading").setOnMenuItemClickListener { repository.setState(book.id, LibraryBook.ReadingState.READING); refresh(); true }
+            menu.add("Mark as finished").setOnMenuItemClickListener { repository.setState(book.id, LibraryBook.ReadingState.FINISHED); refresh(); true }
+            menu.add("Rename title").setOnMenuItemClickListener { rename(book); true }
+            menu.add("Book information").setOnMenuItemClickListener { information(book); true }
+            menu.add("Remove from library").setOnMenuItemClickListener { confirmRemove(book); true }
+            show()
+        }
+    }
+
+    private fun rename(book: LibraryBook) {
+        val input = EditText(activity).apply { setText(book.title); selectAll(); isSingleLine = true }
+        AlertDialog.Builder(activity).setTitle("Rename title").setView(input)
+            .setPositiveButton("Save") { _, _ -> repository.rename(book.id, input.text.toString()); refresh() }
+            .setNegativeButton("Cancel", null).show()
+    }
+
+    private fun information(book: LibraryBook) {
+        val state = book.state.name.lowercase().replaceFirstChar(Char::uppercase)
+        val message = buildString {
+            append(book.author).append("\nEPUB · ").append(state)
+            append("\nProgress: ").append((book.progress * 100).roundToInt()).append('%')
+            append("\nAdded: ").append(DateFormat.getDateInstance().format(Date(book.addedAt)))
+            if (book.lastOpenedAt > 0) append("\nLast opened: ").append(DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(book.lastOpenedAt)))
+            if (book.publisher.isNotBlank()) append("\nPublisher: ").append(book.publisher)
+            if (book.description.isNotBlank()) append("\n\n").append(book.description)
+        }
+        AlertDialog.Builder(activity).setTitle(book.title).setMessage(message).setPositiveButton("Open") { _, _ -> openBook(book) }
+            .setNegativeButton("Close", null).show()
+    }
+
+    private fun confirmRemove(book: LibraryBook) {
+        AlertDialog.Builder(activity).setTitle("Remove book?")
+            .setMessage("Alexandria's private copy of “${book.title}” and its reading state will be deleted. The original file will not change.")
+            .setPositiveButton("Remove") { _, _ -> repository.remove(book.id); refresh() }
+            .setNegativeButton("Cancel", null).show()
+    }
+
+    fun refresh() {
+        adapter.replace(repository.books(sort, query))
+    }
+
+    fun searchFocusActive(): Boolean = searchField != null
+
+    fun closeSearch() {
+        if (searchField != null) toggleSearch()
+    }
+
+    private inner class BookAdapter : BaseAdapter() {
+        private var values = emptyList<LibraryBook>()
+        fun replace(newValues: List<LibraryBook>) { values = newValues; notifyDataSetChanged() }
+        fun value(position: Int): LibraryBook = values[position]
+        override fun getCount(): Int = values.size
+        override fun getItem(position: Int): Any = values[position]
+        override fun getItemId(position: Int): Long = values[position].id.hashCode().toLong()
+
+        override fun getView(position: Int, reusable: View?, parent: ViewGroup?): View {
+            val row = (reusable as? BookRow) ?: BookRow()
+            row.bind(values[position])
+            return row
+        }
+    }
+
+    private inner class BookRow : LinearLayout(activity) {
+        private val cover = ImageView(activity)
+        private val title = TextView(activity)
+        private val author = TextView(activity)
+        private val details = TextView(activity)
+        private val progress = TextView(activity)
+
+        init {
+            orientation = HORIZONTAL
+            gravity = Gravity.CENTER_VERTICAL
+            setPadding(dp(18), dp(10), dp(18), dp(10))
+            minimumHeight = dp(112)
+            cover.scaleType = ImageView.ScaleType.CENTER_CROP
+            cover.setBackgroundColor(Color.rgb(226, 226, 222))
+            addView(cover, LinearLayout.LayoutParams(dp(62), dp(88)).apply { marginEnd = dp(18) })
+            val text = LinearLayout(activity).apply { orientation = VERTICAL; gravity = Gravity.CENTER_VERTICAL }
+            title.textSize = 20f; title.typeface = Typeface.create("serif", Typeface.ITALIC); title.maxLines = 2; title.setTextColor(Color.BLACK)
+            author.textSize = 15f; author.maxLines = 1; author.setTextColor(Color.rgb(45, 45, 45))
+            details.textSize = 12f; details.maxLines = 1; details.setTextColor(Color.GRAY)
+            text.addView(title); text.addView(author); text.addView(details)
+            addView(text, LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
+            progress.textSize = 14f; progress.gravity = Gravity.CENTER; progress.setTextColor(Color.DKGRAY
+            )
+            addView(progress, LinearLayout.LayoutParams(dp(92), LayoutParams.MATCH_PARENT))
+        }
+
+        fun bind(book: LibraryBook) {
+            title.text = book.title
+            author.text = book.author
+            details.text = when (book.state) {
+                LibraryBook.ReadingState.NEW -> "EPUB · New"
+                LibraryBook.ReadingState.READING -> "EPUB · Reading"
+                LibraryBook.ReadingState.FINISHED -> "EPUB · Finished"
+            }
+            progress.text = if (book.state == LibraryBook.ReadingState.NEW) "NEW" else "${(book.progress * 100).roundToInt()}%"
+            progress.typeface = if (book.state == LibraryBook.ReadingState.NEW) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
+            val bitmap = book.coverPath?.let { path ->
+                runCatching {
+                    val options = BitmapFactory.Options().apply { inSampleSize = 4 }
+                    BitmapFactory.decodeFile(path, options)
+                }.getOrNull()
+            }
+            if (bitmap != null) cover.setImageBitmap(bitmap) else cover.setImageDrawable(null)
+            contentDescription = "${book.title}, ${book.author}, ${progress.text}"
+        }
+    }
+
+    private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt()
+}
diff --git a/app/src/main/java/com/alexandria/reader/MainActivity.kt b/app/src/main/java/com/alexandria/reader/MainActivity.kt
new file mode 100644 (file)
index 0000000..543d0f8
--- /dev/null
@@ -0,0 +1,197 @@
+package com.alexandria.reader
+
+import android.app.Activity
+import android.app.AlertDialog
+import android.content.Intent
+import android.graphics.Color
+import android.net.Uri
+import android.os.Bundle
+import android.view.Gravity
+import android.view.KeyEvent
+import android.view.View
+import android.widget.TextView
+import android.widget.Toast
+import java.util.concurrent.atomic.AtomicInteger
+
+class MainActivity : Activity() {
+    private lateinit var repository: LibraryRepository
+    private var libraryScreen: LibraryScreen? = null
+    private var readerScreen: ReaderScreen? = null
+    private val operation = AtomicInteger()
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        window.statusBarColor = Color.rgb(250, 250, 248)
+        window.navigationBarColor = Color.rgb(250, 250, 248)
+        window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
+        repository = LibraryRepository(this)
+        if (!handleIntent(intent)) showLibrary()
+    }
+
+    override fun onNewIntent(intent: Intent) {
+        super.onNewIntent(intent)
+        setIntent(intent)
+        if (!handleIntent(intent)) showLibrary()
+    }
+
+    private fun handleIntent(value: Intent?): Boolean {
+        val intent = value ?: return false
+        val uris = mutableListOf<Uri>()
+        when (intent.action) {
+            Intent.ACTION_VIEW -> intent.data?.let(uris::add)
+            Intent.ACTION_SEND -> {
+                intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let(uris::add)
+                if (uris.isEmpty()) intent.data?.let(uris::add)
+            }
+            Intent.ACTION_SEND_MULTIPLE -> intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.let(uris::addAll)
+        }
+        intent.clipData?.let { clip ->
+            for (index in 0 until clip.itemCount) clip.getItemAt(index).uri?.let { if (it !in uris) uris += it }
+        }
+        if (uris.isEmpty()) return false
+        importUris(uris, intent.flags)
+        return true
+    }
+
+    private fun showLibrary() {
+        operation.incrementAndGet()
+        readerScreen?.dispose()
+        readerScreen = null
+        val screen = LibraryScreen(this, repository, ::openBook, ::openPicker)
+        libraryScreen = screen
+        setContentView(screen)
+        title = "Alexandria Library"
+    }
+
+    private fun openPicker() {
+        @Suppress("DEPRECATION")
+        startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
+            addCategory(Intent.CATEGORY_OPENABLE)
+            type = "application/epub+zip"
+            putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/epub+zip", "application/epub", "application/octet-stream"))
+            putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
+            addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
+        }, OPEN_DOCUMENT)
+    }
+
+    @Deprecated("The framework result API is retained because Alexandria targets Android 11 without AndroidX activity dependencies")
+    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+        super.onActivityResult(requestCode, resultCode, data)
+        if (requestCode != OPEN_DOCUMENT || resultCode != RESULT_OK || data == null) return
+        val uris = mutableListOf<Uri>()
+        data.data?.let(uris::add)
+        data.clipData?.let { clip ->
+            for (index in 0 until clip.itemCount) clip.getItemAt(index).uri?.let { if (it !in uris) uris += it }
+        }
+        if (uris.isNotEmpty()) importUris(uris, data.flags)
+    }
+
+    private fun importUris(uris: List<Uri>, flags: Int) {
+        val token = operation.incrementAndGet()
+        showLoading(if (uris.size == 1) "Importing EPUB…" else "Importing ${uris.size} EPUBs…")
+        Thread {
+            var last: EpubPublication? = null
+            var failure: Throwable? = null
+            uris.forEach { uri ->
+                try {
+                    if (flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION != 0) {
+                        runCatching { contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) }
+                    }
+                    last = repository.import(uri)
+                } catch (error: Throwable) {
+                    failure = error
+                }
+            }
+            runOnUiThread {
+                if (token != operation.get()) return@runOnUiThread
+                when {
+                    last != null -> showReader(last!!)
+                    failure != null -> showImportError(failure!!)
+                    else -> showLibrary()
+                }
+                if (last != null && failure != null) Toast.makeText(this, "Some files could not be imported", Toast.LENGTH_LONG).show()
+            }
+        }.start()
+    }
+
+    private fun openBook(book: LibraryBook) {
+        val token = operation.incrementAndGet()
+        showLoading("Opening ${book.title}…")
+        Thread {
+            val result = runCatching { repository.open(book) }
+            runOnUiThread {
+                if (token != operation.get()) return@runOnUiThread
+                result.onSuccess(::showReader).onFailure(::showImportError)
+            }
+        }.start()
+    }
+
+    private fun showReader(publication: EpubPublication) {
+        readerScreen?.dispose()
+        libraryScreen = null
+        val screen = ReaderScreen(this, publication, repository, ::showLibrary)
+        readerScreen = screen
+        setContentView(screen)
+        title = publication.book.title
+    }
+
+    private fun showLoading(message: String) {
+        readerScreen?.dispose()
+        readerScreen = null
+        libraryScreen = null
+        setContentView(TextView(this).apply {
+            text = message
+            textSize = 20f
+            gravity = Gravity.CENTER
+            setTextColor(Color.rgb(35, 35, 35))
+            setBackgroundColor(Color.WHITE)
+            contentDescription = message
+        })
+    }
+
+    private fun showImportError(error: Throwable) {
+        showLibrary()
+        val message = error.message?.takeIf { it.isNotBlank() } ?: "The file is not a supported or valid EPUB."
+        AlertDialog.Builder(this).setTitle("Unable to open EPUB").setMessage(message)
+            .setPositiveButton("Choose another file") { _, _ -> openPicker() }
+            .setNegativeButton("Close", null).show()
+    }
+
+    override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+        if (event.action == KeyEvent.ACTION_DOWN && readerScreen?.handleKey(event.keyCode) == true) return true
+        return super.dispatchKeyEvent(event)
+    }
+
+    @Deprecated("Android 11 activity back dispatch")
+    override fun onBackPressed() {
+        val reader = readerScreen
+        if (reader != null) {
+            if (!reader.handleBack()) showLibrary()
+        } else if (libraryScreen?.searchFocusActive() == true) {
+            libraryScreen?.closeSearch()
+        } else {
+            super.onBackPressed()
+        }
+    }
+
+    override fun onPause() {
+        readerScreen?.pause()
+        super.onPause()
+    }
+
+    override fun onResume() {
+        super.onResume()
+        readerScreen?.resume()
+    }
+
+    override fun onDestroy() {
+        operation.incrementAndGet()
+        readerScreen?.dispose()
+        readerScreen = null
+        super.onDestroy()
+    }
+
+    companion object {
+        private const val OPEN_DOCUMENT = 41
+    }
+}
diff --git a/app/src/main/java/com/alexandria/reader/Models.kt b/app/src/main/java/com/alexandria/reader/Models.kt
new file mode 100644 (file)
index 0000000..a8e693f
--- /dev/null
@@ -0,0 +1,268 @@
+package com.alexandria.reader
+
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+
+/** Metadata kept for every document that Alexandria imports into its private library. */
+data class LibraryBook(
+    val id: String,
+    var title: String,
+    var author: String,
+    var language: String = "",
+    var publisher: String = "",
+    var description: String = "",
+    var fileName: String,
+    var coverPath: String? = null,
+    var addedAt: Long = System.currentTimeMillis(),
+    var lastOpenedAt: Long = 0L,
+    var progress: Float = 0f,
+    var currentPage: Int = 0,
+    var totalPages: Int = 1,
+    var state: ReadingState = ReadingState.NEW,
+) {
+    enum class ReadingState { NEW, READING, FINISHED }
+
+    fun toJson(): JSONObject = JSONObject().apply {
+        put("id", id)
+        put("title", title)
+        put("author", author)
+        put("language", language)
+        put("publisher", publisher)
+        put("description", description)
+        put("fileName", fileName)
+        put("coverPath", coverPath ?: JSONObject.NULL)
+        put("addedAt", addedAt)
+        put("lastOpenedAt", lastOpenedAt)
+        put("progress", progress.toDouble())
+        put("currentPage", currentPage)
+        put("totalPages", totalPages)
+        put("state", state.name)
+    }
+
+    companion object {
+        fun fromJson(value: JSONObject): LibraryBook = LibraryBook(
+            id = value.getString("id"),
+            title = value.optString("title", "Untitled"),
+            author = value.optString("author", "Unknown author"),
+            language = value.optString("language"),
+            publisher = value.optString("publisher"),
+            description = value.optString("description"),
+            fileName = value.optString("fileName", "book.epub"),
+            coverPath = value.optString("coverPath").takeIf { it.isNotBlank() && it != "null" },
+            addedAt = value.optLong("addedAt", System.currentTimeMillis()),
+            lastOpenedAt = value.optLong("lastOpenedAt"),
+            progress = value.optDouble("progress", 0.0).toFloat().coerceIn(0f, 1f),
+            currentPage = value.optInt("currentPage").coerceAtLeast(0),
+            totalPages = value.optInt("totalPages", 1).coerceAtLeast(1),
+            state = runCatching { ReadingState.valueOf(value.optString("state", "NEW")) }
+                .getOrDefault(ReadingState.NEW),
+        )
+    }
+}
+
+data class SpineItem(
+    val href: String,
+    val mediaType: String,
+    val title: String,
+    val linear: Boolean = true,
+    val fixedLayout: Boolean = false,
+)
+
+data class TocEntry(
+    val title: String,
+    val href: String,
+    val depth: Int,
+    val spineIndex: Int,
+)
+
+data class EpubPublication(
+    val book: LibraryBook,
+    val rootDirectory: File,
+    val packagePath: String,
+    val spine: List<SpineItem>,
+    val toc: List<TocEntry>,
+    val readingDirection: String,
+    val stylesheets: List<String>,
+    val fixedLayout: Boolean,
+    val defaultPageWidth: Int,
+    val defaultPageHeight: Int,
+)
+
+data class ReaderLocation(
+    val page: Int = 0,
+    val totalPages: Int = 1,
+    val chapter: Int = 0,
+    val offset: Int = 0,
+    val progress: Float = 0f,
+    val viewportX: Float = 0f,
+    val viewportY: Float = 0f,
+) {
+    fun toJson(): JSONObject = JSONObject().apply {
+        put("page", page)
+        put("totalPages", totalPages)
+        put("chapter", chapter)
+        put("offset", offset)
+        put("progress", progress.toDouble())
+        put("viewportX", viewportX.toDouble())
+        put("viewportY", viewportY.toDouble())
+    }
+
+    companion object {
+        fun fromJson(value: JSONObject): ReaderLocation = ReaderLocation(
+            page = value.optInt("page").coerceAtLeast(0),
+            totalPages = value.optInt("totalPages", 1).coerceAtLeast(1),
+            chapter = value.optInt("chapter").coerceAtLeast(0),
+            offset = value.optInt("offset").coerceAtLeast(0),
+            progress = value.optDouble("progress", 0.0).toFloat().coerceIn(0f, 1f),
+            viewportX = value.optDouble("viewportX", 0.0).toFloat(),
+            viewportY = value.optDouble("viewportY", 0.0).toFloat(),
+        )
+    }
+}
+
+data class CropMargins(
+    val left: Float = 0f,
+    val top: Float = 0f,
+    val right: Float = 0f,
+    val bottom: Float = 0f,
+) {
+    fun toJson(): JSONObject = JSONObject().apply {
+        put("left", left.toDouble()); put("top", top.toDouble()); put("right", right.toDouble()); put("bottom", bottom.toDouble())
+    }
+
+    companion object {
+        fun fromJson(value: JSONObject?): CropMargins {
+            if (value == null) return CropMargins()
+            fun component(name: String): Float = value.optDouble(name, 0.0).toFloat()
+                .takeIf(Float::isFinite)?.coerceIn(0f, 90f) ?: 0f
+            return CropMargins(component("left"), component("top"), component("right"), component("bottom"))
+        }
+    }
+}
+
+data class ReaderSettings(
+    val fontFamily: String = "Alexandria Serif",
+    val fontSize: Int = 20,
+    val lineHeight: Float = 1.32f,
+    val margin: Int = 32,
+    val textAlign: String = "publisher",
+    val theme: String = "light",
+    val publisherStyles: Boolean = true,
+    val hyphenation: Boolean = true,
+    val mode: String = "paged",
+    val brightness: Int = -1,
+    val volumeKeys: Boolean = true,
+    val zoomMode: String = "fit-page",
+    val customZoom: Int = 100,
+    val scrollMode: String = "screen",
+    val cropScheme: String = "none",
+    val cropAny: CropMargins = CropMargins(),
+    val cropEven: CropMargins = CropMargins(),
+    val cropOdd: CropMargins = CropMargins(),
+    val contrastExponent: Float = 1f,
+    val grayPoint: Int = 255,
+    val dithering: String = "off",
+) {
+    fun toJson(): JSONObject = JSONObject().apply {
+        put("fontFamily", fontFamily)
+        put("fontSize", fontSize)
+        put("lineHeight", lineHeight.toDouble())
+        put("margin", margin)
+        put("textAlign", textAlign)
+        put("theme", theme)
+        put("publisherStyles", publisherStyles)
+        put("hyphenation", hyphenation)
+        put("mode", mode)
+        put("brightness", brightness)
+        put("volumeKeys", volumeKeys)
+        put("zoomMode", zoomMode)
+        put("customZoom", customZoom)
+        put("scrollMode", scrollMode)
+        put("cropScheme", cropScheme)
+        put("cropAny", cropAny.toJson())
+        put("cropEven", cropEven.toJson())
+        put("cropOdd", cropOdd.toJson())
+        put("contrastExponent", contrastExponent.toDouble())
+        put("grayPoint", grayPoint)
+        put("dithering", dithering)
+    }
+
+    companion object {
+        fun fromJson(value: JSONObject): ReaderSettings = ReaderSettings(
+            fontFamily = value.optString("fontFamily", "Alexandria Serif"),
+            fontSize = value.optInt("fontSize", 20).coerceIn(12, 38),
+            lineHeight = value.optDouble("lineHeight", 1.32).toFloat().coerceIn(1f, 2f),
+            margin = value.optInt("margin", 32).coerceIn(0, 96),
+            textAlign = value.optString("textAlign", "publisher")
+                .takeIf { it in setOf("publisher", "left", "right", "center", "justify") } ?: "publisher",
+            theme = value.optString("theme", "light")
+                .takeIf { it in setOf("light", "sepia", "dark") } ?: "light",
+            publisherStyles = value.optBoolean("publisherStyles", true),
+            hyphenation = value.optBoolean("hyphenation", true),
+            mode = value.optString("mode", "paged").takeIf { it in setOf("paged", "continuous") } ?: "paged",
+            brightness = value.optInt("brightness", -1).coerceIn(-1, 100),
+            volumeKeys = value.optBoolean("volumeKeys", true),
+            zoomMode = value.optString("zoomMode", "fit-page")
+                .takeIf { it in setOf("fit-page", "fit-width", "custom") } ?: "fit-page",
+            customZoom = value.optInt("customZoom", 100).coerceIn(50, 400),
+            scrollMode = value.optString("scrollMode", "screen").takeIf { it in setOf("screen", "page") } ?: "screen",
+            cropScheme = value.optString("cropScheme", "none").takeIf { it in setOf("none", "any", "even-odd") } ?: "none",
+            cropAny = CropMargins.fromJson(value.optJSONObject("cropAny")),
+            cropEven = CropMargins.fromJson(value.optJSONObject("cropEven")),
+            cropOdd = CropMargins.fromJson(value.optJSONObject("cropOdd")),
+            contrastExponent = value.optDouble("contrastExponent", 1.0).toFloat().coerceIn(1f, 5f),
+            grayPoint = value.optInt("grayPoint", 255).coerceIn(16, 255),
+            dithering = value.optString("dithering", "off").takeIf { it in setOf("off", "g16", "g2") } ?: "off",
+        )
+    }
+}
+
+data class Bookmark(
+    val id: String,
+    val label: String,
+    val chapter: Int,
+    val offset: Int,
+    val page: Int,
+    val progress: Float,
+    val createdAt: Long,
+) {
+    fun toJson(): JSONObject = JSONObject().apply {
+        put("id", id); put("label", label); put("chapter", chapter); put("offset", offset)
+        put("page", page); put("progress", progress.toDouble()); put("createdAt", createdAt)
+    }
+
+    companion object {
+        fun fromJson(value: JSONObject) = Bookmark(
+            value.getString("id"), value.optString("label", "Bookmark"), value.optInt("chapter"),
+            value.optInt("offset"), value.optInt("page"), value.optDouble("progress").toFloat(),
+            value.optLong("createdAt"),
+        )
+    }
+}
+
+data class Annotation(
+    val id: String,
+    val chapter: Int,
+    val start: Int,
+    val end: Int,
+    val quote: String,
+    var note: String,
+    val createdAt: Long,
+) {
+    fun toJson(): JSONObject = JSONObject().apply {
+        put("id", id); put("chapter", chapter); put("start", start); put("end", end)
+        put("quote", quote); put("note", note); put("createdAt", createdAt)
+    }
+
+    companion object {
+        fun fromJson(value: JSONObject) = Annotation(
+            value.getString("id"), value.optInt("chapter"), value.optInt("start"),
+            value.optInt("end"), value.optString("quote"), value.optString("note"),
+            value.optLong("createdAt"),
+        )
+    }
+}
+
+internal fun <T> JSONArray.mapObjects(block: (JSONObject) -> T): List<T> =
+    (0 until length()).mapNotNull { index -> optJSONObject(index)?.let(block) }
diff --git a/app/src/main/java/com/alexandria/reader/ReaderScreen.kt b/app/src/main/java/com/alexandria/reader/ReaderScreen.kt
new file mode 100644 (file)
index 0000000..f305124
--- /dev/null
@@ -0,0 +1,981 @@
+package com.alexandria.reader
+
+import android.app.Activity
+import android.app.AlertDialog
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ActivityInfo
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.RectF
+import android.graphics.Typeface
+import android.net.Uri
+import android.os.Handler
+import android.os.Looper
+import android.provider.Settings
+import android.text.InputType
+import android.view.Gravity
+import android.view.KeyEvent
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputMethodManager
+import android.widget.ArrayAdapter
+import android.widget.CheckBox
+import android.widget.EditText
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import android.widget.PopupMenu
+import android.widget.RadioButton
+import android.widget.RadioGroup
+import android.widget.ScrollView
+import android.widget.SeekBar
+import android.widget.Spinner
+import android.widget.TextView
+import android.widget.Toast
+import org.json.JSONArray
+import org.json.JSONObject
+import java.text.DateFormat
+import java.util.Date
+import java.util.UUID
+import kotlin.math.roundToInt
+
+/** Native reader controls around the standards-based, local-only EPUB surface. */
+class ReaderScreen(
+    private val activity: Activity,
+    private val publication: EpubPublication,
+    private val repository: LibraryRepository,
+    private val closeReader: () -> Unit,
+) : FrameLayout(activity), ReaderWebView.Listener {
+    private val handler = Handler(Looper.getMainLooper())
+    private val webView = ReaderWebView(activity)
+    private val topBar = LinearLayout(activity)
+    private val bottomBar = LinearLayout(activity)
+    private val titleLabel = TextView(activity)
+    private val progressLabel = TextView(activity)
+    private val bookmarkButton = TextView(activity)
+    private val loading = TextView(activity)
+    private var searchBar: LinearLayout? = null
+    private var searchStatus: TextView? = null
+    private var controlsVisible = true
+    private var initialized = false
+    private var restoring = true
+    private val initialLocation = repository.location(publication.book.id)
+    private var current = initialLocation
+    private var settings = repository.settings(publication.book.id)
+    private val bookmarks = repository.bookmarks(publication.book.id)
+    private val annotations = repository.annotations(publication.book.id)
+    private val locationHistory = ArrayDeque<ReaderLocation>()
+    private var pendingSave: Runnable? = null
+    private var searchCount = 0
+    private var searchIndex = -1
+    private var searchDirection = ReaderWebView.SearchDirection.FORWARD
+
+    init {
+        setBackgroundColor(Color.WHITE)
+        isFocusableInTouchMode = true
+        buildReader()
+        webView.listener = this
+        webView.configurePageTools(publication.fixedLayout, settings)
+        webView.load(repository.readerFile(publication))
+        repository.markOpened(publication.book.id)
+        applyNativeTheme()
+    }
+
+    private fun buildReader() {
+        val barHeight = dp(52)
+        addView(webView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT).apply {
+            topMargin = barHeight
+            bottomMargin = barHeight
+        })
+
+        topBar.orientation = LinearLayout.HORIZONTAL
+        topBar.gravity = Gravity.CENTER_VERTICAL
+        topBar.setPadding(dp(4), 0, dp(4), 0)
+        topBar.elevation = dp(2).toFloat()
+        addView(topBar, LayoutParams(LayoutParams.MATCH_PARENT, barHeight, Gravity.TOP))
+
+        topBar.addView(actionButton("‹", "Back to library") { _ -> flush(); closeReader() }, lp(dp(52)))
+        titleLabel.apply {
+            text = publication.book.title
+            textSize = 15f
+            setTextColor(Color.BLACK)
+            maxLines = 2
+            gravity = Gravity.CENTER_VERTICAL
+            ellipsize = android.text.TextUtils.TruncateAt.END
+            contentDescription = "Book title. Tap for book information"
+            setOnClickListener { showBookInformation() }
+        }
+        topBar.addView(titleLabel, LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply {
+            marginStart = dp(4); marginEnd = dp(4)
+        })
+        bookmarkButton.apply {
+            text = "☆"
+            contentDescription = "Add bookmark"
+            setOnClickListener { toggleBookmark() }
+        }
+        styleAction(bookmarkButton)
+        topBar.addView(bookmarkButton, lp(dp(48)))
+        topBar.addView(actionButton("⌕", "Search in book") { _ -> showSearch() }, lp(dp(48)))
+        topBar.addView(actionButton("Aa", "Reading appearance") { _ -> showAppearance() }, lp(dp(52)))
+        val menuButton = actionButton("⋮", "Reader menu") { view -> showMenu(view) }
+        topBar.addView(menuButton, lp(dp(44)))
+
+        bottomBar.orientation = LinearLayout.HORIZONTAL
+        bottomBar.gravity = Gravity.CENTER_VERTICAL
+        bottomBar.setPadding(dp(4), 0, dp(4), 0)
+        bottomBar.elevation = dp(2).toFloat()
+        addView(bottomBar, LayoutParams(LayoutParams.MATCH_PARENT, barHeight, Gravity.BOTTOM))
+        bottomBar.addView(actionButton("|‹", "Previous chapter") { _ -> previousChapter() }, lp(dp(52)))
+        bottomBar.addView(actionButton("‹", "Previous page") { _ -> previousPage() }, lp(dp(48)))
+        progressLabel.apply {
+            text = "Page 1 of 1"
+            textSize = 14f
+            gravity = Gravity.CENTER
+            setTextColor(Color.DKGRAY)
+            contentDescription = "Reading progress. Tap to go to a page"
+            setOnClickListener { showGoToPage() }
+            setOnLongClickListener { showGoToPercent(); true }
+        }
+        bottomBar.addView(progressLabel, LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f))
+        bottomBar.addView(actionButton("›", "Next page") { _ -> nextPage() }, lp(dp(48)))
+        bottomBar.addView(actionButton("›|", "Next chapter") { _ -> nextChapter() }, lp(dp(52)))
+
+        loading.apply {
+            text = "Laying out ${publication.book.title}…"
+            textSize = 18f
+            gravity = Gravity.CENTER
+            setTextColor(Color.DKGRAY)
+            setBackgroundColor(Color.WHITE)
+            isClickable = true
+            contentDescription = "Opening EPUB"
+        }
+        addView(loading, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT).apply {
+            topMargin = barHeight; bottomMargin = barHeight
+        })
+    }
+
+    private fun actionButton(label: String, description: String, action: (View) -> Unit): TextView = TextView(activity).apply {
+        text = label
+        contentDescription = description
+        styleAction(this)
+        setOnClickListener(action)
+    }
+
+    private fun styleAction(view: TextView) {
+        view.textSize = 20f
+        view.gravity = Gravity.CENTER
+        view.setTextColor(Color.rgb(25, 25, 25))
+        view.setBackgroundColor(Color.TRANSPARENT)
+        view.isClickable = true
+        view.isFocusable = true
+        view.setPadding(dp(4), 0, dp(4), 0)
+    }
+
+    override fun onReady(metrics: JSONObject) {
+        if (!initialized) {
+            initialized = true
+            webView.applyAnnotations(annotations)
+            webView.applySettings(settings, initialLocation)
+            handler.postDelayed({
+                restoring = false
+                loading.visibility = View.GONE
+                webView.captureLocation { onLocationChanged(it) }
+            }, 350)
+        }
+    }
+
+    override fun onLocationChanged(location: ReaderLocation) {
+        // The document reports its temporary page-zero layout immediately before
+        // onReady. Do not let that transient callback replace the saved locator.
+        if (!initialized) return
+        current = location
+        val percent = (location.progress * 100).roundToInt().coerceIn(0, 100)
+        progressLabel.text = if (settings.mode == "continuous") {
+            "${percent}%  ·  ${location.page + 1} / ${location.totalPages}"
+        } else {
+            "Page ${location.page + 1} of ${location.totalPages}  ·  ${percent}%"
+        }
+        progressLabel.contentDescription = "Page ${location.page + 1} of ${location.totalPages}, $percent percent"
+        val chapterTitle = publication.toc.lastOrNull { it.spineIndex <= location.chapter }?.title
+            ?: publication.spine.getOrNull(location.chapter)?.title
+            ?: publication.book.title
+        titleLabel.text = chapterTitle
+        updateBookmarkIcon()
+        if (!restoring) scheduleSave(location)
+    }
+
+    private fun scheduleSave(location: ReaderLocation) {
+        pendingSave?.let(handler::removeCallbacks)
+        val task = Runnable { repository.updateProgress(publication.book.id, location) }
+        pendingSave = task
+        handler.postDelayed(task, 250)
+    }
+
+    override fun onTap(x: Float, y: Float) {
+        if (publication.fixedLayout && settings.zoomMode == "custom") {
+            val centerX = webView.width / 2f
+            val centerY = webView.height / 2f
+            if (x in webView.width * .28f..webView.width * .72f && y in webView.height * .28f..webView.height * .72f) {
+                toggleControls()
+            } else {
+                webView.panBy((centerX - x) * .72f, (centerY - y) * .72f)
+                webView.finishViewportGesture()
+            }
+            return
+        }
+        val cornerX = webView.width * .18f
+        val cornerY = webView.height * .18f
+        when {
+            x < cornerX && y < cornerY -> previousLocation()
+            x > webView.width - cornerX && y < cornerY -> toggleBookmark()
+            x < cornerX && y > webView.height - cornerY -> showTableOfContents()
+            x > webView.width - cornerX && y > webView.height - cornerY -> showGoToPage()
+            x < webView.width * .22f -> previousPage()
+            x > webView.width * .78f -> nextPage()
+            else -> toggleControls()
+        }
+    }
+
+    override fun onSwipe(forward: Boolean) {
+        val effectiveForward = if (publication.readingDirection == "rtl") !forward else forward
+        if (effectiveForward) nextPage() else previousPage()
+    }
+
+    override fun onVerticalSwipe(forward: Boolean) {
+        if (forward) nextPage() else previousPage()
+    }
+
+    override fun onViewportChanged(zoomMode: String, customZoom: Int) {
+        settings = settings.copy(zoomMode = zoomMode, customZoom = customZoom)
+        webView.configurePageTools(publication.fixedLayout, settings)
+        repository.saveSettings(publication.book.id, settings)
+    }
+
+    private fun nextPage() = webView.nextPage()
+    private fun previousPage() = webView.previousPage()
+
+    private fun nextChapter() {
+        if (current.chapter < publication.spine.lastIndex) {
+            rememberLocation()
+            webView.goToChapter(current.chapter + 1)
+        } else webView.goToPage(current.totalPages - 1)
+    }
+
+    private fun previousChapter() {
+        if (current.chapter > 0) {
+            rememberLocation()
+            webView.goToChapter(current.chapter - 1)
+        } else webView.goToPage(0)
+    }
+
+    private fun rememberLocation(location: ReaderLocation = current) {
+        val previous = locationHistory.lastOrNull()
+        val samePosition = previous != null && previous.page == location.page && previous.chapter == location.chapter &&
+            (!publication.fixedLayout ||
+                (kotlin.math.abs(previous.viewportX - location.viewportX) < 1f &&
+                    kotlin.math.abs(previous.viewportY - location.viewportY) < 1f))
+        if (!samePosition) locationHistory.addLast(location)
+        while (locationHistory.size > 40) locationHistory.removeFirst()
+    }
+
+    private fun previousLocation() {
+        if (locationHistory.isEmpty()) Toast.makeText(activity, "No previous location", Toast.LENGTH_SHORT).show()
+        else webView.goToLocation(locationHistory.removeLast())
+    }
+
+    private fun toggleControls() {
+        controlsVisible = !controlsVisible
+        topBar.visibility = if (controlsVisible) View.VISIBLE else View.INVISIBLE
+        bottomBar.visibility = if (controlsVisible) View.VISIBLE else View.INVISIBLE
+        if (!controlsVisible) hideSearch()
+    }
+
+    private fun showMenu(anchor: View) {
+        PopupMenu(activity, anchor).apply {
+            menu.add("Table of contents").setOnMenuItemClickListener { showTableOfContents(); true }
+            menu.add("Search").setOnMenuItemClickListener { showSearch(); true }
+            menu.add("Bookmarks").setOnMenuItemClickListener { showBookmarks(); true }
+            menu.add("Annotations and highlights").setOnMenuItemClickListener { showAnnotations(); true }
+            menu.add("Go to page").setOnMenuItemClickListener { showGoToPage(); true }
+            menu.add("First page").setOnMenuItemClickListener { rememberLocation(); webView.goToPage(0); true }
+            menu.add("Last page").setOnMenuItemClickListener { rememberLocation(); webView.goToPage(current.totalPages - 1); true }
+            menu.add("Reading appearance").setOnMenuItemClickListener { showAppearance(); true }
+            menu.add("Page display tools").setOnMenuItemClickListener { showPageDisplay(); true }
+            menu.add("Rotate screen").setOnMenuItemClickListener { rotateScreen(); true }
+            menu.add("Book information").setOnMenuItemClickListener { showBookInformation(); true }
+            menu.add("Close book").setOnMenuItemClickListener { flush(); closeReader(); true }
+            show()
+        }
+    }
+
+    private fun showTableOfContents() {
+        if (publication.toc.isEmpty()) {
+            Toast.makeText(activity, "This book has no table of contents", Toast.LENGTH_SHORT).show(); return
+        }
+        val labels = publication.toc.map { "   ".repeat(it.depth.coerceAtMost(6)) + it.title }.toTypedArray()
+        AlertDialog.Builder(activity)
+            .setTitle("Table of contents")
+            .setSingleChoiceItems(labels, publication.toc.indexOfLast { it.spineIndex <= current.chapter }) { dialog, index ->
+                rememberLocation()
+                webView.goToReference(publication.toc[index].href)
+                dialog.dismiss()
+            }
+            .setNegativeButton("Close", null)
+            .show()
+    }
+
+    private fun toggleBookmark() {
+        val existing = bookmarks.firstOrNull { it.page == current.page || kotlin.math.abs(it.progress - current.progress) < .001f }
+        if (existing != null) {
+            bookmarks.remove(existing)
+            Toast.makeText(activity, "Bookmark removed", Toast.LENGTH_SHORT).show()
+        } else {
+            val chapter = publication.toc.lastOrNull { it.spineIndex <= current.chapter }?.title ?: "Page ${current.page + 1}"
+            bookmarks += Bookmark(UUID.randomUUID().toString(), chapter, current.chapter, current.offset, current.page,
+                current.progress, System.currentTimeMillis())
+            Toast.makeText(activity, "Page bookmarked", Toast.LENGTH_SHORT).show()
+        }
+        repository.saveBookmarks(publication.book.id, bookmarks)
+        updateBookmarkIcon()
+    }
+
+    private fun updateBookmarkIcon() {
+        val marked = bookmarks.any { it.page == current.page || kotlin.math.abs(it.progress - current.progress) < .001f }
+        bookmarkButton.text = if (marked) "★" else "☆"
+        bookmarkButton.contentDescription = if (marked) "Remove bookmark" else "Add bookmark"
+    }
+
+    private fun showBookmarks() {
+        if (bookmarks.isEmpty()) {
+            AlertDialog.Builder(activity).setTitle("Bookmarks").setMessage("There are no bookmarks in this book.")
+                .setPositiveButton("Add this page") { _, _ -> toggleBookmark() }.setNegativeButton("Close", null).show()
+            return
+        }
+        val labels = bookmarks.sortedBy { it.progress }.map {
+            "${it.label}\n${(it.progress * 100).roundToInt()}% · ${DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it.createdAt))}"
+        }.toTypedArray()
+        AlertDialog.Builder(activity).setTitle("Bookmarks").setItems(labels) { _, index ->
+            val bookmark = bookmarks.sortedBy { it.progress }[index]
+            rememberLocation()
+            webView.goToLocation(ReaderLocation(bookmark.page, current.totalPages, bookmark.chapter, bookmark.offset, bookmark.progress))
+        }.setNeutralButton("Remove all") { _, _ ->
+            bookmarks.clear(); repository.saveBookmarks(publication.book.id, bookmarks); updateBookmarkIcon()
+        }.setNegativeButton("Close", null).show()
+    }
+
+    private fun showAnnotations() {
+        if (annotations.isEmpty()) {
+            AlertDialog.Builder(activity).setTitle("Annotations and highlights")
+                .setMessage("Select text in the book, then choose Highlight or Add note.")
+                .setPositiveButton("OK", null).show()
+            return
+        }
+        val sorted = annotations.sortedWith(compareBy<Annotation> { it.chapter }.thenBy { it.start })
+        val labels = sorted.map { annotation ->
+            val quote = annotation.quote.replace(Regex("\\s+"), " ").take(90)
+            if (annotation.note.isBlank()) "“$quote”" else "${annotation.note}\n“$quote”"
+        }.toTypedArray()
+        AlertDialog.Builder(activity).setTitle("Annotations and highlights").setItems(labels) { _, index ->
+            val annotation = sorted[index]
+            rememberLocation()
+            webView.goToLocation(ReaderLocation(chapter = annotation.chapter, offset = annotation.start, progress = current.progress))
+            showAnnotationEditor(annotation)
+        }.setNeutralButton("Export") { _, _ -> exportAnnotations() }.setNegativeButton("Close", null).show()
+    }
+
+    override fun onSelectionAction(action: ReaderWebView.SelectionAction, selection: JSONObject?) {
+        val selected = selection ?: return
+        when (action) {
+            ReaderWebView.SelectionAction.HIGHLIGHT -> createAnnotation(selected, false)
+            ReaderWebView.SelectionAction.NOTE -> createAnnotation(selected, true)
+            ReaderWebView.SelectionAction.DEFINE -> defineText(selected.optString("quote"))
+            ReaderWebView.SelectionAction.SEARCH -> showSearch(selected.optString("quote").trim().take(120))
+        }
+    }
+
+    private fun createAnnotation(selection: JSONObject, requestNote: Boolean) {
+        val annotation = Annotation(
+            id = UUID.randomUUID().toString(),
+            chapter = selection.optInt("chapter"),
+            start = selection.optInt("start"),
+            end = selection.optInt("end"),
+            quote = selection.optString("quote"),
+            note = "",
+            createdAt = System.currentTimeMillis(),
+        )
+        if (annotation.quote.isBlank() || annotation.end <= annotation.start) return
+        annotations += annotation
+        repository.saveAnnotations(publication.book.id, annotations)
+        webView.applyAnnotation(annotation)
+        if (requestNote) editAnnotationNote(annotation) else Toast.makeText(activity, "Highlight saved", Toast.LENGTH_SHORT).show()
+    }
+
+    override fun onAnnotationTapped(id: String) {
+        annotations.firstOrNull { it.id == id }?.let(::showAnnotationEditor)
+    }
+
+    private fun showAnnotationEditor(annotation: Annotation) {
+        val message = buildString {
+            append('“').append(annotation.quote.replace(Regex("\\s+"), " ").take(600)).append('”')
+            if (annotation.note.isNotBlank()) append("\n\n").append(annotation.note)
+        }
+        AlertDialog.Builder(activity).setTitle(if (annotation.note.isBlank()) "Highlight" else "Annotation")
+            .setMessage(message)
+            .setPositiveButton(if (annotation.note.isBlank()) "Add note" else "Edit note") { _, _ -> editAnnotationNote(annotation) }
+            .setNeutralButton("Remove") { _, _ ->
+                annotations.removeAll { it.id == annotation.id }
+                repository.saveAnnotations(publication.book.id, annotations)
+                webView.removeAnnotation(annotation.id)
+            }
+            .setNegativeButton("Close", null).show()
+    }
+
+    private fun editAnnotationNote(annotation: Annotation) {
+        val input = EditText(activity).apply {
+            setText(annotation.note)
+            hint = "Write a note"
+            minLines = 3
+            inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
+            setSelection(text.length)
+        }
+        AlertDialog.Builder(activity).setTitle("Annotation note").setView(input)
+            .setPositiveButton("Save") { _, _ ->
+                annotation.note = input.text.toString().trim()
+                repository.saveAnnotations(publication.book.id, annotations)
+            }.setNegativeButton("Cancel", null).show()
+    }
+
+    private fun defineText(value: String) {
+        val text = value.trim().take(500)
+        if (text.isBlank()) return
+        val intent = Intent(Intent.ACTION_PROCESS_TEXT).apply {
+            type = "text/plain"
+            putExtra(Intent.EXTRA_PROCESS_TEXT, text)
+            putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
+        }
+        if (intent.resolveActivity(activity.packageManager) != null) {
+            runCatching { activity.startActivity(Intent.createChooser(intent, "Define or process text")) }
+        } else {
+            AlertDialog.Builder(activity).setTitle("Selected text").setMessage(text)
+                .setPositiveButton("Copy") { _, _ ->
+                    (activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager)
+                        .setPrimaryClip(ClipData.newPlainText("Selected text", text))
+                }.setNegativeButton("Close", null).show()
+        }
+    }
+
+    private fun exportAnnotations() {
+        val text = buildString {
+            append(publication.book.title).append(" — ").append(publication.book.author).append("\n\n")
+            annotations.sortedWith(compareBy<Annotation> { it.chapter }.thenBy { it.start }).forEach { annotation ->
+                append('“').append(annotation.quote.trim()).append("”\n")
+                if (annotation.note.isNotBlank()) append(annotation.note).append('\n')
+                append('\n')
+            }
+        }
+        val intent = Intent(Intent.ACTION_SEND).apply {
+            type = "text/plain"
+            putExtra(Intent.EXTRA_SUBJECT, "Annotations — ${publication.book.title}")
+            putExtra(Intent.EXTRA_TEXT, text)
+        }
+        runCatching { activity.startActivity(Intent.createChooser(intent, "Export annotations")) }
+    }
+
+    private fun runSearch(query: String) {
+        searchCount = 0
+        searchIndex = -1
+        searchStatus?.text = if (query.isBlank()) "No results" else "Searching…"
+        webView.search(query, searchDirection)
+    }
+
+    private fun updateSearchDirectionButton(button: TextView, input: EditText) {
+        val forward = searchDirection == ReaderWebView.SearchDirection.FORWARD
+        button.text = if (forward) "↓" else "↑"
+        button.contentDescription = if (forward) {
+            "Search forward from the current location. Tap to search backward."
+        } else {
+            "Search backward from the current location. Tap to search forward."
+        }
+        input.hint = if (forward) "Search forward" else "Search backward"
+    }
+
+    private fun showSearch(initial: String = "") {
+        controlsVisible = true
+        topBar.visibility = View.VISIBLE
+        bottomBar.visibility = View.VISIBLE
+        searchBar?.let {
+            val input = it.findViewWithTag<EditText>("query")
+            if (initial.isNotBlank()) { input.setText(initial); input.setSelection(input.length()); runSearch(initial) }
+            input.requestFocus(); showKeyboard(input); return
+        }
+        val row = LinearLayout(activity).apply {
+            orientation = LinearLayout.HORIZONTAL
+            gravity = Gravity.CENTER_VERTICAL
+            setPadding(dp(8), dp(4), dp(4), dp(4))
+            setBackgroundColor(Color.WHITE)
+            elevation = dp(3).toFloat()
+        }
+        val input = EditText(activity).apply {
+            tag = "query"
+            hint = "Search this book"
+            isSingleLine = true
+            textSize = 16f
+            imeOptions = EditorInfo.IME_ACTION_SEARCH
+            setText(initial)
+            setSelectAllOnFocus(initial.isNotBlank())
+            setOnEditorActionListener { _, action, event ->
+                val submitted = action == EditorInfo.IME_ACTION_SEARCH ||
+                    (event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)
+                if (submitted) {
+                    val query = text.toString()
+                    hideKeyboard(this)
+                    handler.postDelayed({ runSearch(query) }, 180)
+                    true
+                } else false
+            }
+        }
+        val directionButton = actionButton("", "") { view ->
+            searchDirection = if (searchDirection == ReaderWebView.SearchDirection.FORWARD) {
+                ReaderWebView.SearchDirection.BACKWARD
+            } else {
+                ReaderWebView.SearchDirection.FORWARD
+            }
+            updateSearchDirectionButton(view as TextView, input)
+        }
+        updateSearchDirectionButton(directionButton, input)
+        row.addView(directionButton, lp(dp(42)))
+        row.addView(input, LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f))
+        searchStatus = TextView(activity).apply { text = ""; textSize = 12f; gravity = Gravity.CENTER; setTextColor(Color.DKGRAY) }
+        row.addView(searchStatus, lp(dp(74)))
+        row.addView(actionButton("‹", "Previous search result") { _ -> if (searchCount > 0) { webView.moveSearch(-1); searchIndex = (searchIndex - 1 + searchCount) % searchCount; updateSearchStatus() } }, lp(dp(42)))
+        row.addView(actionButton("›", "Next search result") { _ -> if (searchCount > 0) { webView.moveSearch(1); searchIndex = (searchIndex + 1) % searchCount; updateSearchStatus() } }, lp(dp(42)))
+        row.addView(actionButton("×", "Close search") { _ -> hideSearch() }, lp(dp(42)))
+        searchBar = row
+        addView(row, LayoutParams(LayoutParams.MATCH_PARENT, dp(52)).apply { topMargin = dp(52) })
+        row.bringToFront()
+        input.requestFocus()
+        if (initial.isNotBlank()) runSearch(initial)
+        showKeyboard(input)
+    }
+
+    private fun hideSearch() {
+        searchBar?.let(::removeView)
+        searchBar = null
+        searchStatus = null
+        searchCount = 0
+        searchIndex = -1
+        webView.clearSearch()
+        hideKeyboard(webView)
+    }
+
+    override fun onSearchResults(results: JSONArray) {
+        searchCount = results.length()
+        searchIndex = (0 until searchCount).firstOrNull { results.optJSONObject(it)?.optBoolean("current") == true }
+            ?: if (searchCount > 0) 0 else -1
+        updateSearchStatus()
+        if (searchCount == 0) Toast.makeText(activity, "No matches", Toast.LENGTH_SHORT).show()
+    }
+
+    private fun updateSearchStatus() {
+        searchStatus?.text = if (searchCount == 0) "No results" else "${searchIndex + 1} / $searchCount"
+    }
+
+    private fun showGoToPage() {
+        val input = EditText(activity).apply {
+            inputType = InputType.TYPE_CLASS_NUMBER
+            hint = "1–${current.totalPages}"
+            setText((current.page + 1).toString())
+            selectAll()
+        }
+        AlertDialog.Builder(activity).setTitle("Go to page").setView(input)
+            .setPositiveButton("Go") { _, _ ->
+                input.text.toString().toIntOrNull()?.let { page ->
+                    rememberLocation(); webView.goToPage((page - 1).coerceIn(0, current.totalPages - 1))
+                }
+            }.setNegativeButton("Cancel", null).show()
+    }
+
+    private fun showGoToPercent() {
+        val seek = SeekBar(activity).apply { max = 1000; progress = (current.progress * max).roundToInt() }
+        AlertDialog.Builder(activity).setTitle("Go to position").setView(seek)
+            .setPositiveButton("Go") { _, _ ->
+                rememberLocation(); webView.goToPage((seek.progress / seek.max.toFloat() * (current.totalPages - 1)).roundToInt())
+            }.setNegativeButton("Cancel", null).show()
+    }
+
+    private fun showAppearance() {
+        val content = LinearLayout(activity).apply { orientation = LinearLayout.VERTICAL; setPadding(dp(24), dp(8), dp(24), dp(16)) }
+        fun heading(value: String) = TextView(activity).apply {
+            text = value; textSize = 13f; setTextColor(Color.DKGRAY); setPadding(0, dp(14), 0, dp(3))
+        }
+        fun spinner(label: String, choices: List<String>, selected: Int, change: (Int) -> Unit) {
+            content.addView(heading(label))
+            content.addView(Spinner(activity).apply {
+                adapter = ArrayAdapter(activity, android.R.layout.simple_spinner_dropdown_item, choices)
+                setSelection(selected.coerceIn(0, choices.lastIndex), false)
+                onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
+                    var previous = selected.coerceIn(0, choices.lastIndex)
+                    override fun onNothingSelected(parent: android.widget.AdapterView<*>?) = Unit
+                    override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
+                        if (position != previous) {
+                            previous = position
+                            change(position)
+                        }
+                    }
+                }
+            }, LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, dp(48)))
+        }
+        fun slider(label: String, max: Int, value: Int, format: (Int) -> String, change: (Int) -> Unit) {
+            val title = heading("$label: ${format(value)}")
+            content.addView(title)
+            content.addView(SeekBar(activity).apply {
+                this.max = max; progress = value
+                setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+                    override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit
+                    override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { if (fromUser) title.text = "$label: ${format(progress)}" }
+                    override fun onStopTrackingTouch(seekBar: SeekBar) = change(seekBar.progress)
+                })
+            })
+        }
+        val themeNames = listOf("Light", "Sepia", "Dark")
+        spinner("Theme", themeNames, listOf("light", "sepia", "dark").indexOf(settings.theme)) {
+            updateSettings(settings.copy(theme = listOf("light", "sepia", "dark")[it]))
+        }
+        val families = listOf("Publisher", "Alexandria Serif", "Alexandria Sans", "Atkinson Hyperlegible", "Alexandria Mono")
+        spinner("Typeface", families, families.indexOf(settings.fontFamily).coerceAtLeast(1)) { updateSettings(settings.copy(fontFamily = families[it])) }
+        slider("Text size", 26, settings.fontSize - 12, { "${it + 12} sp" }) { updateSettings(settings.copy(fontSize = it + 12)) }
+        slider("Line height", 10, ((settings.lineHeight - 1f) * 10).roundToInt(), { String.format(java.util.Locale.getDefault(), "%.1f", 1f + it / 10f) }) {
+            updateSettings(settings.copy(lineHeight = 1f + it / 10f))
+        }
+        slider("Margins", 96, settings.margin, { "$it dp" }) { updateSettings(settings.copy(margin = it)) }
+        val aligns = listOf("Publisher", "Left", "Right", "Center", "Justify")
+        val alignValues = listOf("publisher", "left", "right", "center", "justify")
+        spinner("Text alignment", aligns, alignValues.indexOf(settings.textAlign)) { updateSettings(settings.copy(textAlign = alignValues[it])) }
+        val modes = listOf("Paged", "Continuous")
+        spinner("Reading mode", modes, if (settings.mode == "continuous") 1 else 0) {
+            updateSettings(settings.copy(mode = if (it == 1) "continuous" else "paged"))
+        }
+        slider("Screen brightness", 101, if (settings.brightness < 0) 0 else settings.brightness + 1,
+            { if (it == 0) "System" else "${it - 1}%" }) {
+            updateSettings(settings.copy(brightness = if (it == 0) -1 else it - 1), reflow = false)
+        }
+        content.addView(CheckBox(activity).apply {
+            text = "Use publisher styles"; isChecked = settings.publisherStyles
+            setOnCheckedChangeListener { _, checked -> updateSettings(settings.copy(publisherStyles = checked)) }
+        })
+        content.addView(CheckBox(activity).apply {
+            text = "Automatic hyphenation"; isChecked = settings.hyphenation
+            setOnCheckedChangeListener { _, checked -> updateSettings(settings.copy(hyphenation = checked)) }
+        })
+        content.addView(CheckBox(activity).apply {
+            text = "Use volume keys to turn pages"; isChecked = settings.volumeKeys
+            setOnCheckedChangeListener { _, checked -> updateSettings(settings.copy(volumeKeys = checked), reflow = false) }
+        })
+        val scroll = ScrollView(activity).apply { addView(content) }
+        AlertDialog.Builder(activity).setTitle("Reading appearance").setView(scroll)
+            .setPositiveButton("Done", null)
+            .setNeutralButton("Use as default") { _, _ -> repository.saveSettings(publication.book.id, settings, makeDefault = true) }
+            .setNegativeButton("Reset") { _, _ -> updateSettings(ReaderSettings()) }.show()
+    }
+
+    private fun showPageDisplay() {
+        val content = LinearLayout(activity).apply {
+            orientation = LinearLayout.VERTICAL
+            setPadding(dp(24), dp(8), dp(24), dp(20))
+        }
+        fun heading(value: String) = TextView(activity).apply {
+            text = value; textSize = 13f; setTextColor(Color.DKGRAY); setPadding(0, dp(14), 0, dp(3))
+        }
+        fun spinner(label: String, choices: List<String>, selected: Int, change: (Int) -> Unit) {
+            content.addView(heading(label))
+            content.addView(Spinner(activity).apply {
+                adapter = ArrayAdapter(activity, android.R.layout.simple_spinner_dropdown_item, choices)
+                setSelection(selected.coerceIn(0, choices.lastIndex), false)
+                onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
+                    var previous = selected.coerceIn(0, choices.lastIndex)
+                    override fun onNothingSelected(parent: android.widget.AdapterView<*>?) = Unit
+                    override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
+                        if (position != previous) { previous = position; change(position) }
+                    }
+                }
+            }, LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, dp(48)))
+        }
+        fun slider(label: String, max: Int, value: Int, format: (Int) -> String, change: (Int) -> Unit) {
+            val title = heading("$label: ${format(value)}")
+            content.addView(title)
+            content.addView(SeekBar(activity).apply {
+                this.max = max; progress = value.coerceIn(0, max)
+                setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+                    override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit
+                    override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
+                        if (fromUser) title.text = "$label: ${format(progress)}"
+                    }
+                    override fun onStopTrackingTouch(seekBar: SeekBar) = change(seekBar.progress)
+                })
+            })
+        }
+        fun cropButton(label: String, margins: () -> CropMargins, save: (CropMargins) -> Unit) {
+            content.addView(TextView(activity).apply {
+                text = label
+                textSize = 16f
+                gravity = Gravity.CENTER_VERTICAL
+                setPadding(dp(14), dp(12), dp(14), dp(12))
+                setTextColor(Color.rgb(25, 25, 25))
+                setBackgroundColor(Color.rgb(232, 232, 229))
+                setOnClickListener { showCropEditor(label, margins(), save) }
+            }, LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { topMargin = dp(7) })
+        }
+
+        if (!publication.fixedLayout) {
+            content.addView(TextView(activity).apply {
+                text = "This EPUB is reflowable. Crop, zoom, pan, and page-cut controls become active for a pre-paginated EPUB. Tone and dithering controls remain available."
+                textSize = 14f; setTextColor(Color.DKGRAY); setPadding(0, dp(8), 0, dp(8))
+            })
+        } else {
+            val zoomValues = listOf("fit-page", "fit-width", "custom")
+            spinner("Zoom mode", listOf("Fit to page", "Fit to width", "Custom"), zoomValues.indexOf(settings.zoomMode)) {
+                updateSettings(settings.copy(zoomMode = zoomValues[it]))
+            }
+            slider("Custom zoom", 350, settings.customZoom - 50, { "${it + 50}%" }) {
+                updateSettings(settings.copy(zoomMode = "custom", customZoom = it + 50))
+            }
+            spinner("Fit-to-width scrolling", listOf("Continuous screen", "Current page"), if (settings.scrollMode == "page") 1 else 0) {
+                updateSettings(settings.copy(scrollMode = if (it == 1) "page" else "screen"))
+            }
+            val cropValues = listOf("none", "any", "even-odd")
+            spinner("Margin cropping", listOf("None", "Same margins on all pages", "Separate even and odd pages"), cropValues.indexOf(settings.cropScheme)) {
+                updateSettings(settings.copy(cropScheme = cropValues[it]))
+            }
+            cropButton("Edit shared crop margins", { settings.cropAny }) { updateSettings(settings.copy(cropAny = it)) }
+            cropButton("Edit even-page crop margins", { settings.cropEven }) { updateSettings(settings.copy(cropEven = it)) }
+            cropButton("Edit odd-page crop margins", { settings.cropOdd }) { updateSettings(settings.copy(cropOdd = it)) }
+        }
+
+        slider("Contrast exponent", 40, ((settings.contrastExponent - 1f) * 10).roundToInt(),
+            { String.format(java.util.Locale.getDefault(), "%.1f", 1f + it / 10f) }) {
+            updateSettings(settings.copy(contrastExponent = 1f + it / 10f))
+        }
+        slider("Gray point", 239, settings.grayPoint - 16, { "${it + 16}" }) {
+            updateSettings(settings.copy(grayPoint = it + 16))
+        }
+        val ditherValues = listOf("off", "g16", "g2")
+        spinner("Dithering", listOf("Off", "16-level grayscale", "Black and white"), ditherValues.indexOf(settings.dithering)) {
+            updateSettings(settings.copy(dithering = ditherValues[it]))
+        }
+
+        val scroll = ScrollView(activity).apply { addView(content) }
+        AlertDialog.Builder(activity).setTitle("Page display tools").setView(scroll)
+            .setPositiveButton("Done", null)
+            .setNegativeButton("Reset") { _, _ ->
+                updateSettings(settings.copy(
+                    zoomMode = "fit-page", customZoom = 100, scrollMode = "screen", cropScheme = "none",
+                    cropAny = CropMargins(), cropEven = CropMargins(), cropOdd = CropMargins(),
+                    contrastExponent = 1f, grayPoint = 255, dithering = "off",
+                ))
+            }.show()
+    }
+
+    private fun showCropEditor(title: String, initial: CropMargins, save: (CropMargins) -> Unit) {
+        var value = initial
+        val content = LinearLayout(activity).apply {
+            orientation = LinearLayout.VERTICAL
+            setPadding(dp(24), dp(14), dp(24), dp(12))
+        }
+        val preview = CropPreview(activity).apply { margins = value }
+        content.addView(preview, LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, dp(230)))
+        fun edge(label: String, initialValue: Float, update: (Float) -> CropMargins) {
+            fun format(value: Float) = String.format(java.util.Locale.getDefault(), "%.1f%%", value)
+            val caption = TextView(activity).apply {
+                text = "$label: ${format(initialValue)}"; textSize = 13f; setTextColor(Color.DKGRAY); setPadding(0, dp(8), 0, 0)
+            }
+            content.addView(caption)
+            content.addView(SeekBar(activity).apply {
+                max = 900; progress = (initialValue * 10).roundToInt()
+                setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+                    override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit
+                    override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
+                        if (!fromUser) return
+                        val percentage = progress / 10f
+                        caption.text = "$label: ${format(percentage)}"
+                        value = update(percentage)
+                        preview.margins = value
+                    }
+                    override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit
+                })
+            })
+        }
+        edge("Left", value.left) { value.copy(left = it) }
+        edge("Top", value.top) { value.copy(top = it) }
+        edge("Right", value.right) { value.copy(right = it) }
+        edge("Bottom", value.bottom) { value.copy(bottom = it) }
+        AlertDialog.Builder(activity).setTitle(title).setView(ScrollView(activity).apply { addView(content) })
+            .setPositiveButton("Apply") { _, _ -> save(value) }
+            .setNeutralButton("Clear") { _, _ -> save(CropMargins()) }
+            .setNegativeButton("Cancel", null).show()
+    }
+
+    private fun updateSettings(value: ReaderSettings, reflow: Boolean = true) {
+        if (value == settings) return
+        if (reflow && initialized) {
+            webView.captureLocation { location ->
+                settings = value
+                repository.saveSettings(publication.book.id, settings)
+                applyNativeTheme()
+                webView.configurePageTools(publication.fixedLayout, settings)
+                webView.applySettings(settings, location)
+            }
+        } else {
+            settings = value
+            repository.saveSettings(publication.book.id, settings)
+            webView.configurePageTools(publication.fixedLayout, settings)
+            applyNativeTheme()
+        }
+    }
+
+    private fun applyNativeTheme() {
+        val (paper, ink) = when (settings.theme) {
+            "sepia" -> Color.rgb(243, 234, 215) to Color.rgb(45, 38, 28)
+            "dark" -> Color.rgb(23, 23, 23) to Color.rgb(237, 237, 237)
+            else -> Color.WHITE to Color.rgb(25, 25, 25)
+        }
+        setBackgroundColor(paper)
+        topBar.setBackgroundColor(paper); bottomBar.setBackgroundColor(paper)
+        titleLabel.setTextColor(ink); progressLabel.setTextColor(ink)
+        sequenceOf(topBar, bottomBar).flatMap { bar -> (0 until bar.childCount).asSequence().map { bar.getChildAt(it) } }
+            .filterIsInstance<TextView>().forEach { it.setTextColor(ink) }
+        activity.window.statusBarColor = paper
+        activity.window.navigationBarColor = paper
+        activity.window.decorView.systemUiVisibility = if (settings.theme == "dark") 0 else
+            View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
+        activity.window.attributes = activity.window.attributes.apply {
+            screenBrightness = if (settings.brightness >= 0) settings.brightness / 100f
+                else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
+        }
+    }
+
+    private fun showBookInformation() {
+        val book = publication.book
+        val message = buildString {
+            append(book.author)
+            if (book.publisher.isNotBlank()) append("\n").append(book.publisher)
+            if (book.language.isNotBlank()) append("\nLanguage: ").append(book.language)
+            append("\n\nEPUB · ").append(publication.spine.size).append(if (publication.fixedLayout) " fixed pages" else " sections")
+            append("\nProgress: ").append((current.progress * 100).roundToInt()).append('%')
+            if (book.description.isNotBlank()) append("\n\n").append(book.description)
+        }
+        AlertDialog.Builder(activity).setTitle(book.title).setMessage(message)
+            .setPositiveButton("Table of contents") { _, _ -> showTableOfContents() }
+            .setNegativeButton("Close", null).show()
+    }
+
+    private fun rotateScreen() {
+        activity.requestedOrientation = when (activity.resources.configuration.orientation) {
+            android.content.res.Configuration.ORIENTATION_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+            else -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+        }
+    }
+
+    override fun onNavigationJump(origin: ReaderLocation) {
+        rememberLocation(origin)
+    }
+
+    override fun onExternalLink(url: String) {
+        val scheme = runCatching { Uri.parse(url).scheme?.lowercase() }.getOrNull()
+        if (scheme !in setOf("http", "https", "mailto", "tel")) return
+        AlertDialog.Builder(activity).setTitle("Open external link?").setMessage(url)
+            .setPositiveButton("Open") { _, _ ->
+                runCatching { activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
+                    .onFailure { Toast.makeText(activity, "No application can open this link", Toast.LENGTH_SHORT).show() }
+            }.setNegativeButton("Cancel", null).show()
+    }
+
+    override fun onRenderError(message: String) {
+        loading.text = "Unable to render this EPUB\n\n$message"
+        loading.setTextColor(Color.rgb(120, 20, 20))
+    }
+
+    fun handleKey(keyCode: Int): Boolean {
+        if (!settings.volumeKeys) return false
+        return when (keyCode) {
+            KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_PAGE_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT -> { nextPage(); true }
+            KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_DPAD_LEFT -> { previousPage(); true }
+            else -> false
+        }
+    }
+
+    fun handleBack(): Boolean {
+        if (searchBar != null) { hideSearch(); return true }
+        if (!controlsVisible) { toggleControls(); return true }
+        return false
+    }
+
+    fun pause() {
+        flush()
+        webView.onPause()
+        webView.pauseTimers()
+    }
+
+    fun resume() {
+        webView.onResume()
+        webView.resumeTimers()
+    }
+
+    fun flush() {
+        pendingSave?.let(handler::removeCallbacks)
+        repository.updateProgress(publication.book.id, current)
+    }
+
+    fun dispose() {
+        flush()
+        handler.removeCallbacksAndMessages(null)
+        removeView(webView)
+        webView.destroy()
+    }
+
+    private fun showKeyboard(view: View) {
+        handler.postDelayed({ (activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) }, 120)
+    }
+
+    private fun hideKeyboard(view: View) {
+        (activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(view.windowToken, 0)
+    }
+
+    private fun lp(width: Int): LinearLayout.LayoutParams = LinearLayout.LayoutParams(width, LayoutParams.MATCH_PARENT)
+    private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt()
+
+    private class CropPreview(context: Context) : View(context) {
+        private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+        var margins: CropMargins = CropMargins()
+            set(value) { field = value; invalidate() }
+
+        override fun onDraw(canvas: Canvas) {
+            super.onDraw(canvas)
+            val pad = width.coerceAtMost(height) * .08f
+            val pageRatio = 3f / 4f
+            val availableWidth = width - 2 * pad
+            val availableHeight = height - 2 * pad
+            val pageWidth = minOf(availableWidth, availableHeight * pageRatio)
+            val pageHeight = pageWidth / pageRatio
+            val page = RectF((width - pageWidth) / 2f, (height - pageHeight) / 2f,
+                (width + pageWidth) / 2f, (height + pageHeight) / 2f)
+            paint.style = Paint.Style.FILL; paint.color = Color.rgb(216, 216, 213); canvas.drawRect(page, paint)
+            val horizontalScale = if (margins.left + margins.right > 95f) 95f / (margins.left + margins.right) else 1f
+            val verticalScale = if (margins.top + margins.bottom > 95f) 95f / (margins.top + margins.bottom) else 1f
+            val crop = RectF(
+                page.left + page.width() * margins.left * horizontalScale / 100f,
+                page.top + page.height() * margins.top * verticalScale / 100f,
+                page.right - page.width() * margins.right * horizontalScale / 100f,
+                page.bottom - page.height() * margins.bottom * verticalScale / 100f,
+            )
+            paint.color = Color.WHITE; canvas.drawRect(crop, paint)
+            paint.style = Paint.Style.STROKE; paint.strokeWidth = resources.displayMetrics.density * 2f
+            paint.color = Color.rgb(25, 25, 25); canvas.drawRect(crop, paint)
+        }
+    }
+
+}
diff --git a/app/src/main/java/com/alexandria/reader/ReaderWebView.kt b/app/src/main/java/com/alexandria/reader/ReaderWebView.kt
new file mode 100644 (file)
index 0000000..3424b88
--- /dev/null
@@ -0,0 +1,363 @@
+package com.alexandria.reader
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.Color
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.view.ActionMode
+import android.view.GestureDetector
+import android.view.Menu
+import android.view.MenuItem
+import android.view.MotionEvent
+import android.view.ScaleGestureDetector
+import android.webkit.ConsoleMessage
+import android.webkit.JavascriptInterface
+import android.webkit.JsResult
+import android.webkit.ValueCallback
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.ByteArrayInputStream
+import java.io.File
+import kotlin.math.abs
+
+@SuppressLint("SetJavaScriptEnabled")
+class ReaderWebView(context: Context) : WebView(context) {
+    interface Listener {
+        fun onReady(metrics: JSONObject)
+        fun onLocationChanged(location: ReaderLocation)
+        fun onTap(x: Float, y: Float)
+        fun onSwipe(forward: Boolean)
+        fun onVerticalSwipe(forward: Boolean)
+        fun onViewportChanged(zoomMode: String, customZoom: Int)
+        fun onExternalLink(url: String)
+        fun onNavigationJump(origin: ReaderLocation)
+        fun onSearchResults(results: JSONArray)
+        fun onAnnotationTapped(id: String)
+        fun onSelectionAction(action: SelectionAction, selection: JSONObject?)
+        fun onRenderError(message: String)
+    }
+
+    enum class SelectionAction { HIGHLIGHT, NOTE, DEFINE, SEARCH }
+    enum class SearchDirection(val wireValue: String) {
+        FORWARD("forward"),
+        BACKWARD("backward"),
+    }
+
+    var listener: Listener? = null
+    private val mainHandler = Handler(Looper.getMainLooper())
+    private var pageLoaded = false
+    private var suppressTapUntil = 0L
+    private var fixedLayout = false
+    private var fixedZoomMode = "fit-page"
+    private var viewportDirty = false
+
+    private val scaleGestures = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
+        override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = fixedLayout
+
+        override fun onScale(detector: ScaleGestureDetector): Boolean {
+            if (!fixedLayout) return false
+            viewportDirty = true
+            customZoomBy(detector.scaleFactor)
+            return true
+        }
+
+        override fun onScaleEnd(detector: ScaleGestureDetector) {
+            if (fixedLayout) finishViewportGesture()
+            viewportDirty = false
+        }
+    })
+
+    private val gestures = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
+        override fun onDown(event: MotionEvent): Boolean = true
+
+        override fun onSingleTapConfirmed(event: MotionEvent): Boolean {
+            if (android.os.SystemClock.uptimeMillis() < suppressTapUntil) return false
+            val hit = hitTestResult
+            val isLink = hit.type in setOf(
+                HitTestResult.SRC_ANCHOR_TYPE,
+                HitTestResult.SRC_IMAGE_ANCHOR_TYPE,
+                HitTestResult.EMAIL_TYPE,
+                HitTestResult.PHONE_TYPE,
+                HitTestResult.GEO_TYPE,
+            )
+            if (!isLink) {
+                performClick()
+                listener?.onTap(event.x, event.y)
+            }
+            return false
+        }
+
+        override fun onScroll(first: MotionEvent?, second: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
+            if (!fixedLayout || fixedZoomMode != "custom" || scaleGestures.isInProgress) return false
+            viewportDirty = true
+            // GestureDetector reports viewport movement. The document follows the finger.
+            panBy(-distanceX, -distanceY)
+            return true
+        }
+
+        override fun onFling(first: MotionEvent?, second: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
+            if (first == null) return false
+            val dx = second.x - first.x
+            val dy = second.y - first.y
+            if (fixedLayout && fixedZoomMode == "custom") return true
+            if (abs(dx) > width * .16f && abs(dx) > abs(dy) * 1.25f && abs(velocityX) > 250f) {
+                // A westward swipe advances, matching Plato's reader gesture.
+                listener?.onSwipe(dx < 0f)
+                return true
+            }
+            if (fixedLayout && abs(dy) > height * .12f && abs(dy) > abs(dx) * 1.25f && abs(velocityY) > 250f) {
+                listener?.onVerticalSwipe(dy < 0f)
+                return true
+            }
+            return false
+        }
+    })
+
+    init {
+        setBackgroundColor(Color.WHITE)
+        isVerticalScrollBarEnabled = false
+        isHorizontalScrollBarEnabled = false
+        overScrollMode = OVER_SCROLL_NEVER
+        settings.apply {
+            javaScriptEnabled = true
+            domStorageEnabled = false
+            databaseEnabled = false
+            allowFileAccess = true
+            allowContentAccess = false
+            allowFileAccessFromFileURLs = true
+            allowUniversalAccessFromFileURLs = false
+            blockNetworkLoads = true
+            cacheMode = WebSettings.LOAD_NO_CACHE
+            builtInZoomControls = false
+            displayZoomControls = false
+            setSupportZoom(false)
+            defaultTextEncodingName = "utf-8"
+            mediaPlaybackRequiresUserGesture = true
+            mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
+        }
+        addJavascriptInterface(Bridge(), "Android")
+        webChromeClient = object : WebChromeClient() {
+            override fun onJsAlert(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean {
+                result?.cancel()
+                return true
+            }
+
+            override fun onConsoleMessage(message: ConsoleMessage): Boolean {
+                when (message.messageLevel()) {
+                    ConsoleMessage.MessageLevel.ERROR -> Log.e("AlexandriaRenderer", "${message.message()} (${message.sourceId()}:${message.lineNumber()})")
+                    ConsoleMessage.MessageLevel.WARNING -> Log.w("AlexandriaRenderer", "${message.message()} (${message.sourceId()}:${message.lineNumber()})")
+                    else -> Unit
+                }
+                return true
+            }
+        }
+        webViewClient = object : WebViewClient() {
+            override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
+                val uri = request?.url ?: return true
+                if (uri.scheme == "file" && uri.path?.endsWith("reader.html") == true) return false
+                listener?.onExternalLink(uri.toString())
+                return true
+            }
+
+            override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
+                val scheme = request?.url?.scheme?.lowercase()
+                if (scheme != null && scheme !in setOf("file", "data", "blob")) {
+                    return WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(ByteArray(0)))
+                }
+                return super.shouldInterceptRequest(view, request)
+            }
+
+            @Deprecated("Required for Android 11 WebView")
+            override fun onReceivedError(view: WebView?, errorCode: Int, description: String?, failingUrl: String?) {
+                if (failingUrl?.endsWith("reader.html") == true) listener?.onRenderError(description ?: "The page could not be rendered.")
+            }
+        }
+    }
+
+    fun load(file: File) {
+        pageLoaded = false
+        loadUrl(file.toURI().toASCIIString())
+    }
+
+    fun applySettings(settings: ReaderSettings, location: ReaderLocation? = null) {
+        fixedZoomMode = settings.zoomMode
+        if (!pageLoaded) return
+        val keep = location?.toJson()?.toString() ?: "Alex.locator()"
+        javascript("Alex.applySettings(${settings.toJson()}, $keep)")
+    }
+
+    fun configurePageTools(isFixedLayout: Boolean, settings: ReaderSettings) {
+        fixedLayout = isFixedLayout
+        fixedZoomMode = settings.zoomMode
+    }
+
+    fun goToPage(page: Int) = javascript("Alex.setPage($page, true)")
+    fun nextPage() = javascript("Alex.next()")
+    fun previousPage() = javascript("Alex.previous()")
+    fun goToLocation(location: ReaderLocation) = javascript("Alex.goToLocator(${location.toJson()})")
+    fun goToReference(reference: String) = javascript("Alex.goToReference(${JSONObject.quote(reference)}, false)")
+    fun goToChapter(index: Int) = javascript("Alex.setPage(Alex.chapterPage($index), true)")
+    fun panBy(dx: Float, dy: Float) = javascript("Alex.panBy($dx,$dy)")
+    fun customZoomBy(factor: Float) = javascript("Alex.zoomBy($factor)")
+    fun finishViewportGesture() = javascript("Alex.finishViewportGesture()")
+    fun search(query: String, direction: SearchDirection) =
+        javascript("Alex.search(${JSONObject.quote(query)}, ${JSONObject.quote(direction.wireValue)})")
+    fun clearSearch() = javascript("Alex.clearSearch()")
+    fun moveSearch(delta: Int) = javascript("Alex.moveSearch($delta)")
+
+    fun captureLocation(callback: (ReaderLocation) -> Unit) {
+        javascript("JSON.stringify(Alex.locator())") { encoded ->
+            decodeJavascriptString(encoded)?.let { runCatching { ReaderLocation.fromJson(JSONObject(it)) }.getOrNull() }?.let(callback)
+        }
+    }
+
+    fun captureSelection(callback: (JSONObject?) -> Unit) {
+        javascript("JSON.stringify(Alex.captureSelection())") { encoded ->
+            val decoded = decodeJavascriptString(encoded)
+            callback(decoded?.takeUnless { it == "null" }?.let { runCatching { JSONObject(it) }.getOrNull() })
+        }
+    }
+
+    fun applyAnnotations(values: List<Annotation>) {
+        val array = JSONArray()
+        values.forEach { array.put(it.toJson()) }
+        javascript("Alex.applyAnnotations($array)")
+    }
+
+    fun applyAnnotation(value: Annotation) = javascript("Alex.wrapAnnotation(${value.toJson()})")
+    fun removeAnnotation(id: String) = javascript("Alex.removeAnnotation(${JSONObject.quote(id)})")
+
+    private fun javascript(source: String, callback: ValueCallback<String>? = null) {
+        if (!pageLoaded && !source.startsWith("Alex.applySettings")) return
+        evaluateJavascript(source, callback)
+    }
+
+    override fun onTouchEvent(event: MotionEvent): Boolean {
+        scaleGestures.onTouchEvent(event)
+        gestures.onTouchEvent(event)
+        if (fixedLayout && fixedZoomMode == "custom") {
+            if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) {
+                if (viewportDirty && !scaleGestures.isInProgress) finishViewportGesture()
+                viewportDirty = false
+            }
+            return true
+        }
+        return super.onTouchEvent(event)
+    }
+
+    override fun performClick(): Boolean {
+        super.performClick()
+        return true
+    }
+
+    override fun startActionMode(callback: ActionMode.Callback?, type: Int): ActionMode? {
+        return super.startActionMode(wrapSelectionCallback(callback), type)
+    }
+
+    @Deprecated("Android still calls this overload on some WebView builds")
+    override fun startActionMode(callback: ActionMode.Callback?): ActionMode? {
+        return super.startActionMode(wrapSelectionCallback(callback))
+    }
+
+    private fun wrapSelectionCallback(delegate: ActionMode.Callback?): ActionMode.Callback {
+        return object : ActionMode.Callback {
+            override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+                val created = delegate?.onCreateActionMode(mode, menu) ?: true
+                if (created) {
+                    menu.add(Menu.NONE, ACTION_HIGHLIGHT, 10, "Highlight").setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
+                    menu.add(Menu.NONE, ACTION_NOTE, 11, "Add note")
+                    menu.add(Menu.NONE, ACTION_DEFINE, 12, "Define")
+                    menu.add(Menu.NONE, ACTION_SEARCH, 13, "Search")
+                }
+                return created
+            }
+
+            override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean =
+                delegate?.onPrepareActionMode(mode, menu) ?: false
+
+            override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+                val action = when (item.itemId) {
+                    ACTION_HIGHLIGHT -> SelectionAction.HIGHLIGHT
+                    ACTION_NOTE -> SelectionAction.NOTE
+                    ACTION_DEFINE -> SelectionAction.DEFINE
+                    ACTION_SEARCH -> SelectionAction.SEARCH
+                    else -> null
+                }
+                if (action != null) {
+                    // WebView clears the DOM selection when the action mode closes.
+                    // Capture it first, then dismiss the platform toolbar.
+                    captureSelection { selection ->
+                        listener?.onSelectionAction(action, selection)
+                        runCatching { mode.finish() }
+                    }
+                    return true
+                }
+                return delegate?.onActionItemClicked(mode, item) ?: false
+            }
+
+            override fun onDestroyActionMode(mode: ActionMode) {
+                delegate?.onDestroyActionMode(mode)
+            }
+        }
+    }
+
+    private inner class Bridge {
+        @JavascriptInterface fun onReaderReady(json: String) = mainHandler.post {
+            pageLoaded = true
+            runCatching { JSONObject(json) }.getOrNull()?.let { listener?.onReady(it) }
+        }
+
+        @JavascriptInterface fun onPageChanged(json: String) = mainHandler.post {
+            runCatching { ReaderLocation.fromJson(JSONObject(json)) }.getOrNull()?.let { listener?.onLocationChanged(it) }
+        }
+
+        @JavascriptInterface fun onViewportChanged(json: String) = mainHandler.post {
+            runCatching { JSONObject(json) }.getOrNull()?.let { value ->
+                val mode = value.optString("zoomMode", "fit-page")
+                val zoom = value.optInt("customZoom", 100).coerceIn(50, 400)
+                fixedZoomMode = mode
+                listener?.onViewportChanged(mode, zoom)
+            }
+        }
+
+        @JavascriptInterface fun onExternalLink(url: String) = mainHandler.post { listener?.onExternalLink(url) }
+        @JavascriptInterface fun onNavigationJump(json: String) = mainHandler.post {
+            runCatching { ReaderLocation.fromJson(JSONObject(json)) }.getOrNull()?.let { listener?.onNavigationJump(it) }
+        }
+        @JavascriptInterface fun onSearchResults(json: String) = mainHandler.post {
+            runCatching { JSONArray(json) }.getOrNull()?.let { listener?.onSearchResults(it) }
+        }
+        @JavascriptInterface fun onAnnotationTapped(id: String) = mainHandler.post {
+            suppressTapUntil = android.os.SystemClock.uptimeMillis() + 700L
+            listener?.onAnnotationTapped(id)
+        }
+    }
+
+    override fun destroy() {
+        listener = null
+        removeJavascriptInterface("Android")
+        stopLoading()
+        loadUrl("about:blank")
+        super.destroy()
+    }
+
+    companion object {
+        private const val ACTION_HIGHLIGHT = 0xA110
+        private const val ACTION_NOTE = 0xA111
+        private const val ACTION_DEFINE = 0xA112
+        private const val ACTION_SEARCH = 0xA113
+
+        private fun decodeJavascriptString(value: String?): String? {
+            if (value == null || value == "null") return null
+            return runCatching { JSONArray("[$value]").getString(0) }.getOrNull()
+        }
+    }
+}
diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644 (file)
index 0000000..beac925
--- /dev/null
@@ -0,0 +1,6 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="48dp" android:height="48dp" android:viewportWidth="48" android:viewportHeight="48">
+    <path android:fillColor="#FAFAF8" android:pathData="M0,0h48v48h-48z"/>
+    <path android:fillColor="#202020" android:pathData="M9,7h25c2.8,0 5,2.2 5,5v27h-26c-2.2,0 -4,-1.8 -4,-4z"/>
+    <path android:fillColor="#FAFAF8" android:pathData="M14,11h18c1.7,0 3,1.3 3,3v20h-21z"/>
+    <path android:fillColor="#202020" android:pathData="M18,17h13v2h-13zM18,23h13v2h-13zM18,29h9v2h-9z"/>
+</vector>
diff --git a/app/src/main/res/font/atkinson.otf b/app/src/main/res/font/atkinson.otf
new file mode 100644 (file)
index 0000000..ba345b5
Binary files /dev/null and b/app/src/main/res/font/atkinson.otf differ
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644 (file)
index 0000000..98a9b3e
--- /dev/null
@@ -0,0 +1,14 @@
+<resources>
+    <style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
+        <item name="android:fontFamily">@font/atkinson</item>
+        <item name="android:windowActionModeOverlay">true</item>
+        <item name="android:colorAccent">#303030</item>
+        <item name="android:navigationBarColor">#fafaf8</item>
+        <item name="android:statusBarColor">#fafaf8</item>
+        <item name="android:windowBackground">#fafaf8</item>
+        <item name="android:windowDisablePreview">true</item>
+        <item name="android:windowLightStatusBar">true</item>
+        <item name="android:windowLightNavigationBar">true</item>
+        <item name="android:windowNoTitle">true</item>
+    </style>
+</resources>
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644 (file)
index 0000000..c7ad754
--- /dev/null
@@ -0,0 +1,4 @@
+plugins {
+    id("com.android.application") version "8.7.3" apply false
+    id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644 (file)
index 0000000..459e0db
--- /dev/null
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644 (file)
index 0000000..09523c0
--- /dev/null
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644 (file)
index 0000000..f5feea6
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,252 @@
+#!/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
+' "$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=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# 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, 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" \
+        org.gradle.wrapper.GradleWrapperMain \
+        "$@"
+
+# 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 (file)
index 0000000..9d21a21
--- /dev/null
@@ -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=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+: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/local.properties b/local.properties
new file mode 100644 (file)
index 0000000..be82eaa
--- /dev/null
@@ -0,0 +1 @@
+sdk.dir=C:\\Users\\cameron\\AppData\\Local\\Android\\Sdk
diff --git a/scripts/test.ps1 b/scripts/test.ps1
new file mode 100644 (file)
index 0000000..47d5196
--- /dev/null
@@ -0,0 +1,76 @@
+[CmdletBinding()]
+param(
+    [Parameter(Mandatory = $true)]
+    [string]$AvdName,
+    [switch]$KeepEmulator
+)
+
+$ErrorActionPreference = "Stop"
+
+function Find-Sdk {
+    if ($env:ANDROID_SDK_ROOT -and (Test-Path $env:ANDROID_SDK_ROOT)) { return $env:ANDROID_SDK_ROOT }
+    if ($env:ANDROID_HOME -and (Test-Path $env:ANDROID_HOME)) { return $env:ANDROID_HOME }
+    $default = Join-Path $env:LOCALAPPDATA "Android\Sdk"
+    if (Test-Path $default) { return $default }
+    throw "Android SDK not found. Set ANDROID_SDK_ROOT or ANDROID_HOME."
+}
+
+$sdk = Find-Sdk
+$adb = Join-Path $sdk "platform-tools\adb.exe"
+$emulator = Join-Path $sdk "emulator\emulator.exe"
+if (-not (Test-Path $adb) -or -not (Test-Path $emulator)) {
+    throw "Android emulator tools were not found under $sdk."
+}
+
+$running = & $adb devices | Select-String "^emulator-[0-9]+\s+device$"
+if ($running) {
+    throw "Stop the running emulator before starting a clean test device."
+}
+
+$env:ANDROID_SDK_ROOT = $sdk
+$env:ANDROID_HOME = $sdk
+& .\gradlew.bat :app:assembleDebug
+if ($LASTEXITCODE -ne 0) { throw "Debug build failed." }
+
+$emulatorProcess = Start-Process -FilePath $emulator -ArgumentList @(
+    "-avd", $AvdName,
+    "-wipe-data",
+    "-no-snapshot",
+    "-no-boot-anim"
+) -PassThru
+
+try {
+    & $adb wait-for-device
+    $serial = $null
+    $deadline = (Get-Date).AddMinutes(3)
+    do {
+        $deviceLine = & $adb devices | Select-String "^emulator-[0-9]+\s+device$" | Select-Object -First 1
+        if ($deviceLine) { $serial = $deviceLine.ToString().Split("\t")[0] }
+        if (-not $serial) { Start-Sleep -Seconds 2 }
+    } while (-not $serial -and (Get-Date) -lt $deadline)
+    if (-not $serial) { throw "Timed out waiting for the emulator." }
+
+    do {
+        $booted = (& $adb -s $serial shell getprop sys.boot_completed).Trim()
+        if ($booted -ne "1") { Start-Sleep -Seconds 2 }
+    } while ($booted -ne "1" -and (Get-Date) -lt $deadline)
+    if ($booted -ne "1") { throw "Timed out waiting for Android to finish booting." }
+
+    & $adb -s $serial install (Join-Path $PWD "app\build\outputs\apk\debug\app-debug.apk")
+    if ($LASTEXITCODE -ne 0) { throw "APK installation failed." }
+    & $adb -s $serial shell am start -n "com.alexandria.reader/.MainActivity"
+    if ($LASTEXITCODE -ne 0) { throw "Could not launch Alexandria." }
+
+    Write-Host "Clean test emulator is ready: $serial"
+    if (-not $KeepEmulator) {
+        Write-Host "Use Ctrl+C to stop the emulator when testing is complete."
+        while (-not $emulatorProcess.HasExited) { Start-Sleep -Seconds 2 }
+    } else {
+        Write-Host "Leaving the emulator running. The next invocation will wipe it again."
+    }
+}
+finally {
+    if (-not $KeepEmulator -and $serial -and -not $emulatorProcess.HasExited) {
+        & $adb -s $serial emu kill | Out-Null
+    }
+}
diff --git a/scripts/verify-fixed-page-tools.ps1 b/scripts/verify-fixed-page-tools.ps1
new file mode 100644 (file)
index 0000000..2aeef52
--- /dev/null
@@ -0,0 +1,151 @@
+[CmdletBinding()]
+param(
+    [string]$Fixture,
+    [string]$OutputDirectory
+)
+
+$ErrorActionPreference = "Stop"
+if (-not $Fixture) { $Fixture = Join-Path $PSScriptRoot "..\tests\fixtures\fixed-layout.epub" }
+if (-not $OutputDirectory) { $OutputDirectory = Join-Path $PSScriptRoot "..\verification" }
+$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } elseif ($env:ANDROID_HOME) { $env:ANDROID_HOME } else { Join-Path $env:LOCALAPPDATA "Android\Sdk" }
+$adb = Join-Path $sdk "platform-tools\adb.exe"
+if (-not (Test-Path $adb)) { throw "adb was not found under $sdk" }
+if (-not (Test-Path $Fixture)) { throw "Fixture not found: $Fixture" }
+$device = (& $adb devices | Select-String "^emulator-[0-9]+\s+device$" | Select-Object -First 1)
+if (-not $device) { throw "Start the Android virtual device before running this script." }
+$serial = $device.ToString().Split("`t")[0]
+$hashStream = [System.IO.File]::OpenRead((Resolve-Path $Fixture))
+try {
+    $hashBytes = [System.Security.Cryptography.SHA256]::Create().ComputeHash($hashStream)
+    $bookId = (($hashBytes | ForEach-Object { $_.ToString("x2") }) -join "").Substring(0, 24)
+}
+finally { $hashStream.Dispose() }
+$bookDirectory = "/data/user/0/com.alexandria.reader/files/library/$bookId"
+New-Item -ItemType Directory -Force $OutputDirectory | Out-Null
+
+function Invoke-Adb([Parameter(ValueFromRemainingArguments=$true)][string[]]$Arguments) {
+    & $adb -s $serial @Arguments
+    if ($LASTEXITCODE -ne 0) { throw "adb failed: $($Arguments -join ' ')" }
+}
+function Wait-Reader([string]$Expected) {
+    $dump = Join-Path $OutputDirectory "fixed-window.xml"
+    Remove-Item -ErrorAction SilentlyContinue $dump
+    & $adb -s $serial shell rm -f /sdcard/alexandria-fixed-window.xml | Out-Null
+    for ($attempt = 0; $attempt -lt 60; $attempt++) {
+        Start-Sleep -Milliseconds 500
+        & $adb -s $serial shell uiautomator dump /sdcard/alexandria-fixed-window.xml | Out-Null
+        if ($LASTEXITCODE -ne 0) { continue }
+        & $adb -s $serial pull /sdcard/alexandria-fixed-window.xml $dump | Out-Null
+        if ($LASTEXITCODE -eq 0 -and (Get-Content $dump -Raw) -match $Expected) { return }
+    }
+    throw "Reader state did not match: $Expected"
+}
+function Wait-Library {
+    $dump = Join-Path $OutputDirectory "fixed-library-window.xml"
+    Remove-Item -ErrorAction SilentlyContinue $dump
+    & $adb -s $serial shell rm -f /sdcard/alexandria-fixed-library.xml | Out-Null
+    for ($attempt = 0; $attempt -lt 30; $attempt++) {
+        Start-Sleep -Milliseconds 500
+        & $adb -s $serial shell uiautomator dump /sdcard/alexandria-fixed-library.xml | Out-Null
+        if ($LASTEXITCODE -ne 0) { continue }
+        & $adb -s $serial pull /sdcard/alexandria-fixed-library.xml $dump | Out-Null
+        if ($LASTEXITCODE -eq 0 -and (Get-Content $dump -Raw) -match "Fixed Page Tools Verification") { return }
+    }
+    throw "The fixed-page fixture was not visible in the library."
+}
+function Capture([string]$Name) {
+    Invoke-Adb shell screencap '-p' "/sdcard/$Name.png"
+    Invoke-Adb pull "/sdcard/$Name.png" (Join-Path $OutputDirectory "$Name.png") | Out-Null
+}
+function New-Settings(
+    [string]$ZoomMode = "fit-page", [int]$CustomZoom = 100, [string]$ScrollMode = "screen",
+    [string]$CropScheme = "none", [hashtable]$CropEven = @{}, [hashtable]$CropOdd = @{},
+    [double]$Contrast = 1.0, [int]$GrayPoint = 255, [string]$Dithering = "off"
+) {
+    [ordered]@{
+        fontFamily="Publisher"; fontSize=20; lineHeight=1.32; margin=32; textAlign="publisher"; theme="light"
+        publisherStyles=$true; hyphenation=$true; mode="paged"; brightness=-1; volumeKeys=$true
+        zoomMode=$ZoomMode; customZoom=$CustomZoom; scrollMode=$ScrollMode; cropScheme=$CropScheme
+        cropAny=@{}; cropEven=$CropEven; cropOdd=$CropOdd
+        contrastExponent=$Contrast; grayPoint=$GrayPoint; dithering=$Dithering
+    } | ConvertTo-Json -Depth 5 -Compress
+}
+function Set-State([string]$Settings, [int]$Page, [double]$ViewportX = 0, [double]$ViewportY = 0) {
+    $settingsFile = Join-Path $OutputDirectory "fixed-settings.json"
+    $locationFile = Join-Path $OutputDirectory "fixed-location.json"
+    Invoke-Adb shell am force-stop com.alexandria.reader
+    $utf8 = New-Object System.Text.UTF8Encoding($false)
+    [System.IO.File]::WriteAllText($settingsFile, $Settings, $utf8)
+    $progress = if ($Page -eq 0) { 0 } else { $Page / 3.0 }
+    $location = [ordered]@{page=$Page;totalPages=4;chapter=$Page;offset=0;progress=$progress;viewportX=$ViewportX;viewportY=$ViewportY} | ConvertTo-Json -Compress
+    [System.IO.File]::WriteAllText($locationFile, $location, $utf8)
+    Invoke-Adb push $settingsFile "$bookDirectory/settings.json" | Out-Null
+    Invoke-Adb push $locationFile "$bookDirectory/location.json" | Out-Null
+    Invoke-Adb shell chmod 666 "$bookDirectory/settings.json" "$bookDirectory/location.json"
+}
+function Open-Book([string]$Expected) {
+    Invoke-Adb shell am force-stop com.alexandria.reader
+    Invoke-Adb shell am start '-W' '-n' com.alexandria.reader/.MainActivity '-a' android.intent.action.MAIN '-c' android.intent.category.LAUNCHER | Out-Null
+    Wait-Library
+    Invoke-Adb shell input tap 500 210
+    Wait-Reader $Expected
+}
+
+Push-Location (Join-Path $PSScriptRoot "..")
+try {
+    & .\gradlew.bat :app:assembleDebug
+    if ($LASTEXITCODE -ne 0) { throw "Debug build failed." }
+    Invoke-Adb install '-r' ".\app\build\outputs\apk\debug\app-debug.apk" | Out-Null
+    Invoke-Adb shell pm clear com.alexandria.reader | Out-Null
+    Invoke-Adb shell am start '-W' '-n' com.alexandria.reader/.MainActivity '-a' android.intent.action.MAIN '-c' android.intent.category.LAUNCHER | Out-Null
+    Start-Sleep -Seconds 1
+    Invoke-Adb root | Out-Null
+    Invoke-Adb wait-for-device
+    Invoke-Adb push $Fixture /data/user/0/com.alexandria.reader/files/fixed-verification.epub | Out-Null
+    Invoke-Adb shell chmod 666 /data/user/0/com.alexandria.reader/files/fixed-verification.epub
+    Invoke-Adb logcat '-c'
+    Invoke-Adb shell am force-stop com.alexandria.reader
+    Invoke-Adb shell am start '-W' '-a' android.intent.action.VIEW '-d' file:///data/user/0/com.alexandria.reader/files/fixed-verification.epub '-t' application/epub+zip com.alexandria.reader/.MainActivity | Out-Null
+    Wait-Reader "Page 1 of 4"
+    Capture "fixed-fit-page"
+
+    Set-State (New-Settings -CropScheme "even-odd" -CropOdd @{left=9;top=8;right=9;bottom=8} -CropEven @{left=14;top=8;right=14;bottom=8}) 0
+    Open-Book "Page 1 of 4"
+    Capture "fixed-odd-cropped"
+    Invoke-Adb shell input tap 1357 1748
+    Wait-Reader "Page 2 of 4"
+    Capture "fixed-even-cropped"
+
+    Set-State (New-Settings -ZoomMode "custom" -CustomZoom 170 -CropScheme "even-odd" -CropOdd @{left=9;top=8;right=9;bottom=8} -CropEven @{left=14;top=8;right=14;bottom=8}) 1 250 250
+    Open-Book "Page 2 of 4"
+    Capture "fixed-custom-before-pan"
+    Invoke-Adb shell input swipe 1050 1400 430 520 1000
+    Start-Sleep -Seconds 2
+    $panned = (Invoke-Adb shell cat "$bookDirectory/location.json" | Out-String | ConvertFrom-Json)
+    if ($panned.viewportY -le 250) { throw "Custom pan did not update the persisted viewport." }
+    Capture "fixed-custom-after-pan"
+
+    Set-State (New-Settings -Contrast 2.5 -GrayPoint 150 -Dithering "g16") 2
+    Open-Book "Page 3 of 4"
+    Capture "fixed-graypoint-dither-g16"
+    Set-State (New-Settings -Contrast 2.5 -GrayPoint 150 -Dithering "g2") 2
+    Open-Book "Page 3 of 4"
+    Capture "fixed-graypoint-dither-g2"
+
+    Set-State (New-Settings -ZoomMode "fit-width" -ScrollMode "screen") 0
+    Open-Book "Page 1 of 4"
+    Capture "fixed-line-cut-before"
+    Invoke-Adb shell input tap 1282 1748
+    Start-Sleep -Seconds 2
+    $cut = (Invoke-Adb shell cat "$bookDirectory/location.json" | Out-String | ConvertFrom-Json)
+    if ($cut.viewportY -lt 1300 -or $cut.viewportY -gt 1400) { throw "The line-preserving cut was not selected." }
+    Capture "fixed-line-cut-after"
+
+    $fatal = & $adb -s $serial logcat -d -v brief | Select-String "FATAL EXCEPTION|Process: com.alexandria.reader|E/AlexandriaRenderer"
+    if ($fatal) { throw "Alexandria failed during fixed-page verification.`n$($fatal -join "`n")" }
+    Write-Host "Verified fixed-page tools. Screenshots are in $OutputDirectory"
+}
+finally {
+    Remove-Item -ErrorAction SilentlyContinue (Join-Path $OutputDirectory "fixed-settings.json"), (Join-Path $OutputDirectory "fixed-location.json"), (Join-Path $OutputDirectory "fixed-library-window.xml")
+    Pop-Location
+}
diff --git a/scripts/verify-running.ps1 b/scripts/verify-running.ps1
new file mode 100644 (file)
index 0000000..354b7de
--- /dev/null
@@ -0,0 +1,65 @@
+[CmdletBinding()]
+param(
+    [string]$Fixture,
+    [string]$OutputDirectory
+)
+
+$ErrorActionPreference = "Stop"
+if (-not $Fixture) { $Fixture = Join-Path $PSScriptRoot "..\tests\fixtures\odyssey.epub" }
+if (-not $OutputDirectory) { $OutputDirectory = Join-Path $PSScriptRoot "..\verification" }
+$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } elseif ($env:ANDROID_HOME) { $env:ANDROID_HOME } else { Join-Path $env:LOCALAPPDATA "Android\Sdk" }
+$adb = Join-Path $sdk "platform-tools\adb.exe"
+if (-not (Test-Path $adb)) { throw "adb was not found under $sdk" }
+if (-not (Test-Path $Fixture)) { throw "Fixture not found: $Fixture" }
+
+$device = (& $adb devices | Select-String "^emulator-[0-9]+\s+device$" | Select-Object -First 1)
+if (-not $device) { throw "Start the Android virtual device before running this script." }
+$serial = $device.ToString().Split("`t")[0]
+
+Push-Location (Join-Path $PSScriptRoot "..")
+try {
+    & .\gradlew.bat :app:assembleDebug
+    if ($LASTEXITCODE -ne 0) { throw "Debug build failed." }
+    & $adb -s $serial install -r ".\app\build\outputs\apk\debug\app-debug.apk" | Out-Null
+    & $adb -s $serial shell pm clear com.alexandria.reader | Out-Null
+
+    # Start once so Android creates the private application directories. The
+    # userdebug AVD then lets this test harness place the fixture there without
+    # granting Alexandria a shared-storage permission.
+    & $adb -s $serial shell am start -W -n com.alexandria.reader/.MainActivity -a android.intent.action.MAIN -c android.intent.category.LAUNCHER | Out-Null
+    Start-Sleep -Seconds 1
+    & $adb -s $serial root | Out-Null
+    & $adb -s $serial wait-for-device
+    $remote = "/data/user/0/com.alexandria.reader/files/verification.epub"
+    & $adb -s $serial push $Fixture $remote | Out-Null
+    & $adb -s $serial shell chmod 666 $remote
+    & $adb -s $serial logcat -c
+    & $adb -s $serial shell am force-stop com.alexandria.reader
+    & $adb -s $serial shell am start -W -a android.intent.action.VIEW -d "file://$remote" -t application/epub+zip com.alexandria.reader/.MainActivity | Out-Null
+
+    New-Item -ItemType Directory -Force $OutputDirectory | Out-Null
+    $dump = Join-Path $OutputDirectory "window.xml"
+    Remove-Item -ErrorAction SilentlyContinue $dump
+    & $adb -s $serial shell rm -f /sdcard/alexandria-window.xml | Out-Null
+    $ready = $false
+    for ($attempt = 0; $attempt -lt 60; $attempt++) {
+        Start-Sleep -Milliseconds 500
+        & $adb -s $serial shell uiautomator dump /sdcard/alexandria-window.xml | Out-Null
+        if ($LASTEXITCODE -ne 0) { continue }
+        & $adb -s $serial pull /sdcard/alexandria-window.xml $dump | Out-Null
+        if ($LASTEXITCODE -eq 0 -and (Get-Content $dump -Raw) -match "Page 1 of ([2-9]|[1-9][0-9]+)") { $ready = $true; break }
+    }
+    if (-not $ready) { throw "The fixture did not reach a rendered first page." }
+
+    & $adb -s $serial shell screencap -p /sdcard/alexandria-odyssey.png
+    $screenshot = Join-Path $OutputDirectory "odyssey-page-1.png"
+    & $adb -s $serial pull /sdcard/alexandria-odyssey.png $screenshot | Out-Null
+    if ((Get-Item $screenshot).Length -lt 100000) { throw "The verification screenshot is unexpectedly small." }
+
+    $fatal = & $adb -s $serial logcat -d -v brief | Select-String "FATAL EXCEPTION|Process: com.alexandria.reader"
+    if ($fatal) { throw "Alexandria crashed during verification.`n$($fatal -join "`n")" }
+    Write-Host "Verified EPUB rendering: $screenshot"
+}
+finally {
+    Pop-Location
+}
diff --git a/scripts/verify-search-history.ps1 b/scripts/verify-search-history.ps1
new file mode 100644 (file)
index 0000000..4cfb4a6
--- /dev/null
@@ -0,0 +1,117 @@
+[CmdletBinding()]
+param(
+    [string]$Fixture,
+    [string]$OutputDirectory
+)
+
+$ErrorActionPreference = "Stop"
+if (-not $Fixture) { $Fixture = Join-Path $PSScriptRoot "..\tests\fixtures\navigation.epub" }
+if (-not $OutputDirectory) { $OutputDirectory = Join-Path $PSScriptRoot "..\verification" }
+$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } elseif ($env:ANDROID_HOME) { $env:ANDROID_HOME } else { Join-Path $env:LOCALAPPDATA "Android\Sdk" }
+$adb = Join-Path $sdk "platform-tools\adb.exe"
+if (-not (Test-Path $adb)) { throw "adb was not found under $sdk" }
+if (-not (Test-Path $Fixture)) { throw "Fixture not found: $Fixture" }
+$device = (& $adb devices | Select-String "^emulator-[0-9]+\s+device$" | Select-Object -First 1)
+if (-not $device) { throw "Start the Android virtual device before running this script." }
+$serial = $device.ToString().Split("`t")[0]
+New-Item -ItemType Directory -Force $OutputDirectory | Out-Null
+
+function Invoke-Adb([Parameter(ValueFromRemainingArguments=$true)][string[]]$Arguments) {
+    & $adb -s $serial @Arguments
+    if ($LASTEXITCODE -ne 0) { throw "adb failed: $($Arguments -join ' ')" }
+}
+function Wait-State([string[]]$Patterns) {
+    $dump = Join-Path $OutputDirectory "navigation-window.xml"
+    Remove-Item -ErrorAction SilentlyContinue $dump
+    & $adb -s $serial shell rm -f /sdcard/alexandria-navigation-window.xml | Out-Null
+    for ($attempt = 0; $attempt -lt 60; $attempt++) {
+        Start-Sleep -Milliseconds 500
+        & $adb -s $serial shell uiautomator dump /sdcard/alexandria-navigation-window.xml | Out-Null
+        if ($LASTEXITCODE -ne 0) { continue }
+        & $adb -s $serial pull /sdcard/alexandria-navigation-window.xml $dump | Out-Null
+        if ($LASTEXITCODE -ne 0) { continue }
+        $content = Get-Content $dump -Raw
+        $matches = $true
+        foreach ($pattern in $Patterns) { if ($content -notmatch $pattern) { $matches = $false; break } }
+        if ($matches) { return }
+    }
+    throw "Reader state did not match: $($Patterns -join ', ')"
+}
+function Capture([string]$Name) {
+    Invoke-Adb shell screencap '-p' "/sdcard/$Name.png"
+    $local = Join-Path $OutputDirectory "$Name.png"
+    Invoke-Adb pull "/sdcard/$Name.png" $local | Out-Null
+    if ((Get-Item $local).Length -lt 50000) { throw "The $Name screenshot is unexpectedly small." }
+}
+function Tap([int]$X, [int]$Y) {
+    Invoke-Adb shell input tap $X $Y | Out-Null
+}
+
+Push-Location (Join-Path $PSScriptRoot "..")
+try {
+    & .\gradlew.bat :app:assembleDebug
+    if ($LASTEXITCODE -ne 0) { throw "Debug build failed." }
+    Invoke-Adb install '-r' ".\app\build\outputs\apk\debug\app-debug.apk" | Out-Null
+    Invoke-Adb shell pm clear com.alexandria.reader | Out-Null
+    Invoke-Adb shell am start '-W' '-n' com.alexandria.reader/.MainActivity '-a' android.intent.action.MAIN '-c' android.intent.category.LAUNCHER | Out-Null
+    Start-Sleep -Seconds 1
+    Invoke-Adb root | Out-Null
+    Invoke-Adb wait-for-device
+    $remote = "/data/user/0/com.alexandria.reader/files/navigation-verification.epub"
+    Invoke-Adb push $Fixture $remote | Out-Null
+    Invoke-Adb shell chmod 666 $remote
+    Invoke-Adb logcat '-c'
+    Invoke-Adb shell am force-stop com.alexandria.reader
+    Invoke-Adb shell am start '-W' '-a' android.intent.action.VIEW '-d' "file://$remote" '-t' application/epub+zip com.alexandria.reader/.MainActivity | Out-Null
+    Wait-State "Page 1 of 4"
+
+    # The publisher link jumps from page one to page three. The north-west
+    # history region must return to the exact origin.
+    Tap 330 550
+    Wait-State "Page 3 of 4"
+    Capture "internal-link-jump"
+    Tap 200 350
+    Wait-State "Page 1 of 4"
+    Capture "internal-link-history-return"
+
+    # Start on page two. Forward search must choose the second of three
+    # matches, which is on page three.
+    Tap 1284 1749
+    Wait-State "Page 2 of 4"
+    Tap 1218 75
+    Wait-State "Search forward from the current location"
+    Invoke-Adb shell input text waypoint | Out-Null
+    Invoke-Adb shell input keyevent 66 | Out-Null
+    Wait-State @("2 / 3", "Page 3 of 4")
+    Capture "search-forward-from-current"
+
+    # A next-result jump must also enter history. Two history returns must
+    # unwind page four to page three and then to the page-two search origin.
+    Tap 1303 153
+    Wait-State @("3 / 3", "Page 4 of 4")
+    Tap 200 350
+    Wait-State "Page 3 of 4"
+    Tap 200 350
+    Wait-State "Page 2 of 4"
+
+    # Select backward direction and repeat the query from page two. The first
+    # match before the origin is result one on page one.
+    Tap 1365 153
+    Tap 1218 75
+    Wait-State "Search forward from the current location"
+    Tap 43 153
+    Wait-State "Search backward from the current location"
+    Invoke-Adb shell input text waypoint | Out-Null
+    Invoke-Adb shell input keyevent 66 | Out-Null
+    Wait-State @("1 / 3", "Page 1 of 4")
+    Capture "search-backward-from-current"
+    Tap 200 350
+    Wait-State "Page 2 of 4"
+
+    $fatal = & $adb -s $serial logcat -d -v brief | Select-String "FATAL EXCEPTION|Process: com.alexandria.reader|E/AlexandriaRenderer"
+    if ($fatal) { throw "Alexandria failed during search/history verification.`n$($fatal -join "`n")" }
+    Write-Host "Verified directional search and location history. Screenshots are in $OutputDirectory"
+}
+finally {
+    Pop-Location
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644 (file)
index 0000000..c2a6b9c
--- /dev/null
@@ -0,0 +1,4 @@
+pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
+dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories { google(); mavenCentral() } }
+rootProject.name = "alexandria"
+include(":app")
diff --git a/tests/fixtures/fixed-layout-src/META-INF/container.xml b/tests/fixtures/fixed-layout-src/META-INF/container.xml
new file mode 100644 (file)
index 0000000..0cd010d
--- /dev/null
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
+  <rootfiles><rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml"/></rootfiles>
+</container>
diff --git a/tests/fixtures/fixed-layout-src/OEBPS/fixed.css b/tests/fixtures/fixed-layout-src/OEBPS/fixed.css
new file mode 100644 (file)
index 0000000..6999a1d
--- /dev/null
@@ -0,0 +1,11 @@
+html, body { width:1200px; height:1600px; margin:0; padding:0; overflow:hidden; background:#b8b8b8; color:#111; font-family:serif; }
+.trim { position:absolute; left:110px; top:130px; width:980px; height:1320px; box-sizing:border-box; border:8px solid #202020; background:#fff; padding:72px 82px; }
+h1 { margin:0 0 70px; font:700 64px sans-serif; text-align:center; letter-spacing:3px; }
+p { margin:0 0 28px; font-size:38px; line-height:1.42; }
+.rule { height:12px; margin:38px 0; background:#111; }
+.swatches { display:flex; height:150px; margin-top:50px; border:2px solid #111; }
+.swatches span { flex:1; }
+.footer { position:absolute; left:0; right:0; bottom:42px; text-align:center; font:32px sans-serif; }
+.cut-test { position:absolute; left:260px; top:1325px; margin:0; font:700 38px/1.42 sans-serif; white-space:nowrap; }
+.crop-label { position:absolute; color:#333; font:28px sans-serif; }
+.north { left:480px; top:35px; }.south { left:470px; bottom:40px; }.west { left:16px; top:760px; transform:rotate(-90deg); }.east { right:15px; top:760px; transform:rotate(90deg); }
diff --git a/tests/fixtures/fixed-layout-src/OEBPS/nav.xhtml b/tests/fixtures/fixed-layout-src/OEBPS/nav.xhtml
new file mode 100644 (file)
index 0000000..53ed9bf
--- /dev/null
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><head><title>Contents</title></head><body><nav epub:type="toc"><ol><li><a href="page1.xhtml">Odd page one</a></li><li><a href="page2.xhtml">Even page two</a></li><li><a href="page3.xhtml">Odd page three</a></li><li><a href="page4.xhtml">Even page four</a></li></ol></nav></body></html>
diff --git a/tests/fixtures/fixed-layout-src/OEBPS/package.opf b/tests/fixtures/fixed-layout-src/OEBPS/package.opf
new file mode 100644 (file)
index 0000000..b728fea
--- /dev/null
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid" prefix="rendition: http://www.idpf.org/vocab/rendition/#">
+  <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
+    <dc:identifier id="uid">urn:alexandria:fixed-tools-fixture</dc:identifier>
+    <dc:title>Fixed Page Tools Verification</dc:title>
+    <dc:creator>Alexandria Tests</dc:creator>
+    <dc:language>en</dc:language>
+    <meta property="rendition:layout">pre-paginated</meta>
+    <meta property="rendition:orientation">auto</meta>
+    <meta property="rendition:spread">none</meta>
+  </metadata>
+  <manifest>
+    <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
+    <item id="css" href="fixed.css" media-type="text/css"/>
+    <item id="p1" href="page1.xhtml" media-type="application/xhtml+xml"/>
+    <item id="p2" href="page2.xhtml" media-type="application/xhtml+xml"/>
+    <item id="p3" href="page3.xhtml" media-type="application/xhtml+xml"/>
+    <item id="p4" href="page4.xhtml" media-type="application/xhtml+xml"/>
+  </manifest>
+  <spine page-progression-direction="ltr">
+    <itemref idref="p1"/><itemref idref="p2"/><itemref idref="p3"/><itemref idref="p4"/>
+  </spine>
+</package>
diff --git a/tests/fixtures/fixed-layout-src/OEBPS/page1.xhtml b/tests/fixtures/fixed-layout-src/OEBPS/page1.xhtml
new file mode 100644 (file)
index 0000000..ee7435b
--- /dev/null
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Odd page one</title><meta name="viewport" content="width=1200,height=1600"/><link rel="stylesheet" href="fixed.css"/></head><body><span class="crop-label north">TOP CROP AREA</span><span class="crop-label south">BOTTOM CROP AREA</span><span class="crop-label west">LEFT CROP</span><span class="crop-label east">RIGHT CROP</span><main class="trim"><h1>FIXED PAGE 1 · ODD</h1><p>This fixed-layout page has a 1200 by 1600 pixel viewport.</p><p>Fit-to-width cuts must stop between these lines instead of cutting through letter shapes.</p><p>The next line is deliberately close to a screen boundary for cut verification.</p><div class="rule"></div><div class="swatches"><span style="background:#000"></span><span style="background:#333"></span><span style="background:#666"></span><span style="background:#999"></span><span style="background:#ccc"></span><span style="background:#fff"></span></div><div class="footer">1</div></main><p class="cut-test">LINE MUST STAY WHOLE AT THE CUT</p></body></html>
diff --git a/tests/fixtures/fixed-layout-src/OEBPS/page2.xhtml b/tests/fixtures/fixed-layout-src/OEBPS/page2.xhtml
new file mode 100644 (file)
index 0000000..7d32a82
--- /dev/null
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Even page two</title><meta name="viewport" content="width=1200,height=1600"/><link rel="stylesheet" href="fixed.css"/></head><body><span class="crop-label north">EVEN TOP MARGIN</span><span class="crop-label south">EVEN BOTTOM MARGIN</span><span class="crop-label west">EVEN LEFT</span><span class="crop-label east">EVEN RIGHT</span><main class="trim" style="left:150px;width:900px"><h1>FIXED PAGE 2 · EVEN</h1><p>This page has visibly different side margins.</p><p>The even-and-odd crop scheme must retain a separate rectangle for this page.</p><p>Custom zoom and pan must remain bounded by the cropped page.</p><div class="rule"></div><div class="swatches"><span style="background:#101010"></span><span style="background:#484848"></span><span style="background:#808080"></span><span style="background:#b8b8b8"></span><span style="background:#f0f0f0"></span></div><div class="footer">2</div></main></body></html>
diff --git a/tests/fixtures/fixed-layout-src/OEBPS/page3.xhtml b/tests/fixtures/fixed-layout-src/OEBPS/page3.xhtml
new file mode 100644 (file)
index 0000000..bbcb5ff
--- /dev/null
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Odd page three</title><meta name="viewport" content="width=1200,height=1600"/><link rel="stylesheet" href="fixed.css"/></head><body><span class="crop-label north">TOP CROP AREA</span><span class="crop-label south">BOTTOM CROP AREA</span><span class="crop-label west">LEFT CROP</span><span class="crop-label east">RIGHT CROP</span><main class="trim"><h1>FIXED PAGE 3 · ODD</h1><p>Gray-point adjustment changes the pivot used by the contrast curve.</p><p>Sixteen-level dithering preserves intermediate shades on grayscale displays.</p><p>Black-and-white dithering produces a two-level halftone.</p><div class="rule"></div><div class="swatches"><span style="background:#181818"></span><span style="background:#505050"></span><span style="background:#888"></span><span style="background:#c0c0c0"></span><span style="background:#f8f8f8"></span></div><div class="footer">3</div></main></body></html>
diff --git a/tests/fixtures/fixed-layout-src/OEBPS/page4.xhtml b/tests/fixtures/fixed-layout-src/OEBPS/page4.xhtml
new file mode 100644 (file)
index 0000000..2013019
--- /dev/null
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Even page four</title><meta name="viewport" content="width=1200,height=1600"/><link rel="stylesheet" href="fixed.css"/></head><body><span class="crop-label north">EVEN TOP MARGIN</span><span class="crop-label south">EVEN BOTTOM MARGIN</span><span class="crop-label west">EVEN LEFT</span><span class="crop-label east">EVEN RIGHT</span><main class="trim" style="left:150px;width:900px"><h1>FIXED PAGE 4 · EVEN</h1><p>The final page verifies page-boundary navigation after vertical slices.</p><p>Previous returns to the bottom slice of the prior fixed page.</p><p>All display settings and viewport coordinates persist after restart.</p><div class="rule"></div><div class="swatches"><span style="background:#000"></span><span style="background:#404040"></span><span style="background:#808080"></span><span style="background:#c0c0c0"></span><span style="background:#fff"></span></div><div class="footer">4</div></main></body></html>
diff --git a/tests/fixtures/fixed-layout-src/mimetype b/tests/fixtures/fixed-layout-src/mimetype
new file mode 100644 (file)
index 0000000..57ef03f
--- /dev/null
@@ -0,0 +1 @@
+application/epub+zip
\ No newline at end of file
diff --git a/tests/fixtures/fixed-layout.epub b/tests/fixtures/fixed-layout.epub
new file mode 100644 (file)
index 0000000..53f5059
Binary files /dev/null and b/tests/fixtures/fixed-layout.epub differ
diff --git a/tests/fixtures/navigation-src/META-INF/container.xml b/tests/fixtures/navigation-src/META-INF/container.xml
new file mode 100644 (file)
index 0000000..fe5cbeb
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
+  <rootfiles>
+    <rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml"/>
+  </rootfiles>
+</container>
diff --git a/tests/fixtures/navigation-src/OEBPS/chapter1.xhtml b/tests/fixtures/navigation-src/OEBPS/chapter1.xhtml
new file mode 100644 (file)
index 0000000..e883e6c
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+<head><title>Origin</title><link rel="stylesheet" type="text/css" href="style.css"/></head>
+<body>
+<h1>Origin</h1>
+<p>The first waypoint is on this page. This result is before the test's current location.</p>
+<p><a id="jump-link" href="chapter3.xhtml#destination">Jump to the linked destination</a></p>
+</body>
+</html>
diff --git a/tests/fixtures/navigation-src/OEBPS/chapter2.xhtml b/tests/fixtures/navigation-src/OEBPS/chapter2.xhtml
new file mode 100644 (file)
index 0000000..97cac4b
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+<head><title>Current location</title><link rel="stylesheet" type="text/css" href="style.css"/></head>
+<body>
+<h1>Current location</h1>
+<p>Directional searches start here. The preceding result is in the first chapter. Two later results are in the third and fourth chapters.</p>
+<p>This chapter intentionally does not contain the search term.</p>
+</body>
+</html>
diff --git a/tests/fixtures/navigation-src/OEBPS/chapter3.xhtml b/tests/fixtures/navigation-src/OEBPS/chapter3.xhtml
new file mode 100644 (file)
index 0000000..83ecba8
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+<head><title>Linked destination</title><link rel="stylesheet" type="text/css" href="style.css"/></head>
+<body>
+<h1 id="destination">Linked destination</h1>
+<p>The second waypoint is here. A forward search from the second chapter must select this result.</p>
+<p>The internal link also targets this location.</p>
+</body>
+</html>
diff --git a/tests/fixtures/navigation-src/OEBPS/chapter4.xhtml b/tests/fixtures/navigation-src/OEBPS/chapter4.xhtml
new file mode 100644 (file)
index 0000000..e401d8d
--- /dev/null
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+<head><title>Final result</title><link rel="stylesheet" type="text/css" href="style.css"/></head>
+<body>
+<h1>Final result</h1>
+<p>The third waypoint is on the final page. Moving to the next result from chapter three must record chapter three in location history.</p>
+</body>
+</html>
diff --git a/tests/fixtures/navigation-src/OEBPS/nav.xhtml b/tests/fixtures/navigation-src/OEBPS/nav.xhtml
new file mode 100644 (file)
index 0000000..56be138
--- /dev/null
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
+<head><title>Contents</title></head>
+<body>
+<nav epub:type="toc"><ol>
+  <li><a href="chapter1.xhtml">Origin</a></li>
+  <li><a href="chapter2.xhtml">Current location</a></li>
+  <li><a href="chapter3.xhtml#destination">Linked destination</a></li>
+  <li><a href="chapter4.xhtml">Final result</a></li>
+</ol></nav>
+</body>
+</html>
diff --git a/tests/fixtures/navigation-src/OEBPS/package.opf b/tests/fixtures/navigation-src/OEBPS/package.opf
new file mode 100644 (file)
index 0000000..2968af3
--- /dev/null
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<package version="3.0" unique-identifier="book-id" xmlns="http://www.idpf.org/2007/opf">
+  <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
+    <dc:identifier id="book-id">urn:uuid:alexandria-navigation-verification</dc:identifier>
+    <dc:title>Navigation and Search Verification</dc:title>
+    <dc:creator>Alexandria Tests</dc:creator>
+    <dc:language>en</dc:language>
+    <meta property="dcterms:modified">2026-08-10T00:00:00Z</meta>
+  </metadata>
+  <manifest>
+    <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
+    <item id="css" href="style.css" media-type="text/css"/>
+    <item id="c1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
+    <item id="c2" href="chapter2.xhtml" media-type="application/xhtml+xml"/>
+    <item id="c3" href="chapter3.xhtml" media-type="application/xhtml+xml"/>
+    <item id="c4" href="chapter4.xhtml" media-type="application/xhtml+xml"/>
+  </manifest>
+  <spine>
+    <itemref idref="c1"/>
+    <itemref idref="c2"/>
+    <itemref idref="c3"/>
+    <itemref idref="c4"/>
+  </spine>
+</package>
diff --git a/tests/fixtures/navigation-src/OEBPS/style.css b/tests/fixtures/navigation-src/OEBPS/style.css
new file mode 100644 (file)
index 0000000..64cb785
--- /dev/null
@@ -0,0 +1,5 @@
+html, body { margin: 0; padding: 0; }
+body { font-family: serif; font-size: 24px; line-height: 1.45; }
+h1 { font-size: 2em; margin: 0 0 1em; }
+p { margin: 1em 0; }
+a { display: inline-block; margin-top: 2em; padding: 1em; border: 3px solid currentColor; font-weight: bold; }
diff --git a/tests/fixtures/navigation-src/mimetype b/tests/fixtures/navigation-src/mimetype
new file mode 100644 (file)
index 0000000..57ef03f
--- /dev/null
@@ -0,0 +1 @@
+application/epub+zip
\ No newline at end of file
diff --git a/tests/fixtures/navigation.epub b/tests/fixtures/navigation.epub
new file mode 100644 (file)
index 0000000..cf88dd9
Binary files /dev/null and b/tests/fixtures/navigation.epub differ