Revamp ImageRepository

This commit is contained in:
Pierre-Yves Nicolas
2026-01-12 16:30:08 +01:00
parent 3b4ba3f027
commit 3c68e08a03
12 changed files with 352 additions and 156 deletions

View File

@@ -19,6 +19,7 @@ import android.graphics.BitmapFactory
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -28,6 +29,8 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.fairscan.app.data.ImageRepository import org.fairscan.app.data.ImageRepository
import org.fairscan.app.domain.CapturedPage import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation
import org.fairscan.app.ui.NavigationState import org.fairscan.app.ui.NavigationState
import org.fairscan.app.ui.Screen import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.state.DocumentUiModel import org.fairscan.app.ui.state.DocumentUiModel
@@ -39,11 +42,13 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
val currentScreen: StateFlow<Screen> = _navigationState.map { it.current } val currentScreen: StateFlow<Screen> = _navigationState.map { it.current }
.stateIn(viewModelScope, SharingStarted.Eagerly, _navigationState.value.current) .stateIn(viewModelScope, SharingStarted.Eagerly, _navigationState.value.current)
private val _pageIds = MutableStateFlow(imageRepository.imageIds()) private val _pages = MutableStateFlow(imageRepository.pages())
val documentUiModel: StateFlow<DocumentUiModel> = val documentUiModel: StateFlow<DocumentUiModel> =
_pageIds.map { ids -> _pages.map { pages ->
DocumentUiModel( DocumentUiModel(
pageIds = ids, pageKeys = pages.map { p ->
PageViewKey(p.id, p.metadata?.manualRotation?: Rotation.R0)
}.toImmutableList(),
imageLoader = ::getBitmap, imageLoader = ::getBitmap,
thumbnailLoader = ::getThumbnail, thumbnailLoader = ::getThumbnail,
) )
@@ -64,38 +69,38 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
fun rotateImage(id: String, clockwise: Boolean) { fun rotateImage(id: String, clockwise: Boolean) {
viewModelScope.launch { viewModelScope.launch {
imageRepository.rotate(id, clockwise) imageRepository.rotate(id, clockwise)
_pageIds.value = imageRepository.imageIds() _pages.value = imageRepository.pages()
} }
} }
fun movePage(id: String, newIndex: Int) { fun movePage(id: String, newIndex: Int) {
viewModelScope.launch { viewModelScope.launch {
imageRepository.movePage(id, newIndex) imageRepository.movePage(id, newIndex)
_pageIds.value = imageRepository.imageIds() _pages.value = imageRepository.pages()
} }
} }
fun deletePage(id: String) { fun deletePage(id: String) {
viewModelScope.launch { viewModelScope.launch {
imageRepository.delete(id) imageRepository.delete(id)
_pageIds.value = imageRepository.imageIds() _pages.value = imageRepository.pages()
} }
} }
fun startNewDocument() { fun startNewDocument() {
_pageIds.value = persistentListOf() _pages.value = persistentListOf()
viewModelScope.launch { viewModelScope.launch {
imageRepository.clear() imageRepository.clear()
} }
} }
fun getBitmap(id: String): Bitmap? { fun getBitmap(key: PageViewKey): Bitmap? {
val bytes = imageRepository.getContent(id) val bytes = imageRepository.jpegBytes(key)
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) } return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
} }
fun getThumbnail(id: String): Bitmap? { fun getThumbnail(key: PageViewKey): Bitmap? {
val bytes = imageRepository.getThumbnail(id) val bytes = imageRepository.getThumbnail(key)
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) } return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
} }
@@ -106,7 +111,7 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
compressJpeg(capturedPage.source, 90), compressJpeg(capturedPage.source, 90),
capturedPage.metadata, capturedPage.metadata,
) )
_pageIds.value = imageRepository.imageIds() _pages.value = imageRepository.pages()
} }
} }

View File

@@ -17,16 +17,28 @@ package org.fairscan.app.data
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class DocumentMetadata( data class DocumentMetadataV1(
val version: Int = 1, val version: Int = 1,
val pages: List<Page> val pages: List<PageV1>
) )
@Serializable @Serializable
data class Page( data class PageV1(
val file: String, val file: String
)
@Serializable
data class DocumentMetadataV2(
val version: Int = 2,
val pages: List<PageV2>
)
@Serializable
data class PageV2(
val id: String,
val baseRotationDegrees: Int = 0,
val manualRotationDegrees: Int = 0,
val quad: NormalizedQuad? = null, val quad: NormalizedQuad? = null,
val rotationDegrees: Int = 0,
val isColored: Boolean? = null val isColored: Boolean? = null
) )

