Use thumbnails for the list of pages (#39)

This commit is contained in:
pynicolas
2025-09-27 14:33:53 +02:00
committed by GitHub
parent e93cf0c3a2
commit ce83ccd560
8 changed files with 102 additions and 17 deletions

View File

@@ -22,13 +22,22 @@ import org.fairscan.app.data.Page
import java.io.File import java.io.File
const val SCAN_DIR_NAME = "scanned_pages" const val SCAN_DIR_NAME = "scanned_pages"
const val THUMBNAIL_DIR_NAME = "thumbnails"
class ImageRepository(appFilesDir: File, val transformations: ImageTransformations) { class ImageRepository(
appFilesDir: File,
val transformations: ImageTransformations,
private val thumbnailSizePx: Int,
) {
private val scanDir: File = File(appFilesDir, SCAN_DIR_NAME).apply { private val scanDir: File = File(appFilesDir, SCAN_DIR_NAME).apply {
if (!exists()) mkdirs() if (!exists()) mkdirs()
} }
private val thumbnailDir: File = File(appFilesDir, THUMBNAIL_DIR_NAME).apply {
if (!exists()) mkdirs()
}
private val metadataFile = File(scanDir, "document.json") private val metadataFile = File(scanDir, "document.json")
private var fileNames: MutableList<String> = private var fileNames: MutableList<String> =
@@ -73,6 +82,7 @@ class ImageRepository(appFilesDir: File, val transformations: ImageTransformatio
val fileName = "${System.currentTimeMillis()}.jpg" val fileName = "${System.currentTimeMillis()}.jpg"
val file = File(scanDir, fileName) val file = File(scanDir, fileName)
file.writeBytes(bytes) file.writeBytes(bytes)
writeThumbnail(file)
fileNames.add(fileName) fileNames.add(fileName)
saveMetadata() saveMetadata()
} }
@@ -110,6 +120,23 @@ class ImageRepository(appFilesDir: File, val transformations: ImageTransformatio
return null return null
} }
fun getThumbnail(id: String): ByteArray? {
val thumbFile = getThumbnailFile(id)
if (!thumbFile.exists()) {
val originalFile = File(scanDir, id)
if (!originalFile.exists()) return null
writeThumbnail(originalFile)
}
return if (thumbFile.exists()) thumbFile.readBytes() else null
}
private fun writeThumbnail(originalFile: File) {
val thumbFile = getThumbnailFile(originalFile.name)
transformations.resize(originalFile, thumbFile, thumbnailSizePx)
}
private fun getThumbnailFile(id: String): File = File(thumbnailDir, id)
fun movePage(id: String, newIndex: Int) { fun movePage(id: String, newIndex: Int) {
if (!fileNames.remove(id)) return if (!fileNames.remove(id)) return
val safeIndex = newIndex.coerceIn(0, fileNames.size) val safeIndex = newIndex.coerceIn(0, fileNames.size)
@@ -118,17 +145,20 @@ class ImageRepository(appFilesDir: File, val transformations: ImageTransformatio
} }
fun delete(id: String) { fun delete(id: String) {
val file = File(scanDir, id) File(scanDir, id).delete()
file.delete() getThumbnailFile(id).delete()
fileNames.remove(id) fileNames.remove(id)
saveMetadata() saveMetadata()
} }
fun clear() { fun clear() {
fileNames.clear() fileNames.clear()
thumbnailDir.listFiles()?.forEach {
file -> file.delete()
}
scanDir.listFiles()?.forEach { scanDir.listFiles()?.forEach {
file -> file.delete() file -> file.delete()
} }
saveMetadata() saveMetadata() // "empty" json file
} }
} }

View File

@@ -16,8 +16,10 @@ package org.fairscan.app
import java.io.File import java.io.File
fun interface ImageTransformations { interface ImageTransformations {
fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) fun rotate(inputFile: File, outputFile: File, clockwise: Boolean)
fun resize(inputFile: File, outputFile: File, maxSize: Int)
} }

View File

