Desktop app (#308)

* Implement build profiles and feature policy for offline desktop builds

* Introduce unified cross-platform Settings Hub

* Refactor main settings into a hierarchical page-based navigation model

* Refactor library projection to use shared multiplatform logic

* Refactor UI state consumption by removing intermediate screen models

* Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic

* Refactor state management for tabs, selection, and pinning to use shared bridge logic

* Refactor file type management and validation into a centralized shared module

* Centralize file type resolution and improve handling of unknown types

* Centralize book import logic with SharedImportPlanner

* Refactor magnifier geometry logic and coordinate mapping

* Properly handle orientation changes in scroll-locked PDF reader

* Add screen orientation controls to EPUB and PDF readers

* Implement right-to-left (RTL) pagination support and refactor reader menus

* Separate right-to-left pagination settings for PDF and EPUB

* Ensure PDF page data is scoped by document key for multi tab support

* Implement theme-aware link styling for the epub reader

* Implement jump history for back and forward navigation in the epub reader

* Improve locator handling and navigation logic in paginated reader mode

* Implement stable pagination navigation and location tracking

* Centralize banner message management and auto-dismiss logic in MainViewModel

* Implement zoom and pan state preservation for PDF pan lock mode

* Enhance reader navigation UI and workspace layout management in desktop app

* Refactor reader navigation sidebar and relocate search controls in desktop app

* Enhance reader UI with redesigned selection menus and bottom sheet overlays

* Implement custom highlight palettes and reader theme customization in desktop app

* Implement cross-platform modal layer and refine reader UI styling

* Improve highlight accuracy and implement metadata enrichment on book open in desktop app

* Implement two-page spread layout for paginated reader on desktop

* Implement persistent caching for book loading and pagination in desktop app

* Implement persistent caching for book loading and pagination in desktop app

* Optimize reader settings updates by separating layout and appearance changes in desktop app

* Improve desktop window branding and native Windows styling

* Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app

* Refine selection handle positioning and interaction logic

* Implement EPUB selection debug logging and improve handle targeting

* Optimize desktop book loading performance and UI responsiveness

* Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer.

* Implement smooth zoom preview for the PDF reader in desktop app

* Optimize PDF rendering performance and responsiveness in the desktop reader

* Implement conditional diagnostic logging and update desktop build configuration

* Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling

* Added management options for annotations and highlights in the sidebar in desktop app

* Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability.

* Refined library filters and enhanced OPDS functionality in desktop app

* Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app

* Added PPTX support including document parsing, rendering, and indexing

* Improved PPTX rendering and layout accuracy

* Implemented text autofit support for PPTX rendering

* Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity

* Improved EPUB pagination accuracy and added layout telemetry in desktop app

* Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app

* Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling

* Implemented billing account obfuscation

* Implemented hierarchical folder navigation and improved library selection functionality in desktop app

* Implemented platform-aware directory resolution and multi-platform native library support for desktop

* Added full-screen mode for the reader workspace

* Added PDF zoom indicator and interactive vertical scrollbar with page tooltips

* Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management

* Updated PDF indexing to replace existing page text and removed search result item keys

* Implemented "preparing" foreground notification for TTS service

* Optimized PDF rendering performance by pre-calculating page-specific annotations

* Refactored desktop packaging tasks and improved distribution configuration

* Optimized EPUB parser memory usage and added path traversal protection

* Refactored WorkManager monitoring logic and added work pruning

* Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks

* Implemented bitmap size limits and scaling to prevent canvas rendering errors

* Split long text paragraphs into multiple semantic blocks during HTML parsing

* Implemented local ActionMode for text selection to prevent platform crashes

* Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic

* Added desktop startup splash screen and deferred WebView initialization

* Reorganized settings hub and added separate PDF reader defaults

* Implemented embedded cover extraction and metadata support for MOBI and FB2 formats

* Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance.

* Implemented procedurally generated book covers and replaced static placeholders

* Redesigned search UI with a top bar and results overlay in desktop app

* Added PDF page gap and overlay visibility options and implemented DesktopBookImporter

* Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop

* Implemented PDF viewport persistence for zoom and scroll positions in desktop app

* Improved desktop fullscreen implementation and state restoration

* Implemented desktop window state persistence

* Implemented flavor-based branding and ProGuard configuration for desktop builds

* Implemented precise reader positioning and improved highlight rendering logic in desktop app

* Added support for user-editable book metadata

* Enhanced book metadata support and integrated info/edit dialogs

* Implemented embedded EPUB metadata editing

* Improved highlight mapping and added custom scrollbar styling for the reader.

* Reduced desktop WebView bundle size by excluding unused locales and runtime files

* Added neutral pan mode as the default PDF interaction state.

* Refactored library empty states and updated primary navigation tabs in desktop app

* Implemented native paginated reader and unified content rendering architecture in desktop epub reader

* Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing.

* Improved pagination overflow detection in desktop

* Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
This commit is contained in:
Aryan 2026-05-15 22:36:51 +05:30 committed by GitHub
parent c0d0e57e79
commit b20ade9946
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
247 changed files with 43321 additions and 7087 deletions

View file

@ -36,8 +36,40 @@ 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("::?(before|after|first-letter|first-line|marker|selection)", 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?
@ -91,6 +123,12 @@ private fun String.capitalizeWords(): String =
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.
@ -144,13 +182,14 @@ private class SemanticHtmlParser(
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()
private var nextBlockIndex = 0
fun parse(html: String): List<SemanticBlock> {
val document = Jsoup.parse(html, chapterAbsPath)
val inlineCssContent = document.head().select("style").joinToString(separator = "\n") { it.data() }
val inlineCssContent = document.head().getElementsByTag("style").joinToString(separator = "\n") { it.data() }
if (inlineCssContent.isNotBlank()) {
HtmlParserLog.d("Found inline <style> content in $chapterAbsPath. Parsing...")
@ -177,6 +216,61 @@ private class SemanticHtmlParser(
return parseContainer(body, getElementStyle(body))
}
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
@ -322,7 +416,7 @@ private class SemanticHtmlParser(
"math-placeholder" -> parseMathPlaceholderToSemantic(element, elementStyle)
"img" -> parseImageElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
"h1", "h2", "h3", "h4", "h5", "h6" -> {
val hasNonTextChildren = element.select("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").isNotEmpty()
val hasNonTextChildren = element.hasSemanticBlockDescendant()
if (hasNonTextChildren) {
val level = tagName.substring(1).toIntOrNull() ?: 1
val fontSizeMultiplier = when (level) {
@ -371,7 +465,7 @@ private class SemanticHtmlParser(
"hr" -> listOf(SemanticSpacer(style = elementStyle, elementId = elementId, cfi = cfi, blockIndex = nextBlockIndex++))
"ul", "ol" -> parseListElementToSemantic(element, elementStyle)
else -> {
val hasBlockDescendant = !element.isBlock && element.select("img, svg, math-placeholder, hr, table, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty()
val hasBlockDescendant = !element.isBlock && element.hasSemanticBlockDescendant()
if (element.isBlock || hasBlockDescendant) {
parseContainer(element, elementStyle)
} else {
@ -397,32 +491,44 @@ private class SemanticHtmlParser(
fun flushTextBuffer() {
if (textNodesBuffer.isEmpty()) return
val (text, spans) = buildSemanticTextAndSpansFromNodes(textNodesBuffer, style)
if (text.isNotBlank()) {
val finalSpans = spans.toMutableList()
val textChunks = buildSemanticTextAndSpanChunksFromNodes(textNodesBuffer, style)
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 = text.length,
end = chunk.text.length,
style = style,
linkHref = href,
tag = "a",
elementId = element.id().ifBlank { null }
elementId = containerElementId.takeIf { chunkIndex == 0 }
))
}
}
children.add(SemanticParagraph(text, finalSpans, style, element.id().ifBlank { null }, element.getCfiPath(), blockIndex = nextBlockIndex++))
children.add(
SemanticParagraph(
text = chunk.text,
spans = finalSpans,
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 tagName = node.tagName().lowercase()
val isEffectivelyBlock = node.isBlock || tagName in listOf("img", "svg", "math-placeholder", "hr") ||
(!node.isBlock && node.select("img, svg, math-placeholder, hr, table, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty())
val isEffectivelyBlock = node.isEffectivelySemanticBlock()
if (isEffectivelyBlock) {
flushTextBuffer()
@ -450,79 +556,190 @@ private class SemanticHtmlParser(
nodes: List<Node>,
rootStyle: CssStyle
): Pair<String, List<SemanticSpan>> {
val chunks = buildSemanticTextAndSpanChunksFromNodes(nodes, rootStyle)
val firstChunk = chunks.firstOrNull() ?: return "" to emptyList()
return firstChunk.text to firstChunk.spans
}
private fun buildSemanticTextAndSpanChunksFromNodes(
nodes: List<Node>,
rootStyle: CssStyle
): List<SemanticTextChunk> {
val textBuilder = StringBuilder()
val spans = mutableListOf<SemanticSpan>()
val chunks = mutableListOf<SemanticTextChunk>()
val activeSpans = mutableListOf<ActiveSemanticSpan>()
var currentChunkStartOffset = 0
fun processNode(node: Node, inheritedStyle: CssStyle) {
when (node) {
is TextNode -> {
var text = node.wholeText.replace('\n', ' ')
when (inheritedStyle.textTransform) {
"uppercase" -> text = text.uppercase()
"lowercase" -> text = text.lowercase()
"capitalize" -> text = text.capitalizeWords()
}
textBuilder.append(text)
}
is Element -> {
if (node.tagName().lowercase() == "br") {
textBuilder.append('\n'); return
}
val currentElementStyle = getElementStyle(node)
val newStyle = inheritedStyle.merge(currentElementStyle)
val startIndex = textBuilder.length
node.childNodes().forEach { processNode(it, newStyle) }
val endIndex = textBuilder.length
val elementId = node.id().ifBlank { null }
val isAnchor = node.tagName().lowercase() == "a" || elementId != null
// Capture span if it has content OR if it has an ID (anchor)
if (startIndex < endIndex || elementId != null) {
val href = if (node.tagName().lowercase() == "a") node.attr("href").ifBlank { null } else null
spans.add(SemanticSpan(
start = startIndex,
end = endIndex,
style = newStyle,
linkHref = href,
tag = node.tagName().lowercase(),
elementId = elementId // Pass the ID here
))
}
}
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
)
)
}
}
nodes.forEach { processNode(it, rootStyle) }
var processedText = textBuilder.toString()
if (processedText.isNotEmpty() && processedText.last().isWhitespace()) {
// 1. Find the index where trailing whitespace begins
var newLength = processedText.length
while (newLength > 0 && processedText[newLength - 1].isWhitespace()) {
fun trimTrailingWhitespace(
text: String,
sourceSpans: List<SemanticSpan>
): Pair<String, List<SemanticSpan>> {
var newLength = text.length
while (newLength > 0 && text[newLength - 1].isWhitespace()) {
newLength--
}
// 2. Cut the text
processedText = processedText.substring(0, newLength)
if (newLength == text.length) return text to sourceSpans
// 3. Filter or Cap spans so they don't point to indices that no longer exist
val adjustedSpans = spans.mapNotNull { span ->
val adjustedSpans = sourceSpans.mapNotNull { span ->
if (span.start >= newLength) {
// Span started in the whitespace area, remove it
null
} else if (span.end > newLength) {
// Span ended in the whitespace area, cap it
span.copy(end = newLength)
} else {
span
}
}
return processedText to adjustedSpans
return text.substring(0, newLength) to adjustedSpans
}
return processedText to spans
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
)
}
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 appendTransformedText(rawText: String, textTransform: String?) {
var start = 0
while (start < rawText.length) {
val end = (start + TEXT_APPEND_SLICE_CHARS).coerceAtMost(rawText.length)
val normalizedSlice = buildString(end - start) {
for (i in start until end) {
append(if (rawText[i] == '\n') ' ' else rawText[i])
}
}
val transformedSlice = when (textTransform) {
"uppercase" -> normalizedSlice.uppercase()
"lowercase" -> normalizedSlice.lowercase()
"capitalize" -> normalizedSlice.capitalizeWords()
else -> normalizedSlice
}
appendText(transformedSlice)
start = end
}
}
fun processNode(node: Node, inheritedStyle: CssStyle) {
when (node) {
is TextNode -> {
appendTransformedText(node.wholeText, inheritedStyle.textTransform)
}
is Element -> {
if (node.tagName().lowercase() == "br") {
appendText("\n"); return
}
val currentElementStyle = getElementStyle(node)
val newStyle = inheritedStyle.merge(currentElementStyle)
val tag = node.tagName().lowercase()
val href = if (tag == "a") node.attr("href").ifBlank { null } else null
val elementId = node.id().ifBlank { null }
val activeSpan = ActiveSemanticSpan(
startInChunk = textBuilder.length,
style = newStyle,
linkHref = href,
tag = tag,
elementId = elementId
)
activeSpans.add(activeSpan)
node.childNodes().forEach { processNode(it, newStyle) }
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
)
}
}
}
nodes.forEach { processNode(it, rootStyle) }
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]
@ -532,7 +749,7 @@ private class SemanticHtmlParser(
var svgViewBox: String? = null
if (svgContent != null) {
val svgDoc = Jsoup.parse(svgContent)
svgDoc.selectFirst("svg")?.let {
svgDoc.getElementsByTag("svg").firstOrNull()?.let {
svgWidth = it.attr("width")
svgHeight = it.attr("height")
svgViewBox = it.attr("viewBox")
@ -564,7 +781,7 @@ private class SemanticHtmlParser(
return SemanticImage(
path = imagePath,
altText = svgElement.selectFirst("title")?.text() ?: "Cover Image",
altText = svgElement.getElementsByTag("title").firstOrNull()?.text() ?: "Cover Image",
intrinsicWidth = width,
intrinsicHeight = height,
style = style,
@ -575,8 +792,8 @@ private class SemanticHtmlParser(
}
HtmlParserLog.d("Parsing genuine SVG content into SemanticMath block.")
val title = svgElement.selectFirst("title")?.text()
val desc = svgElement.selectFirst("desc")?.text()
val title = svgElement.getElementsByTag("title").firstOrNull()?.text()
val desc = svgElement.getElementsByTag("desc").firstOrNull()?.text()
val altText = title ?: desc ?: "SVG Image"
return SemanticMath(
@ -644,7 +861,7 @@ private class SemanticHtmlParser(
}
private fun parseTableElementToSemantic(tableElement: Element, tableStyle: CssStyle): SemanticTable? {
val rows = tableElement.select("tr").mapNotNull { rowElement ->
val rows = tableElement.getElementsByTag("tr").mapNotNull { rowElement ->
val rowStyle = getElementStyle(rowElement)
if (rowStyle.display == "none") return@mapNotNull null