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