View File

@@ -14,10 +14,15 @@
*/ */
package org.fairscan.app.data package org.fairscan.app.data
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation
import org.fairscan.app.domain.ScanPage
import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.Quad
import java.io.File import java.io.File
@@ -46,127 +51,186 @@ class ImageRepository(
private val metadataFile = File(scanDir, "document.json") private val metadataFile = File(scanDir, "document.json")
private var pages: MutableList<Page> = loadPages() private var pages: MutableList<PageV2> = loadPages()
private fun loadPages(): MutableList<Page> { private val json = Json {
prettyPrint = false
encodeDefaults = true
}
private fun loadPages(): MutableList<PageV2> {
val filesOnDisk = scanDir.listFiles() val filesOnDisk = scanDir.listFiles()
?.filter { it.extension == "jpg" } ?.filter { it.extension == "jpg" }
?.map { it.name } ?.map { it.name }
?.toSet() ?.toSet()
?: emptySet() ?: emptySet()
val metadataPages = loadMetadata()?.pages val metadataPages = if (metadataFile.exists()) {
runCatching { loadMetadata() }.getOrNull()
} else null
return when { return when {
metadataPages != null -> metadataPages != null ->
metadataPages metadataPages
.filter { it.file in filesOnDisk } .filter { it.workFileName() in filesOnDisk }
.toMutableList() .toMutableList()
else -> else ->
filesOnDisk filesOnDisk
.sorted() .sorted()
.map { Page(file = it) } .map { pageFromFileName(it) }
.toMutableList() .toMutableList()
} }
} }
private fun indexOfPage(id: String): Int = private fun indexOfPage(id: String): Int =
pages.indexOfFirst { it.file == id } pages.indexOfFirst { it.id == id }
private fun loadMetadata(): DocumentMetadata? = private fun loadMetadata(): List<PageV2> {
if (metadataFile.exists()) { val json = metadataFile.readText()
runCatching {
Json.decodeFromString<DocumentMetadata>(metadataFile.readText()) val jsonElement = Json.parseToJsonElement(json)
}.getOrNull() val version = jsonElement.jsonObject["version"]?.jsonPrimitive?.int ?: 1
} else null
return when (version) {
1 -> migrateFromV1(Json.decodeFromJsonElement<DocumentMetadataV1>(jsonElement))
2 -> Json.decodeFromJsonElement<DocumentMetadataV2>(jsonElement).pages
else -> error("Unsupported metadata version: $version")
}
}
private fun migrateFromV1(meta: DocumentMetadataV1): MutableList<PageV2> {
return meta.pages.map {
old -> pageFromFileName(old.file)
}.toMutableList()
}
private fun pageFromFileName(fileName: String): PageV2 {
val fileName = fileName.removeSuffix(".jpg")
val dashIndex = fileName.lastIndexOf('-')
val rotation = if (dashIndex >= 0)
fileName.substring(dashIndex + 1).toInt()
else
0
val id = if (dashIndex >= 0) fileName.substring(0, dashIndex) else fileName
return PageV2(id, manualRotationDegrees = rotation)
}
private fun saveMetadata() { private fun saveMetadata() {
val metadata = DocumentMetadata(version = 1, pages = pages) val metadata = DocumentMetadataV2(pages = pages)
metadataFile.writeText(Json.encodeToString(metadata)) metadataFile.writeText(json.encodeToString(metadata))
} }
fun imageIds(): PersistentList<String> = fun pages(): List<ScanPage> =
pages.map { it.file }.toPersistentList() pages.map {
ScanPage(it.id, it.toMetadata())
fun getPageMetadata(id: String): PageMetadata? {
val index = indexOfPage(id)
if (index < 0) return null
return pages[index].toMetadata()
} }
// TODO time complexity should be constant
private fun page(id: String): PageV2? = pages.find { p -> p.id == id }
fun add(pageBytes: ByteArray, sourceBytes: ByteArray, metadata: PageMetadata) { fun add(pageBytes: ByteArray, sourceBytes: ByteArray, metadata: PageMetadata) {
val fileName = "${System.currentTimeMillis()}.jpg" val id = "${System.currentTimeMillis()}"
val fileName = "$id.jpg"
val file = File(scanDir, fileName) val file = File(scanDir, fileName)
file.writeBytes(pageBytes) file.writeBytes(pageBytes)
writeThumbnail(file) writeThumbnail(file)
File(sourceDir, fileName).writeBytes(sourceBytes) File(sourceDir, fileName).writeBytes(sourceBytes)
pages.add( pages.add(
Page( PageV2(
file = fileName, id = id,
quad = metadata.normalizedQuad.toSerializable(), quad = metadata.normalizedQuad.toSerializable(),
rotationDegrees = metadata.rotationDegrees, baseRotationDegrees = metadata.baseRotation.degrees,
manualRotationDegrees = metadata.manualRotation.degrees,
isColored = metadata.isColored isColored = metadata.isColored
) )
) )
saveMetadata() saveMetadata()
} }
val idRegex = Regex("([0-9]+)(-(90|180|270))?\\.jpg") private fun workFileName(key: PageViewKey): String =
workFileName(key.pageId, key.rotation)
private fun workFileName(pageId: String, manualRotation: Rotation): String =
workFileName(pageId, manualRotation.degrees)
private fun workFileName(pageId: String, manualRotationDegrees: Int): String =
when (manualRotationDegrees) {
0 -> "$pageId.jpg"
else -> "$pageId-${manualRotationDegrees}.jpg"
}
fun PageV2.workFileName() = workFileName(id, manualRotationDegrees)
fun rotate(id: String, clockwise: Boolean) { fun rotate(id: String, clockwise: Boolean) {
val originalFile = File(scanDir, id)
if (!originalFile.exists()) {
return
}
idRegex.matchEntire(id)?.let {
val baseId = it.groupValues[1]
val degrees = it.groupValues[3].ifEmpty { "0" }.toInt()
val targetDegrees = (degrees + (if (clockwise) 90 else 270)) % 360
val rotatedId = if (targetDegrees == 0) "$baseId.jpg" else "$baseId-$targetDegrees.jpg"
val rotatedFile = File(scanDir, rotatedId)
transformations.rotate(originalFile, rotatedFile, clockwise)
if (rotatedFile.exists()) {
val index = indexOfPage(id) val index = indexOfPage(id)
if (index >= 0) { if (index < 0)
val oldPage = pages[index] return
pages[index] = oldPage.copy(file = rotatedId) val page = pages[index]
val delta = if (clockwise) Rotation.R90 else Rotation.R270
val currentManualRotation = Rotation.fromDegrees(page.manualRotationDegrees)
val newManualRotation = currentManualRotation.add(delta)
if (newManualRotation == currentManualRotation) {
return // no-op
}
val targetFileName = workFileName(page.id, newManualRotation)
val outputFile = File(scanDir, targetFileName)
if (!outputFile.exists()) {
val inputFile = File(scanDir, page.workFileName())
if (!inputFile.exists())
return
transformations.rotate(inputFile, outputFile, clockwise)
}
pages[index] = page.copy(
manualRotationDegrees = newManualRotation.degrees,
)
saveMetadata() saveMetadata()
} }
delete(id)
} fun jpegBytes(key: PageViewKey): ByteArray? {
} val file = File(scanDir, workFileName(key))
return (if (file.exists()) file else null)?.readBytes()
} }
fun getContent(id: String): ByteArray? { fun jpegBytes(id: String): ByteArray? {
return getFileFor(id)?.readBytes() val page = page(id)
if (page == null) return null
val file = File(scanDir, page.workFileName())
return (if (file.exists()) file else null)?.readBytes()
} }
fun getFileFor(id: String): File? { fun sourceJpegBytes(id: String): ByteArray? {
val file = File(scanDir, id) val file = getSourceFile(id)
return if (file.exists()) file else null
}
fun getSourceFor(id: String): ByteArray? {
val file = File(sourceDir, id)
return if (file.exists()) file.readBytes() else null return if (file.exists()) file.readBytes() else null
} }
fun getThumbnail(id: String): ByteArray? { private fun getSourceFile(id: String): File {
val thumbFile = getThumbnailFile(id) return File(sourceDir, "$id.jpg")
}
fun getThumbnail(key: PageViewKey): ByteArray? {
val thumbFile = getThumbnailFile(key)
if (thumbFile == null) {
return null
}
if (!thumbFile.exists()) { if (!thumbFile.exists()) {
val originalFile = File(scanDir, id) val workFile = File(scanDir, workFileName(key))
if (!originalFile.exists()) return null if (!workFile.exists()) return null
writeThumbnail(originalFile) writeThumbnail(workFile)
} }
return if (thumbFile.exists()) thumbFile.readBytes() else null return if (thumbFile.exists()) thumbFile.readBytes() else null
} }
private fun writeThumbnail(originalFile: File) { private fun writeThumbnail(originalFile: File) {
val thumbFile = getThumbnailFile(originalFile.name) val thumbFile = File(thumbnailDir, originalFile.name)
transformations.resize(originalFile, thumbFile, thumbnailSizePx) transformations.resize(originalFile, thumbFile, thumbnailSizePx)
} }
private fun getThumbnailFile(id: String): File = File(thumbnailDir, id) private fun getThumbnailFile(key: PageViewKey): File? {
return File(thumbnailDir, workFileName(key))
}
fun movePage(id: String, newIndex: Int) { fun movePage(id: String, newIndex: Int) {
val index = indexOfPage(id) val index = indexOfPage(id)
@@ -179,15 +243,24 @@ class ImageRepository(
} }
fun delete(id: String) { fun delete(id: String) {
File(scanDir, id).delete() val index = indexOfPage(id)
File(sourceDir, id).delete() if (index < 0)
getThumbnailFile(id).delete() return
pages.removeAll { it.file == id } pages.removeAt(index)
saveMetadata() saveMetadata()
getSourceFile(id).delete()
scanDir.listFiles()
?.filter { it.name.startsWith("${id}.") || it.name.startsWith("$id-") }
?.forEach { it.delete() }
thumbnailDir.listFiles()
?.filter { it.name.startsWith("${id}.") || it.name.startsWith("$id-") }
?.forEach { it.delete() }
} }
fun clear() { fun clear() {
pages.clear() pages.clear()
saveMetadata() // "empty" json file
thumbnailDir.listFiles()?.forEach { thumbnailDir.listFiles()?.forEach {
file -> file.delete() file -> file.delete()
} }
@@ -197,7 +270,6 @@ class ImageRepository(
sourceDir.listFiles()?.forEach { sourceDir.listFiles()?.forEach {
file -> file.delete() file -> file.delete()
} }
saveMetadata() // "empty" json file
} }
} }
@@ -217,7 +289,14 @@ fun NormalizedQuad.toQuad(): Quad =
Point(bottomLeft.x, bottomLeft.y) Point(bottomLeft.x, bottomLeft.y)
) )
fun Page.toMetadata(): PageMetadata? { fun PageV2.toMetadata(): PageMetadata? {
return runCatching {
if (quad == null || isColored == null) return null if (quad == null || isColored == null) return null
return PageMetadata(quad.toQuad(), rotationDegrees, isColored) PageMetadata(
quad.toQuad(),
Rotation.fromDegrees(baseRotationDegrees),
Rotation.fromDegrees(manualRotationDegrees),
isColored
)
}.getOrNull()
} }

