diff --git a/DESIGN.md b/DESIGN.md index d9d40c5..8629eaa 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -75,6 +75,10 @@ chromatic aberration, vibrancy, brightness, tint, highlight, corners, plus prese - Over documents use `chipGlass(onLight)` and `chipInk(onLight)`, picking `onLight` from the content behind the control (the viewer's `bandLuminance`). **In dark reader mode, invert the luminance** (`1 - l`). +- **Every viewer control picks its own ink** (PDF, PPT, Word, spreadsheet, find bar): wrap it with + `rememberAdaptiveInk(barInk)` and put `probe.modifier` first on the control. It reads the real + pixels at the control's corners and beside its glyph (`ProvideScreenInk`, a scaled window copy, + Android 8+), so two circles in one bar can differ. The bar's ink is only the fallback. - Vivid actions use the per-screen accent (`ToolAccents`, `LiquidGlassColors`) via `tint`. --- @@ -180,7 +184,14 @@ by hand must do the same. `settle`, `fade` and layout springs never bounce and i is separate from the app theme. A light app can show dark pages and a dark app white ones. - Inside a viewer, **chrome follows the document, never the app theme**: - PDF viewer: each bar samples the luminance behind it (`bandLuminance`, inverted in dark reader) - and picks `chipGlass(onLight)` / `chipInk(onLight)`. + and picks `chipGlass(onLight)` / `chipInk(onLight)`. It reads where the page really is + (`pageFrame`), so book mode's letterbox and trimmed margins count as dark desk, not paper. +- Reading layouts are settings, not new screens: **Trim page margins** shows one document-wide + content rect (`PdfViewerViewModel.contentCrop`), laid out with `trimToContent` so markups, links, + form fields and selection keep their page coordinates; **Book mode** fits one page per screen, + a fling or sideways swipe turns exactly one page. Both stay on the vertical list. +- Gestures on a page (moving a markup, a list box) consume their pointer; the viewer's pan skips + a one-finger drag a page child has claimed. - Word editor and spreadsheet: the screen provides `LocalIsDarkMode = reader mode` to everything inside it and draws its own desk (light grey `#E3E4E9`, near-black `#0D0D0F`) behind the pages, so every glass control, dropdown menu, dock, dialog and the status-bar icons follow the paper. diff --git a/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt b/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt index 099edba..9f555e2 100644 --- a/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt +++ b/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt @@ -188,6 +188,14 @@ object AppSettingsManager { fun setInvertShareMorph(context: Context, enabled: Boolean) = prefs(context).edit().putBoolean("invert_share_morph", enabled).apply() fun setAutoDarkPages(context: Context, enabled: Boolean) = prefs(context).edit().putBoolean(KEY_AUTO_DARK_PAGES, enabled).apply() + /** The viewer trims the empty border around pages, so the text fills the screen (#85). */ + fun getTrimMargins(context: Context): Boolean = prefs(context).getBoolean("trim_page_margins", false) + fun setTrimMargins(context: Context, enabled: Boolean) = prefs(context).edit().putBoolean("trim_page_margins", enabled).apply() + + /** Book mode: one page per screen, a sideways swipe turns the page (#84). */ + fun getBookMode(context: Context): Boolean = prefs(context).getBoolean("book_mode", false) + fun setBookMode(context: Context, enabled: Boolean) = prefs(context).edit().putBoolean("book_mode", enabled).apply() + fun getReduceGlassMotion(context: Context): Boolean = prefs(context).getBoolean(KEY_REDUCE_GLASS_MOTION, false) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt b/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt index cefb489..2998acb 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt @@ -315,6 +315,8 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu LocalGlassOverlayHost provides overlayHost, androidx.compose.ui.platform.LocalHapticFeedback provides haptics ) { + // Per-icon ink in the document viewers (each control samples what's behind it). + com.chethan616.clearpdf.ui.components.ProvideScreenInk { Box(Modifier.fillMaxSize()) { // Captured layer = wallpaper + the live screen. The floating tab bar // (below) samples THIS, so it reflects real content scrolling under it @@ -562,6 +564,7 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu // Last child: above the tab bar and dialogs, outside the layer it samples. GlassOverlayLayer(overlayHost, contentBackdrop) } + } } } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/AdaptiveInk.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/AdaptiveInk.kt new file mode 100644 index 0000000..8d3b1c5 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/AdaptiveInk.kt @@ -0,0 +1,209 @@ +package com.chethan616.clearpdf.ui.components + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.graphics.Bitmap +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy +import android.view.View +import android.view.ViewTreeObserver +import android.view.Window +import androidx.annotation.RequiresApi +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume + +/** + * Per-control ink for the document viewers. Each icon reads what is really on screen around it + * and picks dark or white ink for itself, so two buttons in the same bar can differ (one over a + * white page, one over the dark desk beside it, a photo, book mode's letterbox). + * + * How: while at least one control asks, the window is copied, scaled down, a few times a second, + * and only after it actually drew something new. A control samples the corners of its own box, + * which sit outside its round or capsule glass, so its own ink never feeds back into the reading. + * Before Android 8 (no window PixelCopy) every control keeps the ink its screen gives it. + */ +@Stable +class ScreenInkSampler internal constructor() { + internal var frame by mutableStateOf(null) + internal var users by mutableIntStateOf(0) + + @RequiresApi(Build.VERSION_CODES.O) + internal suspend fun run(window: Window, view: View) { + val dirty = AtomicBoolean(true) + val listener = ViewTreeObserver.OnDrawListener { dirty.set(true) } + view.viewTreeObserver.addOnDrawListener(listener) + val handler = Handler(Looper.getMainLooper()) + var bitmap: Bitmap? = null + try { + while (true) { + if (users == 0) { + snapshotFlow { users > 0 }.first { it } + dirty.set(true) + } + val decor = window.decorView + val w = decor.width + val h = decor.height + if (w > 0 && h > 0 && dirty.getAndSet(false)) { + val bw = (w / SCALE_DOWN).coerceAtLeast(1) + val bh = (h / SCALE_DOWN).coerceAtLeast(1) + val target = bitmap?.takeIf { it.width == bw && it.height == bh && !it.isRecycled } + ?: Bitmap.createBitmap(bw, bh, Bitmap.Config.ARGB_8888).also { bitmap = it } + val ok = runCatching { + suspendCancellableCoroutine { cont -> + PixelCopy.request(window, target, { result -> if (cont.isActive) cont.resume(result) }, handler) + } == PixelCopy.SUCCESS + }.getOrDefault(false) + if (ok) { + val px = IntArray(bw * bh) + target.getPixels(px, 0, bw, 0, 0, bw, bh) + frame = InkFrame(px, bw, bh, bw / w.toFloat(), bh / h.toFloat()) + } + } + delay(SAMPLE_INTERVAL_MS) + } + } finally { + if (view.viewTreeObserver.isAlive) view.viewTreeObserver.removeOnDrawListener(listener) + bitmap?.recycle() + } + } + + private companion object { + const val SCALE_DOWN = 6 + const val SAMPLE_INTERVAL_MS = 140L + } +} + +/** A scaled-down copy of the window; coordinates in and out are window pixels. */ +internal class InkFrame( + private val pixels: IntArray, + private val width: Int, + private val height: Int, + private val scaleX: Float, + private val scaleY: Float +) { + private fun luminanceAt(x: Float, y: Float): Double { + val px = (x * scaleX).toInt().coerceIn(0, width - 1) + val py = (y * scaleY).toInt().coerceIn(0, height - 1) + val c = pixels[py * width + px] + return (0.299 * ((c shr 16) and 0xFF) + 0.587 * ((c shr 8) and 0xFF) + 0.114 * (c and 0xFF)) / 255.0 + } + + /** + * Luminance around [box]: just inside its four corners (outside the rounded glass), plus, at + * double weight, just outside its left and right edges level with its centre, where the icon + * itself sits. A control straddling a page edge so follows what is beside its glyph. + */ + fun cornerLuminance(box: Rect, inset: Float): Float { + val d = inset.coerceAtMost(minOf(box.width, box.height) * 0.14f) + val corners = luminanceAt(box.left + d, box.top + d) + luminanceAt(box.right - d, box.top + d) + + luminanceAt(box.left + d, box.bottom - d) + luminanceAt(box.right - d, box.bottom - d) + val cy = box.center.y + val sides = luminanceAt(box.left - d * 0.6f, cy) + luminanceAt(box.right + d * 0.6f, cy) + return ((corners + 2 * sides) / 8.0).toFloat() + } +} + +val LocalScreenInk = staticCompositionLocalOf { null } + +/** Runs one [ScreenInkSampler] for this window (Android 8+); the viewers' controls read from it. */ +@Composable +fun ProvideScreenInk(content: @Composable () -> Unit) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + content() + return + } + val view = LocalView.current + val window = remember(view) { view.context.findActivity()?.window } + val sampler = remember { ScreenInkSampler() } + if (window != null) LaunchedEffect(window, view) { sampler.run(window, view) } + CompositionLocalProvider(LocalScreenInk provides if (window != null) sampler else null, content = content) +} + +/** One control's ink: put [modifier] on the control (first, so it measures the resting box). */ +class InkProbe internal constructor(val modifier: Modifier, val color: Color) { + val soft: Color get() = color.copy(alpha = 0.62f) +} + +private val InkOnLight = Color(0xFF15171C) + +/** + * Ink for one control, from the pixels around it. [fallback] is the ink the screen would use for + * the whole bar: it applies until the first reading and on Android 7 and older, and when the + * reading agrees with it, it is kept as is (so a screen's own dark ink stays its exact colour). + */ +@Composable +fun rememberAdaptiveInk(fallback: Color): InkProbe { + val sampler = LocalScreenInk.current + val fallbackOnLight = fallback.luminance() < 0.5f + var onLight by remember { mutableStateOf(fallbackOnLight) } + var measured by remember { mutableStateOf(false) } + val box = remember { arrayOfNulls(1) } + val currentFallback by rememberUpdatedState(fallbackOnLight) + val inset = with(LocalDensity.current) { 5.dp.toPx() } + if (sampler != null) { + DisposableEffect(sampler) { + sampler.users++ + onDispose { sampler.users-- } + } + LaunchedEffect(sampler) { + snapshotFlow { sampler.frame }.collect { frame -> + val b = box[0] + if (frame == null || b == null || b.width <= 0f || b.height <= 0f) return@collect + val lum = frame.cornerLuminance(b, inset) + // Hysteresis, so a control over a mid-grey picture doesn't flicker. + onLight = when { + !measured -> lum > 0.60f + lum > 0.62f -> true + lum < 0.58f -> false + else -> onLight + } + measured = true + } + } + } + val useLight = if (sampler != null && measured) onLight else currentFallback + val target = when { + useLight == fallbackOnLight -> fallback + useLight -> InkOnLight + else -> Color.White + } + val color by animateColorAsState(target, tween(200), label = "adaptiveInk") + val modifier = remember { Modifier.onGloballyPositioned { box[0] = it.boundsInWindow() } } + return InkProbe(modifier, color) +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidBottomTabs.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidBottomTabs.kt index 52b44d7..99a7dc4 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidBottomTabs.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidBottomTabs.kt @@ -184,8 +184,9 @@ fun LiquidBottomTabs( }, highlight = { glassHighlight(GlassSettings.style) }, layerBlock = { + // No width yet: 16 dp / 0 would be NaN, a crash on Android 9 (#80). val progress = dampedDragAnimation.pressProgress - val scale = lerp(1f, 1f + 16f.dp.toPx() / size.width, progress) + val scale = if (size.width <= 0f) 1f else lerp(1f, 1f + 16f.dp.toPx() / size.width, progress) scaleX = scale scaleY = scale }, diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/DecryptPdfScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/DecryptPdfScreen.kt index c065ca4..a0367e9 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/DecryptPdfScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/DecryptPdfScreen.kt @@ -54,6 +54,7 @@ import com.chethan616.clearpdf.ui.components.GlassScreenScaffold import com.chethan616.clearpdf.ui.components.liquidGlassPanel import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.chethan616.clearpdf.utils.displayNameOf import com.kyant.backdrop.backdrops.LayerBackdrop import com.kyant.pdfcore.security.PdfSecurityService import kotlinx.coroutines.Dispatchers @@ -156,7 +157,7 @@ fun DecryptPdfScreen( sourceUri?.let { source -> BasicText( - source.lastPathSegment ?: stringResource(R.string.viewer_title), + remember(source) { displayNameOf(context, source) } ?: stringResource(R.string.viewer_title), style = TextStyle(text, 14.sp, FontWeight.Medium), maxLines = 1 ) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/DocxEditorScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/DocxEditorScreen.kt index a6b0684..c14526b 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/DocxEditorScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/DocxEditorScreen.kt @@ -185,6 +185,7 @@ import com.chethan616.clearpdf.docx.XNode import com.chethan616.clearpdf.docx.XmlScan import com.chethan616.clearpdf.docx.findPara import com.chethan616.clearpdf.docx.forEachPara +import com.chethan616.clearpdf.ui.components.rememberAdaptiveInk import com.chethan616.clearpdf.ui.components.GlassBackButton import com.chethan616.clearpdf.ui.components.GlassColorPicker import com.chethan616.clearpdf.ui.components.GlassDialog @@ -809,6 +810,12 @@ private fun DocxEditorContent( contentHorizontalPadding = 0.dp, headerHorizontalPadding = 12.dp, header = { headerBackdrop -> + // Each control takes its own ink from what is behind it; the paper's ink is the fallback. + val backInk = rememberAdaptiveInk(text) + val titleInk = rememberAdaptiveInk(text) + val findInk = rememberAdaptiveInk(text) + val paperInk = rememberAdaptiveInk(text) + val editInk = rememberAdaptiveInk(text) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -817,7 +824,8 @@ private fun DocxEditorContent( GlassBackButton( onBack = { requestBack() }, backdrop = headerBackdrop, - foreground = text, + modifier = backInk.modifier, + foreground = backInk.color, onLongPressBack = if (viewModel.dirty || editMode) ({ requestBack() }) else LocalBackToLibraryAction.current, longPressLabel = if (viewModel.dirty || editMode) stringResource(R.string.back) else null ) @@ -825,8 +833,9 @@ private fun DocxEditorContent( GlassTitlePill( text = (if (viewModel.dirty) "• " else "") + meta.fileName.ifBlank { stringResource(R.string.viewer_title) }, backdrop = headerBackdrop, + modifier = titleInk.modifier, surfaceColor = chipTint, - contentColor = text + contentColor = titleInk.color ) } if (viewModel.dirty && editable) { @@ -839,14 +848,14 @@ private fun DocxEditorContent( } } if (!editMode) { - LiquidIconButton(onClick = { showSearch = true }, backdrop = headerBackdrop) { - Icon(Icons.Rounded.Search, stringResource(R.string.viewer_find), Modifier.size(20.dp), text) + LiquidIconButton(onClick = { showSearch = true }, backdrop = headerBackdrop, modifier = findInk.modifier) { + Icon(Icons.Rounded.Search, stringResource(R.string.viewer_find), Modifier.size(20.dp), findInk.color) } - LiquidIconButton(onClick = onTogglePaper, backdrop = headerBackdrop) { + LiquidIconButton(onClick = onTogglePaper, backdrop = headerBackdrop, modifier = paperInk.modifier) { Icon( if (paperDark) Icons.Rounded.LightMode else Icons.Rounded.DarkMode, stringResource(if (paperDark) R.string.viewer_page_colors_original else R.string.viewer_page_dark_mode), - Modifier.size(19.dp), text + Modifier.size(19.dp), paperInk.color ) } } @@ -854,10 +863,11 @@ private fun DocxEditorContent( LiquidIconButton( onClick = { if (editMode) exitEdit() else { editMode = true; showSearch = false; searchQuery = "" } }, backdrop = headerBackdrop, + modifier = editInk.modifier, surfaceColor = if (editMode) accent.copy(0.22f) else Color.Unspecified ) { if (editMode) BasicText(stringResource(R.string.sheet_done), style = TextStyle(accent, 13.sp, FontWeight.SemiBold)) - else Icon(Icons.Rounded.Edit, stringResource(R.string.sheet_edit), Modifier.size(19.dp), text) + else Icon(Icons.Rounded.Edit, stringResource(R.string.sheet_edit), Modifier.size(19.dp), editInk.color) } } } @@ -1079,9 +1089,14 @@ private fun DocxEditorContent( // ── reading chrome: page counter, print, PDF layout and share ── if (doc != null && flat != null && !editMode && !showSearch) { + val pageInk = rememberAdaptiveInk(text) + val printInk = rememberAdaptiveInk(text) + val pdfInk = rememberAdaptiveInk(text) + val shareInk = rememberAdaptiveInk(text) Row( Modifier.align(Alignment.BottomStart).navigationBarsPadding().padding(start = 12.dp, bottom = 10.dp) .height(44.dp) + .then(pageInk.modifier) .viewerGlass(screenBackdrop.glass, chipTint, shape = { Capsule }) .clip(Capsule) .liquidRowClick(corner = 22.dp) { showGoTo = true } @@ -1091,7 +1106,7 @@ private fun DocxEditorContent( BasicText( stringResource(R.string.docx_page_of, currentPage + 1, flat.pages) + if (zoom != 1f) " · ${(zoom * 100).roundToInt()}%" else "", - style = TextStyle(text, 12.sp, FontWeight.SemiBold), + style = TextStyle(pageInk.color, 12.sp, FontWeight.SemiBold), maxLines = 1 ) } @@ -1099,18 +1114,18 @@ private fun DocxEditorContent( Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 12.dp, bottom = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - LiquidIconButton(onClick = { print() }, backdrop = screenBackdrop.glass, modifier = Modifier.size(44.dp)) { - Icon(Icons.Rounded.Print, stringResource(R.string.docx_print), Modifier.size(20.dp), text) + LiquidIconButton(onClick = { print() }, backdrop = screenBackdrop.glass, modifier = printInk.modifier.size(44.dp)) { + Icon(Icons.Rounded.Print, stringResource(R.string.docx_print), Modifier.size(20.dp), printInk.color) } - LiquidIconButton(onClick = { openAsPdf() }, backdrop = screenBackdrop.glass, modifier = Modifier.size(44.dp)) { - Icon(Icons.Rounded.PictureAsPdf, stringResource(R.string.docx_open_as_pdf), Modifier.size(20.dp), text) + LiquidIconButton(onClick = { openAsPdf() }, backdrop = screenBackdrop.glass, modifier = pdfInk.modifier.size(44.dp)) { + Icon(Icons.Rounded.PictureAsPdf, stringResource(R.string.docx_open_as_pdf), Modifier.size(20.dp), pdfInk.color) } LiquidIconButton( onClick = { viewModel.currentFile(context) { u -> u?.let { shareFile(context, it) } } }, backdrop = screenBackdrop.glass, - modifier = Modifier.size(44.dp) + modifier = shareInk.modifier.size(44.dp) ) { - Icon(Icons.Rounded.IosShare, stringResource(R.string.viewer_share_document), Modifier.size(20.dp), text) + Icon(Icons.Rounded.IosShare, stringResource(R.string.viewer_share_document), Modifier.size(20.dp), shareInk.color) } } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/EncryptPdfScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/EncryptPdfScreen.kt index a663bf7..40272e2 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/EncryptPdfScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/EncryptPdfScreen.kt @@ -55,6 +55,7 @@ import com.chethan616.clearpdf.ui.components.GlassScreenScaffold import com.chethan616.clearpdf.ui.components.liquidGlassPanel import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.chethan616.clearpdf.utils.displayNameOf import com.kyant.backdrop.backdrops.LayerBackdrop import com.kyant.pdfcore.security.PdfSecurityService import kotlinx.coroutines.Dispatchers @@ -173,7 +174,7 @@ fun EncryptPdfScreen( sourceUri?.let { source -> BasicText( - source.lastPathSegment ?: stringResource(R.string.viewer_title), + remember(source) { displayNameOf(context, source) } ?: stringResource(R.string.viewer_title), style = TextStyle(text, 14.sp, FontWeight.Medium), maxLines = 1 ) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt index 8ed9fc4..d1ffb8a 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt @@ -77,6 +77,9 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.layout +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.customActions @@ -125,6 +128,9 @@ internal fun PdfContinuousPage( page: Int, backdrop: LayerBackdrop, bitmap: Bitmap?, + modifier: Modifier = Modifier, + /** Trim page margins (#85): the 0..1 part of the page to show; the rest is laid out but clipped. */ + crop: android.graphics.RectF? = null, darkPageAppearance: Boolean = false, /** Dark reader: photographs on this page (normalised rects), drawn as printed over the night page. */ nightPhotos: List = emptyList(), @@ -145,6 +151,9 @@ internal fun PdfContinuousPage( onTapFormWidget: (com.kyant.pdfcore.form.PdfFormWidget, Rect) -> Unit = { _, _ -> }, onFormTextChange: (com.kyant.pdfcore.form.PdfFormWidget, String) -> Unit = { _, _ -> }, onFormImeNext: (com.kyant.pdfcore.form.PdfFormWidget) -> Unit = {}, + /** The PDF's own links on this page; a tap on one opens it (#83). */ + links: List = emptyList(), + onOpenLink: (com.kyant.pdfcore.link.PdfLink) -> Unit = {}, ocrBlocks: List, findMatches: List, currentMatchIndex: Int, @@ -193,6 +202,8 @@ internal fun PdfContinuousPage( val latestFormValues by rememberUpdatedState(formValues) val latestOnTapFormWidget by rememberUpdatedState(onTapFormWidget) val latestOnFormValue by rememberUpdatedState(onFormTextChange) + val latestLinks by rememberUpdatedState(links) + val latestOnOpenLink by rememberUpdatedState(onOpenLink) // List boxes scroll inside their own box; offsets in rows, per field box. val formListScroll = remember { androidx.compose.runtime.mutableStateMapOf() } // This page's coordinates, to place a dropdown's menu under the field in window space. @@ -233,12 +244,14 @@ internal fun PdfContinuousPage( } Box( - Modifier + modifier // While an image is dragged across a page boundary, this page draws above its // neighbours so the image isn't hidden behind the next page. .zIndex(if (imageDragging) 1f else 0f) .fillMaxWidth() .padding(vertical = 6.dp) + // The placeholder is trimmed too, so the page keeps its height when the bitmap lands. + .then(if (crop != null) Modifier.trimToContent(crop) else Modifier) .then(if (bitmap == null) Modifier.aspectRatio(placeholderAspect ?: (1f / 1.414f)) else Modifier) .background(Color(0xFF15181E)) // Page-local coordinates for the text selection's screen mapping (see PdfViewportTransform). @@ -660,6 +673,20 @@ internal fun PdfContinuousPage( onShowControls() onInteraction() } + } else if (currentSelected < 0 && currentGroup.isEmpty() && latestLinks.isNotEmpty()) { + // A link printed on the page: a tap (not a drag, not a long-press) follows it. + val nx = down.position.x / size.width.toFloat() + val ny = down.position.y / size.height.toFloat() + val slop = 4f / size.width.toFloat() + val link = latestLinks.lastOrNull { l -> + nx >= l.left - slop && nx <= l.right + slop && ny >= l.top - slop && ny <= l.bottom + slop + } ?: return@awaitEachGesture + val up = waitForUpOrCancellation() ?: return@awaitEachGesture + // A zoomed pan doesn't consume, so tell a pan that ended on the link apart from a tap. + if ((up.position - down.position).getDistance() > viewConfiguration.touchSlop) return@awaitEachGesture + up.consume() + latestOnOpenLink(link) + onInteraction() } else { // Missed everything → clear any selection (tap propagates to container). if (currentSelected >= 0) onSelectMarkup(-1) @@ -1246,3 +1273,21 @@ private fun MarkupBarItem( if (!iconOnly) BasicText(label, style = TextStyle(color, 14.sp, FontWeight.SemiBold), maxLines = 1) } } + +/** + * Trim page margins (#85): the page is laid out larger, so [crop] spans the available width, and + * only that part is shown. Everything inside keeps its own page coordinates, so markups, links, + * form fields and the text selection stay where they are printed. + */ +private fun Modifier.trimToContent(crop: android.graphics.RectF): Modifier = clipToBounds().layout { measurable, constraints -> + if (!constraints.hasBoundedWidth || crop.width() <= 0f || crop.height() <= 0f) { + val p = measurable.measure(constraints) + return@layout layout(p.width, p.height) { p.place(0, 0) } + } + val width = constraints.maxWidth + val inner = (width / crop.width()).roundToInt() + val p = measurable.measure(Constraints(minWidth = inner, maxWidth = inner)) + layout(width, (p.height * crop.height()).roundToInt()) { + p.place(-(crop.left * inner).roundToInt(), -(crop.top * p.height).roundToInt()) + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfSearchBar.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfSearchBar.kt index b6afde9..e23bc94 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfSearchBar.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfSearchBar.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.rememberAdaptiveInk import com.chethan616.clearpdf.ui.components.CloseCrossIcon import com.chethan616.clearpdf.ui.components.GlassSearchPill import com.chethan616.clearpdf.ui.components.LiquidIconButton @@ -63,6 +64,11 @@ internal fun PdfSearchBar( try { focusRequester.requestFocus() } catch (_: Exception) {} } + // Every control takes its ink from what is behind it; [fg] is the fallback. + val pillInk = rememberAdaptiveInk(fg) + val prevInk = rememberAdaptiveInk(fg) + val nextInk = rememberAdaptiveInk(fg) + val closeInk = rememberAdaptiveInk(fg) Row( Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -74,21 +80,21 @@ internal fun PdfSearchBar( hint = stringResource(R.string.viewer_find_hint), backdrop = backdrop, uiSensor = uiSensor, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f).then(pillInk.modifier), focusRequester = focusRequester, // The bar's own slide-up is the entrance; a spring-in on the pill would stack on it. animateIn = false, // Same material as the top bar's title pill, not Home's heavier app chrome. viewerChrome = true, surfaceColor = surface, - contentColor = fg, - hintColor = fgSoft, + contentColor = pillInk.color, + hintColor = pillInk.soft, // The match count belongs with the query it counts, not out beside the buttons. trailing = { if (matchCount > 0) { BasicText( "${currentMatchIndex + 1}/$matchCount", - style = TextStyle(fgSoft, 11.sp, FontWeight.SemiBold) + style = TextStyle(pillInk.soft, 11.sp, FontWeight.SemiBold) ) } else if (query.isNotBlank()) { BasicText( @@ -104,22 +110,22 @@ internal fun PdfSearchBar( LiquidIconButton( onClick = onPrevMatch, backdrop = backdrop, - modifier = Modifier.size(FindBarButtonSize) + modifier = prevInk.modifier.size(FindBarButtonSize) ) { - Icon(Icons.Rounded.KeyboardArrowUp, stringResource(R.string.previous), Modifier.size(20.dp), fg) + Icon(Icons.Rounded.KeyboardArrowUp, stringResource(R.string.previous), Modifier.size(20.dp), prevInk.color) } LiquidIconButton( onClick = onNextMatch, backdrop = backdrop, - modifier = Modifier.size(FindBarButtonSize) + modifier = nextInk.modifier.size(FindBarButtonSize) ) { - Icon(Icons.Rounded.KeyboardArrowDown, stringResource(R.string.next), Modifier.size(20.dp), fg) + Icon(Icons.Rounded.KeyboardArrowDown, stringResource(R.string.next), Modifier.size(20.dp), nextInk.color) } - val closeTint = if (fg.luminance() > 0.5f) Color(0xFFD78A92) else Color(0xFFA84D58) + val closeTint = if (closeInk.color.luminance() > 0.5f) Color(0xFFD78A92) else Color(0xFFA84D58) LiquidIconButton( onClick = onClose, backdrop = backdrop, - modifier = Modifier.size(FindBarButtonSize), + modifier = closeInk.modifier.size(FindBarButtonSize), surfaceColor = closeTint.copy(alpha = 0.12f) ) { CloseCrossIcon(Modifier.size(13.dp), closeTint) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerBottomToolbar.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerBottomToolbar.kt index 828d8a7..1b0fb64 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerBottomToolbar.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerBottomToolbar.kt @@ -88,6 +88,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.chethan616.clearpdf.R import com.chethan616.clearpdf.ui.theme.LiquidGlassColors +import com.chethan616.clearpdf.ui.components.rememberAdaptiveInk import com.chethan616.clearpdf.ui.components.CloseCrossIcon import com.chethan616.clearpdf.ui.components.GlassMotion import com.chethan616.clearpdf.ui.components.GlassGuideCallout @@ -737,6 +738,10 @@ internal fun PdfViewerBottomToolbar( } // ── Reader actions: editor, auto-scroll, speed, and ShareMorph share one bottom row. + // Each control takes its own ink from what is behind it; the bar's [fg] is the fallback. + val editorInk = rememberAdaptiveInk(fg) + val autoScrollInk = rememberAdaptiveInk(fg) + val speedInk = rememberAdaptiveInk(fg) AnimatedVisibility( // Same `selectorGate` as the tool chips above, so the controls fade out together // before the focused sub-toolbar arrives. @@ -757,25 +762,25 @@ internal fun PdfViewerBottomToolbar( if (!editorOpen) onSetActiveTool(PdfEditTool.None) }, backdrop = backdrop, - modifier = Modifier.size(ReaderControlSize), + modifier = editorInk.modifier.size(ReaderControlSize), tint = if (editorOpen) accent else Color.Unspecified, surfaceColor = if (!editorOpen && (readerTourStep == 0 || readerTourStep == 2)) LiquidGlassColors.Blue.copy(alpha = 0.22f) else Color.Unspecified ) { - Icon(Icons.Rounded.Edit, stringResource(R.string.viewer_editor_tools), Modifier.size(20.dp), if (editorOpen) Color.White else fg) + Icon(Icons.Rounded.Edit, stringResource(R.string.viewer_editor_tools), Modifier.size(20.dp), if (editorOpen) Color.White else editorInk.color) } } Box(Modifier.size(ReaderControlSize), contentAlignment = Alignment.Center) { LiquidIconButton( onClick = onToggleAutoScroll, backdrop = backdrop, - modifier = Modifier.size(ReaderControlSize), + modifier = autoScrollInk.modifier.size(ReaderControlSize), tint = if (autoScrollActive) LiquidGlassColors.Green else Color.Unspecified ) { Icon( if (autoScrollActive) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, stringResource(if (autoScrollActive) R.string.viewer_auto_scroll_pause else R.string.viewer_auto_scroll_start), Modifier.size(20.dp), - if (autoScrollActive) Color.White else fg + if (autoScrollActive) Color.White else autoScrollInk.color ) } } @@ -788,12 +793,12 @@ internal fun PdfViewerBottomToolbar( LiquidButton( onClick = onCycleAutoScrollSpeed, backdrop = backdrop, - modifier = Modifier.widthIn(min = 72.dp), + modifier = speedInk.modifier.widthIn(min = 72.dp), surfaceColor = Color.Transparent ) { BasicText( text = "${autoScrollSpeed}×", - style = TextStyle(fg, 13.sp, FontWeight.SemiBold) + style = TextStyle(speedInk.color, 13.sp, FontWeight.SemiBold) ) } } @@ -805,6 +810,7 @@ internal fun PdfViewerBottomToolbar( } // Every document format uses the same ShareMorph position and open/share actions. + val shareInk = rememberAdaptiveInk(fg) AnimatedVisibility( visible = selectorGate && !showFindBar && !showSignaturePad, enter = fadeIn(tween(180)), @@ -815,7 +821,8 @@ internal fun PdfViewerBottomToolbar( size = ReaderControlSize, backdrop = backdrop, glass = if (readerTourStep == 1) LiquidGlassColors.Blue.copy(alpha = 0.2f) else pillGlass, - fg = fg, + fg = shareInk.color, + modifier = shareInk.modifier, onOpen = onOpenAnotherPdf, onShare = onShareDocument, onShareModeChanged = { shareActive = it; onShareHoldChanged(it) } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerDialogs.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerDialogs.kt index b458fd5..7f5b954 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerDialogs.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerDialogs.kt @@ -384,7 +384,9 @@ internal fun ExportShareDialog( onDismiss: () -> Unit, onShare: (format: ShareFormat, encrypt: Boolean, password: String) -> Unit, /** Prints the document exactly as it is, through Android's print screen (after Gander). */ - onPrint: (() -> Unit)? = null + onPrint: (() -> Unit)? = null, + /** Saves the chosen format to a place the user picks, edited or not (#76). */ + onSaveCopy: ((format: ShareFormat, encrypt: Boolean, password: String) -> Unit)? = null ) { val pdfOnly = originalExt == null // Keyed on `visible` so every open starts fresh. @@ -474,6 +476,17 @@ internal fun ExportShareDialog( } } } + if (onSaveCopy != null) { + // Page images are a set of files; everything else saves as one. + val canSave = canShare && format != ShareFormat.IMAGES + Row(Modifier.fillMaxWidth().padding(top = 4.dp)) { + GlassDialogAction( + stringResource(R.string.viewer_save_copy), + { if (canSave) onSaveCopy(format, encrypt && pdfSelected, password) }, + enabled = canSave + ) + } + } } } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt index be31501..c7c5500 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt @@ -7,6 +7,8 @@ import androidx.compose.runtime.derivedStateOf import android.graphics.Bitmap import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.width import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring @@ -138,6 +140,7 @@ import com.chethan616.clearpdf.data.repository.AppSettingsManager import com.chethan616.clearpdf.data.repository.OnboardingManager import com.chethan616.clearpdf.data.repository.RecentFilesManager import com.chethan616.clearpdf.data.repository.PdfPageBookmarksManager +import com.chethan616.clearpdf.ui.components.rememberAdaptiveInk import com.chethan616.clearpdf.ui.components.UnsavedChangesDialog import com.chethan616.clearpdf.ui.components.GlassBottomSheet import com.chethan616.clearpdf.ui.components.DecryptingAnimation @@ -247,6 +250,13 @@ fun PdfViewerScreen( // Only the moon button's choice is saved; until it is used the pages follow the theme, so a // first frame drawn before the theme setting applies can't freeze the wrong colours. var darkPageOverride by rememberSaveable { mutableStateOf(null) } + // Settings > Appearance: trim page margins (#85) and book mode (#84), read once per document. + val trimMargins = remember { com.chethan616.clearpdf.data.repository.AppSettingsManager.getTrimMargins(context) } + val bookMode = remember { com.chethan616.clearpdf.data.repository.AppSettingsManager.getBookMode(context) } + var contentCrop by remember { mutableStateOf(null) } + LaunchedEffect(state.document?.uri, state.pageCount) { + contentCrop = if (trimMargins && state.document != null) viewModel.contentCrop() else null + } val darkPageAppearance = darkPageOverride ?: autoDarkPages var bookmarkedPages by remember { mutableStateOf>(emptyList()) } // Annotation (text box / sticky note) editing @@ -432,6 +442,14 @@ fun PdfViewerScreen( // glides longer and settles smoothly (vs the stiffer platform spline), so even 2–3 page // docs feel continuous instead of stopping abruptly. Only the fling curve changes — the // scroll mechanics and the default stretch overscroll (rubber-band at the edges) are intact. + // Book mode (#84): a fling turns exactly one page, however hard, like a page of a book. + val bookSnap = remember(listState) { + val base = androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider(listState) + object : androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider by base { + override fun calculateApproachOffset(velocity: Float, decayOffset: Float): Float = 0f + } + } + val bookFling = androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior(bookSnap) val iosPageDecay = remember { androidx.compose.animation.core.exponentialDecay(frictionMultiplier = 0.55f) } val pageFling = remember(iosPageDecay) { object : androidx.compose.foundation.gestures.FlingBehavior { @@ -845,6 +863,27 @@ fun PdfViewerScreen( val offset = if (canvas != null) (w.top * canvas.height - viewport * 0.22f).toInt() else 0 runCatching { listState.animateScrollToItem(w.pageIndex, offset.coerceAtLeast(0)) } } + // A link on the page (#83): a jump inside the document scrolls there; a web or mail address + // goes to the app that handles it. Other schemes (javascript:, file:, intent:) are ignored. + val onOpenLink: (com.kyant.pdfcore.link.PdfLink) -> Unit = { link -> + val target = link.targetPage + if (target != null) { + val canvas = pageCanvasSizes[target] + val top = link.targetTop + val offset = if (canvas != null && top != null) (top * canvas.height).toInt() else 0 + viewerScope.launch { + runCatching { listState.animateScrollToItem(target.coerceIn(0, safePageCount - 1), offset.coerceAtLeast(0)) } + } + } else link.uri?.let { raw -> + val withScheme = if (Regex("^[a-zA-Z][a-zA-Z0-9+.-]*:").containsMatchIn(raw)) raw else "https://$raw" + val parsed = android.net.Uri.parse(withScheme) + if (parsed.scheme?.lowercase() in setOf("http", "https", "mailto", "tel")) { + runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, parsed).addCategory(Intent.CATEGORY_BROWSABLE)) + } + } + } + } // A dropdown field's open menu, and its field's box in window space. var formMenu by remember { mutableStateOf(null) } var formMenuAnchor by remember { mutableStateOf(androidx.compose.ui.geometry.Rect.Zero) } @@ -1002,7 +1041,9 @@ fun PdfViewerScreen( .fillMaxSize() .background(if (isLight) Color(0xFF0A0E14).copy(0.85f) else Color(0xFF020305).copy(0.88f)) ) { - val renderWidthPx = with(LocalDensity.current) { maxWidth.roundToPx() }.coerceAtLeast(720) + // Trimmed margins lay the page out wider than the screen, so it renders wider too. + val renderWidthPx = (with(LocalDensity.current) { maxWidth.roundToPx() }.coerceAtLeast(720) / + (contentCrop?.width() ?: 1f)).roundToInt().coerceAtMost(4096) // Re-render the focused page after zoom settles. Scaling a screen-sized bitmap makes PDF // text and fine diagrams soft; a bounded higher-resolution page keeps pinch zoom readable @@ -1071,6 +1112,19 @@ fun PdfViewerScreen( } } + // Book mode / trimmed margins: where each page really sits, for the ink sampling below. + val pageFrameOf: (Int) -> FloatArray? = { p -> + val c = contentCrop + val o = textSelection.pageOrigin(p) + val sz = textSelection.pageSize(p) + when { + !bookMode && c == null -> null + // Not placed yet: a book-mode page sits inside letterbox, so count nothing for now. + o == null || sz == null || sz.height <= 0f -> if (bookMode) floatArrayOf(0f, 1f, 0f, 0f) else null + else -> floatArrayOf(o.y, sz.height, o.y + (c?.top ?: 0f) * sz.height, o.y + (c?.bottom ?: 1f) * sz.height) + } + } + // ── Adaptive control ink, sampled per bar (Apple-style) ────────────────────────────── // Each bar reads the luminance of the content *directly behind it*, so on a page that is a // dark image up top and a white canvas below, the top bar goes white while the bottom toolbar @@ -1089,7 +1143,7 @@ fun PdfViewerScreen( LaunchedEffect(containerHeightPx, darkPageAppearance) { var firstSample = true snapshotFlow { - bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, 0f, topBandBottomPx, darkPageAppearance) + bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, 0f, topBandBottomPx, darkPageAppearance, pageFrame = pageFrameOf) }.collect { lum -> if (firstSample) { topBarLight = lum > 0.60f @@ -1102,7 +1156,7 @@ fun PdfViewerScreen( snapshotFlow { val h = containerHeightPx.toFloat() if (h <= 0f) 0f - else bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, h - bottomBandDepthPx, h, darkPageAppearance) + else bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, h - bottomBandDepthPx, h, darkPageAppearance, pageFrame = pageFrameOf) }.collect { lum -> if (firstSample) { bottomBarLight = lum > 0.60f @@ -1136,7 +1190,7 @@ fun PdfViewerScreen( if (h <= 0f) 0f else { val bottom = (h - imeInset.getBottom(density)).coerceAtLeast(bottomBandDepthPx) - bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, bottom - bottomBandDepthPx, bottom, darkPageAppearance) + bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, bottom - bottomBandDepthPx, bottom, darkPageAppearance, pageFrame = pageFrameOf) } }.collect { lum -> if (lum > 0.62f) findBarLight = true else if (lum < 0.58f) findBarLight = false @@ -1159,6 +1213,29 @@ fun PdfViewerScreen( Modifier .fillMaxSize() .onSizeChanged { containerHeightPx = it.height; containerWidthPx = it.width } + // Book mode (#84): a sideways swipe turns the page. Outside the pan handler, so a + // zoomed-in pan still sees the finger first; a markup drag consumes it first. + .then( + if (!bookMode || activeTool != PdfEditTool.None) Modifier + else Modifier.pointerInput(state.pageCount) { + var dx = 0f + detectHorizontalDragGestures( + onDragStart = { dx = 0f }, + onHorizontalDrag = { _, amount -> dx += amount }, + onDragEnd = { + if (scale <= 1.01f && !textSelection.gestureActive && kotlin.math.abs(dx) > 48.dp.toPx()) { + val info = listState.layoutInfo + val shown = info.visibleItemsInfo + .minByOrNull { kotlin.math.abs(it.offset + it.size / 2 - info.viewportEndOffset / 2) } + ?.index ?: listState.firstVisibleItemIndex + val target = (shown + if (dx < 0f) 1 else -1).coerceIn(0, safePageCount - 1) + viewerScope.launch { listState.animateScrollToItem(target) } + lastInteractionAtMs = System.currentTimeMillis() + } + } + ) + } + ) // Long-press → select word. First in the chain so, once it fires, it can consume // in the Initial pass ahead of the zoom/tap handlers and the page list. .pdfTextSelectionGestures( @@ -1176,18 +1253,32 @@ fun PdfViewerScreen( awaitEachGesture { awaitFirstDown(requireUnconsumed = false) var accumulatedPan = Offset.Zero + // A one-finger pan only starts past touch slop, and a drag a page + // child claimed first (moving or resizing a markup, an image) keeps + // the finger for the whole gesture: the page stays put under it (#88). + var childOwnsDrag = false + var panStarted = false do { val event = awaitPointerEvent() // A selection drag owns the finger: no one-finger pan underneath it. if (textSelection.gestureActive) continue + val oneFinger = event.changes.count { it.pressed } <= 1 + if (oneFinger && event.changes.any { it.isConsumed }) childOwnsDrag = true + if (childOwnsDrag && oneFinger) continue val zoomChange = event.calculateZoom() - val panChange = event.calculatePan() + var panChange = event.calculatePan() accumulatedPan += panChange // A tap may hide the chrome, but must not stop reading. Stop only // after a real drag or pinch takes ownership of the page. if (autoScroll && (zoomChange != 1f || accumulatedPan.getDistance() > viewConfiguration.touchSlop)) { autoScroll = false } + if (!panStarted) { + if (oneFinger && zoomChange == 1f && accumulatedPan.getDistance() <= viewConfiguration.touchSlop) continue + // Catch up on the movement held back while waiting for slop. + panStarted = true + panChange = accumulatedPan + } val zoomed = scale > 1.001f if (zoomChange != 1f || zoomed) { val cw = size.width.toFloat() @@ -1273,7 +1364,7 @@ fun PdfViewerScreen( ) { LazyColumn( state = listState, - flingBehavior = pageFling, + flingBehavior = if (bookMode) bookFling else pageFling, userScrollEnabled = activeTool == PdfEditTool.None && scale <= 1.01f, modifier = Modifier.fillMaxSize(), // Center the block when it's shorter than the viewport (e.g. a @@ -1281,9 +1372,22 @@ fun PdfViewerScreen( // the top with a dark blank half below. No effect once content // overflows (multi-page / zoomed), where it scrolls normally. verticalArrangement = Arrangement.Center, - contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 4.dp, bottom = 4.dp + extraBottomPadding) + contentPadding = androidx.compose.foundation.layout.PaddingValues( + top = if (bookMode) 0.dp else 4.dp, + bottom = (if (bookMode) 0.dp else 4.dp) + extraBottomPadding + ) ) { items(count = safePageCount, key = { it }, contentType = { "pdfPage" }) { page -> + // Book mode (#84): each page fits the screen, centred in a screen-tall slot. + val bookSlot = if (bookMode) Modifier.fillParentMaxSize() else null + val bookPage = if (!bookMode) Modifier else { + val sz = pageBitmapSizes[page] ?: pageBitmapSizes.values.firstOrNull { it.width > 0f && it.height > 0f } + val aspect = sz?.takeIf { it.height > 0f } + ?.let { (it.width * (contentCrop?.width() ?: 1f)) / (it.height * (contentCrop?.height() ?: 1f)) } + val gap = with(density) { 12.dp.toPx() } + if (aspect == null || containerHeightPx <= 0 || containerWidthPx <= 0) Modifier + else Modifier.width(with(density) { minOf(containerWidthPx.toFloat(), (containerHeightPx - gap) * aspect).toDp() }) + } // Re-request whenever this page has no bitmap (covers cache eviction // while the item stays composed in the lazy list). val needsRender = state.pageBitmaps.getOrNull(page) == null @@ -1294,10 +1398,13 @@ fun PdfViewerScreen( LaunchedEffect(page, darkPageAppearance, needsRender) { if (darkPageAppearance && !needsRender) viewModel.ensureNightPhotos(context, page) } + OptionalSlot(bookSlot) { PdfContinuousPage( page = page, backdrop = backdrop, bitmap = state.pageBitmaps.getOrNull(page), + modifier = bookPage, + crop = contentCrop, darkPageAppearance = darkPageAppearance, nightPhotos = if (darkPageAppearance) viewModel.nightPhotos[page].orEmpty() else emptyList(), marks = getPageMarks(page), @@ -1318,6 +1425,8 @@ fun PdfViewerScreen( val i = formOrder.indexOfFirst { it.key == w.key } focusedForm = formOrder.getOrNull(i + 1) }, + links = state.linksByPage[page].orEmpty(), + onOpenLink = onOpenLink, ocrBlocks = state.ocrBlocksByPage[page].orEmpty(), findMatches = state.findMatches, currentMatchIndex = state.currentMatchIndex, @@ -1467,6 +1576,7 @@ fun PdfViewerScreen( } } ) + } } } } @@ -1540,12 +1650,19 @@ fun PdfViewerScreen( horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth() ) { + // Each control picks its own ink from what is behind it (book mode's letterbox, + // a page edge, a photo), with the bar's band reading as the fallback. + val backInk = rememberAdaptiveInk(topFg) + val pillInk = rememberAdaptiveInk(topFg) + val findInk = rememberAdaptiveInk(topFg) + val moonInk = rememberAdaptiveInk(topFg) // No `surfaceColor`, exactly as Home calls it: the circle paints nothing of its own // and is pure refraction. Only the icon's colour adapts to the page. GlassBackButton( onBack = { requestExit() }, backdrop = contentBackdrop, - foreground = topFg, + modifier = backInk.modifier, + foreground = backInk.color, surfaceColor = Color.Unspecified, onLongPressBack = if (hasUnsavedEdits) ({ requestExit() }) else LocalBackToLibraryAction.current, longPressLabel = if (hasUnsavedEdits) stringResource(R.string.back) else null @@ -1557,6 +1674,7 @@ fun PdfViewerScreen( GlassTitlePill( text = stringResource(R.string.viewer_page_of, currentPageIndex + 1, safePageCount), backdrop = contentBackdrop, + modifier = pillInk.modifier, onClick = { showPageJumpDialog = true }, // Transparent, not omitted: omitting falls back to the theme's 0.35 tint, // and this pill sits between two circles that paint nothing at all, so any @@ -1564,7 +1682,7 @@ fun PdfViewerScreen( // `drawRect(Color.Transparent)` is the same no-op `LiquidIconButton` // performs when it is given no `surfaceColor`. surfaceColor = Color.Transparent, - contentColor = topFg, + contentColor = pillInk.color, // The enclosing AnimatedVisibility already slides and fades the whole bar // in every time the chrome returns; the pill's own spring would stack on // top of that and read as a stutter. Same call the find bar makes. @@ -1577,19 +1695,21 @@ fun PdfViewerScreen( if (showFindBar) viewModel.triggerOcrForAllPages(context) else { focusManager.clearFocus(); viewModel.clearSearch(); findQuery = "" } }, - backdrop = contentBackdrop + backdrop = contentBackdrop, + modifier = findInk.modifier ) { - Icon(Icons.Rounded.Search, stringResource(R.string.viewer_find), Modifier.size(20.dp), topFg) + Icon(Icons.Rounded.Search, stringResource(R.string.viewer_find), Modifier.size(20.dp), findInk.color) } LiquidIconButton( onClick = { darkPageOverride = !darkPageAppearance }, - backdrop = contentBackdrop + backdrop = contentBackdrop, + modifier = moonInk.modifier ) { Icon( if (darkPageAppearance) Icons.Rounded.LightMode else Icons.Rounded.DarkMode, stringResource(if (darkPageAppearance) R.string.viewer_page_colors_original else R.string.viewer_page_dark_mode), Modifier.size(20.dp), - topFg + moonInk.color ) } } @@ -2032,7 +2152,7 @@ fun PdfViewerScreen( // Floating contextual chrome, in the title-chip glass over the live page, with ink sampled from // the document behind each control (same band sampling as the bars). val pageLuminanceAt: (Float, Float) -> Float = { top, bottom -> - bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, top, bottom, darkPageAppearance) + bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, top, bottom, darkPageAppearance, pageFrame = pageFrameOf) } PdfMarkupBarLayer( host = markupBarHost, @@ -2282,6 +2402,104 @@ fun PdfViewerScreen( // Original vs PDF (with optional encryption). `originalExt` is null for a plain PDF, which // collapses the dialog to just the encrypt toggle. val shareExt = state.fileName.substringAfterLast('.', "").uppercase() + // The file a Share or a Save a copy hands over, built off the main thread: copy/encrypt into + // cacheDir/shared, which the app FileProvider serves (file_paths.xml cache-path "/"). + fun buildSharePayload(format: ShareFormat, encrypt: Boolean, password: String): Pair? = + runCatching { + fun wrap(u: android.net.Uri): android.net.Uri = + if (u.scheme == "file") + androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.provider", java.io.File(u.path!!) + ) + else u + val shareDir = java.io.File(context.cacheDir, "shared").apply { mkdirs() } + // Unsaved edits travel with the share: build an edited copy first and + // make every PDF-derived format (PDF, images, text) from it. + val edited: android.net.Uri? = if (hasUnsavedEdits && format != ShareFormat.ORIGINAL) { + val overlays = buildExportOverlays(annotationsByPage, state.ocrBlocksByPage, pageCanvasSizes, pageBitmapSizes) + val name = state.fileName.substringBeforeLast('.').ifBlank { "document" } + .replace(Regex("[^A-Za-z0-9._-]"), "_") + viewModel.writeEditedCopy(context, overlays, java.io.File(shareDir, "${name}_edited.pdf")) + } else null + val sourcePdf = edited ?: state.document?.uri + when (format) { + ShareFormat.IMAGES -> sourcePdf?.let { pdf -> + val stem = state.fileName.substringBeforeLast('.').ifBlank { "page" } + .replace(Regex("[^A-Za-z0-9._-]"), "_") + val count = state.pageCount.coerceAtLeast(1) + val uris = ArrayList() + for (p in 0 until count) { + val bmp = com.kyant.pdfcore.raster.PdfRasterizer.rasterizePageBitmap(context, pdf, p) ?: continue + val f = java.io.File(shareDir, "${stem}_${p + 1}.png") + f.outputStream().use { bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) } + bmp.recycle() + uris += androidx.core.content.FileProvider.getUriForFile(context, "${context.packageName}.provider", f) + } + shareMultiple = uris + uris.firstOrNull()?.let { it to "image/png" } + } + ShareFormat.TEXT -> sourcePdf?.let { pdf -> + val stem = state.fileName.substringBeforeLast('.').ifBlank { "document" } + .replace(Regex("[^A-Za-z0-9._-]"), "_") + val f = java.io.File(shareDir, "$stem.txt") + f.writeText(viewModel.extractAllText(context, pdf)) + androidx.core.content.FileProvider.getUriForFile(context, "${context.packageName}.provider", f) to "text/plain" + } + ShareFormat.ORIGINAL -> state.originalUri?.let { orig -> + // Mirror the original into our own storage so the target app can + // actually read it — a SAF uri from another provider can't be + // re-granted to a third app. + val safeName = state.fileName.ifBlank { "document.$shareExt" } + .replace(Regex("[^A-Za-z0-9._-]"), "_") + val out = java.io.File(shareDir, "${System.currentTimeMillis()}_$safeName") + context.contentResolver.openInputStream(orig)?.use { input -> + out.outputStream().use { input.copyTo(it) } + } ?: return@runCatching null + androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.provider", out + ) to (context.contentResolver.getType(orig) ?: "application/octet-stream") + } + ShareFormat.PDF -> sourcePdf?.let { pdf -> + if (encrypt && password.isNotBlank()) { + val out = java.io.File(shareDir, "protected_${System.currentTimeMillis()}.pdf") + val outUri = androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.provider", out + ) + com.kyant.pdfcore.security.PdfSecurityService.encryptToUri(context, pdf, outUri, password) + outUri to "application/pdf" + } else { + wrap(pdf) to "application/pdf" + } + } + } + }.getOrNull() + // Save a copy (#76): the file the user picked, as it is (or as the chosen format), to a + // place they choose. The format etc. wait here while the system save screen is up. + var pendingSaveCopy by remember { mutableStateOf?>(null) } + val saveCopyLauncher = rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult() + ) { res -> + val target = res.data?.data + val req = pendingSaveCopy + pendingSaveCopy = null + if (res.resultCode != android.app.Activity.RESULT_OK || target == null || req == null) return@rememberLauncherForActivityResult + viewerScope.launch { + val ok = withContext(Dispatchers.IO) { + runCatching { + val (src, _) = buildSharePayload(req.first, req.second, req.third) ?: return@runCatching false + shareMultiple = emptyList() + context.contentResolver.openInputStream(src)?.use { input -> + context.contentResolver.openOutputStream(target, "w")?.use { input.copyTo(it) } + } != null + }.getOrDefault(false) + } + android.widget.Toast.makeText( + context, + if (ok) R.string.viewer_save_copy_done else R.string.viewer_save_copy_failed, + android.widget.Toast.LENGTH_SHORT + ).show() + } + } ExportShareDialog( visible = showShareDialog, originalExt = if (shareExt.isNotBlank() && shareExt != "PDF") shareExt else null, @@ -2294,82 +2512,30 @@ fun PdfViewerScreen( android.widget.Toast.makeText(context, R.string.docx_print_failed, android.widget.Toast.LENGTH_SHORT).show() } }, + onSaveCopy = { format, encrypt, password -> + showShareDialog = false + // Unedited, a copy keeps the file's own name (no "_Edited"). + val stem = state.fileName.substringBeforeLast('.').ifBlank { "document" } + val (name, mime) = when (format) { + ShareFormat.ORIGINAL -> state.fileName.ifBlank { "document.${shareExt.lowercase()}" } to + (state.originalUri?.let { context.contentResolver.getType(it) } ?: "application/octet-stream") + ShareFormat.TEXT -> "$stem.txt" to "text/plain" + else -> (if (hasUnsavedEdits) "${stem}_Edited.pdf" else "$stem.pdf") to "application/pdf" + } + pendingSaveCopy = Triple(format, encrypt, password) + runCatching { + saveCopyLauncher.launch( + Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(mime) + .putExtra(Intent.EXTRA_TITLE, name) + ) + }.onFailure { pendingSaveCopy = null } + }, onShare = { format, encrypt, password -> showShareDialog = false viewerScope.launch { - // All file work off the main thread: copy/encrypt, then hand a FileProvider uri to - // the chooser. Everything lands in cacheDir/shared, which the app FileProvider - // serves (file_paths.xml cache-path "/"). - val payload = withContext(Dispatchers.IO) { - runCatching { - fun wrap(u: android.net.Uri): android.net.Uri = - if (u.scheme == "file") - androidx.core.content.FileProvider.getUriForFile( - context, "${context.packageName}.provider", java.io.File(u.path!!) - ) - else u - val shareDir = java.io.File(context.cacheDir, "shared").apply { mkdirs() } - // Unsaved edits travel with the share: build an edited copy first and - // make every PDF-derived format (PDF, images, text) from it. - val edited: android.net.Uri? = if (hasUnsavedEdits && format != ShareFormat.ORIGINAL) { - val overlays = buildExportOverlays(annotationsByPage, state.ocrBlocksByPage, pageCanvasSizes, pageBitmapSizes) - val name = state.fileName.substringBeforeLast('.').ifBlank { "document" } - .replace(Regex("[^A-Za-z0-9._-]"), "_") - viewModel.writeEditedCopy(context, overlays, java.io.File(shareDir, "${name}_edited.pdf")) - } else null - val sourcePdf = edited ?: state.document?.uri - when (format) { - ShareFormat.IMAGES -> sourcePdf?.let { pdf -> - val stem = state.fileName.substringBeforeLast('.').ifBlank { "page" } - .replace(Regex("[^A-Za-z0-9._-]"), "_") - val count = state.pageCount.coerceAtLeast(1) - val uris = ArrayList() - for (p in 0 until count) { - val bmp = com.kyant.pdfcore.raster.PdfRasterizer.rasterizePageBitmap(context, pdf, p) ?: continue - val f = java.io.File(shareDir, "${stem}_${p + 1}.png") - f.outputStream().use { bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) } - bmp.recycle() - uris += androidx.core.content.FileProvider.getUriForFile(context, "${context.packageName}.provider", f) - } - shareMultiple = uris - uris.firstOrNull()?.let { it to "image/png" } - } - ShareFormat.TEXT -> sourcePdf?.let { pdf -> - val stem = state.fileName.substringBeforeLast('.').ifBlank { "document" } - .replace(Regex("[^A-Za-z0-9._-]"), "_") - val f = java.io.File(shareDir, "$stem.txt") - f.writeText(viewModel.extractAllText(context, pdf)) - androidx.core.content.FileProvider.getUriForFile(context, "${context.packageName}.provider", f) to "text/plain" - } - ShareFormat.ORIGINAL -> state.originalUri?.let { orig -> - // Mirror the original into our own storage so the target app can - // actually read it — a SAF uri from another provider can't be - // re-granted to a third app. - val safeName = state.fileName.ifBlank { "document.$shareExt" } - .replace(Regex("[^A-Za-z0-9._-]"), "_") - val out = java.io.File(shareDir, "${System.currentTimeMillis()}_$safeName") - context.contentResolver.openInputStream(orig)?.use { input -> - out.outputStream().use { input.copyTo(it) } - } ?: return@runCatching null - androidx.core.content.FileProvider.getUriForFile( - context, "${context.packageName}.provider", out - ) to (context.contentResolver.getType(orig) ?: "application/octet-stream") - } - ShareFormat.PDF -> sourcePdf?.let { pdf -> - if (encrypt && password.isNotBlank()) { - val out = java.io.File(shareDir, "protected_${System.currentTimeMillis()}.pdf") - val outUri = androidx.core.content.FileProvider.getUriForFile( - context, "${context.packageName}.provider", out - ) - com.kyant.pdfcore.security.PdfSecurityService.encryptToUri(context, pdf, outUri, password) - outUri to "application/pdf" - } else { - wrap(pdf) to "application/pdf" - } - } - } - }.getOrNull() - } + val payload = withContext(Dispatchers.IO) { buildSharePayload(format, encrypt, password) } if (payload != null) { val (shareUri, mime) = payload val many = shareMultiple @@ -2539,7 +2705,11 @@ private fun bandLuminance( // Dark page appearance draws every page through an RGB-inverting colour matrix, so the pixels // on screen are 1 - (bitmap). Sampling the bitmap as-is read a white page behind a black one and // picked dark ink — the "icons invisible in dark mode" bug. Mirror the matrix here. - inverted: Boolean = false + inverted: Boolean = false, + // Where a page really is, when it doesn't fill its list item: (bitmap frame top, frame height, + // visible top, visible bottom) in the same list-local px. Book mode letterboxes the page inside + // a screen-tall item, and trimmed margins show only part of a larger frame (#84, #85). + pageFrame: (Int) -> FloatArray? = { null } ): Float { val s = scale.coerceAtLeast(0.01f) val lTop = screenTopPx / s @@ -2548,15 +2718,19 @@ private fun bandLuminance( var weight = 0.0 for (item in layoutInfo.visibleItemsInfo) { if (item.size <= 0) continue - val io = item.offset.toFloat() - val ib = io + item.size.toFloat() + val f = pageFrame(item.index) + val frameTop = f?.get(0) ?: item.offset.toFloat() + val frameH = f?.get(1) ?: item.size.toFloat() + if (frameH <= 0f) continue + val io = f?.get(2) ?: frameTop + val ib = f?.get(3) ?: (frameTop + frameH) val interTop = maxOf(io, lTop) val interBottom = minOf(ib, lBottom) val cover = interBottom - interTop if (cover <= 0f) continue val bmp = pageBitmaps.getOrNull(item.index) ?: continue - val fTop = (interTop - io) / item.size - val fBottom = (interBottom - io) / item.size + val fTop = (interTop - frameTop) / frameH + val fBottom = (interBottom - frameTop) / frameH val l = regionLuminance(bmp, fTop, fBottom) lum += (if (inverted) 1f - l else l).toDouble() * cover weight += cover.toDouble() @@ -2598,3 +2772,9 @@ internal fun OcrTextRange.snappedToWords(text: String): OcrTextRange { while (b < text.length && inWord(text[b]) && inWord(text[b - 1])) b++ return copy(start = a, end = b) } + +/** [content] centred in a box with [slot] when there is one, otherwise placed as it is. */ +@Composable +private fun OptionalSlot(slot: Modifier?, content: @Composable () -> Unit) { + if (slot != null) Box(slot, contentAlignment = Alignment.Center) { content() } else content() +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt index 1b552ab..a993423 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt @@ -39,6 +39,8 @@ import androidx.compose.material.icons.rounded.AutoAwesome import androidx.compose.material.icons.rounded.Code import androidx.compose.material.icons.rounded.Compress import androidx.compose.material.icons.rounded.DarkMode +import androidx.compose.material.icons.rounded.Crop +import androidx.compose.material.icons.rounded.AutoStories import androidx.compose.material.icons.rounded.Description import androidx.compose.material.icons.rounded.FileCopy import androidx.compose.material.icons.rounded.FolderOpen @@ -347,6 +349,34 @@ fun SettingsScreen( backdrop = backdrop ) SettingsDivider(isLight) + // #85: page margins trimmed in the viewer, so the text fills the screen. + var trimMargins by remember { mutableStateOf(com.chethan616.clearpdf.data.repository.AppSettingsManager.getTrimMargins(context)) } + SettingsToggleRow( + icon = Icons.Rounded.Crop, + title = stringResource(R.string.settings_trim_margins), + desc = stringResource(R.string.settings_trim_margins_desc), + checked = trimMargins, + onCheckedChange = { + trimMargins = it + com.chethan616.clearpdf.data.repository.AppSettingsManager.setTrimMargins(context, it) + }, + backdrop = backdrop + ) + SettingsDivider(isLight) + // #84: one page per screen, turned with a sideways swipe. + var bookMode by remember { mutableStateOf(com.chethan616.clearpdf.data.repository.AppSettingsManager.getBookMode(context)) } + SettingsToggleRow( + icon = Icons.Rounded.AutoStories, + title = stringResource(R.string.settings_book_mode), + desc = stringResource(R.string.settings_book_mode_desc), + checked = bookMode, + onCheckedChange = { + bookMode = it + com.chethan616.clearpdf.data.repository.AppSettingsManager.setBookMode(context, it) + }, + backdrop = backdrop + ) + SettingsDivider(isLight) SettingsToggleRow( icon = Icons.Rounded.Vibration, title = stringResource(R.string.settings_reduce_glass_motion), diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SpreadsheetViewerScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SpreadsheetViewerScreen.kt index 0e8691b..6e6522e 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SpreadsheetViewerScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SpreadsheetViewerScreen.kt @@ -133,6 +133,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlin.math.roundToInt import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.rememberAdaptiveInk import com.chethan616.clearpdf.ui.components.GlassBottomSheet import com.chethan616.clearpdf.ui.components.GlassGuideCallout import com.chethan616.clearpdf.ui.components.GlassBackButton @@ -489,6 +490,12 @@ private fun SpreadsheetViewerContent( contentHorizontalPadding = 12.dp, headerHorizontalPadding = 12.dp, header = { headerBackdrop -> + // Each control takes its own ink from what is behind it; the sheet's ink is the fallback. + val backInk = rememberAdaptiveInk(text) + val titleInk = rememberAdaptiveInk(text) + val findInk = rememberAdaptiveInk(text) + val sheetInk = rememberAdaptiveInk(text) + val editInk = rememberAdaptiveInk(text) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -497,7 +504,8 @@ private fun SpreadsheetViewerContent( GlassBackButton( onBack = { requestBack() }, backdrop = headerBackdrop, - foreground = text, + modifier = backInk.modifier, + foreground = backInk.color, onLongPressBack = if (state.dirty || editing) ({ requestBack() }) else LocalBackToLibraryAction.current, longPressLabel = if (state.dirty || editing) stringResource(R.string.back) else null ) @@ -505,8 +513,10 @@ private fun SpreadsheetViewerContent( GlassTitlePill( text = (if (state.dirty) "• " else "") + state.fileName.ifBlank { stringResource(R.string.viewer_title) }, backdrop = headerBackdrop, + modifier = titleInk.modifier, // Clear like the header circles beside it (the PDF viewer's page pill too). - surfaceColor = Color.Transparent + surfaceColor = Color.Transparent, + contentColor = titleInk.color ) } if (state.dirty && state.editable) { @@ -524,14 +534,14 @@ private fun SpreadsheetViewerContent( } } if (!editMode) { - LiquidIconButton(onClick = { showSearch = true }, backdrop = headerBackdrop) { - Icon(Icons.Rounded.Search, stringResource(R.string.viewer_find), Modifier.size(20.dp), text) + LiquidIconButton(onClick = { showSearch = true }, backdrop = headerBackdrop, modifier = findInk.modifier) { + Icon(Icons.Rounded.Search, stringResource(R.string.viewer_find), Modifier.size(20.dp), findInk.color) } - LiquidIconButton(onClick = onToggleSheet, backdrop = headerBackdrop) { + LiquidIconButton(onClick = onToggleSheet, backdrop = headerBackdrop, modifier = sheetInk.modifier) { Icon( if (sheetDark) Icons.Rounded.LightMode else Icons.Rounded.DarkMode, stringResource(if (sheetDark) R.string.viewer_page_colors_original else R.string.viewer_page_dark_mode), - Modifier.size(19.dp), text + Modifier.size(19.dp), sheetInk.color ) } } @@ -542,10 +552,11 @@ private fun SpreadsheetViewerContent( else { editMode = true; showSearch = false } }, backdrop = headerBackdrop, + modifier = editInk.modifier, surfaceColor = if (editMode || showSpreadsheetTour) accent.copy(0.22f) else Color.Unspecified ) { if (editMode) BasicText(stringResource(R.string.sheet_done), style = TextStyle(accent, 13.sp, FontWeight.SemiBold)) - else Icon(Icons.Rounded.Edit, stringResource(R.string.sheet_edit), Modifier.size(19.dp), if (showSpreadsheetTour) accent else text) + else Icon(Icons.Rounded.Edit, stringResource(R.string.sheet_edit), Modifier.size(19.dp), if (showSpreadsheetTour) accent else editInk.color) } } } @@ -726,6 +737,7 @@ private fun SpreadsheetViewerContent( // one control only needs to do the one thing its icon says. No long-press: it used to open // a PDF export, which surprised people tapping-and-holding by habit. if (sheet != null && !showSearch && !editMode) { + val shareInk = rememberAdaptiveInk(text) LiquidIconButton( onClick = { viewModel.shareableUri(context) { u -> u?.let { shareFile(context, it) } } }, backdrop = screenBackdrop.glass, @@ -735,8 +747,9 @@ private fun SpreadsheetViewerContent( // asks for (that's also why the old 52dp here rendered at a full 52dp, not 40dp -- // visibly oversized next to the header's unmodified 40dp icon buttons). modifier = Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 12.dp, bottom = 10.dp).size(44.dp) + .then(shareInk.modifier) ) { - Icon(Icons.Rounded.IosShare, stringResource(R.string.viewer_share_document), Modifier.size(20.dp), text) + Icon(Icons.Rounded.IosShare, stringResource(R.string.viewer_share_document), Modifier.size(20.dp), shareInk.color) } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/utils/InteractiveHighlight.kt b/app/src/main/java/com/chethan616/clearpdf/ui/utils/InteractiveHighlight.kt index f40a213..8b5e3c0 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/utils/InteractiveHighlight.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/utils/InteractiveHighlight.kt @@ -2,7 +2,9 @@ package com.chethan616.clearpdf.ui.utils import com.chethan616.clearpdf.ui.components.MotionSettings import android.graphics.RuntimeShader +import android.graphics.Shader import android.os.Build +import androidx.annotation.RequiresApi import android.os.SystemClock import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.VectorConverter @@ -90,24 +92,10 @@ class InteractiveHighlight( // owns one of these, and `RuntimeShader(...)` compiles its AGSL on the calling thread — so a // screen of buttons used to pay one shader compile per button during composition (and a list // paid it again for every row it scrolled in), for a highlight most buttons never show. - private val shader: RuntimeShader? by lazy(LazyThreadSafetyMode.NONE) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - RuntimeShader( - """ -uniform float2 size; -layout(color) uniform half4 color; -uniform float radius; -uniform float2 position; - -half4 main(float2 coord) { - float dist = distance(coord, position); - float intensity = smoothstep(radius, radius * 0.5, dist); - return color * intensity; -}""" - ) - } else { - null - } + // Typed Shader, not RuntimeShader: reading a RuntimeShader property casts to that class, which + // doesn't exist before Android 13, so every press crashed there (#80). Only PressGlow touches it. + private val shader: Shader? by lazy(LazyThreadSafetyMode.NONE) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) PressGlow.create() else null } val modifier: Modifier = @@ -119,17 +107,14 @@ half4 main(float2 coord) { val shader = this@InteractiveHighlight.shader if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && shader != null) { drawRect(Color.White.copy(0.08f * progress + 0.05f * bloom), blendMode = BlendMode.Plus) - shader.apply { - val position = position(size, positionAnimation.value) - setFloatUniform("size", size.width, size.height) - setColorUniform("color", Color.White.copy(0.15f * progress + 0.10f * bloom).toArgb()) - setFloatUniform("radius", size.minDimension * (1.5f + 0.5f * bloom)) - setFloatUniform( - "position", - position.x.fastCoerceIn(0f, size.width), - position.y.fastCoerceIn(0f, size.height) - ) - } + val position = position(size, positionAnimation.value) + PressGlow.update( + shader, + size, + Color.White.copy(0.15f * progress + 0.10f * bloom).toArgb(), + size.minDimension * (1.5f + 0.5f * bloom), + Offset(position.x.fastCoerceIn(0f, size.width), position.y.fastCoerceIn(0f, size.height)) + ) drawRect(ShaderBrush(shader), blendMode = BlendMode.Plus) } else { drawRect(Color.White.copy(0.25f * progress), blendMode = BlendMode.Plus) @@ -138,6 +123,33 @@ half4 main(float2 coord) { drawContent() } + /** Everything that names RuntimeShader, kept out of code that runs on older Android. */ + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private object PressGlow { + fun create(): Shader = RuntimeShader( + """ +uniform float2 size; +layout(color) uniform half4 color; +uniform float radius; +uniform float2 position; + +half4 main(float2 coord) { + float dist = distance(coord, position); + float intensity = smoothstep(radius, radius * 0.5, dist); + return color * intensity; +}""" + ) + + fun update(shader: Shader, size: Size, color: Int, radius: Float, position: Offset) { + (shader as RuntimeShader).apply { + setFloatUniform("size", size.width, size.height) + setColorUniform("color", color) + setFloatUniform("radius", radius) + setFloatUniform("position", position.x, position.y) + } + } + } + private fun release() { positionUpdateJob?.cancel() longPressJob?.cancel() @@ -215,6 +227,9 @@ half4 main(float2 coord) { fun GraphicsLayerScope.liquidPressTransform(h: InteractiveHighlight) { val width = size.width val height = size.height + // A control not laid out yet (or collapsed to nothing) has no size: dividing by it gives NaN, + // which Android 9's View-backed layers reject with a crash (#80). + if (!(width > 0f && height > 0f)) return val motion = MotionSettings.style val progress = h.pressProgress diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageOrganizerViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageOrganizerViewModel.kt index 057165a..af91649 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageOrganizerViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageOrganizerViewModel.kt @@ -165,14 +165,17 @@ class PageOrganizerViewModel(private val editor: PdfEditor) : ViewModel() { if (pages.isEmpty()) return _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null, resultMessage = null, lastOutputUri = null) viewModelScope.launch { + var created: Uri? = null try { val target = if (fileName.endsWith(".pdf", true)) fileName else "$fileName.pdf" val saveLabel = SaveLocationManager.getSavePathDisplay(context) val outUri = withContext(Dispatchers.IO) { createOutputUri(context, target, overrideUri) } + created = outUri val source = PdfDocument(uri = src, name = _uiState.value.sourceFileName) val order = pages.map { it.originalIndex } val rotations = pages.associate { it.originalIndex to it.rotation }.filterValues { it != 0 } withContext(Dispatchers.IO) { editor.applyPageEdits(context, source, order, rotations, outUri) } + created = null withContext(Dispatchers.IO) { RecentFilesManager.addRecent(context, RecentFile( @@ -193,6 +196,8 @@ class PageOrganizerViewModel(private val editor: PdfEditor) : ViewModel() { val shouldPrompt = withContext(Dispatchers.IO) { GitHubStarPromptManager.recordSuccess(context) } if (shouldPrompt) StarPromptEventBus.requestPrompt() } catch (e: Exception) { + // Don't leave an empty PDF behind in Downloads. + created?.let { uri -> withContext(Dispatchers.IO) { runCatching { context.contentResolver.delete(uri, null, null) } } } GitHubStarPromptManager.recordFailure(context) _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = context.getString(R.string.organize_save_failed)) } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt index d67d03a..f62cf00 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt @@ -184,6 +184,8 @@ data class PdfViewerUiState( val ocrBlocksByPage: Map> = emptyMap(), /** The PDF's fillable form boxes, by page — filled in place in Edit mode (#68). */ val formWidgetsByPage: Map> = emptyMap(), + /** The PDF's own links (web addresses, jumps inside the document), by page (#83). */ + val linksByPage: Map> = emptyMap(), val ocrPagesInProgress: Set = emptySet(), val isExporting: Boolean = false, val exportMessage: String? = null, @@ -382,6 +384,7 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel pageBitmaps = List(doc.pageCount) { if (it == resumePage) firstPaint else null }, ocrBlocksByPage = emptyMap(), formWidgetsByPage = emptyMap(), + linksByPage = emptyMap(), ocrPagesInProgress = emptySet(), isExporting = false, exportMessage = null, @@ -390,14 +393,24 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel showOfficeEngineHint = com.chethan616.clearpdf.office.OfficeEngine.shouldOfferHint(context, displayName), renderedByOfficeEngine = renderedUri.path?.contains("/office-pdf/") == true ) - // Fillable fields, read off the main thread; most PDFs have none and stay empty. + // Fillable fields and links, read off the main thread in one pass; most PDFs have + // no form and stay empty. val formDocUri = doc.uri viewModelScope.launch { - val widgets = withContext(Dispatchers.IO) { - runCatching { com.kyant.pdfcore.form.PdfFormWidgets.read(context, formDocUri) }.getOrDefault(emptyMap()) + val (widgets, links) = withContext(Dispatchers.IO) { + runCatching { + com.kyant.pdfcore.internal.PdfBox.ensureInitialized(context) + context.contentResolver.openInputStream(formDocUri)?.use { input -> + com.tom_roush.pdfbox.pdmodel.PDDocument.load(input).use { pd -> + val w = runCatching { com.kyant.pdfcore.form.PdfFormWidgets.read(pd) }.getOrDefault(emptyMap()) + val l = runCatching { com.kyant.pdfcore.link.PdfLinks.read(pd) }.getOrDefault(emptyMap()) + w to l + } + } + }.getOrNull() ?: (emptyMap>() to emptyMap()) } - if (widgets.isNotEmpty() && _uiState.value.document?.uri == formDocUri) { - _uiState.value = _uiState.value.copy(formWidgetsByPage = widgets) + if ((widgets.isNotEmpty() || links.isNotEmpty()) && _uiState.value.document?.uri == formDocUri) { + _uiState.value = _uiState.value.copy(formWidgetsByPage = widgets, linksByPage = links) } } // The ORIGINAL uri, deliberately — not `openedUri`. @@ -512,6 +525,42 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel }?.takeIf { _uiState.value.document?.uri == doc.uri } } + /** + * The part of the page that holds content, as a 0..1 rect shared by the whole document (#85). + * A few pages spread through the document are measured at thumbnail size and each side takes + * the generous end of what they print on, so a cover picture or a short chapter page doesn't + * decide it, and every page is trimmed the same (the text keeps one size). Null when the + * margins are too thin to be worth trimming. + */ + suspend fun contentCrop(): android.graphics.RectF? { + val doc = _uiState.value.document ?: return null + val count = _uiState.value.pageCount + if (count <= 0) return null + val samples = minOf(count, 8) + val pages = (0 until samples).map { if (samples == 1) 0 else (it * (count - 1).toFloat() / (samples - 1)).toInt() }.distinct() + val found = ArrayList() + for (p in pages) { + val bmp = withContext(AppDispatchers.pdf) { runCatching { openPdfUseCase.renderPage(doc, p, 240) }.getOrNull() } ?: continue + val r = withContext(Dispatchers.Default) { pageContentBounds(bmp) } + bmp.recycle() + if (_uiState.value.document?.uri != doc.uri) return null + if (r != null) found += r + } + if (found.isEmpty()) return null + fun low(v: List) = v.sorted()[(v.size - 1) / 4] + fun high(v: List) = v.sorted()[v.size - 1 - (v.size - 1) / 4] + val pad = 0.02f + val crop = android.graphics.RectF( + (low(found.map { it.left }) - pad).coerceAtLeast(0f), + (low(found.map { it.top }) - pad).coerceAtLeast(0f), + (high(found.map { it.right }) + pad).coerceAtMost(1f), + (high(found.map { it.bottom }) + pad).coerceAtMost(1f) + ) + if (crop.width() < 0.25f || crop.height() < 0.25f) return null + if (crop.width() > 0.94f && crop.height() > 0.94f) return null + return crop + } + fun renderPage(context: Context, pageIndex: Int, targetWidthPx: Int = DEFAULT_RENDER_WIDTH) { val state = _uiState.value val doc = state.document ?: return @@ -1166,3 +1215,36 @@ private fun OcrTextBlock.findMatches(lower: String, page: Int): List } return out } + +/** + * Where a rendered page prints anything, as a 0..1 rect, or null for a blank page: every pixel that + * differs clearly from the paper (the commonest corner colour), ignoring rows and columns with a + * single stray speck. + */ +internal fun pageContentBounds(page: Bitmap): android.graphics.RectF? = runCatching { + if (page.isRecycled || page.width < 8 || page.height < 8) return null + val w = minOf(page.width, 200) + val h = (page.height * w / page.width.toFloat()).toInt().coerceAtLeast(8) + val small = if (w == page.width) page else Bitmap.createScaledBitmap(page, w, h, true) + val px = IntArray(small.width * small.height) + small.getPixels(px, 0, small.width, 0, 0, small.width, small.height) + val sw = small.width + val sh = small.height + if (small !== page) small.recycle() + val corners = listOf(px[0], px[sw - 1], px[(sh - 1) * sw], px[sh * sw - 1]) + val paper = corners.groupingBy { it }.eachCount().maxByOrNull { it.value }!!.key + val pr = (paper shr 16) and 0xFF; val pg = (paper shr 8) and 0xFF; val pb = paper and 0xFF + val rowHits = IntArray(sh) + val colHits = IntArray(sw) + for (y in 0 until sh) for (x in 0 until sw) { + val c = px[y * sw + x] + val d = kotlin.math.abs(((c shr 16) and 0xFF) - pr) + kotlin.math.abs(((c shr 8) and 0xFF) - pg) + kotlin.math.abs((c and 0xFF) - pb) + if (d > 60) { rowHits[y]++; colHits[x]++ } + } + val top = rowHits.indexOfFirst { it >= 2 } + val bottom = rowHits.indexOfLast { it >= 2 } + val left = colHits.indexOfFirst { it >= 2 } + val right = colHits.indexOfLast { it >= 2 } + if (top < 0 || left < 0 || bottom <= top || right <= left) return null + android.graphics.RectF(left / sw.toFloat(), top / sh.toFloat(), (right + 1) / sw.toFloat(), (bottom + 1) / sh.toFloat()) +}.getOrNull() diff --git a/app/src/main/java/com/chethan616/clearpdf/utils/DisplayName.kt b/app/src/main/java/com/chethan616/clearpdf/utils/DisplayName.kt new file mode 100644 index 0000000..6e29fff --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/utils/DisplayName.kt @@ -0,0 +1,13 @@ +package com.chethan616.clearpdf.utils + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns + +/** The file's real name from its provider (a picked uri's last path segment is an id like "msf:49918"). */ +fun displayNameOf(context: Context, uri: Uri): String? = runCatching { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c -> + val i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (i != -1 && c.moveToFirst()) c.getString(i)?.takeIf { it.isNotBlank() } else null + } +}.getOrNull() ?: uri.lastPathSegment?.substringAfterLast('/')?.takeIf { !it.contains(':') } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 9a7f655..4bdd863 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -270,6 +270,9 @@ التشفير بكلمة مرور كلمة المرور مشاركة + حفظ نسخة + تم حفظ النسخة + تعذّر حفظ النسخة PDF صور (PNG) نص (.txt) @@ -320,6 +323,10 @@ استعادة صفحات داكنة في الوضع الداكن تُفتح المستندات بصفحات داكنة كلما كان التطبيق داكنًا + قص هوامش الصفحة + يقص الحواف الفارغة حول الصفحات ليملأ النص الشاشة + وضع الكتاب + صفحة واحدة في كل مرة، اسحب جانبيًا لتقليب الصفحة الحقل السابق الحقل التالي املأ النموذج على الصفحة diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2a426c6..bc02357 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -251,6 +251,9 @@ Cifrar con contraseña Contraseña Compartir + Guardar una copia + Copia guardada + No se pudo guardar la copia PDF Normal Cifrado @@ -604,6 +607,10 @@ Restaurar Páginas oscuras en modo oscuro Los documentos se abren con páginas oscuras cuando la app está en modo oscuro + Recortar márgenes + Recorta el borde vacío de las páginas para que el texto llene la pantalla + Modo libro + Una página a la vez, desliza hacia un lado para pasar la página Campo anterior Campo siguiente Rellena el formulario en la página diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 839dd3a..901c860 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -252,6 +252,9 @@ Cripta con password Password Condividi + Salva una copia + Copia salvata + Impossibile salvare la copia PDF Normale Criptato @@ -755,6 +758,10 @@ Ripristina Pagine scure in modalità scura I documenti si aprono con pagine scure quando l\'app è in modalità scura + Rifila i margini + Taglia il bordo vuoto delle pagine così il testo riempie lo schermo + Modalità libro + Una pagina alla volta, scorri di lato per girare pagina Campo precedente Campo successivo Compila il modulo sulla pagina diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 960d2a0..52add6c 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -329,6 +329,9 @@ Criptografar com senha Senha Compartilhar + Salvar uma cópia + Cópia salva + Não foi possível salvar a cópia PDF Normal Criptografado @@ -600,6 +603,10 @@ Restaurar Páginas escuras no modo escuro Os documentos abrem com páginas escuras sempre que o app estiver escuro + Cortar margens + Corta a borda vazia das páginas para o texto preencher a tela + Modo livro + Uma página por vez, deslize para o lado para virar a página Campo anterior Próximo campo Preencha o formulário na página diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 9f4bc02..bba5005 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -329,6 +329,9 @@ Criptografar com senha Senha Compartilhar + Guardar uma cópia + Cópia guardada + Não foi possível guardar a cópia PDF Normal Criptografado @@ -600,6 +603,10 @@ Restaurar Páginas escuras no modo escuro Os documentos abrem com páginas escuras sempre que o app estiver escuro + Cortar margens + Corta a margem vazia das páginas para o texto preencher o ecrã + Modo livro + Uma página de cada vez, deslize para o lado para virar a página Campo anterior Próximo campo Preencha o formulário na página diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index eace042..30a6f8f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -271,6 +271,9 @@ Зашифровать паролем Пароль Поделиться + Сохранить копию + Копия сохранена + Не удалось сохранить копию PDF Обычный Зашифрованный @@ -766,6 +769,10 @@ Восстановить Тёмные страницы в тёмной теме Документы открываются с тёмными страницами, когда приложение в тёмной теме + Обрезать поля + Обрезает пустые края страниц, чтобы текст заполнял экран + Режим книги + По одной странице, листайте вбок, чтобы перевернуть Предыдущее поле Следующее поле Заполните форму на странице diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7475542..740b526 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -285,6 +285,9 @@ Encrypt with password Password Share + Save a copy + Copy saved + Couldn\'t save the copy PDF Images (PNG) Text (.txt) @@ -341,6 +344,10 @@ Restore Dark pages in dark mode Documents open with dark pages whenever the app is dark + Trim page margins + Crops the empty border around pages so the text fills the screen + Book mode + One page at a time, swipe sideways to turn the page Previous field Next field Fill the form on the page diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/editor/PdfEditor.kt b/pdf-core/src/main/java/com/kyant/pdfcore/editor/PdfEditor.kt index cbe9c52..e771b97 100644 --- a/pdf-core/src/main/java/com/kyant/pdfcore/editor/PdfEditor.kt +++ b/pdf-core/src/main/java/com/kyant/pdfcore/editor/PdfEditor.kt @@ -46,6 +46,8 @@ class PdfEditorImpl : PdfEditor { val input = context.contentResolver.openInputStream(source.uri) ?: throw IllegalArgumentException("Cannot open source") input.use { stream -> + // importPage shares the source's fonts and images, so the source has to stay open + // until the output is saved ("COSStream has been closed" otherwise). PDDocument.load(stream).use { doc -> for (orig in order) { if (orig !in 0 until doc.numberOfPages) continue @@ -57,12 +59,12 @@ class PdfEditorImpl : PdfEditor { } written++ } + if (written == 0) throw IllegalArgumentException("Result would have no pages") + val out = context.contentResolver.openOutputStream(outputUri) + ?: throw IllegalStateException("Cannot write edited PDF output") + out.use { output.save(it) } } } - if (written == 0) throw IllegalArgumentException("Result would have no pages") - val out = context.contentResolver.openOutputStream(outputUri) - ?: throw IllegalStateException("Cannot write edited PDF output") - out.use { output.save(it) } } finally { output.close() } diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/link/PdfLinks.kt b/pdf-core/src/main/java/com/kyant/pdfcore/link/PdfLinks.kt new file mode 100644 index 0000000..39e17d2 --- /dev/null +++ b/pdf-core/src/main/java/com/kyant/pdfcore/link/PdfLinks.kt @@ -0,0 +1,103 @@ +package com.kyant.pdfcore.link + +import com.tom_roush.pdfbox.pdmodel.PDDocument +import com.tom_roush.pdfbox.pdmodel.common.PDRectangle +import com.tom_roush.pdfbox.pdmodel.interactive.action.PDActionGoTo +import com.tom_roush.pdfbox.pdmodel.interactive.action.PDActionURI +import com.tom_roush.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink +import com.tom_roush.pdfbox.pdmodel.interactive.documentnavigation.destination.PDDestination +import com.tom_roush.pdfbox.pdmodel.interactive.documentnavigation.destination.PDNamedDestination +import com.tom_roush.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageDestination +import com.tom_roush.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageXYZDestination + +/** + * A tappable link on a page: a web / mail address ([uri]) or a place in the same document + * ([targetPage], and [targetTop] as a 0..1 fraction of that page's height when the link names + * one). Geometry is normalized (0..1) in display space, like [com.kyant.pdfcore.form.PdfFormWidget]. + */ +data class PdfLink( + val pageIndex: Int, + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, + val uri: String? = null, + val targetPage: Int? = null, + val targetTop: Float? = null +) + +object PdfLinks { + + /** Every link in [doc], by page (empty when it has none). */ + fun read(doc: PDDocument): Map> { + val out = HashMap>() + for (i in 0 until doc.numberOfPages) { + val page = runCatching { doc.getPage(i) }.getOrNull() ?: continue + val annotations = runCatching { page.annotations }.getOrNull() ?: continue + val box = page.cropBox ?: page.mediaBox ?: continue + val rotation = ((page.rotation % 360) + 360) % 360 + for (a in annotations) { + val link = a as? PDAnnotationLink ?: continue + runCatching { + val rect = normalize(link.rectangle ?: return@runCatching, box, rotation) ?: return@runCatching + var uri: String? = null + var dest: PDDestination? = null + when (val action = link.action) { + is PDActionURI -> uri = action.uri + is PDActionGoTo -> dest = action.destination + null -> dest = link.destination + else -> Unit + } + val target = dest?.let { pageDestination(doc, it) } + val targetPage = target?.retrievePageNumber()?.takeIf { it in 0 until doc.numberOfPages } + if (uri.isNullOrBlank() && targetPage == null) return@runCatching + out.getOrPut(i) { ArrayList() } += PdfLink( + pageIndex = i, + left = rect[0], top = rect[1], right = rect[2], bottom = rect[3], + uri = uri?.trim()?.takeIf { it.isNotEmpty() }, + targetPage = targetPage, + targetTop = targetPage?.let { topOf(doc, it, target) } + ) + } + } + } + return out + } + + private fun pageDestination(doc: PDDocument, dest: PDDestination): PDPageDestination? = when (dest) { + is PDPageDestination -> dest + is PDNamedDestination -> runCatching { doc.documentCatalog.findNamedDestinationPage(dest) }.getOrNull() + else -> null + } + + /** Where an "XYZ" destination puts the top of the view, as a fraction of the page (unrotated pages). */ + private fun topOf(doc: PDDocument, pageIndex: Int, dest: PDPageDestination?): Float? { + val xyz = dest as? PDPageXYZDestination ?: return null + val y = xyz.top.takeIf { it >= 0 }?.toFloat() ?: return null + val page = runCatching { doc.getPage(pageIndex) }.getOrNull() ?: return null + if (((page.rotation % 360) + 360) % 360 != 0) return null + val box = page.cropBox ?: page.mediaBox ?: return null + if (box.height <= 0f) return null + return ((box.upperRightY - y) / box.height).coerceIn(0f, 1f) + } + + /** PDF user space → normalized display rect (left, top, right, bottom). */ + private fun normalize(r: PDRectangle, box: PDRectangle, rotation: Int): FloatArray? { + val w = box.width + val h = box.height + if (w <= 0f || h <= 0f) return null + fun map(x: Float, y: Float): Pair = when (rotation) { + 90 -> (y - box.lowerLeftY) / h to (x - box.lowerLeftX) / w + 180 -> (box.upperRightX - x) / w to (y - box.lowerLeftY) / h + 270 -> (box.upperRightY - y) / h to (box.upperRightX - x) / w + else -> (x - box.lowerLeftX) / w to (box.upperRightY - y) / h + } + val a = map(r.lowerLeftX, r.lowerLeftY) + val b = map(r.upperRightX, r.upperRightY) + val l = minOf(a.first, b.first).coerceIn(0f, 1f) + val t = minOf(a.second, b.second).coerceIn(0f, 1f) + val rr = maxOf(a.first, b.first).coerceIn(0f, 1f) + val bb = maxOf(a.second, b.second).coerceIn(0f, 1f) + return if (rr - l <= 0f || bb - t <= 0f) null else floatArrayOf(l, t, rr, bb) + } +} diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/merger/PdfMerger.kt b/pdf-core/src/main/java/com/kyant/pdfcore/merger/PdfMerger.kt index 763ae2c..4b45767 100644 --- a/pdf-core/src/main/java/com/kyant/pdfcore/merger/PdfMerger.kt +++ b/pdf-core/src/main/java/com/kyant/pdfcore/merger/PdfMerger.kt @@ -57,19 +57,20 @@ class PdfMergerImpl : PdfMerger { PdfBox.ensureInitialized(context) val output = PDDocument() + // importPage shares each source's fonts and images, so every source stays open until the + // output is saved ("COSStream has been closed" otherwise). + val opened = mutableListOf() var written = 0 try { for ((src, pages) in sources) { val input = context.contentResolver.openInputStream(src.uri) ?: throw IllegalStateException("Cannot read ${src.name}") - input.use { stream -> - PDDocument.load(stream).use { doc -> - for (i in pages) { - if (i in 0 until doc.numberOfPages) { - output.importPage(doc.getPage(i)) - written++ - } - } + val doc = input.use { PDDocument.load(it) } + opened += doc + for (i in pages) { + if (i in 0 until doc.numberOfPages) { + output.importPage(doc.getPage(i)) + written++ } } } @@ -78,6 +79,7 @@ class PdfMergerImpl : PdfMerger { out.use { output.save(it) } } finally { output.close() + opened.forEach { runCatching { it.close() } } } return PdfDocument(uri = outputUri, name = "Merged.pdf", pageCount = written) diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/security/PdfSecurityService.kt b/pdf-core/src/main/java/com/kyant/pdfcore/security/PdfSecurityService.kt index 44a9c70..98ea84f 100644 --- a/pdf-core/src/main/java/com/kyant/pdfcore/security/PdfSecurityService.kt +++ b/pdf-core/src/main/java/com/kyant/pdfcore/security/PdfSecurityService.kt @@ -77,8 +77,10 @@ object PdfSecurityService { setCanModify(false) setCanExtractContent(true) } - val policy = StandardProtectionPolicy(password, password, permissions).apply { - encryptionKeyLength = 128 + // AES-256, and an owner password of its own: with owner == user the open password also + // grants owner rights, so the "no modify" permission would mean nothing. + val policy = StandardProtectionPolicy(randomOwnerPassword(), password, permissions).apply { + encryptionKeyLength = 256 } document.protect(policy) context.contentResolver.openOutputStream(destinationUri)?.use { output -> @@ -92,6 +94,11 @@ object PdfSecurityService { } } + private fun randomOwnerPassword(): String { + val bytes = ByteArray(24).also { java.security.SecureRandom().nextBytes(it) } + return android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) + } + private fun isPasswordError(error: Throwable): Boolean { return generateSequence(error) { it.cause } .any { it::class.java.simpleName.contains("InvalidPassword", ignoreCase = true) } diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/splitter/PdfSplitter.kt b/pdf-core/src/main/java/com/kyant/pdfcore/splitter/PdfSplitter.kt index dcbaa53..7d83956 100644 --- a/pdf-core/src/main/java/com/kyant/pdfcore/splitter/PdfSplitter.kt +++ b/pdf-core/src/main/java/com/kyant/pdfcore/splitter/PdfSplitter.kt @@ -58,6 +58,7 @@ class PdfSplitterImpl : PdfSplitter { val input = context.contentResolver.openInputStream(source.uri) ?: throw IllegalArgumentException("Cannot open source") input.use { stream -> + // The source stays open until the output is saved: importPage shares its resources. PDDocument.load(stream).use { doc -> for (i in pages) { if (i in 0 until doc.numberOfPages) { @@ -65,11 +66,11 @@ class PdfSplitterImpl : PdfSplitter { written++ } } + val out = context.contentResolver.openOutputStream(outputUri) + ?: throw IllegalStateException("Cannot write split PDF output") + out.use { output.save(it) } } } - val out = context.contentResolver.openOutputStream(outputUri) - ?: throw IllegalStateException("Cannot write split PDF output") - out.use { output.save(it) } } finally { output.close() }