* 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
614 lines
26 KiB
Kotlin
614 lines
26 KiB
Kotlin
/*
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
*
|
|
* mail: epistemereader@gmail.com
|
|
*/
|
|
package com.aryan.reader.paginatedreader
|
|
|
|
import android.os.Build
|
|
import timber.log.Timber
|
|
import androidx.annotation.RequiresApi
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.isSpecified
|
|
import androidx.compose.ui.text.AnnotatedString
|
|
import androidx.compose.ui.text.ParagraphStyle
|
|
import androidx.compose.ui.text.SpanStyle
|
|
import androidx.compose.ui.text.TextStyle
|
|
import androidx.compose.ui.text.buildAnnotatedString
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.style.BaselineShift
|
|
import androidx.compose.ui.text.style.Hyphens
|
|
import androidx.compose.ui.text.style.LineBreak
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import androidx.compose.ui.text.style.TextDecoration
|
|
import androidx.compose.ui.text.withStyle
|
|
import androidx.compose.ui.unit.Density
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.em
|
|
import androidx.compose.ui.unit.isSpecified
|
|
import androidx.compose.ui.unit.isUnspecified
|
|
import androidx.compose.ui.unit.sp
|
|
import org.jsoup.Jsoup
|
|
import java.io.File
|
|
import java.net.URLDecoder
|
|
|
|
private const val DEBUG_CONTENT_STYLING = false
|
|
|
|
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
|
class ContentStyler(
|
|
private val baseTextStyle: TextStyle,
|
|
private val fontFamilyMap: Map<String, FontFamily>,
|
|
private val density: Density,
|
|
private val isDarkTheme: Boolean,
|
|
private val themeBackgroundColor: Color,
|
|
private val themeTextColor: Color,
|
|
private val chapterAbsPath: String,
|
|
private val extractionBasePath: String,
|
|
private val userTextAlign: TextAlign?,
|
|
private val paragraphGapMultiplier: Float,
|
|
private val adaptThemeColors: Boolean = true
|
|
) {
|
|
|
|
fun style(semanticBlocks: List<SemanticBlock>): List<ContentBlock> {
|
|
return groupFloatingBlocks(semanticBlocks.mapNotNull { styleBlock(it) })
|
|
}
|
|
|
|
private fun groupFloatingBlocks(blocks: List<ContentBlock>): List<ContentBlock> {
|
|
if (blocks.isEmpty()) return emptyList()
|
|
|
|
val result = mutableListOf<ContentBlock>()
|
|
val processingQueue = blocks.toMutableList()
|
|
|
|
while (processingQueue.isNotEmpty()) {
|
|
val currentBlock = processingQueue.removeAt(0)
|
|
val floatDirection = (currentBlock as? ImageBlock)?.style?.float
|
|
|
|
if (currentBlock is ImageBlock && floatDirection in listOf("left", "right")) {
|
|
val paragraphsToWrap = mutableListOf<ParagraphBlock>()
|
|
|
|
while (processingQueue.isNotEmpty()) {
|
|
val nextBlock = processingQueue.first()
|
|
val nextBlockStyle = nextBlock.style
|
|
val shouldClear = nextBlockStyle.clear in listOf("both", floatDirection)
|
|
if (nextBlock is ParagraphBlock && !shouldClear) {
|
|
val paragraph = processingQueue.removeAt(0) as ParagraphBlock
|
|
paragraphsToWrap.add(paragraph)
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
val wrappingBlock = WrappingContentBlock(
|
|
currentBlock,
|
|
paragraphsToWrap,
|
|
elementId = currentBlock.elementId,
|
|
cfi = currentBlock.cfi,
|
|
blockIndex = currentBlock.blockIndex
|
|
)
|
|
result.add(wrappingBlock)
|
|
} else {
|
|
result.add(currentBlock)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private fun styleBlock(block: SemanticBlock): ContentBlock? {
|
|
val themedStyle = applyThemeToStyle(block.style)
|
|
|
|
val finalBlockStyle = if (block is SemanticParagraph) {
|
|
val originalMargin = themedStyle.blockStyle.margin
|
|
val newMargin = originalMargin.copy(
|
|
top = originalMargin.top * paragraphGapMultiplier,
|
|
bottom = originalMargin.bottom * paragraphGapMultiplier
|
|
)
|
|
themedStyle.blockStyle.copy(margin = newMargin)
|
|
} else {
|
|
themedStyle.blockStyle
|
|
}
|
|
|
|
return when (block) {
|
|
is SemanticParagraph -> {
|
|
val computedTextAlign = userTextAlign ?: themedStyle.paragraphStyle.textAlign
|
|
|
|
ParagraphBlock(
|
|
content = buildAnnotatedString(block, themedStyle),
|
|
textAlign = computedTextAlign,
|
|
style = finalBlockStyle,
|
|
elementId = block.elementId,
|
|
cfi = block.cfi,
|
|
startCharOffsetInSource = block.startCharOffsetInSource,
|
|
blockIndex = block.blockIndex
|
|
)
|
|
}
|
|
|
|
is SemanticHeader -> HeaderBlock(
|
|
level = block.level,
|
|
content = buildAnnotatedString(block, themedStyle),
|
|
textAlign = themedStyle.paragraphStyle.textAlign,
|
|
style = themedStyle.blockStyle,
|
|
elementId = block.elementId,
|
|
cfi = block.cfi,
|
|
startCharOffsetInSource = block.startCharOffsetInSource,
|
|
blockIndex = block.blockIndex
|
|
)
|
|
|
|
is SemanticImage -> {
|
|
var finalBlockStyle = themedStyle.blockStyle
|
|
if (themedStyle.paragraphStyle.textAlign == TextAlign.Center) {
|
|
finalBlockStyle = finalBlockStyle.copy(horizontalAlign = "center")
|
|
}
|
|
val shouldInvert = themedStyle.blockStyle.filter == "invert(100%)"
|
|
ImageBlock(
|
|
path = block.path,
|
|
altText = block.altText,
|
|
intrinsicWidth = block.intrinsicWidth,
|
|
intrinsicHeight = block.intrinsicHeight,
|
|
style = finalBlockStyle,
|
|
elementId = block.elementId,
|
|
cfi = block.cfi,
|
|
invertOnDarkTheme = shouldInvert,
|
|
blockIndex = block.blockIndex
|
|
)
|
|
}
|
|
|
|
is SemanticMath -> {
|
|
val svgContent = block.svgContent
|
|
val nonBlankSvgContent = svgContent?.takeIf { it.isNotBlank() }
|
|
val finalSvgContent = when {
|
|
block.isFromMathJax || nonBlankSvgContent == null -> svgContent
|
|
!adaptThemeColors -> embedImagesInSvg(nonBlankSvgContent)
|
|
else -> {
|
|
val themedSvg = applyThemeToSvg(nonBlankSvgContent)
|
|
embedImagesInSvg(themedSvg)
|
|
}
|
|
}
|
|
|
|
MathBlock(
|
|
svgContent = finalSvgContent,
|
|
altText = block.altText,
|
|
style = themedStyle.blockStyle,
|
|
elementId = block.elementId,
|
|
cfi = block.cfi,
|
|
svgWidth = block.svgWidth,
|
|
svgHeight = block.svgHeight,
|
|
svgViewBox = block.svgViewBox,
|
|
isFromMathJax = block.isFromMathJax,
|
|
blockIndex = block.blockIndex
|
|
)
|
|
}
|
|
|
|
is SemanticList -> styleList(block, themedStyle)
|
|
is SemanticTable -> styleTable(block, themedStyle)
|
|
is SemanticSpacer -> {
|
|
val height = if (block.isExplicitLineBreak) with(density) { baseTextStyle.fontSize.toDp() } else 8.dp
|
|
SpacerBlock(height = height, style = themedStyle.blockStyle, elementId = block.elementId, cfi = block.cfi, blockIndex = block.blockIndex)
|
|
}
|
|
is SemanticFlexContainer -> FlexContainerBlock(
|
|
children = block.children.mapNotNull { styleBlock(it) },
|
|
style = themedStyle.blockStyle,
|
|
elementId = block.elementId,
|
|
cfi = block.cfi,
|
|
blockIndex = block.blockIndex
|
|
)
|
|
is SemanticWrappingBlock -> {
|
|
val styledImage = styleBlock(block.floatedImage) as? ImageBlock
|
|
val styledParagraphs = block.paragraphsToWrap.mapNotNull { styleBlock(it) as? ParagraphBlock }
|
|
if (styledImage != null) {
|
|
WrappingContentBlock(
|
|
floatedImage = styledImage,
|
|
paragraphsToWrap = styledParagraphs,
|
|
elementId = block.elementId,
|
|
cfi = block.cfi,
|
|
blockIndex = block.blockIndex
|
|
)
|
|
} else {
|
|
null
|
|
}
|
|
}
|
|
else -> {
|
|
Timber.w("Unsupported or misplaced SemanticBlock type encountered: ${block::class.java.simpleName}")
|
|
null
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun applyThemeToStyle(style: CssStyle): CssStyle {
|
|
if (!adaptThemeColors) {
|
|
return style
|
|
}
|
|
|
|
val newSpanStyle = style.spanStyle.let { original ->
|
|
val newColor = if (original.color.isSpecified) {
|
|
CssParser.adaptColorForTheme(original.color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
|
|
} else {
|
|
original.color
|
|
}
|
|
original.copy(color = newColor)
|
|
}
|
|
|
|
val newBlockStyle = style.blockStyle.let { original ->
|
|
val newBgColor = if (original.backgroundColor.isSpecified) {
|
|
CssParser.adaptColorForTheme(original.backgroundColor, isDarkTheme, isBackground = true, themeBackgroundColor, themeTextColor)
|
|
} else {
|
|
original.backgroundColor
|
|
}
|
|
|
|
fun themeBorder(b: BorderStyle?): BorderStyle? {
|
|
if (b == null) return null
|
|
val newColor = CssParser.adaptColorForTheme(b.color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
|
|
return b.copy(color = newColor)
|
|
}
|
|
|
|
original.copy(
|
|
backgroundColor = newBgColor,
|
|
borderTop = themeBorder(original.borderTop),
|
|
borderRight = themeBorder(original.borderRight),
|
|
borderBottom = themeBorder(original.borderBottom),
|
|
borderLeft = themeBorder(original.borderLeft)
|
|
)
|
|
}
|
|
|
|
val newTextDecorationColor = if (style.textDecorationColor.isSpecified) {
|
|
CssParser.adaptColorForTheme(style.textDecorationColor, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
|
|
} else {
|
|
style.textDecorationColor
|
|
}
|
|
|
|
return style.copy(
|
|
spanStyle = newSpanStyle,
|
|
blockStyle = newBlockStyle,
|
|
textDecorationColor = newTextDecorationColor
|
|
)
|
|
}
|
|
|
|
private fun embedImagesInSvg(svgContent: String): String {
|
|
try {
|
|
val svgDocument = Jsoup.parseBodyFragment(svgContent)
|
|
val svgElement = svgDocument.body().children().firstOrNull() ?: return svgContent
|
|
|
|
svgElement.select("image").forEach { imageElement ->
|
|
val href = imageElement.attr("href").ifBlank { imageElement.attr("xlink:href") }
|
|
if (href.isNotBlank() && !href.startsWith("data:")) {
|
|
resolveImagePath(href)?.let { imageFile ->
|
|
try {
|
|
val imageBytes = imageFile.readBytes()
|
|
val mimeType = when (imageFile.extension.lowercase()) {
|
|
"jpg", "jpeg" -> "image/jpeg"
|
|
"png" -> "image/png"
|
|
"gif" -> "image/gif"
|
|
"webp" -> "image/webp"
|
|
else -> "application/octet-stream"
|
|
}
|
|
val base64 = android.util.Base64.encodeToString(imageBytes, android.util.Base64.NO_WRAP)
|
|
val dataUri = "data:$mimeType;base64,$base64"
|
|
imageElement.attr("xlink:href", dataUri)
|
|
imageElement.removeAttr("href")
|
|
} catch (e: Exception) {
|
|
Timber.e(e, "Failed to read and encode image file '$href' to Base64.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return svgElement.outerHtml()
|
|
} catch (e: Exception) {
|
|
Timber.e(e, "Error while embedding images in SVG content.")
|
|
return svgContent
|
|
}
|
|
}
|
|
|
|
private fun resolveImagePath(src: String): File? {
|
|
if (src.isBlank()) return null
|
|
val decodedSrc = try { URLDecoder.decode(src, "UTF-8") } catch (_: Exception) { src }
|
|
val parentPath = File(this.chapterAbsPath).parent ?: ""
|
|
val fromRelativeFile = File(this.extractionBasePath, File(parentPath, decodedSrc).path)
|
|
if (fromRelativeFile.exists()) return fromRelativeFile
|
|
val fromRootFile = File(this.extractionBasePath, decodedSrc)
|
|
if (fromRootFile.exists()) return fromRootFile
|
|
Timber.w("Image not found for SVG embedding. Tried: ${fromRelativeFile.absolutePath} and ${fromRootFile.absolutePath}")
|
|
return null
|
|
}
|
|
|
|
private fun applyThemeToSvg(svgContent: String): String {
|
|
if (svgContent.isBlank()) return svgContent
|
|
try {
|
|
val textColorHex = baseTextStyle.color.toCssHexString()
|
|
val svgDocument = Jsoup.parseBodyFragment(svgContent)
|
|
val svgElement = svgDocument.body().children().firstOrNull() ?: return svgContent
|
|
|
|
svgElement.select("text").forEach { textElement ->
|
|
val existingStyle = textElement.attr("style")
|
|
val styleWithoutFill = existingStyle.replace(Regex("""\bfill\s*:\s*[^;]+;?"""), "")
|
|
val newStyle = "fill:$textColorHex; $styleWithoutFill".trim()
|
|
textElement.attr("style", newStyle)
|
|
textElement.removeAttr("fill")
|
|
}
|
|
return svgElement.outerHtml()
|
|
} catch (e: Exception) {
|
|
Timber.e(e, "Failed to apply dark theme to SVG content.")
|
|
return svgContent
|
|
}
|
|
}
|
|
|
|
private fun Color.toCssHexString(): String {
|
|
val red = (this.red * 255).toInt()
|
|
val green = (this.green * 255).toInt()
|
|
val blue = (this.blue * 255).toInt()
|
|
return String.format("#%02X%02X%02X", red, green, blue)
|
|
}
|
|
|
|
private fun buildAnnotatedString(
|
|
block: SemanticTextBlock,
|
|
blockStyle: CssStyle
|
|
): AnnotatedString {
|
|
if (DEBUG_CONTENT_STYLING) {
|
|
Timber.d("ContentStyler: Building annotated string. UserAlign=$userTextAlign, CSSAlign=${blockStyle.paragraphStyle.textAlign}")
|
|
}
|
|
|
|
val builtString = buildAnnotatedString {
|
|
val rootFontFamily = findFirstAvailableFontFamily(blockStyle.fontFamilies, fontFamilyMap)
|
|
val hyphensValue = if (blockStyle.hyphens == "auto") Hyphens.Auto else Hyphens.None
|
|
val mergedParagraphStyle = baseTextStyle.toParagraphStyle().merge(blockStyle.paragraphStyle)
|
|
|
|
val finalTextAlign = if (block is SemanticParagraph && userTextAlign != null) {
|
|
userTextAlign
|
|
} else if (mergedParagraphStyle.textAlign == TextAlign.Justify) {
|
|
TextAlign.Left
|
|
} else {
|
|
mergedParagraphStyle.textAlign
|
|
}
|
|
|
|
val isParagraph = block is SemanticParagraph
|
|
val finalLineHeight = if (isParagraph && baseTextStyle.lineHeight.isSpecified) {
|
|
baseTextStyle.lineHeight
|
|
} else {
|
|
mergedParagraphStyle.lineHeight
|
|
}
|
|
|
|
val finalParagraphStyle = ParagraphStyle(
|
|
textAlign = finalTextAlign,
|
|
textDirection = mergedParagraphStyle.textDirection,
|
|
lineHeight = finalLineHeight,
|
|
textIndent = mergedParagraphStyle.textIndent,
|
|
platformStyle = mergedParagraphStyle.platformStyle,
|
|
lineHeightStyle = mergedParagraphStyle.lineHeightStyle,
|
|
lineBreak = LineBreak.Paragraph,
|
|
hyphens = hyphensValue,
|
|
textMotion = mergedParagraphStyle.textMotion
|
|
)
|
|
|
|
val isCustomFont = baseTextStyle.fontFamily != null && baseTextStyle.fontFamily != FontFamily.Default
|
|
|
|
val effectiveBlockFontFamily = if (rootFontFamily == FontFamily.Monospace) {
|
|
FontFamily.Monospace
|
|
} else if (isCustomFont) {
|
|
baseTextStyle.fontFamily
|
|
} else {
|
|
rootFontFamily ?: baseTextStyle.fontFamily
|
|
}
|
|
|
|
val initialSpanStyle = baseTextStyle.toSpanStyle()
|
|
.merge(blockStyle.spanStyle)
|
|
.copy(fontFamily = effectiveBlockFontFamily)
|
|
|
|
if (DEBUG_CONTENT_STYLING) {
|
|
Timber.d("ContentStyler: InitialSpanStyle. BaseFontSize=${baseTextStyle.fontSize}, BlockFontSize=${blockStyle.spanStyle.fontSize} -> Merged=${initialSpanStyle.fontSize}")
|
|
}
|
|
|
|
withStyle(finalParagraphStyle) {
|
|
withStyle(initialSpanStyle) {
|
|
append(block.text)
|
|
block.spans.sortedBy { it.start }.forEach { span ->
|
|
val themedSpanStyle = applyThemeToStyle(span.style)
|
|
val spanFontFamily = findFirstAvailableFontFamily(themedSpanStyle.fontFamilies, fontFamilyMap)
|
|
val effectiveSpanFontFamily = if (spanFontFamily == FontFamily.Monospace) {
|
|
FontFamily.Monospace
|
|
} else if (isCustomFont) {
|
|
baseTextStyle.fontFamily
|
|
} else {
|
|
spanFontFamily
|
|
}
|
|
|
|
val baselineShift = when (span.tag) {
|
|
"sub" -> BaselineShift.Subscript
|
|
"sup" -> BaselineShift.Superscript
|
|
else -> null
|
|
}
|
|
|
|
var finalSpanStyle = themedSpanStyle.spanStyle.copy(
|
|
fontFamily = effectiveSpanFontFamily,
|
|
baselineShift = baselineShift
|
|
)
|
|
|
|
if (!span.linkHref.isNullOrBlank()) {
|
|
finalSpanStyle = finalSpanStyle.withReaderLinkStyle(
|
|
isDarkTheme = isDarkTheme,
|
|
themeBackgroundColor = themeBackgroundColor,
|
|
themeTextColor = themeTextColor
|
|
)
|
|
}
|
|
|
|
val hasCustomDeco = themedSpanStyle.textDecorationStyle != null ||
|
|
themedSpanStyle.textDecorationColor.isSpecified ||
|
|
themedSpanStyle.textUnderlineOffset.isSpecified
|
|
|
|
val combinedDeco = finalSpanStyle.textDecoration ?: TextDecoration.None
|
|
|
|
if (hasCustomDeco && combinedDeco.contains(TextDecoration.Underline)) {
|
|
val decos = mutableListOf<TextDecoration>()
|
|
if (combinedDeco.contains(TextDecoration.LineThrough)) decos.add(TextDecoration.LineThrough)
|
|
finalSpanStyle = finalSpanStyle.copy(
|
|
textDecoration = if (decos.isNotEmpty()) TextDecoration.combine(decos) else TextDecoration.None
|
|
)
|
|
|
|
val styleStr = themedSpanStyle.textDecorationStyle ?: "solid"
|
|
val colorStr = if (themedSpanStyle.textDecorationColor.isSpecified) themedSpanStyle.textDecorationColor.value.toString() else "Unspecified"
|
|
val offsetStr = if (themedSpanStyle.textUnderlineOffset.isSpecified) themedSpanStyle.textUnderlineOffset.value.toString() else "0"
|
|
|
|
val annotationData = "$styleStr|$colorStr|$offsetStr"
|
|
addStringAnnotation("CustomUnderline", annotationData, span.start, span.end)
|
|
}
|
|
|
|
addStyle(initialSpanStyle.merge(finalSpanStyle), span.start, span.end)
|
|
|
|
val ws = themedSpanStyle.wordSpacing
|
|
if (ws.isSpecified && ws.value != 0f) {
|
|
val textToStyle = block.text.substring(span.start, span.end)
|
|
for (i in textToStyle.indices) {
|
|
if (textToStyle[i] == ' ') {
|
|
addStyle(SpanStyle(letterSpacing = ws), span.start + i, span.start + i + 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
span.linkHref?.let { linkHref ->
|
|
addStringAnnotation("URL", linkHref, span.start, span.end)
|
|
}
|
|
span.elementId?.let { elementId ->
|
|
addStringAnnotation("ID", elementId, span.start, span.end)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return builtString.maybeAdjustLineHeightForEmphasis()
|
|
}
|
|
|
|
private fun AnnotatedString.maybeAdjustLineHeightForEmphasis(): AnnotatedString {
|
|
if (this.getStringAnnotations("TextEmphasis", 0, this.length).isNotEmpty()) {
|
|
val currentParagraphStyle = this.paragraphStyles.firstOrNull()?.item ?: ParagraphStyle()
|
|
val currentLineHeight = currentParagraphStyle.lineHeight
|
|
val newLineHeight = if (currentLineHeight.isUnspecified || currentLineHeight.value == 0f) {
|
|
1.8.em
|
|
} else if (currentLineHeight.isEm) {
|
|
(currentLineHeight.value * 1.3f).em
|
|
} else if (currentLineHeight.isSp) {
|
|
(currentLineHeight.value * 1.3f).sp
|
|
} else {
|
|
1.8.em
|
|
}
|
|
return buildAnnotatedString {
|
|
withStyle(ParagraphStyle(lineHeight = newLineHeight)) {
|
|
append(this@maybeAdjustLineHeightForEmphasis)
|
|
}
|
|
}
|
|
}
|
|
return this
|
|
}
|
|
|
|
private fun styleList(list: SemanticList, listStyle: CssStyle): ContentBlock {
|
|
var itemCounter = 1
|
|
|
|
val items = list.items.map { item ->
|
|
val itemThemedStyle = applyThemeToStyle(item.style)
|
|
val mergedBlockStyle = listStyle.blockStyle.merge(itemThemedStyle.blockStyle)
|
|
|
|
val marker = getListMarker(
|
|
listStyleType = mergedBlockStyle.listStyleType,
|
|
counter = itemCounter,
|
|
isOrdered = list.isOrdered
|
|
)
|
|
itemCounter++
|
|
ListItemBlock(
|
|
content = buildAnnotatedString(item, itemThemedStyle),
|
|
itemMarker = marker,
|
|
itemMarkerImage = item.itemMarkerImage,
|
|
style = mergedBlockStyle,
|
|
elementId = item.elementId,
|
|
cfi = item.cfi,
|
|
startCharOffsetInSource = item.startCharOffsetInSource,
|
|
blockIndex = item.blockIndex
|
|
)
|
|
}
|
|
return FlexContainerBlock(items, listStyle.blockStyle, list.elementId, list.cfi, list.blockIndex)
|
|
}
|
|
|
|
private fun styleTable(table: SemanticTable, tableStyle: CssStyle): TableBlock {
|
|
val rows = table.rows.map { row ->
|
|
row.map { cell ->
|
|
val cellCssStyle = applyThemeToStyle(cell.style)
|
|
TableCell(
|
|
content = cell.content.mapNotNull { styleBlock(it) },
|
|
isHeader = cell.isHeader,
|
|
style = cellCssStyle,
|
|
colspan = cell.colspan
|
|
)
|
|
}
|
|
}
|
|
return TableBlock(
|
|
rows = rows,
|
|
style = tableStyle.blockStyle,
|
|
elementId = table.elementId,
|
|
cfi = table.cfi,
|
|
blockIndex = table.blockIndex
|
|
)
|
|
}
|
|
|
|
private fun findFirstAvailableFontFamily(
|
|
fontFamilyNames: List<String>,
|
|
fontFamilyMap: Map<String, FontFamily>
|
|
): FontFamily? {
|
|
if (fontFamilyNames.isEmpty()) return null
|
|
val specificFont = fontFamilyNames.firstNotNullOfOrNull { fontFamilyMap[it] }
|
|
if (specificFont != null) return specificFont
|
|
return fontFamilyNames.firstNotNullOfOrNull { name -> FontFamilyMapper.nameToFontFamily(name) }
|
|
}
|
|
|
|
private fun toRoman(number: Int): String {
|
|
if (number !in 1..3999) return number.toString()
|
|
val values = listOf(1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1)
|
|
val symbols = listOf("M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I")
|
|
val result = StringBuilder()
|
|
var num = number
|
|
for (i in values.indices) {
|
|
while (num >= values[i]) {
|
|
num -= values[i]
|
|
result.append(symbols[i])
|
|
}
|
|
}
|
|
return result.toString()
|
|
}
|
|
|
|
private fun toAlpha(number: Int): String {
|
|
if (number < 1) return number.toString()
|
|
var n = number
|
|
val result = StringBuilder()
|
|
while (n > 0) {
|
|
n--
|
|
result.insert(0, ('a' + n % 26))
|
|
n /= 26
|
|
}
|
|
return result.toString()
|
|
}
|
|
|
|
private fun getListMarker(listStyleType: String?, counter: Int, isOrdered: Boolean): String? {
|
|
val finalType = listStyleType?.trim()?.lowercase() ?: if (isOrdered) "decimal" else "disc"
|
|
|
|
return when (finalType) {
|
|
"none" -> null
|
|
"disc" -> "• "
|
|
"circle" -> "◦ "
|
|
"square" -> "■ "
|
|
"decimal" -> "$counter. "
|
|
"decimal-leading-zero" -> "${counter.toString().padStart(2, '0')}. "
|
|
"lower-roman" -> toRoman(counter).lowercase() + ". "
|
|
"upper-roman" -> toRoman(counter).uppercase() + ". "
|
|
"lower-latin", "lower-alpha" -> toAlpha(counter) + ". "
|
|
"upper-latin", "upper-alpha" -> toAlpha(counter).uppercase() + ". "
|
|
else -> if (isOrdered) "$counter. " else "• "
|
|
}
|
|
}
|
|
}
|