Windows ga (#358)

* Enhance Cloud TTS with navigation controls and shared UI overlay in desktop app

* Refactor Cloud TTS voice settings and remove standalone settings overlay on desktop app

* Persist reader window state and improve slider interaction in desktop app

* Improve PDF page transitions and refine focus management in desktop app

* Refactor scrollbar interaction and adjust desktop modal focus handling

* Improve PDF sidecar synchronization and cross-platform metadata compatibility

* Refactor PDF annotation comment logic to shared module and implement Desktop UI

* Refactor reader screen to use tap-to-toggle and full-width styling in desktop app

* Refactor reader workspace layout and chrome-panel interactions

* Implement global search keyboard shortcuts and focusable chrome layers

* Implement flavor-specific legal links and update the About UI

* Refactor reader UI controls on desktop app

* Enhance desktop folder sync with background metadata extraction and improved error handling

* Refactor Library UI and remove redundant Home tab in desktop app

* Add custom tooltips to reader icon buttons in desktop app

* Integrate app theme controls into reader interfaces on desktop

* Add right-to-left pagination support and improve focus restoration on desktop app

* Improve EPUB pagination geometry and diagnostic logging for layout cutoffs on desktop app

* Update desktop reader defaults and implement settings migration

* Implement block-based position tracking in ReaderLocator

* Enhance EPUB highlighting reliability in desktop app

* Add support for custom reader themes and update highlight palette logic in desktop app

* Replace the Tools panel with a "More" dropdown menu and refactor account UI

* Implement account profile header in desktop sidebar

* Implement cloud sync reliability improvements and sidebar toggle on desktop app

* Improve EPUB annotation synchronization and highlight mapping accuracy in desktop app

* Integrate WebView2 for EPUB vertical rendering on Windows

* Refactor reader layout logic and enhance WebView2 diagnostics

* Improve vertical reading layout and WebView2 resizing on Desktop

* Refine vertical reading mode layout and margin handling

* Enhance reader locator precision and Desktop mode-switching reliability

* Implement chapter-level caching and warm-start pagination in desktop app

* Replace bundled KCEF with native system webviews via SWT

* Refactor EPUB page info bar visibility and layout logic

* Improve PDF toolbar persistence and fix tab reactivation logic

* Enable multi-selection and bulk operations for custom fonts

* Refactor instrumentation tests

* Add EPUB UI test fixture and initial instrumentation tests

* Expand EpubReader UI tests and improve accessibility

* Add instrumentation tests and test tags for library and reader screens

* Enhance OPDS parser logic and catalog integration

* Add support for toggling local synchronization on a per-folder basis.

* Implement tri-state sizing for the TTS overlay

* Persist TTS overlay size across sessions

* Refactor reader brightness control and add incremental step buttons

* Improve CSS support, pagination control, and style-aware semantic caching

* Improve link handling, interaction, and diagnostics in the paginated reader

* crash fixes

* Implement persistent pending removal for external files

* Implement book-specific word replacements

* Add native vertical reading mode with custom renderer

* Implement text selection and navigation improvements for the native vertical reader

* Implement locator-based navigation and improved vertical scrolling in native vertical mode in epub

* Implement lazy loading and chapter prefetching for native vertical reader

* Improve window lifecycle and disposal handling on Desktop

* Optimize vertical reading performance in desktop app

* Enhance TTS start accuracy and diagnostic logging on desktop

* Refactor AI settings visibility on desktop

* Improve pagination height measurement and enhance cutoff diagnostics

* Implement lifecycle management and improve justified text splitting for pagination

* Refine AI usage tracking and force AI feature visibility on Desktop

* Add descriptive context comments and usage examples to string and plural resources.

* Optimize performance and memory usage in search and state mapping

* Replace reader page sliders with minimal slider and navigation controls

* Add support for CBT comic archives

* Harden file path validation and XML parsing to prevent security vulnerabilities

* Implement local account profile caching and optimize desktop performance

* Improve desktop persistence reliability and add Linux secure storage support

* Improved PDF zoom stability and layout prediction during zoom commits

* Improved PDF spread layout prediction, reader focus restoration, and account profile caching

* Enhance highlight precision and scoping using block-local offsets and CFIs

* Enhance cloud book content synchronization and background downloads

* Implement granular timestamp tracking for reading positions and PDF annotations

* Restrict diagnostic logging and stack traces to debug builds

* Refine PDF page gaps and reader chrome interaction logic

* Refactor PDF highlight rendering and overhaul Desktop sidebar UI

* Implement a new interaction dock and undo/redo history for PDF annotations in desktop

* Enhance PDF color picker and improve navigation scroll restoration

* Add highlight palette customization and improve selection menu UI in desktop app epub reader

* Enhance desktop shelf management and library organization
This commit is contained in:
Aryan 2026-06-02 00:51:42 +05:30 committed by GitHub
parent 5971eaa571
commit 83dcafa4b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
444 changed files with 47279 additions and 8096 deletions

View file

@ -39,7 +39,8 @@ import org.jsoup.select.Selector
import java.util.ArrayDeque
import java.util.IdentityHashMap
private val unsupportedPseudoElementRegex = Regex("::?(before|after|first-letter|first-line|marker|selection)", RegexOption.IGNORE_CASE)
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(
@ -181,7 +182,6 @@ private class SemanticHtmlParser(
private val fontFamilyLoader: HtmlFontFamilyLoader,
private val adaptThemeColors: Boolean
) {
private val styleCache = mutableMapOf<String, CssStyle>()
private val semanticBlockDescendantCache = IdentityHashMap<Element, Boolean>()
private var combinedRules: OptimizedCssRules = cssRules
private val currentFontFamilyMap: MutableMap<String, FontFamily> = fontFamilyMap.toMutableMap()
@ -273,12 +273,14 @@ private class SemanticHtmlParser(
private fun parseNodeToSemanticBlocks(
element: Element,
inheritedStyle: CssStyle
inheritedStyle: CssStyle,
inheritedLinkHref: String? = null
): List<SemanticBlock> {
val elementOwnStyle = getElementStyle(element)
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
listStyleImage = elementOwnStyle.blockStyle.listStyleImage ?: inheritedStyle.blockStyle.listStyleImage,
visibility = elementOwnStyle.blockStyle.visibility ?: inheritedStyle.blockStyle.visibility
)
val finalStyle = elementOwnStyle.copy(
@ -290,53 +292,41 @@ private class SemanticHtmlParser(
textTransform = elementOwnStyle.textTransform ?: inheritedStyle.textTransform,
hyphens = elementOwnStyle.hyphens ?: inheritedStyle.hyphens,
fontVariantNumeric = elementOwnStyle.fontVariantNumeric ?: inheritedStyle.fontVariantNumeric,
textEmphasis = elementOwnStyle.textEmphasis ?: inheritedStyle.textEmphasis
textEmphasis = elementOwnStyle.textEmphasis ?: inheritedStyle.textEmphasis,
whiteSpace = elementOwnStyle.whiteSpace ?: inheritedStyle.whiteSpace,
customProperties = inheritedStyle.customProperties + elementOwnStyle.customProperties
)
if (finalStyle.display == "none") return emptyList()
return elementToSemanticBlocks(element, finalStyle)
val linkHref = element.linkHrefOrNull() ?: inheritedLinkHref
return elementToSemanticBlocks(element, finalStyle.withResolvedBlockResources(), linkHref)
}
private fun getElementDescriptor(element: Element): String {
return buildString {
append(element.tagName())
val id = element.id()
if (id.isNotEmpty()) append('#').append(id)
val classes = element.classNames()
if (classes.isNotEmpty()) append('.').append(classes.sorted().joinToString("."))
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 getElementStyle(element: Element): CssStyle {
val cacheKey = getElementDescriptor(element)
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()
}
val baseStyle = styleCache.getOrPut(cacheKey) {
val potentialRules = mutableListOf<CssRule>()
combinedRules.byTag[element.tagName()]?.let { potentialRules.addAll(it) }
element.id().takeIf { it.isNotEmpty() }?.let { id ->
combinedRules.byId[id]?.let { potentialRules.addAll(it) }
}
element.classNames().forEach { className ->
combinedRules.byClass[className]?.let { potentialRules.addAll(it) }
}
potentialRules.addAll(combinedRules.otherComplex)
val matchingRules = potentialRules.filter { rule ->
if (unsupportedPseudoElementRegex.containsMatchIn(rule.selector.selector)) return@filter false
try {
element.`is`(rule.selector.selector)
} catch (e: Selector.SelectorParseException) {
HtmlParserLog.w(e, "Jsoup failed to parse selector '${rule.selector.selector}'.")
false
}
}
matchingRules.sortedBy { it.selector.specificity }.fold(CssStyle()) { acc, rule ->
acc.merge(rule.style)
}
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()) {
@ -347,7 +337,8 @@ private class SemanticHtmlParser(
constraints,
onlyImportant = false,
isDarkTheme = false,
adaptThemeColors = adaptThemeColors
adaptThemeColors = adaptThemeColors,
inheritedCustomProperties = elementStyle.customProperties
)
elementStyle = elementStyle.merge(inlineStyle)
}
@ -365,9 +356,37 @@ private class SemanticHtmlParser(
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)
}
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
elementStyle: CssStyle,
inheritedLinkHref: String?
): List<SemanticBlock> {
val elementId = element.id().ifBlank { null }
val cfi = element.getCfiPath()
@ -378,7 +397,7 @@ private class SemanticHtmlParser(
if (elementStyle.blockStyle.display == "flex") {
val children = element.children().flatMap { child ->
parseNodeToSemanticBlocks(child, elementStyle)
parseNodeToSemanticBlocks(child, elementStyle, inheritedLinkHref)
}
return listOf(SemanticFlexContainer(children, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
}
@ -405,16 +424,23 @@ private class SemanticHtmlParser(
margin = BoxBorders()
)
)
val children = parseContainer(element, childStyle)
val children = parseContainer(element, childStyle, inheritedLinkHref)
listOf(SemanticFlexContainer(children, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else {
parseContainer(element, elementStyle)
parseContainer(element, elementStyle, inheritedLinkHref)
}
}
"svg" -> parseSvgElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
"table" -> parseTableElementToSemantic(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) {
@ -449,13 +475,13 @@ private class SemanticHtmlParser(
margin = BoxBorders()
)
)
val children = parseContainer(element, childStyle)
val children = parseContainer(element, childStyle, inheritedLinkHref)
listOf(SemanticFlexContainer(children, headerStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else {
parseContainer(element, headerStyle)
parseContainer(element, headerStyle, inheritedLinkHref)
}
} else {
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle)
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++))
@ -463,13 +489,13 @@ private class SemanticHtmlParser(
}
}
"hr" -> listOf(SemanticSpacer(style = elementStyle, elementId = elementId, cfi = cfi, blockIndex = nextBlockIndex++))
"ul", "ol" -> parseListElementToSemantic(element, elementStyle)
"ul", "ol" -> parseListElementToSemantic(element, elementStyle, inheritedLinkHref)
else -> {
val hasBlockDescendant = !element.isBlock && element.hasSemanticBlockDescendant()
if (element.isBlock || hasBlockDescendant) {
parseContainer(element, elementStyle)
parseContainer(element, elementStyle, inheritedLinkHref)
} else {
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle)
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle, inheritedLinkHref)
if (text.isNotBlank()) {
listOf(SemanticParagraph(text, spans, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else emptyList()
@ -485,36 +511,61 @@ private class SemanticHtmlParser(
} else result
}
private fun parseContainer(element: Element, style: CssStyle): List<SemanticBlock> {
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(textNodesBuffer, style)
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
val finalSpans = chunk.spans.toMutableList()
if (element.tagName().lowercase() == "a") {
val href = element.attr("href").ifBlank { null }
if (href != null) {
finalSpans.add(SemanticSpan(
start = 0,
end = chunk.text.length,
style = style,
linkHref = href,
tag = "a",
elementId = containerElementId.takeIf { chunkIndex == 0 }
))
}
}
children.add(
SemanticParagraph(
text = chunk.text,
spans = finalSpans,
spans = chunk.spans,
style = style,
elementId = containerElementId.takeIf { chunkIndex == 0 },
cfi = containerCfi,
@ -532,7 +583,7 @@ private class SemanticHtmlParser(
if (isEffectivelyBlock) {
flushTextBuffer()
children.addAll(parseNodeToSemanticBlocks(node, style))
children.addAll(parseNodeToSemanticBlocks(node, style, linkHref))
} else {
textNodesBuffer.add(node)
}
@ -547,23 +598,28 @@ private class SemanticHtmlParser(
private fun buildSemanticTextAndSpans(
rootElement: Element,
rootStyle: CssStyle
rootStyle: CssStyle,
inheritedLinkHref: String? = null
): Pair<String, List<SemanticSpan>> {
return buildSemanticTextAndSpansFromNodes(rootElement.childNodes(), rootStyle)
return buildSemanticTextAndSpansFromNodes(rootElement.childNodes(), rootStyle, rootElement, inheritedLinkHref)
}
private fun buildSemanticTextAndSpansFromNodes(
nodes: List<Node>,
rootStyle: CssStyle
rootStyle: CssStyle,
rootElement: Element? = null,
inheritedLinkHref: String? = null
): Pair<String, List<SemanticSpan>> {
val chunks = buildSemanticTextAndSpanChunksFromNodes(nodes, rootStyle)
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
rootStyle: CssStyle,
rootElement: Element? = null,
inheritedLinkHref: String? = null
): List<SemanticTextChunk> {
val textBuilder = StringBuilder()
val spans = mutableListOf<SemanticSpan>()
@ -602,7 +658,7 @@ private class SemanticHtmlParser(
newLength--
}
if (newLength == text.length) return text to sourceSpans
if (newLength == text.length) return text to sourceSpans.toList()
val adjustedSpans = sourceSpans.mapNotNull { span ->
if (span.start >= newLength) {
@ -630,6 +686,17 @@ private class SemanticHtmlParser(
)
}
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) {
@ -669,16 +736,25 @@ private class SemanticHtmlParser(
}
}
fun appendTransformedText(rawText: String, textTransform: String?) {
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 < rawText.length) {
val end = (start + TEXT_APPEND_SLICE_CHARS).coerceAtMost(rawText.length)
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 (rawText[i] == '\n') ' ' else rawText[i])
append(if (normalizedText[i] == '\n' && style.whiteSpace !in listOf("pre", "pre-wrap", "pre-line", "break-spaces")) ' ' else normalizedText[i])
}
}
val transformedSlice = when (textTransform) {
val transformedSlice = when (style.textTransform) {
"uppercase" -> normalizedSlice.uppercase()
"lowercase" -> normalizedSlice.lowercase()
"capitalize" -> normalizedSlice.capitalizeWords()
@ -689,19 +765,52 @@ private class SemanticHtmlParser(
}
}
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.textTransform)
appendTransformedText(node.wholeText, inheritedStyle)
}
is Element -> {
if (node.tagName().lowercase() == "br") {
appendText("\n"); return
}
val currentElementStyle = getElementStyle(node)
val currentElementStyle = getElementStyle(node, inheritedStyle.customProperties)
val newStyle = inheritedStyle.merge(currentElementStyle)
if (newStyle.display == "none") return
val tag = node.tagName().lowercase()
val href = if (tag == "a") node.attr("href").ifBlank { null } else null
val href = node.linkHrefOrNull()
?: activeSpans.asReversed().firstOrNull { !it.linkHref.isNullOrBlank() }?.linkHref
?: inheritedLinkHref
val elementId = node.id().ifBlank { null }
val activeSpan = ActiveSemanticSpan(
startInChunk = textBuilder.length,
@ -711,7 +820,9 @@ private class SemanticHtmlParser(
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
@ -727,7 +838,9 @@ private class SemanticHtmlParser(
}
}
}
rootElement?.let { appendGeneratedContent(it, rootStyle, "before") }
nodes.forEach { processNode(it, rootStyle) }
rootElement?.let { appendGeneratedContent(it, rootStyle, "after") }
flushChunk(trimTrailing = true)
return chunks
}
@ -848,28 +961,36 @@ private class SemanticHtmlParser(
return resourceResolver.resolvePath(chapterAbsPath, extractionBasePath, src)
}
private fun parseListElementToSemantic(listElement: Element, listStyle: CssStyle): List<SemanticBlock> {
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))
val (text, spans) = buildSemanticTextAndSpans(child, itemStyle)
val itemStyle = listStyle.merge(getElementStyle(child, listStyle.customProperties)).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): SemanticTable? {
private fun parseTableElementToSemantic(
tableElement: Element,
tableStyle: CssStyle,
inheritedLinkHref: String?
): SemanticTable? {
val rows = tableElement.getElementsByTag("tr").mapNotNull { rowElement ->
val rowStyle = getElementStyle(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)
var cellCssStyle = getElementStyle(cellElement, rowStyle.customProperties).withResolvedBlockResources()
if (cellCssStyle.display == "none") return@mapNotNull null
if (!cellCssStyle.blockStyle.backgroundColor.isSpecified) {
@ -882,7 +1003,7 @@ private class SemanticHtmlParser(
}
}
val cellContent = parseContainer(cellElement, cellCssStyle)
val cellContent = parseContainer(cellElement, cellCssStyle, inheritedLinkHref)
SemanticTableCell(cellContent, tagName == "th", cellElement.attr("colspan").toIntOrNull() ?: 1, cellCssStyle)
}
cells.ifEmpty { null }
@ -890,4 +1011,15 @@ private class SemanticHtmlParser(
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 }
}
}