]> git.otsuka.systems Git - alexandria/commitdiff
remove legacy and dead code
authorCameron Otsuka <cameron@otsuka.haus>
Tue, 11 Aug 2026 18:56:58 +0000 (13:56 -0500)
committerCameron Otsuka <cameron@otsuka.haus>
Tue, 11 Aug 2026 18:56:58 +0000 (13:56 -0500)
14 files changed:
README.md
app/build.gradle.kts
app/src/main/AndroidManifest.xml
app/src/main/java/com/alexandria/reader/EInkPalette.kt
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/MainActivity.kt
app/src/main/java/com/alexandria/reader/Models.kt
app/src/main/java/com/alexandria/reader/ReaderScreen.kt
app/src/main/java/com/alexandria/reader/ReaderWebView.kt
app/src/main/res/values/colors.xml
app/src/main/res/values/styles.xml
app/src/main/res/xml/data_extraction_rules.xml [new file with mode: 0644]
scripts/verify-fixed-page-tools.ps1

index aa39ebdd990e3fbe8f9185daf5f7e52a6dc20564..8c0434c9d222dc6cdd280004ac040ce701fdbed9 100644 (file)
--- a/README.md
+++ b/README.md
@@ -6,11 +6,11 @@ The app requests no network or shared-storage permission. It imports each select
 
 ## EPUB support
 
-- EPUB 2 and EPUB 3 package, spine, metadata, cover, NCX, and navigation documents
-- Publisher CSS, inline styles, imported stylesheets, embedded fonts, SVG, images, tables, lists, links, and page-break rules
+- EPUB 3 package, spine, metadata, cover, and navigation documents
+- Publisher CSS, inline styles, imported stylesheets, embedded fonts, SVG, images, tables, lists, links, and break rules
 - Accurate reflow with browser-grade shaping, bidirectional text, hyphenation, and accessibility text
 - Paginated and continuous reading modes
-- Pre-paginated EPUB detection through rendition metadata, legacy display options, numeric viewports, and SVG pages
+- Pre-paginated EPUB detection through standard rendition metadata
 - Right-to-left page progression
 
 ## Reader features
index 6f64da90df81bd008126f2735b04ded60307804f..a50eee2a3b146ca07ad20e935cec86376e9cff19 100644 (file)
@@ -11,12 +11,10 @@ android {
         versionCode = 1
         versionName = "1.0"
     }
-
-    buildFeatures { buildConfig = true }
-    testOptions { unitTests.isIncludeAndroidResources = true }
 }
 
 dependencies {
+    implementation("androidx.activity:activity-ktx:1.10.1")
     implementation("org.jsoup:jsoup:1.18.3")
 }
 
index 3012bf875635188ae7b88c76d9759a4b187828e3..f010ca39360a2aef1369253778be7d31ce2520e8 100644 (file)
@@ -12,6 +12,7 @@
         android:icon="@drawable/ic_launcher"
         android:roundIcon="@drawable/ic_launcher"
         android:allowBackup="false"
+        android:dataExtractionRules="@xml/data_extraction_rules"
         android:usesCleartextTraffic="false"
         android:hardwareAccelerated="true"
         android:supportsRtl="true">
                 <category android:name="android.intent.category.DEFAULT"/>
                 <category android:name="android.intent.category.BROWSABLE"/>
                 <data android:mimeType="application/epub+zip"/>
-                <data android:mimeType="application/epub"/>
                 <data android:scheme="content"/>
-                <data android:scheme="file"/>
             </intent-filter>
             <intent-filter>
                 <action android:name="android.intent.action.SEND"/>
                 <category android:name="android.intent.category.DEFAULT"/>
                 <data android:mimeType="application/epub+zip"/>
-                <data android:mimeType="application/epub"/>
             </intent-filter>
             <intent-filter>
                 <action android:name="android.intent.action.SEND_MULTIPLE"/>