@@ -46,6 +46,8 @@ import org.fairscan.app.view.DocumentUiModel
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
const val THUMBNAIL_SIZE_DP = 120
class MainViewModel( class MainViewModel(
private val imageSegmentationService: ImageSegmentationService, private val imageSegmentationService: ImageSegmentationService,
private val imageRepository: ImageRepository, private val imageRepository: ImageRepository,
@@ -57,9 +59,11 @@ class MainViewModel(
fun getFactory(context: Context) = object : ViewModelProvider.Factory { fun getFactory(context: Context) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T { override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val density = context.resources.displayMetrics.density
val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt()
return MainViewModel( return MainViewModel(
ImageSegmentationService(context), ImageSegmentationService(context),
ImageRepository(context.filesDir, OpenCvTransformations()), ImageRepository(context.filesDir, OpenCvTransformations(), thumbnailSizePx),
PdfFileManager( PdfFileManager(
File(context.cacheDir, "pdfs"), File(context.cacheDir, "pdfs"),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
@@ -83,12 +87,13 @@ class MainViewModel(
_pageIds.map { ids -> _pageIds.map { ids ->
DocumentUiModel( DocumentUiModel(
pageIds = ids, pageIds = ids,
imageLoader = ::getBitmap imageLoader = ::getBitmap,
thumbnailLoader = ::getThumbnail,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.Eagerly, started = SharingStarted.Eagerly,
initialValue = DocumentUiModel(persistentListOf(), ::getBitmap) initialValue = DocumentUiModel(persistentListOf(), ::getBitmap, ::getThumbnail)
) )
private val _captureState = MutableStateFlow<CaptureState>(CaptureState.Idle) private val _captureState = MutableStateFlow<CaptureState>(CaptureState.Idle)
@@ -255,6 +260,11 @@ class MainViewModel(
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) } return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
} }
fun getThumbnail(id: String): Bitmap? {
val bytes = imageRepository.getThumbnail(id)
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
}
private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) { private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) {
val imageIds = imageRepository.imageIds() val imageIds = imageRepository.imageIds()
val jpegs = imageIds.asSequence() val jpegs = imageIds.asSequence()

View File

@@ -14,10 +14,14 @@
*/ */
package org.fairscan.app package org.fairscan.app
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import org.opencv.core.Core import org.opencv.core.Core
import org.opencv.core.Mat import org.opencv.core.Mat
import org.opencv.imgcodecs.Imgcodecs import org.opencv.imgcodecs.Imgcodecs
import java.io.File import java.io.File
import kotlin.math.min
import androidx.core.graphics.scale
class OpenCvTransformations : ImageTransformations { class OpenCvTransformations : ImageTransformations {
override fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) { override fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) {
@@ -37,4 +41,15 @@ class OpenCvTransformations : ImageTransformations {
src.release() src.release()
dst.release() dst.release()
} }
override fun resize(inputFile: File, outputFile: File, maxSize: Int) {
val bitmap = BitmapFactory.decodeFile(inputFile.absolutePath)
val ratio = min(maxSize.toFloat() / bitmap.width, maxSize.toFloat() / bitmap.height)
val newW = (bitmap.width * ratio).toInt()
val newH = (bitmap.height * ratio).toInt()
val scaled = bitmap.scale(newW, newH)
outputFile.outputStream().use {
scaled.compress(Bitmap.CompressFormat.JPEG, 85, it)
}
}
} }

View File

@@ -19,7 +19,8 @@ import kotlinx.collections.immutable.ImmutableList
data class DocumentUiModel( data class DocumentUiModel(
val pageIds: ImmutableList<String>, val pageIds: ImmutableList<String>,
private val imageLoader: (String) -> Bitmap? private val imageLoader: (String) -> Bitmap?,
private val thumbnailLoader: (String) -> Bitmap?
) { ) {
fun pageCount(): Int { fun pageCount(): Int {
return pageIds.size return pageIds.size
@@ -36,4 +37,7 @@ data class DocumentUiModel(
fun load(index: Int): Bitmap? { fun load(index: Int): Bitmap? {
return imageLoader(pageIds[index]) return imageLoader(pageIds[index])
} }
} fun loadThumbnail(index: Int): Bitmap? {
return thumbnailLoader(pageIds[index])
}
}

View File

@@ -49,10 +49,11 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.fairscan.app.THUMBNAIL_SIZE_DP
import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState
const val PAGE_LIST_ELEMENT_SIZE_DP = 120 const val PAGE_LIST_ELEMENT_SIZE_DP = THUMBNAIL_SIZE_DP
data class CommonPageListState( data class CommonPageListState(
val document: DocumentUiModel, val document: DocumentUiModel,
@@ -76,8 +77,7 @@ fun CommonPageList(
val content: LazyListScope.() -> Unit = { val content: LazyListScope.() -> Unit = {
itemsIndexed(state.document.pageIds, key = { _, item -> item}) { index, item -> itemsIndexed(state.document.pageIds, key = { _, item -> item}) { index, item ->
ReorderableItem(reorderableLazyListState, key = item) { _ -> ReorderableItem(reorderableLazyListState, key = item) { _ ->
// TODO Use small images rather than big ones val image = state.document.loadThumbnail(index)
val image = state.document.load(index)
if (image != null) { if (image != null) {
PageThumbnail(image, index, state, Modifier.draggableHandle()) PageThumbnail(image, index, state, Modifier.draggableHandle())
} }
@@ -103,7 +103,7 @@ fun CommonPageList(
if (state.document.isEmpty()) { if (state.document.isEmpty()) {
Box( Box(
modifier = Modifier modifier = Modifier
.height(120.dp) .height(THUMBNAIL_SIZE_DP.dp)
.addPositionCallback(state.onLastItemPosition, LocalDensity.current, 0.5f) .addPositionCallback(state.onLastItemPosition, LocalDensity.current, 0.5f)
) {} ) {}
} }

View File

@@ -25,13 +25,14 @@ fun dummyNavigation(): Navigation {
} }
fun fakeDocument(): DocumentUiModel { fun fakeDocument(): DocumentUiModel {
return DocumentUiModel(persistentListOf()) { _ -> null } return DocumentUiModel(persistentListOf(), { _ -> null }, { _ -> null })
} }
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel { fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {
return DocumentUiModel(pageIds) { id -> val loader = { id:String ->
context.assets.open(id).use { input -> context.assets.open(id).use { input ->
BitmapFactory.decodeStream(input) BitmapFactory.decodeStream(input)
} }
} }
return DocumentUiModel(pageIds, loader, loader)
} }

View File

@@ -35,7 +35,15 @@ class ImageRepositoryTest {
} }
fun repo(): ImageRepository { fun repo(): ImageRepository {
return ImageRepository(getFilesDir(), {f1,f2,_->f1.copyTo(f2)}) val transformations = object : ImageTransformations {
override fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) {
inputFile.copyTo(outputFile)
}
override fun resize(inputFile: File, outputFile: File, maxSize: Int) {
outputFile.writeBytes(byteArrayOf(inputFile.readBytes()[0]))
}
}
return ImageRepository(getFilesDir(), transformations, 200)
} }
@Test @Test
@@ -46,6 +54,7 @@ class ImageRepositoryTest {
repo.add(bytes) repo.add(bytes)
assertThat(repo.imageIds()).hasSize(1) assertThat(repo.imageIds()).hasSize(1)
assertThat(repo.getContent(repo.imageIds()[0])).isEqualTo(bytes) assertThat(repo.getContent(repo.imageIds()[0])).isEqualTo(bytes)
assertThat(repo.getThumbnail(repo.imageIds()[0])).isEqualTo(byteArrayOf(101))
} }
@Test @Test
@@ -60,6 +69,13 @@ class ImageRepositoryTest {
assertThat(repo2.imageIds()).isEmpty() assertThat(repo2.imageIds()).isEmpty()
} }
@Test
fun delete_unknown_id() {
val repo = repo()
repo.delete("x")
assertThat(repo.imageIds()).isEmpty()
}
@Test @Test
fun `should find existing files at initialization with no json`() { fun `should find existing files at initialization with no json`() {
val scanDir = File(getFilesDir(), SCAN_DIR_NAME) val scanDir = File(getFilesDir(), SCAN_DIR_NAME)
@@ -93,6 +109,12 @@ class ImageRepositoryTest {
assertThat(repo1.imageIds()).isNotEmpty() assertThat(repo1.imageIds()).isNotEmpty()
repo1.clear() repo1.clear()
assertThat(repo1.imageIds()).isEmpty() assertThat(repo1.imageIds()).isEmpty()
assertThat(File(getFilesDir(), SCAN_DIR_NAME)
.listFiles { f -> f.name.endsWith(".jpg") })
.isEmpty()
assertThat(File(getFilesDir(), THUMBNAIL_DIR_NAME)
.listFiles { f -> f.name.endsWith(".jpg") })
.isEmpty()
val repo2 = repo() val repo2 = repo()
assertThat(repo2.imageIds()).isEmpty() assertThat(repo2.imageIds()).isEmpty()
} }
@@ -129,6 +151,7 @@ class ImageRepositoryTest {
fun movePage() { fun movePage() {
val repo = repo() val repo = repo()
repo.add(byteArrayOf(101)) repo.add(byteArrayOf(101))
Thread.sleep(1L) // to avoid file name clashes
repo.add(byteArrayOf(110)) repo.add(byteArrayOf(110))
val id0 = repo.imageIds().first() val id0 = repo.imageIds().first()
val id1 = repo.imageIds().last() val id1 = repo.imageIds().last()