/* * 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