View File

@@ -28,26 +28,27 @@ fun jpegsForExport(
exportQuality: ExportQuality exportQuality: ExportQuality
): Sequence<ByteArray> { ): Sequence<ByteArray> {
val imageIds = imageRepository.imageIds() val pages = imageRepository.pages().asSequence()
val baseJpegs = imageIds.asSequence().mapNotNull { id -> imageRepository.getContent(id) }
return when (exportQuality) { return when (exportQuality) {
ExportQuality.BALANCED -> baseJpegs ExportQuality.BALANCED -> pages.mapNotNull { imageRepository.jpegBytes(it.id) }
ExportQuality.LOW -> baseJpegs.mapNotNull { jpeg ->
ExportQuality.LOW -> pages.mapNotNull { page ->
imageRepository.jpegBytes(page.id)?.let { jpeg ->
resizeJpegBytesForMaxPixels( resizeJpegBytesForMaxPixels(
jpegBytes = jpeg, jpegBytes = jpeg,
maxPixels = exportQuality.maxPixels.toDouble(), maxPixels = exportQuality.maxPixels.toDouble(),
jpegQuality = exportQuality.jpegQuality jpegQuality = exportQuality.jpegQuality
) )
} }
ExportQuality.HIGH -> {
imageIds.asSequence().mapNotNull { id ->
val sourceJpegBytes = imageRepository.getSourceFor(id)
val pageMetadata = imageRepository.getPageMetadata(id)
if (sourceJpegBytes != null && pageMetadata != null)
prepareJpegForHigh(sourceJpegBytes, pageMetadata, ExportQuality.HIGH)
else
imageRepository.getContent(id)
} }
ExportQuality.HIGH -> pages.mapNotNull { page ->
val sourceJpegBytes = imageRepository.sourceJpegBytes(page.id)
val pageMetadata = page.metadata
if (sourceJpegBytes != null && pageMetadata != null)
prepareJpegForHigh(sourceJpegBytes, pageMetadata, exportQuality)
else
imageRepository.jpegBytes(page.id)
} }
} }
} }
@@ -83,7 +84,7 @@ fun prepareJpegForHigh(
val page = extractDocument( val page = extractDocument(
decoded, decoded,
quad, quad,
pageMetadata.rotationDegrees, pageMetadata.baseRotation.add(pageMetadata.manualRotation).degrees,
pageMetadata.isColored, pageMetadata.isColored,
exportQuality.maxPixels) exportQuality.maxPixels)
val outJpegBytes = encodeJpeg(page, exportQuality.jpegQuality) val outJpegBytes = encodeJpeg(page, exportQuality.jpegQuality)

View File

@@ -18,6 +18,34 @@ import org.fairscan.imageprocessing.Quad
data class PageMetadata( data class PageMetadata(
val normalizedQuad: Quad, val normalizedQuad: Quad,
val rotationDegrees: Int, val baseRotation: Rotation,
val manualRotation: Rotation,
val isColored: Boolean, val isColored: Boolean,
) )
data class ScanPage(
val id: String,
val metadata: PageMetadata?,
)
data class PageViewKey(
val pageId: String,
val rotation: Rotation,
) {
val saveKey: String get() = "$pageId-${rotation.degrees}"
}
enum class Rotation(val degrees: Int) {
R0(0),
R90(90),
R180(180),
R270(270);
fun add(other: Rotation): Rotation =
fromDegrees((degrees + other.degrees) % 360)
companion object {
fun fromDegrees(deg: Int): Rotation =
entries.first { it.degrees == ((deg % 360 + 360) % 360) }
}
}

View File

@@ -18,6 +18,9 @@ import android.content.Context
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation
import org.fairscan.app.ui.state.DocumentUiModel import org.fairscan.app.ui.state.DocumentUiModel
fun dummyNavigation(): Navigation { fun dummyNavigation(): Navigation {
@@ -29,10 +32,11 @@ fun fakeDocument(): DocumentUiModel {
} }
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel { fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {
val loader = { id:String -> val loader = { key: PageViewKey ->
context.assets.open(id).use { input -> context.assets.open("${key.pageId}.jpg").use { input ->
BitmapFactory.decodeStream(input) BitmapFactory.decodeStream(input)
} }
} }
return DocumentUiModel(pageIds, loader, loader) val pageKeys = pageIds.map { PageViewKey(it, Rotation.R0) }.toImmutableList()
return DocumentUiModel(pageKeys, loader, loader)
} }

View File

@@ -75,8 +75,8 @@ fun CommonPageList(
state.onPageReorder(from.key as String, to.index) state.onPageReorder(from.key as String, to.index)
} }
val content: LazyListScope.() -> Unit = { val content: LazyListScope.() -> Unit = {
itemsIndexed(state.document.pageIds, key = { _, item -> item}) { index, item -> itemsIndexed(state.document.pageKeys, key = { _, item -> item.saveKey}) { index, item ->
ReorderableItem(reorderableLazyListState, key = item) { isDragging -> ReorderableItem(reorderableLazyListState, key = item.saveKey) { isDragging ->
val borderColor = val borderColor =
if (isDragging) MaterialTheme.colorScheme.primary else Color.Transparent if (isDragging) MaterialTheme.colorScheme.primary else Color.Transparent
val modifier = Modifier val modifier = Modifier

View File

@@ -86,6 +86,7 @@ import org.fairscan.app.MainViewModel
import org.fairscan.app.R import org.fairscan.app.R
import org.fairscan.app.domain.CapturedPage import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.Rotation.R0
import org.fairscan.app.ui.Navigation import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.Screen import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.components.CameraPermissionState import org.fairscan.app.ui.components.CameraPermissionState
@@ -471,7 +472,7 @@ fun CameraScreenPreviewWithProcessedImage() {
CapturedPage( CapturedPage(
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"), debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"), debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
PageMetadata(quad, 0, false)))) PageMetadata(quad, R0, R0, false))))
} }
@Preview(showBackground = true, widthDp = 640, heightDp = 320) @Preview(showBackground = true, widthDp = 640, heightDp = 320)

View File

@@ -34,6 +34,7 @@ import org.fairscan.app.AppContainer
import org.fairscan.app.domain.CapturedPage import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.ExportQuality import org.fairscan.app.domain.ExportQuality
import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.Rotation
import org.fairscan.imageprocessing.Mask import org.fairscan.imageprocessing.Mask
import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.Quad
import org.fairscan.imageprocessing.detectDocumentQuad import org.fairscan.imageprocessing.detectDocumentQuad
@@ -206,7 +207,8 @@ fun extractDocumentFromBitmap(
val outBitmap = toBitmap(outBgr) val outBitmap = toBitmap(outBgr)
outBgr.release() outBgr.release()
val normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1) val normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1)
val metadata = PageMetadata(normalizedQuad, rotationDegrees, isColored) val baseRotation = Rotation.fromDegrees(rotationDegrees)
val metadata = PageMetadata(normalizedQuad, baseRotation, Rotation.R0, isColored)
return CapturedPage(outBitmap, source, metadata) return CapturedPage(outBitmap, source, metadata)
} }

