]> git.otsuka.systems Git - alexandria/commitdiff
performance refactor
authorCameron Otsuka <cameron@otsuka.haus>
Tue, 11 Aug 2026 23:11:13 +0000 (16:11 -0700)
committerCameron Otsuka <cameron@otsuka.haus>
Tue, 11 Aug 2026 23:11:13 +0000 (16:11 -0700)
25 files changed:
README.md
app/build.gradle.kts
app/src/androidTest/java/com/alexandria/reader/LibraryRepositoryInstrumentedTest.kt [new file with mode: 0644]
app/src/androidTest/java/com/alexandria/reader/ReaderWebViewTest.kt [new file with mode: 0644]
app/src/main/AndroidManifest.xml
app/src/main/java/com/alexandria/reader/EpubHtmlBuilder.kt [new file with mode: 0644]
app/src/main/java/com/alexandria/reader/EpubParser.kt
app/src/main/java/com/alexandria/reader/LibraryRepository.kt
app/src/main/java/com/alexandria/reader/LibraryScreen.kt
app/src/main/java/com/alexandria/reader/MainActivity.kt
app/src/main/java/com/alexandria/reader/Models.kt
app/src/main/java/com/alexandria/reader/Persistence.kt [new file with mode: 0644]
app/src/main/java/com/alexandria/reader/ReaderScreen.kt
app/src/main/java/com/alexandria/reader/ReaderScripts.kt [new file with mode: 0644]
app/src/main/java/com/alexandria/reader/ReaderWebView.kt
app/src/main/java/com/alexandria/reader/WorkCancellation.kt [new file with mode: 0644]
app/src/main/res/xml/backup_rules.xml [new file with mode: 0644]
app/src/test/java/com/alexandria/reader/EpubParserTest.kt [new file with mode: 0644]
app/src/test/java/com/alexandria/reader/LibraryRepositoryTest.kt [new file with mode: 0644]
app/src/test/java/com/alexandria/reader/ModelsTest.kt [new file with mode: 0644]
app/src/test/java/com/alexandria/reader/PersistenceTest.kt [new file with mode: 0644]
scripts/profile-reader.ps1 [new file with mode: 0644]
scripts/verify-fixed-layout.ps1
scripts/verify-running.ps1
scripts/verify-search-history.ps1

index a71851c6ca05336eb773097053de144f4ecdf33d..4bce1a08c855650f8e40e76095c3a53a6d57ec70 100644 (file)
--- a/README.md
+++ b/README.md
@@ -37,6 +37,18 @@ The app requests no network or shared-storage permission. It imports each select
 
 The APK is written to `app/build/outputs/apk/debug/app-debug.apk`.
 
+Run parser, renderer, repository, persistence, and model tests plus Android lint with:
+
+```powershell
+.\gradlew.bat :app:testDebugUnitTest :app:lintDebug
+```
+
+With an Android virtual device running, execute the WebView lifecycle and main-thread I/O tests with:
+
+```powershell
+.\gradlew.bat :app:connectedDebugAndroidTest
+```
+
 ## End-to-end verification
 
 Start the supplied Android virtual device, then run:
@@ -62,6 +74,14 @@ Run the focused reader tests separately:
 
 The fixed-layout test verifies automatic page fitting, image dithering, navigation, and position restoration. 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/`.
 
+Measure cold import, cached opening, reader-cache reuse, and process memory with:
+
+```powershell
+.\scripts\profile-reader.ps1 -Fixture .\tests\fixtures\odyssey.epub
+```
+
+The profile is written to `verification/reader-profile.json`. Supply a larger standards-compliant EPUB with `-Fixture` to test worst-case layout behavior.
+
 To start a clean AVD before manual testing, use:
 
 ```powershell
index a50eee2a3b146ca07ad20e935cec86376e9cff19..e7e7bec737f5161be8c4710079814a6ffc54c53b 100644 (file)
@@ -10,12 +10,26 @@ android {
         targetSdk = 35
         versionCode = 1
         versionName = "1.0"
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
     }
+
+    sourceSets.getByName("test").resources.srcDir(rootProject.file("tests/fixtures"))
+    sourceSets.getByName("androidTest").assets.srcDir(rootProject.file("tests/fixtures"))
+    testOptions.unitTests.isIncludeAndroidResources = true
 }
 
 dependencies {
     implementation("androidx.activity:activity-ktx:1.10.1")
     implementation("org.jsoup:jsoup:1.18.3")
+
+    testImplementation("junit:junit:4.13.2")
+    testImplementation("org.json:json:20240303")
+    testImplementation("androidx.test:core-ktx:1.7.0")
+    testImplementation("org.robolectric:robolectric:4.14.1")
+
+    androidTestImplementation("androidx.test:core-ktx:1.7.0")
+    androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0")
+    androidTestImplementation("androidx.test:runner:1.7.0")
 }
 
 kotlin { jvmToolchain(17) }
diff --git a/app/src/androidTest/java/com/alexandria/reader/LibraryRepositoryInstrumentedTest.kt b/app/src/androidTest/java/com/alexandria/reader/LibraryRepositoryInstrumentedTest.kt
new file mode 100644 (file)
index 0000000..c8a4755
--- /dev/null
@@ -0,0 +1,62 @@
+package com.alexandria.reader
+
+import android.content.Context
+import android.net.Uri
+import android.os.StrictMode
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import java.util.Collections
+import java.util.concurrent.Executors
+
+@RunWith(AndroidJUnit4::class)
+class LibraryRepositoryInstrumentedTest {
+    @Test
+    fun readerMutationsDoNotWriteFilesOnTheMainThread() {
+        val context = ApplicationProvider.getApplicationContext<Context>()
+        File(context.filesDir, "library").deleteRecursively()
+        val source = File(context.cacheDir, "threading-test.epub")
+        InstrumentationRegistry.getInstrumentation().context.assets.open("navigation.epub").use { input ->
+            source.outputStream().use(input::copyTo)
+        }
+        val repository = LibraryRepository(context)
+        val publication = repository.import(Uri.fromFile(source))
+        val violations = Collections.synchronizedList(mutableListOf<String>())
+        val listenerExecutor = Executors.newSingleThreadExecutor()
+        try {
+            InstrumentationRegistry.getInstrumentation().runOnMainSync {
+                val previous = StrictMode.getThreadPolicy()
+                StrictMode.setThreadPolicy(
+                    StrictMode.ThreadPolicy.Builder(previous)
+                        .detectDiskWrites()
+                        .penaltyListener(listenerExecutor) { violation -> violations += violation.toString() }
+                        .build(),
+                )
+                try {
+                    val id = publication.book.id
+                    repository.markOpened(id)
+                    repository.updateProgress(id, ReaderLocation(1, 4, 1, 20, .25f))
+                    repository.saveSettings(id, ReaderSettings(fontSize = 24))
+                    repository.saveBookmarks(id, listOf(Bookmark("Page", 1, 20, 1, .25f, 1L)))
+                    repository.saveAnnotations(id, listOf(Annotation("note", 1, 0, 4, "Text", "")))
+                    repository.setState(id, LibraryBook.ReadingState.READING)
+                    repository.rename(id, "Thread-safe title")
+                } finally {
+                    StrictMode.setThreadPolicy(previous)
+                }
+            }
+            repository.awaitPendingWrites()
+            listenerExecutor.shutdown()
+            while (!listenerExecutor.isTerminated) Thread.sleep(10)
+            assertTrue("Main-thread disk writes: $violations", violations.isEmpty())
+        } finally {
+            listenerExecutor.shutdownNow()
+            repository.close()
+            source.delete()
+        }
+    }
+}
diff --git a/app/src/androidTest/java/com/alexandria/reader/ReaderWebViewTest.kt b/app/src/androidTest/java/com/alexandria/reader/ReaderWebViewTest.kt
new file mode 100644 (file)
index 0000000..52a465d
--- /dev/null
@@ -0,0 +1,155 @@
+package com.alexandria.reader
+
+import android.content.Context
+import androidx.test.core.app.ActivityScenario
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicReference
+
+@RunWith(AndroidJUnit4::class)
+class ReaderWebViewTest {
+    @Test
+    fun restoresFixedLayoutLocationAndReportsSubsequentNavigation() {
+        val prepared = prepareFixedPublication("restore")
+        val ready = CountDownLatch(1)
+        val layoutReady = CountDownLatch(1)
+        val restored = CountDownLatch(1)
+        val advanced = CountDownLatch(1)
+        val webView = AtomicReference<ReaderWebView>()
+        val latest = AtomicReference(ReaderLocation())
+
+        ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+            scenario.onActivity { activity ->
+                val reader = ReaderWebView(activity)
+                webView.set(reader)
+                reader.listener = listener(
+                    ready = {
+                        ready.countDown()
+                        reader.applySettings(
+                            ReaderSettings(),
+                            ReaderLocation(page = 2, totalPages = 4, chapter = 2, progress = 2f / 3f),
+                        )
+                    },
+                    layoutReady = layoutReady::countDown,
+                    location = { value ->
+                        latest.set(value)
+                        if (value.page == 2 && value.chapter == 2) restored.countDown()
+                        if (value.page == 3 && value.chapter == 3) advanced.countDown()
+                    },
+                )
+                activity.setContentView(reader)
+                reader.load(prepared.reader, prepared.publication.rootDirectory)
+            }
+
+            assertTrue("Reader bridge did not become ready", ready.await(10, TimeUnit.SECONDS))
+            assertTrue("Fixed layout did not report completion", layoutReady.await(10, TimeUnit.SECONDS))
+            assertTrue("Saved fixed-layout location was not restored", restored.await(10, TimeUnit.SECONDS))
+            scenario.onActivity { webView.get().nextPage() }
+            assertTrue("Page navigation was not reported", advanced.await(10, TimeUnit.SECONDS))
+            assertEquals(3, latest.get().page)
+        }
+    }
+
+    @Test
+    fun clampsPrimitiveBridgeValuesAndDropsCallbacksAfterDestroy() {
+        val prepared = prepareFixedPublication("bridge")
+        val ready = CountDownLatch(1)
+        val invalidValue = CountDownLatch(1)
+        val callbackCount = AtomicInteger()
+        val received = AtomicReference<ReaderLocation>()
+        val webView = AtomicReference<ReaderWebView>()
+
+        ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+            scenario.onActivity { activity ->
+                val reader = ReaderWebView(activity)
+                webView.set(reader)
+                reader.listener = listener(
+                    ready = ready::countDown,
+                    location = { value ->
+                        callbackCount.incrementAndGet()
+                        if (value.totalPages == 1) {
+                            received.set(value)
+                            invalidValue.countDown()
+                        }
+                    },
+                )
+                activity.setContentView(reader)
+                reader.load(prepared.reader, prepared.publication.rootDirectory)
+            }
+            assertTrue("Reader bridge did not become ready", ready.await(10, TimeUnit.SECONDS))
+            scenario.onActivity {
+                webView.get().evaluateJavascript("Android.onPageChanged(-5,0,-2,-9,2)", null)
+            }
+            assertTrue("Primitive bridge callback was not delivered", invalidValue.await(10, TimeUnit.SECONDS))
+            assertEquals(ReaderLocation(progress = 1f), received.get())
+
+            val beforeDestroy = callbackCount.get()
+            scenario.onActivity {
+                val reader = webView.get()
+                reader.evaluateJavascript(
+                    "setTimeout(function(){Android.onPageChanged(1,4,1,0,0.33);},250)",
+                    null,
+                )
+                reader.destroy()
+            }
+            Thread.sleep(500)
+            assertEquals("A destroyed WebView delivered a stale callback", beforeDestroy, callbackCount.get())
+        }
+    }
+
+    private data class Prepared(val publication: EpubPublication, val reader: File)
+
+    private fun prepareFixedPublication(name: String): Prepared {
+        val context = ApplicationProvider.getApplicationContext<Context>()
+        val directory = File(context.cacheDir, "reader-webview-$name").apply {
+            deleteRecursively()
+            mkdirs()
+        }
+        val source = File(directory, "book.epub")
+        InstrumentationRegistry.getInstrumentation().context.assets.open("fixed-layout.epub").use { input ->
+            source.outputStream().use(input::copyTo)
+        }
+        val publication = EpubParser.parse(
+            source,
+            File(directory, "content"),
+            LibraryBook(
+                id = "0123456789abcdef01234567",
+                title = "WebView test",
+                author = "Alexandria",
+                fileName = "fixed-layout.epub",
+            ),
+        )
+        return Prepared(publication, EpubHtmlBuilder.write(publication, File(directory, "reader.html")))
+    }
+
+    private fun listener(
+        ready: () -> Unit = {},
+        layoutReady: () -> Unit = {},
+        location: (ReaderLocation) -> Unit = {},
+    ): ReaderWebView.Listener {
+        val onLocation = location
+        return object : ReaderWebView.Listener {
+        override fun onReady() = ready()
+        override fun onLayoutReady() = layoutReady()
+        override fun onLocationChanged(location: ReaderLocation) = onLocation(location)
+        override fun onTap(x: Float, y: Float) = Unit
+        override fun onSwipe(forward: Boolean) = Unit
+        override fun onExternalLink(url: String) = Unit
+        override fun onNavigationJump(origin: ReaderLocation) = Unit
+        override fun onSearchResults(count: Int, currentIndex: Int) = Unit
+        override fun onAnnotationTapped(id: String) = Unit
+        override fun onSelectionAction(action: ReaderWebView.SelectionAction, selection: JSONObject?) = Unit
+        override fun onRenderError(message: String) = Unit
+        }
+    }
+}
index f010ca39360a2aef1369253778be7d31ce2520e8..6bb7051d3e25a6583ee8bc1fdbcdd2d86d416d91 100644 (file)
@@ -12,6 +12,7 @@
         android:icon="@drawable/ic_launcher"
         android:roundIcon="@drawable/ic_launcher"
         android:allowBackup="false"
+        android:fullBackupContent="@xml/backup_rules"
         android:dataExtractionRules="@xml/data_extraction_rules"
         android:usesCleartextTraffic="false"
         android:hardwareAccelerated="true"
