Pdf reader improvments (#113)

* Improve PDF rendering quality and performance.

- Set `FilterQuality.High` when drawing base bitmaps and high-res tiles in `PdfPageComposable`.
- Implement `inSampleSize` calculation in `UniversalDocument` to optimize memory usage during region decoding.
- Enhance bitmap drawing quality by adding `ANTI_ALIAS_FLAG` and `DITHER_FLAG` to the paint configuration.

* Implement custom reader themes for PDF viewer.

This change integrates the `ReaderThemePanel` and theme management logic into the PDF viewer, allowing users to apply preset or custom themes. Previously, the PDF viewer only supported a simple dark mode toggle.

Specific changes:
- Moved `ReaderTheme`, `ReaderTexture`, and theme-related utility functions from `EpubReaderScreen.kt` to `Common.kt` to allow sharing between EPUB and PDF readers.
- Updated `PdfPageComposable` to use `activeTheme` instead of `isDarkMode`, implementing a `ColorMatrix` to apply custom background and text colors to PDF pages.
- Replaced the dark mode toggle in `PdfViewerScreen` with a theme selection button that opens `ReaderThemePanel`.
- Introduced `PdfBuiltInThemes` to provide PDF-specific theme presets.

* Reset page transformation on constraint changes and refine tile rendering logic.

- Reset scale to 1.0 and offset to zero in `PdfPageComposable` when constraints change.
- Update `UniversalDocument` to use floating-point precision for source rectangle calculations and `RectF` for destination mapping to improve rendering accuracy.

* Improve eraser hit detection and visual thickness settings in PDF reader.
This commit is contained in:
Aryan 2026-03-26 13:05:50 +05:30 committed by GitHub
parent 4db2c97a30
commit 60dacf9c12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 834 additions and 741 deletions

View file

@ -407,7 +407,7 @@ internal fun PdfPageComposable(
clearSelectionTrigger: Long = 0L,
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
isDarkMode: Boolean = false,
activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
onDoubleTap: ((Offset) -> Unit)? = null,
isEditMode: Boolean = false,
drawingState: PdfDrawingState? = null,
@ -534,41 +534,55 @@ internal fun PdfPageComposable(
val canvasWidthPx = remember { mutableFloatStateOf(0f) }
val canvasHeightPx = remember { mutableFloatStateOf(0f) }
val colorFilter = remember(isDarkMode) {
if (isDarkMode) {
val colorMatrix = floatArrayOf(
-1f,
0f,
0f,
0f,
255f,
0f,
-1f,
0f,
0f,
255f,
0f,
0f,
-1f,
0f,
255f,
0f,
0f,
0f,
1f,
0f
)
ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
} else {
null
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
val colorFilter = remember(activeTheme) {
when (activeTheme.id) {
"no_theme", "system" -> null
"reverse" -> {
val colorMatrix = floatArrayOf(
-1f, 0f, 0f, 0f, 255f,
0f, -1f, 0f, 0f, 255f,
0f, 0f, -1f, 0f, 255f,
0f, 0f, 0f, 1f, 0f
)
ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
}
else -> {
val bgR = activeTheme.backgroundColor.red * 255f
val bgG = activeTheme.backgroundColor.green * 255f
val bgB = activeTheme.backgroundColor.blue * 255f
val fgR = activeTheme.textColor.red * 255f
val fgG = activeTheme.textColor.green * 255f
val fgB = activeTheme.textColor.blue * 255f
val dr = (bgR - fgR) / 255f
val dg = (bgG - fgG) / 255f
val db = (bgB - fgB) / 255f
val lumR = 0.2126f
val lumG = 0.7152f
val lumB = 0.0722f
val colorMatrix = floatArrayOf(
dr * lumR, dr * lumG, dr * lumB, 0f, fgR,
dg * lumR, dg * lumG, dg * lumB, 0f, fgG,
db * lumR, db * lumG, db * lumB, 0f, fgB,
0f, 0f, 0f, 1f, 0f
)
ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
}
}
}
val backgroundColor = remember(isDarkMode, isVerticalScroll) {
if (isDarkMode) {
Color(0xFF2A2A2A)
} else {
val backgroundColor = remember(activeTheme, isVerticalScroll) {
if (activeTheme.id == "no_theme" || activeTheme.id == "system") {
if (isVerticalScroll) Color.White else Color.Black
} else if (activeTheme.id == "reverse") {
if (isVerticalScroll) Color.Black else Color.White
} else {
activeTheme.backgroundColor
}
}
@ -1628,10 +1642,6 @@ internal fun PdfPageComposable(
}
}
if (selectedTool == InkType.ERASER) {
eraserPosition = down.position
}
if (dragStartedOnHandle) {
Timber.d(
"PointerInput: Drag started on handle $activeDraggingHandle"
@ -3091,8 +3101,12 @@ internal fun PdfPageComposable(
}
LaunchedEffect(
this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight, pageIndex
this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight
) {
scale = 1f
offset = Offset.Zero
onScaleChanged(1f)
Timber.d(
"PdfPageComposable Page $pageIndex | Constraints: maxWidth=${this@BoxWithConstraints.maxWidth}, maxHeight=${this@BoxWithConstraints.maxHeight}"
)
@ -3861,7 +3875,6 @@ private fun PdfBitmapLayer(
.fillMaxSize()
.graphicsLayer()) {
translate(left = centeringOffsetX, top = centeringOffsetY) {
// THIS is the fix: Hard clip to the target bounds so edge tiles can't bleed out.
clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) {
if (bitmapState != null && !bitmapState.isRecycled) {
val dstW = if (targetWidth > 0) targetWidth else bitmapState.width
@ -3869,17 +3882,16 @@ private fun PdfBitmapLayer(
val srcSize = IntSize(bitmapState.width, bitmapState.height)
val dstSize = IntSize(dstW, dstH)
// 1. Draw Base Bitmap
drawImage(
image = bitmapState.asImageBitmap(),
srcOffset = IntOffset.Zero,
srcSize = srcSize,
dstOffset = IntOffset.Zero,
dstSize = dstSize,
colorFilter = colorFilter
colorFilter = colorFilter,
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
// 2. Draw High-Res Tiles
if (effectiveScale > 1f) {
tiles.forEach { tile ->
if (!tile.bitmap.isRecycled) {
@ -3891,7 +3903,8 @@ private fun PdfBitmapLayer(
dstSize = IntSize(
tile.renderRect.width(), tile.renderRect.height()
),
colorFilter = colorFilter
colorFilter = colorFilter,
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
}
}

View file

@ -111,7 +111,6 @@ import com.aryan.reader.SearchResult
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
@ -177,7 +176,7 @@ private data class DividerLayout(val y: Float, val width: Float, val height: Flo
internal fun PdfVerticalReader(
state: VerticalPdfReaderState,
pdfDocument: StableHolder<ReaderDocument>,
isDarkMode: Boolean,
activeTheme: com.aryan.reader.ReaderTheme,
totalPages: Int,
modifier: Modifier = Modifier,
virtualPages: List<VirtualPage> = emptyList(),
@ -243,6 +242,7 @@ internal fun PdfVerticalReader(
}
}
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
val imeInsets = WindowInsets.ime
val density = LocalDensity.current
@ -1497,7 +1497,7 @@ internal fun PdfVerticalReader(
pageIndex = page.index,
virtualPage = virtualPage,
totalPages = totalPages,
isDarkMode = isDarkMode,
activeTheme = activeTheme,
externalScale = highResScale,
onScaleChanged = {},
showAllTextHighlights = showAllTextHighlights,

View file

@ -259,6 +259,7 @@ import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.FileType
import com.aryan.reader.MainViewModel
import com.aryan.reader.R
import com.aryan.reader.ReaderThemePanel
import com.aryan.reader.SearchResult
import com.aryan.reader.SearchTopBar
import com.aryan.reader.SummarizationPopup
@ -270,6 +271,7 @@ import com.aryan.reader.epubreader.AutoScrollControls
import com.aryan.reader.epubreader.DictionarySettingsDialog
import com.aryan.reader.epubreader.ExternalDictionaryHelper
import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
import com.aryan.reader.pdf.data.PdfAnnotation
@ -281,6 +283,7 @@ import com.aryan.reader.pdf.data.SmartSearchResult
import com.aryan.reader.pdf.data.TextStyleConfig
import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
import com.aryan.reader.summarizationUrl
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
@ -343,6 +346,27 @@ private const val PREF_USE_ONLINE_DICT = "use_online_dictionary"
private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package"
private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
private const val PDF_THEME_KEY = "pdf_reader_theme"
private fun savePdfThemeId(context: Context, themeId: String) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(PDF_THEME_KEY, themeId) }
}
private fun loadPdfThemeId(context: Context): String {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(PDF_THEME_KEY, "no_theme") ?: "no_theme"
}
val PdfBuiltInThemes = listOf(
com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
com.aryan.reader.ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true),
com.aryan.reader.ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
com.aryan.reader.ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
com.aryan.reader.ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
com.aryan.reader.ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
com.aryan.reader.ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
)
object PdfiumCoreProvider {
val core: PdfiumCoreKt by lazy {
@ -1065,7 +1089,16 @@ fun PdfViewerScreen(
val focusManager = LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
var displayMode by remember { mutableStateOf(loadDisplayMode(context)) }
var isPdfDarkMode by remember { mutableStateOf(loadPdfDarkMode(context)) }
var showThemePanel by remember { mutableStateOf(false) }
var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) }
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
val activeTheme = remember(currentThemeId, customThemes) {
PdfBuiltInThemes.find { it.id == currentThemeId }
?: customThemes.find { it.id == currentThemeId }
?: PdfBuiltInThemes[0]
}
val isPdfDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
var pageAspectRatios by remember { mutableStateOf<List<Float>>(emptyList()) }
var showBars by rememberSaveable { mutableStateOf(true) }
var isFullScreen by remember { mutableStateOf(false) }
@ -1364,7 +1397,6 @@ fun PdfViewerScreen(
LaunchedEffect(ocrLanguage) { OcrHelper.init(ocrLanguage) }
LaunchedEffect(displayMode) { saveDisplayMode(context, displayMode) }
LaunchedEffect(isPdfDarkMode) { savePdfDarkMode(context, isPdfDarkMode) }
val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) }
val toolSettings by annotationSettingsRepo.settings.collectAsState()
@ -2724,21 +2756,24 @@ fun PdfViewerScreen(
): Boolean {
if (annotation.points.isEmpty()) return false
val effectiveThreshold = threshold + (annotation.strokeWidth / 2f)
val thresholdSq = effectiveThreshold * effectiveThreshold
if (annotation.points.size == 1) {
val p = annotation.points[0]
val dx = (p.x - hitPoint.x) * pageAspectRatio
val dy = (p.y - hitPoint.y)
return (dx * dx + dy * dy) < (threshold * threshold)
val dx = (p.x - hitPoint.x)
val dy = (p.y - hitPoint.y) / pageAspectRatio
return (dx * dx + dy * dy) < thresholdSq
}
for (i in 0 until annotation.points.size - 1) {
val a = annotation.points[i]
val b = annotation.points[i + 1]
val pax = (hitPoint.x - a.x) * pageAspectRatio
val pay = (hitPoint.y - a.y)
val bax = (b.x - a.x) * pageAspectRatio
val bay = (b.y - a.y)
val pax = (hitPoint.x - a.x)
val pay = (hitPoint.y - a.y) / pageAspectRatio
val bax = (b.x - a.x)
val bay = (b.y - a.y) / pageAspectRatio
val segmentLenSq = (bax * bax + bay * bay).coerceAtLeast(1e-6f)
val t = (pax * bax + pay * bay) / segmentLenSq
@ -2749,7 +2784,7 @@ fun PdfViewerScreen(
val distSq = (pax - closestX) * (pax - closestX) + (pay - closestY) * (pay - closestY)
if (distSq < (threshold * threshold)) return true
if (distSq < thresholdSq) return true
}
return false
@ -3580,6 +3615,7 @@ fun PdfViewerScreen(
}
showTtsSettingsSheet -> showTtsSettingsSheet = false
showThemePanel -> showThemePanel = false
else -> {
saveStateAndExit()
@ -4257,7 +4293,7 @@ fun PdfViewerScreen(
pageIndex = pageIndex,
virtualPage = virtualPage,
totalPages = totalDisplayPages,
isDarkMode = isPdfDarkMode,
activeTheme = activeTheme,
isScrollLocked = isScrollLocked,
onScaleChanged = { newScale ->
if (pagerState.currentPage == pageIndex) {
@ -4636,7 +4672,7 @@ fun PdfViewerScreen(
PdfVerticalReader(
state = verticalReaderState,
pdfDocument = docHolder,
isDarkMode = isPdfDarkMode,
activeTheme = activeTheme,
isScrollLocked = isScrollLocked,
totalPages = totalDisplayPages,
pageAspectRatios = ratiosHolder,
@ -5223,22 +5259,14 @@ fun PdfViewerScreen(
)
TooltipIconButton(
text = if (isPdfDarkMode)
stringResource(R.string.tooltip_dark_mode_off)
else
stringResource(R.string.tooltip_dark_mode_on),
description = if (isPdfDarkMode)
stringResource(R.string.tooltip_dark_mode_off_desc)
else
stringResource(R.string.tooltip_dark_mode_on_desc),
onClick = { isPdfDarkMode = !isPdfDarkMode }
text = "Theme",
description = "Theme Settings",
onClick = { showThemePanel = true }
) {
Icon(
painter = painterResource(id = R.drawable.dark_mode),
contentDescription = if (isPdfDarkMode) "Disable Dark Mode"
else "Enable Dark Mode",
tint = if (isPdfDarkMode) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
painter = painterResource(id = R.drawable.palette),
contentDescription = "Theme Settings",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@ -6926,6 +6954,25 @@ fun PdfViewerScreen(
)
}
if (showThemePanel) {
ReaderThemePanel(
isVisible = true,
currentThemeId = currentThemeId,
builtInThemes = PdfBuiltInThemes,
onThemeSelected = {
currentThemeId = it
savePdfThemeId(context, it)
showThemePanel = false
},
onDismiss = { showThemePanel = false },
customThemes = customThemes,
onCustomThemesUpdated = {
customThemes = it
saveCustomThemes(context, it)
}
)
}
if (clickedLinkUrl != null) {
val url = clickedLinkUrl!!
AlertDialog(

View file

@ -128,7 +128,7 @@ fun ToolSettingsPopup(
val thicknessRange = when {
isHighlighter -> 0.01f..0.06f
isEraser -> 0.01f..0.1f
isEraser -> 0.002f..0.1f
else -> 0.001f..0.015f
}
@Suppress("UnusedExpression") if (isHighlighter) 0.005f else 0.001f
@ -170,9 +170,9 @@ fun ToolSettingsPopup(
.height(125.dp),
contentAlignment = Alignment.Center
) {
val radiusDp = (activeToolThickness * 1000).coerceIn(10f, 100f).dp
val diameterDp = (activeToolThickness * 800).coerceIn(4f, 150f).dp
Box(
modifier = Modifier.size(radiusDp),
modifier = Modifier.size(diameterDp),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {

View file

@ -219,8 +219,7 @@ class ArchiveDocumentWrapper(private val file: File) : ReaderDocument {
tempEntries.add(Pair(path, extractedFile))
var pfd: android.os.ParcelFileDescriptor? = null
try {
// Extract seamlessly using fd to avoid ByteBuffer's state sync bug
@Suppress("ConvertTryFinallyToUseCall") try {
pfd = android.os.ParcelFileDescriptor.open(extractedFile, android.os.ParcelFileDescriptor.MODE_READ_WRITE or android.os.ParcelFileDescriptor.MODE_CREATE)
Archive.readDataIntoFd(archive, pfd.fd)
} finally {
@ -231,7 +230,7 @@ class ArchiveDocumentWrapper(private val file: File) : ReaderDocument {
}
}
tempEntries.sortBy { it.first } // Natural sorting order based on the filename inside the archive
tempEntries.sortBy { it.first }
tempEntries.forEach { imageEntries.add(it.second.absolutePath) }
} catch (e: Exception) {
@ -306,15 +305,42 @@ class ArchivePageWrapper(imageBytes: ByteArray) : ReaderPage {
val scaleX = drawSizeX.toFloat() / originalWidth
val scaleY = drawSizeY.toFloat() / originalHeight
val srcLeft = (-startX / scaleX).toInt().coerceAtLeast(0)
val srcTop = (-startY / scaleY).toInt().coerceAtLeast(0)
val srcRight = (srcLeft + (bitmap.width / scaleX).toInt()).coerceAtMost(originalWidth)
val srcBottom = (srcTop + (bitmap.height / scaleY).toInt()).coerceAtMost(originalHeight)
val pageOffsetX = -startX.toFloat()
val pageOffsetY = -startY.toFloat()
val exactSrcLeft = pageOffsetX / scaleX
val exactSrcTop = pageOffsetY / scaleY
val exactSrcRight = (pageOffsetX + bitmap.width) / scaleX
val exactSrcBottom = (pageOffsetY + bitmap.height) / scaleY
val srcLeft = kotlin.math.floor(exactSrcLeft).toInt().coerceAtLeast(0)
val srcTop = kotlin.math.floor(exactSrcTop).toInt().coerceAtLeast(0)
val srcRight = kotlin.math.ceil(exactSrcRight).toInt().coerceAtMost(originalWidth)
val srcBottom = kotlin.math.ceil(exactSrcBottom).toInt().coerceAtMost(originalHeight)
val rect = Rect(srcLeft, srcTop, srcRight, srcBottom)
if (rect.width() <= 0 || rect.height() <= 0) return
val options = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 }
val options = BitmapFactory.Options().apply {
inPreferredConfig = Bitmap.Config.ARGB_8888
inScaled = false
@Suppress("DEPRECATION")
inDither = true
var sampleSize = 1
val srcWidth = rect.width()
val srcHeight = rect.height()
if (srcHeight > bitmap.height || srcWidth > bitmap.width) {
val halfHeight = srcHeight / 2
val halfWidth = srcWidth / 2
while (halfHeight / sampleSize >= bitmap.height && halfWidth / sampleSize >= bitmap.width) {
sampleSize *= 2
}
}
inSampleSize = sampleSize
}
val region = try {
decoder.decodeRegion(rect, options)
} catch (_: Exception) {
@ -323,8 +349,17 @@ class ArchivePageWrapper(imageBytes: ByteArray) : ReaderPage {
if (region != null) {
val canvas = Canvas(bitmap)
val destRect = Rect(0, 0, bitmap.width, bitmap.height)
canvas.drawBitmap(region, null, destRect, Paint(Paint.FILTER_BITMAP_FLAG))
val destLeft = (srcLeft * scaleX) - pageOffsetX
val destTop = (srcTop * scaleY) - pageOffsetY
val destRight = (srcRight * scaleX) - pageOffsetX
val destBottom = (srcBottom * scaleY) - pageOffsetY
val destRect = RectF(destLeft, destTop, destRight, destBottom)
val paint = Paint(Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG or Paint.DITHER_FLAG)
canvas.drawBitmap(region, null, destRect, paint)
region.recycle()
}
}