From: Cameron Otsuka Date: Tue, 11 Aug 2026 19:25:34 +0000 (-0700) Subject: restore epub 2 support X-Git-Url: https://git.otsuka.systems/?a=commitdiff_plain;h=ba9725fd46cf71f4d7d909d9dc360715cc2bf45a;p=alexandria restore epub 2 support --- diff --git a/README.md b/README.md index 8c0434c..f0115ed 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ The app requests no network or shared-storage permission. It imports each select ## EPUB support -- EPUB 3 package, spine, metadata, cover, and navigation documents -- Publisher CSS, inline styles, imported stylesheets, embedded fonts, SVG, images, tables, lists, links, and break rules +- EPUB 2 and EPUB 3 package, spine, metadata, cover, NCX, and navigation documents +- Publisher CSS, inline styles, imported stylesheets, embedded fonts, SVG, images, tables, lists, links, and page-break rules - Accurate reflow with browser-grade shaping, bidirectional text, hyphenation, and accessibility text - Paginated and continuous reading modes - Pre-paginated EPUB detection through standard rendition metadata @@ -48,6 +48,12 @@ Start the supplied Android virtual device, then run: The script performs a clean install, imports `tests/fixtures/odyssey.epub`, waits for a measured page count, checks Logcat for crashes, and writes `verification/odyssey-page-1.png`. +Use the EPUB 2 fixture to verify OPF 2 metadata, cover extraction, NCX navigation, and page-break rules: + +```powershell +.\scripts\verify-running.ps1 -Fixture .\tests\fixtures\epub2.epub -OutputDirectory .\verification\epub2 +``` + Run the focused reader tests separately: ```powershell diff --git a/app/src/main/java/com/alexandria/reader/EpubParser.kt b/app/src/main/java/com/alexandria/reader/EpubParser.kt index e78c001..f1c7601 100644 --- a/app/src/main/java/com/alexandria/reader/EpubParser.kt +++ b/app/src/main/java/com/alexandria/reader/EpubParser.kt @@ -13,7 +13,7 @@ import java.util.Locale import java.util.zip.ZipFile import kotlin.math.roundToInt -/** EPUB 3 package reader. It keeps the original resources intact for standards-based rendering. */ +/** EPUB 2/3 package reader. It keeps the original resources intact for standards-based rendering. */ object EpubParser { private data class ManifestItem(val href: String, val mediaType: String, val properties: Set) private data class ParsedSpineItem(val item: SpineItem, val linear: Boolean, val fixedLayout: Boolean) @@ -37,8 +37,9 @@ object EpubParser { val opf = parseXml(packageFile) val packageElement = opf.allByLocalName("package").firstOrNull() ?: error("The EPUB package document has no package element.") - require(packageElement.attr("version").substringBefore('.').toIntOrNull() == 3) { - "Only EPUB 3 publications are supported." + val packageVersion = packageElement.attr("version").substringBefore('.').toIntOrNull() + require(packageVersion == 2 || packageVersion == 3) { + "Only EPUB 2 and EPUB 3 publications are supported." } val packageDirectory = packagePath.substringBeforeLast('/', "") @@ -50,19 +51,22 @@ object EpubParser { metadata.firstText("language")?.let { book.language = it } metadata.firstText("publisher")?.let { book.publisher = it } metadata.firstText("description")?.let { book.description = it } - val packageViewport = metadata.allByLocalName("meta").firstNotNullOfOrNull { meta -> - if (!meta.attr("property").equals("rendition:viewport", ignoreCase = true)) { - return@firstNotNullOfOrNull null + val packageViewport = if (packageVersion == 3) { + metadata.allByLocalName("meta").firstNotNullOfOrNull { meta -> + if (!meta.attr("property").equals("rendition:viewport", ignoreCase = true)) { + return@firstNotNullOfOrNull null + } + val dimensions = Regex( + "(?:width\\s*=\\s*)?([0-9]+)\\s*(?:x|[,;]\\s*height\\s*=)\\s*([0-9]+)", + RegexOption.IGNORE_CASE, + ).find(meta.text()) ?: return@firstNotNullOfOrNull null + val width = dimensions.groupValues[1].toIntOrNull() ?: return@firstNotNullOfOrNull null + val height = dimensions.groupValues[2].toIntOrNull() ?: return@firstNotNullOfOrNull null + (width to height).takeIf { width > 0 && height > 0 } } - val dimensions = Regex( - "(?:width\\s*=\\s*)?([0-9]+)\\s*(?:x|[,;]\\s*height\\s*=)\\s*([0-9]+)", - RegexOption.IGNORE_CASE, - ).find(meta.text()) ?: return@firstNotNullOfOrNull null - val width = dimensions.groupValues[1].toIntOrNull() ?: return@firstNotNullOfOrNull null - val height = dimensions.groupValues[2].toIntOrNull() ?: return@firstNotNullOfOrNull null - (width to height).takeIf { width > 0 && height > 0 } - } ?: (1200 to 1600) - val packageFixedLayout = metadata.allByLocalName("meta").any { meta -> + } else null + val pageSize = packageViewport ?: (1200 to 1600) + val packageFixedLayout = packageVersion == 3 && metadata.allByLocalName("meta").any { meta -> meta.attr("property").equals("rendition:layout", ignoreCase = true) && meta.text().trim().equals("pre-paginated", ignoreCase = true) } @@ -94,23 +98,56 @@ object EpubParser { ParsedSpineItem( item = SpineItem(item.href, chapterTitle(chapterFile)), linear = !reference.attr("linear").equals("no", ignoreCase = true), - fixedLayout = "rendition:layout-pre-paginated" in item.properties || - "rendition:layout-pre-paginated" in referenceProperties, + fixedLayout = packageVersion == 3 && + ("rendition:layout-pre-paginated" in item.properties || + "rendition:layout-pre-paginated" in referenceProperties), ) } require(parsedSpine.isNotEmpty()) { "The EPUB reading order is empty." } val spine = parsedSpine.map(ParsedSpineItem::item) - manifest.values.firstOrNull { "cover-image" in it.properties }?.let { item -> - val candidate = safeFile(output, item.href) - if (candidate.isFile && item.mediaType.startsWith("image/")) book.coverPath = candidate.absolutePath + if (packageVersion == 3) { + manifest.values.firstOrNull { "cover-image" in it.properties }?.let { item -> + val candidate = safeFile(output, item.href) + if (candidate.isFile && item.mediaType.startsWith("image/")) book.coverPath = candidate.absolutePath + } + } else { + val coverId = metadata.allByLocalName("meta").firstOrNull { + it.attr("name").equals("cover", ignoreCase = true) + }?.attr("content")?.trim() + val metadataCover = coverId?.let(manifest::get)?.takeIf { it.mediaType.startsWith("image/") } + val guideCover = opf.allByLocalName("guide").firstOrNull()?.children() + ?.firstOrNull { reference -> + reference.localName() == "reference" && + reference.attr("type").equals("cover", ignoreCase = true) + }?.attr("href")?.takeIf(String::isNotBlank)?.let { href -> + val coverPath = normalizePath(packageDirectory, href.substringBefore('#').substringBefore('?')) + manifest.values.firstOrNull { it.href == coverPath } + } + setEpub2Cover(output, metadataCover ?: guideCover, book) } - val navItem = manifest.values.singleOrNull { "nav" in it.properties } - ?: error("The EPUB manifest must identify one navigation document.") - require(navItem.mediaType == "application/xhtml+xml") { "The EPUB navigation document must be XHTML." } - val toc = parseHtmlToc(output, navItem.href, spine) - require(toc.isNotEmpty()) { "The EPUB navigation document has no table of contents." } + val toc = if (packageVersion == 3) { + val navItem = manifest.values.singleOrNull { "nav" in it.properties } + ?: error("The EPUB 3 manifest must identify one navigation document.") + require(navItem.mediaType == "application/xhtml+xml") { + "The EPUB navigation document must be XHTML." + } + parseHtmlToc(output, navItem.href, spine).also { + require(it.isNotEmpty()) { "The EPUB navigation document has no table of contents." } + } + } else { + val ncxId = spineElement.attr("toc") + require(ncxId.isNotBlank()) { "The EPUB 2 spine does not identify an NCX document." } + val ncxItem = manifest[ncxId] + ?: error("The EPUB 2 spine references an NCX document that is not in the manifest.") + require(ncxItem.mediaType == "application/x-dtbncx+xml") { + "The EPUB 2 table of contents must be an NCX document." + } + parseNcxToc(output, ncxItem.href, spine).also { + require(it.isNotEmpty()) { "The EPUB NCX document has no table of contents." } + } + } val direction = spineElement.attr("page-progression-direction").lowercase(Locale.ROOT) .takeIf { it == "rtl" || it == "ltr" } ?: "ltr" @@ -124,8 +161,8 @@ object EpubParser { toc = toc, readingDirection = direction, fixedLayout = fixedLayout, - defaultPageWidth = packageViewport.first, - defaultPageHeight = packageViewport.second, + defaultPageWidth = pageSize.first, + defaultPageHeight = pageSize.second, ) } @@ -158,6 +195,31 @@ object EpubParser { } } + private fun setEpub2Cover(root: File, item: ManifestItem?, book: LibraryBook) { + if (item == null) return + val candidate = safeFile(root, item.href) + if (!candidate.isFile) return + if (item.mediaType.startsWith("image/")) { + book.coverPath = candidate.absolutePath + return + } + if (!isContentDocument(item.mediaType)) return + + val document = parseXml(candidate) + val image = document.getAllElements().firstOrNull { element -> + element.localName() == "img" || element.localName() == "image" + } ?: return + val source = image.attr("src").ifBlank { + image.attributes().asList().firstOrNull { attribute -> + attribute.key.substringAfter(':').equals("href", ignoreCase = true) + }?.value.orEmpty() + } + if (source.isBlank()) return + val directory = item.href.substringBeforeLast('/', "") + val imagePath = normalizePath(directory, source.substringBefore('#').substringBefore('?')) + safeFile(root, imagePath).takeIf(File::isFile)?.let { book.coverPath = it.absolutePath } + } + private fun parseHtmlToc(root: File, path: String, spine: List): List { val file = safeFile(root, path) if (!file.isFile) return emptyList() @@ -186,6 +248,35 @@ object EpubParser { return result } + private fun parseNcxToc(root: File, path: String, spine: List): List { + val file = safeFile(root, path) + require(file.isFile) { "The EPUB NCX document is missing." } + val document = parseXml(file) + val navMap = document.allByLocalName("navmap").firstOrNull() + ?: error("The EPUB NCX document has no navigation map.") + val result = mutableListOf() + val directory = path.substringBeforeLast('/', "") + + fun walk(parent: Element, depth: Int) { + 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.") + val label = navLabel.allByLocalName("text").firstOrNull()?.text()?.trim().orEmpty() + require(label.isNotBlank()) { "An EPUB NCX navigation point has an empty label." } + val content = point.children().firstOrNull { it.localName() == "content" } + ?: error("An EPUB NCX navigation point has no content target.") + val href = content.attr("src") + require(href.isNotBlank()) { "An EPUB NCX navigation point has an empty content target." } + val resolved = resolveReference(directory, href) + result += TocEntry(label, resolved, depth, spineIndex(spine, resolved)) + walk(point, depth + 1) + } + } + + walk(navMap, 0) + return result + } + private fun spineIndex(spine: List, reference: String): Int { val path = reference.substringBefore('#') return spine.indexOfFirst { it.href == path }.also { index -> @@ -344,7 +435,10 @@ object EpubHtmlBuilder { val value = attribute.value when { key.startsWith("on") -> element.removeAttr(attribute.key) - key == "style" -> element.attr(attribute.key, rewriteCssUrls(value, chapter.directory, publication.rootDirectory)) + key == "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) @@ -1161,7 +1255,7 @@ ${if (publication.fixedLayout) FIXED_READER_SCRIPT else REFLOWABLE_READER_SCRIPT val body = source.substring(open + 1, close) val trimmed = prelude.trimStart() fun appendRule(rulePrelude: String, declarations: String) { - out.append(transformSelector(rulePrelude)).append('{').append(declarations).append('}') + out.append(transformSelector(rulePrelude)).append('{').append(rewritePageBreaks(declarations)).append('}') } when { trimmed.startsWith("@media", true) || trimmed.startsWith("@supports", true) || trimmed.startsWith("@layer", true) -> @@ -1183,6 +1277,33 @@ ${if (publication.fixedLayout) FIXED_READER_SCRIPT else REFLOWABLE_READER_SCRIPT 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) { diff --git a/scripts/verify-running.ps1 b/scripts/verify-running.ps1 index 354b7de..9da7bac 100644 --- a/scripts/verify-running.ps1 +++ b/scripts/verify-running.ps1 @@ -51,10 +51,21 @@ try { } if (-not $ready) { throw "The fixture did not reach a rendered first page." } - & $adb -s $serial shell screencap -p /sdcard/alexandria-odyssey.png - $screenshot = Join-Path $OutputDirectory "odyssey-page-1.png" - & $adb -s $serial pull /sdcard/alexandria-odyssey.png $screenshot | Out-Null - if ((Get-Item $screenshot).Length -lt 100000) { throw "The verification screenshot is unexpectedly small." } + & $adb -s $serial shell screencap -p /sdcard/alexandria-reader.png + $fixtureName = [System.IO.Path]::GetFileNameWithoutExtension($Fixture) + $screenshot = Join-Path $OutputDirectory "$fixtureName-page-1.png" + & $adb -s $serial pull /sdcard/alexandria-reader.png $screenshot | Out-Null + if ((Get-Item $screenshot).Length -lt 10000) { throw "The verification screenshot is incomplete." } + Add-Type -AssemblyName System.Drawing + $image = [System.Drawing.Image]::FromFile((Resolve-Path $screenshot)) + try { + if ($image.Width -lt 1000 -or $image.Height -lt 1000) { + throw "The verification screenshot has unexpected dimensions: $($image.Width)x$($image.Height)." + } + } + finally { + $image.Dispose() + } $fatal = & $adb -s $serial logcat -d -v brief | Select-String "FATAL EXCEPTION|Process: com.alexandria.reader" if ($fatal) { throw "Alexandria crashed during verification.`n$($fatal -join "`n")" } diff --git a/tests/fixtures/epub2-src/META-INF/container.xml b/tests/fixtures/epub2-src/META-INF/container.xml new file mode 100644 index 0000000..fe5cbeb --- /dev/null +++ b/tests/fixtures/epub2-src/META-INF/container.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/fixtures/epub2-src/OEBPS/chapter1.xhtml b/tests/fixtures/epub2-src/OEBPS/chapter1.xhtml new file mode 100644 index 0000000..890fb29 --- /dev/null +++ b/tests/fixtures/epub2-src/OEBPS/chapter1.xhtml @@ -0,0 +1,13 @@ + + + + EPUB 2 Start + + + +

