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