diff --git a/app/src/main/java/com/alexandria/reader/EpubHtmlBuilder.kt b/app/src/main/java/com/alexandria/reader/EpubHtmlBuilder.kt
new file mode 100644 (file)
index 0000000..5471126
--- /dev/null
@@ -0,0 +1,816 @@
+package com.alexandria.reader
+
+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.util.Locale
+import kotlin.math.roundToInt
+
+/** Builds one secure, paginated HTML document from the complete EPUB spine. */
+internal object EpubHtmlBuilder {
+    internal const val FORMAT_VERSION = "2"
+
+    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 idTargets: MutableMap<String, String> = linkedMapOf(),
+    )
+
+    fun write(publication: EpubPublication, output: File): File {
+        WorkCancellation.check()
+        val chapters = publication.spine.mapIndexed { index, item ->
+            WorkCancellation.check()
+            val file = EpubParser.safeFile(publication.rootDirectory, item.href)
+            require(file.isFile) { "An EPUB spine document is missing." }
+            val document = file.inputStream().use { source ->
+                WorkCancellation.input(source).use { stream ->
+                    Jsoup.parse(stream, 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 = if (publication.fixedLayout) {
+                fixedViewport(document, body, publication.defaultPageWidth, publication.defaultPageHeight)
+            } else {
+                0 to 0
+            }
+            ChapterDocument(
+                index, item, document, body, item.href.substringBeforeLast('/', ""), viewport.first, viewport.second,
+            )
+        }.let(::ArrayDeque)
+
+        // All source documents share one browser document. Namespace their IDs
+        // and rewrite every same-document reference so repeated publisher and
+        // SVG IDs cannot resolve into another spine item.
+        val targetIds = linkedMapOf<String, String>()
+        chapters.forEach { chapter ->
+            WorkCancellation.check()
+            val chapterTarget = "alex-chapter-${chapter.index}"
+            val seenIds = mutableSetOf<String>()
+            var generatedIndex = 0
+            targetIds[chapter.item.href] = chapterTarget
+
+            fun register(element: Element, root: Boolean) {
+                val ids = sourceIds(element)
+                if (ids.isEmpty()) return
+                ids.forEach { id ->
+                    require(seenIds.add(id)) { "An EPUB spine document contains duplicate IDs: $id" }
+                }
+                val generated = if (root) chapterTarget else "alex-${chapter.index}-${generatedIndex++}"
+                ids.forEach { id ->
+                    chapter.idTargets[id] = generated
+                    targetIds["${chapter.item.href}#$id"] = generated
+                }
+                if (!root) {
+                    element.removeAttr("id")
+                    element.removeAttr("xml:id")
+                    element.attr("id", generated)
+                }
+            }
+
+            if (chapter.body !== chapter.document) {
+                chapter.document.getAllElements().firstOrNull { elementName(it) == "html" }
+                    ?.let { register(it, true) }
+            }
+            chapter.body.getAllElements().forEach { element ->
+                WorkCancellation.check()
+                register(element, element === chapter.body || elementName(element) in ROOT_ELEMENTS)
+            }
+        }
+        publication.toc.forEach { entry ->
+            require(entry.href in targetIds) { "The EPUB table of contents has a missing target: ${entry.href}" }
+        }
+
+        // Preserve each source document's stylesheet order and isolate all
+        // selectors to the section that linked or declared them.
+        val publisherCss = buildString {
+            chapters.forEach { chapter ->
+                WorkCancellation.check()
+                chapter.document.getAllElements().forEach { element ->
+                    WorkCancellation.check()
+                    when (elementName(element)) {
+                        "link" -> if (isStylesheet(element)) {
+                            val href = element.attr("href")
+                            if (href.isNotBlank() && !isExternal(href)) {
+                                val path = EpubParser.normalizePath(chapter.directory, href.substringBefore('#'))
+                                val css = loadStylesheet(path, publication.rootDirectory, mutableSetOf())
+                                if (css.isNotBlank()) {
+                                    append("\n/* ").append(path.replace("*/", "")).append(" · spine ")
+                                        .append(chapter.index).append(" */\n")
+                                    append(scopeCss(css, chapter.index, chapter.idTargets))
+                                }
+                            }
+                        }
+                        "style" -> {
+                            val css = element.data().ifBlank { element.wholeText() }
+                            val expanded = expandCssImports(
+                                css, chapter.directory, publication.rootDirectory, mutableSetOf(),
+                            )
+                            append('\n').append(
+                                scopeCss(
+                                    rewriteCssUrls(expanded, chapter.directory, publication.rootDirectory),
+                                    chapter.index,
+                                    chapter.idTargets,
+                                ),
+                            )
+                        }
+                    }
+                }
+            }
+        }
+
+        val references = JSONObject()
+        publication.toc.forEach { entry -> references.put(entry.href, targetIds.getValue(entry.href)) }
+        output.parentFile?.mkdirs()
+        writeHtml(output, publication, publisherCss, chapters, targetIds, references.toString())
+        return output
+    }
+
+    /** Writes one chapter at a time so large books do not need a second full-size HTML string. */
+    private fun writeHtml(
+        output: File,
+        publication: EpubPublication,
+        publisherCss: String,
+        chapters: ArrayDeque<ChapterDocument>,
+        targetIds: Map<String, String>,
+        references: String,
+    ) {
+        val shell = buildHtml(publication, PUBLISHER_SLOT, CONTENT_SLOT, references)
+        val publisherIndex = shell.indexOf(PUBLISHER_SLOT)
+        val contentIndex = shell.indexOf(CONTENT_SLOT, publisherIndex + PUBLISHER_SLOT.length)
+        check(publisherIndex >= 0 && contentIndex > publisherIndex) { "Invalid reader HTML template." }
+        output.bufferedWriter().use { writer ->
+            WorkCancellation.check()
+            writer.append(shell, 0, publisherIndex)
+            writer.append(publisherCss)
+            writer.append(shell, publisherIndex + PUBLISHER_SLOT.length, contentIndex)
+            while (chapters.isNotEmpty()) {
+                WorkCancellation.check()
+                writer.append(renderChapter(chapters.removeFirst(), publication, targetIds))
+            }
+            writer.append(shell, contentIndex + CONTENT_SLOT.length, shell.length)
+        }
+    }
+
+    private fun renderChapter(
+        chapter: ChapterDocument,
+        publication: EpubPublication,
+        targetIds: Map<String, String>,
+    ): String = buildString {
+        WorkCancellation.check()
+        chapter.body.getAllElements().filter { element ->
+            elementName(element) in BLOCKED_ELEMENTS || elementName(element) == "style" || isStylesheet(element)
+        }.forEach(Element::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 ->
+            WorkCancellation.check()
+            val name = elementName(element)
+            element.attributes().asList().toList().forEach { attribute ->
+                val key = attribute.key.lowercase(Locale.ROOT)
+                val value = attribute.value
+                val isHref = key == "href" || key.endsWith(":href")
+                when {
+                    key.startsWith("on") -> element.removeAttr(attribute.key)
+                    key == "style" -> {
+                        val rewritten = rewriteLocalCssFragments(
+                            rewriteCssUrls(
+                                rewritePageBreaks(value), chapter.directory, publication.rootDirectory,
+                            ),
+                            chapter.idTargets,
+                        )
+                        element.attr(attribute.key, rewritten)
+                        element.attr("data-alex-publisher-style", rewritten)
+                    }
+                    key == "srcset" -> element.attr(
+                        attribute.key,
+                        rewriteSrcSet(value, chapter.directory, publication.rootDirectory),
+                    )
+                    isHref && name in LINK_ELEMENTS -> rewriteLink(element, attribute.key, value, chapter, targetIds)
+                    key.substringAfter(':') in ID_REFERENCE_ATTRIBUTES ->
+                        element.attr(attribute.key, rewriteIdReferences(value, chapter.idTargets))
+                    key.substringAfter(':') in SMIL_TIMING_ATTRIBUTES ->
+                        element.attr(attribute.key, rewriteSmilReferences(value, chapter.idTargets))
+                    key == "src" || key == "poster" || key == "data" || isHref -> {
+                        val lower = value.trim().lowercase(Locale.ROOT)
+                        val local = localFragmentTarget(value, chapter.idTargets)
+                        when {
+                            local != null -> element.attr(attribute.key, "#$local")
+                            lower.startsWith("file:") || lower.startsWith("content:") || lower.startsWith("javascript:") ->
+                                element.removeAttr(attribute.key)
+                            value.isNotBlank() && !isExternal(value) && !value.startsWith('#') ->
+                                resourceUrl(chapter.directory, value, publication.rootDirectory)
+                                    ?.let { element.attr(attribute.key, it) } ?: element.removeAttr(attribute.key)
+                        }
+                    }
+                    "url(" in value.lowercase(Locale.ROOT) ->
+                        element.attr(attribute.key, rewriteLocalCssFragments(value, chapter.idTargets))
+                }
+            }
+        }
+        val html = chapter.document.getAllElements().firstOrNull { elementName(it) == "html" }
+        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.body.attr("xml:lang") }
+            .ifBlank { html?.attr("lang").orEmpty() }.ifBlank { html?.attr("xml:lang").orEmpty() }
+        val direction = chapter.body.attr("dir").ifBlank { html?.attr("dir").orEmpty() }
+        append("<section class=\"alex-chapter ").append(classes).append("\" id=\"alex-chapter-")
+            .append(chapter.index).append("\" data-spine=\"").append(chapter.index).append('"')
+        if (publication.fixedLayout) {
+            append(" data-page-width=\"").append(chapter.viewportWidth)
+                .append("\" data-page-height=\"").append(chapter.viewportHeight).append('"')
+        }
+        if (bodyStyle.isNotBlank()) {
+            append(" style=\"").append(escapeAttribute(bodyStyle)).append('"')
+            append(" data-alex-publisher-style=\"").append(escapeAttribute(bodyStyle)).append('"')
+        }
+        if (language.isNotBlank()) append(" lang=\"").append(escapeAttribute(language)).append('"')
+        if (direction.isNotBlank()) append(" dir=\"").append(escapeAttribute(direction)).append('"')
+        append('>')
+        if (!publication.fixedLayout) append("<span class=\"alex-chapter-start\" aria-hidden=\"true\"></span>")
+        append(chapter.body.html()).append("</section>\n")
+    }
+
+    private fun rewriteLink(
+        element: Element,
+        attributeName: String,
+        value: String,
+        chapter: ChapterDocument,
+        targetIds: Map<String, String>,
+    ) {
+        if (value.isBlank()) return
+        if (isExternal(value)) {
+            element.attr("data-external-href", value)
+            element.attr(attributeName, "#")
+            return
+        }
+        val reference = when {
+            value == "#" -> chapter.item.href
+            value.startsWith('#') -> chapter.item.href + value
+            else -> EpubParser.resolveReference(chapter.directory, value)
+        }
+        val target = targetIds[reference] ?: error("An EPUB link points outside the reading order: $reference")
+        element.attr(attributeName, "#$target")
+    }
+
+    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(pattern: Regex): Int? = pattern.find(viewport)?.groupValues?.getOrNull(1)
+            ?.toDoubleOrNull()?.roundToInt()?.takeIf { it > 0 }
+        val viewportWidth = component(VIEWPORT_WIDTH_PATTERN)
+        val viewportHeight = component(VIEWPORT_HEIGHT_PATTERN)
+        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(VIEW_BOX_SEPARATOR)?.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? = DIMENSION_PATTERN.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 = BODY_WIDTH_PATTERN.find(style)?.groupValues?.getOrNull(1)?.toDoubleOrNull()?.roundToInt()
+        val cssHeight = BODY_HEIGHT_PATTERN.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:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_extra_light.otf') format('opentype'); font-style:normal; font-weight:200; }
+@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_extra_light_italic.otf') format('opentype'); font-style:italic; font-weight:200; }
+@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_light.otf') format('opentype'); font-style:normal; font-weight:300; }
+@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_light_italic.otf') format('opentype'); font-style:italic; font-weight:300; }
+@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_regular.otf') format('opentype'); font-style:normal; font-weight:400; }
+@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_regular_italic.otf') format('opentype'); font-style:italic; font-weight:400; }
+@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_medium.otf') format('opentype'); font-style:normal; font-weight:500; }
+@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_medium_italic.otf') format('opentype'); font-style:italic; font-weight:500; }
+@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_semi_bold.otf') format('opentype'); font-style:normal; font-weight:600; }
+@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_semi_bold_italic.otf') format('opentype'); font-style:italic; font-weight:600; }
+@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_bold.otf') format('opentype'); font-style:normal; font-weight:700; }
+@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_bold_italic.otf') format('opentype'); font-style:italic; font-weight:700; }
+@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_extra_bold.otf') format('opentype'); font-style:normal; font-weight:800; }
+@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_extra_bold_italic.otf') format('opentype'); font-style:italic; font-weight:800; }
+:root { --alex-side:32px; --alex-v:22px; --alex-font-size:20px; --alex-line-height:1.32; --alex-font:'Atkinson Hyperlegible Next'; --alex-paper:#fff; --alex-ink:#000; --alex-link:#111; }
+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-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; width:100vw; height:100vh; padding:0 !important; overflow:hidden !important; }
+body.alex-paged .alex-chapter { display:none !important; box-sizing:border-box; position:absolute !important; left:var(--alex-side) !important; top:var(--alex-v) !important; width:calc(100vw - (2 * var(--alex-side))) !important; height:calc(100vh - (2 * var(--alex-v))) !important; max-width:none !important; max-height:none !important; margin:0 !important; padding:0 !important; column-width:calc(100vw - (2 * var(--alex-side))); column-gap:calc(2 * var(--alex-side)); column-fill:auto; overflow:visible !important; }
+body.alex-paged .alex-chapter.alex-current-page, body.alex-paged .alex-chapter.alex-measuring { display:block !important; }
+body.alex-paged .alex-chapter.alex-measuring { visibility:hidden !important; pointer-events:none !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 { display:block !important; }
+body.alex-continuous .alex-chapter { display:block; break-before:auto !important; margin-bottom:3em; transform:none !important; }
+.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; }
+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 { white-space:pre-wrap; overflow-wrap:anywhere; }
+a { color:var(--alex-link); text-decoration:underline; }
+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), sans-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; }
+mark.alex-search { color:inherit !important; background:#ccc !important; border-bottom:2px solid #111; }
+mark.alex-search-current { background:#666 !important; color:#fff !important; }
+mark.alex-annotation { color:inherit !important; background:#ddd !important; border-bottom:2px solid #111; cursor:pointer; }
+</style>
+</head>
+<body class="${if (publication.fixedLayout) "alex-fixed" else "alex-paged"} alex-hyphenate">
+<svg width="0" height="0" aria-hidden="true" style="position:absolute"><defs><filter id="alex-image-dither" x="-10%" y="-10%" width="120%" height="120%" color-interpolation-filters="sRGB"></filter></defs></svg>
+$content
+<script>
+window.ALEX_REFERENCES = $references;
+$IMAGE_DITHER_SCRIPT
+${if (publication.fixedLayout) FIXED_READER_SCRIPT else REFLOWABLE_READER_SCRIPT}
+</script>
+</body>
+</html>"""
+
+
+
+
+    private fun loadStylesheet(path: String, root: File, importStack: MutableSet<String>): String {
+        WorkCancellation.check()
+        val normalized = EpubParser.normalizePath("", path)
+        if (!importStack.add(normalized)) return ""
+        return try {
+            val file = runCatching { EpubParser.safeFile(root, normalized) }.getOrNull()
+            if (file?.isFile != true) return ""
+            val base = normalized.substringBeforeLast('/', "")
+            val css = file.inputStream().use { source ->
+                WorkCancellation.input(source).bufferedReader().use { it.readText() }
+            }
+            val expanded = expandCssImports(css, base, root, importStack)
+            rewriteCssUrls(expanded, base, root)
+        } finally {
+            importStack.remove(normalized)
+        }
+    }
+
+    private fun expandCssImports(css: String, base: String, root: File, importStack: MutableSet<String>): String {
+        return CSS_IMPORT_PATTERN.replace(css) { match ->
+            WorkCancellation.check()
+            val href = match.groupValues.drop(1).firstOrNull { it.isNotBlank() }.orEmpty()
+            if (href.isBlank() || isExternal(href)) ""
+            else loadStylesheet(EpubParser.normalizePath(base, href.substringBefore('#').substringBefore('?')), root, importStack)
+        }
+    }
+
+    private fun rewriteCssUrls(css: String, base: String, root: File): String {
+        var rewritten = CSS_URL_PATTERN.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 = CSS_QUOTED_IMPORT_PATTERN.replace(rewritten) { match ->
+            val target = resourceUrl(base, match.groupValues[2], root)
+            if (target == null) match.value else "@import url('$target')"
+        }
+        return rewritten.replace(CSS_CHARSET_PATTERN, "")
+    }
+
+    private fun rewriteSrcSet(value: String, base: String, root: File): String {
+        val candidates = mutableListOf<String>()
+        var index = 0
+        while (index < value.length) {
+            while (index < value.length && (value[index].isWhitespace() || value[index] == ',')) index++
+            if (index >= value.length) break
+
+            val urlStart = index
+            while (index < value.length && !value[index].isWhitespace()) index++
+            var url = value.substring(urlStart, index)
+            var separatorInUrl = false
+            while (url.endsWith(',')) {
+                separatorInUrl = true
+                url = url.dropLast(1)
+            }
+            if (url.isBlank()) continue
+
+            var descriptor = ""
+            if (!separatorInUrl) {
+                while (index < value.length && value[index].isWhitespace()) index++
+                val descriptorStart = index
+                var parentheses = 0
+                while (index < value.length) {
+                    when (value[index]) {
+                        '(' -> parentheses++
+                        ')' -> if (parentheses > 0) parentheses--
+                        ',' -> if (parentheses == 0) break
+                    }
+                    index++
+                }
+                descriptor = value.substring(descriptorStart, index).trim()
+                if (index < value.length && value[index] == ',') index++
+            }
+
+            val lower = url.lowercase(Locale.ROOT)
+            val rewritten = when {
+                lower.startsWith("file:") || lower.startsWith("content:") || lower.startsWith("javascript:") -> null
+                isExternal(url) || url.startsWith('#') -> url
+                else -> resourceUrl(base, url, root)
+            }
+            if (rewritten != null) candidates += if (descriptor.isBlank()) rewritten else "$rewritten $descriptor"
+        }
+        return candidates.joinToString(", ")
+    }
+
+    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('?'))
+        val fileUrl = runCatching { EpubParser.safeFile(root, path) }.getOrNull()
+            ?.takeIf(File::isFile)?.toURI()?.toASCIIString()?.replace("'", "%27") ?: return null
+        val fragment = value.substringAfter('#', "")
+        return if (fragment.isBlank()) fileUrl else "$fileUrl#${encodeUriComponent(EpubParser.decodeUriComponent(fragment))}"
+    }
+
+    /** Re-targets every selector to the spine section that supplied the CSS. */
+    private fun scopeCss(css: String, chapter: Int, idTargets: Map<String, String>): String {
+        val scope = ".alex-chapter[data-spine=\"$chapter\"]"
+
+        fun transformSelector(value: String): String = splitCssSelectors(value).joinToString(",") { source ->
+            var selector = rewriteSelectorIds(source, idTargets).trim()
+                .replace(HTML_BODY_CHILD_SELECTOR, scope)
+                .replace(HTML_BODY_SELECTOR, scope)
+                .replace(BODY_SELECTOR, scope)
+                .replace(HTML_SELECTOR, scope)
+                .replace(ROOT_SELECTOR, scope)
+            while ("$scope $scope" in selector || "$scope > $scope" in selector) {
+                selector = selector.replace("$scope > $scope", scope).replace("$scope $scope", scope)
+            }
+            val rootId = "#alex-chapter-$chapter"
+            when {
+                scope in selector || rootId in selector || selector.isBlank() -> selector
+                selector.startsWith('.') || selector.startsWith('[') || selector.startsWith(':') ->
+                    "$scope$selector,$scope $selector"
+                else -> "$scope $selector"
+            }
+        }
+
+        fun process(source: String): String {
+            val out = StringBuilder()
+            var index = 0
+            while (index < source.length) {
+                WorkCancellation.check()
+                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) {
+                    out.append(transformSelector(rulePrelude)).append('{')
+                        .append(rewritePageBreaks(rewriteLocalCssFragments(declarations, idTargets))).append('}')
+                }
+                when {
+                    trimmed.startsWith("@media", true) || trimmed.startsWith("@supports", true) ||
+                        trimmed.startsWith("@layer", true) || trimmed.startsWith("@container", 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(rewriteLocalCssFragments(body, idTargets)).append('}')
+                    else -> appendRule(prelude, body)
+                }
+                index = close + 1
+            }
+            return out.toString()
+        }
+        return process(css)
+    }
+
+    private fun splitCssSelectors(value: String): List<String> {
+        val result = mutableListOf<String>()
+        var start = 0
+        var parentheses = 0
+        var brackets = 0
+        var quote = '\u0000'
+        var comment = false
+        var index = 0
+        while (index < value.length) {
+            val character = value[index]
+            when {
+                comment -> if (index + 1 < value.length && character == '*' && value[index + 1] == '/') {
+                    comment = false
+                    index++
+                }
+                quote != '\u0000' -> if (character == '\\') index++ else if (character == quote) quote = '\u0000'
+                index + 1 < value.length && character == '/' && value[index + 1] == '*' -> {
+                    comment = true
+                    index++
+                }
+                character == '\'' || character == '"' -> quote = character
+                character == '\\' && index + 1 < value.length -> index++
+                character == '(' -> parentheses++
+                character == ')' && parentheses > 0 -> parentheses--
+                character == '[' -> brackets++
+                character == ']' && brackets > 0 -> brackets--
+                character == ',' && parentheses == 0 && brackets == 0 -> {
+                    result += value.substring(start, index)
+                    start = index + 1
+                }
+            }
+            index++
+        }
+        result += value.substring(start)
+        return result
+    }
+
+    private fun rewriteSelectorIds(value: String, idTargets: Map<String, String>): String {
+        val output = StringBuilder(value.length)
+        var quote = '\u0000'
+        var comment = false
+        var index = 0
+        while (index < value.length) {
+            val character = value[index]
+            when {
+                comment -> {
+                    output.append(character)
+                    if (index + 1 < value.length && character == '*' && value[index + 1] == '/') {
+                        output.append('/')
+                        index++
+                        comment = false
+                    }
+                }
+                quote != '\u0000' -> {
+                    output.append(character)
+                    if (character == '\\' && index + 1 < value.length) output.append(value[++index])
+                    else if (character == quote) quote = '\u0000'
+                }
+                index + 1 < value.length && character == '/' && value[index + 1] == '*' -> {
+                    output.append("/*")
+                    index++
+                    comment = true
+                }
+                character == '\'' || character == '"' -> {
+                    output.append(character)
+                    quote = character
+                }
+                character == '#' -> {
+                    val identifier = readCssIdentifier(value, index + 1)
+                    val target = idTargets[identifier.value]
+                    output.append('#').append(target ?: value.substring(index + 1, identifier.end))
+                    index = identifier.end - 1
+                }
+                else -> output.append(character)
+            }
+            index++
+        }
+        return output.toString()
+    }
+
+    private data class CssIdentifier(val value: String, val end: Int)
+
+    private fun readCssIdentifier(value: String, start: Int): CssIdentifier {
+        val decoded = StringBuilder()
+        var index = start
+        while (index < value.length) {
+            val character = value[index]
+            when {
+                character == '\\' && index + 1 < value.length -> {
+                    index++
+                    val hexStart = index
+                    while (index < value.length && index - hexStart < 6 && value[index].digitToIntOrNull(16) != null) index++
+                    if (index > hexStart) {
+                        val codePoint = value.substring(hexStart, index).toInt(16).coerceAtMost(0x10ffff)
+                        decoded.appendCodePoint(if (codePoint == 0) 0xfffd else codePoint)
+                        if (index < value.length && value[index].isWhitespace()) index++
+                    } else {
+                        decoded.append(value[index])
+                        index++
+                    }
+                }
+                character == '-' || character == '_' || character.isLetterOrDigit() || character.code >= 0x80 -> {
+                    decoded.append(character)
+                    index++
+                }
+                else -> break
+            }
+        }
+        return CssIdentifier(decoded.toString(), index)
+    }
+
+    private fun rewriteLocalCssFragments(value: String, idTargets: Map<String, String>): String =
+        CSS_URL_PATTERN.replace(value) { match ->
+            val reference = match.groupValues[2].trim()
+            val target = localFragmentTarget(reference, idTargets)
+            if (target == null) match.value else "url('#$target')"
+        }
+
+    private fun localFragmentTarget(value: String, idTargets: Map<String, String>): String? {
+        val trimmed = value.trim()
+        if (!trimmed.startsWith('#') || trimmed.length == 1) return null
+        return idTargets[EpubParser.decodeUriComponent(trimmed.drop(1))]
+    }
+
+    private fun rewriteIdReferences(value: String, idTargets: Map<String, String>): String =
+        value.split(WHITESPACE).joinToString(" ") { idTargets[it] ?: it }
+
+    private fun rewriteSmilReferences(value: String, idTargets: Map<String, String>): String =
+        SMIL_EVENT_REFERENCE_PATTERN.replace(value) { match ->
+            match.groupValues[1] + (idTargets[match.groupValues[2]] ?: match.groupValues[2]) + "."
+        }
+
+    private fun rewritePageBreaks(css: String): String {
+        val rewritten = PAGE_BREAK_EDGE_PATTERN.replace(css) { match ->
+            val value = when (match.groupValues[2].lowercase(Locale.ROOT)) {
+                "always", "left", "right" -> "column"
+                "avoid" -> "avoid-column"
+                else -> match.groupValues[2].lowercase(Locale.ROOT)
+            }
+            "break-${match.groupValues[1].lowercase(Locale.ROOT)}:$value${match.groupValues[3]};"
+        }
+        return PAGE_BREAK_INSIDE_PATTERN.replace(rewritten) { match ->
+            val value = if (match.groupValues[1].equals("avoid", ignoreCase = true)) {
+                "avoid-column"
+            } else {
+                match.groupValues[1].lowercase(Locale.ROOT)
+            }
+            "break-inside:$value${match.groupValues[2]};"
+        }
+    }
+
+    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] == '\\') 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] == '\\') index++
+            else if (value[index] == '{') depth++
+            else if (value[index] == '}' && --depth == 0) return index
+            index++
+        }
+        return -1
+    }
+
+    private fun elementName(element: Element): String = element.tagName().substringAfter(':').lowercase(Locale.ROOT)
+
+    private fun isStylesheet(element: Element): Boolean = elementName(element) == "link" &&
+        "stylesheet" in element.attr("rel").lowercase(Locale.ROOT).split(WHITESPACE)
+
+    private fun sourceIds(element: Element): Set<String> = element.attributes().asList().mapNotNull { attribute ->
+        attribute.value.takeIf {
+            it.isNotBlank() && (attribute.key.equals("id", true) || attribute.key.equals("xml:id", true))
+        }
+    }.toSet()
+
+    private fun encodeUriComponent(value: String): String = buildString {
+        value.encodeToByteArray().forEach { byte ->
+            val unsigned = byte.toInt() and 0xff
+            val character = unsigned.toChar()
+            if (unsigned in 'a'.code..'z'.code || unsigned in 'A'.code..'Z'.code || unsigned in '0'.code..'9'.code ||
+                character == '-' || character == '.' || character == '_' || character == '~'
+            ) {
+                append(character)
+            } else {
+                append('%').append(HEX[unsigned ushr 4]).append(HEX[unsigned and 0x0f])
+            }
+        }
+    }
+
+    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 val WHITESPACE = Regex("\\s+")
+    private val VIEWPORT_WIDTH_PATTERN = Regex(
+        "(?:^|[,;\\s])width\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)",
+        RegexOption.IGNORE_CASE,
+    )
+    private val VIEWPORT_HEIGHT_PATTERN = Regex(
+        "(?:^|[,;\\s])height\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)",
+        RegexOption.IGNORE_CASE,
+    )
+    private val VIEW_BOX_SEPARATOR = Regex("[\\s,]+")
+    private val DIMENSION_PATTERN = Regex("([0-9]+(?:\\.[0-9]+)?)")
+    private val BODY_WIDTH_PATTERN = Regex(
+        "(?:^|;)\\s*width\\s*:\\s*([0-9]+(?:\\.[0-9]+)?)px",
+        RegexOption.IGNORE_CASE,
+    )
+    private val BODY_HEIGHT_PATTERN = Regex(
+        "(?:^|;)\\s*height\\s*:\\s*([0-9]+(?:\\.[0-9]+)?)px",
+        RegexOption.IGNORE_CASE,
+    )
+    private val CSS_IMPORT_PATTERN = Regex(
+        "@import\\s+(?:url\\(\\s*)?(?:\\\"([^\\\"]+)\\\"|'([^']+)'|([^\\s;)]+))\\s*\\)?[^;]*;",
+        setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL),
+    )
+    private val CSS_URL_PATTERN = Regex(
+        "url\\(\\s*(['\"]?)(.*?)\\1\\s*\\)",
+        setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL),
+    )
+    private val CSS_QUOTED_IMPORT_PATTERN = Regex("@import\\s+(['\"])(.*?)\\1", RegexOption.IGNORE_CASE)
+    private val CSS_CHARSET_PATTERN = Regex("@charset\\s+[^;]+;", RegexOption.IGNORE_CASE)
+    private val HTML_BODY_CHILD_SELECTOR = Regex(
+        "(?<![-\\w])html\\s*>\\s*body(?=([.#:\\[]|\\s|>|\\+|~|$))",
+        RegexOption.IGNORE_CASE,
+    )
+    private val HTML_BODY_SELECTOR = Regex(
+        "(?<![-\\w])html\\s+body(?=([.#:\\[]|\\s|>|\\+|~|$))",
+        RegexOption.IGNORE_CASE,
+    )
+    private val BODY_SELECTOR = Regex("(?<![-\\w])body(?=([.#:\\[]|\\s|>|\\+|~|$))", RegexOption.IGNORE_CASE)
+    private val HTML_SELECTOR = Regex("(?<![-\\w])html(?=([.#:\\[]|\\s|>|\\+|~|$))", RegexOption.IGNORE_CASE)
+    private val ROOT_SELECTOR = Regex("(?<![-\\w]):root(?![-\\w])", RegexOption.IGNORE_CASE)
+    private val PAGE_BREAK_EDGE_PATTERN = Regex(
+        "page-break-(before|after)\\s*:\\s*(always|avoid|auto|left|right|inherit|initial|unset)(\\s*!important)?\\s*;?",
+        RegexOption.IGNORE_CASE,
+    )
+    private val PAGE_BREAK_INSIDE_PATTERN = Regex(
+        "page-break-inside\\s*:\\s*(avoid|auto|inherit|initial|unset)(\\s*!important)?\\s*;?",
+        RegexOption.IGNORE_CASE,
+    )
+    private val SMIL_EVENT_REFERENCE_PATTERN = Regex("(^|[;\\s])([A-Za-z_][A-Za-z0-9_.:-]*)(?=\\.)")
+    private val ID_REFERENCE_ATTRIBUTES = setOf(
+        "aria-activedescendant", "aria-controls", "aria-describedby", "aria-details", "aria-errormessage",
+        "aria-flowto", "aria-labelledby", "aria-owns", "for", "form", "headers", "list",
+    )
+    private val SMIL_TIMING_ATTRIBUTES = setOf("begin", "end")
+    private val ROOT_ELEMENTS = setOf("html", "body")
+    private val LINK_ELEMENTS = setOf("a", "area")
+    private val BLOCKED_ELEMENTS = setOf("script", "iframe", "frame", "object", "embed")
+    private const val HEX = "0123456789ABCDEF"
+    private const val PUBLISHER_SLOT = "\u0000alex-publisher-css\u0000"
+    private const val CONTENT_SLOT = "\u0000alex-publication-content\u0000"
+
+    private fun escapeHtml(value: String): String = value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
+    private fun escapeAttribute(value: String): String = escapeHtml(value).replace("\"", "&quot;")
+}
index 1208eac3f5098c788a5268b21571f6da7094444a..cb8916d7c0a7eed14315b4abda65fff738147d28 100644 (file)
@@ -1,31 +1,31 @@
 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.ZipEntry
 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 {
+internal object EpubParser {
     private data class ManifestItem(val href: String, val mediaType: String, val properties: Set<String>)
     private data class ParsedSpineItem(val item: SpineItem, val linear: Boolean, val fixedLayout: Boolean)
 
     fun parse(source: File, output: File, book: LibraryBook): EpubPublication {
+        WorkCancellation.check()
         if (!File(output, ".complete").isFile) {
             output.deleteRecursively()
             output.mkdirs()
             extract(source, output)
+            WorkCancellation.check()
             File(output, ".complete").writeText("ok")
         }
 
+        WorkCancellation.check()
         val containerFile = File(output, "META-INF/container.xml")
         require(containerFile.isFile) { "This file has no EPUB container document." }
         val rootfile = parseXml(containerFile).allByLocalName("rootfile").firstOrNull()
@@ -56,10 +56,7 @@ object EpubParser {
                 if (!meta.attr("property").equals("rendition:viewport", ignoreCase = true)) {
                     return@firstNotNullOfOrNull null
                 }
-                val dimensions = Regex(
-                    "(?:width\\s*=\\s*)?([0-9]+)\\s*(?:x|[,;]\\s*height\\s*=)\\s*([0-9]+)",
-                    RegexOption.IGNORE_CASE,
-                ).find(meta.text()) ?: return@firstNotNullOfOrNull null
+                val dimensions = PACKAGE_VIEWPORT_PATTERN.find(meta.text()) ?: 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 }
@@ -71,32 +68,40 @@ object EpubParser {
                 meta.text().trim().equals("pre-paginated", ignoreCase = true)
         }
 
-        val manifest = opf.allByLocalName("manifest").firstOrNull()?.children()
+        val manifest = linkedMapOf<String, ManifestItem>()
+        opf.allByLocalName("manifest").firstOrNull()?.children()
             ?.filter { it.localName() == "item" }
-            ?.associate { element ->
+            ?.forEach { element ->
+                WorkCancellation.check()
                 val id = element.attr("id")
                 val href = element.attr("href")
                 require(id.isNotBlank() && href.isNotBlank()) { "Every EPUB manifest item must have an ID and an href." }
-                id to ManifestItem(
-                    href = normalizePath(packageDirectory, href.substringBefore('#').substringBefore('?')),
-                    mediaType = element.attr("media-type").lowercase(Locale.ROOT),
-                    properties = element.attr("properties").lowercase(Locale.ROOT).split(Regex("\\s+")).filter(String::isNotBlank).toSet(),
+                val previous = manifest.put(
+                    id,
+                    ManifestItem(
+                        href = normalizePath(packageDirectory, href.substringBefore('#').substringBefore('?')),
+                        mediaType = element.attr("media-type").lowercase(Locale.ROOT),
+                        properties = element.attr("properties").lowercase(Locale.ROOT)
+                            .split(WHITESPACE).filter(String::isNotBlank).toSet(),
+                    ),
                 )
-            }.orEmpty()
+                require(previous == null) { "Every EPUB manifest item must have a unique ID." }
+            }
         require(manifest.isNotEmpty()) { "The EPUB manifest is empty." }
 
         val spineElement = opf.allByLocalName("spine").firstOrNull()
             ?: error("The EPUB package document has no spine element.")
         val parsedSpine = spineElement.children().filter { it.localName() == "itemref" }.map { reference ->
+            WorkCancellation.check()
             val item = manifest[reference.attr("idref")]
                 ?: error("The EPUB spine references an item that is not in the manifest.")
             require(isContentDocument(item.mediaType)) { "The EPUB spine contains a non-content document." }
             val chapterFile = safeFile(output, item.href)
             require(chapterFile.isFile) { "An EPUB spine document is missing." }
             val referenceProperties = reference.attr("properties").lowercase(Locale.ROOT)
-                .split(Regex("\\s+")).filter(String::isNotBlank).toSet()
+                .split(WHITESPACE).filter(String::isNotBlank).toSet()
             ParsedSpineItem(
-                item = SpineItem(item.href, chapterTitle(chapterFile)),
+                item = SpineItem(item.href, chapterFile.nameWithoutExtension),
                 linear = !reference.attr("linear").equals("no", ignoreCase = true),
                 fixedLayout = packageVersion == 3 &&
                     ("rendition:layout-pre-paginated" in item.properties ||
@@ -149,6 +154,9 @@ object EpubParser {
             }
         }
 
+        val titledSpine = spine.mapIndexed { index, item ->
+            item.copy(title = toc.firstOrNull { it.spineIndex == index }?.title ?: item.title)
+        }
         val direction = spineElement.attr("page-progression-direction").lowercase(Locale.ROOT)
             .takeIf { it == "rtl" || it == "ltr" } ?: "ltr"
         val linearSpine = parsedSpine.filter(ParsedSpineItem::linear)
@@ -157,7 +165,7 @@ object EpubParser {
         return EpubPublication(
             book = book,
             rootDirectory = output,
-            spine = spine,
+            spine = titledSpine,
             toc = toc,
             readingDirection = direction,
             fixedLayout = fixedLayout,
@@ -168,26 +176,40 @@ object EpubParser {
 
     private fun extract(source: File, output: File) {
         var expanded = 0L
+        val extractedPaths = mutableSetOf<String>()
         ZipFile(source).use { zip ->
+            val first = zip.entries().takeIf { it.hasMoreElements() }?.nextElement()
+                ?: error("The EPUB archive is empty.")
+            require(
+                first.name == "mimetype" && first.method == ZipEntry.STORED &&
+                    first.size == EPUB_MEDIA_TYPE.length.toLong()
+            ) {
+                "The EPUB mimetype must be the first uncompressed archive entry."
+            }
+            val mediaType = zip.getInputStream(first).use { it.readBytes().decodeToString() }
+            require(mediaType == EPUB_MEDIA_TYPE) { "The EPUB archive has an invalid mimetype." }
+
             val entries = zip.entries()
             while (entries.hasMoreElements()) {
+                WorkCancellation.check()
                 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 name = archivePath(entry.name)
+                require(extractedPaths.add(name)) { "The EPUB contains duplicate resource paths." }
                 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)
+                zip.getInputStream(entry).use { source ->
+                    WorkCancellation.input(source).use { input ->
+                        FileOutputStream(target).use { outputStream ->
+                            val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+                            while (true) {
+                                WorkCancellation.check()
+                                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)
+                            }
                         }
                     }
                 }
@@ -227,24 +249,25 @@ object EpubParser {
         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+"))
+                    "toc" in attribute.value.lowercase().split(WHITESPACE)
             }
         } ?: return emptyList()
         val result = mutableListOf<TocEntry>()
         val navDirectory = path.substringBeforeLast('/', "")
 
         fun walk(container: Element, depth: Int) {
+            WorkCancellation.check()
             container.children().filter { it.localName() == "li" }.forEach { item ->
-                val label = item.children().firstOrNull { it.localName() in setOf("a", "span") }
+                val label = item.children().firstOrNull { it.localName() in TOC_LABEL_ELEMENTS }
                 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) }
+                item.children().filter { it.localName() in TOC_LIST_ELEMENTS }.forEach { walk(it, depth + 1) }
             }
         }
-        nav.children().filter { it.localName() in setOf("ol", "ul") }.forEach { walk(it, 0) }
+        nav.children().filter { it.localName() in TOC_LIST_ELEMENTS }.forEach { walk(it, 0) }
         return result
     }
 
@@ -258,6 +281,7 @@ object EpubParser {
         val directory = path.substringBeforeLast('/', "")
 
         fun walk(parent: Element, depth: Int) {
+            WorkCancellation.check()
             parent.children().filter { it.localName() == "navpoint" }.forEach { point ->
                 val navLabel = point.children().firstOrNull { it.localName() == "navlabel" }
                     ?: error("An EPUB NCX navigation point has no label.")
@@ -284,23 +308,22 @@ object EpubParser {
         }
     }
 
-    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 decoded = decodeUriComponent(value.trim().substringBefore('?'))
+        require('\\' !in decoded && !decoded.startsWith('/')) { "The EPUB contains an unsafe resource path." }
         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)
+                ".." -> {
+                    require(parts.isNotEmpty()) { "Resource path leaves the EPUB container." }
+                    parts.removeLast()
+                }
+                else -> {
+                    require('\u0000' !in part) { "The EPUB contains an unsafe resource path." }
+                    parts.addLast(part)
+                }
             }
         }
         return parts.joinToString("/")
@@ -310,7 +333,40 @@ object EpubParser {
         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)}"
+        return if (fragment.isBlank()) path else "$path#${decodeUriComponent(fragment)}"
+    }
+
+    private fun archivePath(value: String): String {
+        require(value.isNotBlank() && !value.startsWith('/') && '\\' !in value && '\u0000' !in value) {
+            "The EPUB contains an unsafe resource path."
+        }
+        require(value.split('/').all { it.isNotBlank() && it != "." && it != ".." }) {
+            "The EPUB contains an unsafe resource path."
+        }
+        return value
+    }
+
+    internal fun decodeUriComponent(value: String): String {
+        val decoded = StringBuilder(value.length)
+        var index = 0
+        while (index < value.length) {
+            if (value[index] != '%') {
+                decoded.append(value[index++])
+                continue
+            }
+            val bytes = ByteArray((value.length - index + 2) / 3)
+            var count = 0
+            while (index < value.length && value[index] == '%') {
+                require(index + 2 < value.length) { "The EPUB contains an invalid encoded path." }
+                val high = value[index + 1].digitToIntOrNull(16)
+                val low = value[index + 2].digitToIntOrNull(16)
+                require(high != null && low != null) { "The EPUB contains an invalid encoded path." }
+                bytes[count++] = ((high shl 4) or low).toByte()
+                index += 3
+            }
+            decoded.append(bytes.decodeToString(0, count, throwOnInvalidSequence = true))
+        }
+        return decoded.toString()
     }
 
     internal fun safeFile(root: File, path: String): File {
@@ -323,8 +379,10 @@ object EpubParser {
         return canonical
     }
 
-    private fun parseXml(file: File): Document = file.inputStream().use { stream ->
-        Jsoup.parse(stream, null, file.toURI().toString(), Parser.xmlParser())
+    private fun parseXml(file: File): Document = file.inputStream().use { source ->
+        WorkCancellation.input(source).use { stream ->
+            Jsoup.parse(stream, null, file.toURI().toString(), Parser.xmlParser())
+        }
     }
 
     private fun isContentDocument(mediaType: String): Boolean =
@@ -335,734 +393,13 @@ object EpubParser {
         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,
+    private val WHITESPACE = Regex("\\s+")
+    private val PACKAGE_VIEWPORT_PATTERN = Regex(
+        "(?:width\\s*=\\s*)?([0-9]+)\\s*(?:x|[,;]\\s*height\\s*=)\\s*([0-9]+)",
+        RegexOption.IGNORE_CASE,
     )
-
-    fun write(publication: EpubPublication, output: File): File {
-        val chapters = publication.spine.mapIndexed { index, item ->
-            val file = EpubParser.safeFile(publication.rootDirectory, item.href)
-            require(file.isFile) { "An EPUB spine document is missing." }
-            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 = if (publication.fixedLayout) {
-                fixedViewport(document, body, publication.defaultPageWidth, publication.defaultPageHeight)
-            } else {
-                0 to 0
-            }
-            ChapterDocument(
-                index, item, document, body, item.href.substringBeforeLast('/', ""), viewport.first, viewport.second,
-            )
-        }
-        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)
-                }
-            }
-        }
-        publication.toc.forEach { entry ->
-            require(entry.href in targetIds) { "The EPUB table of contents has a missing target: ${entry.href}" }
-        }
-
-        val cssPaths = linkedSetOf<String>()
-        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(rewritePageBreaks(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 = when {
-                                        value == "#" -> chapter.item.href
-                                        value.startsWith('#') -> chapter.item.href + value
-                                        else -> EpubParser.resolveReference(chapter.directory, value)
-                                    }
-                                    val target = targetIds[reference]
-                                        ?: error("An EPUB link points outside the reading order: $reference")
-                                    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('"')
-                if (publication.fixedLayout) {
-                    append(" data-page-width=\"").append(chapter.viewportWidth)
-                        .append("\" data-page-height=\"").append(chapter.viewportHeight).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('>')
-                if (!publication.fixedLayout) 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 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:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_extra_light.otf') format('opentype'); font-style:normal; font-weight:200; }
-@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_extra_light_italic.otf') format('opentype'); font-style:italic; font-weight:200; }
-@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_light.otf') format('opentype'); font-style:normal; font-weight:300; }
-@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_light_italic.otf') format('opentype'); font-style:italic; font-weight:300; }
-@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_regular.otf') format('opentype'); font-style:normal; font-weight:400; }
-@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_regular_italic.otf') format('opentype'); font-style:italic; font-weight:400; }
-@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_medium.otf') format('opentype'); font-style:normal; font-weight:500; }
-@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_medium_italic.otf') format('opentype'); font-style:italic; font-weight:500; }
-@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_semi_bold.otf') format('opentype'); font-style:normal; font-weight:600; }
-@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_semi_bold_italic.otf') format('opentype'); font-style:italic; font-weight:600; }
-@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_bold.otf') format('opentype'); font-style:normal; font-weight:700; }
-@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_bold_italic.otf') format('opentype'); font-style:italic; font-weight:700; }
-@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_extra_bold.otf') format('opentype'); font-style:normal; font-weight:800; }
-@font-face { font-family:'Atkinson Hyperlegible Next'; src:url('file:///android_res/font/atkinson_hyperlegible_next_extra_bold_italic.otf') format('opentype'); font-style:italic; font-weight:800; }
-:root { --alex-side:32px; --alex-v:22px; --alex-font-size:20px; --alex-line-height:1.32; --alex-font:'Atkinson Hyperlegible Next'; --alex-paper:#fff; --alex-ink:#000; --alex-link:#111; }
-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-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 { display:block !important; }
-body.alex-paged .alex-chapter { break-before:column !important; }
-body.alex-paged .alex-chapter:first-of-type { break-before:auto !important; }
-body.alex-continuous .alex-chapter { 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; }
-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 { white-space:pre-wrap; overflow-wrap:anywhere; }
-a { color:var(--alex-link); text-decoration:underline; }
-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), sans-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; }
-mark.alex-search { color:inherit !important; background:#ccc !important; border-bottom:2px solid #111; }
-mark.alex-search-current { background:#666 !important; color:#fff !important; }
-mark.alex-annotation { color:inherit !important; background:#ddd !important; border-bottom:2px solid #111; cursor:pointer; }
-</style>
-</head>
-<body class="${if (publication.fixedLayout) "alex-fixed" else "alex-paged"} alex-hyphenate">
-<svg width="0" height="0" aria-hidden="true" style="position:absolute"><defs><filter id="alex-image-dither" x="-10%" y="-10%" width="120%" height="120%" color-interpolation-filters="sRGB"></filter></defs></svg>
-$content
-<script>
-window.ALEX_REFERENCES = $references;
-$IMAGE_DITHER_SCRIPT
-${if (publication.fixedLayout) FIXED_READER_SCRIPT else REFLOWABLE_READER_SCRIPT}
-</script>
-</body>
-</html>"""
-
-    private const val IMAGE_DITHER_SCRIPT = """
-function applyImageDithering(root) {
-  var filter=document.getElementById('alex-image-dither');
-  if(!filter)return;
-  if(!filter.firstChild){
-    function node(name,attributes){var value=document.createElementNS('http://www.w3.org/2000/svg',name);Object.keys(attributes||{}).forEach(function(key){value.setAttribute(key,String(attributes[key]));});return value;}
-    filter.appendChild(node('feColorMatrix',{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(node('feTurbulence',{type:'fractalNoise',baseFrequency:'.47',numOctaves:'1',seed:'37',stitchTiles:'stitch',result:'alex-noise'}));
-    filter.appendChild(node('feColorMatrix',{in:'alex-noise',type:'matrix',result:'alex-drift',values:'.065 0 0 0 -.0325  .065 0 0 0 -.0325  .065 0 0 0 -.0325  0 0 0 1 0'}));
-    filter.appendChild(node('feComposite',{in:'alex-gray',in2:'alex-drift',operator:'arithmetic',k1:'0',k2:'1',k3:'1',k4:'0',result:'alex-noisy'}));
-    var levels=[];for(var level=0;level<16;level++)levels.push((level/15).toFixed(4));
-    var quantized=node('feComponentTransfer',{in:'alex-noisy',result:'alex-quantized'});
-    ['R','G','B'].forEach(function(channel){quantized.appendChild(node('feFunc'+channel,{type:'discrete',tableValues:levels.join(' ')}));});
-    quantized.appendChild(node('feFuncA',{type:'table',tableValues:'0 1'}));filter.appendChild(quantized);
-    filter.appendChild(node('feComposite',{in:'alex-quantized',in2:'SourceGraphic',operator:'in'}));
-  }
-  Array.prototype.forEach.call(root.querySelectorAll('img,canvas,video,.alex-chapter svg'),function(image){image.style.filter='url(#alex-image-dither)';});
-}
-"""
-
-    private const val REFLOWABLE_READER_SCRIPT = """
-(function () {
-  'use strict';
-  var state = { page:0, total:1, mode:'paged', chapterStarts:[], searchIndex:-1, searchToken:0 };
-  var scrolling = function () { return document.scrollingElement; };
-  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();
-  }
-  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) {
-      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() {
-    if (!window.Android || !Android.onPageChanged) return;
-    Android.onPageChanged(JSON.stringify(locator()));
-  }
-  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();
-  }
-  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) { scrollToPage(0, true); return; }
-    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;
-        scrollToPage(Math.floor((absolute + 2) / pageExtent()), true); return;
-      }
-    }
-    if (Number(value.totalPages) === state.total && typeof value.page === 'number') { scrollToPage(value.page, true); return; }
-    if (typeof value.progress === 'number') { scrollToPage(Math.round(value.progress * Math.max(0, state.total - 1)), true); return; }
-    scrollToPage(value.page || 0, true);
-  }
-  function applySettings(settings, keep) {
-    var root = document.documentElement, body = document.body;
-    var family = settings.fontFamily || 'Atkinson Hyperlegible Next';
-    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); });
-    document.getElementById('alex-publisher-styles').disabled = settings.publisherStyles === false;
-    applyImageDithering(body);
-    setTimeout(function () { recalculate(); if (keep) goToLocator(keep); }, 80);
-  }
-  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];
-    if (!id) return;
-    var target = document.getElementById(id);
-    if (!target) return;
-    var page = pageForElement(target); if (record !== false) recordNavigation(page); scrollToPage(page, 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.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; }
-    var needle = query.toLocaleLowerCase(), counts = {}, walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {acceptNode:function(node){
-      var parent = node.parentElement, chapter = parent ? 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){
-        return {page:pageForElement(mark),chapter:parseInt(mark.dataset.chapter || '0'),offset:parseInt(mark.dataset.offset || '0')};
-      });
-      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.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);
-  }
-  function moveSearch(delta) {
-    var marks = document.querySelectorAll('mark.alex-search'); if (!marks.length) return;
-    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);
-  }
-  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('.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) };
-  }
-  function wrapAnnotation(annotation) {
-    var chapter = document.querySelector('.alex-chapter[data-spine="' + annotation.chapter + '"]'); if (!chapter || annotation.end <= annotation.start) return;
-    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); }
-    });
-  }
-  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('mark.alex-annotation'); if(mark && window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;}
-    var link=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();}
-    },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 = { setPage:scrollToPage, next:function(){scrollToPage(state.page+1,true);}, previous:function(){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;} };
-  function ready() { recalculate(); if(window.Android&&Android.onReaderReady)Android.onReaderReady(); }
-  document.fonts.ready.then(function(){setTimeout(ready,30);});
-})();
-"""
-
-    private const val FIXED_READER_SCRIPT = """
-(function () {
-  'use strict';
-  var pages = Array.prototype.slice.call(document.querySelectorAll('.alex-chapter'));
-  var state = {page:0,total:pages.length,searchIndex:-1};
-  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)];}
-  function pageSize(page){return{width:Math.max(1,number(page.getAttribute('data-page-width'),1200)),height:Math.max(1,number(page.getAttribute('data-page-height'),1600))};}
-  function layoutPage(){
-    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);});
-    var page=pageElement(),size=pageSize(page),availableWidth=Math.max(1,window.innerWidth),availableHeight=Math.max(1,window.innerHeight);
-    var scale=Math.max(0.01,Math.min(availableWidth/size.width,availableHeight/size.height));
-    var x=(availableWidth-size.width*scale)/2,y=(availableHeight-size.height*scale)/2;
-    page.style.width=size.width+'px';page.style.height=size.height+'px';page.style.transform='translate('+x+'px,'+y+'px) scale('+scale+')';
-    applyImageDithering(page);
-  }
-  function locator(){return{page:state.page,totalPages:state.total,chapter:state.page,offset:0,progress:state.total<=1?0:state.page/(state.total-1)};}
-  function notifyPage(){if(window.Android&&Android.onPageChanged)Android.onPageChanged(JSON.stringify(locator()));}
-  function recalculate(){state.total=pages.length;layoutPage();notifyPage();}
-  function setPage(page,report){state.page=clamp(Math.round(number(page,0)),0,state.total-1);layoutPage();if(report!==false)notifyPage();}
-  function pageForElement(element){var chapter=element?element.closest('.alex-chapter'):null;return chapter?clamp(number(chapter.getAttribute('data-spine'),0),0,state.total-1):0;}
-  function goToLocator(value){if(!value){setPage(0,true);return;}setPage(number(value.chapter,value.page||0),true);}
-  function applySettings(settings,keep){document.getElementById('alex-publisher-styles').disabled=false;if(keep)state.page=clamp(Math.round(number(keep.chapter,keep.page||0)),0,state.total-1);layoutPage();notifyPage();}
-  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];if(!id)return;
-    var target=document.getElementById(id);if(!target)return;
-    var page=pageForElement(target);if(record!==false)recordNavigation(page);setPage(page,true);
-  }
-  function clearSearch() {
-    Array.prototype.forEach.call(document.querySelectorAll('mark.alex-search'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));});
-    document.body.normalize();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;}
-    var needle=query.toLocaleLowerCase(),walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,{acceptNode:function(node){
-      var parent=node.parentElement,chapter=parent?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){return{page:pageForElement(mark)};});
-    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.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));
-  }
-  function moveSearch(delta) {
-    var marks=document.querySelectorAll('mark.alex-search');if(!marks.length)return;
-    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);
-  }
-  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('.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)};
-  }
-  function wrapAnnotation(annotation) {
-    var chapter=document.querySelector('.alex-chapter[data-spine="'+annotation.chapter+'"]');if(!chapter||annotation.end<=annotation.start)return;
-    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);}});
-  }
-  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();}
-  document.addEventListener('click',function(event){
-    var mark=event.target.closest('mark.alex-annotation');if(mark&&window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;}
-    var link=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();},120);});
-  window.Alex={setPage:setPage,next:function(){setPage(state.page+1,true);},previous:function(){setPage(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 clamp(index,0,state.total-1);}};
-  function ready(){recalculate();if(window.Android&&Android.onReaderReady)Android.onReaderReady();}
-  document.fonts.ready.then(function(){setTimeout(ready,30);});
-})();
-"""
-
-    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) {
-                    out.append(transformSelector(rulePrelude)).append('{').append(rewritePageBreaks(declarations)).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 rewritePageBreaks(css: String): String {
-        val edgeRule = Regex(
-            "page-break-(before|after)\\s*:\\s*(always|avoid|auto|left|right|inherit|initial|unset)(\\s*!important)?\\s*;?",
-            RegexOption.IGNORE_CASE,
-        )
-        val rewritten = edgeRule.replace(css) { match ->
-            val value = when (match.groupValues[2].lowercase(Locale.ROOT)) {
-                "always", "left", "right" -> "column"
-                "avoid" -> "avoid-column"
-                else -> match.groupValues[2].lowercase(Locale.ROOT)
-            }
-            "break-${match.groupValues[1].lowercase(Locale.ROOT)}:$value${match.groupValues[3]};"
-        }
-        val insideRule = Regex(
-            "page-break-inside\\s*:\\s*(avoid|auto|inherit|initial|unset)(\\s*!important)?\\s*;?",
-            RegexOption.IGNORE_CASE,
-        )
-        return insideRule.replace(rewritten) { match ->
-            val value = if (match.groupValues[1].equals("avoid", ignoreCase = true)) {
-                "avoid-column"
-            } else {
-                match.groupValues[1].lowercase(Locale.ROOT)
-            }
-            "break-inside:$value${match.groupValues[2]};"
-        }
-    }
-
-    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;")
+    private val TOC_LABEL_ELEMENTS = setOf("a", "span")
+    private val TOC_LIST_ELEMENTS = setOf("ol", "ul")
+    private const val EPUB_MEDIA_TYPE = "application/epub+zip"
+    private const val MAX_EXPANDED_BYTES = 2_147_483_648L
 }
index 7f5997442236674f316844ba5a1fcd8048198bbf..e836dcc1764f4bcb53c2cac90ca8a3371e49b9ea 100644 (file)
@@ -11,12 +11,21 @@ import java.io.FileInputStream
 import java.io.FileOutputStream
 import java.security.MessageDigest
 import java.util.UUID
+import kotlin.math.roundToInt
 
 /** Owns imported files and all reading state. No broad storage permission is required. */
-class LibraryRepository(private val context: Context) {
+internal class LibraryRepository(private val context: Context) : AutoCloseable {
+    data class ReaderState(
+        val location: ReaderLocation,
+        val settings: ReaderSettings,
+        val bookmarks: MutableList<Bookmark>,
+        val annotations: MutableList<Annotation>,
+    )
+
     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 stateWriter = CoalescingFileWriter()
     private val lock = Any()
     private var books = loadIndex().toMutableList()
 
@@ -35,13 +44,14 @@ class LibraryRepository(private val context: Context) {
             }
             selected.first.lowercase().contains(selected.second)
         }
-        when (order) {
+        val sorted = 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 }
         }
+        sorted.map(LibraryBook::copy)
     }
 
     fun sortOrder(): SortOrder = runCatching {
@@ -54,90 +64,135 @@ class LibraryRepository(private val context: Context) {
 
     /** Copies a SAF document into private storage, verifies it, and extracts its EPUB package. */
     fun import(uri: Uri): EpubPublication {
+        WorkCancellation.check()
         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)
+            context.contentResolver.openInputStream(uri)?.use { source ->
+                WorkCancellation.input(source).use { input ->
+                    FileOutputStream(temporary).use { output ->
+                        val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+                        var total = 0L
+                        while (true) {
+                            WorkCancellation.check()
+                            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.")
+            WorkCancellation.check()
             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 id = digest.digest().toHex().take(24)
+            val existing = synchronized(lock) { books.firstOrNull { it.id == id }?.copy() }
+            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)
-            } catch (error: Throwable) {
+            try {
+                val source = File(bookDirectory, "source.epub")
+                if (!temporary.renameTo(source)) copyFile(temporary, source)
+                WorkCancellation.check()
+                val safeName = displayName.replace(INVALID_FILE_NAME_CHARACTER, "_").take(160)
+                val book = LibraryBook(
+                    id = id,
+                    title = safeName.substringBeforeLast('.').ifBlank { "Untitled" },
+                    author = "Unknown author",
+                    fileName = safeName,
+                )
+                val publication = EpubParser.parse(source, File(bookDirectory, "content"), book)
+                synchronized(lock) {
+                    books.removeAll { it.id == id }
+                    books.add(publication.book)
+                    enqueueIndexLocked()
+                }
+                stateWriter.await()
+                WorkCancellation.check()
+                return publication
+            } catch (error: Exception) {
+                synchronized(lock) {
+                    if (books.removeAll { it.id == id }) enqueueIndexLocked()
+                }
                 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.first { it.id == book.id } }
-        val directory = directoryFor(canonical.id)
+        WorkCancellation.check()
+        val stored = synchronized(lock) { books.first { it.id == book.id }.copy() }
+        val directory = directoryFor(stored.id)
         val source = File(directory, "source.epub")
         require(source.isFile) { "The imported EPUB is missing." }
-        return EpubParser.parse(source, File(directory, "content"), canonical)
+        val parsed = EpubParser.parse(source, File(directory, "content"), stored.copy())
+        WorkCancellation.check()
+        return parsed.copy(book = stored)
+    }
+
+    /** Returns a cached reader document, rebuilding it only when its inputs change. */
+    fun readerFile(publication: EpubPublication): File {
+        WorkCancellation.check()
+        val directory = directoryFor(publication.book.id)
+        val reader = File(directory, "reader.html")
+        val version = File(directory, "reader.version")
+        val expectedVersion = readerCacheVersion(publication)
+        if (reader.isFile && reader.length() > 0L && version.readTextOrNull() == expectedVersion) return reader
+
+        val temporary = File.createTempFile("reader-", ".html", directory)
+        try {
+            EpubHtmlBuilder.write(publication, temporary)
+            WorkCancellation.check()
+            AtomicFiles.replace(temporary, reader)
+            AtomicFiles.write(version, expectedVersion)
+        } finally {
+            temporary.delete()
+        }
+        return reader
     }
 
-    fun readerFile(publication: EpubPublication): File =
-        EpubHtmlBuilder.write(publication, File(directoryFor(publication.book.id), "reader.html"))
+    /** Reads a consistent snapshot after all state queued by the previous reader has reached disk. */
+    fun readerState(bookId: String): ReaderState {
+        stateWriter.await()
+        WorkCancellation.check()
+        return ReaderState(
+            location = readLocation(bookId),
+            settings = readSettings(bookId),
+            bookmarks = readArray(bookId, "bookmarks.json", Bookmark::fromJson),
+            annotations = readArray(bookId, "annotations.json", Annotation::fromJson),
+        )
+    }
 
     fun updateProgress(bookId: String, location: ReaderLocation) = synchronized(lock) {
         books.firstOrNull { it.id == bookId }?.let { book ->
-            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
+            val progress = location.progress.coerceIn(0f, 1f)
+            val state = when {
+                progress >= .985f -> LibraryBook.ReadingState.FINISHED
+                progress > 0f -> LibraryBook.ReadingState.READING
                 else -> book.state
             }
-            saveIndex()
+            // The library shows whole percentages. Keep exact progress in memory and
+            // avoid rewriting the full index when its visible value is unchanged.
+            val displayedProgressChanged = (book.progress * 100).roundToInt() != (progress * 100).roundToInt()
+            val stateChanged = book.state != state
+            book.progress = progress
+            book.state = state
+            if (displayedProgressChanged || stateChanged) enqueueIndexLocked()
+            stateWriter.enqueue(File(directoryFor(bookId), "location.json")) { location.toJson().toString() }
         }
-        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()
+            enqueueIndexLocked()
         }
     }
 
@@ -145,62 +200,84 @@ class LibraryRepository(private val context: Context) {
         books.firstOrNull { it.id == bookId }?.let {
             it.state = state
             if (state == LibraryBook.ReadingState.FINISHED) it.progress = 1f
-            saveIndex()
+            enqueueIndexLocked()
         }
     }
 
     fun rename(bookId: String, title: String) = synchronized(lock) {
         books.firstOrNull { it.id == bookId }?.let {
-            it.title = title.trim().ifBlank { it.title }
-            saveIndex()
+            val updated = title.trim().ifBlank { it.title }
+            if (updated != it.title) {
+                it.title = updated
+                enqueueIndexLocked()
+            }
         }
     }
 
-    fun remove(bookId: String) = synchronized(lock) {
-        books.removeAll { it.id == bookId }
-        saveIndex()
+    fun remove(bookId: String) {
+        stateWriter.await()
+        WorkCancellation.check()
+        val removed = synchronized(lock) {
+            val index = books.indexOfFirst { it.id == bookId }
+            if (index < 0) {
+                null
+            } else {
+                val book = books.removeAt(index)
+                enqueueIndexLocked()
+                index to book
+            }
+        } ?: return
+        try {
+            stateWriter.await()
+            WorkCancellation.check()
+        } catch (error: Exception) {
+            synchronized(lock) {
+                books.add(removed.first.coerceAtMost(books.size), removed.second)
+                enqueueIndexLocked()
+            }
+            throw error
+        }
         directoryFor(bookId).deleteRecursively()
     }
 
-    fun location(bookId: String): ReaderLocation {
-        val file = File(directoryFor(bookId), "location.json")
-        return runCatching { ReaderLocation.fromJson(JSONObject(file.readText())) }.getOrDefault(ReaderLocation())
+    fun saveSettings(bookId: String, settings: ReaderSettings, makeDefault: Boolean = false) {
+        val json = settings.toJson().toString()
+        stateWriter.enqueue(File(directoryFor(bookId), "settings.json")) { json }
+        if (makeDefault) preferences.edit().putString("default-settings", json).apply()
+    }
+
+    fun saveBookmarks(bookId: String, values: List<Bookmark>) =
+        enqueueArray(bookId, "bookmarks.json", values.toList(), Bookmark::toJson)
+
+    fun saveAnnotations(bookId: String, values: List<Annotation>) =
+        enqueueArray(bookId, "annotations.json", values.map(Annotation::copy), Annotation::toJson)
+
+    internal fun awaitPendingWrites() = stateWriter.await()
+
+    override fun close() {
+        stateWriter.close()
     }
 
-    private fun saveLocation(bookId: String, location: ReaderLocation) {
-        writeAtomically(File(directoryFor(bookId), "location.json"), location.toJson().toString())
+    private fun readLocation(bookId: String): ReaderLocation {
+        val file = File(directoryFor(bookId), "location.json")
+        return runCatching { ReaderLocation.fromJson(JSONObject(file.readText())) }.getOrDefault(ReaderLocation())
     }
 
-    fun settings(bookId: String): ReaderSettings {
+    private fun readSettings(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 <T> enqueueArray(bookId: String, name: String, values: List<T>, convert: (T) -> JSONObject) {
+        stateWriter.enqueue(File(directoryFor(bookId), name)) {
+            JSONArray().apply { values.forEach { put(convert(it)) } }.toString()
+        }
     }
 
     private fun directoryFor(id: String): File = File(libraryDirectory, id)
@@ -210,30 +287,51 @@ class LibraryRepository(private val context: Context) {
             .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 enqueueIndexLocked() {
+        val snapshot = books.map(LibraryBook::copy)
+        stateWriter.enqueue(indexFile) {
+            JSONArray().apply { snapshot.forEach { put(it.toJson()) } }.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()
+    private fun copyFile(source: File, target: File) {
+        FileInputStream(source).use { fileInput ->
+            WorkCancellation.input(fileInput).use { input ->
+                FileOutputStream(target).use { output ->
+                    val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+                    while (true) {
+                        WorkCancellation.check()
+                        val count = input.read(buffer)
+                        if (count < 0) break
+                        output.write(buffer, 0, count)
+                    }
+                }
+            }
         }
     }
 
+    private fun File.readTextOrNull(): String? = runCatching { takeIf(File::isFile)?.readText() }.getOrNull()
+
     companion object {
+        private val INVALID_FILE_NAME_CHARACTER = Regex("[\\/\\u0000-\\u001f]")
         private const val MAX_IMPORT_BYTES = 1_073_741_824L
 
+        internal fun readerCacheVersion(publication: EpubPublication): String {
+            val inputs = "${publication.book.title}\u0000${publication.book.language}"
+            return "${EpubHtmlBuilder.FORMAT_VERSION}:${MessageDigest.getInstance("SHA-256").digest(inputs.encodeToByteArray()).toHex()}"
+        }
+
+        private fun ByteArray.toHex(): String = joinToString("") { byte ->
+            val value = byte.toInt() and 0xff
+            "${HEX[value ushr 4]}${HEX[value and 0x0f]}"
+        }
+
         private fun displayName(resolver: ContentResolver, uri: Uri): String? = runCatching {
             resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
                 if (cursor.moveToFirst()) cursor.getString(0) else null
             }
         }.getOrNull()
+
+        private const val HEX = "0123456789abcdef"
     }
 }
index 29876fdd2c56cc618d0144a320d8a1ba571b7ca7..d8f4c897fa16a3a5c3650560134a95345dac11d6 100644 (file)
@@ -1,14 +1,18 @@
 package com.alexandria.reader
 
+import android.annotation.SuppressLint
 import android.app.Activity
 import android.app.AlertDialog
-import android.graphics.BitmapFactory
+import android.graphics.Bitmap
+import android.graphics.ImageDecoder
+import android.graphics.Rect
 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.util.LruCache
 import android.view.ViewGroup
 import android.widget.BaseAdapter
 import android.widget.EditText
@@ -18,21 +22,26 @@ import android.widget.LinearLayout
 import android.widget.ListView
 import android.widget.PopupMenu
 import android.widget.TextView
-import android.widget.Toast
+import java.io.File
 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(
+@SuppressLint("ViewConstructor", "SetTextI18n")
+internal class LibraryScreen(
     private val activity: Activity,
     private val repository: LibraryRepository,
     private val openBook: (LibraryBook) -> Unit,
     private val importBooks: () -> Unit,
+    private val removeBook: (LibraryBook) -> Unit,
 ) : FrameLayout(activity) {
     private val list = ListView(activity)
     private val adapter = BookAdapter()
     private val toolbar = LinearLayout(activity)
+    private val coverCache = object : LruCache<String, Bitmap>(COVER_CACHE_KB) {
+        override fun sizeOf(key: String, value: Bitmap): Int = (value.allocationByteCount / 1024).coerceAtLeast(1)
+    }
     private var searchField: EditText? = null
     private var query = ""
     private var sort = repository.sortOrder()
@@ -51,14 +60,7 @@ class LibraryScreen(
         toolbar.setBackgroundColor(EInkPalette.PAPER)
         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(EInkPalette.INK)
-            contentDescription = "Alexandria library"
-        }, LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { gravity = Gravity.CENTER_VERTICAL })
+        toolbar.addView(titleView(), LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f))
         toolbar.addView(button("⌕", "Search library") { _ -> toggleSearch() })
         toolbar.addView(button("⇅", "Sort library") { view -> showSortMenu(view) })
         toolbar.addView(button("+", "Import EPUB") { _ -> importBooks() })
@@ -147,13 +149,20 @@ class LibraryScreen(
         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)
+        input.post {
+            (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(EInkPalette.INK)
+        text = "ALEXANDRIA"
+        textSize = 19f
+        typeface = Typeface.create("sans-serif", Typeface.BOLD)
+        letterSpacing = .11f
+        gravity = Gravity.CENTER_VERTICAL
+        setTextColor(EInkPalette.INK)
+        contentDescription = "Alexandria library"
     }
 
     private fun showSortMenu(anchor: View) {
@@ -211,11 +220,11 @@ class LibraryScreen(
     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() }
+            .setPositiveButton("Remove") { _, _ -> removeBook(book) }
             .setNegativeButton("Cancel", null).show()
     }
 
-    fun refresh() {
+    private fun refresh() {
         adapter.replace(repository.books(sort, query))
     }
 
@@ -275,16 +284,42 @@ class LibraryScreen(
             }
             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()
-            }
+            val bitmap = book.coverPath?.let(::coverBitmap)
             if (bitmap != null) cover.setImageBitmap(bitmap) else cover.setImageDrawable(null)
             contentDescription = "${book.title}, ${book.author}, ${progress.text}"
         }
     }
 
+    private fun coverBitmap(path: String): Bitmap? {
+        coverCache.get(path)?.let { return it }
+        val targetWidth = dp(62)
+        val targetHeight = dp(88)
+        val bitmap = runCatching {
+            ImageDecoder.decodeBitmap(ImageDecoder.createSource(File(path))) { decoder, info, _ ->
+                val width = info.size.width
+                val height = info.size.height
+                require(width > 0 && height > 0) { "Invalid cover image dimensions." }
+                val crop = if (width.toLong() * targetHeight > height.toLong() * targetWidth) {
+                    val cropWidth = (height.toLong() * targetWidth / targetHeight).toInt().coerceAtLeast(1)
+                    val left = (width - cropWidth) / 2
+                    Rect(left, 0, left + cropWidth, height)
+                } else {
+                    val cropHeight = (width.toLong() * targetHeight / targetWidth).toInt().coerceAtLeast(1)
+                    val top = (height - cropHeight) / 2
+                    Rect(0, top, width, top + cropHeight)
+                }
+                decoder.crop = crop
+                decoder.setTargetSize(targetWidth, targetHeight)
+                decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
+            }
+        }.getOrNull() ?: return null
+        coverCache.put(path, bitmap)
+        return bitmap
+    }
+
     private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt()
+
+    companion object {
+        private const val COVER_CACHE_KB = 8 * 1024
+    }
 }
index 096f3e4de1bcdeae24f9ac0af6aca4cd44619218..e67619a735b4372da5136ec83a5a42b2899eac9a 100644 (file)
@@ -12,20 +12,32 @@ import androidx.activity.ComponentActivity
 import androidx.activity.addCallback
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.core.content.IntentCompat
+import java.io.File
+import java.util.concurrent.CancellationException
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
 import java.util.concurrent.atomic.AtomicInteger
 
 class MainActivity : ComponentActivity() {
+    private data class PreparedBook(
+        val publication: EpubPublication,
+        val readerFile: File,
+        val state: LibraryRepository.ReaderState,
+    )
+
     private lateinit var repository: LibraryRepository
     private var libraryScreen: LibraryScreen? = null
     private var readerScreen: ReaderScreen? = null
     private val operation = AtomicInteger()
+    private val ioExecutor = Executors.newSingleThreadExecutor()
+    private var activeWork: Future<*>? = null
     private val openDocuments = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
         if (uris.isNotEmpty()) importUris(uris)
     }
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
-        repository = LibraryRepository(this)
+        repository = LibraryRepository(applicationContext)
         onBackPressedDispatcher.addCallback(this) { navigateBack() }
         if (!handleIntent(intent)) showLibrary()
     }
@@ -53,10 +65,10 @@ class MainActivity : ComponentActivity() {
     }
 
     private fun showLibrary() {
-        operation.incrementAndGet()
+        beginOperation()
         readerScreen?.dispose()
         readerScreen = null
-        val screen = LibraryScreen(this, repository, ::openBook, ::openPicker)
+        val screen = LibraryScreen(this, repository, ::openBook, ::openPicker, ::removeBook)
         libraryScreen = screen
         setContentView(screen)
         title = "Alexandria Library"
@@ -67,46 +79,88 @@ class MainActivity : ComponentActivity() {
     }
 
     private fun importUris(uris: List<Uri>) {
-        val token = operation.incrementAndGet()
+        val token = beginOperation()
         showLoading(if (uris.size == 1) "Importing EPUB…" else "Importing ${uris.size} EPUBs…")
-        Thread {
+        submitOperation {
             var last: EpubPublication? = null
-            var failure: Throwable? = null
-            uris.forEach { uri ->
+            var failure: Exception? = null
+            for (uri in uris) {
+                if (token != operation.get()) return@submitOperation
                 try {
                     last = repository.import(uri)
-                } catch (error: Throwable) {
+                } catch (error: Exception) {
+                    WorkCancellation.check()
                     failure = error
                 }
             }
+            if (token != operation.get()) return@submitOperation
+            val prepared = try {
+                last?.let(::prepare)
+            } catch (error: Exception) {
+                failure = error
+                null
+            }
+            runOnUiThread {
+                if (token != operation.get()) return@runOnUiThread
+                if (prepared != null) showReader(prepared) else showImportError(requireNotNull(failure))
+                if (prepared != null && failure != null) Toast.makeText(this, "Some files could not be imported", Toast.LENGTH_LONG).show()
+            }
+        }
+    }
+
+    private fun removeBook(book: LibraryBook) {
+        val token = beginOperation()
+        showLoading("Removing ${book.title}…")
+        submitOperation {
+            val failure = try {
+                repository.remove(book.id)
+                null
+            } catch (error: Exception) {
+                WorkCancellation.check()
+                error
+            }
             runOnUiThread {
                 if (token != operation.get()) return@runOnUiThread
-                val publication = last
-                if (publication != null) showReader(publication) else showImportError(requireNotNull(failure))
-                if (publication != null && failure != null) Toast.makeText(this, "Some files could not be imported", Toast.LENGTH_LONG).show()
+                showLibrary()
+                if (failure != null) Toast.makeText(this, "The book could not be removed", Toast.LENGTH_LONG).show()
             }
-        }.start()
+        }
     }
 
     private fun openBook(book: LibraryBook) {
-        val token = operation.incrementAndGet()
+        val token = beginOperation()
         showLoading("Opening ${book.title}…")
-        Thread {
-            val result = runCatching { repository.open(book) }
+        submitOperation {
+            val result = try {
+                val publication = repository.open(book)
+                if (token != operation.get()) return@submitOperation
+                Result.success(prepare(publication))
+            } catch (error: Exception) {
+                WorkCancellation.check()
+                Result.failure(error)
+            }
             runOnUiThread {
                 if (token != operation.get()) return@runOnUiThread
                 result.onSuccess(::showReader).onFailure(::showImportError)
             }
-        }.start()
+        }
     }
 
-    private fun showReader(publication: EpubPublication) {
+    private fun prepare(publication: EpubPublication): PreparedBook = PreparedBook(
+        publication = publication,
+        readerFile = repository.readerFile(publication),
+        state = repository.readerState(publication.book.id),
+    )
+
+    private fun showReader(prepared: PreparedBook) {
         readerScreen?.dispose()
         libraryScreen = null
-        val screen = ReaderScreen(this, publication, repository, ::showLibrary)
+        val screen = ReaderScreen(
+            this, prepared.publication, prepared.readerFile, prepared.state, repository, ::showLibrary,
+        )
         readerScreen = screen
         setContentView(screen)
-        title = publication.book.title
+        title = prepared.publication.book.title
     }
 
     private fun showLoading(message: String) {
@@ -143,6 +197,7 @@ class MainActivity : ComponentActivity() {
         } else if (libraryScreen?.searchFocusActive() == true) {
             libraryScreen?.closeSearch()
         } else {
+            beginOperation()
             finish()
         }
     }
@@ -157,10 +212,32 @@ class MainActivity : ComponentActivity() {
         readerScreen?.resume()
     }
 
+    private fun beginOperation(): Int {
+        activeWork?.cancel(true)
+        activeWork = null
+        return operation.incrementAndGet()
+    }
+
+    private fun submitOperation(action: () -> Unit) {
+        activeWork = ioExecutor.submit {
+            try {
+                action()
+            } catch (_: CancellationException) {
+                // A newer navigation request owns the screen.
+            } catch (_: InterruptedException) {
+                Thread.currentThread().interrupt()
+            }
+        }
+    }
+
     override fun onDestroy() {
-        operation.incrementAndGet()
+        beginOperation()
         readerScreen?.dispose()
         readerScreen = null
+        // Close the repository after the cancelled task has unwound. This keeps
+        // its state writer available for import cleanup and the reader's final save.
+        ioExecutor.execute(repository::close)
+        ioExecutor.shutdown()
         super.onDestroy()
     }
 }
index da267944dd07ffd50dfbf4db76a0e8cd680e8095..f907d9327c01413b932dc1ddd5eb7c20caf33225 100644 (file)
@@ -5,7 +5,7 @@ import org.json.JSONObject
 import java.io.File
 
 /** Metadata kept for every document that Alexandria imports into its private library. */
-data class LibraryBook(
+internal data class LibraryBook(
     val id: String,
     var title: String,
     var author: String,
@@ -37,37 +37,43 @@ data class LibraryBook(
     }
 
     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),
-            state = runCatching { ReadingState.valueOf(value.optString("state", "NEW")) }
-                .getOrDefault(ReadingState.NEW),
-        )
+        private val ID_PATTERN = Regex("[0-9a-f]{24}")
+
+        fun fromJson(value: JSONObject): LibraryBook {
+            val id = value.getString("id")
+            require(ID_PATTERN.matches(id)) { "Invalid library book ID." }
+            return LibraryBook(
+                id = 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()).coerceAtLeast(0L),
+                lastOpenedAt = value.optLong("lastOpenedAt").coerceAtLeast(0L),
+                progress = value.finiteFloat("progress", 0f).coerceIn(0f, 1f),
+                state = runCatching { ReadingState.valueOf(value.optString("state", "NEW")) }
+                    .getOrDefault(ReadingState.NEW),
+            )
+        }
     }
 }
 
-data class SpineItem(
+internal data class SpineItem(
     val href: String,
     val title: String,
 )
 
-data class TocEntry(
+internal data class TocEntry(
     val title: String,
     val href: String,
     val depth: Int,
     val spineIndex: Int,
 )
 
-data class EpubPublication(
+internal data class EpubPublication(
     val book: LibraryBook,
     val rootDirectory: File,
     val spine: List<SpineItem>,
@@ -78,7 +84,7 @@ data class EpubPublication(
     val defaultPageHeight: Int,
 )
 
-data class ReaderLocation(
+internal data class ReaderLocation(
     val page: Int = 0,
     val totalPages: Int = 1,
     val chapter: Int = 0,
@@ -94,17 +100,20 @@ data class ReaderLocation(
     }
 
     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),
-        )
+        fun fromJson(value: JSONObject): ReaderLocation {
+            val totalPages = value.optInt("totalPages", 1).coerceAtLeast(1)
+            return ReaderLocation(
+                page = value.optInt("page").coerceIn(0, totalPages - 1),
+                totalPages = totalPages,
+                chapter = value.optInt("chapter").coerceAtLeast(0),
+                offset = value.optInt("offset").coerceAtLeast(0),
+                progress = value.finiteFloat("progress", 0f).coerceIn(0f, 1f),
+            )
+        }
     }
 }
 
-data class ReaderSettings(
+internal data class ReaderSettings(
     val fontFamily: String = ATKINSON_HYPERLEGIBLE_NEXT,
     val fontSize: Int = 20,
     val lineHeight: Float = 1.32f,
@@ -131,6 +140,8 @@ data class ReaderSettings(
         const val PUBLISHER = "Publisher"
         const val ATKINSON_HYPERLEGIBLE_NEXT = "Atkinson Hyperlegible Next"
         val FONT_FAMILIES = listOf(PUBLISHER, ATKINSON_HYPERLEGIBLE_NEXT)
+        private val TEXT_ALIGNMENTS = setOf("publisher", "left", "right", "center", "justify")
+        private val READING_MODES = setOf("paged", "continuous")
 
         fun fromJson(value: JSONObject): ReaderSettings = ReaderSettings(
             fontFamily = when (value.optString("fontFamily", ATKINSON_HYPERLEGIBLE_NEXT)) {
@@ -138,19 +149,18 @@ data class ReaderSettings(
                 else -> ATKINSON_HYPERLEGIBLE_NEXT
             },
             fontSize = value.optInt("fontSize", 20).coerceIn(12, 38),
-            lineHeight = value.optDouble("lineHeight", 1.32).toFloat().coerceIn(1f, 2f),
+            lineHeight = value.finiteFloat("lineHeight", 1.32f).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",
+            textAlign = value.optString("textAlign", "publisher").takeIf { it in TEXT_ALIGNMENTS } ?: "publisher",
             publisherStyles = value.optBoolean("publisherStyles", true),
             hyphenation = value.optBoolean("hyphenation", true),
-            mode = value.optString("mode", "paged").takeIf { it in setOf("paged", "continuous") } ?: "paged",
+            mode = value.optString("mode", "paged").takeIf { it in READING_MODES } ?: "paged",
             brightness = value.optInt("brightness", -1).coerceIn(-1, 100),
         )
     }
 }
 
-data class Bookmark(
+internal data class Bookmark(
     val label: String,
     val chapter: Int,
     val offset: Int,
@@ -165,14 +175,14 @@ data class Bookmark(
 
     companion object {
         fun fromJson(value: JSONObject) = Bookmark(
-            value.optString("label", "Bookmark"), value.optInt("chapter"),
-            value.optInt("offset"), value.optInt("page"), value.optDouble("progress").toFloat(),
-            value.optLong("createdAt"),
+            value.optString("label", "Bookmark"), value.optInt("chapter").coerceAtLeast(0),
+            value.optInt("offset").coerceAtLeast(0), value.optInt("page").coerceAtLeast(0),
+            value.finiteFloat("progress", 0f).coerceIn(0f, 1f), value.optLong("createdAt").coerceAtLeast(0L),
         )
     }
 }
 
-data class Annotation(
+internal data class Annotation(
     val id: String,
     val chapter: Int,
     val start: Int,
@@ -186,12 +196,22 @@ data class Annotation(
     }
 
     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"),
-        )
+        fun fromJson(value: JSONObject): Annotation {
+            val id = value.getString("id")
+            require(id.isNotBlank()) { "Invalid annotation ID." }
+            val start = value.optInt("start").coerceAtLeast(0)
+            return Annotation(
+                id, value.optInt("chapter").coerceAtLeast(0), start,
+                value.optInt("end").coerceAtLeast(start), value.optString("quote"), value.optString("note"),
+            )
+        }
     }
 }
 
 internal fun <T> JSONArray.mapObjects(block: (JSONObject) -> T): List<T> =
-    (0 until length()).mapNotNull { index -> optJSONObject(index)?.let(block) }
+    (0 until length()).mapNotNull { index ->
+        optJSONObject(index)?.let { value -> runCatching { block(value) }.getOrNull() }
+    }
+
+private fun JSONObject.finiteFloat(name: String, fallback: Float): Float =
+    optDouble(name, fallback.toDouble()).takeIf(Double::isFinite)?.toFloat() ?: fallback
diff --git a/app/src/main/java/com/alexandria/reader/Persistence.kt b/app/src/main/java/com/alexandria/reader/Persistence.kt
new file mode 100644 (file)
index 0000000..1f8c9e0
--- /dev/null
@@ -0,0 +1,116 @@
+package com.alexandria.reader
+
+import android.util.Log
+import java.io.File
+import java.io.FileOutputStream
+import java.io.OutputStreamWriter
+import java.nio.charset.StandardCharsets
+import java.nio.file.AtomicMoveNotSupportedException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import java.util.LinkedHashMap
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.atomic.AtomicReference
+
+/** Durable replacement for small private-state files. */
+internal object AtomicFiles {
+    fun write(target: File, value: String) {
+        val parent = requireNotNull(target.parentFile)
+        require(parent.mkdirs() || parent.isDirectory) { "Could not create ${parent.path}." }
+        val temporary = File.createTempFile(".${target.name}-", ".tmp", parent)
+        try {
+            FileOutputStream(temporary).use { stream ->
+                val writer = OutputStreamWriter(stream, StandardCharsets.UTF_8)
+                writer.write(value)
+                writer.flush()
+                stream.fd.sync()
+            }
+            replace(temporary, target)
+        } finally {
+            temporary.delete()
+        }
+    }
+
+    fun replace(source: File, target: File) {
+        try {
+            Files.move(
+                source.toPath(), target.toPath(),
+                StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING,
+            )
+        } catch (_: AtomicMoveNotSupportedException) {
+            Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
+        }
+    }
+}
+
+/**
+ * Serializes private-state writes and retains only the latest queued value for
+ * each file. Value creation also runs off the caller thread.
+ */
+internal class CoalescingFileWriter(
+    private val executor: ExecutorService = Executors.newSingleThreadExecutor { task ->
+        Thread(task, "alexandria-state-writer")
+    },
+    private val write: (File, String) -> Unit = AtomicFiles::write,
+) : AutoCloseable {
+    private data class PendingWrite(val target: File, val value: () -> String)
+
+    private val lock = Any()
+    private val pending = LinkedHashMap<String, PendingWrite>()
+    private val failure = AtomicReference<Throwable?>()
+    private var drainScheduled = false
+    private var closed = false
+
+    fun enqueue(target: File, value: () -> String) = synchronized(lock) {
+        check(!closed) { "The state writer is closed." }
+        val normalized = target.absoluteFile.normalize()
+        pending[normalized.path] = PendingWrite(normalized, value)
+        scheduleDrainLocked()
+    }
+
+    /** Waits for writes already queued when this method starts. Never call it from the UI thread. */
+    fun await() {
+        val complete = CountDownLatch(1)
+        synchronized(lock) {
+            check(!closed) { "The state writer is closed." }
+            executor.execute(complete::countDown)
+        }
+        complete.await()
+        failure.getAndSet(null)?.let { throw IllegalStateException("Could not save reading state.", it) }
+    }
+
+    override fun close() = synchronized(lock) {
+        if (closed) return@synchronized
+        closed = true
+        if (pending.isNotEmpty()) scheduleDrainLocked()
+        executor.shutdown()
+    }
+
+    private fun scheduleDrainLocked() {
+        if (drainScheduled) return
+        drainScheduled = true
+        executor.execute(::drain)
+    }
+
+    private fun drain() {
+        while (true) {
+            val next = synchronized(lock) {
+                val entry = pending.entries.firstOrNull()
+                if (entry == null) {
+                    drainScheduled = false
+                    return
+                }
+                pending.remove(entry.key)
+                entry.value
+            }
+            try {
+                write(next.target, next.value())
+            } catch (error: Exception) {
+                failure.compareAndSet(null, error)
+                Log.e("AlexandriaState", "Could not write ${next.target.path}", error)
+            }
+        }
+    }
+}
index d64bb23b564d35d5ae13dbf2193717e486689d61..c93c44f3bd29f3eb6edd7e6d16f2ef93bea4afaa 100644 (file)
@@ -1,5 +1,6 @@
 package com.alexandria.reader
 
+import android.annotation.SuppressLint
 import android.app.Activity
 import android.app.AlertDialog
 import android.content.ClipData
@@ -7,16 +8,13 @@ import android.content.ClipboardManager
 import android.content.Context
 import android.content.Intent
 import android.content.pm.ActivityInfo
-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
@@ -31,17 +29,20 @@ 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.io.File
 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(
+@SuppressLint("ViewConstructor", "SetTextI18n")
+internal class ReaderScreen(
     private val activity: Activity,
     private val publication: EpubPublication,
+    private val readerFile: File,
+    initialState: LibraryRepository.ReaderState,
     private val repository: LibraryRepository,
     private val closeReader: () -> Unit,
 ) : FrameLayout(activity), ReaderWebView.Listener {
@@ -58,23 +59,26 @@ class ReaderScreen(
     private var controlsVisible = true
     private var initialized = false
     private var restoring = true
-    private val initialLocation = repository.location(publication.book.id)
+    private val initialLocation = initialState.location
     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 var lastSavedLocation = initialLocation
+    private var settings = initialState.settings
+    private val bookmarks = initialState.bookmarks
+    private val annotations = initialState.annotations
     private val locationHistory = ArrayDeque<ReaderLocation>()
     private var pendingSave: Runnable? = null
     private var searchCount = 0
     private var searchIndex = -1
     private var searchDirection = ReaderWebView.SearchDirection.FORWARD
+    private var settingsRevision = 0
+    private var disposed = false
 
     init {
         setBackgroundColor(EInkPalette.PAPER)
         isFocusableInTouchMode = true
         buildReader()
         webView.listener = this
-        webView.load(repository.readerFile(publication))
+        webView.load(readerFile, publication.rootDirectory)
         repository.markOpened(publication.book.id)
         applyBrightness()
     }
@@ -91,7 +95,7 @@ class ReaderScreen(
         topBar.setPadding(dp(4), 0, dp(4), 0)
         addView(topBar, LayoutParams(LayoutParams.MATCH_PARENT, barHeight, Gravity.TOP))
 
-        topBar.addView(actionButton("‹", "Back to library") { _ -> flush(); closeReader() }, lp(dp(52)))
+        topBar.addView(actionButton("‹", "Back to library") { _ -> closeReader() }, lp(dp(52)))
         titleLabel.apply {
             text = publication.book.title
             textSize = 15f
@@ -167,23 +171,27 @@ class ReaderScreen(
     }
 
     override fun onReady() {
+        if (disposed) return
         initialized = true
         webView.applyAnnotations(annotations)
         webView.applySettings(settings, initialLocation)
-        handler.postDelayed({
-            restoring = false
-            loading.visibility = View.GONE
-            webView.captureLocation { onLocationChanged(it) }
-        }, 350)
+    }
+
+    override fun onLayoutReady() {
+        if (disposed || !restoring) return
+        restoring = false
+        loading.visibility = View.GONE
+        webView.captureLocation(::onLocationChanged)
     }
 
     override fun onLocationChanged(location: ReaderLocation) {
+        if (disposed) return
         // 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") {
+        progressLabel.text = if (!publication.fixedLayout && settings.mode == "continuous") {
             "${percent}%  ·  ${location.page + 1} / ${location.totalPages}"
         } else {
             "Page ${location.page + 1} of ${location.totalPages}  ·  ${percent}%"
@@ -198,10 +206,20 @@ class ReaderScreen(
     }
 
     private fun scheduleSave(location: ReaderLocation) {
+        if (location == lastSavedLocation) return
         pendingSave?.let(handler::removeCallbacks)
-        val task = Runnable { repository.updateProgress(publication.book.id, location) }
+        val task = Runnable {
+            pendingSave = null
+            persist(location)
+        }
         pendingSave = task
-        handler.postDelayed(task, 250)
+        handler.postDelayed(task, SAVE_DELAY_MS)
+    }
+
+    private fun persist(location: ReaderLocation) {
+        if (location == lastSavedLocation) return
+        repository.updateProgress(publication.book.id, location)
+        lastSavedLocation = location
     }
 
     override fun onTap(x: Float, y: Float) {
@@ -267,7 +285,7 @@ class ReaderScreen(
             menu.add("Go to page").setOnMenuItemClickListener { showGoToPage(); true }
             menu.add("Rotate screen").setOnMenuItemClickListener { rotateScreen(); true }
             menu.add("Book information").setOnMenuItemClickListener { showBookInformation(); true }
-            menu.add("Close book").setOnMenuItemClickListener { flush(); closeReader(); true }
+            menu.add("Close book").setOnMenuItemClickListener { closeReader(); true }
             show()
         }
     }
@@ -311,11 +329,13 @@ class ReaderScreen(
                 .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))}"
+        val sorted = bookmarks.sortedBy { it.progress }
+        val dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM)
+        val labels = sorted.map {
+            "${it.label}\n${(it.progress * 100).roundToInt()}% · ${dateFormat.format(Date(it.createdAt))}"
         }.toTypedArray()
         AlertDialog.Builder(activity).setTitle("Bookmarks").setItems(labels) { _, index ->
-            val bookmark = bookmarks.sortedBy { it.progress }[index]
+            val bookmark = sorted[index]
             rememberLocation()
             webView.goToLocation(ReaderLocation(bookmark.page, current.totalPages, bookmark.chapter, bookmark.offset, bookmark.progress))
         }.setNeutralButton("Remove all") { _, _ ->
@@ -332,7 +352,7 @@ class ReaderScreen(
         }
         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)
+            val quote = annotation.quote.replace(WHITESPACE, " ").take(90)
             if (annotation.note.isBlank()) "“$quote”" else "${annotation.note}\n“$quote”"
         }.toTypedArray()
         AlertDialog.Builder(activity).setTitle("Annotations and highlights").setItems(labels) { _, index ->
@@ -375,7 +395,7 @@ class ReaderScreen(
 
     private fun showAnnotationEditor(annotation: Annotation) {
         val message = buildString {
-            append('“').append(annotation.quote.replace(Regex("\\s+"), " ").take(600)).append('”')
+            append('“').append(annotation.quote.replace(WHITESPACE, " ").take(600)).append('”')
             if (annotation.note.isNotBlank()) append("\n\n").append(annotation.note)
         }
         AlertDialog.Builder(activity).setTitle(if (annotation.note.isBlank()) "Highlight" else "Annotation")
@@ -487,7 +507,7 @@ class ReaderScreen(
                 if (submitted) {
                     val query = text.toString()
                     hideKeyboard(this)
-                    handler.postDelayed({ runSearch(query) }, 180)
+                    runSearch(query)
                     true
                 } else false
             }
@@ -526,10 +546,9 @@ class ReaderScreen(
         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
+    override fun onSearchResults(count: Int, currentIndex: Int) {
+        searchCount = count
+        searchIndex = if (searchCount > 0) currentIndex.coerceIn(0, searchCount - 1) else -1
         updateSearchStatus()
         if (searchCount == 0) Toast.makeText(activity, "No matches", Toast.LENGTH_SHORT).show()
     }
@@ -630,17 +649,14 @@ class ReaderScreen(
 
     private fun updateSettings(value: ReaderSettings, reflow: Boolean = true) {
         if (value == settings) return
+        settings = value
+        repository.saveSettings(publication.book.id, settings)
+        applyBrightness()
         if (reflow && initialized) {
+            val revision = ++settingsRevision
             webView.captureLocation { location ->
-                settings = value
-                repository.saveSettings(publication.book.id, settings)
-                applyBrightness()
-                webView.applySettings(settings, location)
+                if (!disposed && revision == settingsRevision) webView.applySettings(settings, location)
             }
-        } else {
-            settings = value
-            repository.saveSettings(publication.book.id, settings)
-            applyBrightness()
         }
     }
 
@@ -679,7 +695,7 @@ class ReaderScreen(
 
     override fun onExternalLink(url: String) {
         val scheme = runCatching { Uri.parse(url).scheme?.lowercase() }.getOrNull()
-        if (scheme !in setOf("http", "https", "mailto", "tel")) return
+        if (scheme !in EXTERNAL_LINK_SCHEMES) return
         AlertDialog.Builder(activity).setTitle("Open external link?").setMessage(url)
             .setPositiveButton("Open") { _, _ ->
                 runCatching { activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
@@ -715,20 +731,29 @@ class ReaderScreen(
         webView.resumeTimers()
     }
 
-    fun flush() {
+    private fun flush() {
         pendingSave?.let(handler::removeCallbacks)
-        repository.updateProgress(publication.book.id, current)
+        pendingSave = null
+        if (!restoring) persist(current)
     }
 
     fun dispose() {
+        if (disposed) return
         flush()
+        disposed = true
         handler.removeCallbacksAndMessages(null)
         removeView(webView)
         webView.destroy()
+        activity.window.attributes = activity.window.attributes.apply {
+            screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
+        }
     }
 
     private fun showKeyboard(view: View) {
-        handler.postDelayed({ (activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) }, 120)
+        view.post {
+            (activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
+                .showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
+        }
     }
 
     private fun hideKeyboard(view: View) {
@@ -738,4 +763,9 @@ class ReaderScreen(
     private fun lp(width: Int): LinearLayout.LayoutParams = LinearLayout.LayoutParams(width, LayoutParams.MATCH_PARENT)
     private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt()
 
+    companion object {
+        private const val SAVE_DELAY_MS = 250L
+        private val EXTERNAL_LINK_SCHEMES = setOf("http", "https", "mailto", "tel")
+        private val WHITESPACE = Regex("\\s+")
+    }
 }
diff --git a/app/src/main/java/com/alexandria/reader/ReaderScripts.kt b/app/src/main/java/com/alexandria/reader/ReaderScripts.kt
new file mode 100644 (file)
index 0000000..98ca388
--- /dev/null
@@ -0,0 +1,276 @@
+package com.alexandria.reader
+
+internal const val IMAGE_DITHER_SCRIPT = """
+function applyPublisherStyles(enabled) {
+  document.getElementById('alex-publisher-styles').disabled=!enabled;
+  Array.prototype.forEach.call(document.querySelectorAll('[data-alex-publisher-style]'),function(element){
+    if(enabled)element.setAttribute('style',element.getAttribute('data-alex-publisher-style'));else element.removeAttribute('style');
+  });
+}
+function applyImageDithering(root) {
+  var filter=document.getElementById('alex-image-dither');
+  if(!filter)return;
+  if(!filter.firstChild){
+    function node(name,attributes){var value=document.createElementNS('http://www.w3.org/2000/svg',name);Object.keys(attributes||{}).forEach(function(key){value.setAttribute(key,String(attributes[key]));});return value;}
+    filter.appendChild(node('feColorMatrix',{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(node('feTurbulence',{type:'fractalNoise',baseFrequency:'.47',numOctaves:'1',seed:'37',stitchTiles:'stitch',result:'alex-noise'}));
+    filter.appendChild(node('feColorMatrix',{in:'alex-noise',type:'matrix',result:'alex-drift',values:'.065 0 0 0 -.0325  .065 0 0 0 -.0325  .065 0 0 0 -.0325  0 0 0 1 0'}));
+    filter.appendChild(node('feComposite',{in:'alex-gray',in2:'alex-drift',operator:'arithmetic',k1:'0',k2:'1',k3:'1',k4:'0',result:'alex-noisy'}));
+    var levels=[];for(var level=0;level<16;level++)levels.push((level/15).toFixed(4));
+    var quantized=node('feComponentTransfer',{in:'alex-noisy',result:'alex-quantized'});
+    ['R','G','B'].forEach(function(channel){quantized.appendChild(node('feFunc'+channel,{type:'discrete',tableValues:levels.join(' ')}));});
+    quantized.appendChild(node('feFuncA',{type:'table',tableValues:'0 1'}));filter.appendChild(quantized);
+    filter.appendChild(node('feComposite',{in:'alex-quantized',in2:'SourceGraphic',operator:'in'}));
+  }
+  Array.prototype.forEach.call(root.querySelectorAll('img,canvas,video,.alex-chapter svg'),function(image){image.style.filter='url(#alex-image-dither)';});
+}
+"""
+
+internal const val REFLOWABLE_READER_SCRIPT = """
+(function () {
+  'use strict';
+  var chapters = Array.prototype.slice.call(document.querySelectorAll('.alex-chapter'));
+  var state = { page:0, total:1, mode:'paged', chapterStarts:[], searchIndex:-1, searchToken:0, layoutToken:0, layoutReady:false };
+  var visibleChapter = null;
+  var scrolling = function () { return document.scrollingElement; };
+  var pageExtent = function () { return state.mode === 'paged' ? window.innerWidth : window.innerHeight; };
+  var scrollPosition = function () { return scrolling().scrollTop; };
+  function clamp(value, minimum, maximum) { return Math.max(minimum, Math.min(maximum, value)); }
+  function documentExtent() { var root=scrolling(); return Math.max(root.scrollHeight, document.body.scrollHeight); }
+  function chapterIndex(element) {
+    var chapter=element ? element.closest('.alex-chapter') : null;
+    return chapter ? clamp(parseInt(chapter.getAttribute('data-spine') || '0'),0,chapters.length-1) : 0;
+  }
+  function chapterAt(page) {
+    var low=0,high=Math.max(0,state.chapterStarts.length-1),result=0;
+    while(low<=high){var middle=(low+high)>>1;if((state.chapterStarts[middle]||0)<=page){result=middle;low=middle+1;}else high=middle-1;}
+    return result;
+  }
+  function setVisibleChapter(index) {
+    var next=chapters[clamp(index,0,chapters.length-1)];
+    if(visibleChapter!==next){if(visibleChapter)visibleChapter.classList.remove('alex-current-page');next.classList.add('alex-current-page');visibleChapter=next;}
+    return next;
+  }
+  function showPaged(page, report) {
+    state.page=clamp(Math.round(Number(page)||0),0,state.total-1);
+    var chapter=chapterAt(state.page),local=state.page-(state.chapterStarts[chapter]||0),element=setVisibleChapter(chapter);
+    element.style.transform='translateX('+(-local*Math.max(1,window.innerWidth))+'px)';
+    if(report!==false)notifyPage();
+  }
+  function scrollToPage(page, report) {
+    if(state.mode==='paged'){showPaged(page,report);return;}
+    state.page=clamp(Math.round(Number(page)||0),0,state.total-1);
+    scrolling().scrollTo(0,state.page*Math.max(1,pageExtent()));
+    if(report!==false)notifyPage();
+  }
+  function withMeasuredChapter(index, action) {
+    var chapter=chapters[clamp(index,0,chapters.length-1)],wasVisible=chapter===visibleChapter,transform=chapter.style.transform;
+    if(!wasVisible)chapter.classList.add('alex-measuring');
+    chapter.style.transform='none';
+    var result=action(chapter);
+    chapter.style.transform=transform;
+    if(!wasVisible)chapter.classList.remove('alex-measuring');
+    return result;
+  }
+  function localPageForElement(element) {
+    if(!element)return 0;
+    var index=chapterIndex(element);
+    return withMeasuredChapter(index,function(chapter){
+      var rects=element.getClientRects(),rect=rects.length?rects[0]:element.getBoundingClientRect(),origin=chapter.getBoundingClientRect().left;
+      return Math.max(0,Math.floor((rect.left-origin+2)/Math.max(1,window.innerWidth)));
+    });
+  }
+  function pageForElement(element) {
+    if(!element)return 0;
+    if(state.mode==='paged'){var index=chapterIndex(element);return(state.chapterStarts[index]||0)+localPageForElement(element);}
+    var rects=element.getClientRects(),rect=rects.length?rects[0]:element.getBoundingClientRect();
+    return Math.max(0,Math.floor((rect.top+scrolling().scrollTop+2)/Math.max(1,pageExtent())));
+  }
+  function measurePaged(keep, complete) {
+    var token=++state.layoutToken,index=0,total=0,starts=[];state.layoutReady=false;
+    function batch(){
+      if(token!==state.layoutToken)return;
+      var limit=Math.min(chapters.length,index+8);
+      for(;index<limit;index++){
+        var chapter=chapters[index],wasVisible=chapter===visibleChapter,transform=chapter.style.transform;
+        if(!wasVisible)chapter.classList.add('alex-measuring');chapter.style.transform='none';
+        starts[index]=total;
+        var extent=Math.max(chapter.scrollWidth,chapter.getBoundingClientRect().width);
+        var count=Math.max(1,Math.ceil((extent-1)/Math.max(1,window.innerWidth)));
+        total+=count;chapter.style.transform=transform;if(!wasVisible)chapter.classList.remove('alex-measuring');
+      }
+      if(index<chapters.length){requestAnimationFrame(batch);return;}
+      state.chapterStarts=starts;state.total=Math.max(1,total);state.layoutReady=true;
+      goToLocator(keep||{page:0,chapter:0,offset:0,progress:0},true);if(complete)complete();
+    }
+    requestAnimationFrame(batch);
+  }
+  function recalculateContinuous() {
+    var extent=documentExtent();state.total=Math.max(1,Math.ceil((extent-1)/Math.max(1,pageExtent())));
+    state.page=clamp(Math.round(scrollPosition()/Math.max(1,pageExtent())),0,state.total-1);
+    state.chapterStarts=Array.prototype.map.call(document.querySelectorAll('.alex-chapter-start'),pageForElement);
+    notifyPage();
+  }
+  function refreshLayout(keep, complete) {
+    if(state.mode==='paged'){measurePaged(keep,complete);return;}
+    ++state.layoutToken;state.layoutReady=false;
+    chapters.forEach(function(chapter){chapter.classList.remove('alex-current-page','alex-measuring');chapter.style.transform='none';});visibleChapter=null;
+    setTimeout(function(){recalculateContinuous();state.layoutReady=true;if(keep)goToLocator(keep,true);if(complete)complete();},40);
+  }
+  function textOffsetAtPoint(chapter) {
+    var range=null,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)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 index=chapterAt(state.page),chapter=chapters[index];
+    return{page:state.page,totalPages:state.total,chapter:index,offset:chapter?textOffsetAtPoint(chapter):0,progress:state.total<=1?0:state.page/(state.total-1)};
+  }
+  function notifyPage() { if(window.Android&&Android.onPageChanged){var value=locator();Android.onPageChanged(value.page,value.totalPages,value.chapter,value.offset,value.progress);} }
+  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;}}),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 pageForTextPoint(chapterIndex, point) {
+    return withMeasuredChapter(chapterIndex,function(chapter){
+      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))return-1;
+      if(state.mode==='paged')return(state.chapterStarts[chapterIndex]||0)+Math.max(0,Math.floor((rect.left-chapter.getBoundingClientRect().left+2)/Math.max(1,window.innerWidth)));
+      return Math.max(0,Math.floor((rect.top+scrolling().scrollTop+2)/Math.max(1,pageExtent())));
+    });
+  }
+  function goToLocator(value, report) {
+    if(!value){scrollToPage(0,report);return;}
+    var index=clamp(Math.round(Number(value.chapter)||0),0,chapters.length-1),chapter=chapters[index],point=chapter?nodeAtOffset(chapter,Math.max(0,value.offset||0)):null;
+    if(point){var textPage=pageForTextPoint(index,point);if(textPage>=0){scrollToPage(textPage,report);return;}}
+    if(Number(value.totalPages)===state.total&&typeof value.page==='number'){scrollToPage(value.page,report);return;}
+    if(typeof value.progress==='number'){scrollToPage(Math.round(value.progress*Math.max(0,state.total-1)),report);return;}
+    scrollToPage((state.chapterStarts[index]||0),report);
+  }
+  function applySettings(settings,keep) {
+    var root=document.documentElement,body=document.body,family=settings.fontFamily||'Atkinson Hyperlegible Next';
+    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);});applyPublisherStyles(settings.publisherStyles!==false);applyImageDithering(body);
+    refreshLayout(keep,function(){if(window.Android&&Android.onLayoutReady)Android.onLayoutReady();});
+  }
+  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(from.page,from.totalPages,from.chapter,from.offset,from.progress);}
+  function goToTarget(target,record) {if(!target)return;var page=pageForElement(target);if(record!==false)recordNavigation(page);scrollToPage(page,true);}
+  function goToReference(reference,record) {var id=window.ALEX_REFERENCES[reference];if(id)goToTarget(document.getElementById(id),record);}
+  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.searchIndex=-1;}
+  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(0,-1);return;}
+    var needle=query.toLocaleLowerCase(),counts={},walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,{acceptNode:function(node){var parent=node.parentElement,chapter=parent?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'),chapterNumber=parseInt(chapter.getAttribute('data-spine')||'0'),base=counts[chapterNumber]||0;nodes.push({node:node,chapter:chapterNumber,offset:base});counts[chapterNumber]=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;var selected=-1;
+      if(direction==='backward'){for(var i=marks.length-1;i>=0;i--){var backChapter=parseInt(marks[i].dataset.chapter||'0'),backOffset=parseInt(marks[i].dataset.offset||'0');if(backChapter<origin.chapter||(backChapter===origin.chapter&&backOffset<=origin.offset)){selected=i;break;}}if(selected<0&&marks.length)selected=marks.length-1;}
+      else{for(var j=0;j<marks.length;j++){var forwardChapter=parseInt(marks[j].dataset.chapter||'0'),forwardOffset=parseInt(marks[j].dataset.offset||'0');if(forwardChapter>origin.chapter||(forwardChapter===origin.chapter&&forwardOffset>=origin.offset)){selected=j;break;}}if(selected<0&&marks.length)selected=0;}
+      state.searchIndex=selected;if(selected>=0){marks[selected].classList.add('alex-search-current');var page=pageForElement(marks[selected]);recordNavigation(page,origin);scrollToPage(page,true);}if(window.Android)Android.onSearchResults(marks.length,selected);
+    },20);
+  }
+  function moveSearch(delta){var marks=document.querySelectorAll('mark.alex-search');if(!marks.length)return;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);}
+  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('.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)};}
+  function wrapAnnotation(annotation){var chapter=chapters[annotation.chapter];if(!chapter||annotation.end<=annotation.start)return;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);}});}
+  function applyAnnotations(values){(values||[]).slice().sort(function(a,b){return b.start-a.start;}).forEach(wrapAnnotation);if(state.layoutReady)refreshLayout(locator());}
+  function removeAnnotation(id){var keep=locator();Array.prototype.forEach.call(document.querySelectorAll('mark[data-annotation="'+id+'"]'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));});document.body.normalize();if(state.layoutReady)refreshLayout(keep);}
+  document.addEventListener('click',function(event){var mark=event.target.closest('mark.alex-annotation');if(mark&&window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;}var link=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 href=link.getAttribute('href')||'';if(href.charAt(0)==='#'){event.preventDefault();goToTarget(document.getElementById(href.substring(1)),true);}},true);
+  var scrollTimer=0;function handleScroll(){if(state.mode!=='continuous')return;clearTimeout(scrollTimer);scrollTimer=setTimeout(function(){var next=clamp(Math.round(scrollPosition()/Math.max(1,pageExtent())),0,state.total-1);if(next!==state.page){state.page=next;notifyPage();}},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();refreshLayout(keep);},120);});
+  window.Alex={setPage:scrollToPage,next:function(){scrollToPage(state.page+1,true);},previous:function(){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[clamp(index,0,state.chapterStarts.length-1)]||0;}};
+  var loaded=document.readyState==='complete'?Promise.resolve():new Promise(function(resolve){window.addEventListener('load',resolve,{once:true});});
+  Promise.all([document.fonts.ready,loaded]).then(function(){setTimeout(function(){if(window.Android&&Android.onReaderReady)Android.onReaderReady();},20);});
+})();
+"""
+
+internal const val FIXED_READER_SCRIPT = """
+(function () {
+  'use strict';
+  var pages = Array.prototype.slice.call(document.querySelectorAll('.alex-chapter'));
+  var state = {page:0,total:pages.length,searchIndex:-1},visiblePage=null;
+  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)];}
+  function pageSize(page){return{width:Math.max(1,number(page.getAttribute('data-page-width'),1200)),height:Math.max(1,number(page.getAttribute('data-page-height'),1600))};}
+  function layoutPage(){
+    state.page=clamp(Math.round(number(state.page,0)),0,pages.length-1);
+    var page=pageElement();if(visiblePage!==page){if(visiblePage)visiblePage.classList.remove('alex-current-page');page.classList.add('alex-current-page');visiblePage=page;}
+    var size=pageSize(page),availableWidth=Math.max(1,window.innerWidth),availableHeight=Math.max(1,window.innerHeight);
+    var scale=Math.max(0.01,Math.min(availableWidth/size.width,availableHeight/size.height));
+    var x=(availableWidth-size.width*scale)/2,y=(availableHeight-size.height*scale)/2;
+    page.style.width=size.width+'px';page.style.height=size.height+'px';page.style.transform='translate('+x+'px,'+y+'px) scale('+scale+')';
+    applyImageDithering(page);
+  }
+  function locator(){return{page:state.page,totalPages:state.total,chapter:state.page,offset:0,progress:state.total<=1?0:state.page/(state.total-1)};}
+  function notifyPage(){if(window.Android&&Android.onPageChanged){var value=locator();Android.onPageChanged(value.page,value.totalPages,value.chapter,value.offset,value.progress);}}
+  function recalculate(){state.total=pages.length;layoutPage();notifyPage();}
+  function setPage(page,report){state.page=clamp(Math.round(number(page,0)),0,state.total-1);layoutPage();if(report!==false)notifyPage();}
+  function pageForElement(element){var chapter=element?element.closest('.alex-chapter'):null;return chapter?clamp(number(chapter.getAttribute('data-spine'),0),0,state.total-1):0;}
+  function goToLocator(value){if(!value){setPage(0,true);return;}setPage(number(value.chapter,value.page||0),true);}
+  function applySettings(settings,keep){applyPublisherStyles(true);if(keep)state.page=clamp(Math.round(number(keep.chapter,keep.page||0)),0,state.total-1);layoutPage();notifyPage();if(window.Android&&Android.onLayoutReady)Android.onLayoutReady();}
+  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(from.page,from.totalPages,from.chapter,from.offset,from.progress);
+  }
+  function goToReference(reference,record) {
+    var id=window.ALEX_REFERENCES[reference];if(!id)return;
+    var target=document.getElementById(id);if(!target)return;
+    var page=pageForElement(target);if(record!==false)recordNavigation(page);setPage(page,true);
+  }
+  function clearSearch() {
+    Array.prototype.forEach.call(document.querySelectorAll('mark.alex-search'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));});
+    document.body.normalize();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(0,-1);return;}
+    var needle=query.toLocaleLowerCase(),walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,{acceptNode:function(node){
+      var parent=node.parentElement,chapter=parent?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){return{page:pageForElement(mark)};});
+    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;}
+    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(results.length,selected);
+  }
+  function moveSearch(delta) {
+    var marks=document.querySelectorAll('mark.alex-search');if(!marks.length)return;
+    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);
+  }
+  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('.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)};
+  }
+  function wrapAnnotation(annotation) {
+    var chapter=document.querySelector('.alex-chapter[data-spine="'+annotation.chapter+'"]');if(!chapter||annotation.end<=annotation.start)return;
+    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);}});
+  }
+  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();}
+  document.addEventListener('click',function(event){
+    var mark=event.target.closest('mark.alex-annotation');if(mark&&window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;}
+    var link=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 href=link.getAttribute('href')||'';if(href.charAt(0)==='#'){event.preventDefault();var target=document.getElementById(href.substring(1));if(target){var page=pageForElement(target);recordNavigation(page);setPage(page,true);}}
+  },true);
+  var resizeTimer=0;window.addEventListener('resize',function(){clearTimeout(resizeTimer);resizeTimer=setTimeout(function(){layoutPage();notifyPage();},120);});
+  window.Alex={setPage:setPage,next:function(){setPage(state.page+1,true);},previous:function(){setPage(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 clamp(index,0,state.total-1);}};
+  function ready(){recalculate();if(window.Android&&Android.onReaderReady)Android.onReaderReady();}
+  document.fonts.ready.then(function(){setTimeout(ready,30);});
+})();
+"""
index 4542eecd370876d80274e6e3b750ab0237aa5946..1300fa8c0dd5b0f8a319db8eda6272fe77beff18 100644 (file)
@@ -12,7 +12,6 @@ import android.view.MenuItem
 import android.view.MotionEvent
 import android.webkit.ConsoleMessage
 import android.webkit.JavascriptInterface
-import android.webkit.JsResult
 import android.webkit.ValueCallback
 import android.webkit.WebChromeClient
 import android.webkit.WebResourceError
@@ -27,16 +26,17 @@ import java.io.ByteArrayInputStream
 import java.io.File
 import kotlin.math.abs
 
-@SuppressLint("SetJavaScriptEnabled")
-class ReaderWebView(context: Context) : WebView(context) {
+@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
+internal class ReaderWebView(context: Context) : WebView(context) {
     interface Listener {
         fun onReady()
+        fun onLayoutReady()
         fun onLocationChanged(location: ReaderLocation)
         fun onTap(x: Float, y: Float)
         fun onSwipe(forward: Boolean)
         fun onExternalLink(url: String)
         fun onNavigationJump(origin: ReaderLocation)
-        fun onSearchResults(results: JSONArray)
+        fun onSearchResults(count: Int, currentIndex: Int)
         fun onAnnotationTapped(id: String)
         fun onSelectionAction(action: SelectionAction, selection: JSONObject?)
         fun onRenderError(message: String)
@@ -51,6 +51,8 @@ class ReaderWebView(context: Context) : WebView(context) {
     var listener: Listener? = null
     private val mainHandler = Handler(Looper.getMainLooper())
     private var pageLoaded = false
+    private var loadedReader: File? = null
+    private var resourceRoot: File? = null
     private var suppressTapUntil = 0L
 
     private val gestures = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
@@ -59,13 +61,7 @@ class ReaderWebView(context: Context) : WebView(context) {
         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,
-            )
+            val isLink = hit.type in LINK_HIT_TYPES
             if (!isLink) {
                 performClick()
                 listener?.onTap(event.x, event.y)
@@ -97,7 +93,6 @@ class ReaderWebView(context: Context) : WebView(context) {
             allowFileAccess = true
             allowContentAccess = false
             blockNetworkLoads = true
-            cacheMode = WebSettings.LOAD_NO_CACHE
             builtInZoomControls = false
             displayZoomControls = false
             setSupportZoom(false)
@@ -108,11 +103,6 @@ class ReaderWebView(context: Context) : WebView(context) {
         }
         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()})")
@@ -125,17 +115,19 @@ class ReaderWebView(context: Context) : WebView(context) {
         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
+                if (request.isForMainFrame && uri.toString() == loadedReader?.toURI()?.toASCIIString()) 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)))
+                val uri = request?.url ?: return emptyResponse()
+                val allowed = when (uri.scheme?.lowercase()) {
+                    "data", "blob" -> true
+                    "file" -> isAllowedFile(uri.path)
+                    else -> false
                 }
-                return super.shouldInterceptRequest(view, request)
+                return if (allowed) super.shouldInterceptRequest(view, request) else emptyResponse()
             }
 
             override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
@@ -146,11 +138,25 @@ class ReaderWebView(context: Context) : WebView(context) {
         }
     }
 
-    fun load(file: File) {
+    fun load(file: File, resources: File) {
         pageLoaded = false
-        loadUrl(file.toURI().toASCIIString())
+        loadedReader = file.canonicalFile
+        resourceRoot = resources.canonicalFile
+        loadUrl(requireNotNull(loadedReader).toURI().toASCIIString())
     }
 
+    private fun isAllowedFile(path: String?): Boolean {
+        if (path == null) return false
+        if (path.startsWith("/android_res/")) return true
+        val candidate = runCatching { File(path).canonicalFile }.getOrNull() ?: return false
+        if (candidate == loadedReader) return true
+        val root = resourceRoot ?: return false
+        return candidate.path == root.path || candidate.path.startsWith(root.path + File.separator)
+    }
+
+    private fun emptyResponse(): WebResourceResponse =
+        WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(ByteArray(0)))
+
     fun applySettings(settings: ReaderSettings, location: ReaderLocation? = null) {
         val keep = location?.toJson()?.toString() ?: "Alex.locator()"
         javascript("Alex.applySettings(${settings.toJson()}, $keep)")
@@ -256,16 +262,21 @@ class ReaderWebView(context: Context) : WebView(context) {
             listener?.onReady()
         }
 
-        @JavascriptInterface fun onPageChanged(json: String) = mainHandler.post {
-            runCatching { ReaderLocation.fromJson(JSONObject(json)) }.getOrNull()?.let { listener?.onLocationChanged(it) }
+        @JavascriptInterface fun onLayoutReady() = mainHandler.post { listener?.onLayoutReady() }
+
+        @JavascriptInterface
+        fun onPageChanged(page: Int, totalPages: Int, chapter: Int, offset: Int, progress: Double) = mainHandler.post {
+            listener?.onLocationChanged(location(page, totalPages, chapter, offset, progress))
         }
 
         @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 onNavigationJump(page: Int, totalPages: Int, chapter: Int, offset: Int, progress: Double) = mainHandler.post {
+            listener?.onNavigationJump(location(page, totalPages, chapter, offset, progress))
         }
-        @JavascriptInterface fun onSearchResults(json: String) = mainHandler.post {
-            runCatching { JSONArray(json) }.getOrNull()?.let { listener?.onSearchResults(it) }
+        @JavascriptInterface fun onSearchResults(count: Int, currentIndex: Int) = mainHandler.post {
+            listener?.onSearchResults(count.coerceAtLeast(0), currentIndex)
         }
         @JavascriptInterface fun onAnnotationTapped(id: String) = mainHandler.post {
             suppressTapUntil = android.os.SystemClock.uptimeMillis() + 700L
@@ -275,17 +286,38 @@ class ReaderWebView(context: Context) : WebView(context) {
 
     override fun destroy() {
         listener = null
+        pageLoaded = false
+        loadedReader = null
+        resourceRoot = null
+        mainHandler.removeCallbacksAndMessages(null)
         removeJavascriptInterface("Android")
         stopLoading()
-        loadUrl("about:blank")
         super.destroy()
     }
 
+    private fun location(page: Int, totalPages: Int, chapter: Int, offset: Int, progress: Double): ReaderLocation {
+        val total = totalPages.coerceAtLeast(1)
+        return ReaderLocation(
+            page = page.coerceIn(0, total - 1),
+            totalPages = total,
+            chapter = chapter.coerceAtLeast(0),
+            offset = offset.coerceAtLeast(0),
+            progress = progress.takeIf(Double::isFinite)?.toFloat()?.coerceIn(0f, 1f) ?: 0f,
+        )
+    }
+
     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 val LINK_HIT_TYPES = setOf(
+            HitTestResult.SRC_ANCHOR_TYPE,
+            HitTestResult.SRC_IMAGE_ANCHOR_TYPE,
+            HitTestResult.EMAIL_TYPE,
+            HitTestResult.PHONE_TYPE,
+            HitTestResult.GEO_TYPE,
+        )
 
         private fun decodeJavascriptString(value: String?): String? {
             if (value == null || value == "null") return null
diff --git a/app/src/main/java/com/alexandria/reader/WorkCancellation.kt b/app/src/main/java/com/alexandria/reader/WorkCancellation.kt
new file mode 100644 (file)
index 0000000..4b64674
--- /dev/null
@@ -0,0 +1,29 @@
+package com.alexandria.reader
+
+import java.io.FilterInputStream
+import java.io.InputStream
+import java.util.concurrent.CancellationException
+
+/** Cooperative cancellation for import, extraction, parsing, and rendering work. */
+internal object WorkCancellation {
+    fun check() {
+        if (Thread.currentThread().isInterrupted) throw CancellationException("Operation cancelled.")
+    }
+
+    fun input(stream: InputStream): InputStream = object : FilterInputStream(stream) {
+        override fun read(): Int {
+            WorkCancellation.check()
+            return super.read()
+        }
+
+        override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
+            WorkCancellation.check()
+            return super.read(buffer, offset, length)
+        }
+
+        override fun skip(count: Long): Long {
+            WorkCancellation.check()
+            return super.skip(count)
+        }
+    }
+}
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644 (file)
index 0000000..3e88e57
--- /dev/null
@@ -0,0 +1,11 @@
+<full-backup-content>
+    <exclude domain="root" path="."/>
+    <exclude domain="file" path="."/>
+    <exclude domain="database" path="."/>
+    <exclude domain="sharedpref" path="."/>
+    <exclude domain="external" path="."/>
+    <exclude domain="device_root" path="."/>
+    <exclude domain="device_file" path="."/>
+    <exclude domain="device_database" path="."/>
+    <exclude domain="device_sharedpref" path="."/>
+</full-backup-content>
diff --git a/app/src/test/java/com/alexandria/reader/EpubParserTest.kt b/app/src/test/java/com/alexandria/reader/EpubParserTest.kt
new file mode 100644 (file)
index 0000000..f5fca05
--- /dev/null
@@ -0,0 +1,215 @@
+package com.alexandria.reader
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.jsoup.Jsoup
+import java.io.File
+import java.util.zip.CRC32
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+
+class EpubParserTest {
+    @get:Rule
+    val temporary = TemporaryFolder()
+
+    @Test
+    fun parsesEpub2MetadataCoverAndNestedNcx() {
+        val book = testBook()
+        val publication = EpubParser.parse(fixture("epub2.epub"), temporary.newFolder("epub2"), book)
+
+        assertEquals("EPUB 2 Verification", publication.book.title)
+        assertEquals("Alexandria Tests", publication.book.author)
+        assertEquals(2, publication.spine.size)
+        assertEquals("EPUB 2 Start", publication.spine.first().title)
+        assertTrue(publication.toc.any { it.depth > 0 })
+        assertTrue(publication.book.coverPath?.let(::File)?.isFile == true)
+        assertFalse(publication.fixedLayout)
+    }
+
+    @Test
+    fun parsesStandardFixedLayoutMetadata() {
+        val publication = EpubParser.parse(
+            fixture("fixed-layout.epub"),
+            temporary.newFolder("fixed"),
+            testBook(),
+        )
+
+        assertTrue(publication.fixedLayout)
+        assertEquals(4, publication.spine.size)
+        assertEquals(1200, publication.defaultPageWidth)
+        assertEquals(1600, publication.defaultPageHeight)
+    }
+
+    @Test
+    fun resolvesEncodedRelativePathsWithoutLeavingTheContainer() {
+        assertEquals("OPS/Images/cover art.png", EpubParser.normalizePath("OPS/Text", "../Images/cover%20art.png"))
+        assertEquals("OPS/Text/chapter.xhtml#part one", EpubParser.resolveReference("OPS/Text", "chapter.xhtml#part%20one"))
+
+        assertThrows(IllegalArgumentException::class.java) {
+            EpubParser.normalizePath("OPS", "../../outside.xhtml")
+        }
+        assertThrows(IllegalArgumentException::class.java) {
+            EpubParser.normalizePath("OPS", "Text\\chapter.xhtml")
+        }
+        assertThrows(IllegalArgumentException::class.java) {
+            EpubParser.normalizePath("", "/chapter.xhtml")
+        }
+    }
+
+    @Test
+    fun rejectsUnsafeArchiveEntryBeforeWritingIt() {
+        val source = temporary.newFile("unsafe.epub")
+        ZipOutputStream(source.outputStream()).use { zip ->
+            val mimetype = "application/epub+zip".encodeToByteArray()
+            zip.putNextEntry(ZipEntry("mimetype").apply {
+                method = ZipEntry.STORED
+                size = mimetype.size.toLong()
+                compressedSize = size
+                crc = CRC32().apply { update(mimetype) }.value
+            })
+            zip.write(mimetype)
+            zip.closeEntry()
+            zip.putNextEntry(ZipEntry("../outside.txt"))
+            zip.write("unsafe".encodeToByteArray())
+            zip.closeEntry()
+        }
+        val output = temporary.newFolder("unsafe-output")
+
+        assertThrows(IllegalArgumentException::class.java) {
+            EpubParser.parse(source, output, testBook())
+        }
+        assertFalse(File(output.parentFile, "outside.txt").exists())
+    }
+
+    @Test
+    fun preservesCssFunctionCommasSvgResourceFragmentsAndRootTargets() {
+        val root = temporary.newFolder("publication")
+        val text = File(root, "Text").apply(File::mkdirs)
+        val images = File(root, "Images").apply(File::mkdirs)
+        File(images, "sprite.svg").writeText(
+            """<svg xmlns="http://www.w3.org/2000/svg"><g id="shape"><rect width="10" height="10"/></g></svg>""",
+        )
+        File(text, "chapter.xhtml").writeText(
+            """<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr">
+<head><style>.pick:is(.one,.two), body { background-image:url('../Images/sprite.svg#shape'); }</style></head>
+<body xml:id="chapter-body"><svg xmlns="http://www.w3.org/2000/svg"><use href="../Images/sprite.svg#shape"/></svg><p id="target" style="color:red">Text</p></body>
+</html>""",
+        )
+        val publication = EpubPublication(
+            book = testBook(),
+            rootDirectory = root,
+            spine = listOf(SpineItem("Text/chapter.xhtml", "Chapter")),
+            toc = listOf(
+                TocEntry("Chapter", "Text/chapter.xhtml#chapter-body", 0, 0),
+                TocEntry("Target", "Text/chapter.xhtml#target", 1, 0),
+            ),
+            readingDirection = "ltr",
+            fixedLayout = false,
+            defaultPageWidth = 1200,
+            defaultPageHeight = 1600,
+        )
+
+        val html = EpubHtmlBuilder.write(publication, temporary.newFile("reader.html")).readText()
+
+        assertTrue(html.contains(".pick:is(.one,.two)"))
+        assertTrue(html.contains("sprite.svg#shape"))
+        assertTrue(html.contains("\"Text/chapter.xhtml#chapter-body\":\"alex-chapter-0\""))
+        assertTrue(html.contains("lang=\"fr\""))
+        val target = requireNotNull(Jsoup.parse(html).selectFirst("p"))
+        assertTrue(target.id().startsWith("alex-0-"))
+        assertEquals("color:red", target.attr("data-alex-publisher-style"))
+    }
+
+    @Test
+    fun isolatesChapterCssAndNamespacesRepeatedIdsAndSvgReferences() {
+        val root = temporary.newFolder("isolated-publication")
+        val text = File(root, "Text").apply(File::mkdirs)
+        val styles = File(root, "Styles").apply(File::mkdirs)
+        File(styles, "shared.css").writeText(".shared:is(.one,.two) { font-weight:bold; } #paint stop { stop-color:#000; }")
+        repeat(2) { index ->
+            File(text, "chapter${index + 1}.xhtml").writeText(
+                """<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml"><head>
+<link rel="stylesheet" href="../Styles/shared.css"/>
+<style>.chapter-only { color:${if (index == 0) "red" else "blue"}; } .body-class > svg { display:block; } #box { stroke:#111; }</style>
+</head><body id="same-body" class="body-class"><svg xmlns="http://www.w3.org/2000/svg">
+<defs><linearGradient id="paint"><stop offset="1"/></linearGradient></defs>
+<title id="label">Image</title><rect id="box" fill="url(#paint)" aria-labelledby="label"/>
+<use href="#box"/></svg><p class="shared chapter-only"><a href="#box">Target</a></p></body></html>""",
+            )
+        }
+        val publication = EpubPublication(
+            book = testBook(),
+            rootDirectory = root,
+            spine = listOf(
+                SpineItem("Text/chapter1.xhtml", "One"),
+                SpineItem("Text/chapter2.xhtml", "Two"),
+            ),
+            toc = listOf(
+                TocEntry("One", "Text/chapter1.xhtml#same-body", 0, 0),
+                TocEntry("Two", "Text/chapter2.xhtml#same-body", 0, 1),
+            ),
+            readingDirection = "ltr",
+            fixedLayout = false,
+            defaultPageWidth = 1200,
+            defaultPageHeight = 1600,
+        )
+
+        val document = Jsoup.parse(EpubHtmlBuilder.write(publication, temporary.newFile("isolated.html")).readText())
+        val sections = document.select("section.alex-chapter")
+        assertEquals(2, sections.size)
+        val paintIds = sections.map { requireNotNull(it.selectFirst("linearGradient")).id() }
+        val boxIds = sections.map { requireNotNull(it.selectFirst("rect")).id() }
+        val labelIds = sections.map { requireNotNull(it.selectFirst("title")).id() }
+        assertNotEquals(paintIds[0], paintIds[1])
+        assertNotEquals(boxIds[0], boxIds[1])
+        sections.forEachIndexed { index, section ->
+            assertEquals("url('#${paintIds[index]}')", requireNotNull(section.selectFirst("rect")).attr("fill"))
+            assertEquals(labelIds[index], requireNotNull(section.selectFirst("rect")).attr("aria-labelledby"))
+            assertEquals("#${boxIds[index]}", requireNotNull(section.selectFirst("use")).attr("href"))
+            assertEquals("#${boxIds[index]}", requireNotNull(section.selectFirst("a")).attr("href"))
+            assertTrue(section.select("style, link[rel=stylesheet]").isEmpty())
+        }
+        val css = requireNotNull(document.getElementById("alex-publisher-styles")).data()
+        assertTrue(css.contains(".alex-chapter[data-spine=\"0\"] .chapter-only"))
+        assertTrue(css.contains(".alex-chapter[data-spine=\"1\"] .chapter-only"))
+        assertTrue(css.contains(".alex-chapter[data-spine=\"0\"] .shared:is(.one,.two)"))
+        assertTrue(css.contains(".alex-chapter[data-spine=\"1\"] .shared:is(.one,.two)"))
+        assertTrue(css, css.contains(".alex-chapter[data-spine=\"0\"].body-class > svg"))
+        assertFalse(css.contains("#paint"))
+        assertFalse(css.contains("#box"))
+    }
+
+    @Test
+    fun rejectsDuplicateIdsInsideOneSpineDocument() {
+        val root = temporary.newFolder("duplicate-id-publication")
+        File(root, "chapter.xhtml").writeText(
+            """<html xmlns="http://www.w3.org/1999/xhtml"><body><p id="same">One</p><p id="same">Two</p></body></html>""",
+        )
+        val publication = EpubPublication(
+            testBook(), root, listOf(SpineItem("chapter.xhtml", "Chapter")),
+            listOf(TocEntry("Chapter", "chapter.xhtml", 0, 0)), "ltr", false, 1200, 1600,
+        )
+
+        assertThrows(IllegalArgumentException::class.java) {
+            EpubHtmlBuilder.write(publication, temporary.newFile("duplicate.html"))
+        }
+    }
+
+    private fun fixture(name: String): File =
+        File(requireNotNull(javaClass.classLoader?.getResource(name)) { "Missing fixture: $name" }.toURI())
+
+    private fun testBook() = LibraryBook(
+        id = "0123456789abcdef01234567",
+        title = "Test book",
+        author = "Test author",
+        fileName = "test.epub",
+    )
+}
diff --git a/app/src/test/java/com/alexandria/reader/LibraryRepositoryTest.kt b/app/src/test/java/com/alexandria/reader/LibraryRepositoryTest.kt
new file mode 100644 (file)
index 0000000..865612f
--- /dev/null
@@ -0,0 +1,133 @@
+package com.alexandria.reader
+
+import android.content.Context
+import android.net.Uri
+import androidx.test.core.app.ApplicationProvider
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.File
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [35])
+class LibraryRepositoryTest {
+    private lateinit var context: Context
+    private var repository: LibraryRepository? = null
+
+    @Before
+    fun resetPrivateStorage() {
+        context = ApplicationProvider.getApplicationContext()
+        File(context.filesDir, "library").deleteRecursively()
+        context.cacheDir.listFiles().orEmpty().filter { it.name.startsWith("import-") }.forEach(File::deleteRecursively)
+        context.getSharedPreferences("alexandria-reader", Context.MODE_PRIVATE).edit().clear().commit()
+    }
+
+    @After
+    fun closeRepository() {
+        repository?.close()
+    }
+
+    @Test
+    fun persistsLatestReaderStateAndSkipsMalformedRecords() {
+        val repository = newRepository()
+        val publication = repository.import(Uri.fromFile(fixture("navigation.epub")))
+        val id = publication.book.id
+        repository.updateProgress(id, ReaderLocation(1, 4, 1, 20, .25f))
+        repository.updateProgress(id, ReaderLocation(3, 4, 3, 40, 1f))
+        repository.saveSettings(id, ReaderSettings(fontSize = 28))
+        repository.saveBookmarks(
+            id,
+            listOf(Bookmark("End", 3, 40, 3, 1f, 100L)),
+        )
+        repository.saveAnnotations(id, listOf(Annotation("valid", 3, 0, 4, "Text", "Note")))
+        repository.awaitPendingWrites()
+
+        val bookDirectory = File(context.filesDir, "library/$id")
+        val bookmarks = JSONArray(File(bookDirectory, "bookmarks.json").readText())
+            .put(JSONObject().put("id", "not-a-bookmark"))
+            .put("invalid")
+        AtomicFiles.write(File(bookDirectory, "bookmarks.json"), bookmarks.toString())
+        val annotationsFile = File(bookDirectory, "annotations.json")
+        val annotations = JSONArray(annotationsFile.readText()).put(JSONObject()).put("invalid")
+        AtomicFiles.write(annotationsFile, annotations.toString())
+        val state = repository.readerState(id)
+
+        assertEquals(3, state.location.page)
+        assertEquals(1f, state.location.progress)
+        assertEquals(28, state.settings.fontSize)
+        assertEquals(listOf("End", "Bookmark"), state.bookmarks.map(Bookmark::label))
+        assertEquals(listOf("valid"), state.annotations.map(Annotation::id))
+        assertEquals(LibraryBook.ReadingState.FINISHED, repository.books().single().state)
+        assertFalse(bookDirectory.walk().any { it.name.endsWith(".tmp") })
+    }
+
+    @Test
+    fun loadsValidIndexRecordsWhenOtherRecordsAreMalformed() {
+        val library = File(context.filesDir, "library").apply(File::mkdirs)
+        val valid = LibraryBook(
+            id = "0123456789abcdef01234567",
+            title = "Valid",
+            author = "Author",
+            fileName = "valid.epub",
+        )
+        File(library, valid.id).apply(File::mkdirs).resolve("source.epub").writeText("source")
+        AtomicFiles.write(
+            File(library, "index.json"),
+            JSONArray().put(valid.toJson()).put(JSONObject().put("id", "../../outside")).put("invalid").toString(),
+        )
+
+        val repository = newRepository()
+
+        assertEquals(listOf("Valid"), repository.books().map(LibraryBook::title))
+    }
+
+    @Test
+    fun invalidatesReaderCacheWhenRenderedMetadataChanges() {
+        val repository = newRepository()
+        val publication = repository.import(Uri.fromFile(fixture("navigation.epub")))
+        val reader = repository.readerFile(publication)
+        val firstVersion = File(reader.parentFile, "reader.version").readText()
+        val firstContent = reader.readText()
+
+        assertEquals(reader, repository.readerFile(publication))
+        assertEquals(firstContent, reader.readText())
+        repository.rename(publication.book.id, "Renamed title")
+        val renamedBook = repository.books().single()
+        val renamedPublication = repository.open(renamedBook)
+        repository.readerFile(renamedPublication)
+        val secondVersion = File(reader.parentFile, "reader.version").readText()
+
+        assertNotEquals(firstVersion, secondVersion)
+        assertTrue(reader.readText().contains("<title>Renamed title</title>"))
+    }
+
+    @Test
+    fun failedImportRemovesStagingFilesAndLibraryDirectory() {
+        val repository = newRepository()
+        val invalid = File(context.cacheDir, "invalid.epub").apply { writeText("not an EPUB") }
+
+        assertThrows(Exception::class.java) {
+            repository.import(Uri.fromFile(invalid))
+        }
+
+        assertTrue(repository.books().isEmpty())
+        val library = File(context.filesDir, "library")
+        assertFalse(library.listFiles().orEmpty().any { it.isDirectory })
+        assertFalse(context.cacheDir.listFiles().orEmpty().any { it.name.startsWith("import-") })
+    }
+
+    private fun newRepository(): LibraryRepository = LibraryRepository(context).also { repository = it }
+
+    private fun fixture(name: String): File =
+        File(requireNotNull(javaClass.classLoader?.getResource(name)) { "Missing fixture: $name" }.toURI())
+}
diff --git a/app/src/test/java/com/alexandria/reader/ModelsTest.kt b/app/src/test/java/com/alexandria/reader/ModelsTest.kt
new file mode 100644 (file)
index 0000000..1b4eb44
--- /dev/null
@@ -0,0 +1,57 @@
+package com.alexandria.reader
+
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ModelsTest {
+    @Test
+    fun clampsMalformedLocationsToValidFiniteValues() {
+        val location = ReaderLocation.fromJson(
+            JSONObject("""{"page":99,"totalPages":3,"chapter":-2,"offset":-4,"progress":"NaN"}"""),
+        )
+
+        assertEquals(2, location.page)
+        assertEquals(3, location.totalPages)
+        assertEquals(0, location.chapter)
+        assertEquals(0, location.offset)
+        assertEquals(0f, location.progress)
+        assertTrue(location.progress.isFinite())
+    }
+
+    @Test
+    fun keepsValidRecordsWhenOneStoredRecordIsInvalid() {
+        val valid = LibraryBook(
+            id = "0123456789abcdef01234567",
+            title = "Valid",
+            author = "Author",
+            fileName = "valid.epub",
+        ).toJson()
+        val records = JSONArray().put(valid).put(JSONObject().put("id", "../../outside"))
+
+        val books = records.mapObjects(LibraryBook::fromJson)
+
+        assertEquals(listOf("Valid"), books.map(LibraryBook::title))
+    }
+
+    @Test
+    fun normalizesStoredSettingsAndAnnotations() {
+        val settings = ReaderSettings.fromJson(
+            JSONObject("""{"fontSize":400,"lineHeight":"NaN","margin":-1,"textAlign":"invalid","mode":"invalid"}"""),
+        )
+        val annotation = Annotation.fromJson(
+            JSONObject("""{"id":"note","chapter":-1,"start":8,"end":3,"quote":"text"}"""),
+        )
+
+        assertEquals(38, settings.fontSize)
+        assertEquals(1.32f, settings.lineHeight)
+        assertEquals(0, settings.margin)
+        assertEquals("publisher", settings.textAlign)
+        assertEquals("paged", settings.mode)
+        assertEquals(0, annotation.chapter)
+        assertEquals(8, annotation.start)
+        assertEquals(8, annotation.end)
+    }
+}
diff --git a/app/src/test/java/com/alexandria/reader/PersistenceTest.kt b/app/src/test/java/com/alexandria/reader/PersistenceTest.kt
new file mode 100644 (file)
index 0000000..ecaf6c8
--- /dev/null
@@ -0,0 +1,73 @@
+package com.alexandria.reader
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.util.Collections
+import java.util.concurrent.CancellationException
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+class PersistenceTest {
+    @get:Rule
+    val temporary = TemporaryFolder()
+
+    @Test
+    fun atomicWritesReplaceCompleteFilesAndRemoveTemporaryFiles() {
+        val directory = temporary.newFolder("atomic")
+        val target = File(directory, "state.json")
+        AtomicFiles.write(target, "{\"value\":1}")
+        AtomicFiles.write(target, "{\"value\":2}")
+
+        assertEquals("{\"value\":2}", target.readText())
+        assertFalse(directory.listFiles().orEmpty().any { it.name.endsWith(".tmp") })
+    }
+
+    @Test
+    fun interruptibleInputStopsCancelledWork() {
+        Thread.currentThread().interrupt()
+        try {
+            val error = org.junit.Assert.assertThrows(CancellationException::class.java) {
+                WorkCancellation.input(ByteArrayInputStream(byteArrayOf(1))).read()
+            }
+            assertTrue(error.message?.contains("cancelled") == true)
+        } finally {
+            Thread.interrupted()
+        }
+    }
+
+    @Test
+    fun coalescesQueuedValuesForTheSameStateFile() {
+        val target = temporary.newFile("coalesced.json")
+        val firstStarted = CountDownLatch(1)
+        val releaseFirst = CountDownLatch(1)
+        val writes = Collections.synchronizedList(mutableListOf<String>())
+        val writer = CoalescingFileWriter(write = { file, value ->
+            writes += value
+            if (value == "first") {
+                firstStarted.countDown()
+                check(releaseFirst.await(5, TimeUnit.SECONDS))
+            }
+            file.writeText(value)
+        })
+        try {
+            writer.enqueue(target) { "first" }
+            check(firstStarted.await(5, TimeUnit.SECONDS))
+            writer.enqueue(target) { "second" }
+            writer.enqueue(target) { "latest" }
+            releaseFirst.countDown()
+            writer.await()
+
+            assertEquals(listOf("first", "latest"), writes)
+            assertEquals("latest", target.readText())
+        } finally {
+            releaseFirst.countDown()
+            writer.close()
+        }
+    }
+}
diff --git a/scripts/profile-reader.ps1 b/scripts/profile-reader.ps1
new file mode 100644 (file)
index 0000000..2432398
--- /dev/null
@@ -0,0 +1,118 @@
+[CmdletBinding()]
+param(
+    [string]$Fixture,
+    [string]$OutputFile
+)
+
+$ErrorActionPreference = "Stop"
+if (-not $Fixture) { $Fixture = Join-Path $PSScriptRoot "..\tests\fixtures\odyssey.epub" }
+if (-not $OutputFile) { $OutputFile = Join-Path $PSScriptRoot "..\verification\reader-profile.json" }
+$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 profiling." }
+$serial = $device.ToString().Split("`t")[0]
+$outputDirectory = Split-Path -Parent $OutputFile
+New-Item -ItemType Directory -Force $outputDirectory | Out-Null
+
+function Invoke-Adb([Parameter(ValueFromRemainingArguments=$true)][string[]]$Arguments) {
+    $preference = $ErrorActionPreference
+    $ErrorActionPreference = "Continue"
+    & $adb -s $serial @Arguments
+    $exitCode = $LASTEXITCODE
+    $ErrorActionPreference = $preference
+    if ($exitCode -ne 0) { throw "adb failed: $($Arguments -join ' ')" }
+}
+
+function Wait-Reader([Diagnostics.Stopwatch]$Stopwatch) {
+    $localDump = Join-Path $outputDirectory "profile-window.xml"
+    for ($attempt = 0; $attempt -lt 60; $attempt++) {
+        Start-Sleep -Milliseconds 250
+        $preference = $ErrorActionPreference
+        $ErrorActionPreference = "Continue"
+        & $adb -s $serial shell timeout 2 uiautomator dump /sdcard/alexandria-profile.xml 2>$null | Out-Null
+        $dumpExitCode = $LASTEXITCODE
+        if ($dumpExitCode -eq 0) {
+            & $adb -s $serial pull /sdcard/alexandria-profile.xml $localDump 2>$null | Out-Null
+            $pullExitCode = $LASTEXITCODE
+        } else {
+            $pullExitCode = 1
+        }
+        $ErrorActionPreference = $preference
+        if ($pullExitCode -eq 0 -and (Get-Content $localDump -Raw) -match "Page 1 of ([2-9]|[1-9][0-9]+)") {
+            $Stopwatch.Stop()
+            Remove-Item -ErrorAction SilentlyContinue $localDump
+            return $Stopwatch.ElapsedMilliseconds
+        }
+    }
+    throw "The reader did not finish layout within the profiling timeout."
+}
+
+function Read-Memory {
+    $memory = (& $adb -s $serial shell dumpsys meminfo com.alexandria.reader) -join "`n"
+    $totalPss = if ($memory -match "TOTAL PSS:\s+([0-9]+)") { [int]$Matches[1] } else { $null }
+    $javaHeap = if ($memory -match "Java Heap:\s+([0-9]+)") { [int]$Matches[1] } else { $null }
+    $rendererRss = 0
+    (& $adb -s $serial shell ps -A -o NAME,RSS) | ForEach-Object {
+        if ($_ -match "webview:sandboxed_process.*\s+([0-9]+)$") { $rendererRss += [int]$Matches[1] }
+    }
+    return @{ totalPssKb = $totalPss; javaHeapKb = $javaHeap; rendererRssKb = $rendererRss }
+}
+
+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/profile.epub"
+    $staged = "/data/local/tmp/alexandria-profile.epub"
+    Invoke-Adb push $Fixture $staged | Out-Null
+    Invoke-Adb shell run-as com.alexandria.reader cp $staged files/profile.epub | Out-Null
+    Invoke-Adb shell rm '-f' $staged | Out-Null
+    Invoke-Adb shell am force-stop com.alexandria.reader
+    $coldWatch = [Diagnostics.Stopwatch]::StartNew()
+    Invoke-Adb shell am start '-W' '-a' android.intent.action.VIEW '-d' "file://$remote" '-t' application/epub+zip com.alexandria.reader/.MainActivity | Out-Null
+    $coldMs = Wait-Reader $coldWatch
+    Start-Sleep -Milliseconds 500
+    $coldMemory = Read-Memory
+    $readerStatBefore = ((& $adb -s $serial shell "stat -c '%i:%s:%Y' /data/user/0/com.alexandria.reader/files/library/*/reader.html") -join "").Trim()
+
+    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
+    Start-Sleep -Milliseconds 750
+    $warmWatch = [Diagnostics.Stopwatch]::StartNew()
+    Invoke-Adb shell input tap 500 210 | Out-Null
+    $warmMs = Wait-Reader $warmWatch
+    Start-Sleep -Milliseconds 500
+    $warmMemory = Read-Memory
+    $readerStatAfter = ((& $adb -s $serial shell "stat -c '%i:%s:%Y' /data/user/0/com.alexandria.reader/files/library/*/reader.html") -join "").Trim()
+
+    $result = [ordered]@{
+        fixture = (Resolve-Path $Fixture).Path
+        fixtureBytes = (Get-Item $Fixture).Length
+        coldImportAndLayoutMs = $coldMs
+        cachedOpenAndLayoutMs = $warmMs
+        coldTotalPssKb = $coldMemory.totalPssKb
+        coldJavaHeapKb = $coldMemory.javaHeapKb
+        coldWebViewRendererRssKb = $coldMemory.rendererRssKb
+        cachedTotalPssKb = $warmMemory.totalPssKb
+        cachedJavaHeapKb = $warmMemory.javaHeapKb
+        cachedWebViewRendererRssKb = $warmMemory.rendererRssKb
+        readerFile = $readerStatAfter
+        cacheFileUnchanged = $readerStatBefore -eq $readerStatAfter
+        measuredAtUtc = [DateTime]::UtcNow.ToString("o")
+    }
+    $result | ConvertTo-Json | Set-Content -Encoding utf8 $OutputFile
+    Write-Host "Reader profile written to $OutputFile"
+    $result | Format-List
+}
+finally {
+    Pop-Location
+}
index ea81925fbf1299602e4298e71f0b48552d4a73cd..9fd12d71b59e7946b6f38e18c820b8a543e2ddc3 100644 (file)
@@ -68,8 +68,10 @@ try {
     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
+    $staged = "/data/local/tmp/alexandria-fixed-verification.epub"
+    Invoke-Adb push $Fixture $staged | Out-Null
+    Invoke-Adb shell run-as com.alexandria.reader cp $staged files/fixed-verification.epub | Out-Null
+    Invoke-Adb shell rm '-f' $staged | Out-Null
     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
index 9da7bac4b28f7e319df670a202558c63a9aab554..c1a26654f95acf27de12c6f15219345e91a69161 100644 (file)
@@ -31,8 +31,11 @@ try {
     & $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
+    $staged = "/data/local/tmp/alexandria-verification.epub"
+    & $adb -s $serial push $Fixture $staged | Out-Null
+    & $adb -s $serial shell run-as com.alexandria.reader cp $staged files/verification.epub
+    if ($LASTEXITCODE -ne 0) { throw "Could not stage the fixture in private app storage." }
+    & $adb -s $serial shell rm -f $staged | Out-Null
     & $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
index 4cfb4a6640aa1d0c8569e70639ca07de0da9f8b9..fb709ae72b6d14c3587d15e5493649ca3fba2736 100644 (file)
@@ -58,8 +58,10 @@ try {
     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
+    $staged = "/data/local/tmp/alexandria-navigation-verification.epub"
+    Invoke-Adb push $Fixture $staged | Out-Null
+    Invoke-Adb shell run-as com.alexandria.reader cp $staged files/navigation-verification.epub | Out-Null
+    Invoke-Adb shell rm '-f' $staged | Out-Null
     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