/* * 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 android.graphics.BitmapFactory import android.os.Build import timber.log.Timber import androidx.annotation.RequiresApi 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.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 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.io.File import java.net.URLDecoder import java.nio.file.Paths private val unsupportedPseudoElementRegex = Regex("::?(before|after|first-letter|first-line|marker|selection)", RegexOption.IGNORE_CASE) 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 "" } /** * 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. */ @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) fun htmlToSemanticBlocks( html: String, cssRules: OptimizedCssRules, textStyle: TextStyle, chapterAbsPath: String, extractionBasePath: String, density: Density, fontFamilyMap: Map, constraints: Constraints, imageDimensionsCache: Map> = emptyMap(), mathSvgCache: Map = emptyMap() ): List { return SemanticHtmlParser( cssRules, textStyle, chapterAbsPath, extractionBasePath, density, fontFamilyMap, constraints, imageDimensionsCache, mathSvgCache ).parse(html) } /** * A stateful parser that holds the context for a single HTML-to-SemanticBlock conversion. */ @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) 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 styleCache = mutableMapOf() 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().select("style").joinToString(separator = "\n") { it.data() } if (inlineCssContent.isNotBlank()) { Timber.d("Found inline