]> git.otsuka.systems Git - alexandria/commitdiff
restore epub 2 support
authorCameron Otsuka <cameron@otsuka.haus>
Tue, 11 Aug 2026 19:25:34 +0000 (12:25 -0700)
committerCameron Otsuka <cameron@otsuka.haus>
Tue, 11 Aug 2026 19:25:34 +0000 (12:25 -0700)
12 files changed:
README.md
app/src/main/java/com/alexandria/reader/EpubParser.kt
scripts/verify-running.ps1
tests/fixtures/epub2-src/META-INF/container.xml [new file with mode: 0644]
tests/fixtures/epub2-src/OEBPS/chapter1.xhtml [new file with mode: 0644]
tests/fixtures/epub2-src/OEBPS/chapter2.xhtml [new file with mode: 0644]
tests/fixtures/epub2-src/OEBPS/cover.png [new file with mode: 0644]
tests/fixtures/epub2-src/OEBPS/package.opf [new file with mode: 0644]
tests/fixtures/epub2-src/OEBPS/style.css [new file with mode: 0644]
tests/fixtures/epub2-src/OEBPS/toc.ncx [new file with mode: 0644]
tests/fixtures/epub2-src/mimetype [new file with mode: 0644]
tests/fixtures/epub2.epub [new file with mode: 0644]

index 8c0434c9d222dc6cdd280004ac040ce701fdbed9..f0115ed8a3fdfaeeeb1cc07772fbbdd1ab0e3d2d 100644 (file)
--- 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
index e78c001c84baad5317d9512c838983e2ee7177a6..f1c7601d73982392ed99e08171a67606de506c29 100644 (file)
@@ -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<String>)
     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<SpineItem>): List<TocEntry> {
         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<SpineItem>): List<TocEntry> {
+        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<TocEntry>()
+        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<SpineItem>, 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) {
index 354b7de7fe6b21f6588db7454ed2b959dc64e251..9da7bac4b28f7e319df670a202558c63a9aab554 100644 (file)
@@ -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 (file)
index 0000000..fe5cbeb
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
+  <rootfiles>
+    <rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml"/>
+  </rootfiles>
+</container>
diff --git a/tests/fixtures/epub2-src/OEBPS/chapter1.xhtml b/tests/fixtures/epub2-src/OEBPS/chapter1.xhtml
new file mode 100644 (file)
index 0000000..890fb29
--- /dev/null
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
+<head>
+  <title>EPUB 2 Start</title>
+  <link rel="stylesheet" type="text/css" href="style.css"/>
+</head>
+<body>
+  <h1>EPUB 2 Start</h1>
+  <p>This publication verifies OPF 2 metadata, reading order, and NCX navigation.</p>
+  <h2 id="nested-section">Nested Section</h2>
+  <p class="keep">This section uses EPUB 2 page-break properties and links to the <a href="chapter2.xhtml#target">second chapter</a>.</p>
+</body>
+</html>
diff --git a/tests/fixtures/epub2-src/OEBPS/chapter2.xhtml b/tests/fixtures/epub2-src/OEBPS/chapter2.xhtml
new file mode 100644 (file)
index 0000000..9aeef34
--- /dev/null
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
+<head>
+  <title>Second Chapter</title>
+  <link rel="stylesheet" type="text/css" href="style.css"/>
+</head>
+<body>
+  <h1 id="target">Second Chapter</h1>
+  <p>The EPUB 2 NCX points to this chapter.</p>
+</body>
+</html>
diff --git a/tests/fixtures/epub2-src/OEBPS/cover.png b/tests/fixtures/epub2-src/OEBPS/cover.png
new file mode 100644 (file)
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 (file)
index 0000000..2b6f2ec
--- /dev/null
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<package version="2.0" unique-identifier="book-id" xmlns="http://www.idpf.org/2007/opf">
+  <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
+    <dc:identifier id="book-id">urn:uuid:alexandria-epub2-verification</dc:identifier>
+    <dc:title>EPUB 2 Verification</dc:title>
+    <dc:creator>Alexandria Tests</dc:creator>
+    <dc:language>en</dc:language>
+    <meta name="cover" content="cover-image"/>
+  </metadata>
+  <manifest>
+    <item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
+    <item id="cover-image" href="cover.png" media-type="image/png"/>
+    <item id="css" href="style.css" media-type="text/css"/>
+    <item id="chapter-1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
+    <item id="chapter-2" href="chapter2.xhtml" media-type="application/xhtml+xml"/>
+  </manifest>
+  <spine toc="ncx">
+    <itemref idref="chapter-1"/>
+    <itemref idref="chapter-2"/>
+  </spine>
+</package>
diff --git a/tests/fixtures/epub2-src/OEBPS/style.css b/tests/fixtures/epub2-src/OEBPS/style.css
new file mode 100644 (file)
index 0000000..69737b3
--- /dev/null
@@ -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 (file)
index 0000000..13a904c
--- /dev/null
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ncx version="2005-1" xmlns="http://www.daisy.org/z3986/2005/ncx/">
+  <head>
+    <meta name="dtb:uid" content="urn:uuid:alexandria-epub2-verification"/>
+    <meta name="dtb:depth" content="2"/>
+    <meta name="dtb:totalPageCount" content="0"/>
+    <meta name="dtb:maxPageNumber" content="0"/>
+  </head>
+  <docTitle><text>EPUB 2 Verification</text></docTitle>
+  <navMap>
+    <navPoint id="chapter-1" playOrder="1">
+      <navLabel><text>EPUB 2 Start</text></navLabel>
+      <content src="chapter1.xhtml"/>
+      <navPoint id="section-1" playOrder="2">
+        <navLabel><text>Nested Section</text></navLabel>
+        <content src="chapter1.xhtml#nested-section"/>
+      </navPoint>
+    </navPoint>
+    <navPoint id="chapter-2" playOrder="3">
+      <navLabel><text>Second Chapter</text></navLabel>
+      <content src="chapter2.xhtml"/>
+    </navPoint>
+  </navMap>
+</ncx>
diff --git a/tests/fixtures/epub2-src/mimetype b/tests/fixtures/epub2-src/mimetype
new file mode 100644 (file)
index 0000000..57ef03f
--- /dev/null
@@ -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 (file)
index 0000000..d215f7f
Binary files /dev/null and b/tests/fixtures/epub2.epub differ