index 6cab51c8539eb478fcf28eb944307b3310f039b6..791a1689cb543dca32c44fa5ebe0329b660bbed1 100644 (file)
@@ -7,7 +7,7 @@ import android.graphics.Color
  * 16 native grayscale levels: 0x00, 0x11, …, 0xff.
  */
 internal object EInkPalette {
-    const val LEVEL_COUNT = 16
+    private const val LEVEL_COUNT = 16
 
     val INK = gray(0)
     val ACTION = gray(1)
@@ -17,7 +17,6 @@ internal object EInkPalette {
     val PAGE_PREVIEW = gray(13)
     val SURFACE = gray(14)
     val PAPER = gray(15)
-    val TRANSPARENT = Color.TRANSPARENT
 
     private fun gray(level: Int): Int {
         require(level in 0 until LEVEL_COUNT)
index a803a71e001ab498b6d7623ffe6b8ef3bdbb887b..e78c001c84baad5317d9512c838983e2ee7177a6 100644 (file)
@@ -13,12 +13,13 @@ import java.util.Locale
 import java.util.zip.ZipFile
 import kotlin.math.roundToInt
 
-/** EPUB 2/3 package reader. It keeps the original resources intact for standards-based rendering. */
+/** EPUB 3 package reader. It keeps the original resources intact for standards-based rendering. */
 object EpubParser {
-    private data class ManifestItem(val id: String, val href: String, val mediaType: String, val properties: String)
+    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, forceExtract: Boolean): EpubPublication {
-        if (forceExtract || !File(output, ".complete").isFile) {
+    fun parse(source: File, output: File, book: LibraryBook): EpubPublication {
+        if (!File(output, ".complete").isFile) {
             output.deleteRecursively()
             output.mkdirs()
             extract(source, output)
@@ -27,141 +28,104 @@ object EpubParser {
 
         val containerFile = File(output, "META-INF/container.xml")
         require(containerFile.isFile) { "This file has no EPUB container document." }
-        val container = parseXml(containerFile)
-        val rootfile = container.allByLocalName("rootfile").firstOrNull()
+        val rootfile = parseXml(containerFile).allByLocalName("rootfile").firstOrNull()
             ?: error("The EPUB container does not identify a package document.")
         val packagePath = normalizePath("", rootfile.attr("full-path"))
         require(packagePath.isNotBlank()) { "The EPUB package path is empty." }
         val packageFile = safeFile(output, packagePath)
         require(packageFile.isFile) { "The EPUB package document is missing." }
         val opf = parseXml(packageFile)
+        val packageElement = opf.allByLocalName("package").firstOrNull()
+            ?: error("The EPUB package document has no package element.")
+        require(packageElement.attr("version").substringBefore('.').toIntOrNull() == 3) {
+            "Only EPUB 3 publications are supported."
+        }
         val packageDirectory = packagePath.substringBeforeLast('/', "")
 
         val metadata = opf.allByLocalName("metadata").firstOrNull()
-        metadata?.firstText("title")?.takeIf(String::isNotBlank)?.let { book.title = it }
-        metadata?.allByLocalName("creator")?.map { it.text().trim() }?.filter(String::isNotBlank)
-            ?.distinct()?.takeIf(List<String>::isNotEmpty)?.joinToString(", ")?.let { book.author = it }
-        metadata?.firstText("language")?.let { book.language = it }
-        metadata?.firstText("publisher")?.let { book.publisher = it }
-        metadata?.firstText("description")?.let { book.description = it }
-        val packageViewport = metadata?.allByLocalName("meta")?.firstNotNullOfOrNull { meta ->
-            val name = meta.attr("name").lowercase(Locale.ROOT)
-            val property = meta.attr("property").lowercase(Locale.ROOT)
-            if (name != "original-resolution" && property != "rendition:viewport") return@firstNotNullOfOrNull null
-            val value = meta.text().ifBlank { meta.attr("content") }
-            val dimensions = Regex("(?:width\\s*=\\s*)?([0-9]+)\\s*(?:x|[,;]\\s*height\\s*=)\\s*([0-9]+)", RegexOption.IGNORE_CASE).find(value)
-                ?: return@firstNotNullOfOrNull null
+            ?: error("The EPUB package document has no metadata element.")
+        metadata.firstText("title")?.takeIf(String::isNotBlank)?.let { book.title = it }
+        metadata.allByLocalName("creator").map { it.text().trim() }.filter(String::isNotBlank)
+            .distinct().takeIf(List<String>::isNotEmpty)?.joinToString(", ")?.let { book.author = it }
+        metadata.firstText("language")?.let { book.language = it }
+        metadata.firstText("publisher")?.let { book.publisher = it }
+        metadata.firstText("description")?.let { book.description = it }
+        val packageViewport = metadata.allByLocalName("meta").firstNotNullOfOrNull { meta ->
+            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 width = dimensions.groupValues[1].toIntOrNull() ?: return@firstNotNullOfOrNull null
             val height = dimensions.groupValues[2].toIntOrNull() ?: return@firstNotNullOfOrNull null
             (width to height).takeIf { width > 0 && height > 0 }
         } ?: (1200 to 1600)
-        val appleDisplayOptions = File(output, "META-INF/com.apple.ibooks.display-options.xml")
-        val legacyFixedLayout = appleDisplayOptions.takeIf(File::isFile)?.let { file ->
-            runCatching {
-                parseXml(file).getAllElements().any { element ->
-                    element.attr("name").equals("fixed-layout", true) && element.text().trim().equals("true", true)
-                }
-            }.getOrDefault(false)
-        } == true
-        val packageFixedLayout = legacyFixedLayout || metadata?.allByLocalName("meta")?.any { meta ->
-            val property = meta.attr("property").lowercase(Locale.ROOT)
-            val name = meta.attr("name").lowercase(Locale.ROOT)
-            val value = meta.text().ifBlank { meta.attr("content") }.trim().lowercase(Locale.ROOT)
-            (property == "rendition:layout" && value == "pre-paginated") ||
-                (name in setOf("fixed-layout", "fixed_layout") && value in setOf("true", "yes", "pre-paginated")) ||
-                name == "original-resolution"
-        } == true
+        val packageFixedLayout = metadata.allByLocalName("meta").any { meta ->
+            meta.attr("property").equals("rendition:layout", ignoreCase = true) &&
+                meta.text().trim().equals("pre-paginated", ignoreCase = true)
+        }
 
         val manifest = opf.allByLocalName("manifest").firstOrNull()?.children()
             ?.filter { it.localName() == "item" }
-            ?.mapNotNull { element ->
+            ?.associate { element ->
                 val id = element.attr("id")
                 val href = element.attr("href")
-                if (id.isBlank() || href.isBlank()) null else ManifestItem(
-                    id,
-                    normalizePath(packageDirectory, href.substringBefore('#').substringBefore('?')),
-                    element.attr("media-type").lowercase(Locale.ROOT),
-                    element.attr("properties"),
+                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(),
                 )
-            }?.associateBy { it.id }.orEmpty()
+            }.orEmpty()
+        require(manifest.isNotEmpty()) { "The EPUB manifest is empty." }
 
         val spineElement = opf.allByLocalName("spine").firstOrNull()
-        val spine = spineElement?.children()?.filter { it.localName() == "itemref" }?.mapNotNull { reference ->
-            val item = manifest[reference.attr("idref")] ?: return@mapNotNull null
-            if (!isHtml(item.mediaType, item.href)) return@mapNotNull null
+            ?: error("The EPUB package document has no spine element.")
+        val parsedSpine = spineElement.children().filter { it.localName() == "itemref" }.map { reference ->
+            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)
-            if (!chapterFile.isFile) return@mapNotNull null
-            val layoutProperties = "${item.properties} ${reference.attr("properties")}".lowercase(Locale.ROOT)
-            SpineItem(
-                href = item.href,
-                mediaType = item.mediaType,
-                title = chapterTitle(chapterFile),
+            require(chapterFile.isFile) { "An EPUB spine document is missing." }
+            val referenceProperties = reference.attr("properties").lowercase(Locale.ROOT)
+                .split(Regex("\\s+")).filter(String::isNotBlank).toSet()
+            ParsedSpineItem(
+                item = SpineItem(item.href, chapterTitle(chapterFile)),
                 linear = !reference.attr("linear").equals("no", ignoreCase = true),
-                fixedLayout = packageFixedLayout || "rendition:layout-pre-paginated" in layoutProperties ||
-                    item.mediaType == "image/svg+xml" || looksFixedLayout(chapterFile),
+                fixedLayout = "rendition:layout-pre-paginated" in item.properties ||
+                    "rendition:layout-pre-paginated" in referenceProperties,
             )
-        }.orEmpty().ifEmpty {
-            manifest.values.filter { isHtml(it.mediaType, it.href) && safeFile(output, it.href).isFile }
-                .map {
-                    SpineItem(
-                        it.href,
-                        it.mediaType,
-                        chapterTitle(safeFile(output, it.href)),
-                        fixedLayout = packageFixedLayout || "rendition:layout-pre-paginated" in it.properties.lowercase(Locale.ROOT) ||
-                            it.mediaType == "image/svg+xml" || looksFixedLayout(safeFile(output, it.href)),
-                    )
-                }
         }
-        require(spine.isNotEmpty()) { "The EPUB reading order is empty." }
+        require(parsedSpine.isNotEmpty()) { "The EPUB reading order is empty." }
+        val spine = parsedSpine.map(ParsedSpineItem::item)
 
-        val coverId = metadata?.allByLocalName("meta")?.firstOrNull {
-            it.attr("name").equals("cover", true)
-        }?.attr("content")
-        val cover = manifest.values.firstOrNull { "cover-image" in it.properties.split(Regex("\\s+")) }
-            ?: coverId?.let(manifest::get)
-            ?: opf.allByLocalName("reference").firstOrNull { "cover" in it.attr("type").lowercase() }
-                ?.attr("href")?.takeIf(String::isNotBlank)?.let { href ->
-                    val path = normalizePath(packageDirectory, href.substringBefore('#'))
-                    manifest.values.firstOrNull { it.href == path }
-                }
-        val coverItem = cover ?: manifest.values.firstOrNull { it.properties.contains("cover", true) }
-        coverItem?.let { item ->
+        manifest.values.firstOrNull { "cover-image" in it.properties }?.let { item ->
             val candidate = safeFile(output, item.href)
-            when {
-                candidate.isFile && item.mediaType.startsWith("image/") -> book.coverPath = candidate.absolutePath
-                candidate.isFile && isHtml(item.mediaType, item.href) -> {
-                    val coverDocument = runCatching { parseXml(candidate) }.getOrNull()
-                    val image = coverDocument?.getAllElements()?.firstOrNull {
-                        it.tagName().substringAfter(':').lowercase() in setOf("img", "image")
-                    }
-                    val sourceValue = image?.attr("src").orEmpty().ifBlank {
-                        image?.attributes()?.asList()?.firstOrNull { it.key.endsWith(":href") }?.value.orEmpty()
-                    }
-                    if (sourceValue.isNotBlank()) {
-                        val imagePath = normalizePath(item.href.substringBeforeLast('/', ""), sourceValue.substringBefore('#'))
-                        safeFile(output, imagePath).takeIf(File::isFile)?.let { book.coverPath = it.absolutePath }
-                    }
-                }
-            }
+            if (candidate.isFile && item.mediaType.startsWith("image/")) book.coverPath = candidate.absolutePath
         }
 
-        val navItem = manifest.values.firstOrNull { "nav" in it.properties.split(Regex("\\s+")) }
-        val ncxId = spineElement?.attr("toc")
-        val ncxItem = ncxId?.let(manifest::get)
-            ?: manifest.values.firstOrNull { it.mediaType == "application/x-dtbncx+xml" }
-        var toc = navItem?.let { parseHtmlToc(output, it.href, spine) }.orEmpty()
-        if (toc.isEmpty()) toc = ncxItem?.let { parseNcxToc(output, it.href, spine) }.orEmpty()
-        if (toc.isEmpty()) toc = spine.mapIndexed { index, item ->
-            TocEntry(item.title.ifBlank { "Section ${index + 1}" }, item.href, 0, index)
-        }
+        val navItem = manifest.values.singleOrNull { "nav" in it.properties }
+            ?: error("The EPUB manifest must identify one navigation document.")
+        require(navItem.mediaType == "application/xhtml+xml") { "The EPUB navigation document must be XHTML." }
+        val toc = parseHtmlToc(output, navItem.href, spine)
+        require(toc.isNotEmpty()) { "The EPUB navigation document has no table of contents." }
 
-        val direction = spineElement?.attr("page-progression-direction")?.lowercase(Locale.ROOT)
-            ?.takeIf { it == "rtl" || it == "ltr" } ?: "ltr"
-        val stylesheets = manifest.values.filter { it.mediaType == "text/css" }.map { it.href }
+        val direction = spineElement.attr("page-progression-direction").lowercase(Locale.ROOT)
+            .takeIf { it == "rtl" || it == "ltr" } ?: "ltr"
+        val linearSpine = parsedSpine.filter(ParsedSpineItem::linear)
+        val fixedLayout = packageFixedLayout || linearSpine.isNotEmpty() && linearSpine.all(ParsedSpineItem::fixedLayout)
 
         return EpubPublication(
-            book, output, packagePath, spine, toc, direction, stylesheets,
-            packageFixedLayout || spine.filter(SpineItem::linear).let { it.isNotEmpty() && it.all(SpineItem::fixedLayout) }, packageViewport.first, packageViewport.second,
+            book = book,
+            rootDirectory = output,
+            spine = spine,
+            toc = toc,
+            readingDirection = direction,
+            fixedLayout = fixedLayout,
+            defaultPageWidth = packageViewport.first,
+            defaultPageHeight = packageViewport.second,
         )
     }
 
@@ -203,7 +167,7 @@ object EpubParser {
                 attribute.key.substringAfter(':').equals("type", true) &&
                     "toc" in attribute.value.lowercase().split(Regex("\\s+"))
             }
-        } ?: document.allByLocalName("nav").firstOrNull() ?: return emptyList()
+        } ?: return emptyList()
         val result = mutableListOf<TocEntry>()
         val navDirectory = path.substringBeforeLast('/', "")
 
@@ -222,44 +186,12 @@ object EpubParser {
         return result
     }
 
-    private fun parseNcxToc(root: File, path: String, spine: List<SpineItem>): List<TocEntry> {
-        val file = safeFile(root, path)
-        if (!file.isFile) return emptyList()
-        val document = parseXml(file)
-        val result = mutableListOf<TocEntry>()
-        val base = path.substringBeforeLast('/', "")
-        fun walk(parent: Element, depth: Int) {
-            parent.children().filter { it.localName() == "navpoint" }.forEach { point ->
-                val label = point.allByLocalName("navlabel").firstOrNull()?.allByLocalName("text")?.firstOrNull()?.text()
-                    ?.trim().orEmpty().ifBlank { "Untitled section" }
-                val href = point.allByLocalName("content").firstOrNull()?.attr("src").orEmpty()
-                if (href.isNotBlank()) {
-                    val resolved = resolveReference(base, href)
-                    result += TocEntry(label, resolved, depth, spineIndex(spine, resolved))
-                }
-                walk(point, depth + 1)
-            }
-        }
-        document.allByLocalName("navmap").firstOrNull()?.let { walk(it, 0) }
-        return result
-    }
-
     private fun spineIndex(spine: List<SpineItem>, reference: String): Int {
         val path = reference.substringBefore('#')
-        return spine.indexOfFirst { it.href == path }.coerceAtLeast(0)
-    }
-
-    private fun looksFixedLayout(file: File): Boolean = runCatching {
-        val document = parseXml(file)
-        document.getAllElements().any { element ->
-            val name = element.attr("name").lowercase(Locale.ROOT)
-            val content = element.attr("content")
-            (element.localName() == "meta" && name == "viewport" &&
-                Regex("(?:^|[,;\\s])width\\s*=\\s*[0-9]", RegexOption.IGNORE_CASE).containsMatchIn(content) &&
-                Regex("(?:^|[,;\\s])height\\s*=\\s*[0-9]", RegexOption.IGNORE_CASE).containsMatchIn(content)) ||
-                (element.localName() == "svg" && (element.hasAttr("viewBox") || element.hasAttr("viewbox")))
+        return spine.indexOfFirst { it.href == path }.also { index ->
+            require(index >= 0) { "The EPUB table of contents points outside the reading order: $reference" }
         }
-    }.getOrDefault(false)
+    }
 
     private fun chapterTitle(file: File): String = runCatching {
         val document = parseXml(file)
@@ -304,9 +236,8 @@ object EpubParser {
         Jsoup.parse(stream, null, file.toURI().toString(), Parser.xmlParser())
     }
 
-    private fun isHtml(mediaType: String, path: String): Boolean =
-        mediaType in setOf("application/xhtml+xml", "text/html", "image/svg+xml") ||
-            path.endsWith(".xhtml", true) || path.endsWith(".html", true) || path.endsWith(".htm", true)
+    private fun isContentDocument(mediaType: String): Boolean =
+        mediaType == "application/xhtml+xml" || mediaType == "image/svg+xml"
 
     private fun Element.localName(): String = tagName().substringAfter(':').lowercase(Locale.ROOT)
     private fun Element.allByLocalName(name: String): List<Element> =
@@ -330,30 +261,30 @@ object EpubHtmlBuilder {
     )
 
     fun write(publication: EpubPublication, output: File): File {
-        val chapters = publication.spine.mapIndexedNotNull { index, item ->
+        val chapters = publication.spine.mapIndexed { index, item ->
             val file = EpubParser.safeFile(publication.rootDirectory, item.href)
-            if (!file.isFile) return@mapIndexedNotNull null
-            runCatching {
-                val document = file.inputStream().use { Jsoup.parse(it, null, file.toURI().toString(), Parser.xmlParser()) }
-                // The spine is parsed as XML, but it is embedded in an HTML document.
-                // HTML serialization expands empty non-void tags such as <a/> to
-                // <a></a>; otherwise the browser treats the rest of a chapter as a link.
-                document.outputSettings().syntax(Document.OutputSettings.Syntax.html).prettyPrint(false)
-                val body = document.getAllElements().firstOrNull { it.tagName().substringAfter(':').equals("body", true) }
-                    ?: document
-                val viewport = fixedViewport(document, body, publication.defaultPageWidth, publication.defaultPageHeight)
-                ChapterDocument(
-                    index, item, document, body, item.href.substringBeforeLast('/', ""), viewport.first, viewport.second,
-                    if (publication.fixedLayout) {
-                        runCatching { fixedLineBoxes(document, item.href.substringBeforeLast('/', ""), publication.rootDirectory) }
-                            .onFailure { android.util.Log.w("AlexandriaRenderer", "Could not derive fixed-page line boxes for ${item.href}", it) }
-                            .getOrDefault("")
-                    } else "",
-                )
-            }.getOrNull()
+            require(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,
+                if (publication.fixedLayout) {
+                    runCatching { fixedLineBoxes(document, item.href.substringBeforeLast('/', ""), publication.rootDirectory) }
+                        .onFailure { android.util.Log.w("AlexandriaRenderer", "Could not derive fixed-page line boxes for ${item.href}", it) }
+                        .getOrDefault("")
+                } else "",
+            )
         }
-        require(chapters.isNotEmpty()) { "No readable spine document was found." }
-
         val targetIds = mutableMapOf<String, String>()
         chapters.forEach { chapter ->
             targetIds[chapter.item.href] = "alex-chapter-${chapter.index}"
@@ -366,9 +297,11 @@ object EpubHtmlBuilder {
                 }
             }
         }
+        publication.toc.forEach { entry ->
+            require(entry.href in targetIds) { "The EPUB table of contents has a missing target: ${entry.href}" }
+        }
 
         val cssPaths = linkedSetOf<String>()
-        cssPaths.addAll(publication.stylesheets)
         val inlineStyles = mutableListOf<Pair<String, String>>()
         chapters.forEach { chapter ->
             chapter.document.getAllElements().filter { it.tagName().substringAfter(':').equals("link", true) }.forEach { link ->
@@ -428,11 +361,13 @@ object EpubHtmlBuilder {
                                     element.attr("data-external-href", value)
                                     element.attr("href", "#")
                                 } else {
-                                    val reference = if (value.startsWith('#')) chapter.item.href + value
-                                        else EpubParser.resolveReference(chapter.directory, value)
+                                    val reference = when {
+                                        value == "#" -> chapter.item.href
+                                        value.startsWith('#') -> chapter.item.href + value
+                                        else -> EpubParser.resolveReference(chapter.directory, value)
+                                    }
                                     val target = targetIds[reference]
-                                        ?: targetIds[reference.substringBefore('#')]
-                                        ?: "alex-chapter-${publication.spine.indexOfFirst { it.href == reference.substringBefore('#') }.coerceAtLeast(0)}"
+                                        ?: error("An EPUB link points outside the reading order: $reference")
                                     element.attr("href", "#$target")
                                     element.attr("data-alex-ref", reference)
                                 }
@@ -445,14 +380,17 @@ object EpubHtmlBuilder {
                 val language = chapter.body.attr("lang").ifBlank { chapter.document.attr("lang") }
                 val direction = chapter.body.attr("dir")
                 append("<section class=\"alex-chapter ").append(classes).append("\" id=\"alex-chapter-")
-                    .append(chapter.index).append("\" data-spine=\"").append(chapter.index).append("\" data-href=\"")
-                    .append(escapeAttribute(chapter.item.href)).append("\" data-page-width=\"")
-                    .append(chapter.viewportWidth).append("\" data-page-height=\"").append(chapter.viewportHeight)
-                    .append("\" data-line-boxes=\"").append(chapter.lineBoxes).append('"')
+                    .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("\" data-line-boxes=\"").append(chapter.lineBoxes).append('"')
+                }
                 if (bodyStyle.isNotBlank()) append(" style=\"").append(escapeAttribute(bodyStyle)).append('"')
                 if (language.isNotBlank()) append(" lang=\"").append(escapeAttribute(language)).append('"')
                 if (direction.isNotBlank()) append(" dir=\"").append(escapeAttribute(direction)).append('"')
-                append("><span class=\"alex-chapter-start\" aria-hidden=\"true\"></span>")
+                append('>')
+                if (!publication.fixedLayout) append("<span class=\"alex-chapter-start\" aria-hidden=\"true\"></span>")
                 append(chapter.body.html()).append("</section>\n")
             }
         }
@@ -517,12 +455,6 @@ object EpubHtmlBuilder {
 
         val boxes = linkedSetOf<Pair<Int, Int>>()
         document.getAllElements().forEach { element ->
-            val explicitTop = number(element.attr("data-line-top"))
-            val explicitBottom = number(element.attr("data-line-bottom"))
-            if (explicitTop != null && explicitBottom != null && explicitBottom > explicitTop) {
-                boxes += explicitTop.roundToInt() to explicitBottom.roundToInt()
-                return@forEach
-            }
             val localName = element.tagName().substringAfter(':').lowercase(Locale.ROOT)
             if (localName in setOf("text", "tspan")) {
                 val baseline = number(element.attr("y")) ?: return@forEach
@@ -610,13 +542,12 @@ html.alex-continuous { overflow-x:hidden; overflow-y:auto; }
 body.alex-fixed { box-sizing:border-box; width:100vw; height:100vh; padding:0 !important; overflow:hidden !important; touch-action:none; }
 body.alex-fixed .alex-chapter { display:none !important; position:absolute !important; left:0 !important; top:0 !important; max-width:none !important; max-height:none !important; margin:0 !important; padding:0 !important; overflow:visible !important; transform-origin:0 0; box-sizing:border-box; background-color:var(--alex-paper); }
 body.alex-fixed .alex-chapter.alex-current-page, body.alex-fixed .alex-chapter.alex-stream-next { display:block !important; }
-body.alex-fixed .alex-chapter-start { display:none !important; }
 #alex-cut-mask { display:none; position:fixed; z-index:2147483000; left:0; right:0; bottom:0; height:0; background:var(--alex-paper); pointer-events:none; }
-body.alex-paged .alex-chapter { page-break-before:always; break-before:column !important; }
-body.alex-paged .alex-chapter:first-of-type { page-break-before:auto; break-before:auto !important; }
-body.alex-continuous .alex-chapter { break-before:auto !important; page-break-before:auto !important; margin-bottom:3em; }
+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; page-break-inside:avoid; }
+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; }
@@ -635,23 +566,21 @@ 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" data-direction="${publication.readingDirection}">
+<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-page-filter" x="-10%" y="-10%" width="120%" height="120%" color-interpolation-filters="sRGB"></filter></defs></svg>
 $content
 <div id="alex-cut-mask" aria-hidden="true"></div>
 <script>
 window.ALEX_REFERENCES = $references;
-window.ALEX_DIRECTION = '${publication.readingDirection}';
-window.ALEX_FIXED = ${publication.fixedLayout};
-${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
+${if (publication.fixedLayout) FIXED_READER_SCRIPT else REFLOWABLE_READER_SCRIPT}
 </script>
 </body>
 </html>"""
 
-    private fun readerScript(): String = """
+    private const val REFLOWABLE_READER_SCRIPT = """
 (function () {
   'use strict';
-  var state = { page:0, total:1, mode:'paged', chapterStarts:[], searchPages:[], searchIndex:-1, searchToken:0, settings:null };
+  var state = { page:0, total:1, mode:'paged', chapterStarts:[], searchIndex:-1, searchToken:0 };
   function filterNode(name,attributes){var node=document.createElementNS('http://www.w3.org/2000/svg',name);Object.keys(attributes||{}).forEach(function(key){node.setAttribute(key,String(attributes[key]));});return node;}
   function filterFunctions(parent,type,values){['R','G','B'].forEach(function(channel){parent.appendChild(filterNode('feFunc'+channel,{type:type,tableValues:values}));});parent.appendChild(filterNode('feFuncA',{type:'table',tableValues:'0 1'}));}
   function applyToneFilter(settings){
@@ -662,7 +591,7 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
     if(dither!=='off'){filter.appendChild(filterNode('feColorMatrix',{in:source,type:'matrix',result:'alex-gray',values:'0.2126 0.7152 0.0722 0 0  0.2126 0.7152 0.0722 0 0  0.2126 0.7152 0.0722 0 0  0 0 0 1 0'}));filter.appendChild(filterNode('feTurbulence',{type:'fractalNoise',baseFrequency:'.47',numOctaves:'1',seed:'37',stitchTiles:'stitch',result:'alex-noise'}));var amplitude=dither==='g2' ? 0.96 : 0.065,offset=-amplitude/2;filter.appendChild(filterNode('feColorMatrix',{in:'alex-noise',type:'matrix',result:'alex-drift',values:amplitude+' 0 0 0 '+offset+'  '+amplitude+' 0 0 0 '+offset+'  '+amplitude+' 0 0 0 '+offset+'  0 0 0 1 0'}));filter.appendChild(filterNode('feComposite',{in:'alex-gray',in2:'alex-drift',operator:'arithmetic',k1:'0',k2:'1',k3:'1',k4:'0',result:'alex-noisy'}));var levels=[];if(dither==='g2')levels=['0','1'];else for(var level=0;level<16;level++)levels.push((level/15).toFixed(4));var quantized=filterNode('feComponentTransfer',{in:'alex-noisy',result:'alex-quantized'});filterFunctions(quantized,'discrete',levels.join(' '));filter.appendChild(quantized);filter.appendChild(filterNode('feComposite',{in:'alex-quantized',in2:'SourceGraphic',operator:'in'}));}
     document.body.style.filter='url(#alex-page-filter)';
   }
-  var scrolling = function () { return document.scrollingElement || document.documentElement; };
+  var 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() {
@@ -681,8 +610,7 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
     state.total = Math.max(1, Math.ceil((extent - 1) / Math.max(1, pageExtent())));
     state.page = Math.max(0, Math.min(state.total - 1, Math.round(scrollPosition() / Math.max(1, pageExtent()))));
     state.chapterStarts = Array.prototype.map.call(document.querySelectorAll('.alex-chapter-start'), function (element) { return pageForElement(element); });
-    notifyPage(false);
-    return { page:state.page, total:state.total, chapters:state.chapterStarts, mode:state.mode };
+    notifyPage();
   }
   function chapterAt(page) {
     var result = 0;
@@ -693,7 +621,7 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
     var range = null;
     var x = Math.max(4, Math.min(window.innerWidth - 4, parseInt(getComputedStyle(document.documentElement).getPropertyValue('--alex-side')) + 5 || 20));
     for (var y = 8; y < window.innerHeight - 8 && !range; y += 24) {
-      if (document.caretRangeFromPoint) range = document.caretRangeFromPoint(x, y);
+      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);
@@ -704,17 +632,15 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
     var chapter = document.querySelector('.alex-chapter[data-spine="' + chapterIndex + '"]') || document.querySelectorAll('.alex-chapter')[chapterIndex];
     return { page:state.page, totalPages:state.total, chapter:chapterIndex, offset:chapter ? textOffsetAtPoint(chapter) : 0, progress:state.total <= 1 ? 0 : state.page / (state.total - 1) };
   }
-  function notifyPage(force) {
+  function notifyPage() {
     if (!window.Android || !Android.onPageChanged) return;
-    var value = locator(); value.force = !!force;
-    Android.onPageChanged(JSON.stringify(value));
+    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(true);
-    return JSON.stringify(locator());
+    if (report !== false) notifyPage();
   }
   function nodeAtOffset(chapter, offset) {
     var walker = document.createTreeWalker(chapter, NodeFilter.SHOW_TEXT, { acceptNode:function (node) {
@@ -725,7 +651,7 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
     return last ? {node:last, offset:last.data.length} : null;
   }
   function goToLocator(value) {
-    if (!value) return scrollToPage(0, true);
+    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) {
@@ -733,15 +659,14 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
       var rects = range.getClientRects(), rect = rects.length ? rects[0] : null;
       if (rect && isFinite(rect.left) && isFinite(rect.top) && (rect.width > 0 || rect.height > 0)) {
         var absolute = state.mode === 'paged' ? rect.left + scrolling().scrollLeft : rect.top + scrolling().scrollTop;
-        return scrollToPage(Math.floor((absolute + 2) / pageExtent()), true);
+        scrollToPage(Math.floor((absolute + 2) / pageExtent()), true); return;
       }
     }
-    if (Number(value.totalPages) === state.total && typeof value.page === 'number') return scrollToPage(value.page, true);
-    if (typeof value.progress === 'number') return scrollToPage(Math.round(value.progress * Math.max(0, state.total - 1)), true);
-    return scrollToPage(value.page || 0, true);
+    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) {
-    state.settings = settings || {};
     var root = document.documentElement, body = document.body;
     var family = settings.fontFamily || 'Atkinson Hyperlegible Next';
     root.style.setProperty('--alex-font', "'" + family.replace(/'/g, '') + "'");
@@ -757,7 +682,6 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
     document.getElementById('alex-publisher-styles').disabled = settings.publisherStyles === false;
     applyToneFilter(settings);
     setTimeout(function () { recalculate(); if (keep) goToLocator(keep); }, 80);
-    return true;
   }
   function recordNavigation(targetPage, origin) {
     var from = origin || locator();
@@ -767,23 +691,23 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
     Android.onNavigationJump(JSON.stringify(from));
   }
   function goToReference(reference, record) {
-    var id = window.ALEX_REFERENCES[reference] || window.ALEX_REFERENCES[String(reference).split('#')[0]] || reference;
-    if (id && id.charAt(0) === '#') id = id.substring(1);
+    var id = window.ALEX_REFERENCES[reference];
+    if (!id) return;
     var target = document.getElementById(id);
-    if (!target) return false;
-    var page = pageForElement(target); if (record !== false) recordNavigation(page); scrollToPage(page, true); return true;
+    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.searchPages = []; state.searchIndex = -1; recalculate();
+    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 0; }
+    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 ? parent.closest('.alex-chapter') : null;
+      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())) {
@@ -803,9 +727,8 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
     marks = Array.prototype.slice.call(document.querySelectorAll('mark.alex-search'));
     setTimeout(function () {
       if (token !== state.searchToken) return;
-      recalculate(); var results = marks.map(function(mark,index){
-        var page = pageForElement(mark), text = mark.parentElement ? mark.parentElement.textContent.replace(/\s+/g,' ').trim() : mark.textContent;
-        return {index:index,page:page,chapter:parseInt(mark.dataset.chapter || '0'),offset:parseInt(mark.dataset.offset || '0'),snippet:text.substring(0,180)};
+      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') {
@@ -816,40 +739,39 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
         if (selected < 0 && results.length) selected = 0;
       }
       results.forEach(function(result,index){result.current = index === selected;});
-      state.searchPages = results.map(function(r){return r.page;}); state.searchIndex = selected;
+      state.searchIndex = selected;
       if (selected >= 0) { marks[selected].classList.add('alex-search-current'); recordNavigation(results[selected].page, origin); scrollToPage(results[selected].page, true); }
       if (window.Android) Android.onSearchResults(JSON.stringify(results));
     }, 40);
-    return marks.length;
   }
   function moveSearch(delta) {
-    var marks = document.querySelectorAll('mark.alex-search'); if (!marks.length) return -1;
+    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); return state.searchIndex;
+    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 ? chapter.closest('.alex-chapter') : null; if (!chapter) return null;
+    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), page:state.page };
+    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 false;
+    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); }
-    }); return true;
+    });
   }
   function applyAnnotations(values) { (values || []).slice().sort(function(a,b){return b.start-a.start;}).forEach(wrapAnnotation); setTimeout(recalculate,20); }
   function removeAnnotation(id) { Array.prototype.forEach.call(document.querySelectorAll('mark[data-annotation="' + id + '"]'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));}); document.body.normalize(); recalculate(); }
   document.addEventListener('click', function (event) {
-    var mark=event.target.closest && event.target.closest('mark.alex-annotation'); if(mark && window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;}
-    var link=event.target.closest && event.target.closest('a'); if(!link)return;
+    var 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);
@@ -857,37 +779,35 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
   function handleScroll(){
     clearTimeout(scrollTimer); scrollTimer=setTimeout(function(){
       var next=Math.max(0,Math.min(state.total-1,Math.round(scrollPosition()/Math.max(1,pageExtent()))));
-      if(next!==state.page){state.page=next;notifyPage(false);}
+      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 = { recalculate:recalculate, setPage:scrollToPage, next:function(){return scrollToPage(state.page+1,true);}, previous:function(){return scrollToPage(state.page-1,true);},
+  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;}, metrics:function(){return state;} };
-  function ready() { var value=recalculate(); if(window.Android&&Android.onReaderReady)Android.onReaderReady(JSON.stringify(value)); }
-  if (document.fonts && document.fonts.ready) document.fonts.ready.then(function(){setTimeout(ready,30);}); else window.addEventListener('load',ready);
-  window.addEventListener('load',function(){setTimeout(ready,120);});
+    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 fun fixedReaderScript(): String = """
+    private const val FIXED_READER_SCRIPT = """
 (function () {
   'use strict';
   var pages = Array.prototype.slice.call(document.querySelectorAll('.alex-chapter'));
   var state = {
-    page:0, total:Math.max(1,pages.length), settings:{}, offsetX:NaN, offsetY:NaN,
-    scale:1, crop:{x:0,y:0,width:1,height:1}, pageWidth:1, pageHeight:1, nextVisible:0,
-    searchIndex:-1, searchPages:[], searchToken:0, lineCache:[]
+    page:0, total:pages.length, settings:{}, offsetX:NaN, offsetY:NaN,
+    scale:1, crop:{x:0,y:0,width:1,height:1}, nextVisible:0, searchIndex:-1, lineCache:[]
   };
   function number(value, fallback) { value=Number(value); return isFinite(value)?value:fallback; }
   function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }
-  function pageElement() { return pages[clamp(state.page,0,pages.length-1)] || null; }
+  function pageElement() { return pages[clamp(state.page,0,pages.length-1)]; }
   function pageSize(page) {
-    return { width:Math.max(1,number(page && page.getAttribute('data-page-width'),1200)),
-             height:Math.max(1,number(page && page.getAttribute('data-page-height'),1600)) };
+    return { width:Math.max(1,number(page.getAttribute('data-page-width'),1200)),
+             height:Math.max(1,number(page.getAttribute('data-page-height'),1600)) };
   }
   function normalizedMargins(value) {
     value=value||{};
@@ -937,7 +857,6 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
     return values.join(' ');
   }
   function updateFilter(page) {
-    if(!page) return;
     var settings=state.settings||{}, exponent=clamp(number(settings.contrastExponent,1),1,5);
     var gray=clamp(number(settings.grayPoint,255),16,255), dither=settings.dithering||'off';
     var filter=document.getElementById('alex-page-filter');
@@ -965,7 +884,6 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
     page.style.filter='url(#alex-page-filter)';
   }
   function layoutPage() {
-    if(!pages.length) return;
     showCutMask(0,1);
     state.page=clamp(Math.round(number(state.page,0)),0,pages.length-1);
     pages.forEach(function(page,index){page.classList.toggle('alex-current-page',index===state.page);page.classList.remove('alex-stream-next');});
@@ -987,7 +905,7 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
       state.offsetY=clampOffset(state.offsetY,crop.y,crop.height,visibleHeight);
     }
     page.style.transform='translate('+(-state.offsetX*scale)+'px,'+(-state.offsetY*scale)+'px) scale('+scale+')';
-    state.scale=scale; state.crop=crop; state.pageWidth=size.width; state.pageHeight=size.height; state.nextVisible=0;
+    state.scale=scale; state.crop=crop; state.nextVisible=0;
     updateFilter(page);
     if(mode==='fit-width' && (state.settings.scrollMode||'screen')==='screen'){
       var currentTarget=state.offsetY+visibleHeight,currentEnd=crop.y+crop.height;
@@ -1013,20 +931,19 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
       progress:state.total<=1?0:state.page/(state.total-1),
       viewportX:isFinite(state.offsetX)?state.offsetX:0,viewportY:isFinite(state.offsetY)?state.offsetY:0};
   }
-  function notifyPage(force) {
+  function notifyPage() {
     if(!window.Android||!Android.onPageChanged)return;
-    var value=locator();value.force=!!force;Android.onPageChanged(JSON.stringify(value));
+    Android.onPageChanged(JSON.stringify(locator()));
   }
   function recalculate() {
-    state.total=Math.max(1,pages.length);layoutPage();notifyPage(false);
-    return {page:state.page,total:state.total,chapters:pages.map(function(_,index){return index;}),mode:'fixed'};
+    state.total=pages.length;layoutPage();notifyPage();
   }
   function setPage(page,report) {
     var next=clamp(Math.round(number(page,0)),0,state.total-1), changed=next!==state.page;
-    state.page=next;if(changed){state.offsetX=NaN;state.offsetY=NaN;}layoutPage();if(report!==false)notifyPage(true);return JSON.stringify(locator());
+    state.page=next;if(changed){state.offsetX=NaN;state.offsetY=NaN;}layoutPage();if(report!==false)notifyPage();
   }
   function pageForElement(element) {
-    var chapter=element&&element.closest?element.closest('.alex-chapter'):null;
+    var chapter=element?element.closest('.alex-chapter'):null;
     return chapter?clamp(number(chapter.getAttribute('data-spine'),0),0,state.total-1):0;
   }
   function measuredLines(page) {
@@ -1056,121 +973,119 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
     mask.style.height=Math.ceil(height)+'px';mask.style.display='block';
   }
   function advanceSlice(direction) {
-    if((state.settings.zoomMode||'fit-page')!=='fit-width') return setPage(state.page+direction,true);
+    if((state.settings.zoomMode||'fit-page')!=='fit-width'){setPage(state.page+direction,true);return;}
     var availableHeight=window.innerHeight,visibleHeight=availableHeight/state.scale,crop=state.crop,start=crop.y,end=crop.y+crop.height;
     if(direction>0){
       if(state.offsetY+visibleHeight<end-1){
         var target=linePreservingCut(Math.min(end-1,state.offsetY+visibleHeight),1);
-        state.offsetY=clamp(target,state.offsetY+Math.min(8,visibleHeight/10),end-1);layoutPage();notifyPage(true);return JSON.stringify(locator());
+        state.offsetY=clamp(target,state.offsetY+Math.min(8,visibleHeight/10),end-1);layoutPage();notifyPage();return;
       }
       if(state.page<state.total-1){
         var alreadyShown=(state.settings.scrollMode||'screen')==='screen'?state.nextVisible:0;
         state.page+=1;state.offsetX=NaN;state.offsetY=NaN;layoutPage();
         if(alreadyShown>0){state.offsetY=state.crop.y+alreadyShown;layoutPage();}
-        notifyPage(true);return JSON.stringify(locator());
+        notifyPage();
       }
-      return JSON.stringify(locator());
+      return;
     }
     var precedingScreen=(state.offsetY-start)*state.scale;
     if(precedingScreen>=availableHeight-1){
-      state.offsetY=clamp(linePreservingCut(Math.max(start,state.offsetY-visibleHeight),-1),start,state.offsetY-1);layoutPage();notifyPage(true);return JSON.stringify(locator());
+      state.offsetY=clamp(linePreservingCut(Math.max(start,state.offsetY-visibleHeight),-1),start,state.offsetY-1);layoutPage();notifyPage();return;
     }
     if(state.page>0){
       var currentPreceding=Math.max(0,precedingScreen),needed=(state.settings.scrollMode||'screen')==='screen'?availableHeight-currentPreceding:availableHeight;
       state.page-=1;state.offsetX=NaN;state.offsetY=NaN;layoutPage();
       state.offsetY=clamp(linePreservingCut(state.crop.y+state.crop.height-needed/state.scale,-1),state.crop.y,state.crop.y+state.crop.height-1);
-      layoutPage();notifyPage(true);return JSON.stringify(locator());
+      layoutPage();notifyPage();return;
     }
-    if(state.offsetY>start){state.offsetY=start;layoutPage();notifyPage(true);}return JSON.stringify(locator());
+    if(state.offsetY>start){state.offsetY=start;layoutPage();notifyPage();}
   }
   function goToLocator(value) {
-    if(!value)return setPage(0,true);
+    if(!value){setPage(0,true);return;}
     state.page=clamp(Math.round(number(value.chapter,value.page||0)),0,state.total-1);
-    state.offsetX=number(value.viewportX,NaN);state.offsetY=number(value.viewportY,NaN);layoutPage();notifyPage(true);return JSON.stringify(locator());
+    state.offsetX=number(value.viewportX,NaN);state.offsetY=number(value.viewportY,NaN);layoutPage();notifyPage();
   }
   function applySettings(settings,keep) {
     state.settings=settings||{};
     // Fixed-layout coordinates depend on the publisher stylesheet.
     document.getElementById('alex-publisher-styles').disabled=false;
     if(keep){state.page=clamp(Math.round(number(keep.chapter,keep.page||0)),0,state.total-1);state.offsetX=number(keep.viewportX,NaN);state.offsetY=number(keep.viewportY,NaN);}
-    layoutPage();notifyPage(true);return true;
+    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]||window.ALEX_REFERENCES[String(reference).split('#')[0]]||reference;
-    if(id&&id.charAt(0)==='#')id=id.substring(1);var target=document.getElementById(id);if(!target)return false;
-    var page=pageForElement(target);if(record!==false)recordNavigation(page);setPage(page,true);return true;
+    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() {
-    state.searchToken++;
     Array.prototype.forEach.call(document.querySelectorAll('mark.alex-search'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));});
-    document.body.normalize();state.searchPages=[];state.searchIndex=-1;
+    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 0;}
+    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?parent.closest('.alex-chapter'):null;
+      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,index){var page=pageForElement(mark),text=mark.parentElement?mark.parentElement.textContent.replace(/\s+/g,' ').trim():mark.textContent;return{index:index,page:page,chapter:page,offset:0,snippet:text.substring(0,180)};});
+    var 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.searchPages=results.map(function(result){return result.page;});state.searchIndex=selected;
+    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));return marks.length;
+    if(window.Android)Android.onSearchResults(JSON.stringify(results));
   }
   function moveSearch(delta) {
-    var marks=document.querySelectorAll('mark.alex-search');if(!marks.length)return-1;
+    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);return state.searchIndex;
+    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?chapter.closest('.alex-chapter'):null;if(!chapter)return null;
+    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),page:state.page};
+    return{chapter:parseInt(chapter.getAttribute('data-spine')||'0'),start:start,end:start+quote.length,quote:quote.substring(0,10000)};
   }
   function nodeAtOffset(chapter,offset){var walker=document.createTreeWalker(chapter,NodeFilter.SHOW_TEXT),node,count=0,last=null;while((node=walker.nextNode())){last=node;if(count+node.data.length>=offset)return{node:node,offset:Math.max(0,offset-count)};count+=node.data.length;}return last?{node:last,offset:last.data.length}:null;}
   function wrapAnnotation(annotation) {
-    var chapter=document.querySelector('.alex-chapter[data-spine="'+annotation.chapter+'"]');if(!chapter||annotation.end<=annotation.start)return false;
+    var 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);}});return true;
+    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();}
   function panBy(dx,dy){
-    if((state.settings.zoomMode||'fit-page')!=='custom')return false;state.offsetX-=number(dx,0)/state.scale;state.offsetY-=number(dy,0)/state.scale;layoutPage();return true;
+    if((state.settings.zoomMode||'fit-page')!=='custom')return;state.offsetX-=number(dx,0)/state.scale;state.offsetY-=number(dy,0)/state.scale;layoutPage();
   }
   function zoomBy(factor){
     factor=clamp(number(factor,1),0.75,1.33);var crop=state.crop,fitPage=Math.min(window.innerWidth/crop.width,window.innerHeight/crop.height);
     if((state.settings.zoomMode||'fit-page')!=='custom')state.settings.customZoom=clamp(Math.round(100*state.scale/fitPage),50,400);
     var centerX=state.offsetX+window.innerWidth/(2*state.scale),centerY=state.offsetY+window.innerHeight/(2*state.scale);
     state.settings.zoomMode='custom';state.settings.customZoom=clamp(number(state.settings.customZoom,100)*factor,50,400);
-    var nextScale=fitPage*state.settings.customZoom/100;state.offsetX=centerX-window.innerWidth/(2*nextScale);state.offsetY=centerY-window.innerHeight/(2*nextScale);layoutPage();return state.settings.customZoom;
+    var nextScale=fitPage*state.settings.customZoom/100;state.offsetX=centerX-window.innerWidth/(2*nextScale);state.offsetY=centerY-window.innerHeight/(2*nextScale);layoutPage();
   }
-  function finishViewportGesture(){notifyPage(true);if(window.Android&&Android.onViewportChanged)Android.onViewportChanged(JSON.stringify({zoomMode:state.settings.zoomMode||'fit-page',customZoom:Math.round(number(state.settings.customZoom,100))}));return JSON.stringify(locator());}
+  function finishViewportGesture(){notifyPage();if(window.Android&&Android.onViewportChanged)Android.onViewportChanged(JSON.stringify({zoomMode:state.settings.zoomMode||'fit-page',customZoom:Math.round(number(state.settings.customZoom,100))}));}
   document.addEventListener('click',function(event){
-    var mark=event.target.closest&&event.target.closest('mark.alex-annotation');if(mark&&window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;}
-    var link=event.target.closest&&event.target.closest('a');if(!link)return;var external=link.getAttribute('data-external-href');if(external){event.preventDefault();if(window.Android)Android.onExternalLink(external);return;}
+    var 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(true);},120);});
-  window.Alex={recalculate:recalculate,setPage:setPage,next:function(){return advanceSlice(1);},previous:function(){return advanceSlice(-1);},locator:locator,goToLocator:goToLocator,
+  var resizeTimer=0;window.addEventListener('resize',function(){clearTimeout(resizeTimer);resizeTimer=setTimeout(function(){layoutPage();notifyPage();},120);});
+  window.Alex={setPage:setPage,next:function(){advanceSlice(1);},previous:function(){advanceSlice(-1);},locator:locator,goToLocator:goToLocator,
     applySettings:applySettings,goToReference:goToReference,search:search,clearSearch:clearSearch,moveSearch:moveSearch,captureSelection:captureSelection,
-    applyAnnotations:applyAnnotations,wrapAnnotation:wrapAnnotation,removeAnnotation:removeAnnotation,chapterPage:function(index){return clamp(index,0,state.total-1);},metrics:function(){return state;},
+    applyAnnotations:applyAnnotations,wrapAnnotation:wrapAnnotation,removeAnnotation:removeAnnotation,chapterPage:function(index){return clamp(index,0,state.total-1);},
     panBy:panBy,zoomBy:zoomBy,finishViewportGesture:finishViewportGesture};
-  function ready(){var value=recalculate();if(window.Android&&Android.onReaderReady)Android.onReaderReady(JSON.stringify(value));}
-  if(document.fonts&&document.fonts.ready)document.fonts.ready.then(function(){setTimeout(ready,30);});else window.addEventListener('load',ready);
-  window.addEventListener('load',function(){setTimeout(ready,120);});
+  function ready(){recalculate();if(window.Android&&Android.onReaderReady)Android.onReaderReady();}
+  document.fonts.ready.then(function(){setTimeout(ready,30);});
 })();
 """
 
@@ -1246,13 +1161,7 @@ ${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
                 val body = source.substring(open + 1, close)
                 val trimmed = prelude.trimStart()
                 fun appendRule(rulePrelude: String, declarations: String) {
-                    val pagedBody = declarations
-                        .replace(Regex("page-break-before\\s*:\\s*always\\s*;?", RegexOption.IGNORE_CASE)) { "page-break-before:always;break-before:column;" }
-                        .replace(Regex("page-break-after\\s*:\\s*always\\s*;?", RegexOption.IGNORE_CASE)) { "page-break-after:always;break-after:column;" }
-                        .replace(Regex("page-break-before\\s*:\\s*avoid\\s*;?", RegexOption.IGNORE_CASE)) { "page-break-before:avoid;break-before:avoid-column;" }
-                        .replace(Regex("page-break-after\\s*:\\s*avoid\\s*;?", RegexOption.IGNORE_CASE)) { "page-break-after:avoid;break-after:avoid-column;" }
-                        .replace(Regex("page-break-inside\\s*:\\s*avoid\\s*;?", RegexOption.IGNORE_CASE)) { "page-break-inside:avoid;break-inside:avoid-column;" }
-                    out.append(transformSelector(rulePrelude)).append('{').append(pagedBody).append('}')
+                    out.append(transformSelector(rulePrelude)).append('{').append(declarations).append('}')
                 }
                 when {
                     trimmed.startsWith("@media", true) || trimmed.startsWith("@supports", true) || trimmed.startsWith("@layer", true) ->
index d4b813f70825e2808c8819faf0e77e0f836a3055..7f5997442236674f316844ba5a1fcd8048198bbf 100644 (file)
@@ -92,7 +92,7 @@ class LibraryRepository(private val context: Context) {
                 fileName = safeName,
             )
             val publication = try {
-                EpubParser.parse(source, File(bookDirectory, "content"), book, forceExtract = true)
+                EpubParser.parse(source, File(bookDirectory, "content"), book)
             } catch (error: Throwable) {
                 bookDirectory.deleteRecursively()
                 throw error
@@ -109,11 +109,11 @@ class LibraryRepository(private val context: Context) {
     }
 
     fun open(book: LibraryBook): EpubPublication {
-        val canonical = synchronized(lock) { books.firstOrNull { it.id == book.id } ?: book }
+        val canonical = synchronized(lock) { books.first { it.id == book.id } }
         val directory = directoryFor(canonical.id)
         val source = File(directory, "source.epub")
         require(source.isFile) { "The imported EPUB is missing." }
-        return EpubParser.parse(source, File(directory, "content"), canonical, forceExtract = false)
+        return EpubParser.parse(source, File(directory, "content"), canonical)
     }
 
     fun readerFile(publication: EpubPublication): File =
@@ -121,8 +121,6 @@ class LibraryRepository(private val context: Context) {
 
     fun updateProgress(bookId: String, location: ReaderLocation) = synchronized(lock) {
         books.firstOrNull { it.id == bookId }?.let { book ->
-            book.currentPage = location.page
-            book.totalPages = location.totalPages.coerceAtLeast(1)
             book.progress = location.progress.coerceIn(0f, 1f)
             book.lastOpenedAt = System.currentTimeMillis()
             book.state = when {
@@ -166,10 +164,7 @@ class LibraryRepository(private val context: Context) {
 
     fun location(bookId: String): ReaderLocation {
         val file = File(directoryFor(bookId), "location.json")
-        return runCatching { ReaderLocation.fromJson(JSONObject(file.readText())) }.getOrElse {
-            val book = synchronized(lock) { books.firstOrNull { it.id == bookId } }
-            ReaderLocation(book?.currentPage ?: 0, book?.totalPages ?: 1, progress = book?.progress ?: 0f)
-        }
+        return runCatching { ReaderLocation.fromJson(JSONObject(file.readText())) }.getOrDefault(ReaderLocation())
     }
 
     private fun saveLocation(bookId: String, location: ReaderLocation) {
@@ -235,13 +230,10 @@ class LibraryRepository(private val context: Context) {
     companion object {
         private const val MAX_IMPORT_BYTES = 1_073_741_824L
 
-        private fun displayName(resolver: ContentResolver, uri: Uri): String? {
-            if (uri.scheme == "file") return uri.lastPathSegment
-            return runCatching {
-                resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
-                    if (cursor.moveToFirst()) cursor.getString(0) else null
-                }
-            }.getOrNull()
-        }
+        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()
     }
 }
index 6262c82e6c25ccee668d237e77f0a19ae6740c52..096f3e4de1bcdeae24f9ac0af6aca4cd44619218 100644 (file)
@@ -1,29 +1,32 @@
 package com.alexandria.reader
 
-import android.app.Activity
 import android.app.AlertDialog
 import android.content.Intent
 import android.net.Uri
 import android.os.Bundle
 import android.view.Gravity
 import android.view.KeyEvent
-import android.view.View
 import android.widget.TextView
 import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.addCallback
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.IntentCompat
 import java.util.concurrent.atomic.AtomicInteger
 
-class MainActivity : Activity() {
+class MainActivity : ComponentActivity() {
     private lateinit var repository: LibraryRepository
     private var libraryScreen: LibraryScreen? = null
     private var readerScreen: ReaderScreen? = null
     private val operation = AtomicInteger()
+    private val openDocuments = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
+        if (uris.isNotEmpty()) importUris(uris)
+    }
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
-        window.statusBarColor = EInkPalette.PAPER
-        window.navigationBarColor = EInkPalette.PAPER
-        window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
         repository = LibraryRepository(this)
+        onBackPressedDispatcher.addCallback(this) { navigateBack() }
         if (!handleIntent(intent)) showLibrary()
     }
 
@@ -33,22 +36,19 @@ class MainActivity : Activity() {
         if (!handleIntent(intent)) showLibrary()
     }
 
-    private fun handleIntent(value: Intent?): Boolean {
-        val intent = value ?: return false
+    private fun handleIntent(intent: Intent): Boolean {
         val uris = mutableListOf<Uri>()
         when (intent.action) {
             Intent.ACTION_VIEW -> intent.data?.let(uris::add)
-            Intent.ACTION_SEND -> {
-                intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let(uris::add)
-                if (uris.isEmpty()) intent.data?.let(uris::add)
-            }
-            Intent.ACTION_SEND_MULTIPLE -> intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.let(uris::addAll)
+            Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let(uris::add)
+            Intent.ACTION_SEND_MULTIPLE ->
+                IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let(uris::addAll)
         }
         intent.clipData?.let { clip ->
             for (index in 0 until clip.itemCount) clip.getItemAt(index).uri?.let { if (it !in uris) uris += it }
         }
         if (uris.isEmpty()) return false
-        importUris(uris, intent.flags)
+        importUris(uris)
         return true
     }
 
@@ -63,29 +63,10 @@ class MainActivity : Activity() {
     }
 
     private fun openPicker() {
-        @Suppress("DEPRECATION")
-        startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
-            addCategory(Intent.CATEGORY_OPENABLE)
-            type = "application/epub+zip"
-            putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/epub+zip", "application/epub", "application/octet-stream"))
-            putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
-            addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
-        }, OPEN_DOCUMENT)
-    }
-
-    @Deprecated("The framework result API is retained because Alexandria targets Android 11 without AndroidX activity dependencies")
-    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
-        super.onActivityResult(requestCode, resultCode, data)
-        if (requestCode != OPEN_DOCUMENT || resultCode != RESULT_OK || data == null) return
-        val uris = mutableListOf<Uri>()
-        data.data?.let(uris::add)
-        data.clipData?.let { clip ->
-            for (index in 0 until clip.itemCount) clip.getItemAt(index).uri?.let { if (it !in uris) uris += it }
-        }
-        if (uris.isNotEmpty()) importUris(uris, data.flags)
+        openDocuments.launch(arrayOf("application/epub+zip"))
     }
 
-    private fun importUris(uris: List<Uri>, flags: Int) {
+    private fun importUris(uris: List<Uri>) {
         val token = operation.incrementAndGet()
         showLoading(if (uris.size == 1) "Importing EPUB…" else "Importing ${uris.size} EPUBs…")
         Thread {
@@ -93,9 +74,6 @@ class MainActivity : Activity() {
             var failure: Throwable? = null
             uris.forEach { uri ->
                 try {
-                    if (flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION != 0) {
-                        runCatching { contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) }
-                    }
                     last = repository.import(uri)
                 } catch (error: Throwable) {
                     failure = error
@@ -103,12 +81,9 @@ class MainActivity : Activity() {
             }
             runOnUiThread {
                 if (token != operation.get()) return@runOnUiThread
-                when {
-                    last != null -> showReader(last!!)
-                    failure != null -> showImportError(failure!!)
-                    else -> showLibrary()
-                }
-                if (last != null && failure != null) Toast.makeText(this, "Some files could not be imported", Toast.LENGTH_LONG).show()
+                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()
             }
         }.start()
     }
@@ -156,20 +131,19 @@ class MainActivity : Activity() {
             .setNegativeButton("Close", null).show()
     }
 
-    override fun dispatchKeyEvent(event: KeyEvent): Boolean {
-        if (event.action == KeyEvent.ACTION_DOWN && readerScreen?.handleKey(event.keyCode) == true) return true
-        return super.dispatchKeyEvent(event)
+    override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
+        if (readerScreen?.handleKey(keyCode) == true) return true
+        return super.onKeyDown(keyCode, event)
     }
 
-    @Deprecated("Android 11 activity back dispatch")
-    override fun onBackPressed() {
+    private fun navigateBack() {
         val reader = readerScreen
         if (reader != null) {
             if (!reader.handleBack()) showLibrary()
         } else if (libraryScreen?.searchFocusActive() == true) {
             libraryScreen?.closeSearch()
         } else {
-            super.onBackPressed()
+            finish()
         }
     }
 
@@ -189,8 +163,4 @@ class MainActivity : Activity() {
         readerScreen = null
         super.onDestroy()
     }
-
-    companion object {
-        private const val OPEN_DOCUMENT = 41
-    }
 }
index 17936c394f6f3bc2d829c1b68338b0ee2cd2c9c9..a66ba2b2bc446f96e9f4a74bc9dc8699e764849c 100644 (file)
@@ -17,8 +17,6 @@ data class LibraryBook(
     var addedAt: Long = System.currentTimeMillis(),
     var lastOpenedAt: Long = 0L,
     var progress: Float = 0f,
-    var currentPage: Int = 0,
-    var totalPages: Int = 1,
     var state: ReadingState = ReadingState.NEW,
 ) {
     enum class ReadingState { NEW, READING, FINISHED }
@@ -35,8 +33,6 @@ data class LibraryBook(
         put("addedAt", addedAt)
         put("lastOpenedAt", lastOpenedAt)
         put("progress", progress.toDouble())
-        put("currentPage", currentPage)
-        put("totalPages", totalPages)
         put("state", state.name)
     }
 
@@ -53,8 +49,6 @@ data class LibraryBook(
             addedAt = value.optLong("addedAt", System.currentTimeMillis()),
             lastOpenedAt = value.optLong("lastOpenedAt"),
             progress = value.optDouble("progress", 0.0).toFloat().coerceIn(0f, 1f),
-            currentPage = value.optInt("currentPage").coerceAtLeast(0),
-            totalPages = value.optInt("totalPages", 1).coerceAtLeast(1),
             state = runCatching { ReadingState.valueOf(value.optString("state", "NEW")) }
                 .getOrDefault(ReadingState.NEW),
         )
@@ -63,10 +57,7 @@ data class LibraryBook(
 
 data class SpineItem(
     val href: String,
-    val mediaType: String,
     val title: String,
-    val linear: Boolean = true,
-    val fixedLayout: Boolean = false,
 )
 
 data class TocEntry(
@@ -79,11 +70,9 @@ data class TocEntry(
 data class EpubPublication(
     val book: LibraryBook,
     val rootDirectory: File,
-    val packagePath: String,
     val spine: List<SpineItem>,
     val toc: List<TocEntry>,
     val readingDirection: String,
-    val stylesheets: List<String>,
     val fixedLayout: Boolean,
     val defaultPageWidth: Int,
     val defaultPageHeight: Int,
@@ -151,7 +140,6 @@ data class ReaderSettings(
     val hyphenation: Boolean = true,
     val mode: String = "paged",
     val brightness: Int = -1,
-    val volumeKeys: Boolean = true,
     val zoomMode: String = "fit-page",
     val customZoom: Int = 100,
     val scrollMode: String = "screen",
@@ -173,7 +161,6 @@ data class ReaderSettings(
         put("hyphenation", hyphenation)
         put("mode", mode)
         put("brightness", brightness)
-        put("volumeKeys", volumeKeys)
         put("zoomMode", zoomMode)
         put("customZoom", customZoom)
         put("scrollMode", scrollMode)
@@ -205,7 +192,6 @@ data class ReaderSettings(
             hyphenation = value.optBoolean("hyphenation", true),
             mode = value.optString("mode", "paged").takeIf { it in setOf("paged", "continuous") } ?: "paged",
             brightness = value.optInt("brightness", -1).coerceIn(-1, 100),
-            volumeKeys = value.optBoolean("volumeKeys", true),
             zoomMode = value.optString("zoomMode", "fit-page")
                 .takeIf { it in setOf("fit-page", "fit-width", "custom") } ?: "fit-page",
             customZoom = value.optInt("customZoom", 100).coerceIn(50, 400),
@@ -222,7 +208,6 @@ data class ReaderSettings(
 }
 
 data class Bookmark(
-    val id: String,
     val label: String,
     val chapter: Int,
     val offset: Int,
@@ -231,13 +216,13 @@ data class Bookmark(
     val createdAt: Long,
 ) {
     fun toJson(): JSONObject = JSONObject().apply {
-        put("id", id); put("label", label); put("chapter", chapter); put("offset", offset)
+        put("label", label); put("chapter", chapter); put("offset", offset)
         put("page", page); put("progress", progress.toDouble()); put("createdAt", createdAt)
     }
 
     companion object {
         fun fromJson(value: JSONObject) = Bookmark(
-            value.getString("id"), value.optString("label", "Bookmark"), value.optInt("chapter"),
+            value.optString("label", "Bookmark"), value.optInt("chapter"),
             value.optInt("offset"), value.optInt("page"), value.optDouble("progress").toFloat(),
             value.optLong("createdAt"),
         )
@@ -251,18 +236,16 @@ data class Annotation(
     val end: Int,
     val quote: String,
     var note: String,
-    val createdAt: Long,
 ) {
     fun toJson(): JSONObject = JSONObject().apply {
         put("id", id); put("chapter", chapter); put("start", start); put("end", end)
-        put("quote", quote); put("note", note); put("createdAt", createdAt)
+        put("quote", quote); put("note", note)
     }
 
     companion object {
         fun fromJson(value: JSONObject) = Annotation(
             value.getString("id"), value.optInt("chapter"), value.optInt("start"),
             value.optInt("end"), value.optString("quote"), value.optString("note"),
-            value.optLong("createdAt"),
         )
     }
 }
index 706934cd097ce9e96e4c9a0ffcb583631d289ab3..26b2ee59e421261b7a006694b81bafdfccf7c98d 100644 (file)
@@ -82,7 +82,7 @@ class ReaderScreen(
         webView.configurePageTools(publication.fixedLayout, settings)
         webView.load(repository.readerFile(publication))
         repository.markOpened(publication.book.id)
-        applyEInkAppearance()
+        applyBrightness()
     }
 
     private fun buildReader() {
@@ -167,23 +167,20 @@ class ReaderScreen(
         view.textSize = 20f
         view.gravity = Gravity.CENTER
         view.setTextColor(EInkPalette.INK)
-        view.setBackgroundColor(EInkPalette.TRANSPARENT)
         view.isClickable = true
         view.isFocusable = true
         view.setPadding(dp(4), 0, dp(4), 0)
     }
 
-    override fun onReady(metrics: JSONObject) {
-        if (!initialized) {
-            initialized = true
-            webView.applyAnnotations(annotations)
-            webView.applySettings(settings, initialLocation)
-            handler.postDelayed({
-                restoring = false
-                loading.visibility = View.GONE
-                webView.captureLocation { onLocationChanged(it) }
-            }, 350)
-        }
+    override fun onReady() {
+        initialized = true
+        webView.applyAnnotations(annotations)
+        webView.applySettings(settings, initialLocation)
+        handler.postDelayed({
+            restoring = false
+            loading.visibility = View.GONE
+            webView.captureLocation { onLocationChanged(it) }
+        }, 350)
     }
 
     override fun onLocationChanged(location: ReaderLocation) {
@@ -295,12 +292,9 @@ class ReaderScreen(
     private fun showMenu(anchor: View) {
         PopupMenu(activity, anchor).apply {
             menu.add("Table of contents").setOnMenuItemClickListener { showTableOfContents(); true }
-            menu.add("Search").setOnMenuItemClickListener { showSearch(); true }
             menu.add("Bookmarks").setOnMenuItemClickListener { showBookmarks(); true }
             menu.add("Annotations and highlights").setOnMenuItemClickListener { showAnnotations(); true }
             menu.add("Go to page").setOnMenuItemClickListener { showGoToPage(); true }
-            menu.add("First page").setOnMenuItemClickListener { rememberLocation(); webView.goToPage(0); true }
-            menu.add("Last page").setOnMenuItemClickListener { rememberLocation(); webView.goToPage(current.totalPages - 1); true }
             menu.add("Reading appearance").setOnMenuItemClickListener { showAppearance(); true }
             menu.add("Page display tools").setOnMenuItemClickListener { showPageDisplay(); true }
             menu.add("Rotate screen").setOnMenuItemClickListener { rotateScreen(); true }
@@ -311,9 +305,6 @@ class ReaderScreen(
     }
 
     private fun showTableOfContents() {
-        if (publication.toc.isEmpty()) {
-            Toast.makeText(activity, "This book has no table of contents", Toast.LENGTH_SHORT).show(); return
-        }
         val labels = publication.toc.map { "   ".repeat(it.depth.coerceAtMost(6)) + it.title }.toTypedArray()
         AlertDialog.Builder(activity)
             .setTitle("Table of contents")
@@ -333,8 +324,7 @@ class ReaderScreen(
             Toast.makeText(activity, "Bookmark removed", Toast.LENGTH_SHORT).show()
         } else {
             val chapter = publication.toc.lastOrNull { it.spineIndex <= current.chapter }?.title ?: "Page ${current.page + 1}"
-            bookmarks += Bookmark(UUID.randomUUID().toString(), chapter, current.chapter, current.offset, current.page,
-                current.progress, System.currentTimeMillis())
+            bookmarks += Bookmark(chapter, current.chapter, current.offset, current.page, current.progress, System.currentTimeMillis())
             Toast.makeText(activity, "Page bookmarked", Toast.LENGTH_SHORT).show()
         }
         repository.saveBookmarks(publication.book.id, bookmarks)
@@ -403,7 +393,6 @@ class ReaderScreen(
             end = selection.optInt("end"),
             quote = selection.optString("quote"),
             note = "",
-            createdAt = System.currentTimeMillis(),
         )
         if (annotation.quote.isBlank() || annotation.end <= annotation.start) return
         annotations += annotation
@@ -664,10 +653,6 @@ class ReaderScreen(
             text = "Automatic hyphenation"; isChecked = settings.hyphenation
             setOnCheckedChangeListener { _, checked -> updateSettings(settings.copy(hyphenation = checked)) }
         })
-        content.addView(CheckBox(activity).apply {
-            text = "Use volume keys to turn pages"; isChecked = settings.volumeKeys
-            setOnCheckedChangeListener { _, checked -> updateSettings(settings.copy(volumeKeys = checked), reflow = false) }
-        })
         val scroll = ScrollView(activity).apply { addView(content) }
         AlertDialog.Builder(activity).setTitle("Reading appearance").setView(scroll)
             .setPositiveButton("Done", null)
@@ -817,7 +802,7 @@ class ReaderScreen(
             webView.captureLocation { location ->
                 settings = value
                 repository.saveSettings(publication.book.id, settings)
-                applyEInkAppearance()
+                applyBrightness()
                 webView.configurePageTools(publication.fixedLayout, settings)
                 webView.applySettings(settings, location)
             }
@@ -825,20 +810,11 @@ class ReaderScreen(
             settings = value
             repository.saveSettings(publication.book.id, settings)
             webView.configurePageTools(publication.fixedLayout, settings)
-            applyEInkAppearance()
+            applyBrightness()
         }
     }
 
-    private fun applyEInkAppearance() {
-        setBackgroundColor(EInkPalette.PAPER)
-        topBar.setBackgroundColor(EInkPalette.PAPER); bottomBar.setBackgroundColor(EInkPalette.PAPER)
-        titleLabel.setTextColor(EInkPalette.INK); progressLabel.setTextColor(EInkPalette.INK)
-        sequenceOf(topBar, bottomBar).flatMap { bar -> (0 until bar.childCount).asSequence().map { bar.getChildAt(it) } }
-            .filterIsInstance<TextView>().forEach { it.setTextColor(EInkPalette.INK) }
-        activity.window.statusBarColor = EInkPalette.PAPER
-        activity.window.navigationBarColor = EInkPalette.PAPER
-        activity.window.decorView.systemUiVisibility =
-            View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
+    private fun applyBrightness() {
         activity.window.attributes = activity.window.attributes.apply {
             screenBrightness = if (settings.brightness >= 0) settings.brightness / 100f
                 else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
@@ -886,13 +862,10 @@ class ReaderScreen(
         loading.setTextColor(EInkPalette.INK)
     }
 
-    fun handleKey(keyCode: Int): Boolean {
-        if (!settings.volumeKeys) return false
-        return when (keyCode) {
-            KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_PAGE_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT -> { nextPage(); true }
-            KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_DPAD_LEFT -> { previousPage(); true }
-            else -> false
-        }
+    fun handleKey(keyCode: Int): Boolean = when (keyCode) {
+        KeyEvent.KEYCODE_PAGE_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT -> { nextPage(); true }
+        KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_DPAD_LEFT -> { previousPage(); true }
+        else -> false
     }
 
     fun handleBack(): Boolean {
@@ -937,6 +910,8 @@ class ReaderScreen(
 
     private class CropPreview(context: Context) : View(context) {
         private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+        private val pageBounds = RectF()
+        private val cropBounds = RectF()
         var margins: CropMargins = CropMargins()
             set(value) { field = value; invalidate() }
 
@@ -948,20 +923,24 @@ class ReaderScreen(
             val availableHeight = height - 2 * pad
             val pageWidth = minOf(availableWidth, availableHeight * pageRatio)
             val pageHeight = pageWidth / pageRatio
-            val page = RectF((width - pageWidth) / 2f, (height - pageHeight) / 2f,
-                (width + pageWidth) / 2f, (height + pageHeight) / 2f)
-            paint.style = Paint.Style.FILL; paint.color = EInkPalette.PAGE_PREVIEW; canvas.drawRect(page, paint)
+            pageBounds.set(
+                (width - pageWidth) / 2f,
+                (height - pageHeight) / 2f,
+                (width + pageWidth) / 2f,
+                (height + pageHeight) / 2f,
+            )
+            paint.style = Paint.Style.FILL; paint.color = EInkPalette.PAGE_PREVIEW; canvas.drawRect(pageBounds, paint)
             val horizontalScale = if (margins.left + margins.right > 95f) 95f / (margins.left + margins.right) else 1f
             val verticalScale = if (margins.top + margins.bottom > 95f) 95f / (margins.top + margins.bottom) else 1f
-            val crop = RectF(
-                page.left + page.width() * margins.left * horizontalScale / 100f,
-                page.top + page.height() * margins.top * verticalScale / 100f,
-                page.right - page.width() * margins.right * horizontalScale / 100f,
-                page.bottom - page.height() * margins.bottom * verticalScale / 100f,
+            cropBounds.set(
+                pageBounds.left + pageBounds.width() * margins.left * horizontalScale / 100f,
+                pageBounds.top + pageBounds.height() * margins.top * verticalScale / 100f,
+                pageBounds.right - pageBounds.width() * margins.right * horizontalScale / 100f,
+                pageBounds.bottom - pageBounds.height() * margins.bottom * verticalScale / 100f,
             )
-            paint.color = EInkPalette.PAPER; canvas.drawRect(crop, paint)
+            paint.color = EInkPalette.PAPER; canvas.drawRect(cropBounds, paint)
             paint.style = Paint.Style.STROKE; paint.strokeWidth = resources.displayMetrics.density * 2f
-            paint.color = EInkPalette.INK; canvas.drawRect(crop, paint)
+            paint.color = EInkPalette.INK; canvas.drawRect(cropBounds, paint)
         }
     }
 
index 018cd3ea8236455e2b788c10ae07be8e2ad65217..bfe7ed4b272e1877ceeef47253ebfaefb852ca02 100644 (file)
@@ -16,6 +16,7 @@ import android.webkit.JavascriptInterface
 import android.webkit.JsResult
 import android.webkit.ValueCallback
 import android.webkit.WebChromeClient
+import android.webkit.WebResourceError
 import android.webkit.WebResourceRequest
 import android.webkit.WebResourceResponse
 import android.webkit.WebSettings
@@ -30,7 +31,7 @@ import kotlin.math.abs
 @SuppressLint("SetJavaScriptEnabled")
 class ReaderWebView(context: Context) : WebView(context) {
     interface Listener {
-        fun onReady(metrics: JSONObject)
+        fun onReady()
         fun onLocationChanged(location: ReaderLocation)
         fun onTap(x: Float, y: Float)
         fun onSwipe(forward: Boolean)
@@ -128,11 +129,8 @@ class ReaderWebView(context: Context) : WebView(context) {
         settings.apply {
             javaScriptEnabled = true
             domStorageEnabled = false
-            databaseEnabled = false
             allowFileAccess = true
             allowContentAccess = false
-            allowFileAccessFromFileURLs = true
-            allowUniversalAccessFromFileURLs = false
             blockNetworkLoads = true
             cacheMode = WebSettings.LOAD_NO_CACHE
             builtInZoomControls = false
@@ -175,9 +173,10 @@ class ReaderWebView(context: Context) : WebView(context) {
                 return super.shouldInterceptRequest(view, request)
             }
 
-            @Deprecated("Required for Android 11 WebView")
-            override fun onReceivedError(view: WebView?, errorCode: Int, description: String?, failingUrl: String?) {
-                if (failingUrl?.endsWith("reader.html") == true) listener?.onRenderError(description ?: "The page could not be rendered.")
+            override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
+                if (request?.isForMainFrame == true && request.url.path?.endsWith("/reader.html") == true) {
+                    listener?.onRenderError(error?.description?.toString() ?: "The page could not be rendered.")
+                }
             }
         }
     }
@@ -189,7 +188,6 @@ class ReaderWebView(context: Context) : WebView(context) {
 
     fun applySettings(settings: ReaderSettings, location: ReaderLocation? = null) {
         fixedZoomMode = settings.zoomMode
-        if (!pageLoaded) return
         val keep = location?.toJson()?.toString() ?: "Alex.locator()"
         javascript("Alex.applySettings(${settings.toJson()}, $keep)")
     }
@@ -236,7 +234,7 @@ class ReaderWebView(context: Context) : WebView(context) {
     fun removeAnnotation(id: String) = javascript("Alex.removeAnnotation(${JSONObject.quote(id)})")
 
     private fun javascript(source: String, callback: ValueCallback<String>? = null) {
-        if (!pageLoaded && !source.startsWith("Alex.applySettings")) return
+        if (!pageLoaded) return
         evaluateJavascript(source, callback)
     }
 
@@ -262,11 +260,6 @@ class ReaderWebView(context: Context) : WebView(context) {
         return super.startActionMode(wrapSelectionCallback(callback), type)
     }
 
-    @Deprecated("Android still calls this overload on some WebView builds")
-    override fun startActionMode(callback: ActionMode.Callback?): ActionMode? {
-        return super.startActionMode(wrapSelectionCallback(callback))
-    }
-
     private fun wrapSelectionCallback(delegate: ActionMode.Callback?): ActionMode.Callback {
         return object : ActionMode.Callback {
             override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
@@ -310,9 +303,9 @@ class ReaderWebView(context: Context) : WebView(context) {
     }
 
     private inner class Bridge {
-        @JavascriptInterface fun onReaderReady(json: String) = mainHandler.post {
+        @JavascriptInterface fun onReaderReady() = mainHandler.post {
             pageLoaded = true
-            runCatching { JSONObject(json) }.getOrNull()?.let { listener?.onReady(it) }
+            listener?.onReady()
         }
 
         @JavascriptInterface fun onPageChanged(json: String) = mainHandler.post {
index df0f0f7ef2542cf1e3df129dd1cb0d83c8b5603b..d8cbe2506b2560bfbb9faa98a202edecfa34c557 100644 (file)
@@ -1,19 +1,9 @@
 <resources>
-    <!-- Carta 1000 native 16-level grayscale palette. -->
+    <!-- Carta 1000 grayscale levels used by Android resources. -->
     <color name="eink_gray_00">#000000</color>
     <color name="eink_gray_01">#111111</color>
     <color name="eink_gray_02">#222222</color>
-    <color name="eink_gray_03">#333333</color>
-    <color name="eink_gray_04">#444444</color>
     <color name="eink_gray_05">#555555</color>
-    <color name="eink_gray_06">#666666</color>
-    <color name="eink_gray_07">#777777</color>
-    <color name="eink_gray_08">#888888</color>
-    <color name="eink_gray_09">#999999</color>
-    <color name="eink_gray_10">#aaaaaa</color>
-    <color name="eink_gray_11">#bbbbbb</color>
-    <color name="eink_gray_12">#cccccc</color>
     <color name="eink_gray_13">#dddddd</color>
-    <color name="eink_gray_14">#eeeeee</color>
     <color name="eink_gray_15">#ffffff</color>
 </resources>
index 6efac44fe37a55017a2d367ad5c3dc59940a2c27..2d5c3806fc964aa9726dfdab45fff00eb3064b22 100644 (file)
         <item name="android:statusBarColor">@color/eink_gray_15</item>
         <item name="android:windowBackground">@color/eink_gray_15</item>
         <item name="android:forceDarkAllowed">false</item>
-        <item name="android:windowActionModeOverlay">true</item>
         <item name="android:windowActivityTransitions">false</item>
         <item name="android:windowContentTransitions">false</item>
         <item name="android:windowAnimationStyle">@null</item>
         <item name="android:windowDisablePreview">true</item>
         <item name="android:windowLightStatusBar">true</item>
         <item name="android:windowLightNavigationBar">true</item>
-        <item name="android:windowNoTitle">true</item>
     </style>
 </resources>
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644 (file)
index 0000000..968eed7
--- /dev/null
@@ -0,0 +1,24 @@
+<data-extraction-rules>
+    <cloud-backup>
+        <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="."/>
+    </cloud-backup>
+    <device-transfer>
+        <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="."/>
+    </device-transfer>
+</data-extraction-rules>
index ce34741ec3fdac0b69a0c2ee3fe871ab162c6c39..45900ab2ccd127304a86f2aaf7059eac4d098377 100644 (file)
@@ -64,7 +64,7 @@ function New-Settings(
 ) {
     [ordered]@{
         fontFamily="Publisher"; fontSize=20; lineHeight=1.32; margin=32; textAlign="publisher"
-        publisherStyles=$true; hyphenation=$true; mode="paged"; brightness=-1; volumeKeys=$true
+        publisherStyles=$true; hyphenation=$true; mode="paged"; brightness=-1
         zoomMode=$ZoomMode; customZoom=$CustomZoom; scrollMode=$ScrollMode; cropScheme=$CropScheme
         cropAny=@{}; cropEven=$CropEven; cropOdd=$CropOdd
         contrastExponent=$Contrast; grayPoint=$GrayPoint; dithering=$Dithering