View File

@@ -16,28 +16,29 @@ package org.fairscan.app.ui.state
import android.graphics.Bitmap import android.graphics.Bitmap
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import org.fairscan.app.domain.PageViewKey
data class DocumentUiModel( data class DocumentUiModel(
val pageIds: ImmutableList<String>, val pageKeys: ImmutableList<PageViewKey>,
private val imageLoader: (String) -> Bitmap?, private val imageLoader: (PageViewKey) -> Bitmap?,
private val thumbnailLoader: (String) -> Bitmap? private val thumbnailLoader: (PageViewKey) -> Bitmap?
) { ) {
fun pageCount(): Int { fun pageCount(): Int {
return pageIds.size return pageKeys.size
} }
fun pageId(index: Int): String { fun pageId(index: Int): String {
return pageIds[index] return pageKeys[index].pageId
} }
fun isEmpty(): Boolean { fun isEmpty(): Boolean {
return pageIds.isEmpty() return pageKeys.isEmpty()
} }
fun lastIndex(): Int { fun lastIndex(): Int {
return pageIds.lastIndex return pageKeys.lastIndex
} }
fun load(index: Int): Bitmap? { fun load(index: Int): Bitmap? {
return imageLoader(pageIds[index]) return imageLoader(pageKeys[index])
} }
fun loadThumbnail(index: Int): Bitmap? { fun loadThumbnail(index: Int): Bitmap? {
return thumbnailLoader(pageIds[index]) return thumbnailLoader(pageKeys[index])
} }
} }

