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)
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('/', "")
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)
}
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"
toc = toc,
readingDirection = direction,
fixedLayout = fixedLayout,
- defaultPageWidth = packageViewport.first,
- defaultPageHeight = packageViewport.second,
+ defaultPageWidth = pageSize.first,
+ defaultPageHeight = pageSize.second,
)
}
}
}
+ 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()
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 ->
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)
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) ->
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) {