feat: preview PDF links inline with first-page thumbnail and full pager
Adds MediaUrlPdf / PdfSegment to the rich-text pipeline so PDF URLs (detected by .pdf extension, NIP-92 imeta m tag, or application/pdf Content-Type) render a card showing the first page, filename, and page count. Tapping opens a full-screen HorizontalPager over every page rendered on demand with PdfRenderer. Long-press surfaces the existing share menu. Uses only the built-in Android PdfRenderer; no new dependencies. Desktop continues to fall back to a clickable link.
This commit is contained in:
@@ -71,6 +71,8 @@ class UrlPreview {
|
|||||||
UrlInfoItem(url, image = url, mimeType = mimeType.toString())
|
UrlInfoItem(url, image = url, mimeType = mimeType.toString())
|
||||||
} else if (mimeType.type == "video") {
|
} else if (mimeType.type == "video") {
|
||||||
UrlInfoItem(url, image = url, mimeType = mimeType.toString())
|
UrlInfoItem(url, image = url, mimeType = mimeType.toString())
|
||||||
|
} else if (mimeType.type == "application" && mimeType.subtype == "pdf") {
|
||||||
|
UrlInfoItem(url, image = url, mimeType = mimeType.toString())
|
||||||
} else {
|
} else {
|
||||||
throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType")
|
throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||||
|
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||||
@@ -112,6 +113,15 @@ fun RenderLoaded(
|
|||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else if (state.previewInfo.mimeType.startsWith("application/pdf")) {
|
||||||
|
Box(modifier = HalfVertPadding) {
|
||||||
|
ZoomableContentView(
|
||||||
|
content = MediaUrlPdf(url, uri = callbackUri, mimeType = state.previewInfo.mimeType),
|
||||||
|
roundedCorner = true,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
UrlPreviewCard(url, state.previewInfo)
|
UrlPreviewCard(url, state.previewInfo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
|
|||||||
import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
|
import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
|
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.ParagraphState
|
import com.vitorpamplona.amethyst.commons.richtext.ParagraphState
|
||||||
|
import com.vitorpamplona.amethyst.commons.richtext.PdfSegment
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
|
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
|
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.RelayUrlSegment
|
import com.vitorpamplona.amethyst.commons.richtext.RelayUrlSegment
|
||||||
@@ -480,6 +481,9 @@ private fun RenderWordWithoutPreview(
|
|||||||
// Don't preview Videos
|
// Don't preview Videos
|
||||||
is VideoSegment -> ClickableUrl(word.segmentText, word.segmentText)
|
is VideoSegment -> ClickableUrl(word.segmentText, word.segmentText)
|
||||||
|
|
||||||
|
// Don't preview PDFs
|
||||||
|
is PdfSegment -> ClickableUrl(word.segmentText, word.segmentText)
|
||||||
|
|
||||||
is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText)
|
is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText)
|
||||||
|
|
||||||
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
|
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
|
||||||
@@ -529,6 +533,7 @@ private fun RenderWordWithPreview(
|
|||||||
when (word) {
|
when (word) {
|
||||||
is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
||||||
is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
||||||
|
is PdfSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
||||||
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel)
|
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel)
|
||||||
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
|
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
|
||||||
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel)
|
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel)
|
||||||
|
|||||||
+30
-9
@@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo
|
|||||||
import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent
|
import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
|
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||||
|
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||||
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
|
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
|
||||||
@@ -93,6 +94,8 @@ import com.vitorpamplona.amethyst.service.playback.composable.VideoView
|
|||||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
|
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
|
||||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||||
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
|
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.pdf.PdfPreviewCard
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.pdf.PdfViewerDialog
|
||||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||||
import com.vitorpamplona.amethyst.ui.note.BlankNote
|
import com.vitorpamplona.amethyst.ui.note.BlankNote
|
||||||
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
|
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
|
||||||
@@ -221,18 +224,36 @@ fun ZoomableContentView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is MediaUrlPdf -> {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier)) {
|
||||||
|
PdfPreviewCard(
|
||||||
|
content = content,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onOpen = { dialogOpen = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
ZoomableImageDialog(
|
if (content is MediaUrlPdf) {
|
||||||
imageUrl = content,
|
PdfViewerDialog(
|
||||||
allImages = images,
|
content = content,
|
||||||
sourceBounds = sourceBounds,
|
accountViewModel = accountViewModel,
|
||||||
onDismiss = {
|
onDismiss = { dialogOpen = false },
|
||||||
dialogOpen = false
|
)
|
||||||
},
|
} else {
|
||||||
accountViewModel = accountViewModel,
|
ZoomableImageDialog(
|
||||||
)
|
imageUrl = content,
|
||||||
|
allImages = images,
|
||||||
|
sourceBounds = sourceBounds,
|
||||||
|
onDismiss = {
|
||||||
|
dialogOpen = false
|
||||||
|
},
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.components.pdf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.coroutines.executeAsync
|
||||||
|
import okio.sink
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
object PdfFetcher {
|
||||||
|
private const val CACHE_DIR_NAME = "pdf_cache"
|
||||||
|
|
||||||
|
private fun cacheDir(context: Context): File = File(context.cacheDir, CACHE_DIR_NAME).also { it.mkdirs() }
|
||||||
|
|
||||||
|
fun cachedFileOrNull(
|
||||||
|
context: Context,
|
||||||
|
url: String,
|
||||||
|
): File? {
|
||||||
|
val file = cacheFile(context, url)
|
||||||
|
return if (file.exists() && file.length() > 0) file else null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cacheFile(
|
||||||
|
context: Context,
|
||||||
|
url: String,
|
||||||
|
): File {
|
||||||
|
val key = sha256(url.toByteArray()).toHexKey()
|
||||||
|
return File(cacheDir(context), "$key.pdf")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetch(
|
||||||
|
context: Context,
|
||||||
|
url: String,
|
||||||
|
okHttpClient: (String) -> OkHttpClient,
|
||||||
|
): File =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val file = cacheFile(context, url)
|
||||||
|
if (file.exists() && file.length() > 0) return@withContext file
|
||||||
|
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(url)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
okHttpClient(url).newCall(request).executeAsync().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw IOException("PDF download failed: ${response.code}")
|
||||||
|
}
|
||||||
|
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||||
|
tmp.outputStream().use { out ->
|
||||||
|
val bytes = response.body.source().readAll(out.sink())
|
||||||
|
if (bytes == 0L) throw IOException("PDF download failed: empty response body")
|
||||||
|
}
|
||||||
|
if (!tmp.renameTo(file)) {
|
||||||
|
tmp.copyTo(file, overwrite = true)
|
||||||
|
tmp.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file
|
||||||
|
}
|
||||||
|
}
|
||||||
+210
@@ -0,0 +1,210 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.components.pdf
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.pdf.PdfRenderer
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.PictureAsPdf
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.DisplayUrlWithLoadingSymbol
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.WaitAndDisplay
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
data class PdfPreview(
|
||||||
|
val thumbnail: Bitmap,
|
||||||
|
val pageCount: Int,
|
||||||
|
val aspectRatio: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
private sealed class PdfLoadState {
|
||||||
|
data object Loading : PdfLoadState()
|
||||||
|
|
||||||
|
data class Ready(
|
||||||
|
val preview: PdfPreview,
|
||||||
|
) : PdfLoadState()
|
||||||
|
|
||||||
|
data object Failed : PdfLoadState()
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun PdfPreviewCard(
|
||||||
|
content: MediaUrlPdf,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val sharePopupExpanded = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
@Suppress("ProduceStateDoesNotAssignValue")
|
||||||
|
val state by produceState<PdfLoadState>(initialValue = PdfLoadState.Loading, key1 = content.url) {
|
||||||
|
value =
|
||||||
|
try {
|
||||||
|
val file =
|
||||||
|
PdfFetcher.fetch(context, content.url) { url ->
|
||||||
|
accountViewModel.httpClientBuilder.okHttpClientForPreview(url)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd ->
|
||||||
|
PdfRenderer(pfd).use { renderer ->
|
||||||
|
val pageCount = renderer.pageCount
|
||||||
|
if (pageCount <= 0) {
|
||||||
|
PdfLoadState.Failed
|
||||||
|
} else {
|
||||||
|
renderer.openPage(0).use { page ->
|
||||||
|
val bitmap = Bitmap.createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888)
|
||||||
|
bitmap.eraseColor(android.graphics.Color.WHITE)
|
||||||
|
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||||
|
PdfLoadState.Ready(
|
||||||
|
PdfPreview(
|
||||||
|
thumbnail = bitmap,
|
||||||
|
pageCount = pageCount,
|
||||||
|
aspectRatio = page.width.toFloat() / page.height.toFloat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
Log.w("PdfPreviewCard", "Failed to render PDF preview: ${content.url}", e)
|
||||||
|
PdfLoadState.Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareMediaAction(
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
popupExpanded = sharePopupExpanded,
|
||||||
|
content = content,
|
||||||
|
onDismiss = { sharePopupExpanded.value = false },
|
||||||
|
)
|
||||||
|
|
||||||
|
when (val current = state) {
|
||||||
|
is PdfLoadState.Loading -> {
|
||||||
|
WaitAndDisplay {
|
||||||
|
DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is PdfLoadState.Failed -> {
|
||||||
|
ClickableUrl(urlText = content.url, url = content.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
is PdfLoadState.Ready -> {
|
||||||
|
val filename = remember(content.url) { extractFilename(content.url) }
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
MaterialTheme.colorScheme.innerPostModifier
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = onOpen,
|
||||||
|
onLongClick = { sharePopupExpanded.value = true },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = current.preview.thumbnail.asImageBitmap(),
|
||||||
|
contentDescription = content.description ?: filename,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(current.preview.aspectRatio.coerceAtLeast(0.2f)),
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.PictureAsPdf,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Size20Modifier,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = filename,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = pageCountLabel(current.preview.pageCount),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = DoubleVertSpacer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractFilename(url: String): String {
|
||||||
|
val afterQuery = url.substringBefore('?').substringBefore('#')
|
||||||
|
val name = afterQuery.substringAfterLast('/', afterQuery)
|
||||||
|
return if (name.isBlank()) url else name
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pageCountLabel(pageCount: Int): String = if (pageCount == 1) "1 page" else "$pageCount pages"
|
||||||
+305
@@ -0,0 +1,305 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.components.pdf
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.pdf.PdfRenderer
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import net.engawapg.lib.zoomable.rememberZoomState
|
||||||
|
import net.engawapg.lib.zoomable.zoomable
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
private class PdfDocumentHandle(
|
||||||
|
val file: File,
|
||||||
|
val pfd: ParcelFileDescriptor,
|
||||||
|
val renderer: PdfRenderer,
|
||||||
|
) {
|
||||||
|
val pageCount: Int get() = renderer.pageCount
|
||||||
|
val mutex: Mutex = Mutex()
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
try {
|
||||||
|
renderer.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
Log.w("PdfViewerDialog", "Failed to close PdfRenderer", e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
pfd.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
Log.w("PdfViewerDialog", "Failed to close ParcelFileDescriptor", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PdfViewerDialog(
|
||||||
|
content: MediaUrlPdf,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties =
|
||||||
|
DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
decorFitsSystemWindows = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) {
|
||||||
|
PdfViewerContent(
|
||||||
|
content = content,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PdfViewerContent(
|
||||||
|
content: MediaUrlPdf,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
@Suppress("ProduceStateDoesNotAssignValue")
|
||||||
|
val handleState by produceState<PdfDocumentHandle?>(initialValue = null, key1 = content.url) {
|
||||||
|
value =
|
||||||
|
try {
|
||||||
|
val file =
|
||||||
|
PdfFetcher.fetch(context, content.url) { url ->
|
||||||
|
accountViewModel.httpClientBuilder.okHttpClientForPreview(url)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||||
|
val renderer = PdfRenderer(pfd)
|
||||||
|
PdfDocumentHandle(file, pfd, renderer)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
Log.w("PdfViewerDialog", "Failed to open PDF: ${content.url}", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(handleState) {
|
||||||
|
onDispose {
|
||||||
|
handleState?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val sharePopupExpanded = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
ShareMediaAction(
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
popupExpanded = sharePopupExpanded,
|
||||||
|
content = content,
|
||||||
|
onDismiss = { sharePopupExpanded.value = false },
|
||||||
|
)
|
||||||
|
|
||||||
|
val handle = handleState
|
||||||
|
if (handle == null) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
}
|
||||||
|
} else if (handle.pageCount == 0) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = "Unable to open PDF",
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val pagerState = rememberPagerState { handle.pageCount }
|
||||||
|
val pageCache = remember(handle) { mutableStateMapOf<Int, Bitmap>() }
|
||||||
|
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) { pageIndex ->
|
||||||
|
PdfPageView(
|
||||||
|
handle = handle,
|
||||||
|
pageIndex = pageIndex,
|
||||||
|
cache = pageCache,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = Size15dp, vertical = Size10dp)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.systemBarsPadding(),
|
||||||
|
horizontalArrangement = spacedBy(Size10dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringRes(R.string.back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${pagerState.currentPage + 1} / ${handle.pageCount}",
|
||||||
|
color = Color.White,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.small)
|
||||||
|
.padding(horizontal = Size10dp, vertical = Size5dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { sharePopupExpanded.value = true },
|
||||||
|
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Share,
|
||||||
|
modifier = Size20Modifier,
|
||||||
|
contentDescription = stringRes(R.string.quick_action_share),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PdfPageView(
|
||||||
|
handle: PdfDocumentHandle,
|
||||||
|
pageIndex: Int,
|
||||||
|
cache: MutableMap<Int, Bitmap>,
|
||||||
|
) {
|
||||||
|
val cached = cache[pageIndex]
|
||||||
|
|
||||||
|
@Suppress("ProduceStateDoesNotAssignValue")
|
||||||
|
val bitmap by produceState<Bitmap?>(initialValue = cached, key1 = handle, key2 = pageIndex) {
|
||||||
|
if (value != null) return@produceState
|
||||||
|
val rendered =
|
||||||
|
try {
|
||||||
|
handle.mutex.withLock {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
handle.renderer.openPage(pageIndex).use { page ->
|
||||||
|
val scale = 2f
|
||||||
|
val width = (page.width * scale).toInt().coerceAtLeast(1)
|
||||||
|
val height = (page.height * scale).toInt().coerceAtLeast(1)
|
||||||
|
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
bmp.eraseColor(android.graphics.Color.WHITE)
|
||||||
|
page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||||
|
bmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
Log.w("PdfViewerDialog", "Failed to render page $pageIndex", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered?.let { cache[pageIndex] = it }
|
||||||
|
value = rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
val zoomState = rememberZoomState()
|
||||||
|
val current = bitmap
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
if (current != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = current.asImageBitmap(),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.zoomable(zoomState),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -68,6 +68,17 @@ class EncryptedMediaUrlImage(
|
|||||||
val encryptionNonce: ByteArray,
|
val encryptionNonce: ByteArray,
|
||||||
) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType)
|
) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
open class MediaUrlPdf(
|
||||||
|
url: String,
|
||||||
|
description: String? = null,
|
||||||
|
hash: String? = null,
|
||||||
|
blurhash: String? = null,
|
||||||
|
dim: DimensionTag? = null,
|
||||||
|
uri: String? = null,
|
||||||
|
mimeType: String? = null,
|
||||||
|
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
open class MediaUrlVideo(
|
open class MediaUrlVideo(
|
||||||
url: String,
|
url: String,
|
||||||
|
|||||||
+36
-2
@@ -58,17 +58,21 @@ class RichTextParser {
|
|||||||
|
|
||||||
val isImage: Boolean
|
val isImage: Boolean
|
||||||
val isVideo: Boolean
|
val isVideo: Boolean
|
||||||
|
val isPdf: Boolean
|
||||||
|
|
||||||
if (contentType != null) {
|
if (contentType != null) {
|
||||||
isImage = contentType.startsWith("image/")
|
isImage = contentType.startsWith("image/")
|
||||||
isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/")
|
isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/")
|
||||||
|
isPdf = contentType.startsWith("application/pdf")
|
||||||
} else if (fullUrl.startsWith("data:")) {
|
} else if (fullUrl.startsWith("data:")) {
|
||||||
isImage = fullUrl.startsWith("data:image/")
|
isImage = fullUrl.startsWith("data:image/")
|
||||||
isVideo = fullUrl.startsWith("data:video/") || fullUrl.startsWith("data:audio/")
|
isVideo = fullUrl.startsWith("data:video/") || fullUrl.startsWith("data:audio/")
|
||||||
|
isPdf = fullUrl.startsWith("data:application/pdf")
|
||||||
} else {
|
} else {
|
||||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
||||||
isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
|
isPdf = pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
return if (isImage) {
|
return if (isImage) {
|
||||||
@@ -93,6 +97,16 @@ class RichTextParser {
|
|||||||
uri = callbackUri,
|
uri = callbackUri,
|
||||||
mimeType = contentType,
|
mimeType = contentType,
|
||||||
)
|
)
|
||||||
|
} else if (isPdf) {
|
||||||
|
MediaUrlPdf(
|
||||||
|
url = fullUrl,
|
||||||
|
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
|
||||||
|
hash = frags[HashSha256Tag.TAG_NAME] ?: tags[HashSha256Tag.TAG_NAME]?.firstOrNull(),
|
||||||
|
blurhash = frags[BlurhashTag.TAG_NAME] ?: tags[BlurhashTag.TAG_NAME]?.firstOrNull(),
|
||||||
|
dim = frags[DimensionTag.TAG_NAME]?.let { DimensionTag.parse(it) } ?: tags[DimensionTag.TAG_NAME]?.firstOrNull()?.let { DimensionTag.parse(it) },
|
||||||
|
uri = callbackUri,
|
||||||
|
mimeType = contentType,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -158,6 +172,7 @@ class RichTextParser {
|
|||||||
|
|
||||||
val imageUrls = mediaForPager.filterValues { it is MediaUrlImage }.keys
|
val imageUrls = mediaForPager.filterValues { it is MediaUrlImage }.keys
|
||||||
val videoUrls = mediaForPager.filterValues { it is MediaUrlVideo }.keys
|
val videoUrls = mediaForPager.filterValues { it is MediaUrlVideo }.keys
|
||||||
|
val pdfUrls = mediaForPager.filterValues { it is MediaUrlPdf }.keys
|
||||||
|
|
||||||
val emojiMap = CustomEmoji.createEmojiMap(tags.lists)
|
val emojiMap = CustomEmoji.createEmojiMap(tags.lists)
|
||||||
|
|
||||||
@@ -165,7 +180,7 @@ class RichTextParser {
|
|||||||
|
|
||||||
val newContent = fixMissingSpaces(content, allUrls)
|
val newContent = fixMissingSpaces(content, allUrls)
|
||||||
|
|
||||||
val segments = findTextSegments(newContent, imageUrls, videoUrls, urlSet, emojiMap, tags)
|
val segments = findTextSegments(newContent, imageUrls, videoUrls, pdfUrls, urlSet, emojiMap, tags)
|
||||||
|
|
||||||
val mediaForPagerWithBase64 =
|
val mediaForPagerWithBase64 =
|
||||||
mediaForPager +
|
mediaForPager +
|
||||||
@@ -197,6 +212,7 @@ class RichTextParser {
|
|||||||
content: String,
|
content: String,
|
||||||
images: Set<String>,
|
images: Set<String>,
|
||||||
videos: Set<String>,
|
videos: Set<String>,
|
||||||
|
pdfs: Set<String>,
|
||||||
urls: Urls,
|
urls: Urls,
|
||||||
emojis: Map<String, String>,
|
emojis: Map<String, String>,
|
||||||
tags: ImmutableListOfLists<String>,
|
tags: ImmutableListOfLists<String>,
|
||||||
@@ -211,7 +227,7 @@ class RichTextParser {
|
|||||||
|
|
||||||
val segments = ArrayList<Segment>(wordList.size)
|
val segments = ArrayList<Segment>(wordList.size)
|
||||||
wordList.forEach { word ->
|
wordList.forEach { word ->
|
||||||
segments.add(wordIdentifier(word, images, videos, urls, emojis, tags))
|
segments.add(wordIdentifier(word, images, videos, pdfs, urls, emojis, tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL))
|
paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL))
|
||||||
@@ -262,6 +278,7 @@ class RichTextParser {
|
|||||||
word: String,
|
word: String,
|
||||||
images: Set<String>,
|
images: Set<String>,
|
||||||
videos: Set<String>,
|
videos: Set<String>,
|
||||||
|
pdfs: Set<String>,
|
||||||
urls: Urls,
|
urls: Urls,
|
||||||
emojis: Map<String, String>,
|
emojis: Map<String, String>,
|
||||||
tags: ImmutableListOfLists<String>,
|
tags: ImmutableListOfLists<String>,
|
||||||
@@ -288,6 +305,14 @@ class RichTextParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pdfs.contains(word)) {
|
||||||
|
return if (urls.withoutScheme.contains(word)) {
|
||||||
|
PdfSegment("https://$word")
|
||||||
|
} else {
|
||||||
|
PdfSegment(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (urls.withoutScheme.contains(word)) return SchemelessUrlSegment(word)
|
if (urls.withoutScheme.contains(word)) return SchemelessUrlSegment(word)
|
||||||
|
|
||||||
if (urls.withScheme.contains(word)) return LinkSegment(word)
|
if (urls.withScheme.contains(word)) return LinkSegment(word)
|
||||||
@@ -377,9 +402,11 @@ class RichTextParser {
|
|||||||
|
|
||||||
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
|
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
|
||||||
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a")
|
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a")
|
||||||
|
val pdfExt = listOf("pdf")
|
||||||
|
|
||||||
val imageExtensions = imageExt + imageExt.map { it.uppercase() }
|
val imageExtensions = imageExt + imageExt.map { it.uppercase() }
|
||||||
val videoExtensions = videoExt + videoExt.map { it.uppercase() }
|
val videoExtensions = videoExt + videoExt.map { it.uppercase() }
|
||||||
|
val pdfExtensions = pdfExt + pdfExt.map { it.uppercase() }
|
||||||
|
|
||||||
val tagIndex = Regex("\\#\\[([0-9]+)\\](.*)")
|
val tagIndex = Regex("\\#\\[([0-9]+)\\](.*)")
|
||||||
val hashTagsPattern: Regex =
|
val hashTagsPattern: Regex =
|
||||||
@@ -421,6 +448,11 @@ class RichTextParser {
|
|||||||
return videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
return videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isPdfUrl(url: String): Boolean {
|
||||||
|
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
|
||||||
|
return pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
|
}
|
||||||
|
|
||||||
fun isValidURL(url: String?): Boolean =
|
fun isValidURL(url: String?): Boolean =
|
||||||
try {
|
try {
|
||||||
if (url != null) {
|
if (url != null) {
|
||||||
@@ -496,4 +528,6 @@ val mimeTypeMap: Map<String, String> =
|
|||||||
"m4a" to "audio/mp4",
|
"m4a" to "audio/mp4",
|
||||||
"aac" to "audio/aac",
|
"aac" to "audio/aac",
|
||||||
"flac" to "audio/flac",
|
"flac" to "audio/flac",
|
||||||
|
// Documents
|
||||||
|
"pdf" to "application/pdf",
|
||||||
)
|
)
|
||||||
|
|||||||
+5
@@ -62,6 +62,11 @@ class VideoSegment(
|
|||||||
segment: String,
|
segment: String,
|
||||||
) : Segment(segment)
|
) : Segment(segment)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
class PdfSegment(
|
||||||
|
segment: String,
|
||||||
|
) : Segment(segment)
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
class LinkSegment(
|
class LinkSegment(
|
||||||
segment: String,
|
segment: String,
|
||||||
|
|||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.richtext
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class PdfParserTest {
|
||||||
|
@Test
|
||||||
|
fun detectsPdfByExtension() {
|
||||||
|
val url = "https://example.com/docs/paper.pdf"
|
||||||
|
val state = RichTextParser().parseText(url, EmptyTagList, null)
|
||||||
|
|
||||||
|
val pdfMedia = state.mediaForPager[url]
|
||||||
|
assertTrue(pdfMedia is MediaUrlPdf, "Expected MediaUrlPdf for .pdf URL")
|
||||||
|
|
||||||
|
val segment = state.paragraphs[0].words[0]
|
||||||
|
assertTrue(segment is PdfSegment, "Expected PdfSegment for .pdf URL, got ${segment::class.simpleName}")
|
||||||
|
assertEquals(url, segment.segmentText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun detectsPdfFromImetaMimeTypeWithoutExtension() {
|
||||||
|
val url = "https://files.example.com/abcd1234"
|
||||||
|
val tags =
|
||||||
|
ImmutableListOfLists(
|
||||||
|
arrayOf(
|
||||||
|
arrayOf("imeta", "url $url", "m application/pdf"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val state = RichTextParser().parseText(url, tags, null)
|
||||||
|
|
||||||
|
val pdfMedia = state.mediaForPager[url]
|
||||||
|
assertTrue(pdfMedia is MediaUrlPdf, "Expected MediaUrlPdf from imeta MIME tag")
|
||||||
|
assertEquals("application/pdf", (pdfMedia as MediaUrlPdf).mimeType)
|
||||||
|
|
||||||
|
val segment = state.paragraphs[0].words[0]
|
||||||
|
assertTrue(segment is PdfSegment, "Expected PdfSegment from imeta MIME tag, got ${segment::class.simpleName}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isPdfUrlHelperMatchesPdfExtension() {
|
||||||
|
assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf"))
|
||||||
|
assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.PDF"))
|
||||||
|
assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf?sig=abc"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user