View File

@@ -14,8 +14,15 @@
*/ */
package org.fairscan.app.data package org.fairscan.app.data
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation.R0
import org.fairscan.app.domain.Rotation.R180
import org.fairscan.app.domain.Rotation.R270
import org.fairscan.app.domain.Rotation.R90
import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.Quad
import org.junit.Rule import org.junit.Rule
@@ -31,7 +38,7 @@ class ImageRepositoryTest {
private var _filesDir: File? = null private var _filesDir: File? = null
val quad1 = Quad(Point(.01, .02), Point(.1, .03), Point(.11, .12), Point(.03, .09)) val quad1 = Quad(Point(.01, .02), Point(.1, .03), Point(.11, .12), Point(.03, .09))
val metadata1 = PageMetadata(quad1, 90, true) val metadata1 = PageMetadata(quad1, R90, R0, true)
fun getFilesDir(): File { fun getFilesDir(): File {
if (_filesDir == null) { if (_filesDir == null) {
@@ -60,14 +67,17 @@ class ImageRepositoryTest {
repo.add(bytes, byteArrayOf(51), metadata1) repo.add(bytes, byteArrayOf(51), metadata1)
assertThat(repo.imageIds()).hasSize(1) assertThat(repo.imageIds()).hasSize(1)
val id = repo.imageIds()[0] val id = repo.imageIds()[0]
assertThat(repo.getContent(id)).isEqualTo(bytes) val key = PageViewKey(id, R0)
assertThat(repo.getThumbnail(id)).isEqualTo(byteArrayOf(101)) assertThat(repo.jpegBytes(key)).isEqualTo(bytes)
assertThat(repo.getThumbnail(key)).isEqualTo(byteArrayOf(101))
assertThat(repo().getPageMetadata("x")).isNull() val page = repo.pages().first()
val metadata = repo.getPageMetadata(id) assertThat(page.id).isEqualTo(id)
val metadata = page.metadata
assertThat(metadata).isNotNull() assertThat(metadata).isNotNull()
assertThat(metadata!!.normalizedQuad).isEqualTo(quad1) assertThat(metadata!!.normalizedQuad).isEqualTo(quad1)
assertThat(metadata.rotationDegrees).isEqualTo(metadata1.rotationDegrees) assertThat(metadata.baseRotation).isEqualTo(metadata1.baseRotation)
assertThat(metadata.manualRotation).isEqualTo(metadata1.manualRotation)
assertThat(metadata.isColored).isEqualTo(metadata1.isColored) assertThat(metadata.isColored).isEqualTo(metadata1.isColored)
} }
@@ -98,7 +108,17 @@ class ImageRepositoryTest {
fun `should find existing files at initialization with no json`() { fun `should find existing files at initialization with no json`() {
scanDir().mkdirs() scanDir().mkdirs()
File(scanDir(), "1.jpg").writeBytes(byteArrayOf(101, 102, 103)) File(scanDir(), "1.jpg").writeBytes(byteArrayOf(101, 102, 103))
assertThat(repo().imageIds()).containsExactly("1.jpg") assertThat(repo().imageIds()).containsExactly("1")
}
@Test
fun `should find existing files at initialization with no json and with rotation`() {
scanDir().mkdirs()
val bytes = byteArrayOf(101, 102, 103)
File(scanDir(), "1-90.jpg").writeBytes(bytes)
val repo = repo()
assertThat(repo.imageIds()).containsExactly("1")
assertThat(repo.jpegBytes("1")).isEqualTo(bytes)
} }
@Test @Test
@@ -107,14 +127,14 @@ class ImageRepositoryTest {
val json = """{"pages":[{"file":"1.jpg"}, {"file":"2.jpg"}]}""" val json = """{"pages":[{"file":"1.jpg"}, {"file":"2.jpg"}]}"""
File(scanDir(), "document.json").writeText(json) File(scanDir(), "document.json").writeText(json)
File(scanDir(), "2.jpg").writeBytes(byteArrayOf(101, 102, 103)) File(scanDir(), "2.jpg").writeBytes(byteArrayOf(101, 102, 103))
assertThat(repo().imageIds()).containsExactly("2.jpg") assertThat(repo().imageIds()).containsExactly("2")
} }
@Test @Test
fun `should return null on invalid id`() { fun `should return null on invalid id`() {
val repo = repo() val repo = repo()
assertThat(repo.imageIds()).isEmpty() assertThat(repo.imageIds()).isEmpty()
assertThat(repo.getContent("x")).isNull() assertThat(repo.jpegBytes("x")).isNull()
} }
@Test @Test
@@ -136,28 +156,19 @@ class ImageRepositoryTest {
fun rotate() { fun rotate() {
val repo = repo() val repo = repo()
repo.add(byteArrayOf(101, 102, 103), byteArrayOf(51), metadata1) repo.add(byteArrayOf(101, 102, 103), byteArrayOf(51), metadata1)
val id0 = repo.imageIds().last() assertThat(metadata1.manualRotation).isEqualTo(R0)
val baseId = id0.substring(0, id0.length - 4) assertThat(repo.pages().last().metadata).isEqualTo(metadata1)
val id = repo.pages().last().id
repo.rotate(id0, true) repo.rotate(id, true)
val id1 = repo.imageIds().last() assertThat(repo.pages().last().metadata).isEqualTo(metadata1.copy(manualRotation = R90))
assertThat(id1).isEqualTo("$baseId-90.jpg") repo.rotate(id, true)
assertThat(repo.pages().last().metadata).isEqualTo(metadata1.copy(manualRotation = R180))
repo.rotate(id1, true) repo.rotate(id, true)
val id2 = repo.imageIds().last() assertThat(repo.pages().last().metadata).isEqualTo(metadata1.copy(manualRotation = R270))
assertThat(id2).isEqualTo("$baseId-180.jpg") repo.rotate(id, true)
assertThat(repo.pages().last().metadata).isEqualTo(metadata1.copy(manualRotation = R0))
repo.rotate(id2, true) repo.rotate(id, false)
val id3 = repo.imageIds().last() assertThat(repo.pages().last().metadata).isEqualTo(metadata1.copy(manualRotation = R270))
assertThat(id3).isEqualTo("$baseId-270.jpg")
repo.rotate(id3, true)
val id4 = repo.imageIds().last()
assertThat(id4).isEqualTo("$baseId.jpg")
repo.rotate(id4, false)
val id5 = repo.imageIds().last()
assertThat(id5).isEqualTo("$baseId-270.jpg")
} }
@Test @Test
@@ -179,14 +190,16 @@ class ImageRepositoryTest {
fun metadata() { fun metadata() {
val quad = quad1.toSerializable() val quad = quad1.toSerializable()
assertThat(Page("f1", null, 0, true).toMetadata()).isNull() assertThat(PageV2("1", 0, 0, null,true).toMetadata()).isNull()
assertThat(Page("f1", quad, 0, null).toMetadata()).isNull() assertThat(PageV2("1", 0, 0, quad, null).toMetadata()).isNull()
listOf(true, false).forEach { isColored -> listOf(true, false).forEach { isColored ->
val metadata = Page("f1", quad, 0, isColored).toMetadata() val metadata = PageV2("1", 0, 0, quad, isColored).toMetadata()
assertThat(metadata).isNotNull() assertThat(metadata).isNotNull()
assertThat(metadata!!.isColored).isEqualTo(isColored) assertThat(metadata!!.isColored).isEqualTo(isColored)
} }
assertThat(PageV2("1", 42, 0, quad, true).toMetadata()).isNull()
} }
private fun scanDir(): File = File(getFilesDir(), SCAN_DIR_NAME) private fun scanDir(): File = File(getFilesDir(), SCAN_DIR_NAME)
@@ -194,4 +207,7 @@ class ImageRepositoryTest {
private fun jpegFiles(dir: File): Array<out File?>? private fun jpegFiles(dir: File): Array<out File?>?
= dir.listFiles { f -> f.name.endsWith(".jpg") } = dir.listFiles { f -> f.name.endsWith(".jpg") }
fun ImageRepository.imageIds(): PersistentList<String> =
pages().map { it.id }.toPersistentList()
} }

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.domain
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.fairscan.app.domain.Rotation.Companion.fromDegrees
import org.fairscan.app.domain.Rotation.R0
import org.fairscan.app.domain.Rotation.R180
import org.fairscan.app.domain.Rotation.R270
import org.fairscan.app.domain.Rotation.R90
import org.junit.Test
class RotationTest {
@Test
fun fromDegreesFunction() {
assertThat(fromDegrees(0)).isEqualTo(R0)
assertThat(fromDegrees(90)).isEqualTo(R90)
assertThat(fromDegrees(180)).isEqualTo(R180)
assertThat(fromDegrees(270)).isEqualTo(R270)
assertThat(fromDegrees(360)).isEqualTo(R0)
assertThat(fromDegrees(-90)).isEqualTo(R270)
assertThatThrownBy { fromDegrees(30) }.isInstanceOf(NoSuchElementException::class.java)
}
@Test
fun add() {
assertThat(R0.add(R90)).isEqualTo(R90)
assertThat(R90.add(R90)).isEqualTo(R180)
assertThat(R90.add(R180)).isEqualTo(R270)
assertThat(R180.add(R180)).isEqualTo(R0)
assertThat(R180.add(R270)).isEqualTo(R90)
}
}