From: Cameron Otsuka Date: Tue, 11 Aug 2026 15:46:09 +0000 (-0700) Subject: initial commit X-Git-Url: https://git.otsuka.systems/?a=commitdiff_plain;h=116fa570e9d952c4ef7830f06a5182838430ab27;p=alexandria initial commit --- 116fa570e9d952c4ef7830f06a5182838430ab27 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4130000 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +.gradle +**/build/ +!**/src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrapper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +# Kotlin Gradle plugin data, see https://kotlinlang.org/docs/whatsnew20.html#new-directory-for-kotlin-data-in-gradle-projects +.kotlin/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..43699cc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +Android Studio should already be installed. Use it and Android virtual devices to test the application from end-to-end. Use screenshots to verify functionality works as intended. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1306213 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Alexandria + +Alexandria is an Android 11 EPUB library and reader written in Kotlin. Its design follows Plato's library and reader workflows. + +The app requests no network or shared-storage permission. It imports each selected book through Android's Storage Access Framework and keeps a private copy. + +## EPUB support + +- EPUB 2 and EPUB 3 package, spine, metadata, cover, NCX, and navigation documents +- Publisher CSS, inline styles, imported stylesheets, embedded fonts, SVG, images, tables, lists, links, and page-break rules +- Accurate reflow with browser-grade shaping, bidirectional text, hyphenation, and accessibility text +- Paginated and continuous reading modes +- Pre-paginated EPUB detection through rendition metadata, legacy display options, numeric viewports, and SVG pages +- Right-to-left page progression + +## Reader features + +- Plato-style touch regions, page swipes, chapter navigation, and non-linear location history +- Persistent reading position and progress +- Table of contents and internal links with previous-location return +- Forward or backward full-book search from the current text location, with previous and next results +- Bookmarks +- Text selection, highlights, notes, annotation editing, and annotation export +- System dictionary and text-processing integration +- Typeface, text size, line height, margin, alignment, publisher-style, and hyphenation controls +- Light, sepia, and dark themes; contrast and screen-brightness controls +- Fixed-page fit-to-page, fit-to-width page streams with line-preserving cuts, and 50–400% custom zoom and bounded pan +- Shared or separate even/odd crop margins with a visual crop editor +- Plato-compatible contrast exponent and gray-point controls, plus 16-level and black-and-white spatial dithering +- Portrait and landscape layouts +- Searchable and sortable library with covers and reading states + +## Build + +```powershell +.\gradlew.bat :app:assembleDebug +``` + +The APK is written to `app/build/outputs/apk/debug/app-debug.apk`. + +## End-to-end verification + +Start the supplied Android virtual device, then run: + +```powershell +.\scripts\verify-running.ps1 +``` + +The script performs a clean install, imports `tests/fixtures/odyssey.epub`, waits for a measured page count, checks Logcat for crashes, and writes `verification/odyssey-page-1.png`. + +Run the focused reader tests separately: + +```powershell +.\scripts\verify-fixed-page-tools.ps1 +.\scripts\verify-search-history.ps1 +``` + +The fixed-page test verifies fit modes, continuous line cuts, even/odd crops, custom pan persistence, gray-point adjustment, and dithering. The search/history test verifies forward and backward searches from page two, result-jump history, an internal-link jump, and previous-location returns. Both write screenshots under `verification/`. + +To start a clean AVD before manual testing, use: + +```powershell +.\scripts\test.ps1 -AvdName "Onyx_Boox_Note_Air_2" -KeepEmulator +``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..6f64da9 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { id("com.android.application"); id("org.jetbrains.kotlin.android") } + +android { + namespace = "com.alexandria.reader" + compileSdk = 35 + + defaultConfig { + applicationId = "com.alexandria.reader" + minSdk = 30 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + + buildFeatures { buildConfig = true } + testOptions { unitTests.isIncludeAndroidResources = true } +} + +dependencies { + implementation("org.jsoup:jsoup:1.18.3") +} + +kotlin { jvmToolchain(17) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c3ee819 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/fonts/Atkinson.otf b/app/src/main/assets/fonts/Atkinson.otf new file mode 100644 index 0000000..ba345b5 Binary files /dev/null and b/app/src/main/assets/fonts/Atkinson.otf differ diff --git a/app/src/main/assets/fonts/LibertinusSerif-Bold.otf b/app/src/main/assets/fonts/LibertinusSerif-Bold.otf new file mode 100644 index 0000000..39a8ee5 Binary files /dev/null and b/app/src/main/assets/fonts/LibertinusSerif-Bold.otf differ diff --git a/app/src/main/assets/fonts/LibertinusSerif-BoldItalic.otf b/app/src/main/assets/fonts/LibertinusSerif-BoldItalic.otf new file mode 100644 index 0000000..d63f36e Binary files /dev/null and b/app/src/main/assets/fonts/LibertinusSerif-BoldItalic.otf differ diff --git a/app/src/main/assets/fonts/LibertinusSerif-Italic.otf b/app/src/main/assets/fonts/LibertinusSerif-Italic.otf new file mode 100644 index 0000000..ddf1996 Binary files /dev/null and b/app/src/main/assets/fonts/LibertinusSerif-Italic.otf differ diff --git a/app/src/main/assets/fonts/LibertinusSerif-Regular.otf b/app/src/main/assets/fonts/LibertinusSerif-Regular.otf new file mode 100644 index 0000000..e42f25c Binary files /dev/null and b/app/src/main/assets/fonts/LibertinusSerif-Regular.otf differ diff --git a/app/src/main/assets/fonts/NotoSans-Bold.ttf b/app/src/main/assets/fonts/NotoSans-Bold.ttf new file mode 100644 index 0000000..1db7886 Binary files /dev/null and b/app/src/main/assets/fonts/NotoSans-Bold.ttf differ diff --git a/app/src/main/assets/fonts/NotoSans-Regular.ttf b/app/src/main/assets/fonts/NotoSans-Regular.ttf new file mode 100644 index 0000000..0a01a06 Binary files /dev/null and b/app/src/main/assets/fonts/NotoSans-Regular.ttf differ diff --git a/app/src/main/assets/fonts/SourceCode-Regular.otf b/app/src/main/assets/fonts/SourceCode-Regular.otf new file mode 100644 index 0000000..3004698 Binary files /dev/null and b/app/src/main/assets/fonts/SourceCode-Regular.otf differ diff --git a/app/src/main/java/com/alexandria/reader/EpubParser.kt b/app/src/main/java/com/alexandria/reader/EpubParser.kt new file mode 100644 index 0000000..c275f0c --- /dev/null +++ b/app/src/main/java/com/alexandria/reader/EpubParser.kt @@ -0,0 +1,1316 @@ +package com.alexandria.reader + +import android.net.Uri +import org.json.JSONObject +import org.jsoup.Jsoup +import org.jsoup.nodes.Comment +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.parser.Parser +import java.io.File +import java.io.FileOutputStream +import java.util.Locale +import java.util.zip.ZipFile +import kotlin.math.roundToInt + +/** EPUB 2/3 package reader. It keeps the original resources intact for standards-based rendering. */ +object EpubParser { + private data class ManifestItem(val id: String, val href: String, val mediaType: String, val properties: String) + + fun parse(source: File, output: File, book: LibraryBook, forceExtract: Boolean): EpubPublication { + if (forceExtract || !File(output, ".complete").isFile) { + output.deleteRecursively() + output.mkdirs() + extract(source, output) + File(output, ".complete").writeText("ok") + } + + val containerFile = File(output, "META-INF/container.xml") + require(containerFile.isFile) { "This file has no EPUB container document." } + val container = parseXml(containerFile) + val rootfile = container.allByLocalName("rootfile").firstOrNull() + ?: error("The EPUB container does not identify a package document.") + val packagePath = normalizePath("", rootfile.attr("full-path")) + require(packagePath.isNotBlank()) { "The EPUB package path is empty." } + val packageFile = safeFile(output, packagePath) + require(packageFile.isFile) { "The EPUB package document is missing." } + val opf = parseXml(packageFile) + val packageDirectory = packagePath.substringBeforeLast('/', "") + + val metadata = opf.allByLocalName("metadata").firstOrNull() + metadata?.firstText("title")?.takeIf(String::isNotBlank)?.let { book.title = it } + metadata?.allByLocalName("creator")?.map { it.text().trim() }?.filter(String::isNotBlank) + ?.distinct()?.takeIf(List::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): List { + 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() + 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): List { + val file = safeFile(root, path) + if (!file.isFile) return emptyList() + val document = parseXml(file) + val result = mutableListOf() + 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, 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() + 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 = + 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 to + // ; 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() + 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() + cssPaths.addAll(publication.stylesheets) + val inlineStyles = mutableListOf>() + 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() + 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 , 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("
") + append(chapter.body.html()).append("
\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() + val css = buildString { + val loaded = mutableSetOf() + 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>() + 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 { + 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 = + """ + + + + + +${escapeHtml(publication.book.title)} + + + + + +$content + + + +""" + + 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=cg&&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 (to0) 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(cg && 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(currentTargetline.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;iline.top-2&&target0) 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+visibleHeight0){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=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(to0)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 { + 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 { + 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("(?|\\+|~|$))", RegexOption.IGNORE_CASE), ".alex-chapter") + .replace(Regex("(?|\\+|~|$))", RegexOption.IGNORE_CASE), ".alex-chapter") + .replace(Regex("(?|\\+|~|$))", RegexOption.IGNORE_CASE), "#alex-book") + .replace(Regex("(? + 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("\"", """) +} diff --git a/app/src/main/java/com/alexandria/reader/LibraryRepository.kt b/app/src/main/java/com/alexandria/reader/LibraryRepository.kt new file mode 100644 index 0000000..d4b813f --- /dev/null +++ b/app/src/main/java/com/alexandria/reader/LibraryRepository.kt @@ -0,0 +1,247 @@ +package com.alexandria.reader + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.security.MessageDigest +import java.util.UUID + +/** Owns imported files and all reading state. No broad storage permission is required. */ +class LibraryRepository(private val context: Context) { + private val libraryDirectory = File(context.filesDir, "library").apply { mkdirs() } + private val indexFile = File(libraryDirectory, "index.json") + private val preferences = context.getSharedPreferences("alexandria-reader", Context.MODE_PRIVATE) + private val lock = Any() + private var books = loadIndex().toMutableList() + + enum class SortOrder { RECENT, ADDED, TITLE, AUTHOR, PROGRESS } + + fun books(order: SortOrder = sortOrder(), query: String = ""): List = 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 { it.lastOpenedAt }.thenBy { it.title.lowercase() }) + SortOrder.ADDED -> filtered.sortedByDescending { it.addedAt } + SortOrder.TITLE -> filtered.sortedBy { it.title.lowercase() } + SortOrder.AUTHOR -> filtered.sortedWith(compareBy { 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 = readArray(bookId, "bookmarks.json", Bookmark::fromJson) + fun annotations(bookId: String): MutableList = readArray(bookId, "annotations.json", Annotation::fromJson) + + fun saveBookmarks(bookId: String, values: List) = + writeArray(bookId, "bookmarks.json", values.map(Bookmark::toJson)) + + fun saveAnnotations(bookId: String, values: List) = + writeArray(bookId, "annotations.json", values.map(Annotation::toJson)) + + private fun readArray(bookId: String, name: String, convert: (JSONObject) -> T): MutableList = + runCatching { JSONArray(File(directoryFor(bookId), name).readText()).mapObjects(convert).toMutableList() } + .getOrDefault(mutableListOf()) + + private fun writeArray(bookId: String, name: String, values: List) { + 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 = runCatching { + JSONArray(indexFile.readText()).mapObjects(LibraryBook::fromJson) + .filter { File(libraryDirectory, "${it.id}/source.epub").isFile } + }.getOrDefault(emptyList()) + + private fun saveIndex() { + val array = JSONArray() + books.forEach { array.put(it.toJson()) } + writeAtomically(indexFile, array.toString()) + } + + private fun writeAtomically(target: File, value: String) { + target.parentFile?.mkdirs() + val temporary = File(target.parentFile, ".${target.name}.tmp") + temporary.writeText(value) + if (target.exists()) target.delete() + if (!temporary.renameTo(target)) { + target.writeText(value) + temporary.delete() + } + } + + companion object { + private const val MAX_IMPORT_BYTES = 1_073_741_824L + + private fun displayName(resolver: ContentResolver, uri: Uri): String? { + if (uri.scheme == "file") return uri.lastPathSegment + return runCatching { + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + }.getOrNull() + } + } +} diff --git a/app/src/main/java/com/alexandria/reader/LibraryScreen.kt b/app/src/main/java/com/alexandria/reader/LibraryScreen.kt new file mode 100644 index 0000000..a35a044 --- /dev/null +++ b/app/src/main/java/com/alexandria/reader/LibraryScreen.kt @@ -0,0 +1,293 @@ +package com.alexandria.reader + +import android.app.Activity +import android.app.AlertDialog +import android.graphics.BitmapFactory +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.text.Editable +import android.text.TextWatcher +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ListView +import android.widget.PopupMenu +import android.widget.TextView +import android.widget.Toast +import java.text.DateFormat +import java.util.Date +import kotlin.math.roundToInt + +/** Plato-style searchable library shelf backed by Alexandria's private imports. */ +class LibraryScreen( + private val activity: Activity, + private val repository: LibraryRepository, + private val openBook: (LibraryBook) -> Unit, + private val importBooks: () -> Unit, +) : FrameLayout(activity) { + private val list = ListView(activity) + private val adapter = BookAdapter() + private val toolbar = LinearLayout(activity) + private var searchField: EditText? = null + private var query = "" + private var sort = repository.sortOrder() + + init { + setBackgroundColor(Color.rgb(250, 250, 248)) + build() + refresh() + } + + private fun build() { + val toolbarHeight = dp(64) + toolbar.orientation = LinearLayout.HORIZONTAL + toolbar.gravity = Gravity.CENTER_VERTICAL + toolbar.setPadding(dp(18), 0, dp(8), 0) + toolbar.setBackgroundColor(Color.rgb(250, 250, 248)) + toolbar.elevation = dp(2).toFloat() + addView(toolbar, LayoutParams(LayoutParams.MATCH_PARENT, toolbarHeight, Gravity.TOP)) + + toolbar.addView(TextView(activity).apply { + text = "ALEXANDRIA" + textSize = 19f + typeface = Typeface.create("sans-serif", Typeface.BOLD) + letterSpacing = .11f + setTextColor(Color.rgb(20, 20, 20)) + contentDescription = "Alexandria library" + }, LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { gravity = Gravity.CENTER_VERTICAL }) + toolbar.addView(button("⌕", "Search library") { _ -> toggleSearch() }) + toolbar.addView(button("⇅", "Sort library") { view -> showSortMenu(view) }) + toolbar.addView(button("+", "Import EPUB") { _ -> importBooks() }) + + list.apply { + divider = ColorDrawable(Color.rgb(190, 190, 187)) + dividerHeight = 1 + setBackgroundColor(Color.rgb(250, 250, 248)) + this.adapter = this@LibraryScreen.adapter + emptyView = buildEmptyView() + setOnItemClickListener { _, _, position, _ -> openBook(this@LibraryScreen.adapter.value(position)) } + setOnItemLongClickListener { view, _, position, _ -> showBookMenu(view, this@LibraryScreen.adapter.value(position)); true } + contentDescription = "Imported books" + } + addView(list, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT).apply { topMargin = toolbarHeight }) + } + + private fun buildEmptyView(): View { + val empty = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setPadding(dp(32), dp(32), dp(32), dp(32)) + setBackgroundColor(Color.rgb(250, 250, 248)) + addView(TextView(activity).apply { + text = "Your library is empty" + textSize = 28f + typeface = Typeface.create("serif", Typeface.ITALIC) + gravity = Gravity.CENTER + setTextColor(Color.rgb(25, 25, 25)) + }) + addView(TextView(activity).apply { + text = "Import an EPUB to start reading. Alexandria keeps its own private copy and requests no storage or network permission." + textSize = 16f + gravity = Gravity.CENTER + setTextColor(Color.DKGRAY) + setPadding(0, dp(16), 0, dp(24)) + }) + addView(TextView(activity).apply { + text = "Open EPUB" + textSize = 17f + gravity = Gravity.CENTER + setTextColor(Color.WHITE) + setBackgroundColor(Color.rgb(35, 35, 35)) + setPadding(dp(30), dp(13), dp(30), dp(13)) + contentDescription = "Open EPUB file picker" + setOnClickListener { importBooks() } + }, LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + } + addView(empty, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT).apply { topMargin = dp(64) }) + return empty + } + + private fun button(label: String, description: String, action: (View) -> Unit): TextView = TextView(activity).apply { + text = label + textSize = 23f + gravity = Gravity.CENTER + setTextColor(Color.rgb(25, 25, 25)) + contentDescription = description + isClickable = true + isFocusable = true + setOnClickListener(action) + layoutParams = LinearLayout.LayoutParams(dp(52), LayoutParams.MATCH_PARENT) + } + + private fun toggleSearch() { + if (searchField != null) { + query = "" + searchField = null + toolbar.removeViewAt(0) + toolbar.addView(titleView(), 0, LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)) + refresh() + return + } + toolbar.removeViewAt(0) + val input = EditText(activity).apply { + hint = "Search title or author" + isSingleLine = true + textSize = 17f + contentDescription = "Library search" + addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { query = s?.toString().orEmpty(); refresh() } + override fun afterTextChanged(s: Editable?) = Unit + }) + } + searchField = input + toolbar.addView(input, 0, LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)) + input.requestFocus() + (activity.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager) + .showSoftInput(input, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT) + } + + private fun titleView(): TextView = TextView(activity).apply { + text = "ALEXANDRIA"; textSize = 19f; typeface = Typeface.DEFAULT_BOLD; letterSpacing = .11f + gravity = Gravity.CENTER_VERTICAL; setTextColor(Color.rgb(20, 20, 20)) + } + + private fun showSortMenu(anchor: View) { + PopupMenu(activity, anchor).apply { + LibraryRepository.SortOrder.entries.forEach { value -> + val label = when (value) { + LibraryRepository.SortOrder.RECENT -> "Date opened" + LibraryRepository.SortOrder.ADDED -> "Date added" + LibraryRepository.SortOrder.TITLE -> "Title" + LibraryRepository.SortOrder.AUTHOR -> "Author" + LibraryRepository.SortOrder.PROGRESS -> "Progress" + } + menu.add(if (value == sort) "✓ $label" else label).setOnMenuItemClickListener { + sort = value; repository.setSortOrder(value); refresh(); true + } + } + show() + } + } + + private fun showBookMenu(anchor: View, book: LibraryBook) { + PopupMenu(activity, anchor).apply { + menu.add("Open").setOnMenuItemClickListener { openBook(book); true } + menu.add("Mark as new").setOnMenuItemClickListener { repository.setState(book.id, LibraryBook.ReadingState.NEW); refresh(); true } + menu.add("Mark as reading").setOnMenuItemClickListener { repository.setState(book.id, LibraryBook.ReadingState.READING); refresh(); true } + menu.add("Mark as finished").setOnMenuItemClickListener { repository.setState(book.id, LibraryBook.ReadingState.FINISHED); refresh(); true } + menu.add("Rename title").setOnMenuItemClickListener { rename(book); true } + menu.add("Book information").setOnMenuItemClickListener { information(book); true } + menu.add("Remove from library").setOnMenuItemClickListener { confirmRemove(book); true } + show() + } + } + + private fun rename(book: LibraryBook) { + val input = EditText(activity).apply { setText(book.title); selectAll(); isSingleLine = true } + AlertDialog.Builder(activity).setTitle("Rename title").setView(input) + .setPositiveButton("Save") { _, _ -> repository.rename(book.id, input.text.toString()); refresh() } + .setNegativeButton("Cancel", null).show() + } + + private fun information(book: LibraryBook) { + val state = book.state.name.lowercase().replaceFirstChar(Char::uppercase) + val message = buildString { + append(book.author).append("\nEPUB · ").append(state) + append("\nProgress: ").append((book.progress * 100).roundToInt()).append('%') + append("\nAdded: ").append(DateFormat.getDateInstance().format(Date(book.addedAt))) + if (book.lastOpenedAt > 0) append("\nLast opened: ").append(DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(book.lastOpenedAt))) + if (book.publisher.isNotBlank()) append("\nPublisher: ").append(book.publisher) + if (book.description.isNotBlank()) append("\n\n").append(book.description) + } + AlertDialog.Builder(activity).setTitle(book.title).setMessage(message).setPositiveButton("Open") { _, _ -> openBook(book) } + .setNegativeButton("Close", null).show() + } + + private fun confirmRemove(book: LibraryBook) { + AlertDialog.Builder(activity).setTitle("Remove book?") + .setMessage("Alexandria's private copy of “${book.title}” and its reading state will be deleted. The original file will not change.") + .setPositiveButton("Remove") { _, _ -> repository.remove(book.id); refresh() } + .setNegativeButton("Cancel", null).show() + } + + fun refresh() { + adapter.replace(repository.books(sort, query)) + } + + fun searchFocusActive(): Boolean = searchField != null + + fun closeSearch() { + if (searchField != null) toggleSearch() + } + + private inner class BookAdapter : BaseAdapter() { + private var values = emptyList() + fun replace(newValues: List) { values = newValues; notifyDataSetChanged() } + fun value(position: Int): LibraryBook = values[position] + override fun getCount(): Int = values.size + override fun getItem(position: Int): Any = values[position] + override fun getItemId(position: Int): Long = values[position].id.hashCode().toLong() + + override fun getView(position: Int, reusable: View?, parent: ViewGroup?): View { + val row = (reusable as? BookRow) ?: BookRow() + row.bind(values[position]) + return row + } + } + + private inner class BookRow : LinearLayout(activity) { + private val cover = ImageView(activity) + private val title = TextView(activity) + private val author = TextView(activity) + private val details = TextView(activity) + private val progress = TextView(activity) + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(18), dp(10), dp(18), dp(10)) + minimumHeight = dp(112) + cover.scaleType = ImageView.ScaleType.CENTER_CROP + cover.setBackgroundColor(Color.rgb(226, 226, 222)) + addView(cover, LinearLayout.LayoutParams(dp(62), dp(88)).apply { marginEnd = dp(18) }) + val text = LinearLayout(activity).apply { orientation = VERTICAL; gravity = Gravity.CENTER_VERTICAL } + title.textSize = 20f; title.typeface = Typeface.create("serif", Typeface.ITALIC); title.maxLines = 2; title.setTextColor(Color.BLACK) + author.textSize = 15f; author.maxLines = 1; author.setTextColor(Color.rgb(45, 45, 45)) + details.textSize = 12f; details.maxLines = 1; details.setTextColor(Color.GRAY) + text.addView(title); text.addView(author); text.addView(details) + addView(text, LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + progress.textSize = 14f; progress.gravity = Gravity.CENTER; progress.setTextColor(Color.DKGRAY + ) + addView(progress, LinearLayout.LayoutParams(dp(92), LayoutParams.MATCH_PARENT)) + } + + fun bind(book: LibraryBook) { + title.text = book.title + author.text = book.author + details.text = when (book.state) { + LibraryBook.ReadingState.NEW -> "EPUB · New" + LibraryBook.ReadingState.READING -> "EPUB · Reading" + LibraryBook.ReadingState.FINISHED -> "EPUB · Finished" + } + progress.text = if (book.state == LibraryBook.ReadingState.NEW) "NEW" else "${(book.progress * 100).roundToInt()}%" + progress.typeface = if (book.state == LibraryBook.ReadingState.NEW) Typeface.DEFAULT_BOLD else Typeface.DEFAULT + val bitmap = book.coverPath?.let { path -> + runCatching { + val options = BitmapFactory.Options().apply { inSampleSize = 4 } + BitmapFactory.decodeFile(path, options) + }.getOrNull() + } + if (bitmap != null) cover.setImageBitmap(bitmap) else cover.setImageDrawable(null) + contentDescription = "${book.title}, ${book.author}, ${progress.text}" + } + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt() +} diff --git a/app/src/main/java/com/alexandria/reader/MainActivity.kt b/app/src/main/java/com/alexandria/reader/MainActivity.kt new file mode 100644 index 0000000..543d0f8 --- /dev/null +++ b/app/src/main/java/com/alexandria/reader/MainActivity.kt @@ -0,0 +1,197 @@ +package com.alexandria.reader + +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.view.Gravity +import android.view.KeyEvent +import android.view.View +import android.widget.TextView +import android.widget.Toast +import java.util.concurrent.atomic.AtomicInteger + +class MainActivity : Activity() { + private lateinit var repository: LibraryRepository + private var libraryScreen: LibraryScreen? = null + private var readerScreen: ReaderScreen? = null + private val operation = AtomicInteger() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.statusBarColor = Color.rgb(250, 250, 248) + window.navigationBarColor = Color.rgb(250, 250, 248) + window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + repository = LibraryRepository(this) + if (!handleIntent(intent)) showLibrary() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + if (!handleIntent(intent)) showLibrary() + } + + private fun handleIntent(value: Intent?): Boolean { + val intent = value ?: return false + val uris = mutableListOf() + when (intent.action) { + Intent.ACTION_VIEW -> intent.data?.let(uris::add) + Intent.ACTION_SEND -> { + intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let(uris::add) + if (uris.isEmpty()) intent.data?.let(uris::add) + } + Intent.ACTION_SEND_MULTIPLE -> intent.getParcelableArrayListExtra(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() + 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, flags: Int) { + val token = operation.incrementAndGet() + showLoading(if (uris.size == 1) "Importing EPUB…" else "Importing ${uris.size} EPUBs…") + Thread { + var last: EpubPublication? = null + var failure: Throwable? = null + uris.forEach { uri -> + try { + if (flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION != 0) { + runCatching { contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) } + } + last = repository.import(uri) + } catch (error: Throwable) { + failure = error + } + } + runOnUiThread { + if (token != operation.get()) return@runOnUiThread + when { + last != null -> showReader(last!!) + failure != null -> showImportError(failure!!) + else -> showLibrary() + } + if (last != null && failure != null) Toast.makeText(this, "Some files could not be imported", Toast.LENGTH_LONG).show() + } + }.start() + } + + private fun openBook(book: LibraryBook) { + val token = operation.incrementAndGet() + showLoading("Opening ${book.title}…") + Thread { + val result = runCatching { repository.open(book) } + runOnUiThread { + if (token != operation.get()) return@runOnUiThread + result.onSuccess(::showReader).onFailure(::showImportError) + } + }.start() + } + + private fun showReader(publication: EpubPublication) { + readerScreen?.dispose() + libraryScreen = null + val screen = ReaderScreen(this, publication, repository, ::showLibrary) + readerScreen = screen + setContentView(screen) + title = publication.book.title + } + + private fun showLoading(message: String) { + readerScreen?.dispose() + readerScreen = null + libraryScreen = null + setContentView(TextView(this).apply { + text = message + textSize = 20f + gravity = Gravity.CENTER + setTextColor(Color.rgb(35, 35, 35)) + setBackgroundColor(Color.WHITE) + contentDescription = message + }) + } + + private fun showImportError(error: Throwable) { + showLibrary() + val message = error.message?.takeIf { it.isNotBlank() } ?: "The file is not a supported or valid EPUB." + AlertDialog.Builder(this).setTitle("Unable to open EPUB").setMessage(message) + .setPositiveButton("Choose another file") { _, _ -> openPicker() } + .setNegativeButton("Close", null).show() + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_DOWN && readerScreen?.handleKey(event.keyCode) == true) return true + return super.dispatchKeyEvent(event) + } + + @Deprecated("Android 11 activity back dispatch") + override fun onBackPressed() { + val reader = readerScreen + if (reader != null) { + if (!reader.handleBack()) showLibrary() + } else if (libraryScreen?.searchFocusActive() == true) { + libraryScreen?.closeSearch() + } else { + super.onBackPressed() + } + } + + override fun onPause() { + readerScreen?.pause() + super.onPause() + } + + override fun onResume() { + super.onResume() + readerScreen?.resume() + } + + override fun onDestroy() { + operation.incrementAndGet() + readerScreen?.dispose() + readerScreen = null + super.onDestroy() + } + + companion object { + private const val OPEN_DOCUMENT = 41 + } +} diff --git a/app/src/main/java/com/alexandria/reader/Models.kt b/app/src/main/java/com/alexandria/reader/Models.kt new file mode 100644 index 0000000..a8e693f --- /dev/null +++ b/app/src/main/java/com/alexandria/reader/Models.kt @@ -0,0 +1,268 @@ +package com.alexandria.reader + +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** Metadata kept for every document that Alexandria imports into its private library. */ +data class LibraryBook( + val id: String, + var title: String, + var author: String, + var language: String = "", + var publisher: String = "", + var description: String = "", + var fileName: String, + var coverPath: String? = null, + var addedAt: Long = System.currentTimeMillis(), + var lastOpenedAt: Long = 0L, + var progress: Float = 0f, + var currentPage: Int = 0, + var totalPages: Int = 1, + var state: ReadingState = ReadingState.NEW, +) { + enum class ReadingState { NEW, READING, FINISHED } + + fun toJson(): JSONObject = JSONObject().apply { + put("id", id) + put("title", title) + put("author", author) + put("language", language) + put("publisher", publisher) + put("description", description) + put("fileName", fileName) + put("coverPath", coverPath ?: JSONObject.NULL) + put("addedAt", addedAt) + put("lastOpenedAt", lastOpenedAt) + put("progress", progress.toDouble()) + put("currentPage", currentPage) + put("totalPages", totalPages) + put("state", state.name) + } + + companion object { + fun fromJson(value: JSONObject): LibraryBook = LibraryBook( + id = value.getString("id"), + title = value.optString("title", "Untitled"), + author = value.optString("author", "Unknown author"), + language = value.optString("language"), + publisher = value.optString("publisher"), + description = value.optString("description"), + fileName = value.optString("fileName", "book.epub"), + coverPath = value.optString("coverPath").takeIf { it.isNotBlank() && it != "null" }, + addedAt = value.optLong("addedAt", System.currentTimeMillis()), + lastOpenedAt = value.optLong("lastOpenedAt"), + progress = value.optDouble("progress", 0.0).toFloat().coerceIn(0f, 1f), + currentPage = value.optInt("currentPage").coerceAtLeast(0), + totalPages = value.optInt("totalPages", 1).coerceAtLeast(1), + state = runCatching { ReadingState.valueOf(value.optString("state", "NEW")) } + .getOrDefault(ReadingState.NEW), + ) + } +} + +data class SpineItem( + val href: String, + val mediaType: String, + val title: String, + val linear: Boolean = true, + val fixedLayout: Boolean = false, +) + +data class TocEntry( + val title: String, + val href: String, + val depth: Int, + val spineIndex: Int, +) + +data class EpubPublication( + val book: LibraryBook, + val rootDirectory: File, + val packagePath: String, + val spine: List, + val toc: List, + val readingDirection: String, + val stylesheets: List, + 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 JSONArray.mapObjects(block: (JSONObject) -> T): List = + (0 until length()).mapNotNull { index -> optJSONObject(index)?.let(block) } diff --git a/app/src/main/java/com/alexandria/reader/ReaderScreen.kt b/app/src/main/java/com/alexandria/reader/ReaderScreen.kt new file mode 100644 index 0000000..f305124 --- /dev/null +++ b/app/src/main/java/com/alexandria/reader/ReaderScreen.kt @@ -0,0 +1,981 @@ +package com.alexandria.reader + +import android.app.Activity +import android.app.AlertDialog +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.pm.ActivityInfo +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.text.InputType +import android.view.Gravity +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import android.widget.ArrayAdapter +import android.widget.CheckBox +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.PopupMenu +import android.widget.RadioButton +import android.widget.RadioGroup +import android.widget.ScrollView +import android.widget.SeekBar +import android.widget.Spinner +import android.widget.TextView +import android.widget.Toast +import org.json.JSONArray +import org.json.JSONObject +import java.text.DateFormat +import java.util.Date +import java.util.UUID +import kotlin.math.roundToInt + +/** Native reader controls around the standards-based, local-only EPUB surface. */ +class ReaderScreen( + private val activity: Activity, + private val publication: EpubPublication, + private val repository: LibraryRepository, + private val closeReader: () -> Unit, +) : FrameLayout(activity), ReaderWebView.Listener { + private val handler = Handler(Looper.getMainLooper()) + private val webView = ReaderWebView(activity) + private val topBar = LinearLayout(activity) + private val bottomBar = LinearLayout(activity) + private val titleLabel = TextView(activity) + private val progressLabel = TextView(activity) + private val bookmarkButton = TextView(activity) + private val loading = TextView(activity) + private var searchBar: LinearLayout? = null + private var searchStatus: TextView? = null + private var controlsVisible = true + private var initialized = false + private var restoring = true + private val initialLocation = repository.location(publication.book.id) + private var current = initialLocation + private var settings = repository.settings(publication.book.id) + private val bookmarks = repository.bookmarks(publication.book.id) + private val annotations = repository.annotations(publication.book.id) + private val locationHistory = ArrayDeque() + 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 { 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 { 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("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, 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, 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().forEach { it.setTextColor(ink) } + activity.window.statusBarColor = paper + activity.window.navigationBarColor = paper + activity.window.decorView.systemUiVisibility = if (settings.theme == "dark") 0 else + View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + activity.window.attributes = activity.window.attributes.apply { + screenBrightness = if (settings.brightness >= 0) settings.brightness / 100f + else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + } + } + + private fun showBookInformation() { + val book = publication.book + val message = buildString { + append(book.author) + if (book.publisher.isNotBlank()) append("\n").append(book.publisher) + if (book.language.isNotBlank()) append("\nLanguage: ").append(book.language) + append("\n\nEPUB · ").append(publication.spine.size).append(if (publication.fixedLayout) " fixed pages" else " sections") + append("\nProgress: ").append((current.progress * 100).roundToInt()).append('%') + if (book.description.isNotBlank()) append("\n\n").append(book.description) + } + AlertDialog.Builder(activity).setTitle(book.title).setMessage(message) + .setPositiveButton("Table of contents") { _, _ -> showTableOfContents() } + .setNegativeButton("Close", null).show() + } + + private fun rotateScreen() { + activity.requestedOrientation = when (activity.resources.configuration.orientation) { + android.content.res.Configuration.ORIENTATION_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + else -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + } + + override fun onNavigationJump(origin: ReaderLocation) { + rememberLocation(origin) + } + + override fun onExternalLink(url: String) { + val scheme = runCatching { Uri.parse(url).scheme?.lowercase() }.getOrNull() + if (scheme !in setOf("http", "https", "mailto", "tel")) return + AlertDialog.Builder(activity).setTitle("Open external link?").setMessage(url) + .setPositiveButton("Open") { _, _ -> + runCatching { activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } + .onFailure { Toast.makeText(activity, "No application can open this link", Toast.LENGTH_SHORT).show() } + }.setNegativeButton("Cancel", null).show() + } + + override fun onRenderError(message: String) { + loading.text = "Unable to render this EPUB\n\n$message" + loading.setTextColor(Color.rgb(120, 20, 20)) + } + + fun handleKey(keyCode: Int): Boolean { + if (!settings.volumeKeys) return false + return when (keyCode) { + KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_PAGE_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT -> { nextPage(); true } + KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_DPAD_LEFT -> { previousPage(); true } + else -> false + } + } + + fun handleBack(): Boolean { + if (searchBar != null) { hideSearch(); return true } + if (!controlsVisible) { toggleControls(); return true } + return false + } + + fun pause() { + flush() + webView.onPause() + webView.pauseTimers() + } + + fun resume() { + webView.onResume() + webView.resumeTimers() + } + + fun flush() { + pendingSave?.let(handler::removeCallbacks) + repository.updateProgress(publication.book.id, current) + } + + fun dispose() { + flush() + handler.removeCallbacksAndMessages(null) + removeView(webView) + webView.destroy() + } + + private fun showKeyboard(view: View) { + handler.postDelayed({ (activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) }, 120) + } + + private fun hideKeyboard(view: View) { + (activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(view.windowToken, 0) + } + + private fun lp(width: Int): LinearLayout.LayoutParams = LinearLayout.LayoutParams(width, LayoutParams.MATCH_PARENT) + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt() + + private class CropPreview(context: Context) : View(context) { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + var margins: CropMargins = CropMargins() + set(value) { field = value; invalidate() } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val pad = width.coerceAtMost(height) * .08f + val pageRatio = 3f / 4f + val availableWidth = width - 2 * pad + val availableHeight = height - 2 * pad + val pageWidth = minOf(availableWidth, availableHeight * pageRatio) + val pageHeight = pageWidth / pageRatio + val page = RectF((width - pageWidth) / 2f, (height - pageHeight) / 2f, + (width + pageWidth) / 2f, (height + pageHeight) / 2f) + paint.style = Paint.Style.FILL; paint.color = Color.rgb(216, 216, 213); canvas.drawRect(page, paint) + val horizontalScale = if (margins.left + margins.right > 95f) 95f / (margins.left + margins.right) else 1f + val verticalScale = if (margins.top + margins.bottom > 95f) 95f / (margins.top + margins.bottom) else 1f + val crop = RectF( + page.left + page.width() * margins.left * horizontalScale / 100f, + page.top + page.height() * margins.top * verticalScale / 100f, + page.right - page.width() * margins.right * horizontalScale / 100f, + page.bottom - page.height() * margins.bottom * verticalScale / 100f, + ) + paint.color = Color.WHITE; canvas.drawRect(crop, paint) + paint.style = Paint.Style.STROKE; paint.strokeWidth = resources.displayMetrics.density * 2f + paint.color = Color.rgb(25, 25, 25); canvas.drawRect(crop, paint) + } + } + +} diff --git a/app/src/main/java/com/alexandria/reader/ReaderWebView.kt b/app/src/main/java/com/alexandria/reader/ReaderWebView.kt new file mode 100644 index 0000000..3424b88 --- /dev/null +++ b/app/src/main/java/com/alexandria/reader/ReaderWebView.kt @@ -0,0 +1,363 @@ +package com.alexandria.reader + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.ActionMode +import android.view.GestureDetector +import android.view.Menu +import android.view.MenuItem +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.webkit.ConsoleMessage +import android.webkit.JavascriptInterface +import android.webkit.JsResult +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import org.json.JSONArray +import org.json.JSONObject +import java.io.ByteArrayInputStream +import java.io.File +import kotlin.math.abs + +@SuppressLint("SetJavaScriptEnabled") +class ReaderWebView(context: Context) : WebView(context) { + interface Listener { + fun onReady(metrics: JSONObject) + fun onLocationChanged(location: ReaderLocation) + fun onTap(x: Float, y: Float) + fun onSwipe(forward: Boolean) + fun onVerticalSwipe(forward: Boolean) + fun onViewportChanged(zoomMode: String, customZoom: Int) + fun onExternalLink(url: String) + fun onNavigationJump(origin: ReaderLocation) + fun onSearchResults(results: JSONArray) + fun onAnnotationTapped(id: String) + fun onSelectionAction(action: SelectionAction, selection: JSONObject?) + fun onRenderError(message: String) + } + + enum class SelectionAction { HIGHLIGHT, NOTE, DEFINE, SEARCH } + enum class SearchDirection(val wireValue: String) { + FORWARD("forward"), + BACKWARD("backward"), + } + + var listener: Listener? = null + private val mainHandler = Handler(Looper.getMainLooper()) + private var pageLoaded = false + private var suppressTapUntil = 0L + private var fixedLayout = false + private var fixedZoomMode = "fit-page" + private var viewportDirty = false + + private val scaleGestures = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = fixedLayout + + override fun onScale(detector: ScaleGestureDetector): Boolean { + if (!fixedLayout) return false + viewportDirty = true + customZoomBy(detector.scaleFactor) + return true + } + + override fun onScaleEnd(detector: ScaleGestureDetector) { + if (fixedLayout) finishViewportGesture() + viewportDirty = false + } + }) + + private val gestures = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(event: MotionEvent): Boolean = true + + override fun onSingleTapConfirmed(event: MotionEvent): Boolean { + if (android.os.SystemClock.uptimeMillis() < suppressTapUntil) return false + val hit = hitTestResult + val isLink = hit.type in setOf( + HitTestResult.SRC_ANCHOR_TYPE, + HitTestResult.SRC_IMAGE_ANCHOR_TYPE, + HitTestResult.EMAIL_TYPE, + HitTestResult.PHONE_TYPE, + HitTestResult.GEO_TYPE, + ) + if (!isLink) { + performClick() + listener?.onTap(event.x, event.y) + } + return false + } + + override fun onScroll(first: MotionEvent?, second: MotionEvent, distanceX: Float, distanceY: Float): Boolean { + if (!fixedLayout || fixedZoomMode != "custom" || scaleGestures.isInProgress) return false + viewportDirty = true + // GestureDetector reports viewport movement. The document follows the finger. + panBy(-distanceX, -distanceY) + return true + } + + override fun onFling(first: MotionEvent?, second: MotionEvent, velocityX: Float, velocityY: Float): Boolean { + if (first == null) return false + val dx = second.x - first.x + val dy = second.y - first.y + if (fixedLayout && fixedZoomMode == "custom") return true + if (abs(dx) > width * .16f && abs(dx) > abs(dy) * 1.25f && abs(velocityX) > 250f) { + // A westward swipe advances, matching Plato's reader gesture. + listener?.onSwipe(dx < 0f) + return true + } + if (fixedLayout && abs(dy) > height * .12f && abs(dy) > abs(dx) * 1.25f && abs(velocityY) > 250f) { + listener?.onVerticalSwipe(dy < 0f) + return true + } + return false + } + }) + + init { + setBackgroundColor(Color.WHITE) + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + overScrollMode = OVER_SCROLL_NEVER + settings.apply { + javaScriptEnabled = true + domStorageEnabled = false + databaseEnabled = false + allowFileAccess = true + allowContentAccess = false + allowFileAccessFromFileURLs = true + allowUniversalAccessFromFileURLs = false + blockNetworkLoads = true + cacheMode = WebSettings.LOAD_NO_CACHE + builtInZoomControls = false + displayZoomControls = false + setSupportZoom(false) + defaultTextEncodingName = "utf-8" + mediaPlaybackRequiresUserGesture = true + mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + } + addJavascriptInterface(Bridge(), "Android") + webChromeClient = object : WebChromeClient() { + override fun onJsAlert(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean { + result?.cancel() + return true + } + + override fun onConsoleMessage(message: ConsoleMessage): Boolean { + when (message.messageLevel()) { + ConsoleMessage.MessageLevel.ERROR -> Log.e("AlexandriaRenderer", "${message.message()} (${message.sourceId()}:${message.lineNumber()})") + ConsoleMessage.MessageLevel.WARNING -> Log.w("AlexandriaRenderer", "${message.message()} (${message.sourceId()}:${message.lineNumber()})") + else -> Unit + } + return true + } + } + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + val uri = request?.url ?: return true + if (uri.scheme == "file" && uri.path?.endsWith("reader.html") == true) return false + listener?.onExternalLink(uri.toString()) + return true + } + + override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { + val scheme = request?.url?.scheme?.lowercase() + if (scheme != null && scheme !in setOf("file", "data", "blob")) { + return WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(ByteArray(0))) + } + return super.shouldInterceptRequest(view, request) + } + + @Deprecated("Required for Android 11 WebView") + override fun onReceivedError(view: WebView?, errorCode: Int, description: String?, failingUrl: String?) { + if (failingUrl?.endsWith("reader.html") == true) listener?.onRenderError(description ?: "The page could not be rendered.") + } + } + } + + fun load(file: File) { + pageLoaded = false + loadUrl(file.toURI().toASCIIString()) + } + + fun applySettings(settings: ReaderSettings, location: ReaderLocation? = null) { + fixedZoomMode = settings.zoomMode + if (!pageLoaded) return + val keep = location?.toJson()?.toString() ?: "Alex.locator()" + javascript("Alex.applySettings(${settings.toJson()}, $keep)") + } + + fun configurePageTools(isFixedLayout: Boolean, settings: ReaderSettings) { + fixedLayout = isFixedLayout + fixedZoomMode = settings.zoomMode + } + + fun goToPage(page: Int) = javascript("Alex.setPage($page, true)") + fun nextPage() = javascript("Alex.next()") + fun previousPage() = javascript("Alex.previous()") + fun goToLocation(location: ReaderLocation) = javascript("Alex.goToLocator(${location.toJson()})") + fun goToReference(reference: String) = javascript("Alex.goToReference(${JSONObject.quote(reference)}, false)") + fun goToChapter(index: Int) = javascript("Alex.setPage(Alex.chapterPage($index), true)") + fun panBy(dx: Float, dy: Float) = javascript("Alex.panBy($dx,$dy)") + fun customZoomBy(factor: Float) = javascript("Alex.zoomBy($factor)") + fun finishViewportGesture() = javascript("Alex.finishViewportGesture()") + fun search(query: String, direction: SearchDirection) = + javascript("Alex.search(${JSONObject.quote(query)}, ${JSONObject.quote(direction.wireValue)})") + fun clearSearch() = javascript("Alex.clearSearch()") + fun moveSearch(delta: Int) = javascript("Alex.moveSearch($delta)") + + fun captureLocation(callback: (ReaderLocation) -> Unit) { + javascript("JSON.stringify(Alex.locator())") { encoded -> + decodeJavascriptString(encoded)?.let { runCatching { ReaderLocation.fromJson(JSONObject(it)) }.getOrNull() }?.let(callback) + } + } + + fun captureSelection(callback: (JSONObject?) -> Unit) { + javascript("JSON.stringify(Alex.captureSelection())") { encoded -> + val decoded = decodeJavascriptString(encoded) + callback(decoded?.takeUnless { it == "null" }?.let { runCatching { JSONObject(it) }.getOrNull() }) + } + } + + fun applyAnnotations(values: List) { + 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? = null) { + if (!pageLoaded && !source.startsWith("Alex.applySettings")) return + evaluateJavascript(source, callback) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + scaleGestures.onTouchEvent(event) + gestures.onTouchEvent(event) + if (fixedLayout && fixedZoomMode == "custom") { + if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) { + if (viewportDirty && !scaleGestures.isInProgress) finishViewportGesture() + viewportDirty = false + } + return true + } + return super.onTouchEvent(event) + } + + override fun performClick(): Boolean { + super.performClick() + return true + } + + override fun startActionMode(callback: ActionMode.Callback?, type: Int): ActionMode? { + return super.startActionMode(wrapSelectionCallback(callback), type) + } + + @Deprecated("Android still calls this overload on some WebView builds") + override fun startActionMode(callback: ActionMode.Callback?): ActionMode? { + return super.startActionMode(wrapSelectionCallback(callback)) + } + + private fun wrapSelectionCallback(delegate: ActionMode.Callback?): ActionMode.Callback { + return object : ActionMode.Callback { + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + val created = delegate?.onCreateActionMode(mode, menu) ?: true + if (created) { + menu.add(Menu.NONE, ACTION_HIGHLIGHT, 10, "Highlight").setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) + menu.add(Menu.NONE, ACTION_NOTE, 11, "Add note") + menu.add(Menu.NONE, ACTION_DEFINE, 12, "Define") + menu.add(Menu.NONE, ACTION_SEARCH, 13, "Search") + } + return created + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = + delegate?.onPrepareActionMode(mode, menu) ?: false + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + val action = when (item.itemId) { + ACTION_HIGHLIGHT -> SelectionAction.HIGHLIGHT + ACTION_NOTE -> SelectionAction.NOTE + ACTION_DEFINE -> SelectionAction.DEFINE + ACTION_SEARCH -> SelectionAction.SEARCH + else -> null + } + if (action != null) { + // WebView clears the DOM selection when the action mode closes. + // Capture it first, then dismiss the platform toolbar. + captureSelection { selection -> + listener?.onSelectionAction(action, selection) + runCatching { mode.finish() } + } + return true + } + return delegate?.onActionItemClicked(mode, item) ?: false + } + + override fun onDestroyActionMode(mode: ActionMode) { + delegate?.onDestroyActionMode(mode) + } + } + } + + private inner class Bridge { + @JavascriptInterface fun onReaderReady(json: String) = mainHandler.post { + pageLoaded = true + runCatching { JSONObject(json) }.getOrNull()?.let { listener?.onReady(it) } + } + + @JavascriptInterface fun onPageChanged(json: String) = mainHandler.post { + runCatching { ReaderLocation.fromJson(JSONObject(json)) }.getOrNull()?.let { listener?.onLocationChanged(it) } + } + + @JavascriptInterface fun onViewportChanged(json: String) = mainHandler.post { + runCatching { JSONObject(json) }.getOrNull()?.let { value -> + val mode = value.optString("zoomMode", "fit-page") + val zoom = value.optInt("customZoom", 100).coerceIn(50, 400) + fixedZoomMode = mode + listener?.onViewportChanged(mode, zoom) + } + } + + @JavascriptInterface fun onExternalLink(url: String) = mainHandler.post { listener?.onExternalLink(url) } + @JavascriptInterface fun onNavigationJump(json: String) = mainHandler.post { + runCatching { ReaderLocation.fromJson(JSONObject(json)) }.getOrNull()?.let { listener?.onNavigationJump(it) } + } + @JavascriptInterface fun onSearchResults(json: String) = mainHandler.post { + runCatching { JSONArray(json) }.getOrNull()?.let { listener?.onSearchResults(it) } + } + @JavascriptInterface fun onAnnotationTapped(id: String) = mainHandler.post { + suppressTapUntil = android.os.SystemClock.uptimeMillis() + 700L + listener?.onAnnotationTapped(id) + } + } + + override fun destroy() { + listener = null + removeJavascriptInterface("Android") + stopLoading() + loadUrl("about:blank") + super.destroy() + } + + companion object { + private const val ACTION_HIGHLIGHT = 0xA110 + private const val ACTION_NOTE = 0xA111 + private const val ACTION_DEFINE = 0xA112 + private const val ACTION_SEARCH = 0xA113 + + private fun decodeJavascriptString(value: String?): String? { + if (value == null || value == "null") return null + return runCatching { JSONArray("[$value]").getString(0) }.getOrNull() + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..beac925 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/font/atkinson.otf b/app/src/main/res/font/atkinson.otf new file mode 100644 index 0000000..ba345b5 Binary files /dev/null and b/app/src/main/res/font/atkinson.otf differ diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..98a9b3e --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,14 @@ + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..c7ad754 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..459e0db --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..09523c0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/local.properties b/local.properties new file mode 100644 index 0000000..be82eaa --- /dev/null +++ b/local.properties @@ -0,0 +1 @@ +sdk.dir=C:\\Users\\cameron\\AppData\\Local\\Android\\Sdk diff --git a/scripts/test.ps1 b/scripts/test.ps1 new file mode 100644 index 0000000..47d5196 --- /dev/null +++ b/scripts/test.ps1 @@ -0,0 +1,76 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$AvdName, + [switch]$KeepEmulator +) + +$ErrorActionPreference = "Stop" + +function Find-Sdk { + if ($env:ANDROID_SDK_ROOT -and (Test-Path $env:ANDROID_SDK_ROOT)) { return $env:ANDROID_SDK_ROOT } + if ($env:ANDROID_HOME -and (Test-Path $env:ANDROID_HOME)) { return $env:ANDROID_HOME } + $default = Join-Path $env:LOCALAPPDATA "Android\Sdk" + if (Test-Path $default) { return $default } + throw "Android SDK not found. Set ANDROID_SDK_ROOT or ANDROID_HOME." +} + +$sdk = Find-Sdk +$adb = Join-Path $sdk "platform-tools\adb.exe" +$emulator = Join-Path $sdk "emulator\emulator.exe" +if (-not (Test-Path $adb) -or -not (Test-Path $emulator)) { + throw "Android emulator tools were not found under $sdk." +} + +$running = & $adb devices | Select-String "^emulator-[0-9]+\s+device$" +if ($running) { + throw "Stop the running emulator before starting a clean test device." +} + +$env:ANDROID_SDK_ROOT = $sdk +$env:ANDROID_HOME = $sdk +& .\gradlew.bat :app:assembleDebug +if ($LASTEXITCODE -ne 0) { throw "Debug build failed." } + +$emulatorProcess = Start-Process -FilePath $emulator -ArgumentList @( + "-avd", $AvdName, + "-wipe-data", + "-no-snapshot", + "-no-boot-anim" +) -PassThru + +try { + & $adb wait-for-device + $serial = $null + $deadline = (Get-Date).AddMinutes(3) + do { + $deviceLine = & $adb devices | Select-String "^emulator-[0-9]+\s+device$" | Select-Object -First 1 + if ($deviceLine) { $serial = $deviceLine.ToString().Split("\t")[0] } + if (-not $serial) { Start-Sleep -Seconds 2 } + } while (-not $serial -and (Get-Date) -lt $deadline) + if (-not $serial) { throw "Timed out waiting for the emulator." } + + do { + $booted = (& $adb -s $serial shell getprop sys.boot_completed).Trim() + if ($booted -ne "1") { Start-Sleep -Seconds 2 } + } while ($booted -ne "1" -and (Get-Date) -lt $deadline) + if ($booted -ne "1") { throw "Timed out waiting for Android to finish booting." } + + & $adb -s $serial install (Join-Path $PWD "app\build\outputs\apk\debug\app-debug.apk") + if ($LASTEXITCODE -ne 0) { throw "APK installation failed." } + & $adb -s $serial shell am start -n "com.alexandria.reader/.MainActivity" + if ($LASTEXITCODE -ne 0) { throw "Could not launch Alexandria." } + + Write-Host "Clean test emulator is ready: $serial" + if (-not $KeepEmulator) { + Write-Host "Use Ctrl+C to stop the emulator when testing is complete." + while (-not $emulatorProcess.HasExited) { Start-Sleep -Seconds 2 } + } else { + Write-Host "Leaving the emulator running. The next invocation will wipe it again." + } +} +finally { + if (-not $KeepEmulator -and $serial -and -not $emulatorProcess.HasExited) { + & $adb -s $serial emu kill | Out-Null + } +} diff --git a/scripts/verify-fixed-page-tools.ps1 b/scripts/verify-fixed-page-tools.ps1 new file mode 100644 index 0000000..2aeef52 --- /dev/null +++ b/scripts/verify-fixed-page-tools.ps1 @@ -0,0 +1,151 @@ +[CmdletBinding()] +param( + [string]$Fixture, + [string]$OutputDirectory +) + +$ErrorActionPreference = "Stop" +if (-not $Fixture) { $Fixture = Join-Path $PSScriptRoot "..\tests\fixtures\fixed-layout.epub" } +if (-not $OutputDirectory) { $OutputDirectory = Join-Path $PSScriptRoot "..\verification" } +$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } elseif ($env:ANDROID_HOME) { $env:ANDROID_HOME } else { Join-Path $env:LOCALAPPDATA "Android\Sdk" } +$adb = Join-Path $sdk "platform-tools\adb.exe" +if (-not (Test-Path $adb)) { throw "adb was not found under $sdk" } +if (-not (Test-Path $Fixture)) { throw "Fixture not found: $Fixture" } +$device = (& $adb devices | Select-String "^emulator-[0-9]+\s+device$" | Select-Object -First 1) +if (-not $device) { throw "Start the Android virtual device before running this script." } +$serial = $device.ToString().Split("`t")[0] +$hashStream = [System.IO.File]::OpenRead((Resolve-Path $Fixture)) +try { + $hashBytes = [System.Security.Cryptography.SHA256]::Create().ComputeHash($hashStream) + $bookId = (($hashBytes | ForEach-Object { $_.ToString("x2") }) -join "").Substring(0, 24) +} +finally { $hashStream.Dispose() } +$bookDirectory = "/data/user/0/com.alexandria.reader/files/library/$bookId" +New-Item -ItemType Directory -Force $OutputDirectory | Out-Null + +function Invoke-Adb([Parameter(ValueFromRemainingArguments=$true)][string[]]$Arguments) { + & $adb -s $serial @Arguments + if ($LASTEXITCODE -ne 0) { throw "adb failed: $($Arguments -join ' ')" } +} +function Wait-Reader([string]$Expected) { + $dump = Join-Path $OutputDirectory "fixed-window.xml" + Remove-Item -ErrorAction SilentlyContinue $dump + & $adb -s $serial shell rm -f /sdcard/alexandria-fixed-window.xml | Out-Null + for ($attempt = 0; $attempt -lt 60; $attempt++) { + Start-Sleep -Milliseconds 500 + & $adb -s $serial shell uiautomator dump /sdcard/alexandria-fixed-window.xml | Out-Null + if ($LASTEXITCODE -ne 0) { continue } + & $adb -s $serial pull /sdcard/alexandria-fixed-window.xml $dump | Out-Null + if ($LASTEXITCODE -eq 0 -and (Get-Content $dump -Raw) -match $Expected) { return } + } + throw "Reader state did not match: $Expected" +} +function Wait-Library { + $dump = Join-Path $OutputDirectory "fixed-library-window.xml" + Remove-Item -ErrorAction SilentlyContinue $dump + & $adb -s $serial shell rm -f /sdcard/alexandria-fixed-library.xml | Out-Null + for ($attempt = 0; $attempt -lt 30; $attempt++) { + Start-Sleep -Milliseconds 500 + & $adb -s $serial shell uiautomator dump /sdcard/alexandria-fixed-library.xml | Out-Null + if ($LASTEXITCODE -ne 0) { continue } + & $adb -s $serial pull /sdcard/alexandria-fixed-library.xml $dump | Out-Null + if ($LASTEXITCODE -eq 0 -and (Get-Content $dump -Raw) -match "Fixed Page Tools Verification") { return } + } + throw "The fixed-page fixture was not visible in the library." +} +function Capture([string]$Name) { + Invoke-Adb shell screencap '-p' "/sdcard/$Name.png" + Invoke-Adb pull "/sdcard/$Name.png" (Join-Path $OutputDirectory "$Name.png") | Out-Null +} +function New-Settings( + [string]$ZoomMode = "fit-page", [int]$CustomZoom = 100, [string]$ScrollMode = "screen", + [string]$CropScheme = "none", [hashtable]$CropEven = @{}, [hashtable]$CropOdd = @{}, + [double]$Contrast = 1.0, [int]$GrayPoint = 255, [string]$Dithering = "off" +) { + [ordered]@{ + fontFamily="Publisher"; fontSize=20; lineHeight=1.32; margin=32; textAlign="publisher"; theme="light" + publisherStyles=$true; hyphenation=$true; mode="paged"; brightness=-1; volumeKeys=$true + zoomMode=$ZoomMode; customZoom=$CustomZoom; scrollMode=$ScrollMode; cropScheme=$CropScheme + cropAny=@{}; cropEven=$CropEven; cropOdd=$CropOdd + contrastExponent=$Contrast; grayPoint=$GrayPoint; dithering=$Dithering + } | ConvertTo-Json -Depth 5 -Compress +} +function Set-State([string]$Settings, [int]$Page, [double]$ViewportX = 0, [double]$ViewportY = 0) { + $settingsFile = Join-Path $OutputDirectory "fixed-settings.json" + $locationFile = Join-Path $OutputDirectory "fixed-location.json" + Invoke-Adb shell am force-stop com.alexandria.reader + $utf8 = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($settingsFile, $Settings, $utf8) + $progress = if ($Page -eq 0) { 0 } else { $Page / 3.0 } + $location = [ordered]@{page=$Page;totalPages=4;chapter=$Page;offset=0;progress=$progress;viewportX=$ViewportX;viewportY=$ViewportY} | ConvertTo-Json -Compress + [System.IO.File]::WriteAllText($locationFile, $location, $utf8) + Invoke-Adb push $settingsFile "$bookDirectory/settings.json" | Out-Null + Invoke-Adb push $locationFile "$bookDirectory/location.json" | Out-Null + Invoke-Adb shell chmod 666 "$bookDirectory/settings.json" "$bookDirectory/location.json" +} +function Open-Book([string]$Expected) { + Invoke-Adb shell am force-stop com.alexandria.reader + Invoke-Adb shell am start '-W' '-n' com.alexandria.reader/.MainActivity '-a' android.intent.action.MAIN '-c' android.intent.category.LAUNCHER | Out-Null + Wait-Library + Invoke-Adb shell input tap 500 210 + Wait-Reader $Expected +} + +Push-Location (Join-Path $PSScriptRoot "..") +try { + & .\gradlew.bat :app:assembleDebug + if ($LASTEXITCODE -ne 0) { throw "Debug build failed." } + Invoke-Adb install '-r' ".\app\build\outputs\apk\debug\app-debug.apk" | Out-Null + Invoke-Adb shell pm clear com.alexandria.reader | Out-Null + Invoke-Adb shell am start '-W' '-n' com.alexandria.reader/.MainActivity '-a' android.intent.action.MAIN '-c' android.intent.category.LAUNCHER | Out-Null + Start-Sleep -Seconds 1 + Invoke-Adb root | Out-Null + Invoke-Adb wait-for-device + Invoke-Adb push $Fixture /data/user/0/com.alexandria.reader/files/fixed-verification.epub | Out-Null + Invoke-Adb shell chmod 666 /data/user/0/com.alexandria.reader/files/fixed-verification.epub + Invoke-Adb logcat '-c' + Invoke-Adb shell am force-stop com.alexandria.reader + Invoke-Adb shell am start '-W' '-a' android.intent.action.VIEW '-d' file:///data/user/0/com.alexandria.reader/files/fixed-verification.epub '-t' application/epub+zip com.alexandria.reader/.MainActivity | Out-Null + Wait-Reader "Page 1 of 4" + Capture "fixed-fit-page" + + Set-State (New-Settings -CropScheme "even-odd" -CropOdd @{left=9;top=8;right=9;bottom=8} -CropEven @{left=14;top=8;right=14;bottom=8}) 0 + Open-Book "Page 1 of 4" + Capture "fixed-odd-cropped" + Invoke-Adb shell input tap 1357 1748 + Wait-Reader "Page 2 of 4" + Capture "fixed-even-cropped" + + Set-State (New-Settings -ZoomMode "custom" -CustomZoom 170 -CropScheme "even-odd" -CropOdd @{left=9;top=8;right=9;bottom=8} -CropEven @{left=14;top=8;right=14;bottom=8}) 1 250 250 + Open-Book "Page 2 of 4" + Capture "fixed-custom-before-pan" + Invoke-Adb shell input swipe 1050 1400 430 520 1000 + Start-Sleep -Seconds 2 + $panned = (Invoke-Adb shell cat "$bookDirectory/location.json" | Out-String | ConvertFrom-Json) + if ($panned.viewportY -le 250) { throw "Custom pan did not update the persisted viewport." } + Capture "fixed-custom-after-pan" + + Set-State (New-Settings -Contrast 2.5 -GrayPoint 150 -Dithering "g16") 2 + Open-Book "Page 3 of 4" + Capture "fixed-graypoint-dither-g16" + Set-State (New-Settings -Contrast 2.5 -GrayPoint 150 -Dithering "g2") 2 + Open-Book "Page 3 of 4" + Capture "fixed-graypoint-dither-g2" + + Set-State (New-Settings -ZoomMode "fit-width" -ScrollMode "screen") 0 + Open-Book "Page 1 of 4" + Capture "fixed-line-cut-before" + Invoke-Adb shell input tap 1282 1748 + Start-Sleep -Seconds 2 + $cut = (Invoke-Adb shell cat "$bookDirectory/location.json" | Out-String | ConvertFrom-Json) + if ($cut.viewportY -lt 1300 -or $cut.viewportY -gt 1400) { throw "The line-preserving cut was not selected." } + Capture "fixed-line-cut-after" + + $fatal = & $adb -s $serial logcat -d -v brief | Select-String "FATAL EXCEPTION|Process: com.alexandria.reader|E/AlexandriaRenderer" + if ($fatal) { throw "Alexandria failed during fixed-page verification.`n$($fatal -join "`n")" } + Write-Host "Verified fixed-page tools. Screenshots are in $OutputDirectory" +} +finally { + Remove-Item -ErrorAction SilentlyContinue (Join-Path $OutputDirectory "fixed-settings.json"), (Join-Path $OutputDirectory "fixed-location.json"), (Join-Path $OutputDirectory "fixed-library-window.xml") + Pop-Location +} diff --git a/scripts/verify-running.ps1 b/scripts/verify-running.ps1 new file mode 100644 index 0000000..354b7de --- /dev/null +++ b/scripts/verify-running.ps1 @@ -0,0 +1,65 @@ +[CmdletBinding()] +param( + [string]$Fixture, + [string]$OutputDirectory +) + +$ErrorActionPreference = "Stop" +if (-not $Fixture) { $Fixture = Join-Path $PSScriptRoot "..\tests\fixtures\odyssey.epub" } +if (-not $OutputDirectory) { $OutputDirectory = Join-Path $PSScriptRoot "..\verification" } +$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } elseif ($env:ANDROID_HOME) { $env:ANDROID_HOME } else { Join-Path $env:LOCALAPPDATA "Android\Sdk" } +$adb = Join-Path $sdk "platform-tools\adb.exe" +if (-not (Test-Path $adb)) { throw "adb was not found under $sdk" } +if (-not (Test-Path $Fixture)) { throw "Fixture not found: $Fixture" } + +$device = (& $adb devices | Select-String "^emulator-[0-9]+\s+device$" | Select-Object -First 1) +if (-not $device) { throw "Start the Android virtual device before running this script." } +$serial = $device.ToString().Split("`t")[0] + +Push-Location (Join-Path $PSScriptRoot "..") +try { + & .\gradlew.bat :app:assembleDebug + if ($LASTEXITCODE -ne 0) { throw "Debug build failed." } + & $adb -s $serial install -r ".\app\build\outputs\apk\debug\app-debug.apk" | Out-Null + & $adb -s $serial shell pm clear com.alexandria.reader | Out-Null + + # Start once so Android creates the private application directories. The + # userdebug AVD then lets this test harness place the fixture there without + # granting Alexandria a shared-storage permission. + & $adb -s $serial shell am start -W -n com.alexandria.reader/.MainActivity -a android.intent.action.MAIN -c android.intent.category.LAUNCHER | Out-Null + Start-Sleep -Seconds 1 + & $adb -s $serial root | Out-Null + & $adb -s $serial wait-for-device + $remote = "/data/user/0/com.alexandria.reader/files/verification.epub" + & $adb -s $serial push $Fixture $remote | Out-Null + & $adb -s $serial shell chmod 666 $remote + & $adb -s $serial logcat -c + & $adb -s $serial shell am force-stop com.alexandria.reader + & $adb -s $serial shell am start -W -a android.intent.action.VIEW -d "file://$remote" -t application/epub+zip com.alexandria.reader/.MainActivity | Out-Null + + New-Item -ItemType Directory -Force $OutputDirectory | Out-Null + $dump = Join-Path $OutputDirectory "window.xml" + Remove-Item -ErrorAction SilentlyContinue $dump + & $adb -s $serial shell rm -f /sdcard/alexandria-window.xml | Out-Null + $ready = $false + for ($attempt = 0; $attempt -lt 60; $attempt++) { + Start-Sleep -Milliseconds 500 + & $adb -s $serial shell uiautomator dump /sdcard/alexandria-window.xml | Out-Null + if ($LASTEXITCODE -ne 0) { continue } + & $adb -s $serial pull /sdcard/alexandria-window.xml $dump | Out-Null + if ($LASTEXITCODE -eq 0 -and (Get-Content $dump -Raw) -match "Page 1 of ([2-9]|[1-9][0-9]+)") { $ready = $true; break } + } + if (-not $ready) { throw "The fixture did not reach a rendered first page." } + + & $adb -s $serial shell screencap -p /sdcard/alexandria-odyssey.png + $screenshot = Join-Path $OutputDirectory "odyssey-page-1.png" + & $adb -s $serial pull /sdcard/alexandria-odyssey.png $screenshot | Out-Null + if ((Get-Item $screenshot).Length -lt 100000) { throw "The verification screenshot is unexpectedly small." } + + $fatal = & $adb -s $serial logcat -d -v brief | Select-String "FATAL EXCEPTION|Process: com.alexandria.reader" + if ($fatal) { throw "Alexandria crashed during verification.`n$($fatal -join "`n")" } + Write-Host "Verified EPUB rendering: $screenshot" +} +finally { + Pop-Location +} diff --git a/scripts/verify-search-history.ps1 b/scripts/verify-search-history.ps1 new file mode 100644 index 0000000..4cfb4a6 --- /dev/null +++ b/scripts/verify-search-history.ps1 @@ -0,0 +1,117 @@ +[CmdletBinding()] +param( + [string]$Fixture, + [string]$OutputDirectory +) + +$ErrorActionPreference = "Stop" +if (-not $Fixture) { $Fixture = Join-Path $PSScriptRoot "..\tests\fixtures\navigation.epub" } +if (-not $OutputDirectory) { $OutputDirectory = Join-Path $PSScriptRoot "..\verification" } +$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } elseif ($env:ANDROID_HOME) { $env:ANDROID_HOME } else { Join-Path $env:LOCALAPPDATA "Android\Sdk" } +$adb = Join-Path $sdk "platform-tools\adb.exe" +if (-not (Test-Path $adb)) { throw "adb was not found under $sdk" } +if (-not (Test-Path $Fixture)) { throw "Fixture not found: $Fixture" } +$device = (& $adb devices | Select-String "^emulator-[0-9]+\s+device$" | Select-Object -First 1) +if (-not $device) { throw "Start the Android virtual device before running this script." } +$serial = $device.ToString().Split("`t")[0] +New-Item -ItemType Directory -Force $OutputDirectory | Out-Null + +function Invoke-Adb([Parameter(ValueFromRemainingArguments=$true)][string[]]$Arguments) { + & $adb -s $serial @Arguments + if ($LASTEXITCODE -ne 0) { throw "adb failed: $($Arguments -join ' ')" } +} +function Wait-State([string[]]$Patterns) { + $dump = Join-Path $OutputDirectory "navigation-window.xml" + Remove-Item -ErrorAction SilentlyContinue $dump + & $adb -s $serial shell rm -f /sdcard/alexandria-navigation-window.xml | Out-Null + for ($attempt = 0; $attempt -lt 60; $attempt++) { + Start-Sleep -Milliseconds 500 + & $adb -s $serial shell uiautomator dump /sdcard/alexandria-navigation-window.xml | Out-Null + if ($LASTEXITCODE -ne 0) { continue } + & $adb -s $serial pull /sdcard/alexandria-navigation-window.xml $dump | Out-Null + if ($LASTEXITCODE -ne 0) { continue } + $content = Get-Content $dump -Raw + $matches = $true + foreach ($pattern in $Patterns) { if ($content -notmatch $pattern) { $matches = $false; break } } + if ($matches) { return } + } + throw "Reader state did not match: $($Patterns -join ', ')" +} +function Capture([string]$Name) { + Invoke-Adb shell screencap '-p' "/sdcard/$Name.png" + $local = Join-Path $OutputDirectory "$Name.png" + Invoke-Adb pull "/sdcard/$Name.png" $local | Out-Null + if ((Get-Item $local).Length -lt 50000) { throw "The $Name screenshot is unexpectedly small." } +} +function Tap([int]$X, [int]$Y) { + Invoke-Adb shell input tap $X $Y | Out-Null +} + +Push-Location (Join-Path $PSScriptRoot "..") +try { + & .\gradlew.bat :app:assembleDebug + if ($LASTEXITCODE -ne 0) { throw "Debug build failed." } + Invoke-Adb install '-r' ".\app\build\outputs\apk\debug\app-debug.apk" | Out-Null + Invoke-Adb shell pm clear com.alexandria.reader | Out-Null + Invoke-Adb shell am start '-W' '-n' com.alexandria.reader/.MainActivity '-a' android.intent.action.MAIN '-c' android.intent.category.LAUNCHER | Out-Null + Start-Sleep -Seconds 1 + Invoke-Adb root | Out-Null + Invoke-Adb wait-for-device + $remote = "/data/user/0/com.alexandria.reader/files/navigation-verification.epub" + Invoke-Adb push $Fixture $remote | Out-Null + Invoke-Adb shell chmod 666 $remote + Invoke-Adb logcat '-c' + Invoke-Adb shell am force-stop com.alexandria.reader + Invoke-Adb shell am start '-W' '-a' android.intent.action.VIEW '-d' "file://$remote" '-t' application/epub+zip com.alexandria.reader/.MainActivity | Out-Null + Wait-State "Page 1 of 4" + + # The publisher link jumps from page one to page three. The north-west + # history region must return to the exact origin. + Tap 330 550 + Wait-State "Page 3 of 4" + Capture "internal-link-jump" + Tap 200 350 + Wait-State "Page 1 of 4" + Capture "internal-link-history-return" + + # Start on page two. Forward search must choose the second of three + # matches, which is on page three. + Tap 1284 1749 + Wait-State "Page 2 of 4" + Tap 1218 75 + Wait-State "Search forward from the current location" + Invoke-Adb shell input text waypoint | Out-Null + Invoke-Adb shell input keyevent 66 | Out-Null + Wait-State @("2 / 3", "Page 3 of 4") + Capture "search-forward-from-current" + + # A next-result jump must also enter history. Two history returns must + # unwind page four to page three and then to the page-two search origin. + Tap 1303 153 + Wait-State @("3 / 3", "Page 4 of 4") + Tap 200 350 + Wait-State "Page 3 of 4" + Tap 200 350 + Wait-State "Page 2 of 4" + + # Select backward direction and repeat the query from page two. The first + # match before the origin is result one on page one. + Tap 1365 153 + Tap 1218 75 + Wait-State "Search forward from the current location" + Tap 43 153 + Wait-State "Search backward from the current location" + Invoke-Adb shell input text waypoint | Out-Null + Invoke-Adb shell input keyevent 66 | Out-Null + Wait-State @("1 / 3", "Page 1 of 4") + Capture "search-backward-from-current" + Tap 200 350 + Wait-State "Page 2 of 4" + + $fatal = & $adb -s $serial logcat -d -v brief | Select-String "FATAL EXCEPTION|Process: com.alexandria.reader|E/AlexandriaRenderer" + if ($fatal) { throw "Alexandria failed during search/history verification.`n$($fatal -join "`n")" } + Write-Host "Verified directional search and location history. Screenshots are in $OutputDirectory" +} +finally { + Pop-Location +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..c2a6b9c --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,4 @@ +pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } +dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories { google(); mavenCentral() } } +rootProject.name = "alexandria" +include(":app") diff --git a/tests/fixtures/fixed-layout-src/META-INF/container.xml b/tests/fixtures/fixed-layout-src/META-INF/container.xml new file mode 100644 index 0000000..0cd010d --- /dev/null +++ b/tests/fixtures/fixed-layout-src/META-INF/container.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/fixtures/fixed-layout-src/OEBPS/fixed.css b/tests/fixtures/fixed-layout-src/OEBPS/fixed.css new file mode 100644 index 0000000..6999a1d --- /dev/null +++ b/tests/fixtures/fixed-layout-src/OEBPS/fixed.css @@ -0,0 +1,11 @@ +html, body { width:1200px; height:1600px; margin:0; padding:0; overflow:hidden; background:#b8b8b8; color:#111; font-family:serif; } +.trim { position:absolute; left:110px; top:130px; width:980px; height:1320px; box-sizing:border-box; border:8px solid #202020; background:#fff; padding:72px 82px; } +h1 { margin:0 0 70px; font:700 64px sans-serif; text-align:center; letter-spacing:3px; } +p { margin:0 0 28px; font-size:38px; line-height:1.42; } +.rule { height:12px; margin:38px 0; background:#111; } +.swatches { display:flex; height:150px; margin-top:50px; border:2px solid #111; } +.swatches span { flex:1; } +.footer { position:absolute; left:0; right:0; bottom:42px; text-align:center; font:32px sans-serif; } +.cut-test { position:absolute; left:260px; top:1325px; margin:0; font:700 38px/1.42 sans-serif; white-space:nowrap; } +.crop-label { position:absolute; color:#333; font:28px sans-serif; } +.north { left:480px; top:35px; }.south { left:470px; bottom:40px; }.west { left:16px; top:760px; transform:rotate(-90deg); }.east { right:15px; top:760px; transform:rotate(90deg); } diff --git a/tests/fixtures/fixed-layout-src/OEBPS/nav.xhtml b/tests/fixtures/fixed-layout-src/OEBPS/nav.xhtml new file mode 100644 index 0000000..53ed9bf --- /dev/null +++ b/tests/fixtures/fixed-layout-src/OEBPS/nav.xhtml @@ -0,0 +1,2 @@ + +Contents
diff --git a/tests/fixtures/fixed-layout-src/OEBPS/package.opf b/tests/fixtures/fixed-layout-src/OEBPS/package.opf new file mode 100644 index 0000000..b728fea --- /dev/null +++ b/tests/fixtures/fixed-layout-src/OEBPS/package.opf @@ -0,0 +1,23 @@ + + + + urn:alexandria:fixed-tools-fixture + Fixed Page Tools Verification + Alexandria Tests + en + pre-paginated + auto + none + + + + + + + + + + + + + diff --git a/tests/fixtures/fixed-layout-src/OEBPS/page1.xhtml b/tests/fixtures/fixed-layout-src/OEBPS/page1.xhtml new file mode 100644 index 0000000..ee7435b --- /dev/null +++ b/tests/fixtures/fixed-layout-src/OEBPS/page1.xhtml @@ -0,0 +1,2 @@ + +Odd page oneTOP CROP AREABOTTOM CROP AREALEFT CROPRIGHT CROP

FIXED PAGE 1 · ODD

This fixed-layout page has a 1200 by 1600 pixel viewport.

Fit-to-width cuts must stop between these lines instead of cutting through letter shapes.

The next line is deliberately close to a screen boundary for cut verification.

LINE MUST STAY WHOLE AT THE CUT

diff --git a/tests/fixtures/fixed-layout-src/OEBPS/page2.xhtml b/tests/fixtures/fixed-layout-src/OEBPS/page2.xhtml new file mode 100644 index 0000000..7d32a82 --- /dev/null +++ b/tests/fixtures/fixed-layout-src/OEBPS/page2.xhtml @@ -0,0 +1,2 @@ + +Even page twoEVEN TOP MARGINEVEN BOTTOM MARGINEVEN LEFTEVEN RIGHT

FIXED PAGE 2 · EVEN

This page has visibly different side margins.

The even-and-odd crop scheme must retain a separate rectangle for this page.

Custom zoom and pan must remain bounded by the cropped page.

diff --git a/tests/fixtures/fixed-layout-src/OEBPS/page3.xhtml b/tests/fixtures/fixed-layout-src/OEBPS/page3.xhtml new file mode 100644 index 0000000..bbcb5ff --- /dev/null +++ b/tests/fixtures/fixed-layout-src/OEBPS/page3.xhtml @@ -0,0 +1,2 @@ + +Odd page threeTOP CROP AREABOTTOM CROP AREALEFT CROPRIGHT CROP

FIXED PAGE 3 · ODD

Gray-point adjustment changes the pivot used by the contrast curve.

Sixteen-level dithering preserves intermediate shades on grayscale displays.

Black-and-white dithering produces a two-level halftone.

diff --git a/tests/fixtures/fixed-layout-src/OEBPS/page4.xhtml b/tests/fixtures/fixed-layout-src/OEBPS/page4.xhtml new file mode 100644 index 0000000..2013019 --- /dev/null +++ b/tests/fixtures/fixed-layout-src/OEBPS/page4.xhtml @@ -0,0 +1,2 @@ + +Even page fourEVEN TOP MARGINEVEN BOTTOM MARGINEVEN LEFTEVEN RIGHT

FIXED PAGE 4 · EVEN

The final page verifies page-boundary navigation after vertical slices.

Previous returns to the bottom slice of the prior fixed page.

All display settings and viewport coordinates persist after restart.

diff --git a/tests/fixtures/fixed-layout-src/mimetype b/tests/fixtures/fixed-layout-src/mimetype new file mode 100644 index 0000000..57ef03f --- /dev/null +++ b/tests/fixtures/fixed-layout-src/mimetype @@ -0,0 +1 @@ +application/epub+zip \ No newline at end of file diff --git a/tests/fixtures/fixed-layout.epub b/tests/fixtures/fixed-layout.epub new file mode 100644 index 0000000..53f5059 Binary files /dev/null and b/tests/fixtures/fixed-layout.epub differ diff --git a/tests/fixtures/navigation-src/META-INF/container.xml b/tests/fixtures/navigation-src/META-INF/container.xml new file mode 100644 index 0000000..fe5cbeb --- /dev/null +++ b/tests/fixtures/navigation-src/META-INF/container.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/fixtures/navigation-src/OEBPS/chapter1.xhtml b/tests/fixtures/navigation-src/OEBPS/chapter1.xhtml new file mode 100644 index 0000000..e883e6c --- /dev/null +++ b/tests/fixtures/navigation-src/OEBPS/chapter1.xhtml @@ -0,0 +1,9 @@ + + +Origin + +

Origin

+

The first waypoint is on this page. This result is before the test's current location.

+

Jump to the linked destination

+ + diff --git a/tests/fixtures/navigation-src/OEBPS/chapter2.xhtml b/tests/fixtures/navigation-src/OEBPS/chapter2.xhtml new file mode 100644 index 0000000..97cac4b --- /dev/null +++ b/tests/fixtures/navigation-src/OEBPS/chapter2.xhtml @@ -0,0 +1,9 @@ + + +Current location + +

Current location

+

Directional searches start here. The preceding result is in the first chapter. Two later results are in the third and fourth chapters.

+

This chapter intentionally does not contain the search term.

+ + diff --git a/tests/fixtures/navigation-src/OEBPS/chapter3.xhtml b/tests/fixtures/navigation-src/OEBPS/chapter3.xhtml new file mode 100644 index 0000000..83ecba8 --- /dev/null +++ b/tests/fixtures/navigation-src/OEBPS/chapter3.xhtml @@ -0,0 +1,9 @@ + + +Linked destination + +

Linked destination

+

The second waypoint is here. A forward search from the second chapter must select this result.

+

The internal link also targets this location.

+ + diff --git a/tests/fixtures/navigation-src/OEBPS/chapter4.xhtml b/tests/fixtures/navigation-src/OEBPS/chapter4.xhtml new file mode 100644 index 0000000..e401d8d --- /dev/null +++ b/tests/fixtures/navigation-src/OEBPS/chapter4.xhtml @@ -0,0 +1,8 @@ + + +Final result + +

Final result

+

The third waypoint is on the final page. Moving to the next result from chapter three must record chapter three in location history.

+ + diff --git a/tests/fixtures/navigation-src/OEBPS/nav.xhtml b/tests/fixtures/navigation-src/OEBPS/nav.xhtml new file mode 100644 index 0000000..56be138 --- /dev/null +++ b/tests/fixtures/navigation-src/OEBPS/nav.xhtml @@ -0,0 +1,12 @@ + + +Contents + + + + diff --git a/tests/fixtures/navigation-src/OEBPS/package.opf b/tests/fixtures/navigation-src/OEBPS/package.opf new file mode 100644 index 0000000..2968af3 --- /dev/null +++ b/tests/fixtures/navigation-src/OEBPS/package.opf @@ -0,0 +1,24 @@ + + + + urn:uuid:alexandria-navigation-verification + Navigation and Search Verification + Alexandria Tests + en + 2026-08-10T00:00:00Z + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/navigation-src/OEBPS/style.css b/tests/fixtures/navigation-src/OEBPS/style.css new file mode 100644 index 0000000..64cb785 --- /dev/null +++ b/tests/fixtures/navigation-src/OEBPS/style.css @@ -0,0 +1,5 @@ +html, body { margin: 0; padding: 0; } +body { font-family: serif; font-size: 24px; line-height: 1.45; } +h1 { font-size: 2em; margin: 0 0 1em; } +p { margin: 1em 0; } +a { display: inline-block; margin-top: 2em; padding: 1em; border: 3px solid currentColor; font-weight: bold; } diff --git a/tests/fixtures/navigation-src/mimetype b/tests/fixtures/navigation-src/mimetype new file mode 100644 index 0000000..57ef03f --- /dev/null +++ b/tests/fixtures/navigation-src/mimetype @@ -0,0 +1 @@ +application/epub+zip \ No newline at end of file diff --git a/tests/fixtures/navigation.epub b/tests/fixtures/navigation.epub new file mode 100644 index 0000000..cf88dd9 Binary files /dev/null and b/tests/fixtures/navigation.epub differ