import java.util.zip.ZipFile
import kotlin.math.roundToInt
-/** EPUB 2/3 package reader. It keeps the original resources intact for standards-based rendering. */
+/** EPUB 3 package reader. It keeps the original resources intact for standards-based rendering. */
object EpubParser {
- private data class ManifestItem(val id: String, val href: String, val mediaType: String, val properties: String)
+ private data class ManifestItem(val href: String, val mediaType: String, val properties: Set<String>)
+ private data class ParsedSpineItem(val item: SpineItem, val linear: Boolean, val fixedLayout: Boolean)
- fun parse(source: File, output: File, book: LibraryBook, forceExtract: Boolean): EpubPublication {
- if (forceExtract || !File(output, ".complete").isFile) {
+ fun parse(source: File, output: File, book: LibraryBook): EpubPublication {
+ if (!File(output, ".complete").isFile) {
output.deleteRecursively()
output.mkdirs()
extract(source, output)
val containerFile = File(output, "META-INF/container.xml")
require(containerFile.isFile) { "This file has no EPUB container document." }
- val container = parseXml(containerFile)
- val rootfile = container.allByLocalName("rootfile").firstOrNull()
+ val rootfile = parseXml(containerFile).allByLocalName("rootfile").firstOrNull()
?: error("The EPUB container does not identify a package document.")
val packagePath = normalizePath("", rootfile.attr("full-path"))
require(packagePath.isNotBlank()) { "The EPUB package path is empty." }
val packageFile = safeFile(output, packagePath)
require(packageFile.isFile) { "The EPUB package document is missing." }
val opf = parseXml(packageFile)
+ val packageElement = opf.allByLocalName("package").firstOrNull()
+ ?: error("The EPUB package document has no package element.")
+ require(packageElement.attr("version").substringBefore('.').toIntOrNull() == 3) {
+ "Only EPUB 3 publications are supported."
+ }
val packageDirectory = packagePath.substringBeforeLast('/', "")
val metadata = opf.allByLocalName("metadata").firstOrNull()
- metadata?.firstText("title")?.takeIf(String::isNotBlank)?.let { book.title = it }
- metadata?.allByLocalName("creator")?.map { it.text().trim() }?.filter(String::isNotBlank)
- ?.distinct()?.takeIf(List<String>::isNotEmpty)?.joinToString(", ")?.let { book.author = it }
- metadata?.firstText("language")?.let { book.language = it }
- metadata?.firstText("publisher")?.let { book.publisher = it }
- metadata?.firstText("description")?.let { book.description = it }
- val packageViewport = metadata?.allByLocalName("meta")?.firstNotNullOfOrNull { meta ->
- val name = meta.attr("name").lowercase(Locale.ROOT)
- val property = meta.attr("property").lowercase(Locale.ROOT)
- if (name != "original-resolution" && property != "rendition:viewport") return@firstNotNullOfOrNull null
- val value = meta.text().ifBlank { meta.attr("content") }
- val dimensions = Regex("(?:width\\s*=\\s*)?([0-9]+)\\s*(?:x|[,;]\\s*height\\s*=)\\s*([0-9]+)", RegexOption.IGNORE_CASE).find(value)
- ?: return@firstNotNullOfOrNull null
+ ?: error("The EPUB package document has no metadata element.")
+ metadata.firstText("title")?.takeIf(String::isNotBlank)?.let { book.title = it }
+ metadata.allByLocalName("creator").map { it.text().trim() }.filter(String::isNotBlank)
+ .distinct().takeIf(List<String>::isNotEmpty)?.joinToString(", ")?.let { book.author = it }
+ metadata.firstText("language")?.let { book.language = it }
+ metadata.firstText("publisher")?.let { book.publisher = it }
+ metadata.firstText("description")?.let { book.description = it }
+ val packageViewport = metadata.allByLocalName("meta").firstNotNullOfOrNull { meta ->
+ if (!meta.attr("property").equals("rendition:viewport", ignoreCase = true)) {
+ return@firstNotNullOfOrNull null
+ }
+ val dimensions = Regex(
+ "(?:width\\s*=\\s*)?([0-9]+)\\s*(?:x|[,;]\\s*height\\s*=)\\s*([0-9]+)",
+ RegexOption.IGNORE_CASE,
+ ).find(meta.text()) ?: return@firstNotNullOfOrNull null
val width = dimensions.groupValues[1].toIntOrNull() ?: return@firstNotNullOfOrNull null
val height = dimensions.groupValues[2].toIntOrNull() ?: return@firstNotNullOfOrNull null
(width to height).takeIf { width > 0 && height > 0 }
} ?: (1200 to 1600)
- val appleDisplayOptions = File(output, "META-INF/com.apple.ibooks.display-options.xml")
- val legacyFixedLayout = appleDisplayOptions.takeIf(File::isFile)?.let { file ->
- runCatching {
- parseXml(file).getAllElements().any { element ->
- element.attr("name").equals("fixed-layout", true) && element.text().trim().equals("true", true)
- }
- }.getOrDefault(false)
- } == true
- val packageFixedLayout = legacyFixedLayout || metadata?.allByLocalName("meta")?.any { meta ->
- val property = meta.attr("property").lowercase(Locale.ROOT)
- val name = meta.attr("name").lowercase(Locale.ROOT)
- val value = meta.text().ifBlank { meta.attr("content") }.trim().lowercase(Locale.ROOT)
- (property == "rendition:layout" && value == "pre-paginated") ||
- (name in setOf("fixed-layout", "fixed_layout") && value in setOf("true", "yes", "pre-paginated")) ||
- name == "original-resolution"
- } == true
+ val packageFixedLayout = metadata.allByLocalName("meta").any { meta ->
+ meta.attr("property").equals("rendition:layout", ignoreCase = true) &&
+ meta.text().trim().equals("pre-paginated", ignoreCase = true)
+ }
val manifest = opf.allByLocalName("manifest").firstOrNull()?.children()
?.filter { it.localName() == "item" }
- ?.mapNotNull { element ->
+ ?.associate { element ->
val id = element.attr("id")
val href = element.attr("href")
- if (id.isBlank() || href.isBlank()) null else ManifestItem(
- id,
- normalizePath(packageDirectory, href.substringBefore('#').substringBefore('?')),
- element.attr("media-type").lowercase(Locale.ROOT),
- element.attr("properties"),
+ require(id.isNotBlank() && href.isNotBlank()) { "Every EPUB manifest item must have an ID and an href." }
+ id to ManifestItem(
+ href = normalizePath(packageDirectory, href.substringBefore('#').substringBefore('?')),
+ mediaType = element.attr("media-type").lowercase(Locale.ROOT),
+ properties = element.attr("properties").lowercase(Locale.ROOT).split(Regex("\\s+")).filter(String::isNotBlank).toSet(),
)
- }?.associateBy { it.id }.orEmpty()
+ }.orEmpty()
+ require(manifest.isNotEmpty()) { "The EPUB manifest is empty." }
val spineElement = opf.allByLocalName("spine").firstOrNull()
- val spine = spineElement?.children()?.filter { it.localName() == "itemref" }?.mapNotNull { reference ->
- val item = manifest[reference.attr("idref")] ?: return@mapNotNull null
- if (!isHtml(item.mediaType, item.href)) return@mapNotNull null
+ ?: error("The EPUB package document has no spine element.")
+ val parsedSpine = spineElement.children().filter { it.localName() == "itemref" }.map { reference ->
+ val item = manifest[reference.attr("idref")]
+ ?: error("The EPUB spine references an item that is not in the manifest.")
+ require(isContentDocument(item.mediaType)) { "The EPUB spine contains a non-content document." }
val chapterFile = safeFile(output, item.href)
- if (!chapterFile.isFile) return@mapNotNull null
- val layoutProperties = "${item.properties} ${reference.attr("properties")}".lowercase(Locale.ROOT)
- SpineItem(
- href = item.href,
- mediaType = item.mediaType,
- title = chapterTitle(chapterFile),
+ require(chapterFile.isFile) { "An EPUB spine document is missing." }
+ val referenceProperties = reference.attr("properties").lowercase(Locale.ROOT)
+ .split(Regex("\\s+")).filter(String::isNotBlank).toSet()
+ ParsedSpineItem(
+ item = SpineItem(item.href, chapterTitle(chapterFile)),
linear = !reference.attr("linear").equals("no", ignoreCase = true),
- fixedLayout = packageFixedLayout || "rendition:layout-pre-paginated" in layoutProperties ||
- item.mediaType == "image/svg+xml" || looksFixedLayout(chapterFile),
+ fixedLayout = "rendition:layout-pre-paginated" in item.properties ||
+ "rendition:layout-pre-paginated" in referenceProperties,
)
- }.orEmpty().ifEmpty {
- manifest.values.filter { isHtml(it.mediaType, it.href) && safeFile(output, it.href).isFile }
- .map {
- SpineItem(
- it.href,
- it.mediaType,
- chapterTitle(safeFile(output, it.href)),
- fixedLayout = packageFixedLayout || "rendition:layout-pre-paginated" in it.properties.lowercase(Locale.ROOT) ||
- it.mediaType == "image/svg+xml" || looksFixedLayout(safeFile(output, it.href)),
- )
- }
}
- require(spine.isNotEmpty()) { "The EPUB reading order is empty." }
+ require(parsedSpine.isNotEmpty()) { "The EPUB reading order is empty." }
+ val spine = parsedSpine.map(ParsedSpineItem::item)
- val coverId = metadata?.allByLocalName("meta")?.firstOrNull {
- it.attr("name").equals("cover", true)
- }?.attr("content")
- val cover = manifest.values.firstOrNull { "cover-image" in it.properties.split(Regex("\\s+")) }
- ?: coverId?.let(manifest::get)
- ?: opf.allByLocalName("reference").firstOrNull { "cover" in it.attr("type").lowercase() }
- ?.attr("href")?.takeIf(String::isNotBlank)?.let { href ->
- val path = normalizePath(packageDirectory, href.substringBefore('#'))
- manifest.values.firstOrNull { it.href == path }
- }
- val coverItem = cover ?: manifest.values.firstOrNull { it.properties.contains("cover", true) }
- coverItem?.let { item ->
+ manifest.values.firstOrNull { "cover-image" in it.properties }?.let { item ->
val candidate = safeFile(output, item.href)
- when {
- candidate.isFile && item.mediaType.startsWith("image/") -> book.coverPath = candidate.absolutePath
- candidate.isFile && isHtml(item.mediaType, item.href) -> {
- val coverDocument = runCatching { parseXml(candidate) }.getOrNull()
- val image = coverDocument?.getAllElements()?.firstOrNull {
- it.tagName().substringAfter(':').lowercase() in setOf("img", "image")
- }
- val sourceValue = image?.attr("src").orEmpty().ifBlank {
- image?.attributes()?.asList()?.firstOrNull { it.key.endsWith(":href") }?.value.orEmpty()
- }
- if (sourceValue.isNotBlank()) {
- val imagePath = normalizePath(item.href.substringBeforeLast('/', ""), sourceValue.substringBefore('#'))
- safeFile(output, imagePath).takeIf(File::isFile)?.let { book.coverPath = it.absolutePath }
- }
- }
- }
+ if (candidate.isFile && item.mediaType.startsWith("image/")) book.coverPath = candidate.absolutePath
}
- val navItem = manifest.values.firstOrNull { "nav" in it.properties.split(Regex("\\s+")) }
- val ncxId = spineElement?.attr("toc")
- val ncxItem = ncxId?.let(manifest::get)
- ?: manifest.values.firstOrNull { it.mediaType == "application/x-dtbncx+xml" }
- var toc = navItem?.let { parseHtmlToc(output, it.href, spine) }.orEmpty()
- if (toc.isEmpty()) toc = ncxItem?.let { parseNcxToc(output, it.href, spine) }.orEmpty()
- if (toc.isEmpty()) toc = spine.mapIndexed { index, item ->
- TocEntry(item.title.ifBlank { "Section ${index + 1}" }, item.href, 0, index)
- }
+ val navItem = manifest.values.singleOrNull { "nav" in it.properties }
+ ?: error("The EPUB manifest must identify one navigation document.")
+ require(navItem.mediaType == "application/xhtml+xml") { "The EPUB navigation document must be XHTML." }
+ val toc = parseHtmlToc(output, navItem.href, spine)
+ require(toc.isNotEmpty()) { "The EPUB navigation document has no table of contents." }
- val direction = spineElement?.attr("page-progression-direction")?.lowercase(Locale.ROOT)
- ?.takeIf { it == "rtl" || it == "ltr" } ?: "ltr"
- val stylesheets = manifest.values.filter { it.mediaType == "text/css" }.map { it.href }
+ val direction = spineElement.attr("page-progression-direction").lowercase(Locale.ROOT)
+ .takeIf { it == "rtl" || it == "ltr" } ?: "ltr"
+ val linearSpine = parsedSpine.filter(ParsedSpineItem::linear)
+ val fixedLayout = packageFixedLayout || linearSpine.isNotEmpty() && linearSpine.all(ParsedSpineItem::fixedLayout)
return EpubPublication(
- book, output, packagePath, spine, toc, direction, stylesheets,
- packageFixedLayout || spine.filter(SpineItem::linear).let { it.isNotEmpty() && it.all(SpineItem::fixedLayout) }, packageViewport.first, packageViewport.second,
+ book = book,
+ rootDirectory = output,
+ spine = spine,
+ toc = toc,
+ readingDirection = direction,
+ fixedLayout = fixedLayout,
+ defaultPageWidth = packageViewport.first,
+ defaultPageHeight = packageViewport.second,
)
}
attribute.key.substringAfter(':').equals("type", true) &&
"toc" in attribute.value.lowercase().split(Regex("\\s+"))
}
- } ?: document.allByLocalName("nav").firstOrNull() ?: return emptyList()
+ } ?: return emptyList()
val result = mutableListOf<TocEntry>()
val navDirectory = path.substringBeforeLast('/', "")
return result
}
- private fun parseNcxToc(root: File, path: String, spine: List<SpineItem>): List<TocEntry> {
- val file = safeFile(root, path)
- if (!file.isFile) return emptyList()
- val document = parseXml(file)
- val result = mutableListOf<TocEntry>()
- val base = path.substringBeforeLast('/', "")
- fun walk(parent: Element, depth: Int) {
- parent.children().filter { it.localName() == "navpoint" }.forEach { point ->
- val label = point.allByLocalName("navlabel").firstOrNull()?.allByLocalName("text")?.firstOrNull()?.text()
- ?.trim().orEmpty().ifBlank { "Untitled section" }
- val href = point.allByLocalName("content").firstOrNull()?.attr("src").orEmpty()
- if (href.isNotBlank()) {
- val resolved = resolveReference(base, href)
- result += TocEntry(label, resolved, depth, spineIndex(spine, resolved))
- }
- walk(point, depth + 1)
- }
- }
- document.allByLocalName("navmap").firstOrNull()?.let { walk(it, 0) }
- return result
- }
-
private fun spineIndex(spine: List<SpineItem>, reference: String): Int {
val path = reference.substringBefore('#')
- return spine.indexOfFirst { it.href == path }.coerceAtLeast(0)
- }
-
- private fun looksFixedLayout(file: File): Boolean = runCatching {
- val document = parseXml(file)
- document.getAllElements().any { element ->
- val name = element.attr("name").lowercase(Locale.ROOT)
- val content = element.attr("content")
- (element.localName() == "meta" && name == "viewport" &&
- Regex("(?:^|[,;\\s])width\\s*=\\s*[0-9]", RegexOption.IGNORE_CASE).containsMatchIn(content) &&
- Regex("(?:^|[,;\\s])height\\s*=\\s*[0-9]", RegexOption.IGNORE_CASE).containsMatchIn(content)) ||
- (element.localName() == "svg" && (element.hasAttr("viewBox") || element.hasAttr("viewbox")))
+ return spine.indexOfFirst { it.href == path }.also { index ->
+ require(index >= 0) { "The EPUB table of contents points outside the reading order: $reference" }
}
- }.getOrDefault(false)
+ }
private fun chapterTitle(file: File): String = runCatching {
val document = parseXml(file)
Jsoup.parse(stream, null, file.toURI().toString(), Parser.xmlParser())
}
- private fun isHtml(mediaType: String, path: String): Boolean =
- mediaType in setOf("application/xhtml+xml", "text/html", "image/svg+xml") ||
- path.endsWith(".xhtml", true) || path.endsWith(".html", true) || path.endsWith(".htm", true)
+ private fun isContentDocument(mediaType: String): Boolean =
+ mediaType == "application/xhtml+xml" || mediaType == "image/svg+xml"
private fun Element.localName(): String = tagName().substringAfter(':').lowercase(Locale.ROOT)
private fun Element.allByLocalName(name: String): List<Element> =
)
fun write(publication: EpubPublication, output: File): File {
- val chapters = publication.spine.mapIndexedNotNull { index, item ->
+ val chapters = publication.spine.mapIndexed { index, item ->
val file = EpubParser.safeFile(publication.rootDirectory, item.href)
- if (!file.isFile) return@mapIndexedNotNull null
- runCatching {
- val document = file.inputStream().use { Jsoup.parse(it, null, file.toURI().toString(), Parser.xmlParser()) }
- // The spine is parsed as XML, but it is embedded in an HTML document.
- // HTML serialization expands empty non-void tags such as <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 = fixedViewport(document, body, publication.defaultPageWidth, publication.defaultPageHeight)
- ChapterDocument(
- index, item, document, body, item.href.substringBeforeLast('/', ""), viewport.first, viewport.second,
- if (publication.fixedLayout) {
- runCatching { fixedLineBoxes(document, item.href.substringBeforeLast('/', ""), publication.rootDirectory) }
- .onFailure { android.util.Log.w("AlexandriaRenderer", "Could not derive fixed-page line boxes for ${item.href}", it) }
- .getOrDefault("")
- } else "",
- )
- }.getOrNull()
+ require(file.isFile) { "An EPUB spine document is missing." }
+ val document = file.inputStream().use { Jsoup.parse(it, null, file.toURI().toString(), Parser.xmlParser()) }
+ // The spine is parsed as XML, but it is embedded in an HTML document.
+ // HTML serialization expands empty non-void tags such as <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,
+ if (publication.fixedLayout) {
+ runCatching { fixedLineBoxes(document, item.href.substringBeforeLast('/', ""), publication.rootDirectory) }
+ .onFailure { android.util.Log.w("AlexandriaRenderer", "Could not derive fixed-page line boxes for ${item.href}", it) }
+ .getOrDefault("")
+ } else "",
+ )
}
- require(chapters.isNotEmpty()) { "No readable spine document was found." }
-
val targetIds = mutableMapOf<String, String>()
chapters.forEach { chapter ->
targetIds[chapter.item.href] = "alex-chapter-${chapter.index}"
}
}
}
+ publication.toc.forEach { entry ->
+ require(entry.href in targetIds) { "The EPUB table of contents has a missing target: ${entry.href}" }
+ }
val cssPaths = linkedSetOf<String>()
- cssPaths.addAll(publication.stylesheets)
val inlineStyles = mutableListOf<Pair<String, String>>()
chapters.forEach { chapter ->
chapter.document.getAllElements().filter { it.tagName().substringAfter(':').equals("link", true) }.forEach { link ->
element.attr("data-external-href", value)
element.attr("href", "#")
} else {
- val reference = if (value.startsWith('#')) chapter.item.href + value
- else EpubParser.resolveReference(chapter.directory, value)
+ val reference = when {
+ value == "#" -> chapter.item.href
+ value.startsWith('#') -> chapter.item.href + value
+ else -> EpubParser.resolveReference(chapter.directory, value)
+ }
val target = targetIds[reference]
- ?: targetIds[reference.substringBefore('#')]
- ?: "alex-chapter-${publication.spine.indexOfFirst { it.href == reference.substringBefore('#') }.coerceAtLeast(0)}"
+ ?: error("An EPUB link points outside the reading order: $reference")
element.attr("href", "#$target")
element.attr("data-alex-ref", reference)
}
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("\" data-href=\"")
- .append(escapeAttribute(chapter.item.href)).append("\" data-page-width=\"")
- .append(chapter.viewportWidth).append("\" data-page-height=\"").append(chapter.viewportHeight)
- .append("\" data-line-boxes=\"").append(chapter.lineBoxes).append('"')
+ .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("\" data-line-boxes=\"").append(chapter.lineBoxes).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("><span class=\"alex-chapter-start\" aria-hidden=\"true\"></span>")
+ append('>')
+ if (!publication.fixedLayout) append("<span class=\"alex-chapter-start\" aria-hidden=\"true\"></span>")
append(chapter.body.html()).append("</section>\n")
}
}
val boxes = linkedSetOf<Pair<Int, Int>>()
document.getAllElements().forEach { element ->
- val explicitTop = number(element.attr("data-line-top"))
- val explicitBottom = number(element.attr("data-line-bottom"))
- if (explicitTop != null && explicitBottom != null && explicitBottom > explicitTop) {
- boxes += explicitTop.roundToInt() to explicitBottom.roundToInt()
- return@forEach
- }
val localName = element.tagName().substringAfter(':').lowercase(Locale.ROOT)
if (localName in setOf("text", "tspan")) {
val baseline = number(element.attr("y")) ?: return@forEach
body.alex-fixed { box-sizing:border-box; width:100vw; height:100vh; padding:0 !important; overflow:hidden !important; touch-action:none; }
body.alex-fixed .alex-chapter { display:none !important; position:absolute !important; left:0 !important; top:0 !important; max-width:none !important; max-height:none !important; margin:0 !important; padding:0 !important; overflow:visible !important; transform-origin:0 0; box-sizing:border-box; background-color:var(--alex-paper); }
body.alex-fixed .alex-chapter.alex-current-page, body.alex-fixed .alex-chapter.alex-stream-next { display:block !important; }
-body.alex-fixed .alex-chapter-start { display:none !important; }
#alex-cut-mask { display:none; position:fixed; z-index:2147483000; left:0; right:0; bottom:0; height:0; background:var(--alex-paper); pointer-events:none; }
-body.alex-paged .alex-chapter { page-break-before:always; break-before:column !important; }
-body.alex-paged .alex-chapter:first-of-type { page-break-before:auto; break-before:auto !important; }
-body.alex-continuous .alex-chapter { break-before:auto !important; page-break-before:auto !important; margin-bottom:3em; }
+body.alex-paged .alex-chapter { break-before:column !important; }
+body.alex-paged .alex-chapter:first-of-type { break-before:auto !important; }
+body.alex-continuous .alex-chapter { break-before:auto !important; margin-bottom:3em; }
.alex-chapter-start { display:block; position:absolute; left:0; top:0; width:0; height:0; overflow:hidden; }
-body:not(.alex-fixed) img, body:not(.alex-fixed) svg, body:not(.alex-fixed) video, body:not(.alex-fixed) canvas { max-width:100% !important; height:auto; object-fit:contain; break-inside:avoid; page-break-inside:avoid; }
+body:not(.alex-fixed) img, body:not(.alex-fixed) svg, body:not(.alex-fixed) video, body:not(.alex-fixed) canvas { max-width:100% !important; height:auto; object-fit:contain; break-inside:avoid; }
body.alex-fixed .alex-chapter > svg:only-child { display:block; width:100%; height:100%; max-width:none !important; max-height:none !important; }
body.alex-paged .x-ebookmaker-cover { height:calc(100vh - (2 * var(--alex-v))) !important; max-height:calc(100vh - (2 * var(--alex-v))) !important; overflow:hidden; }
svg[height="100%"], .x-ebookmaker-cover svg { display:block; height:calc(100vh - (2 * var(--alex-v))) !important; max-height:calc(100vh - (2 * var(--alex-v))) !important; }
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" data-direction="${publication.readingDirection}">
+<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-page-filter" x="-10%" y="-10%" width="120%" height="120%" color-interpolation-filters="sRGB"></filter></defs></svg>
$content
<div id="alex-cut-mask" aria-hidden="true"></div>
<script>
window.ALEX_REFERENCES = $references;
-window.ALEX_DIRECTION = '${publication.readingDirection}';
-window.ALEX_FIXED = ${publication.fixedLayout};
-${if (publication.fixedLayout) fixedReaderScript() else readerScript()}
+${if (publication.fixedLayout) FIXED_READER_SCRIPT else REFLOWABLE_READER_SCRIPT}
</script>
</body>
</html>"""
- private fun readerScript(): String = """
+ private const val REFLOWABLE_READER_SCRIPT = """
(function () {
'use strict';
- var state = { page:0, total:1, mode:'paged', chapterStarts:[], searchPages:[], searchIndex:-1, searchToken:0, settings:null };
+ var state = { page:0, total:1, mode:'paged', chapterStarts:[], searchIndex:-1, searchToken:0 };
function filterNode(name,attributes){var node=document.createElementNS('http://www.w3.org/2000/svg',name);Object.keys(attributes||{}).forEach(function(key){node.setAttribute(key,String(attributes[key]));});return node;}
function filterFunctions(parent,type,values){['R','G','B'].forEach(function(channel){parent.appendChild(filterNode('feFunc'+channel,{type:type,tableValues:values}));});parent.appendChild(filterNode('feFuncA',{type:'table',tableValues:'0 1'}));}
function applyToneFilter(settings){
if(dither!=='off'){filter.appendChild(filterNode('feColorMatrix',{in:source,type:'matrix',result:'alex-gray',values:'0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0'}));filter.appendChild(filterNode('feTurbulence',{type:'fractalNoise',baseFrequency:'.47',numOctaves:'1',seed:'37',stitchTiles:'stitch',result:'alex-noise'}));var amplitude=dither==='g2' ? 0.96 : 0.065,offset=-amplitude/2;filter.appendChild(filterNode('feColorMatrix',{in:'alex-noise',type:'matrix',result:'alex-drift',values:amplitude+' 0 0 0 '+offset+' '+amplitude+' 0 0 0 '+offset+' '+amplitude+' 0 0 0 '+offset+' 0 0 0 1 0'}));filter.appendChild(filterNode('feComposite',{in:'alex-gray',in2:'alex-drift',operator:'arithmetic',k1:'0',k2:'1',k3:'1',k4:'0',result:'alex-noisy'}));var levels=[];if(dither==='g2')levels=['0','1'];else for(var level=0;level<16;level++)levels.push((level/15).toFixed(4));var quantized=filterNode('feComponentTransfer',{in:'alex-noisy',result:'alex-quantized'});filterFunctions(quantized,'discrete',levels.join(' '));filter.appendChild(quantized);filter.appendChild(filterNode('feComposite',{in:'alex-quantized',in2:'SourceGraphic',operator:'in'}));}
document.body.style.filter='url(#alex-page-filter)';
}
- var scrolling = function () { return document.scrollingElement || document.documentElement; };
+ var scrolling = function () { return document.scrollingElement; };
var pageExtent = function () { return state.mode === 'paged' ? window.innerWidth : window.innerHeight; };
var scrollPosition = function () { return state.mode === 'paged' ? scrolling().scrollLeft : scrolling().scrollTop; };
function documentExtent() {
state.total = Math.max(1, Math.ceil((extent - 1) / Math.max(1, pageExtent())));
state.page = Math.max(0, Math.min(state.total - 1, Math.round(scrollPosition() / Math.max(1, pageExtent()))));
state.chapterStarts = Array.prototype.map.call(document.querySelectorAll('.alex-chapter-start'), function (element) { return pageForElement(element); });
- notifyPage(false);
- return { page:state.page, total:state.total, chapters:state.chapterStarts, mode:state.mode };
+ notifyPage();
}
function chapterAt(page) {
var result = 0;
var range = null;
var x = Math.max(4, Math.min(window.innerWidth - 4, parseInt(getComputedStyle(document.documentElement).getPropertyValue('--alex-side')) + 5 || 20));
for (var y = 8; y < window.innerHeight - 8 && !range; y += 24) {
- if (document.caretRangeFromPoint) range = document.caretRangeFromPoint(x, y);
+ range = document.caretRangeFromPoint(x, y);
}
if (!range || !chapter.contains(range.startContainer)) return 0;
var before = document.createRange(); before.selectNodeContents(chapter); before.setEnd(range.startContainer, range.startOffset);
var chapter = document.querySelector('.alex-chapter[data-spine="' + chapterIndex + '"]') || document.querySelectorAll('.alex-chapter')[chapterIndex];
return { page:state.page, totalPages:state.total, chapter:chapterIndex, offset:chapter ? textOffsetAtPoint(chapter) : 0, progress:state.total <= 1 ? 0 : state.page / (state.total - 1) };
}
- function notifyPage(force) {
+ function notifyPage() {
if (!window.Android || !Android.onPageChanged) return;
- var value = locator(); value.force = !!force;
- Android.onPageChanged(JSON.stringify(value));
+ Android.onPageChanged(JSON.stringify(locator()));
}
function scrollToPage(page, report) {
state.page = Math.max(0, Math.min(state.total - 1, Math.round(Number(page) || 0)));
var position = state.page * pageExtent();
if (state.mode === 'paged') scrolling().scrollTo(position, 0); else scrolling().scrollTo(0, position);
- if (report !== false) notifyPage(true);
- return JSON.stringify(locator());
+ if (report !== false) notifyPage();
}
function nodeAtOffset(chapter, offset) {
var walker = document.createTreeWalker(chapter, NodeFilter.SHOW_TEXT, { acceptNode:function (node) {
return last ? {node:last, offset:last.data.length} : null;
}
function goToLocator(value) {
- if (!value) return scrollToPage(0, true);
+ if (!value) { scrollToPage(0, true); return; }
var chapter = document.querySelector('.alex-chapter[data-spine="' + (value.chapter || 0) + '"]');
var point = chapter ? nodeAtOffset(chapter, Math.max(0, value.offset || 0)) : null;
if (point) {
var rects = range.getClientRects(), rect = rects.length ? rects[0] : null;
if (rect && isFinite(rect.left) && isFinite(rect.top) && (rect.width > 0 || rect.height > 0)) {
var absolute = state.mode === 'paged' ? rect.left + scrolling().scrollLeft : rect.top + scrolling().scrollTop;
- return scrollToPage(Math.floor((absolute + 2) / pageExtent()), true);
+ scrollToPage(Math.floor((absolute + 2) / pageExtent()), true); return;
}
}
- if (Number(value.totalPages) === state.total && typeof value.page === 'number') return scrollToPage(value.page, true);
- if (typeof value.progress === 'number') return scrollToPage(Math.round(value.progress * Math.max(0, state.total - 1)), true);
- return scrollToPage(value.page || 0, true);
+ if (Number(value.totalPages) === state.total && typeof value.page === 'number') { scrollToPage(value.page, true); return; }
+ if (typeof value.progress === 'number') { scrollToPage(Math.round(value.progress * Math.max(0, state.total - 1)), true); return; }
+ scrollToPage(value.page || 0, true);
}
function applySettings(settings, keep) {
- state.settings = settings || {};
var root = document.documentElement, body = document.body;
var family = settings.fontFamily || 'Atkinson Hyperlegible Next';
root.style.setProperty('--alex-font', "'" + family.replace(/'/g, '') + "'");
document.getElementById('alex-publisher-styles').disabled = settings.publisherStyles === false;
applyToneFilter(settings);
setTimeout(function () { recalculate(); if (keep) goToLocator(keep); }, 80);
- return true;
}
function recordNavigation(targetPage, origin) {
var from = origin || locator();
Android.onNavigationJump(JSON.stringify(from));
}
function goToReference(reference, record) {
- var id = window.ALEX_REFERENCES[reference] || window.ALEX_REFERENCES[String(reference).split('#')[0]] || reference;
- if (id && id.charAt(0) === '#') id = id.substring(1);
+ var id = window.ALEX_REFERENCES[reference];
+ if (!id) return;
var target = document.getElementById(id);
- if (!target) return false;
- var page = pageForElement(target); if (record !== false) recordNavigation(page); scrollToPage(page, true); return true;
+ if (!target) return;
+ var page = pageForElement(target); if (record !== false) recordNavigation(page); scrollToPage(page, true);
}
function clearSearch() {
state.searchToken++;
Array.prototype.forEach.call(document.querySelectorAll('mark.alex-search'), function (mark) { mark.replaceWith(document.createTextNode(mark.textContent)); });
- document.body.normalize(); state.searchPages = []; state.searchIndex = -1; recalculate();
+ document.body.normalize(); state.searchIndex = -1; recalculate();
}
function search(query, direction) {
var origin = locator(); clearSearch(); var token = state.searchToken;
query = String(query || '').trim(); direction = direction === 'backward' ? 'backward' : 'forward';
- if (!query) { if (window.Android) Android.onSearchResults('[]'); return 0; }
+ if (!query) { if (window.Android) Android.onSearchResults('[]'); return; }
var needle = query.toLocaleLowerCase(), counts = {}, walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {acceptNode:function(node){
- var parent = node.parentElement, chapter = parent && parent.closest ? parent.closest('.alex-chapter') : null;
+ var parent = node.parentElement, chapter = parent ? parent.closest('.alex-chapter') : null;
return !chapter || /^(SCRIPT|STYLE|TEXTAREA)$/.test(parent.tagName) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
}}), nodes = [], node;
while ((node = walker.nextNode())) {
marks = Array.prototype.slice.call(document.querySelectorAll('mark.alex-search'));
setTimeout(function () {
if (token !== state.searchToken) return;
- recalculate(); var results = marks.map(function(mark,index){
- var page = pageForElement(mark), text = mark.parentElement ? mark.parentElement.textContent.replace(/\s+/g,' ').trim() : mark.textContent;
- return {index:index,page:page,chapter:parseInt(mark.dataset.chapter || '0'),offset:parseInt(mark.dataset.offset || '0'),snippet:text.substring(0,180)};
+ recalculate(); var results = marks.map(function(mark){
+ return {page:pageForElement(mark),chapter:parseInt(mark.dataset.chapter || '0'),offset:parseInt(mark.dataset.offset || '0')};
});
var selected = -1;
if (direction === 'backward') {
if (selected < 0 && results.length) selected = 0;
}
results.forEach(function(result,index){result.current = index === selected;});
- state.searchPages = results.map(function(r){return r.page;}); state.searchIndex = selected;
+ state.searchIndex = selected;
if (selected >= 0) { marks[selected].classList.add('alex-search-current'); recordNavigation(results[selected].page, origin); scrollToPage(results[selected].page, true); }
if (window.Android) Android.onSearchResults(JSON.stringify(results));
}, 40);
- return marks.length;
}
function moveSearch(delta) {
- var marks = document.querySelectorAll('mark.alex-search'); if (!marks.length) return -1;
+ var marks = document.querySelectorAll('mark.alex-search'); if (!marks.length) return;
if (state.searchIndex >= 0 && marks[state.searchIndex]) marks[state.searchIndex].classList.remove('alex-search-current');
state.searchIndex = (state.searchIndex + delta + marks.length) % marks.length;
- var page = pageForElement(marks[state.searchIndex]); recordNavigation(page); marks[state.searchIndex].classList.add('alex-search-current'); scrollToPage(page, true); return state.searchIndex;
+ var page = pageForElement(marks[state.searchIndex]); recordNavigation(page); marks[state.searchIndex].classList.add('alex-search-current'); scrollToPage(page, true);
}
function captureSelection() {
var selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
var range = selection.getRangeAt(0), chapter = range.commonAncestorContainer.nodeType === 1 ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement;
- chapter = chapter && chapter.closest ? chapter.closest('.alex-chapter') : null; if (!chapter) return null;
+ chapter = chapter ? chapter.closest('.alex-chapter') : null; if (!chapter) return null;
var before = document.createRange(); before.selectNodeContents(chapter); before.setEnd(range.startContainer, range.startOffset);
var start = before.toString().length, quote = range.toString();
- return { chapter:parseInt(chapter.getAttribute('data-spine') || '0'), start:start, end:start+quote.length, quote:quote.substring(0,10000), page:state.page };
+ return { chapter:parseInt(chapter.getAttribute('data-spine') || '0'), start:start, end:start+quote.length, quote:quote.substring(0,10000) };
}
function wrapAnnotation(annotation) {
- var chapter = document.querySelector('.alex-chapter[data-spine="' + annotation.chapter + '"]'); if (!chapter || annotation.end <= annotation.start) return false;
+ var chapter = document.querySelector('.alex-chapter[data-spine="' + annotation.chapter + '"]'); if (!chapter || annotation.end <= annotation.start) return;
var walker = document.createTreeWalker(chapter, NodeFilter.SHOW_TEXT, {acceptNode:function(node){ return node.parentElement && node.parentElement.closest('mark.alex-annotation') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; }});
var nodes=[], node, count=0; while ((node=walker.nextNode())) { nodes.push({node:node,start:count,end:count+node.data.length}); count += node.data.length; }
nodes.reverse().forEach(function(part){
var from=Math.max(annotation.start,part.start)-part.start, to=Math.min(annotation.end,part.end)-part.start;
if (to>from) { var selected=part.node; if (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); }
- }); return true;
+ });
}
function applyAnnotations(values) { (values || []).slice().sort(function(a,b){return b.start-a.start;}).forEach(wrapAnnotation); setTimeout(recalculate,20); }
function removeAnnotation(id) { Array.prototype.forEach.call(document.querySelectorAll('mark[data-annotation="' + id + '"]'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));}); document.body.normalize(); recalculate(); }
document.addEventListener('click', function (event) {
- var mark=event.target.closest && event.target.closest('mark.alex-annotation'); if(mark && window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;}
- var link=event.target.closest && event.target.closest('a'); if(!link)return;
+ var mark=event.target.closest('mark.alex-annotation'); if(mark && window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;}
+ var link=event.target.closest('a'); if(!link)return;
var external=link.getAttribute('data-external-href'); if(external){event.preventDefault();if(window.Android)Android.onExternalLink(external);return;}
var reference=link.getAttribute('data-alex-ref'); if(reference){event.preventDefault();goToReference(reference,true);}
}, true);
function handleScroll(){
clearTimeout(scrollTimer); scrollTimer=setTimeout(function(){
var next=Math.max(0,Math.min(state.total-1,Math.round(scrollPosition()/Math.max(1,pageExtent()))));
- if(next!==state.page){state.page=next;notifyPage(false);}
+ if(next!==state.page){state.page=next;notifyPage();}
},80);
}
scrolling().addEventListener('scroll',handleScroll,{passive:true});
window.addEventListener('scroll',handleScroll,{passive:true});
var resizeTimer=0; window.addEventListener('resize',function(){clearTimeout(resizeTimer);resizeTimer=setTimeout(function(){var keep=locator();recalculate();goToLocator(keep);},120);});
- window.Alex = { recalculate:recalculate, setPage:scrollToPage, next:function(){return scrollToPage(state.page+1,true);}, previous:function(){return scrollToPage(state.page-1,true);},
+ window.Alex = { setPage:scrollToPage, next:function(){scrollToPage(state.page+1,true);}, previous:function(){scrollToPage(state.page-1,true);},
locator:locator, goToLocator:goToLocator, applySettings:applySettings, goToReference:goToReference, search:search, clearSearch:clearSearch,
moveSearch:moveSearch, captureSelection:captureSelection, applyAnnotations:applyAnnotations, wrapAnnotation:wrapAnnotation, removeAnnotation:removeAnnotation,
- chapterPage:function(index){return state.chapterStarts[Math.max(0,Math.min(state.chapterStarts.length-1,index))]||0;}, metrics:function(){return state;} };
- function ready() { var value=recalculate(); if(window.Android&&Android.onReaderReady)Android.onReaderReady(JSON.stringify(value)); }
- if (document.fonts && document.fonts.ready) document.fonts.ready.then(function(){setTimeout(ready,30);}); else window.addEventListener('load',ready);
- window.addEventListener('load',function(){setTimeout(ready,120);});
+ chapterPage:function(index){return state.chapterStarts[Math.max(0,Math.min(state.chapterStarts.length-1,index))]||0;} };
+ function ready() { recalculate(); if(window.Android&&Android.onReaderReady)Android.onReaderReady(); }
+ document.fonts.ready.then(function(){setTimeout(ready,30);});
})();
"""
- private fun fixedReaderScript(): String = """
+ private const val FIXED_READER_SCRIPT = """
(function () {
'use strict';
var pages = Array.prototype.slice.call(document.querySelectorAll('.alex-chapter'));
var state = {
- page:0, total:Math.max(1,pages.length), settings:{}, offsetX:NaN, offsetY:NaN,
- scale:1, crop:{x:0,y:0,width:1,height:1}, pageWidth:1, pageHeight:1, nextVisible:0,
- searchIndex:-1, searchPages:[], searchToken:0, lineCache:[]
+ page:0, total:pages.length, settings:{}, offsetX:NaN, offsetY:NaN,
+ scale:1, crop:{x:0,y:0,width:1,height:1}, nextVisible:0, searchIndex:-1, lineCache:[]
};
function number(value, fallback) { value=Number(value); return isFinite(value)?value:fallback; }
function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }
- function pageElement() { return pages[clamp(state.page,0,pages.length-1)] || null; }
+ function pageElement() { return pages[clamp(state.page,0,pages.length-1)]; }
function pageSize(page) {
- return { width:Math.max(1,number(page && page.getAttribute('data-page-width'),1200)),
- height:Math.max(1,number(page && page.getAttribute('data-page-height'),1600)) };
+ return { width:Math.max(1,number(page.getAttribute('data-page-width'),1200)),
+ height:Math.max(1,number(page.getAttribute('data-page-height'),1600)) };
}
function normalizedMargins(value) {
value=value||{};
return values.join(' ');
}
function updateFilter(page) {
- if(!page) return;
var settings=state.settings||{}, exponent=clamp(number(settings.contrastExponent,1),1,5);
var gray=clamp(number(settings.grayPoint,255),16,255), dither=settings.dithering||'off';
var filter=document.getElementById('alex-page-filter');
page.style.filter='url(#alex-page-filter)';
}
function layoutPage() {
- if(!pages.length) return;
showCutMask(0,1);
state.page=clamp(Math.round(number(state.page,0)),0,pages.length-1);
pages.forEach(function(page,index){page.classList.toggle('alex-current-page',index===state.page);page.classList.remove('alex-stream-next');});
state.offsetY=clampOffset(state.offsetY,crop.y,crop.height,visibleHeight);
}
page.style.transform='translate('+(-state.offsetX*scale)+'px,'+(-state.offsetY*scale)+'px) scale('+scale+')';
- state.scale=scale; state.crop=crop; state.pageWidth=size.width; state.pageHeight=size.height; state.nextVisible=0;
+ state.scale=scale; state.crop=crop; state.nextVisible=0;
updateFilter(page);
if(mode==='fit-width' && (state.settings.scrollMode||'screen')==='screen'){
var currentTarget=state.offsetY+visibleHeight,currentEnd=crop.y+crop.height;
progress:state.total<=1?0:state.page/(state.total-1),
viewportX:isFinite(state.offsetX)?state.offsetX:0,viewportY:isFinite(state.offsetY)?state.offsetY:0};
}
- function notifyPage(force) {
+ function notifyPage() {
if(!window.Android||!Android.onPageChanged)return;
- var value=locator();value.force=!!force;Android.onPageChanged(JSON.stringify(value));
+ Android.onPageChanged(JSON.stringify(locator()));
}
function recalculate() {
- state.total=Math.max(1,pages.length);layoutPage();notifyPage(false);
- return {page:state.page,total:state.total,chapters:pages.map(function(_,index){return index;}),mode:'fixed'};
+ state.total=pages.length;layoutPage();notifyPage();
}
function setPage(page,report) {
var next=clamp(Math.round(number(page,0)),0,state.total-1), changed=next!==state.page;
- state.page=next;if(changed){state.offsetX=NaN;state.offsetY=NaN;}layoutPage();if(report!==false)notifyPage(true);return JSON.stringify(locator());
+ state.page=next;if(changed){state.offsetX=NaN;state.offsetY=NaN;}layoutPage();if(report!==false)notifyPage();
}
function pageForElement(element) {
- var chapter=element&&element.closest?element.closest('.alex-chapter'):null;
+ var chapter=element?element.closest('.alex-chapter'):null;
return chapter?clamp(number(chapter.getAttribute('data-spine'),0),0,state.total-1):0;
}
function measuredLines(page) {
mask.style.height=Math.ceil(height)+'px';mask.style.display='block';
}
function advanceSlice(direction) {
- if((state.settings.zoomMode||'fit-page')!=='fit-width') return setPage(state.page+direction,true);
+ if((state.settings.zoomMode||'fit-page')!=='fit-width'){setPage(state.page+direction,true);return;}
var availableHeight=window.innerHeight,visibleHeight=availableHeight/state.scale,crop=state.crop,start=crop.y,end=crop.y+crop.height;
if(direction>0){
if(state.offsetY+visibleHeight<end-1){
var target=linePreservingCut(Math.min(end-1,state.offsetY+visibleHeight),1);
- state.offsetY=clamp(target,state.offsetY+Math.min(8,visibleHeight/10),end-1);layoutPage();notifyPage(true);return JSON.stringify(locator());
+ state.offsetY=clamp(target,state.offsetY+Math.min(8,visibleHeight/10),end-1);layoutPage();notifyPage();return;
}
if(state.page<state.total-1){
var alreadyShown=(state.settings.scrollMode||'screen')==='screen'?state.nextVisible:0;
state.page+=1;state.offsetX=NaN;state.offsetY=NaN;layoutPage();
if(alreadyShown>0){state.offsetY=state.crop.y+alreadyShown;layoutPage();}
- notifyPage(true);return JSON.stringify(locator());
+ notifyPage();
}
- return JSON.stringify(locator());
+ return;
}
var precedingScreen=(state.offsetY-start)*state.scale;
if(precedingScreen>=availableHeight-1){
- state.offsetY=clamp(linePreservingCut(Math.max(start,state.offsetY-visibleHeight),-1),start,state.offsetY-1);layoutPage();notifyPage(true);return JSON.stringify(locator());
+ state.offsetY=clamp(linePreservingCut(Math.max(start,state.offsetY-visibleHeight),-1),start,state.offsetY-1);layoutPage();notifyPage();return;
}
if(state.page>0){
var currentPreceding=Math.max(0,precedingScreen),needed=(state.settings.scrollMode||'screen')==='screen'?availableHeight-currentPreceding:availableHeight;
state.page-=1;state.offsetX=NaN;state.offsetY=NaN;layoutPage();
state.offsetY=clamp(linePreservingCut(state.crop.y+state.crop.height-needed/state.scale,-1),state.crop.y,state.crop.y+state.crop.height-1);
- layoutPage();notifyPage(true);return JSON.stringify(locator());
+ layoutPage();notifyPage();return;
}
- if(state.offsetY>start){state.offsetY=start;layoutPage();notifyPage(true);}return JSON.stringify(locator());
+ if(state.offsetY>start){state.offsetY=start;layoutPage();notifyPage();}
}
function goToLocator(value) {
- if(!value)return setPage(0,true);
+ if(!value){setPage(0,true);return;}
state.page=clamp(Math.round(number(value.chapter,value.page||0)),0,state.total-1);
- state.offsetX=number(value.viewportX,NaN);state.offsetY=number(value.viewportY,NaN);layoutPage();notifyPage(true);return JSON.stringify(locator());
+ state.offsetX=number(value.viewportX,NaN);state.offsetY=number(value.viewportY,NaN);layoutPage();notifyPage();
}
function applySettings(settings,keep) {
state.settings=settings||{};
// Fixed-layout coordinates depend on the publisher stylesheet.
document.getElementById('alex-publisher-styles').disabled=false;
if(keep){state.page=clamp(Math.round(number(keep.chapter,keep.page||0)),0,state.total-1);state.offsetX=number(keep.viewportX,NaN);state.offsetY=number(keep.viewportY,NaN);}
- layoutPage();notifyPage(true);return true;
+ layoutPage();notifyPage();
}
function recordNavigation(targetPage,origin) {
var from=origin||locator(),target=clamp(Math.round(number(targetPage,0)),0,state.total-1),fromPage=clamp(Math.round(number(from.page,0)),0,state.total-1);
if(target===fromPage||!window.Android||!Android.onNavigationJump)return;Android.onNavigationJump(JSON.stringify(from));
}
function goToReference(reference,record) {
- var id=window.ALEX_REFERENCES[reference]||window.ALEX_REFERENCES[String(reference).split('#')[0]]||reference;
- if(id&&id.charAt(0)==='#')id=id.substring(1);var target=document.getElementById(id);if(!target)return false;
- var page=pageForElement(target);if(record!==false)recordNavigation(page);setPage(page,true);return true;
+ var id=window.ALEX_REFERENCES[reference];if(!id)return;
+ var target=document.getElementById(id);if(!target)return;
+ var page=pageForElement(target);if(record!==false)recordNavigation(page);setPage(page,true);
}
function clearSearch() {
- state.searchToken++;
Array.prototype.forEach.call(document.querySelectorAll('mark.alex-search'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));});
- document.body.normalize();state.searchPages=[];state.searchIndex=-1;
+ document.body.normalize();state.searchIndex=-1;
}
function search(query,direction) {
var origin=locator();clearSearch();query=String(query||'').trim();direction=direction==='backward'?'backward':'forward';
- if(!query){if(window.Android)Android.onSearchResults('[]');return 0;}
+ if(!query){if(window.Android)Android.onSearchResults('[]');return;}
var needle=query.toLocaleLowerCase(),walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,{acceptNode:function(node){
- var parent=node.parentElement,chapter=parent&&parent.closest?parent.closest('.alex-chapter'):null;
+ var parent=node.parentElement,chapter=parent?parent.closest('.alex-chapter'):null;
return !chapter||/^(SCRIPT|STYLE|TEXTAREA)$/.test(parent.tagName)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT;
}}),nodes=[],node;while((node=walker.nextNode()))nodes.push(node);var marks=[];
nodes.forEach(function(textNode){var lower=textNode.data.toLocaleLowerCase(),positions=[],from=0,found;
while((found=lower.indexOf(needle,from))>=0){positions.push(found);from=found+Math.max(1,needle.length);}
for(var i=positions.length-1;i>=0;i--){var after=textNode.splitText(positions[i]);after.splitText(query.length);var mark=document.createElement('mark');mark.className='alex-search';after.parentNode.replaceChild(mark,after);mark.appendChild(after);marks.push(mark);}
});marks=Array.prototype.slice.call(document.querySelectorAll('mark.alex-search'));
- var results=marks.map(function(mark,index){var page=pageForElement(mark),text=mark.parentElement?mark.parentElement.textContent.replace(/\s+/g,' ').trim():mark.textContent;return{index:index,page:page,chapter:page,offset:0,snippet:text.substring(0,180)};});
+ var results=marks.map(function(mark){return{page:pageForElement(mark)};});
var selected=-1;if(direction==='backward'){for(var back=results.length-1;back>=0;back--)if(results[back].page<=origin.page){selected=back;break;}if(selected<0&&results.length)selected=results.length-1;}
else{for(var forward=0;forward<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.searchPages=results.map(function(result){return result.page;});state.searchIndex=selected;
+ results.forEach(function(result,index){result.current=index===selected;});state.searchIndex=selected;
if(selected>=0){marks[selected].classList.add('alex-search-current');recordNavigation(results[selected].page,origin);setPage(results[selected].page,true);}
- if(window.Android)Android.onSearchResults(JSON.stringify(results));return marks.length;
+ if(window.Android)Android.onSearchResults(JSON.stringify(results));
}
function moveSearch(delta) {
- var marks=document.querySelectorAll('mark.alex-search');if(!marks.length)return-1;
+ var marks=document.querySelectorAll('mark.alex-search');if(!marks.length)return;
if(state.searchIndex>=0&&marks[state.searchIndex])marks[state.searchIndex].classList.remove('alex-search-current');
- state.searchIndex=(state.searchIndex+delta+marks.length)%marks.length;var page=pageForElement(marks[state.searchIndex]);recordNavigation(page);marks[state.searchIndex].classList.add('alex-search-current');setPage(page,true);return state.searchIndex;
+ state.searchIndex=(state.searchIndex+delta+marks.length)%marks.length;var page=pageForElement(marks[state.searchIndex]);recordNavigation(page);marks[state.searchIndex].classList.add('alex-search-current');setPage(page,true);
}
function captureSelection() {
var selection=window.getSelection();if(!selection||selection.rangeCount===0||selection.isCollapsed)return null;
var range=selection.getRangeAt(0),chapter=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;
- chapter=chapter&&chapter.closest?chapter.closest('.alex-chapter'):null;if(!chapter)return null;
+ chapter=chapter?chapter.closest('.alex-chapter'):null;if(!chapter)return null;
var before=document.createRange();before.selectNodeContents(chapter);before.setEnd(range.startContainer,range.startOffset);var start=before.toString().length,quote=range.toString();
- return{chapter:parseInt(chapter.getAttribute('data-spine')||'0'),start:start,end:start+quote.length,quote:quote.substring(0,10000),page:state.page};
+ return{chapter:parseInt(chapter.getAttribute('data-spine')||'0'),start:start,end:start+quote.length,quote:quote.substring(0,10000)};
}
function nodeAtOffset(chapter,offset){var walker=document.createTreeWalker(chapter,NodeFilter.SHOW_TEXT),node,count=0,last=null;while((node=walker.nextNode())){last=node;if(count+node.data.length>=offset)return{node:node,offset:Math.max(0,offset-count)};count+=node.data.length;}return last?{node:last,offset:last.data.length}:null;}
function wrapAnnotation(annotation) {
- var chapter=document.querySelector('.alex-chapter[data-spine="'+annotation.chapter+'"]');if(!chapter||annotation.end<=annotation.start)return false;
+ var chapter=document.querySelector('.alex-chapter[data-spine="'+annotation.chapter+'"]');if(!chapter||annotation.end<=annotation.start)return;
var walker=document.createTreeWalker(chapter,NodeFilter.SHOW_TEXT,{acceptNode:function(node){return node.parentElement&&node.parentElement.closest('mark.alex-annotation')?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT;}}),nodes=[],node,count=0;
- while((node=walker.nextNode())){nodes.push({node:node,start:count,end:count+node.data.length});count+=node.data.length;}nodes.reverse().forEach(function(part){var from=Math.max(annotation.start,part.start)-part.start,to=Math.min(annotation.end,part.end)-part.start;if(to>from){var selected=part.node;if(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);}});return true;
+ while((node=walker.nextNode())){nodes.push({node:node,start:count,end:count+node.data.length});count+=node.data.length;}nodes.reverse().forEach(function(part){var from=Math.max(annotation.start,part.start)-part.start,to=Math.min(annotation.end,part.end)-part.start;if(to>from){var selected=part.node;if(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();}
function panBy(dx,dy){
- if((state.settings.zoomMode||'fit-page')!=='custom')return false;state.offsetX-=number(dx,0)/state.scale;state.offsetY-=number(dy,0)/state.scale;layoutPage();return true;
+ if((state.settings.zoomMode||'fit-page')!=='custom')return;state.offsetX-=number(dx,0)/state.scale;state.offsetY-=number(dy,0)/state.scale;layoutPage();
}
function zoomBy(factor){
factor=clamp(number(factor,1),0.75,1.33);var crop=state.crop,fitPage=Math.min(window.innerWidth/crop.width,window.innerHeight/crop.height);
if((state.settings.zoomMode||'fit-page')!=='custom')state.settings.customZoom=clamp(Math.round(100*state.scale/fitPage),50,400);
var centerX=state.offsetX+window.innerWidth/(2*state.scale),centerY=state.offsetY+window.innerHeight/(2*state.scale);
state.settings.zoomMode='custom';state.settings.customZoom=clamp(number(state.settings.customZoom,100)*factor,50,400);
- var nextScale=fitPage*state.settings.customZoom/100;state.offsetX=centerX-window.innerWidth/(2*nextScale);state.offsetY=centerY-window.innerHeight/(2*nextScale);layoutPage();return state.settings.customZoom;
+ var nextScale=fitPage*state.settings.customZoom/100;state.offsetX=centerX-window.innerWidth/(2*nextScale);state.offsetY=centerY-window.innerHeight/(2*nextScale);layoutPage();
}
- function finishViewportGesture(){notifyPage(true);if(window.Android&&Android.onViewportChanged)Android.onViewportChanged(JSON.stringify({zoomMode:state.settings.zoomMode||'fit-page',customZoom:Math.round(number(state.settings.customZoom,100))}));return JSON.stringify(locator());}
+ function finishViewportGesture(){notifyPage();if(window.Android&&Android.onViewportChanged)Android.onViewportChanged(JSON.stringify({zoomMode:state.settings.zoomMode||'fit-page',customZoom:Math.round(number(state.settings.customZoom,100))}));}
document.addEventListener('click',function(event){
- var mark=event.target.closest&&event.target.closest('mark.alex-annotation');if(mark&&window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;}
- var link=event.target.closest&&event.target.closest('a');if(!link)return;var external=link.getAttribute('data-external-href');if(external){event.preventDefault();if(window.Android)Android.onExternalLink(external);return;}
+ var mark=event.target.closest('mark.alex-annotation');if(mark&&window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;}
+ var link=event.target.closest('a');if(!link)return;var external=link.getAttribute('data-external-href');if(external){event.preventDefault();if(window.Android)Android.onExternalLink(external);return;}
var reference=link.getAttribute('data-alex-ref');if(reference){event.preventDefault();goToReference(reference,true);}
},true);
- var resizeTimer=0;window.addEventListener('resize',function(){clearTimeout(resizeTimer);resizeTimer=setTimeout(function(){layoutPage();notifyPage(true);},120);});
- window.Alex={recalculate:recalculate,setPage:setPage,next:function(){return advanceSlice(1);},previous:function(){return advanceSlice(-1);},locator:locator,goToLocator:goToLocator,
+ var resizeTimer=0;window.addEventListener('resize',function(){clearTimeout(resizeTimer);resizeTimer=setTimeout(function(){layoutPage();notifyPage();},120);});
+ window.Alex={setPage:setPage,next:function(){advanceSlice(1);},previous:function(){advanceSlice(-1);},locator:locator,goToLocator:goToLocator,
applySettings:applySettings,goToReference:goToReference,search:search,clearSearch:clearSearch,moveSearch:moveSearch,captureSelection:captureSelection,
- applyAnnotations:applyAnnotations,wrapAnnotation:wrapAnnotation,removeAnnotation:removeAnnotation,chapterPage:function(index){return clamp(index,0,state.total-1);},metrics:function(){return state;},
+ applyAnnotations:applyAnnotations,wrapAnnotation:wrapAnnotation,removeAnnotation:removeAnnotation,chapterPage:function(index){return clamp(index,0,state.total-1);},
panBy:panBy,zoomBy:zoomBy,finishViewportGesture:finishViewportGesture};
- function ready(){var value=recalculate();if(window.Android&&Android.onReaderReady)Android.onReaderReady(JSON.stringify(value));}
- if(document.fonts&&document.fonts.ready)document.fonts.ready.then(function(){setTimeout(ready,30);});else window.addEventListener('load',ready);
- window.addEventListener('load',function(){setTimeout(ready,120);});
+ function ready(){recalculate();if(window.Android&&Android.onReaderReady)Android.onReaderReady();}
+ document.fonts.ready.then(function(){setTimeout(ready,30);});
})();
"""
val body = source.substring(open + 1, close)
val trimmed = prelude.trimStart()
fun appendRule(rulePrelude: String, declarations: String) {
- val pagedBody = declarations
- .replace(Regex("page-break-before\\s*:\\s*always\\s*;?", RegexOption.IGNORE_CASE)) { "page-break-before:always;break-before:column;" }
- .replace(Regex("page-break-after\\s*:\\s*always\\s*;?", RegexOption.IGNORE_CASE)) { "page-break-after:always;break-after:column;" }
- .replace(Regex("page-break-before\\s*:\\s*avoid\\s*;?", RegexOption.IGNORE_CASE)) { "page-break-before:avoid;break-before:avoid-column;" }
- .replace(Regex("page-break-after\\s*:\\s*avoid\\s*;?", RegexOption.IGNORE_CASE)) { "page-break-after:avoid;break-after:avoid-column;" }
- .replace(Regex("page-break-inside\\s*:\\s*avoid\\s*;?", RegexOption.IGNORE_CASE)) { "page-break-inside:avoid;break-inside:avoid-column;" }
- out.append(transformSelector(rulePrelude)).append('{').append(pagedBody).append('}')
+ out.append(transformSelector(rulePrelude)).append('{').append(declarations).append('}')
}
when {
trimmed.startsWith("@media", true) || trimmed.startsWith("@supports", true) || trimmed.startsWith("@layer", true) ->