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