* Add desktop release CI and support for Arch Linux packaging * Make Gradle wrapper executable in desktop-release workflow * Make Gradle wrapper executable in desktop-release workflow * Make Gradle wrapper executable in desktop-release workflow * Configure Gradle and update Java environment in desktop-release workflow * Update Java setup and AUR packaging in desktop release workflow * Update Java setup and AUR packaging in desktop release workflow * Add MSIX packaging support for Windows desktop distribution * Update AUR packaging metadata and validation * Use spine toc attribute for NCX resolution * crash fixes * Implement automatic discovery and injection of EPUB font face siblings * Enhance custom font support with family grouping and variable font handling * Optimize metadata loading and improve TTS highlighting * Add keyboard navigation support for EPUB reader * Refine PDF spread page sizing to respect aspect ratios * Implement responsive maximum height for reader popups and sheets * Handle TTS generation failures by skipping problematic chunks * Refactor PDF tile rendering logic and zoom indicator behavior * Prefer block and offset locators over page index in native vertical flow * Implement save and share actions for original book files * Add Estonian language support * Implement temporary viewing mode for external files * Implement direct opening for temporary external files without library persistence * fix failing tests * Import SharedFileCapabilities in DesktopLibraryUi * Improve native vertical reader progress, persistence, and image support * Center target in viewport for native vertical reader and support animated scrolling
1042 lines
44 KiB
Kotlin
1042 lines
44 KiB
Kotlin
/*
|
|
* Episteme Reader - A native Android document reader.
|
|
* Copyright (C) 2026 Episteme
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as
|
|
* published by the Free Software Foundation, either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*
|
|
* mail: epistemereader@gmail.com
|
|
*/
|
|
package com.aryan.reader.paginatedreader
|
|
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.isSpecified
|
|
import androidx.compose.ui.text.ParagraphStyle
|
|
import androidx.compose.ui.text.TextStyle
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import androidx.compose.ui.unit.Constraints
|
|
import androidx.compose.ui.unit.Density
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.isSpecified
|
|
import androidx.compose.ui.unit.sp
|
|
import org.jsoup.Jsoup
|
|
import org.jsoup.nodes.Element
|
|
import org.jsoup.nodes.Node
|
|
import org.jsoup.nodes.TextNode
|
|
import org.jsoup.select.Selector
|
|
import java.util.ArrayDeque
|
|
import java.util.IdentityHashMap
|
|
|
|
private val unsupportedPseudoElementRegex = Regex("::?(first-letter|first-line|marker|selection)", RegexOption.IGNORE_CASE)
|
|
private val cssUrlRegex = Regex("""url\((['"]?)(.*?)\1\)""", RegexOption.IGNORE_CASE)
|
|
private const val MAX_SEMANTIC_TEXT_BLOCK_CHARS = 32_000
|
|
private const val TEXT_APPEND_SLICE_CHARS = 2_048
|
|
private val semanticBlockDescendantTags = setOf(
|
|
"img",
|
|
"svg",
|
|
"math-placeholder",
|
|
"table",
|
|
"hr",
|
|
"div",
|
|
"p",
|
|
"h1",
|
|
"h2",
|
|
"h3",
|
|
"h4",
|
|
"h5",
|
|
"h6",
|
|
"ul",
|
|
"ol",
|
|
"li",
|
|
"blockquote",
|
|
"figure",
|
|
"article",
|
|
"aside",
|
|
"header",
|
|
"footer",
|
|
"nav",
|
|
"section",
|
|
"main"
|
|
)
|
|
private val forcedStandaloneSemanticTags = setOf("img", "svg", "math-placeholder", "hr", "table")
|
|
|
|
interface HtmlResourceResolver {
|
|
fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String?
|
|
fun readText(path: String): String?
|
|
fun imageDimensions(path: String): Pair<Float?, Float?>?
|
|
}
|
|
|
|
interface HtmlFontFamilyLoader {
|
|
fun load(fontFaces: List<FontFaceInfo>, extractionBasePath: String): Map<String, FontFamily>
|
|
}
|
|
|
|
object NoOpHtmlResourceResolver : HtmlResourceResolver {
|
|
override fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String? = null
|
|
override fun readText(path: String): String? = null
|
|
override fun imageDimensions(path: String): Pair<Float?, Float?>? = null
|
|
}
|
|
|
|
object NoOpHtmlFontFamilyLoader : HtmlFontFamilyLoader {
|
|
override fun load(fontFaces: List<FontFaceInfo>, extractionBasePath: String): Map<String, FontFamily> = emptyMap()
|
|
}
|
|
|
|
private object HtmlParserLog {
|
|
fun d(@Suppress("UNUSED_PARAMETER") message: String) = Unit
|
|
fun w(@Suppress("UNUSED_PARAMETER") throwable: Throwable, @Suppress("UNUSED_PARAMETER") message: String) = Unit
|
|
fun e(@Suppress("UNUSED_PARAMETER") throwable: Throwable, @Suppress("UNUSED_PARAMETER") message: String) = Unit
|
|
}
|
|
|
|
private fun Element.getCfiPath(): String {
|
|
val path = mutableListOf<Int>()
|
|
var currentNode: Node? = this
|
|
while (currentNode != null && (currentNode !is Element || currentNode.tagName() != "body")) {
|
|
val parent = currentNode.parent() ?: break
|
|
val children = parent.childNodes().filter { node ->
|
|
node is Element || (node is TextNode && node.text().trim().isNotEmpty())
|
|
}
|
|
val nodeIndex = children.indexOf(currentNode)
|
|
if (nodeIndex == -1) {
|
|
currentNode = parent
|
|
continue
|
|
}
|
|
val cfiIndex = (nodeIndex * 2) + 2
|
|
path.add(0, cfiIndex)
|
|
currentNode = parent
|
|
}
|
|
path.add(0, 4)
|
|
return "/" + path.joinToString("/")
|
|
}
|
|
|
|
private fun String.capitalizeWords(): String =
|
|
split(' ').joinToString(" ") { word ->
|
|
if (word.isNotEmpty()) word.replaceFirstChar { it.titlecase() } else ""
|
|
}
|
|
|
|
private data class SemanticTextChunk(
|
|
val text: String,
|
|
val spans: List<SemanticSpan>,
|
|
val startCharOffsetInSource: Int
|
|
)
|
|
|
|
/**
|
|
* The public entry point for converting HTML to a list of [SemanticBlock]s.
|
|
* This function sets up a parsing context and delegates the work to a [SemanticHtmlParser] instance.
|
|
*/
|
|
fun htmlToSemanticBlocks(
|
|
html: String,
|
|
cssRules: OptimizedCssRules,
|
|
textStyle: TextStyle,
|
|
chapterAbsPath: String,
|
|
extractionBasePath: String,
|
|
density: Density,
|
|
fontFamilyMap: Map<String, FontFamily>,
|
|
constraints: Constraints,
|
|
imageDimensionsCache: Map<String, Pair<Float, Float>> = emptyMap(),
|
|
mathSvgCache: Map<String, String> = emptyMap(),
|
|
resourceResolver: HtmlResourceResolver = NoOpHtmlResourceResolver,
|
|
fontFamilyLoader: HtmlFontFamilyLoader = NoOpHtmlFontFamilyLoader,
|
|
adaptThemeColors: Boolean = false
|
|
): List<SemanticBlock> {
|
|
return SemanticHtmlParser(
|
|
cssRules,
|
|
textStyle,
|
|
chapterAbsPath,
|
|
extractionBasePath,
|
|
density,
|
|
fontFamilyMap,
|
|
constraints,
|
|
imageDimensionsCache,
|
|
mathSvgCache,
|
|
resourceResolver,
|
|
fontFamilyLoader,
|
|
adaptThemeColors
|
|
).parse(html)
|
|
}
|
|
|
|
/**
|
|
* A stateful parser that holds the context for a single HTML-to-SemanticBlock conversion.
|
|
*/
|
|
private class SemanticHtmlParser(
|
|
cssRules: OptimizedCssRules,
|
|
private val textStyle: TextStyle,
|
|
private val chapterAbsPath: String,
|
|
private val extractionBasePath: String,
|
|
private val density: Density,
|
|
fontFamilyMap: Map<String, FontFamily>,
|
|
private val constraints: Constraints,
|
|
private val imageDimensionsCache: Map<String, Pair<Float, Float>>,
|
|
private val mathSvgCache: Map<String, String>,
|
|
private val resourceResolver: HtmlResourceResolver,
|
|
private val fontFamilyLoader: HtmlFontFamilyLoader,
|
|
private val adaptThemeColors: Boolean
|
|
) {
|
|
private val semanticBlockDescendantCache = IdentityHashMap<Element, Boolean>()
|
|
private var combinedRules: OptimizedCssRules = cssRules
|
|
private val currentFontFamilyMap: MutableMap<String, FontFamily> = fontFamilyMap.toMutableMap()
|
|
private var nextBlockIndex = 0
|
|
|
|
fun parse(html: String): List<SemanticBlock> {
|
|
val document = Jsoup.parse(html, chapterAbsPath)
|
|
val inlineCssContent = document.head().getElementsByTag("style").joinToString(separator = "\n") { it.data() }
|
|
|
|
if (inlineCssContent.isNotBlank()) {
|
|
HtmlParserLog.d("Found inline <style> content in $chapterAbsPath. Parsing...")
|
|
val inlineParseResult = CssParser.parse(
|
|
cssContent = inlineCssContent,
|
|
cssPath = chapterAbsPath,
|
|
baseFontSizeSp = textStyle.fontSize.value,
|
|
density = density.density,
|
|
constraints = constraints,
|
|
isDarkTheme = false,
|
|
adaptThemeColors = adaptThemeColors
|
|
)
|
|
|
|
if (inlineParseResult.fontFaces.isNotEmpty()) {
|
|
val newFonts = fontFamilyLoader.load(inlineParseResult.fontFaces, extractionBasePath)
|
|
if (newFonts.isNotEmpty()) {
|
|
currentFontFamilyMap.putAll(newFonts)
|
|
}
|
|
}
|
|
combinedRules = combinedRules.merge(inlineParseResult.rules)
|
|
}
|
|
|
|
val body = document.body()
|
|
return parseContainer(body, getElementStyle(body).withResolvedFontFamily())
|
|
}
|
|
|
|
private inline fun Element.anyChildElement(predicate: (Element) -> Boolean): Boolean {
|
|
childNodes().forEach { child ->
|
|
if (child is Element && predicate(child)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
private fun Element.hasSemanticBlockDescendant(): Boolean {
|
|
semanticBlockDescendantCache[this]?.let { return it }
|
|
|
|
if (anyChildElement { child -> child.tagName().lowercase() in semanticBlockDescendantTags }) {
|
|
semanticBlockDescendantCache[this] = true
|
|
return true
|
|
}
|
|
|
|
val stack = ArrayDeque<Element>()
|
|
stack.add(this)
|
|
val expanded = IdentityHashMap<Element, Boolean>()
|
|
|
|
while (stack.isNotEmpty()) {
|
|
val current = stack.peekLast()
|
|
if (semanticBlockDescendantCache.containsKey(current)) {
|
|
stack.removeLast()
|
|
continue
|
|
}
|
|
|
|
if (expanded.put(current, true) == null) {
|
|
current.childNodes().forEach { child ->
|
|
if (child is Element && !semanticBlockDescendantCache.containsKey(child)) {
|
|
stack.add(child)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
stack.removeLast()
|
|
val hasSemanticDescendant = current.anyChildElement { child ->
|
|
child.tagName().lowercase() in semanticBlockDescendantTags ||
|
|
semanticBlockDescendantCache[child] == true
|
|
}
|
|
semanticBlockDescendantCache[current] = hasSemanticDescendant
|
|
}
|
|
|
|
return semanticBlockDescendantCache[this] == true
|
|
}
|
|
|
|
private fun Element.isEffectivelySemanticBlock(): Boolean {
|
|
val tagName = tagName().lowercase()
|
|
return isBlock ||
|
|
tagName in forcedStandaloneSemanticTags ||
|
|
(!isBlock && hasSemanticBlockDescendant())
|
|
}
|
|
|
|
private fun parseNodeToSemanticBlocks(
|
|
element: Element,
|
|
inheritedStyle: CssStyle,
|
|
inheritedLinkHref: String? = null
|
|
): List<SemanticBlock> {
|
|
val elementOwnStyle = getElementStyle(element, inheritedStyle.customProperties)
|
|
val finalBlockStyle = elementOwnStyle.blockStyle.copy(
|
|
listStyleType = elementOwnStyle.blockStyle.listStyleType ?: inheritedStyle.blockStyle.listStyleType,
|
|
listStyleImage = elementOwnStyle.blockStyle.listStyleImage ?: inheritedStyle.blockStyle.listStyleImage,
|
|
visibility = elementOwnStyle.blockStyle.visibility ?: inheritedStyle.blockStyle.visibility
|
|
)
|
|
|
|
val finalStyle = elementOwnStyle.copy(
|
|
spanStyle = inheritedStyle.spanStyle.merge(elementOwnStyle.spanStyle),
|
|
paragraphStyle = inheritedStyle.paragraphStyle.merge(elementOwnStyle.paragraphStyle),
|
|
blockStyle = finalBlockStyle,
|
|
fontFamilies = elementOwnStyle.fontFamilies.ifEmpty { inheritedStyle.fontFamilies },
|
|
fontSize = if (elementOwnStyle.fontSize.isSpecified) elementOwnStyle.fontSize else inheritedStyle.fontSize,
|
|
textTransform = elementOwnStyle.textTransform ?: inheritedStyle.textTransform,
|
|
hyphens = elementOwnStyle.hyphens ?: inheritedStyle.hyphens,
|
|
fontVariantNumeric = elementOwnStyle.fontVariantNumeric ?: inheritedStyle.fontVariantNumeric,
|
|
textEmphasis = elementOwnStyle.textEmphasis ?: inheritedStyle.textEmphasis,
|
|
whiteSpace = elementOwnStyle.whiteSpace ?: inheritedStyle.whiteSpace,
|
|
customProperties = inheritedStyle.customProperties + elementOwnStyle.customProperties
|
|
).withResolvedFontFamily()
|
|
|
|
if (finalStyle.display == "none") return emptyList()
|
|
|
|
val linkHref = element.linkHrefOrNull() ?: inheritedLinkHref
|
|
return elementToSemanticBlocks(element, finalStyle.withResolvedBlockResources(), linkHref)
|
|
}
|
|
|
|
private fun CssRule.matchesElement(element: Element, pseudoElement: String? = null): Boolean {
|
|
if (this.pseudoElement != pseudoElement) return false
|
|
if (unsupportedPseudoElementRegex.containsMatchIn(selector.selector)) return false
|
|
return try {
|
|
element.`is`(selector.selector)
|
|
} catch (e: Selector.SelectorParseException) {
|
|
HtmlParserLog.w(e, "Jsoup failed to parse selector '${selector.selector}'.")
|
|
false
|
|
}
|
|
}
|
|
|
|
private fun rulesForElement(element: Element, pseudoElement: String? = null): List<CssRule> {
|
|
val rules = combinedRules.toFlatList()
|
|
return rules
|
|
.asSequence()
|
|
.filter { it.matchesElement(element, pseudoElement) }
|
|
.sortedWith(compareBy<CssRule> { it.selector.specificity }.thenBy { it.sourceOrder })
|
|
.toList()
|
|
}
|
|
|
|
private fun getElementStyle(element: Element, inheritedCustomProperties: Map<String, String> = emptyMap()): CssStyle {
|
|
val baseStyle = rulesForElement(element).fold(CssStyle(customProperties = inheritedCustomProperties)) { acc, rule ->
|
|
acc.merge(rule.style)
|
|
}
|
|
var elementStyle = baseStyle
|
|
val inlineStyleAttribute = element.attr("style")
|
|
if (inlineStyleAttribute.isNotBlank()) {
|
|
val inlineStyle = CssParser.parseProperties(
|
|
inlineStyleAttribute,
|
|
textStyle.fontSize.value,
|
|
density.density,
|
|
constraints,
|
|
onlyImportant = false,
|
|
isDarkTheme = false,
|
|
adaptThemeColors = adaptThemeColors,
|
|
inheritedCustomProperties = elementStyle.customProperties
|
|
)
|
|
elementStyle = elementStyle.merge(inlineStyle)
|
|
}
|
|
|
|
element.attr("align").takeIf { it.isNotBlank() }?.let { align ->
|
|
val textAlign = when (align.lowercase()) {
|
|
"center" -> TextAlign.Center; "right" -> TextAlign.End
|
|
"justify" -> TextAlign.Justify; "left" -> TextAlign.Start
|
|
else -> null
|
|
}
|
|
if (textAlign != null) {
|
|
elementStyle = elementStyle.merge(CssStyle(paragraphStyle = ParagraphStyle(textAlign = textAlign)))
|
|
}
|
|
}
|
|
return elementStyle
|
|
}
|
|
|
|
private fun getPseudoElementStyle(
|
|
element: Element,
|
|
pseudoElement: String,
|
|
inheritedStyle: CssStyle
|
|
): CssStyle {
|
|
val pseudoStyle = rulesForElement(element, pseudoElement).fold(CssStyle()) { acc, rule ->
|
|
acc.merge(rule.style)
|
|
}
|
|
return inheritedStyle.merge(pseudoStyle).withResolvedFontFamily()
|
|
}
|
|
|
|
private fun CssStyle.withResolvedFontFamily(): CssStyle {
|
|
if (spanStyle.fontFamily != null) return this
|
|
val resolvedFontFamily = fontFamilies.asSequence()
|
|
.mapNotNull { name ->
|
|
val normalized = name.trim().lowercase()
|
|
currentFontFamilyMap[normalized] ?: FontFamilyMapper.nameToFontFamily(normalized)
|
|
}
|
|
.firstOrNull()
|
|
?: return this
|
|
return copy(spanStyle = spanStyle.copy(fontFamily = resolvedFontFamily))
|
|
}
|
|
|
|
private fun firstCssUrl(value: String): String? {
|
|
return cssUrlRegex.find(value)?.groupValues?.getOrNull(2)?.trim()?.takeIf { it.isNotBlank() }
|
|
}
|
|
|
|
private fun CssStyle.withResolvedBlockResources(): CssStyle {
|
|
val resolvedBackgroundImage = blockStyle.backgroundImage?.let { raw ->
|
|
val url = firstCssUrl(raw) ?: raw.takeIf { !it.contains("(") } ?: return@let raw
|
|
resolveImagePath(url) ?: raw
|
|
}
|
|
return if (resolvedBackgroundImage == blockStyle.backgroundImage) {
|
|
this
|
|
} else {
|
|
copy(blockStyle = blockStyle.copy(backgroundImage = resolvedBackgroundImage))
|
|
}
|
|
}
|
|
|
|
private fun elementToSemanticBlocks(
|
|
element: Element,
|
|
elementStyle: CssStyle,
|
|
inheritedLinkHref: String?
|
|
): List<SemanticBlock> {
|
|
val elementId = element.id().ifBlank { null }
|
|
val cfi = element.getCfiPath()
|
|
|
|
if (element.tagName().equals("br", ignoreCase = true)) {
|
|
return listOf(SemanticSpacer(style = elementStyle, elementId = elementId, cfi = cfi, isExplicitLineBreak = true, blockIndex = nextBlockIndex++))
|
|
}
|
|
|
|
if (elementStyle.blockStyle.display == "flex") {
|
|
val children = element.children().flatMap { child ->
|
|
parseNodeToSemanticBlocks(child, elementStyle, inheritedLinkHref)
|
|
}
|
|
return listOf(SemanticFlexContainer(children, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
|
|
}
|
|
|
|
val result = when (val tagName = element.tagName().lowercase()) {
|
|
"div", "header", "section", "article", "aside", "main", "footer", "nav", "figure" -> {
|
|
val hasBoxStyles = elementStyle.blockStyle.backgroundColor.isSpecified ||
|
|
elementStyle.blockStyle.borderTop != null ||
|
|
elementStyle.blockStyle.borderRight != null ||
|
|
elementStyle.blockStyle.borderBottom != null ||
|
|
elementStyle.blockStyle.borderLeft != null ||
|
|
elementStyle.blockStyle.padding != BoxBorders() ||
|
|
elementStyle.blockStyle.borderTopLeftRadius > 0.dp ||
|
|
elementStyle.blockStyle.borderTopRightRadius > 0.dp ||
|
|
elementStyle.blockStyle.borderBottomRightRadius > 0.dp ||
|
|
elementStyle.blockStyle.borderBottomLeftRadius > 0.dp
|
|
|
|
if (hasBoxStyles) {
|
|
val childStyle = elementStyle.copy(
|
|
blockStyle = elementStyle.blockStyle.copy(
|
|
backgroundColor = Color.Unspecified,
|
|
borderTop = null, borderRight = null, borderBottom = null, borderLeft = null,
|
|
padding = BoxBorders(),
|
|
margin = BoxBorders()
|
|
)
|
|
)
|
|
val children = parseContainer(element, childStyle, inheritedLinkHref)
|
|
listOf(SemanticFlexContainer(children, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
|
|
} else {
|
|
parseContainer(element, elementStyle, inheritedLinkHref)
|
|
}
|
|
}
|
|
"svg" -> parseSvgElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
|
|
"table" -> parseTableElementToSemantic(element, elementStyle, inheritedLinkHref)?.let { listOf(it) } ?: emptyList()
|
|
"math-placeholder" -> parseMathPlaceholderToSemantic(element, elementStyle)
|
|
"img" -> parseImageElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
|
|
"p" -> {
|
|
if (element.hasSemanticBlockDescendant()) {
|
|
parseContainer(element, elementStyle, inheritedLinkHref)
|
|
} else {
|
|
textElementToSemanticParagraphs(element, elementStyle, inheritedLinkHref)
|
|
}
|
|
}
|
|
"h1", "h2", "h3", "h4", "h5", "h6" -> {
|
|
val hasNonTextChildren = element.hasSemanticBlockDescendant()
|
|
if (hasNonTextChildren) {
|
|
val level = tagName.substring(1).toIntOrNull() ?: 1
|
|
val fontSizeMultiplier = when (level) {
|
|
1 -> 1.5f; 2 -> 1.4f; 3 -> 1.3f; 4 -> 1.2f; 5 -> 1.1f; else -> 1.0f
|
|
}
|
|
val headerStyle = elementStyle.copy(
|
|
spanStyle = elementStyle.spanStyle.copy(
|
|
fontWeight = FontWeight.Bold,
|
|
fontSize = (textStyle.fontSize.value * fontSizeMultiplier).sp
|
|
)
|
|
)
|
|
|
|
val hasBoxStyles = headerStyle.blockStyle.backgroundColor.isSpecified ||
|
|
headerStyle.blockStyle.borderTop != null ||
|
|
headerStyle.blockStyle.borderRight != null ||
|
|
headerStyle.blockStyle.borderBottom != null ||
|
|
headerStyle.blockStyle.borderLeft != null ||
|
|
headerStyle.blockStyle.padding != BoxBorders() ||
|
|
headerStyle.blockStyle.borderTopLeftRadius > 0.dp ||
|
|
headerStyle.blockStyle.borderTopRightRadius > 0.dp ||
|
|
headerStyle.blockStyle.borderBottomRightRadius > 0.dp ||
|
|
headerStyle.blockStyle.borderBottomLeftRadius > 0.dp
|
|
|
|
if (hasBoxStyles) {
|
|
val childStyle = headerStyle.copy(
|
|
blockStyle = headerStyle.blockStyle.copy(
|
|
backgroundColor = Color.Unspecified,
|
|
borderTop = null, borderRight = null, borderBottom = null, borderLeft = null,
|
|
padding = BoxBorders(),
|
|
margin = BoxBorders()
|
|
)
|
|
)
|
|
val children = parseContainer(element, childStyle, inheritedLinkHref)
|
|
listOf(SemanticFlexContainer(children, headerStyle, elementId, cfi, blockIndex = nextBlockIndex++))
|
|
} else {
|
|
parseContainer(element, headerStyle, inheritedLinkHref)
|
|
}
|
|
} else {
|
|
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle, inheritedLinkHref)
|
|
if (text.isNotBlank()) {
|
|
val level = tagName.substring(1).toIntOrNull() ?: 1
|
|
listOf(SemanticHeader(level, text, spans, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
|
|
} else emptyList()
|
|
}
|
|
}
|
|
"hr" -> listOf(SemanticSpacer(style = elementStyle, elementId = elementId, cfi = cfi, blockIndex = nextBlockIndex++))
|
|
"ul", "ol" -> parseListElementToSemantic(element, elementStyle, inheritedLinkHref)
|
|
else -> {
|
|
val hasBlockDescendant = !element.isBlock && element.hasSemanticBlockDescendant()
|
|
if (element.isBlock || hasBlockDescendant) {
|
|
parseContainer(element, elementStyle, inheritedLinkHref)
|
|
} else {
|
|
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle, inheritedLinkHref)
|
|
if (text.isNotBlank()) {
|
|
listOf(SemanticParagraph(text, spans, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
|
|
} else emptyList()
|
|
}
|
|
}
|
|
}
|
|
|
|
return if (elementId != null && result.isNotEmpty()) {
|
|
val first = result.first()
|
|
if (first.elementId == null) {
|
|
listOf(first.withElementId(elementId)) + result.drop(1)
|
|
} else result
|
|
} else result
|
|
}
|
|
|
|
private fun textElementToSemanticParagraphs(
|
|
element: Element,
|
|
style: CssStyle,
|
|
inheritedLinkHref: String?
|
|
): List<SemanticBlock> {
|
|
val textChunks = buildSemanticTextAndSpanChunksFromNodes(
|
|
nodes = element.childNodes(),
|
|
rootStyle = style,
|
|
rootElement = element,
|
|
inheritedLinkHref = inheritedLinkHref
|
|
)
|
|
val elementId = element.id().ifBlank { null }
|
|
val cfi = element.getCfiPath()
|
|
return textChunks.mapIndexedNotNull { chunkIndex, chunk ->
|
|
if (chunk.text.isBlank()) {
|
|
null
|
|
} else {
|
|
SemanticParagraph(
|
|
text = chunk.text,
|
|
spans = chunk.spans,
|
|
style = style,
|
|
elementId = elementId.takeIf { chunkIndex == 0 },
|
|
cfi = cfi,
|
|
startCharOffsetInSource = chunk.startCharOffsetInSource,
|
|
blockIndex = nextBlockIndex++
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun parseContainer(
|
|
element: Element,
|
|
style: CssStyle,
|
|
inheritedLinkHref: String? = null
|
|
): List<SemanticBlock> {
|
|
val children = mutableListOf<SemanticBlock>()
|
|
val textNodesBuffer = mutableListOf<Node>()
|
|
val linkHref = element.linkHrefOrNull() ?: inheritedLinkHref
|
|
|
|
fun flushTextBuffer() {
|
|
if (textNodesBuffer.isEmpty()) return
|
|
val textChunks = buildSemanticTextAndSpanChunksFromNodes(
|
|
nodes = textNodesBuffer,
|
|
rootStyle = style,
|
|
inheritedLinkHref = linkHref
|
|
)
|
|
val containerElementId = element.id().ifBlank { null }
|
|
val containerCfi = element.getCfiPath()
|
|
textChunks.forEachIndexed { chunkIndex, chunk ->
|
|
if (chunk.text.isBlank()) return@forEachIndexed
|
|
|
|
children.add(
|
|
SemanticParagraph(
|
|
text = chunk.text,
|
|
spans = chunk.spans,
|
|
style = style,
|
|
elementId = containerElementId.takeIf { chunkIndex == 0 },
|
|
cfi = containerCfi,
|
|
startCharOffsetInSource = chunk.startCharOffsetInSource,
|
|
blockIndex = nextBlockIndex++
|
|
)
|
|
)
|
|
}
|
|
textNodesBuffer.clear()
|
|
}
|
|
|
|
element.childNodes().forEach { node ->
|
|
if (node is Element) {
|
|
val isEffectivelyBlock = node.isEffectivelySemanticBlock()
|
|
|
|
if (isEffectivelyBlock) {
|
|
flushTextBuffer()
|
|
children.addAll(parseNodeToSemanticBlocks(node, style, linkHref))
|
|
} else {
|
|
textNodesBuffer.add(node)
|
|
}
|
|
} else {
|
|
textNodesBuffer.add(node)
|
|
}
|
|
}
|
|
|
|
flushTextBuffer()
|
|
return children
|
|
}
|
|
|
|
private fun buildSemanticTextAndSpans(
|
|
rootElement: Element,
|
|
rootStyle: CssStyle,
|
|
inheritedLinkHref: String? = null
|
|
): Pair<String, List<SemanticSpan>> {
|
|
return buildSemanticTextAndSpansFromNodes(rootElement.childNodes(), rootStyle, rootElement, inheritedLinkHref)
|
|
}
|
|
|
|
private fun buildSemanticTextAndSpansFromNodes(
|
|
nodes: List<Node>,
|
|
rootStyle: CssStyle,
|
|
rootElement: Element? = null,
|
|
inheritedLinkHref: String? = null
|
|
): Pair<String, List<SemanticSpan>> {
|
|
val chunks = buildSemanticTextAndSpanChunksFromNodes(nodes, rootStyle, rootElement, inheritedLinkHref)
|
|
val firstChunk = chunks.firstOrNull() ?: return "" to emptyList()
|
|
return firstChunk.text to firstChunk.spans
|
|
}
|
|
|
|
private fun buildSemanticTextAndSpanChunksFromNodes(
|
|
nodes: List<Node>,
|
|
rootStyle: CssStyle,
|
|
rootElement: Element? = null,
|
|
inheritedLinkHref: String? = null
|
|
): List<SemanticTextChunk> {
|
|
val textBuilder = StringBuilder()
|
|
val spans = mutableListOf<SemanticSpan>()
|
|
val chunks = mutableListOf<SemanticTextChunk>()
|
|
val activeSpans = mutableListOf<ActiveSemanticSpan>()
|
|
var currentChunkStartOffset = 0
|
|
|
|
fun addSpan(
|
|
start: Int,
|
|
end: Int,
|
|
style: CssStyle,
|
|
linkHref: String?,
|
|
tag: String,
|
|
elementId: String?
|
|
) {
|
|
if (start < end || elementId != null) {
|
|
spans.add(
|
|
SemanticSpan(
|
|
start = start.coerceAtLeast(0),
|
|
end = end.coerceAtLeast(start),
|
|
style = style,
|
|
linkHref = linkHref,
|
|
tag = tag,
|
|
elementId = elementId
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
fun trimTrailingWhitespace(
|
|
text: String,
|
|
sourceSpans: List<SemanticSpan>
|
|
): Pair<String, List<SemanticSpan>> {
|
|
var newLength = text.length
|
|
while (newLength > 0 && text[newLength - 1].isWhitespace()) {
|
|
newLength--
|
|
}
|
|
|
|
if (newLength == text.length) return text to sourceSpans.toList()
|
|
|
|
val adjustedSpans = sourceSpans.mapNotNull { span ->
|
|
if (span.start >= newLength) {
|
|
null
|
|
} else if (span.end > newLength) {
|
|
span.copy(end = newLength)
|
|
} else {
|
|
span
|
|
}
|
|
}
|
|
return text.substring(0, newLength) to adjustedSpans
|
|
}
|
|
|
|
fun flushChunk(trimTrailing: Boolean) {
|
|
if (textBuilder.isEmpty()) return
|
|
|
|
activeSpans.forEach { active ->
|
|
addSpan(
|
|
start = active.startInChunk,
|
|
end = textBuilder.length,
|
|
style = active.style,
|
|
linkHref = active.linkHref,
|
|
tag = active.tag,
|
|
elementId = active.elementId
|
|
)
|
|
}
|
|
|
|
if (!inheritedLinkHref.isNullOrBlank()) {
|
|
addSpan(
|
|
start = 0,
|
|
end = textBuilder.length,
|
|
style = rootStyle,
|
|
linkHref = inheritedLinkHref,
|
|
tag = "a",
|
|
elementId = rootElement?.id()?.ifBlank { null }
|
|
)
|
|
}
|
|
|
|
val rawText = textBuilder.toString()
|
|
val rawLength = rawText.length
|
|
val (trimmedText, trimmedSpans) = if (trimTrailing) {
|
|
trimTrailingWhitespace(rawText, spans)
|
|
} else {
|
|
rawText to spans.toList()
|
|
}
|
|
if (trimmedText.isNotBlank()) {
|
|
chunks.add(
|
|
SemanticTextChunk(
|
|
text = trimmedText,
|
|
spans = trimmedSpans,
|
|
startCharOffsetInSource = currentChunkStartOffset
|
|
)
|
|
)
|
|
}
|
|
|
|
currentChunkStartOffset += rawLength
|
|
textBuilder.clear()
|
|
spans.clear()
|
|
activeSpans.forEach { it.startInChunk = 0 }
|
|
}
|
|
|
|
fun appendText(text: String) {
|
|
var offset = 0
|
|
while (offset < text.length) {
|
|
if (textBuilder.length >= MAX_SEMANTIC_TEXT_BLOCK_CHARS) {
|
|
flushChunk(trimTrailing = false)
|
|
}
|
|
val available = (MAX_SEMANTIC_TEXT_BLOCK_CHARS - textBuilder.length).coerceAtLeast(1)
|
|
val end = (offset + available).coerceAtMost(text.length)
|
|
textBuilder.append(text, offset, end)
|
|
offset = end
|
|
if (textBuilder.length >= MAX_SEMANTIC_TEXT_BLOCK_CHARS) {
|
|
flushChunk(trimTrailing = false)
|
|
}
|
|
}
|
|
}
|
|
|
|
fun normalizeTextForWhiteSpace(rawText: String, whiteSpace: String?): String {
|
|
return when (whiteSpace) {
|
|
"pre", "pre-wrap", "break-spaces" -> rawText
|
|
"pre-line" -> rawText.replace(Regex("[\\t\\x0B\\f\\r ]+"), " ")
|
|
else -> rawText.replace(Regex("\\s+"), " ")
|
|
}
|
|
}
|
|
|
|
fun appendTransformedText(rawText: String, style: CssStyle) {
|
|
val normalizedText = normalizeTextForWhiteSpace(rawText, style.whiteSpace)
|
|
var start = 0
|
|
while (start < normalizedText.length) {
|
|
val end = (start + TEXT_APPEND_SLICE_CHARS).coerceAtMost(normalizedText.length)
|
|
val normalizedSlice = buildString(end - start) {
|
|
for (i in start until end) {
|
|
append(if (normalizedText[i] == '\n' && style.whiteSpace !in listOf("pre", "pre-wrap", "pre-line", "break-spaces")) ' ' else normalizedText[i])
|
|
}
|
|
}
|
|
val transformedSlice = when (style.textTransform) {
|
|
"uppercase" -> normalizedSlice.uppercase()
|
|
"lowercase" -> normalizedSlice.lowercase()
|
|
"capitalize" -> normalizedSlice.capitalizeWords()
|
|
else -> normalizedSlice
|
|
}
|
|
appendText(transformedSlice)
|
|
start = end
|
|
}
|
|
}
|
|
|
|
fun materializeCssContent(rawContent: String?, element: Element): String? {
|
|
if (rawContent.isNullOrBlank()) return null
|
|
val content = rawContent.trim()
|
|
if (content == "none" || content == "normal") return null
|
|
val tokens = Regex("""attr\(([^)]+)\)|"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'""")
|
|
.findAll(content)
|
|
.mapNotNull { match ->
|
|
when {
|
|
match.groupValues[1].isNotBlank() -> element.attr(match.groupValues[1].trim()).ifBlank { null }
|
|
match.groupValues[2].isNotBlank() -> match.groupValues[2].replace("\\\"", "\"")
|
|
match.groupValues[3].isNotBlank() -> match.groupValues[3].replace("\\'", "'")
|
|
else -> null
|
|
}
|
|
}
|
|
.toList()
|
|
return tokens.joinToString("").ifBlank {
|
|
content.removeSurrounding("\"").removeSurrounding("'").takeIf { it.isNotBlank() }
|
|
}
|
|
}
|
|
|
|
fun appendGeneratedContent(element: Element, inheritedStyle: CssStyle, pseudoElement: String) {
|
|
val generatedStyle = getPseudoElementStyle(element, pseudoElement, inheritedStyle)
|
|
if (generatedStyle.display == "none") return
|
|
val text = materializeCssContent(generatedStyle.content, element) ?: return
|
|
val start = textBuilder.length
|
|
appendTransformedText(text, generatedStyle)
|
|
val end = textBuilder.length
|
|
addSpan(start, end, generatedStyle, null, "::$pseudoElement", element.id().ifBlank { null })
|
|
}
|
|
|
|
fun processNode(node: Node, inheritedStyle: CssStyle) {
|
|
when (node) {
|
|
is TextNode -> {
|
|
appendTransformedText(node.wholeText, inheritedStyle)
|
|
}
|
|
is Element -> {
|
|
if (node.tagName().lowercase() == "br") {
|
|
appendText("\n"); return
|
|
}
|
|
val currentElementStyle = getElementStyle(node, inheritedStyle.customProperties)
|
|
val newStyle = inheritedStyle.merge(currentElementStyle).withResolvedFontFamily()
|
|
if (newStyle.display == "none") return
|
|
val tag = node.tagName().lowercase()
|
|
val href = node.linkHrefOrNull()
|
|
?: activeSpans.asReversed().firstOrNull { !it.linkHref.isNullOrBlank() }?.linkHref
|
|
?: inheritedLinkHref
|
|
val elementId = node.id().ifBlank { null }
|
|
val activeSpan = ActiveSemanticSpan(
|
|
startInChunk = textBuilder.length,
|
|
style = newStyle,
|
|
linkHref = href,
|
|
tag = tag,
|
|
elementId = elementId
|
|
)
|
|
activeSpans.add(activeSpan)
|
|
appendGeneratedContent(node, newStyle, "before")
|
|
node.childNodes().forEach { processNode(it, newStyle) }
|
|
appendGeneratedContent(node, newStyle, "after")
|
|
activeSpans.removeAt(activeSpans.lastIndex)
|
|
val endIndex = textBuilder.length
|
|
|
|
// Capture span if it has content OR if it has an ID (anchor)
|
|
addSpan(
|
|
start = activeSpan.startInChunk,
|
|
end = endIndex,
|
|
style = newStyle,
|
|
linkHref = href,
|
|
tag = tag,
|
|
elementId = elementId
|
|
)
|
|
}
|
|
}
|
|
}
|
|
rootElement?.let { appendGeneratedContent(it, rootStyle, "before") }
|
|
nodes.forEach { processNode(it, rootStyle) }
|
|
rootElement?.let { appendGeneratedContent(it, rootStyle, "after") }
|
|
flushChunk(trimTrailing = true)
|
|
return chunks
|
|
}
|
|
|
|
private data class ActiveSemanticSpan(
|
|
var startInChunk: Int,
|
|
val style: CssStyle,
|
|
val linkHref: String?,
|
|
val tag: String,
|
|
val elementId: String?
|
|
)
|
|
|
|
private fun parseMathPlaceholderToSemantic(element: Element, style: CssStyle): List<SemanticBlock> {
|
|
val uniqueId = element.id()
|
|
val svgContent = mathSvgCache[uniqueId]
|
|
val altText = element.attr("alttext").ifBlank { "Equation" }
|
|
var svgWidth: String? = null
|
|
var svgHeight: String? = null
|
|
var svgViewBox: String? = null
|
|
if (svgContent != null) {
|
|
val svgDoc = Jsoup.parse(svgContent)
|
|
svgDoc.getElementsByTag("svg").firstOrNull()?.let {
|
|
svgWidth = it.attr("width")
|
|
svgHeight = it.attr("height")
|
|
svgViewBox = it.attr("viewBox")
|
|
}
|
|
}
|
|
return listOf(
|
|
SemanticMath(
|
|
svgContent, altText, svgWidth, svgHeight, svgViewBox,
|
|
isFromMathJax = true, style = style,
|
|
elementId = element.id().ifBlank { null }, cfi = element.getCfiPath(), blockIndex = nextBlockIndex++
|
|
)
|
|
)
|
|
}
|
|
|
|
private fun parseSvgElementToSemantic(svgElement: Element, style: CssStyle): SemanticBlock? {
|
|
val children = svgElement.children()
|
|
val imageElement = children.firstOrNull()?.takeIf { children.size == 1 && it.tagName() == "image" }
|
|
|
|
if (imageElement != null) {
|
|
HtmlParserLog.d("Detected SVG acting as a wrapper for an image. Parsing as SemanticImage.")
|
|
val href = imageElement.attr("href").ifBlank { imageElement.attr("xlink:href") }
|
|
if (href.isBlank()) return null
|
|
|
|
val imagePath = resolveImagePath(href) ?: return null
|
|
|
|
val (width, height) = imageDimensionsCache[imagePath]
|
|
?: resourceResolver.imageDimensions(imagePath)
|
|
?: Pair(null, null)
|
|
|
|
return SemanticImage(
|
|
path = imagePath,
|
|
altText = svgElement.getElementsByTag("title").firstOrNull()?.text() ?: "Cover Image",
|
|
intrinsicWidth = width,
|
|
intrinsicHeight = height,
|
|
style = style,
|
|
elementId = svgElement.id().ifBlank { null },
|
|
cfi = svgElement.getCfiPath(),
|
|
blockIndex = nextBlockIndex++
|
|
)
|
|
}
|
|
|
|
HtmlParserLog.d("Parsing genuine SVG content into SemanticMath block.")
|
|
val title = svgElement.getElementsByTag("title").firstOrNull()?.text()
|
|
val desc = svgElement.getElementsByTag("desc").firstOrNull()?.text()
|
|
val altText = title ?: desc ?: "SVG Image"
|
|
|
|
return SemanticMath(
|
|
svgContent = svgElement.outerHtml(),
|
|
altText = altText,
|
|
style = style,
|
|
elementId = svgElement.id().ifBlank { null },
|
|
cfi = svgElement.getCfiPath(),
|
|
svgWidth = svgElement.attr("width").ifBlank { null },
|
|
svgHeight = svgElement.attr("height").ifBlank { null },
|
|
svgViewBox = svgElement.attr("viewBox").ifBlank { null },
|
|
isFromMathJax = false,
|
|
blockIndex = nextBlockIndex++
|
|
)
|
|
}
|
|
|
|
private fun parseImageElementToSemantic(element: Element, style: CssStyle): SemanticBlock? {
|
|
val src = element.attr("src")
|
|
if (src.isBlank()) return null
|
|
|
|
val imagePath = resolveImagePath(src) ?: return null
|
|
|
|
if (imagePath.substringAfterLast('.', "").equals("svg", ignoreCase = true)) {
|
|
return try {
|
|
val svgContent = resourceResolver.readText(imagePath) ?: return null
|
|
val svgElement = Jsoup.parseBodyFragment(svgContent).body().children().firstOrNull()
|
|
svgElement?.let { parseSvgElementToSemantic(it, style) }
|
|
} catch (e: Exception) {
|
|
HtmlParserLog.e(e, "Failed to read SVG from <img> tag: $imagePath")
|
|
null
|
|
}
|
|
}
|
|
|
|
val (width, height) = imageDimensionsCache[imagePath]
|
|
?: resourceResolver.imageDimensions(imagePath)
|
|
?: Pair(null, null)
|
|
|
|
return SemanticImage(
|
|
path = imagePath,
|
|
altText = element.attr("alt"),
|
|
intrinsicWidth = width,
|
|
intrinsicHeight = height,
|
|
style = style,
|
|
elementId = element.id().ifBlank { null },
|
|
cfi = element.getCfiPath(),
|
|
blockIndex = nextBlockIndex++
|
|
)
|
|
}
|
|
|
|
private fun resolveImagePath(src: String): String? {
|
|
if (src.isBlank()) return null
|
|
return resourceResolver.resolvePath(chapterAbsPath, extractionBasePath, src)
|
|
}
|
|
|
|
private fun parseListElementToSemantic(
|
|
listElement: Element,
|
|
listStyle: CssStyle,
|
|
inheritedLinkHref: String?
|
|
): List<SemanticBlock> {
|
|
val isOrdered = listElement.tagName().lowercase() == "ol"
|
|
val items = listElement.children().mapNotNull { child ->
|
|
if (child.tagName().lowercase() != "li") return@mapNotNull null
|
|
val itemStyle = listStyle
|
|
.merge(getElementStyle(child, listStyle.customProperties))
|
|
.withResolvedFontFamily()
|
|
.withResolvedBlockResources()
|
|
val (text, spans) = buildSemanticTextAndSpans(child, itemStyle, inheritedLinkHref)
|
|
val imageSrc = itemStyle.blockStyle.listStyleImage?.let { resolveImagePath(it) }
|
|
SemanticListItem(text, spans, itemStyle, child.id().ifBlank { null }, child.getCfiPath(), 0, imageSrc, blockIndex = nextBlockIndex++)
|
|
}
|
|
return listOf(SemanticList(items, isOrdered, listStyle, listElement.id().ifBlank { null }, listElement.getCfiPath(), blockIndex = nextBlockIndex++))
|
|
}
|
|
|
|
private fun parseTableElementToSemantic(
|
|
tableElement: Element,
|
|
tableStyle: CssStyle,
|
|
inheritedLinkHref: String?
|
|
): SemanticTable? {
|
|
val rows = tableElement.getElementsByTag("tr").mapNotNull { rowElement ->
|
|
val rowStyle = getElementStyle(rowElement, tableStyle.customProperties)
|
|
if (rowStyle.display == "none") return@mapNotNull null
|
|
|
|
val cells = rowElement.children().mapNotNull { cellElement ->
|
|
val tagName = cellElement.tagName().lowercase()
|
|
if (tagName !in listOf("td", "th")) return@mapNotNull null
|
|
|
|
var cellCssStyle = getElementStyle(cellElement, rowStyle.customProperties)
|
|
.withResolvedFontFamily()
|
|
.withResolvedBlockResources()
|
|
if (cellCssStyle.display == "none") return@mapNotNull null
|
|
|
|
if (!cellCssStyle.blockStyle.backgroundColor.isSpecified) {
|
|
if (rowStyle.blockStyle.backgroundColor.isSpecified) {
|
|
cellCssStyle = cellCssStyle.copy(
|
|
blockStyle = cellCssStyle.blockStyle.copy(
|
|
backgroundColor = rowStyle.blockStyle.backgroundColor
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
val cellContent = parseContainer(cellElement, cellCssStyle, inheritedLinkHref)
|
|
SemanticTableCell(cellContent, tagName == "th", cellElement.attr("colspan").toIntOrNull() ?: 1, cellCssStyle)
|
|
}
|
|
cells.ifEmpty { null }
|
|
}
|
|
if (rows.isEmpty()) return null
|
|
return SemanticTable(rows, tableStyle, tableElement.id().ifBlank { null }, tableElement.getCfiPath(), blockIndex = nextBlockIndex++)
|
|
}
|
|
|
|
private fun Element.linkHrefOrNull(): String? {
|
|
val normalizedTagName = tagName().substringAfter(':')
|
|
if (!normalizedTagName.equals("a", ignoreCase = true)) return null
|
|
|
|
return attr("href")
|
|
.ifBlank { attr("xlink:href") }
|
|
.ifBlank { attr("l:href") }
|
|
.ifBlank { attr("epub:href") }
|
|
.ifBlank { null }
|
|
}
|
|
}
|