EPUB 2 Start

+

This publication verifies OPF 2 metadata, reading order, and NCX navigation.

+

Nested Section

+

This section uses EPUB 2 page-break properties and links to the second chapter.

+ + diff --git a/tests/fixtures/epub2-src/OEBPS/chapter2.xhtml b/tests/fixtures/epub2-src/OEBPS/chapter2.xhtml new file mode 100644 index 0000000..9aeef34 --- /dev/null +++ b/tests/fixtures/epub2-src/OEBPS/chapter2.xhtml @@ -0,0 +1,11 @@ + + + + Second Chapter + + + +

Second Chapter

+

The EPUB 2 NCX points to this chapter.

+ + diff --git a/tests/fixtures/epub2-src/OEBPS/cover.png b/tests/fixtures/epub2-src/OEBPS/cover.png new file mode 100644 index 0000000..1dbf082 Binary files /dev/null and b/tests/fixtures/epub2-src/OEBPS/cover.png differ diff --git a/tests/fixtures/epub2-src/OEBPS/package.opf b/tests/fixtures/epub2-src/OEBPS/package.opf new file mode 100644 index 0000000..2b6f2ec --- /dev/null +++ b/tests/fixtures/epub2-src/OEBPS/package.opf @@ -0,0 +1,21 @@ + + + + urn:uuid:alexandria-epub2-verification + EPUB 2 Verification + Alexandria Tests + en + + + + + + + + + + + + + + diff --git a/tests/fixtures/epub2-src/OEBPS/style.css b/tests/fixtures/epub2-src/OEBPS/style.css new file mode 100644 index 0000000..69737b3 --- /dev/null +++ b/tests/fixtures/epub2-src/OEBPS/style.css @@ -0,0 +1,5 @@ +html, body { margin: 0; padding: 0; } +body { font-family: serif; font-size: 24px; line-height: 1.45; } +h1 { font-size: 2em; } +h2 { page-break-before: always; } +.keep { page-break-inside: avoid; } diff --git a/tests/fixtures/epub2-src/OEBPS/toc.ncx b/tests/fixtures/epub2-src/OEBPS/toc.ncx new file mode 100644 index 0000000..13a904c --- /dev/null +++ b/tests/fixtures/epub2-src/OEBPS/toc.ncx @@ -0,0 +1,24 @@ + + + + + + + + + EPUB 2 Verification + + + EPUB 2 Start + + + Nested Section + + + + + Second Chapter + + + + diff --git a/tests/fixtures/epub2-src/mimetype b/tests/fixtures/epub2-src/mimetype new file mode 100644 index 0000000..57ef03f --- /dev/null +++ b/tests/fixtures/epub2-src/mimetype @@ -0,0 +1 @@ +application/epub+zip \ No newline at end of file diff --git a/tests/fixtures/epub2.epub b/tests/fixtures/epub2.epub new file mode 100644 index 0000000..d215f7f Binary files /dev/null and b/tests/fixtures/epub2.epub differ