/*
* 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 .
*
* 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?
}
interface HtmlFontFamilyLoader {
fun load(fontFaces: List, extractionBasePath: String): Map
}
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? = null
}
object NoOpHtmlFontFamilyLoader : HtmlFontFamilyLoader {
override fun load(fontFaces: List, extractionBasePath: String): Map = 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()
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,
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,
constraints: Constraints,
imageDimensionsCache: Map> = emptyMap(),
mathSvgCache: Map = emptyMap(),
resourceResolver: HtmlResourceResolver = NoOpHtmlResourceResolver,
fontFamilyLoader: HtmlFontFamilyLoader = NoOpHtmlFontFamilyLoader,
adaptThemeColors: Boolean = false
): List {
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,
private val constraints: Constraints,
private val imageDimensionsCache: Map>,
private val mathSvgCache: Map,
private val resourceResolver: HtmlResourceResolver,
private val fontFamilyLoader: HtmlFontFamilyLoader,
private val adaptThemeColors: Boolean
) {
private val semanticBlockDescendantCache = IdentityHashMap()
private var combinedRules: OptimizedCssRules = cssRules
private val currentFontFamilyMap: MutableMap = fontFamilyMap.toMutableMap()
private var nextBlockIndex = 0
fun parse(html: String): List {
val document = Jsoup.parse(html, chapterAbsPath)
val inlineCssContent = document.head().getElementsByTag("style").joinToString(separator = "\n") { it.data() }
if (inlineCssContent.isNotBlank()) {
HtmlParserLog.d("Found inline