From 3e6d2e4bcc67fa0911328740272a558803220850 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:40:01 +0200 Subject: [PATCH 01/16] Grayscale: fix perf issue to compute percentiles --- .../imageprocessing/PostProcessing.kt | 37 ++++++++++--------- .../org/fairscan/imageprocessing/Utils.kt | 4 +- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt index 2e8368e4..c2f1c231 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt @@ -175,6 +175,7 @@ fun multiScaleRetinexOnL(bgr: Mat): Mat { return result } +// Expects values in [0..255] fun percentileL(l: Mat, p: Double): Double { val hist = Mat() Imgproc.calcHist( @@ -199,6 +200,22 @@ fun percentileL(l: Mat, p: Double): Double { return 255.0 } +// Estimate 2 percentiles in `src` on a subsample rather than the full image. +private fun percentiles(src: Mat, low: Double, high: Double): Pair { + val sample = resizeForMaxPixels(src, 500_000.0, Imgproc.INTER_NEAREST) + val flat = Mat() + sample.reshape(1, 1).copyTo(flat) + val sorted = Mat() + Core.sort(flat, sorted, Core.SORT_ASCENDING) + val n = sorted.cols() + val pLow = sorted.get(0, (n * low).toInt().coerceIn(0, n - 1))[0] + val pHigh = sorted.get(0, (n * high).toInt().coerceIn(0, n - 1))[0] + flat.release() + sorted.release() + sample.release() + return pLow to pHigh +} + fun enhanceGrayscaleImage(img: Mat): Mat { // -- 1. Convert to grayscale -------- @@ -247,16 +264,7 @@ fun enhanceGrayscaleImage(img: Mat): Mat { // gray areas more visible. val retinexExp = Mat() Core.exp(retinex, retinexExp) - - val flat = Mat() - retinexExp.reshape(1, 1).copyTo(flat) - val sorted = Mat() - Core.sort(flat, sorted, Core.SORT_ASCENDING) - val n = sorted.cols() - val pLow = sorted.get(0, (n * 0.004).toInt())[0] - val pHigh = sorted.get(0, (n * 0.99).toInt())[0] - flat.release(); sorted.release() - + val (pLow, pHigh) = percentiles(retinexExp, 0.004, 0.99) val normalized = Mat() Core.subtract(retinexExp, Scalar(pLow), normalized) val scale = if (pHigh > pLow) 255.0 / (pHigh - pLow) else 1.0 @@ -291,14 +299,7 @@ fun enhanceGrayscaleImage(img: Mat): Mat { if (modeVal >= 254) { val grayF = Mat() gray.convertTo(grayF, CvType.CV_32F) - val grayFlat = Mat() - grayF.reshape(1, 1).copyTo(grayFlat) - val graySorted = Mat() - Core.sort(grayFlat, graySorted, Core.SORT_ASCENDING) - val gN = graySorted.cols() - val gLow = graySorted.get(0, (gN * 0.01).toInt())[0] - val gHigh = graySorted.get(0, (gN * 0.99).toInt())[0] - grayFlat.release(); graySorted.release() + val (gLow, gHigh) = percentiles(grayF, 0.01, 0.99) Core.subtract(grayF, Scalar(gLow), grayF) Core.multiply(grayF, Scalar(255.0 / (gHigh - gLow + 1e-6)), grayF) Core.min(grayF, Scalar(255.0), grayF) diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt index ec0d76fd..eca3f8ad 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt @@ -23,7 +23,7 @@ import org.opencv.imgproc.Imgproc import java.io.IOException import kotlin.math.sqrt -fun resizeForMaxPixels(img: Mat, maxPixels: Double): Mat { +fun resizeForMaxPixels(img: Mat, maxPixels: Double, interpolation: Int = Imgproc.INTER_AREA): Mat { val origPixels = img.width() * img.height() if (origPixels <= maxPixels) { return img.clone() @@ -31,7 +31,7 @@ fun resizeForMaxPixels(img: Mat, maxPixels: Double): Mat { val scale = sqrt(maxPixels / origPixels) val size = Size(img.width() * scale, img.height() * scale) val resizedImg = Mat() - Imgproc.resize(img, resizedImg, size, 0.0, 0.0, Imgproc.INTER_AREA) + Imgproc.resize(img, resizedImg, size, 0.0, 0.0, interpolation) return resizedImg } From 2694c8b6151ae9dccb5be62e2b906b3b65b0287e Mon Sep 17 00:00:00 2001 From: toolicious <291496779+toolicious@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:24:50 +0200 Subject: [PATCH 02/16] New feature: black and white color mode --- .../java/org/fairscan/app/MainActivity.kt | 2 +- .../java/org/fairscan/app/MainViewModel.kt | 8 +- .../fairscan/app/domain/ExportPreparation.kt | 88 ++++--- .../org/fairscan/app/domain/ExportQuality.kt | 29 +++ .../java/org/fairscan/app/domain/Image.kt | 3 + .../fairscan/app/platform/AndroidPdfWriter.kt | 52 +++- .../fairscan/app/platform/ImageProcessor.kt | 104 +++++++- .../app/ui/screens/document/DocumentScreen.kt | 72 +++--- .../app/ui/screens/export/ExportScreen.kt | 11 + .../app/ui/screens/export/ExportUiState.kt | 5 + .../app/ui/screens/export/ExportViewModel.kt | 7 + .../ui/screens/settings/SettingsRepository.kt | 2 + app/src/main/res/values-ar/strings.xml | 3 + app/src/main/res/values-cs/strings.xml | 3 + app/src/main/res/values-de/strings.xml | 3 + app/src/main/res/values-es/strings.xml | 3 + app/src/main/res/values-et/strings.xml | 3 + app/src/main/res/values-fr/strings.xml | 3 + app/src/main/res/values-gl/strings.xml | 3 + app/src/main/res/values-hu/strings.xml | 3 + app/src/main/res/values-in/strings.xml | 3 + app/src/main/res/values-it/strings.xml | 3 + app/src/main/res/values-ja/strings.xml | 3 + app/src/main/res/values-lv/strings.xml | 3 + app/src/main/res/values-pt-rBR/strings.xml | 3 + app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values-sv/strings.xml | 3 + app/src/main/res/values-tr/strings.xml | 3 + app/src/main/res/values-zh-rTW/strings.xml | 3 + app/src/main/res/values-zh/strings.xml | 3 + app/src/main/res/values/strings.xml | 10 + .../org/fairscan/app/data/FileManagerTest.kt | 2 +- .../app/domain/ExportPreparationTest.kt | 116 +++++++++ .../imageprocessing/BitonalPacking.kt | 52 ++++ .../imageprocessing/DocumentDetection.kt | 7 +- .../imageprocessing/PostProcessing.kt | 231 +++++++++++++++++- .../BinarizationParametersTest.kt | 64 +++++ .../imageprocessing/BitonalPackingTest.kt | 78 ++++++ 38 files changed, 913 insertions(+), 84 deletions(-) create mode 100644 app/src/test/java/org/fairscan/app/domain/ExportPreparationTest.kt create mode 100644 imageprocessing/src/main/java/org/fairscan/imageprocessing/BitonalPacking.kt create mode 100644 imageprocessing/src/test/java/org/fairscan/imageprocessing/BinarizationParametersTest.kt create mode 100644 imageprocessing/src/test/java/org/fairscan/imageprocessing/BitonalPackingTest.kt diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index b9034887..cb1ecf28 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -210,7 +210,7 @@ class MainActivity : ComponentActivity() { onExportClick = onExportClick, onDeleteImage = { viewModel.deleteCurrentPage() }, onRotateImage = { clockwise -> viewModel.rotateCurrentPage(clockwise) }, - onToggleColorMode = { viewModel.toggleCurrentPageColorMode() }, + onColorModeSelected = { viewModel.setCurrentPageColorMode(it) }, onCropClick = { viewModel.onClickOnCropButton() }, onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) }, onPageSelected = viewModel::onPageSelected diff --git a/app/src/main/java/org/fairscan/app/MainViewModel.kt b/app/src/main/java/org/fairscan/app/MainViewModel.kt index f8dc0e5d..4642eed4 100644 --- a/app/src/main/java/org/fairscan/app/MainViewModel.kt +++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt @@ -179,15 +179,13 @@ class MainViewModel(val imageRepository: ImageRepository, logger: Logger): ViewM } } - fun toggleCurrentPageColorMode() { + fun setCurrentPageColorMode(colorMode: ColorMode) { viewModelScope.launch { val currentPage = currentPage() - currentPage.colorMode?.let { + if (currentPage.colorMode != colorMode) { _loadingPageId.value = currentPage.id - val newColorMode = - if (it == ColorMode.COLOR) ColorMode.GRAYSCALE else ColorMode.COLOR val pages = withContext(Dispatchers.IO) { - imageRepository.setColorMode(currentPage.id, newColorMode) + imageRepository.setColorMode(currentPage.id, colorMode) imageRepository.pages() } _pages.value = pages diff --git a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt index dbd68428..f37b1d09 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt @@ -15,7 +15,10 @@ package org.fairscan.app.domain import org.fairscan.app.data.ImageRepository +import org.fairscan.app.platform.bitonalFromJpeg +import org.fairscan.app.platform.processedBitonalImage import org.fairscan.app.platform.processedImage +import org.fairscan.imageprocessing.ColorMode import org.fairscan.imageprocessing.EstimatedDimensions import org.fairscan.imageprocessing.estimateRealDimensions import org.fairscan.imageprocessing.resizeForMaxPixels @@ -26,9 +29,17 @@ fun interface JpegProvider { suspend fun get(): Jpeg } +fun interface BitonalProvider { + suspend fun get(): Bitonal +} + data class PageToExport( val page: ScanPage, val jpeg: JpegProvider, + // Set for black and white pages only. The PDF writer embeds it instead of the JPEG. + val bitonal: BitonalProvider? = null, + // What OCR reads when the embedded image is not a JPEG it can use. + val ocrJpeg: JpegProvider = jpeg, ) { fun estimatedDimensions(): EstimatedDimensions? { val metadata = page.metadata @@ -56,40 +67,61 @@ private fun EstimatedDimensions.applyRotation(rotation: Rotation): EstimatedDime suspend fun pagesToExport( imageRepository: ImageRepository, exportQuality: ExportQuality -): List { +): List = imageRepository.pages().map { page -> + if (page.colorMode == ColorMode.BLACK_AND_WHITE) + bitonalPageToExport(page, imageRepository, exportQuality) + else + standardPageToExport(page, imageRepository, exportQuality) +} - val pages = imageRepository.pages() - return when (exportQuality) { - ExportQuality.BALANCED -> pages.map { - PageToExport(it) { jpeg(it, imageRepository) } - } +private fun standardPageToExport( + page: ScanPage, + imageRepository: ImageRepository, + exportQuality: ExportQuality, +): PageToExport = when (exportQuality) { + ExportQuality.BALANCED -> PageToExport(page, jpeg = { jpeg(page, imageRepository) }) - ExportQuality.LOW -> pages.map { page -> - PageToExport(page) { - resizeJpegBytesForMaxPixels( - jpeg = jpeg(page, imageRepository), - maxPixels = exportQuality.maxPixels.toDouble(), - jpegQuality = exportQuality.jpegQuality - ) - } - } + ExportQuality.LOW -> PageToExport(page, jpeg = { + resizeJpegBytesForMaxPixels( + jpeg = jpeg(page, imageRepository), + maxPixels = exportQuality.maxPixels.toDouble(), + jpegQuality = exportQuality.jpegQuality + ) + }) - ExportQuality.HIGH -> pages.map { page -> - PageToExport(page) { - val source = imageRepository.source(page.id) - val metadata = page.metadata - val colorMode = page.colorMode - if (source != null && metadata != null && colorMode != null) { - val rotation = page.totalRotation() - processedImage(source, metadata, rotation, colorMode, exportQuality) - } - else - jpeg(page, imageRepository) - } + ExportQuality.HIGH -> PageToExport(page, jpeg = { + val source = imageRepository.source(page.id) + val metadata = page.metadata + val colorMode = page.colorMode + if (source != null && metadata != null && colorMode != null) { + val rotation = page.totalRotation() + processedImage(source, metadata, rotation, colorMode, exportQuality) } - } + else + jpeg(page, imageRepository) + }) } +// Only the PDF writer looks at the bitonal provider, JPEG export keeps the standard one. +private fun bitonalPageToExport( + page: ScanPage, + imageRepository: ImageRepository, + exportQuality: ExportQuality, +): PageToExport = standardPageToExport(page, imageRepository, exportQuality).copy( + // The stored page, not the one the export quality asks for: OCR does not benefit from the + // higher resolution, and rendering it again would double the work for the page. + ocrJpeg = { jpeg(page, imageRepository) }, + bitonal = { + val source = imageRepository.source(page.id) + val metadata = page.metadata + if (source != null && metadata != null) { + processedBitonalImage(source, metadata, page.totalRotation(), exportQuality) + } + else + bitonalFromJpeg(jpeg(page, imageRepository)) + }, +) + private suspend fun jpeg(page: ScanPage, imageRepository: ImageRepository): Jpeg { val key = page.key() return imageRepository.jpegBytes(key) diff --git a/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt b/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt index 0a6c5cdb..dc49e8a1 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt @@ -15,25 +15,54 @@ package org.fairscan.app.domain import org.fairscan.app.R +import org.fairscan.imageprocessing.EstimatedDimensions +import org.fairscan.imageprocessing.PaperFormats +// Black and white is sized by target resolution rather than by a pixel count: it is compressed +// losslessly, so its size follows the number of black-white transitions rather than the pixels, +// and a page only looks sharp in one bit per pixel at a high enough resolution. A pixel budget +// would also give a receipt a very different resolution than an A4 page. enum class ExportQuality( val jpegQuality: Int, val maxPixels: Long, + val bitonalDpi: Int, val labelResource: Int ) { LOW( jpegQuality = 60, maxPixels = 1_000_000, + bitonalDpi = 150, R.string.export_quality_low, ), BALANCED( jpegQuality = 75, maxPixels = 2_000_000, + bitonalDpi = 300, R.string.export_quality_balanced, ), HIGH( jpegQuality = 80, maxPixels = 4_000_000, + bitonalDpi = 450, R.string.export_quality_high, ) } + +// 450 dpi on A4 is 19.6 megapixels, which is the most the highest setting ever asks for. +private const val MAX_BITONAL_PIXELS = 20_000_000L + +// Pixels needed to reach bitonalDpi on this page. Falls back to A4 when the physical size could +// not be estimated, the same assumption the PDF writer makes for the page box. +fun ExportQuality.bitonalMaxPixels(dimensions: EstimatedDimensions): Long { + val widthMm: Double + val heightMm: Double + if (dimensions is EstimatedDimensions.Physical) { + widthMm = dimensions.widthMm + heightMm = dimensions.heightMm + } else { + widthMm = PaperFormats.A4.widthMm + heightMm = PaperFormats.A4.heightMm + } + val pixels = (widthMm / 25.4 * bitonalDpi) * (heightMm / 25.4 * bitonalDpi) + return pixels.toLong().coerceIn(maxPixels, MAX_BITONAL_PIXELS) +} diff --git a/app/src/main/java/org/fairscan/app/domain/Image.kt b/app/src/main/java/org/fairscan/app/domain/Image.kt index 9a1bd289..2c3a9695 100644 --- a/app/src/main/java/org/fairscan/app/domain/Image.kt +++ b/app/src/main/java/org/fairscan/app/domain/Image.kt @@ -29,6 +29,9 @@ class Jpeg(val bytes: ByteArray) { fun toMat() : Mat = decodeJpeg(bytes) } +// One bit per pixel, MSB first, rows padded to whole bytes, set bit means black. +class Bitonal(val width: Int, val height: Int, val bits: ByteArray) + interface ImageLoader { suspend fun load(uri: Uri): Bitmap } diff --git a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt index 04ab72f2..29c36a63 100644 --- a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt +++ b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt @@ -15,10 +15,12 @@ package org.fairscan.app.platform import android.content.res.AssetManager +import android.graphics.Bitmap import android.util.Log import com.tom_roush.pdfbox.cos.COSArray import com.tom_roush.pdfbox.cos.COSDictionary import com.tom_roush.pdfbox.cos.COSName +import com.tom_roush.pdfbox.filter.FilterFactory import com.tom_roush.pdfbox.pdmodel.PDDocument import com.tom_roush.pdfbox.pdmodel.PDPage import com.tom_roush.pdfbox.pdmodel.PDPageContentStream @@ -27,14 +29,19 @@ import com.tom_roush.pdfbox.pdmodel.PDResources import com.tom_roush.pdfbox.pdmodel.common.PDRectangle import com.tom_roush.pdfbox.pdmodel.common.PDStream import com.tom_roush.pdfbox.pdmodel.font.PDFontDescriptor +import com.tom_roush.pdfbox.pdmodel.graphics.color.PDDeviceGray import com.tom_roush.pdfbox.pdmodel.graphics.image.JPEGFactory +import com.tom_roush.pdfbox.pdmodel.graphics.image.PDImageXObject import org.fairscan.app.BuildConfig import org.fairscan.app.data.PdfWriter +import org.fairscan.app.domain.Bitonal import org.fairscan.app.domain.OcrService import org.fairscan.app.domain.PageToExport import org.fairscan.imageprocessing.EstimatedDimensions import org.fairscan.imageprocessing.OcrTextBox import org.fairscan.imageprocessing.PaperFormats +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.io.OutputStream import java.util.Calendar import java.util.Locale @@ -47,14 +54,22 @@ class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : P disableOcr: Boolean, onProgress: (Int) -> Unit, ) { + // Without a language, runOcr returns nothing, and decoding a page for it is not free: + // at the highest quality it renders the page a second time. + val ocrEnabled = !disableOcr && ocrService.languageString().isNotEmpty() val doc = PDDocument() doc.documentInformation.creationDate = Calendar.getInstance() doc.documentInformation.creator = "FairScan ${BuildConfig.VERSION_NAME}" doc.use { document -> val ocrDocument = OcrDocument(document, assets) for ((index, page) in pages.withIndex()) { - val jpeg = page.jpeg.get() - val image = JPEGFactory.createFromByteArray(document, jpeg.bytes) + val bitonal = page.bitonal?.get() + val embedded = if (bitonal == null) page.jpeg.get() else null + val ocrJpeg = page.ocrJpeg + val image = if (bitonal != null) + createCcittG4Image(document, bitonal) + else + JPEGFactory.createFromByteArray(document, requireNotNull(embedded).bytes) // PDF has 72 points (units) per inch, 1 inch = 25.4 mm val pointsPerMm = 72f / 25.4f @@ -82,9 +97,11 @@ class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : P val contentStream = PDPageContentStream(document, page, AppendMode.OVERWRITE, false) contentStream.drawImage(image, 0f, 0f, widthPoints, heightPoints) - if (!disableOcr) { + if (ocrEnabled) { + var bitmap: Bitmap? = null try { - val bitmap = jpeg.toBitmap() + // For every mode but black and white this is the image just embedded. + bitmap = (embedded ?: ocrJpeg.get()).toBitmap() val ocrTextBoxes = ocrService.runOcr(bitmap) val pdfPageDimensions = PageDimensions( bitmap.width, @@ -95,6 +112,8 @@ class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : P ocrDocument.addPage(page, ocrTextBoxes, pdfPageDimensions) } catch (e: Exception) { Log.e("AndroidPdfWriter", "Failed to run OCR on page $index", e) + } finally { + bitmap?.recycle() } } contentStream.close() @@ -107,6 +126,31 @@ class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : P } } +// Same sequence of calls as PDFBox's own CCITTFactory, which is not usable here because it +// insists on an ALPHA_8 bitmap. The filter reads a set bit as black, so /BlackIs1 is left out. +private fun createCcittG4Image(document: PDDocument, bitonal: Bitonal): PDImageXObject { + val encoded = ByteArrayOutputStream() + val decodeParms = COSDictionary().apply { + setInt(COSName.COLUMNS, bitonal.width) + setInt(COSName.ROWS, bitonal.height) + } + FilterFactory.INSTANCE.getFilter(COSName.CCITTFAX_DECODE) + .encode(ByteArrayInputStream(bitonal.bits), encoded, decodeParms, 0) + + val image = PDImageXObject( + document, + ByteArrayInputStream(encoded.toByteArray()), + COSName.CCITTFAX_DECODE, + bitonal.width, + bitonal.height, + 1, + PDDeviceGray.INSTANCE, + ) + decodeParms.setInt(COSName.K, -1) + image.cosObject.setItem(COSName.DECODE_PARMS, decodeParms) + return image +} + fun constrainToMaxFormat(widthMm: Double, heightMm: Double): Pair { val maxDim = 297.0 // A4 height val minDim = 215.9 // Letter width diff --git a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt index e96ff0ec..1f554c40 100644 --- a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt +++ b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt @@ -19,8 +19,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import org.fairscan.app.data.ImageTransformations +import org.fairscan.app.domain.Bitonal import org.fairscan.app.domain.CapturedPage import org.fairscan.app.domain.ExportQuality +import org.fairscan.app.domain.bitonalMaxPixels import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.Rotation @@ -33,7 +35,9 @@ import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.autoColorMode import org.fairscan.imageprocessing.createQuad +import org.fairscan.imageprocessing.estimateRealDimensions import org.fairscan.imageprocessing.extractDocument +import org.fairscan.imageprocessing.packBitsMsbFirst import org.fairscan.imageprocessing.resizeForMaxPixels import org.fairscan.imageprocessing.rotate import org.fairscan.imageprocessing.scaledTo @@ -108,15 +112,106 @@ fun processedImage( try { sourceMat = source.toMat() val quad = metadata.normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height()) - page = extractDocument(sourceMat, quad, rotationDegrees, colorMode, exportQuality.maxPixels, + page = renderPage(sourceMat, quad, rotationDegrees, colorMode, exportQuality, metadata.opticalMeasures) - return Jpeg.fromMat(page, exportQuality.jpegQuality) + return Jpeg.fromMat(page, storedJpegQuality(colorMode, exportQuality)) } finally { sourceMat?.release() page?.release() } } +// A scaled down bitonal page is nothing but hard edges, which is exactly where JPEG rings. +private const val BITONAL_JPEG_QUALITY = 92 + +private fun storedJpegQuality(colorMode: ColorMode, exportQuality: ExportQuality) = + if (colorMode == ColorMode.BLACK_AND_WHITE) BITONAL_JPEG_QUALITY + else exportQuality.jpegQuality + +private fun bitonalMaxPixels( + source: Mat, + quad: Quad, + exportQuality: ExportQuality, + opticalMeasures: OpticalMeasures?, +): Long = exportQuality.bitonalMaxPixels( + estimateRealDimensions(quad, source.cols(), source.rows(), opticalMeasures) + .snapToStandardFormat() +) + +// Black and white is binarized at the export resolution and scaled down afterwards: at preview +// resolution a speck of glare merges with a glyph and can no longer be told apart from it. +private fun renderPage( + source: Mat, + quad: Quad, + rotationDegrees: Int, + colorMode: ColorMode, + exportQuality: ExportQuality, + opticalMeasures: OpticalMeasures?, +): Mat { + if (colorMode != ColorMode.BLACK_AND_WHITE) { + return extractDocument(source, quad, rotationDegrees, colorMode, + exportQuality.maxPixels, opticalMeasures) + } + val full = extractDocument(source, quad, rotationDegrees, colorMode, + bitonalMaxPixels(source, quad, exportQuality, opticalMeasures), opticalMeasures, + allowUpscaling = true) + return try { + resizeForMaxPixels(full, exportQuality.maxPixels.toDouble()) + } finally { + full.release() + } +} + +// Rebuilt from the original capture, because the stored page is a JPEG and would carry its +// compression artifacts into the PDF. +fun processedBitonalImage( + source: Jpeg, + metadata: PageMetadata, + rotation: Rotation, + exportQuality: ExportQuality, +): Bitonal { + var sourceMat: Mat? = null + var page: Mat? = null + var gray: Mat? = null + try { + sourceMat = source.toMat() + val quad = metadata.normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height()) + page = extractDocument(sourceMat, quad, rotation.degrees, ColorMode.BLACK_AND_WHITE, + bitonalMaxPixels(sourceMat, quad, exportQuality, metadata.opticalMeasures), + metadata.opticalMeasures, allowUpscaling = true) + gray = Mat() + Imgproc.cvtColor(page, gray, Imgproc.COLOR_BGR2GRAY) + return packBitonal(gray) + } finally { + sourceMat?.release() + page?.release() + gray?.release() + } +} + +// Fallback for pages whose original capture is no longer available. +fun bitonalFromJpeg(jpeg: Jpeg): Bitonal { + var mat: Mat? = null + var gray: Mat? = null + try { + mat = jpeg.toMat() + gray = Mat() + Imgproc.cvtColor(mat, gray, Imgproc.COLOR_BGR2GRAY) + return packBitonal(gray) + } finally { + mat?.release() + gray?.release() + } +} + +private fun packBitonal(gray: Mat): Bitonal { + val width = gray.width() + val height = gray.height() + val pixels = ByteArray(width * height) + gray.get(0, 0, pixels) + return Bitonal(width, height, packBitsMsbFirst(pixels, width, height)) +} + fun extractDocumentFromBitmap( source: Bitmap, quadInMask: Quad?, @@ -150,11 +245,10 @@ fun extractDocumentFromBitmap( normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1) autoColorMode = autoColorMode(bgr, mask, quad) colorMode = defaultColorMode.colorMode ?: autoColorMode - page = extractDocument(bgr, quad, rotationDegrees, colorMode, exportQuality.maxPixels, - opticalMeasures) + page = renderPage(bgr, quad, rotationDegrees, colorMode, exportQuality, opticalMeasures) } - val pageJpeg = Jpeg.fromMat(page, exportQuality.jpegQuality) + val pageJpeg = Jpeg.fromMat(page, storedJpegQuality(colorMode, exportQuality)) bgr.release() page.release() diff --git a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt index b10c2b18..2bdab56a 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Contrast import androidx.compose.material.icons.filled.Crop import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.FontDownload import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.RotateLeft import androidx.compose.material.icons.filled.RotateRight @@ -63,6 +64,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource @@ -87,6 +89,7 @@ import org.fairscan.app.ui.fakeDocument import org.fairscan.app.ui.fakeImage import org.fairscan.app.ui.theme.FairScanTheme import org.fairscan.imageprocessing.ColorMode +import org.fairscan.imageprocessing.ColorMode.BLACK_AND_WHITE import org.fairscan.imageprocessing.ColorMode.COLOR import org.fairscan.imageprocessing.ColorMode.GRAYSCALE @@ -98,7 +101,7 @@ fun DocumentScreen( onExportClick: () -> Unit, onDeleteImage: () -> Unit, onRotateImage: (Boolean) -> Unit, - onToggleColorMode: () -> Unit, + onColorModeSelected: (ColorMode) -> Unit, onCropClick: () -> Unit, onPageReorder: (String, Int) -> Unit, onPageSelected: (Int) -> Unit, @@ -132,7 +135,7 @@ fun DocumentScreen( uiState, { showDeletePageDialog.value = true }, onRotateImage, - onToggleColorMode, + onColorModeSelected, onCropClick, modifier ) @@ -151,7 +154,7 @@ private fun DocumentPreview( uiState: DocumentUiState, onDeleteImage: () -> Unit, onRotateImage: (Boolean) -> Unit, - onToggleColorMode: () -> Unit, + onColorModeSelected: (ColorMode) -> Unit, onCropClick: () -> Unit, modifier: Modifier, ) { @@ -199,7 +202,7 @@ private fun DocumentPreview( } EditButtons( uiState, - onToggleColorMode, + onColorModeSelected, onCropClick, modifier = Modifier.align(Alignment.BottomStart) ) @@ -256,7 +259,7 @@ fun RotationButtons( @Composable fun EditButtons( uiState: DocumentUiState, - onToggleColorMode: () -> Unit, + onColorModeSelected: (ColorMode) -> Unit, onCropClick: () -> Unit, modifier: Modifier ) { @@ -264,7 +267,7 @@ fun EditButtons( uiState.currentPage?.colorMode?.let { ColorModeButton( currentColorMode = it, - onToggle = { onToggleColorMode() }, + onColorModeSelected = onColorModeSelected, ) } Spacer(Modifier.width(8.dp)) @@ -281,7 +284,7 @@ fun EditButtons( @Composable fun ColorModeButton( currentColorMode: ColorMode, - onToggle: () -> Unit, + onColorModeSelected: (ColorMode) -> Unit, modifier: Modifier = Modifier ) { var expanded by remember { mutableStateOf(false) } @@ -296,36 +299,39 @@ fun ColorModeButton( expanded = expanded, onDismissRequest = { expanded = false }, ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.color_mode_color)) }, - leadingIcon = { Icon(Icons.Default.Palette, contentDescription = null) }, - onClick = { - if (currentColorMode != COLOR) onToggle() - expanded = false - }, - trailingIcon = { - if (currentColorMode == COLOR) { - Icon(Icons.Default.Check, contentDescription = null) + ColorMode.entries.forEach { colorMode -> + DropdownMenuItem( + text = { Text(stringResource(colorMode.labelResource)) }, + leadingIcon = { Icon(colorMode.icon, contentDescription = null) }, + onClick = { + onColorModeSelected(colorMode) + expanded = false + }, + trailingIcon = { + if (currentColorMode == colorMode) { + Icon(Icons.Default.Check, contentDescription = null) + } } - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.color_mode_grayscale)) }, - leadingIcon = { Icon(Icons.Default.Contrast, contentDescription = null) }, - onClick = { - if (currentColorMode != GRAYSCALE) onToggle() - expanded = false - }, - trailingIcon = { - if (currentColorMode == GRAYSCALE) { - Icon(Icons.Default.Check, contentDescription = null) - } - } - ) + ) + } } } } +private val ColorMode.labelResource: Int + get() = when (this) { + COLOR -> R.string.color_mode_color + GRAYSCALE -> R.string.color_mode_grayscale + BLACK_AND_WHITE -> R.string.color_mode_black_and_white + } + +private val ColorMode.icon: ImageVector + get() = when (this) { + COLOR -> Icons.Default.Palette + GRAYSCALE -> Icons.Default.Contrast + BLACK_AND_WHITE -> Icons.Default.FontDownload + } + @Composable private fun BottomBar( onExportClick: () -> Unit, @@ -378,7 +384,7 @@ fun DocumentScreenPreview() { onExportClick = {}, onDeleteImage = { }, onRotateImage = { _ -> }, - onToggleColorMode = { }, + onColorModeSelected = { }, onCropClick = { }, onPageReorder = { _,_ -> }, onPageSelected = { _ -> }, diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt index 945330aa..5e2c6387 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt @@ -300,6 +300,17 @@ private fun PdfInfos( ) } + uiState.bitonalAsJpeg?.let { pages -> + val hint = stringResource(R.string.black_and_white_jpeg_hint) + val affected = if (pages.count == pages.total) "" else " " + stringResource( + R.string.black_and_white_jpeg_pages, pages.count, pageCountText(pages.total)) + Text( + hint + affected, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + if (uiState.isGenerating) { Text( text = stringResource(R.string.creating_export), diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt index 06897388..b14365fc 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt @@ -23,6 +23,9 @@ data class ExportUiState( val isGenerating: Boolean = false, val progress: ExportProgress? = null, val ocrActivation: Boolean? = null, + // Set only when exporting to JPEG, which cannot store one bit per pixel, and at least one + // page is black and white and therefore loses its size advantage. + val bitonalAsJpeg: BitonalPages? = null, val isSaving: Boolean = false, val result: ExportResult? = null, val savedBundle: SavedBundle? = null, @@ -32,6 +35,8 @@ data class ExportUiState( val hasSavedOrShared get() = savedBundle != null || hasShared } +data class BitonalPages(val count: Int, val total: Int) + data class ExportProgress( val completedPages: Int, val totalPages: Int, diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt index c2a5bd60..17a18ab8 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt @@ -49,6 +49,7 @@ import org.fairscan.app.domain.PageViewKey import org.fairscan.app.domain.pagesToExport import org.fairscan.app.ui.screens.settings.ExportFormat import org.fairscan.app.ui.screens.settings.ExportFormat.PDF +import org.fairscan.imageprocessing.ColorMode import java.io.File import java.io.FileInputStream import java.io.IOException @@ -160,6 +161,11 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit preparationJob = launch { val ocrActivation = if (exportFormat == PDF) ocrLanguageString.isNotEmpty() else null + val bitonalCount = currentPageKeys.count { it.colorMode == ColorMode.BLACK_AND_WHITE } + val bitonalAsJpeg = + if (exportFormat == ExportFormat.JPEG && bitonalCount > 0) + BitonalPages(bitonalCount, pageCount) + else null _uiState.update { ExportUiState( filename = it.filename, @@ -167,6 +173,7 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit isGenerating = true, progress = ExportProgress(0, pageCount), ocrActivation = ocrActivation, + bitonalAsJpeg = bitonalAsJpeg, ) } val onProgress: (Int) -> Unit = { completedPages -> diff --git a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt index 28d7f565..9f62e7ef 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt @@ -43,6 +43,7 @@ class SettingsRepository( "AUTO" -> DefaultColorMode.AUTO "COLOR" -> DefaultColorMode.COLOR "GRAYSCALE" -> DefaultColorMode.GRAYSCALE + "BLACK_AND_WHITE" -> DefaultColorMode.BLACK_AND_WHITE else -> DefaultColorMode.AUTO } } @@ -108,6 +109,7 @@ enum class DefaultColorMode(val colorMode: ColorMode?, val labelResource: Int) { AUTO(null, R.string.color_mode_auto), COLOR(ColorMode.COLOR, R.string.color_mode_color), GRAYSCALE(ColorMode.GRAYSCALE, R.string.color_mode_grayscale), + BLACK_AND_WHITE(ColorMode.BLACK_AND_WHITE, R.string.color_mode_black_and_white), } enum class ExportFormat(val mimeType: String) { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index e84878bd..e626911e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -4,6 +4,8 @@ تطبيق بسيط ومحترم لمسح مستنداتك ضوئيًا. تطبيق ارجع + استخدم PDF كصيغة إخراج للحفاظ على صفحات الأبيض والأسود صغيرة وواضحة. + (%1$d/%2$s) رُفض إذن الوصول إلى الكاميرا يتطلب التطبيق الوصول إلى الكاميرا لمسح المستندات ضوئيًا. تُخزن الصور الملتقطة على هذا الجهاز فقط، وسيتم حذفها عند إغلاق المسح الحالي. ألغِ @@ -12,6 +14,7 @@ إغلاق فلتر تلقائي + أبيض وأسود ألوان الفلتر الافتراضي تدرج الرمادي diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index b615c981..ea0c3a4d 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -4,6 +4,8 @@ Jednoduchá a respektující aplikace pro skenování vašich dokumentů Použít Zpět + Použijte PDF jako výstupní formát, aby černobílé stránky zůstaly malé a ostré. + (%1$d/%2$s) Byl odepřen přístup k fotoaparátu Aby bylo možné skenovat dokumenty, tato aplikace potřebuje přístup k fotoaparátu. Nasnímané obrázky jsou ukládány pouze do tohoto zařízení a budou smazány, když ukončíte aktuální sken. Zrušit @@ -12,6 +14,7 @@ Zavřít Filtr Automaticky + Černobílá Barva Výchozí filtr Odstíny šedi diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 430ad623..2f02ca00 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -4,6 +4,8 @@ Eine einfache und respektvolle App zum Scannen Ihrer Dokumente. Anwenden Zurück + Verwenden Sie PDF als Ausgabeformat, damit Schwarzweiß-Seiten kompakt und scharf bleiben. + (%1$d/%2$s) Kamerazugriff wurde verweigert Die App benötigt Zugriff auf die Kamera, um Dokumente zu scannen. Aufgenommene Bilder werden nur auf diesem Gerät gespeichert und beim Schließen des aktuellen Scans gelöscht. Abbrechen @@ -12,6 +14,7 @@ Schließen Filter Automatisch + Schwarzweiß Farbe Standardfilter Graustufen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 841fb1a3..0a5672c5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -4,6 +4,8 @@ Una aplicación sencilla y respetuosa para escanear tus documentos. Aplicar Atrás + Use PDF como formato de salida para que las páginas en blanco y negro se mantengan compactas y nítidas. + (%1$d/%2$s) Permiso de cámara denegado La aplicación necesita acceso a la cámara para escanear documentos. Las imágenes capturadas se guardan solo en este dispositivo y se eliminarán cuando cierres el escaneo actual. Cancelar @@ -12,6 +14,7 @@ Cerrar Filtro Automático + Blanco y negro Color Filtro predeterminado Escala de grises diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 9dc57f85..80ac9787 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -5,6 +5,8 @@ Lihtsaltkasutatav ja viisakas rakendus dokumentide skaneerimiseks. Rakenda Tagasi + Kasutage väljundvorminguna PDF-i, et mustvalged lehed jääksid kompaktseks ja teravaks. + (%1$d/%2$s) Kaamera kasutamise õigused on puudu Et dokumentide skaneerimine toimiks, vajab see rakendus õigust kasutada kaamerat. Pildihõive käigus jäädvustatud pildid salvestuvad vaid siin selles seadmes ja kustutatakse töösoleva skaneerimise lõppedes. Katkesta @@ -13,6 +15,7 @@ Sulge Filtreeri Automaatne + Mustvalge Värvid Vaikimisi filter Halltoonides diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8330f8ac..aba5c913 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -5,6 +5,8 @@ Une application simple et respectueuse pour scanner vos documents. Appliquer Retour + Utilisez le PDF comme format de sortie pour garder les pages en noir et blanc compactes et nettes. + (%1$d/%2$s) L\'autorisation d\'accès à la caméra a été refusée L’application a besoin d’accéder à l’appareil photo pour scanner des documents. Les images capturées sont enregistrées uniquement sur cet appareil et seront supprimées lorsque vous fermerez le scan en cours. Annuler @@ -13,6 +15,7 @@ Fermer Filtre Automatique + Noir et blanc Couleur Filtre par défaut Niveaux de gris diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 4efbec77..c365c79e 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -4,6 +4,8 @@ Unha aplicación sinxela e respectuosa para escanear os teus documentos. Aplicar Atrás + Use PDF como formato de saída para que as páxinas en branco e negro se manteñan compactas e nítidas. + (%1$d/%2$s) Permiso da cámara denegado A aplicación precisa acceso á cámara para escanear documentos. As imaxes capturadas gárdanse só neste dispositivo e eliminaranse ao pechar o escaneo actual. Cancelar @@ -12,6 +14,7 @@ Pechar Filtro Automático + Branco e negro Cor Filtro predeterminado Escala de grises diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index c105a39c..00b472bc 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -4,6 +4,8 @@ Egy egyszerű, a felhasználót tiszteletben tartó dokumentum szkenner alkalmazás. Alkalmaz Vissza + Használjon PDF kimeneti formátumot, hogy a fekete-fehér oldalak tömörek és élesek maradjanak. + (%1$d/%2$s) Kamerahozzáférés megtagadva Az alkalmazásnak a dokumentumok beolvasásához kamerahozzáférésre van szüksége. A rögzített képek csak ezen az eszközön kerülnek tárolásra, és törlődnek, amikor bezárod a folyamatban lévő szkennelést. Mégse @@ -12,6 +14,7 @@ Bezárás Színszűrő Automatikus + Fekete-fehér Színes Alapértelmezett színszűrő Szürkeárnyalatos diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 53faef4d..ca3899c4 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -4,6 +4,8 @@ Aplikasi sederhana dan aman untuk memindai dokumen Anda Terapkan Kembali + Gunakan PDF sebagai format keluaran agar halaman hitam putih tetap ringkas dan tajam. + (%1$d/%2$s) Izin penggunaan kamera ditolak Aplikasi ini memerlukan akses kamera untuk memindai dokumen. Gambar yang diambil hanya disimpan di perangkat ini dan akan dihapus saat Anda menutup pemindaian saat ini. Batalkan @@ -12,6 +14,7 @@ Tutup Filter Otomatis + Hitam putih Warna Filter bawaan Abu-abu diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2a865033..7c20bb75 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -4,6 +4,8 @@ Un\'app semplice e rispettosa per scansionare i tuoi documenti. Applica Indietro + Usare il PDF come formato di output per mantenere le pagine in bianco e nero compatte e nitide. + (%1$d/%2$s) Autorizzazione fotocamera negata L\'app richiede l\'accesso alla fotocamera per scansionare documenti. Le immagini catturate sono salvate solo in questo dispositivo e verranno eliminate quando chiudi la scansione corrente. Annulla @@ -12,6 +14,7 @@ Chiudi Filtro Automatico + Bianco e nero Colore Filtro predefinito Scala di grigi diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index ab5828aa..ffd77a1f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -4,6 +4,8 @@ シンプルで安心なドキュメントスキャナーです。 適用 戻る + 白黒ページを小さく鮮明に保つには、出力形式に PDF を使用してください。 + (%1$d/%2$s) カメラの権限が拒否されました ドキュメントをスキャンするためにカメラへのアクセスが必要です。撮影した画像はこのデバイスにのみ保存され、現在のスキャンを閉じると削除されます。 キャンセル @@ -12,6 +14,7 @@ 閉じる フィルター 自動 + 白黒 カラー デフォルトフィルター グレースケール diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 0094cb99..8b77cd5e 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -1,5 +1,8 @@ + Izmantojiet PDF kā izvades formātu, lai melnbaltās lapas paliktu kompaktas un asas. + (%1$d/%2$s) + Melnbalts Sabalansēta Aizvērt Saglabāt diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 20c1dbf6..0c3e60d9 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -4,6 +4,8 @@ Um aplicativo simples e respeitoso para digitalizar seus documentos. Aplicar Voltar + Use PDF como formato de saída para manter as páginas em preto e branco compactas e nítidas. + (%1$d/%2$s) Permissão da câmera negada O aplicativo precisa de acesso à câmera para digitalizar documentos. As imagens capturadas são armazenadas apenas neste dispositivo e serão excluídas quando você fechar a digitalização atual. Cancelar @@ -12,6 +14,7 @@ Fechar Filtro Automático + Preto e branco Cor Filtro padrão Escala de cinza diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index e1e63f41..f316f744 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -4,6 +4,8 @@ Простое и открытое приложение для сканирования Ваших документов. Применить Назад + Используйте PDF в качестве формата вывода, чтобы чёрно-белые страницы оставались компактными и чёткими. + (%1$d/%2$s) В доступе к камере отказано Для сканирования документов приложению требуется доступ к камере. Отснятые изображения хранятся только на данном устройстве и удаляются по окончании текущего сканирования. Отмена @@ -12,6 +14,7 @@ Закрыть Фильтр Автоматически + Чёрно-белый Цвет Фильтр по умолчанию Оттенки серого diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 0946eebe..4cc40cc6 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -4,6 +4,8 @@ En enkel och respektfull app för att skanna dina dokument. Använd Tillbaka + Använd PDF som utdataformat för att hålla svartvita sidor kompakta och skarpa. + (%1$d/%2$s) Kamerabehörighet nekades Appen behöver åtkomst till kameran för att kunna skanna dokument. Tagna bilder lagras endast på den här enheten och raderas när du stänger den aktuella skanningen. Avbryt @@ -12,6 +14,7 @@ Stäng Filter Automatiskt + Svartvitt Färg Standardfilter Gråskala diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 2ce5b445..b7e4b59c 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -4,6 +4,8 @@ Belgelerinizi taramak için basit ve saygılı bir uygulama. Uygula Geri + Siyah beyaz sayfaların küçük ve net kalması için çıktı biçimi olarak PDF kullanın. + (%1$d/%2$s) Kamera izni reddedildi Uygulama, belgeleri taramak için kamera erişimi gerektirir. Yakalanan görüntüler yalnızca bu cihazda saklanır ve geçerli taramayı kapattığınızda silinir. İptal @@ -12,6 +14,7 @@ Kapat Filtre Otomatik + Siyah beyaz Renkli Varsayılan filtre Gri tonlama diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 80c9d8f7..de2cc4db 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -4,6 +4,8 @@ 一個簡單且尊重隱私的文件掃描應用程式。 套用 返回 + 請使用 PDF 作為輸出格式,讓黑白頁面保持小巧清晰。 + (%1$d/%2$s) 已拒絕相機權限 此應用程式需要相機存取權限才能掃描文件。擷取的影像僅儲存在此裝置上,並會在您關閉目前掃描時刪除。 取消 @@ -12,6 +14,7 @@ 關閉 濾鏡 自動 + 黑白 彩色 預設濾鏡 灰階 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 56dd1c35..01ccd2e1 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -4,6 +4,8 @@ 一个简单且克制的文档扫描应用 应用 返回 + 请使用 PDF 作为输出格式,让黑白页面保持小巧清晰。 + (%1$d/%2$s) 相机权限被拒绝 应用请求相机权限访问扫描文档。捕获的图像仅存储在此设备上,并且在关闭当前扫描时将被删除。 取消 @@ -12,6 +14,7 @@ 关闭 滤镜 自动 + 黑白 彩色 默认滤镜 灰度 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 42c74164..c8d3c490 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,13 @@ Apply Back + + Use PDF as the output format to keep black & white pages compact and crisp. + + (%1$d/%2$s) Camera permission was denied @@ -32,6 +39,9 @@ Filter Automatic + + Black & white Color diff --git a/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt b/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt index 3443f5de..6ed2a3a5 100644 --- a/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt +++ b/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt @@ -87,7 +87,7 @@ class FileManagerTest { } val manager = FileManager(pdfDir, externalDir, fakePdfWriter) val pages = listOf(byteArrayOf(0x01, 0x02), byteArrayOf(0x11)) - .map { PageToExport(ScanPage("1", Rotation.R0, null, 1, null)) { Jpeg(it) } } + .map { PageToExport(ScanPage("1", Rotation.R0, null, 1, null), jpeg = { Jpeg(it) }) } val pdf = manager.generatePdf(pages, true) {} assertThat(pdf.pageCount).isEqualTo(2) assertThat(pdf.sizeInBytes).isEqualTo(3) diff --git a/app/src/test/java/org/fairscan/app/domain/ExportPreparationTest.kt b/app/src/test/java/org/fairscan/app/domain/ExportPreparationTest.kt new file mode 100644 index 00000000..3e2a9a00 --- /dev/null +++ b/app/src/test/java/org/fairscan/app/domain/ExportPreparationTest.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * 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 . + */ +package org.fairscan.app.domain + +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.within +import org.fairscan.app.data.ImageRepository +import org.fairscan.app.data.ImageTransformations +import org.fairscan.app.data.Logger +import org.fairscan.imageprocessing.CameraIntrinsics +import org.fairscan.imageprocessing.ColorMode +import org.fairscan.imageprocessing.ColorMode.BLACK_AND_WHITE +import org.fairscan.imageprocessing.ColorMode.COLOR +import org.fairscan.imageprocessing.ColorMode.GRAYSCALE +import org.fairscan.imageprocessing.EstimatedDimensions +import org.fairscan.imageprocessing.ImageSize +import org.fairscan.imageprocessing.OpticalMeasures +import org.fairscan.imageprocessing.Point +import org.fairscan.imageprocessing.Quad +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class ExportPreparationTest { + + @get:Rule + var folder: TemporaryFolder = TemporaryFolder() + + private val testScope = TestScope() + + private val quad = Quad(Point(.01, .02), Point(.1, .03), Point(.11, .12), Point(.03, .09)) + private val metadata = PageMetadata( + quad, Rotation.R0, COLOR, + ImageSize(1600, 1200), + OpticalMeasures(CameraIntrinsics(42.0f, 43.0f), 44.0f), + ) + + private suspend fun repoWithPage(colorMode: ColorMode): ImageRepository { + val transformations = object : ImageTransformations { + override fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg = input + override fun resizeToThumbnail(input: Jpeg): Jpeg = input + override fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg = + throw UnsupportedOperationException() + } + val repo = ImageRepository( + folder.newFolder(), transformations, testScope, Logger { _, _, _ -> }) + repo.add(Jpeg(byteArrayOf(1, 2, 3)), Jpeg(byteArrayOf(4)), metadata, colorMode) + return repo + } + + @Test + fun black_and_white_pages_carry_a_bitonal_provider() = runTest { + for (quality in ExportQuality.entries) { + val pages = pagesToExport(repoWithPage(BLACK_AND_WHITE), quality) + assertThat(pages).hasSize(1) + assertThat(pages.first().bitonal) + .describedAs("bitonal provider for %s", quality) + .isNotNull() + } + } + + @Test + fun other_color_modes_do_not() = runTest { + for (colorMode in listOf(COLOR, GRAYSCALE)) { + for (quality in ExportQuality.entries) { + val pages = pagesToExport(repoWithPage(colorMode), quality) + assertThat(pages).hasSize(1) + assertThat(pages.first().bitonal) + .describedAs("bitonal provider for %s at %s", colorMode, quality) + .isNull() + } + } + } + + @Test + fun black_and_white_targets_its_resolution_on_the_actual_page_size() { + val a4 = EstimatedDimensions.Physical(210.0, 297.0) + // 300 dpi on A4 is 2480 x 3508 pixels + assertThat(ExportQuality.BALANCED.bitonalMaxPixels(a4)) + .isCloseTo(2480L * 3508, within(10_000L)) + // A receipt at the same setting needs far fewer pixels for the same sharpness + val receipt = EstimatedDimensions.Physical(80.0, 200.0) + assertThat(ExportQuality.BALANCED.bitonalMaxPixels(receipt)) + .isLessThan(ExportQuality.BALANCED.bitonalMaxPixels(a4)) + } + + @Test + fun black_and_white_stays_between_the_normal_budget_and_the_memory_ceiling() { + val sizes = listOf( + EstimatedDimensions.Physical(210.0, 297.0), + EstimatedDimensions.Physical(50.0, 50.0), + EstimatedDimensions.Ratio(1.0, 1.41), + ) + for (quality in ExportQuality.entries) { + for (size in sizes) { + assertThat(quality.bitonalMaxPixels(size)) + .describedAs("%s at %s", quality, size) + .isBetween(quality.maxPixels, 20_000_000) + } + } + } +} diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/BitonalPacking.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/BitonalPacking.kt new file mode 100644 index 00000000..fbe4f5bd --- /dev/null +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/BitonalPacking.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * 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 . + */ +package org.fairscan.imageprocessing + +private const val BITONAL_THRESHOLD = 128 + +private fun bytesPerRow(width: Int): Int = (width + 7) / 8 + +// One bit per pixel, MSB first, each row padded to whole bytes. A set bit means black, which +// is what the CCITT fax encoder expects when /BlackIs1 is left out. +fun packBitsMsbFirst(gray: ByteArray, width: Int, height: Int): ByteArray { + val stride = bytesPerRow(width) + val packed = ByteArray(stride * height) + for (y in 0 until height) { + val sourceRow = y * width + val targetRow = y * stride + for (x in 0 until width) { + if ((gray[sourceRow + x].toInt() and 0xFF) < BITONAL_THRESHOLD) { + val index = targetRow + (x shr 3) + packed[index] = (packed[index].toInt() or (0x80 ushr (x and 7))).toByte() + } + } + } + return packed +} + +// Inverse of packBitsMsbFirst. +internal fun unpackBitsMsbFirst(packed: ByteArray, width: Int, height: Int): ByteArray { + val stride = bytesPerRow(width) + val gray = ByteArray(width * height) + for (y in 0 until height) { + val sourceRow = y * stride + val targetRow = y * width + for (x in 0 until width) { + val bit = (packed[sourceRow + (x shr 3)].toInt() shr (7 - (x and 7))) and 1 + gray[targetRow + x] = if (bit == 1) 0 else 255.toByte() + } + } + return gray +} diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt index 1a7bbaf2..5b28a3bd 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt @@ -166,6 +166,10 @@ fun extractDocument( colorMode: ColorMode, maxPixels: Long, opticalMeasures: OpticalMeasures? = null, + // Lets the warp interpolate up to maxPixels. One bit per pixel loses the sub-pixel edge + // position that the gray levels still carry, so a finer grid gives smoother contours even + // though it adds no detail. + allowUpscaling: Boolean = false, ): Mat { val estimatedDimensions = estimateRealDimensions( quad, @@ -193,7 +197,8 @@ fun extractDocument( Imgproc.warpPerspective(inputMat, warped, transform, outputSize) val resized = resizeForMaxPixels(warped, maxPixels.toDouble()) - val enhanced = enhanceCapturedImage(resized, colorMode) + val enhanced = enhanceCapturedImage( + resized, colorMode, if (allowUpscaling) maxPixels else 0L) val rotated = rotate(enhanced, rotationDegrees) warped.release() diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt index c2f1c231..72165392 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt @@ -19,21 +19,26 @@ import org.opencv.core.CvType import org.opencv.core.Mat import org.opencv.core.MatOfFloat import org.opencv.core.MatOfInt +import org.opencv.core.Rect import org.opencv.core.Scalar import org.opencv.core.Size import org.opencv.imgproc.Imgproc import kotlin.math.max import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sqrt enum class ColorMode { COLOR, GRAYSCALE, + BLACK_AND_WHITE, } -fun enhanceCapturedImage(img: Mat, colorMode: ColorMode): Mat { +fun enhanceCapturedImage(img: Mat, colorMode: ColorMode, upscaleTo: Long = 0L): Mat { return when (colorMode) { ColorMode.COLOR -> multiScaleRetinexOnL(img) ColorMode.GRAYSCALE -> enhanceGrayscaleImage(img) + ColorMode.BLACK_AND_WHITE -> binarizeDocument(img, upscaleTo) } } @@ -217,6 +222,16 @@ private fun percentiles(src: Mat, low: Double, high: Double): Pair 1) { + val statsData = IntArray(count * 5) + stats.get(0, 0, statsData) + val centres = DoubleArray(count * 2) + centroids.get(0, 0, centres) + val replacement = Scalar(if (ink) 255.0 else 0.0) + + for (label in 1 until count) { + val offset = label * 5 + if (statsData[offset + Imgproc.CC_STAT_AREA] >= minArea) continue + if (within != null && !isSet(within, centres[label * 2], centres[label * 2 + 1])) + continue + val box = Rect( + statsData[offset + Imgproc.CC_STAT_LEFT], + statsData[offset + Imgproc.CC_STAT_TOP], + statsData[offset + Imgproc.CC_STAT_WIDTH], + statsData[offset + Imgproc.CC_STAT_HEIGHT], + ) + val labelBox = labels.submat(box) + val speck = Mat() + Core.compare(labelBox, Scalar(label.toDouble()), speck, Core.CMP_EQ) + val target = binary.submat(box) + target.setTo(replacement, speck) + labelBox.release(); speck.release(); target.release() + } + } + + labels.release(); stats.release(); centroids.release() +} + +private fun isSet(mask: Mat, x: Double, y: Double): Boolean { + val col = x.toInt().coerceIn(0, mask.cols() - 1) + val row = y.toInt().coerceIn(0, mask.rows() - 1) + return mask.get(row, col)[0] != 0.0 } diff --git a/imageprocessing/src/test/java/org/fairscan/imageprocessing/BinarizationParametersTest.kt b/imageprocessing/src/test/java/org/fairscan/imageprocessing/BinarizationParametersTest.kt new file mode 100644 index 00000000..08f4f1de --- /dev/null +++ b/imageprocessing/src/test/java/org/fairscan/imageprocessing/BinarizationParametersTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * 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 . + */ +package org.fairscan.imageprocessing + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class BinarizationParametersTest { + + @Test + fun sauvola_window_is_always_odd() { + for (maxDim in 100..6000 step 7) { + assertThat(sauvolaWindow(maxDim) % 2).isEqualTo(1) + } + } + + @Test + fun sauvola_window_is_clamped_at_both_ends() { + assertThat(sauvolaWindow(1)).isEqualTo(15) + assertThat(sauvolaWindow(100_000)).isEqualTo(101) + } + + @Test + fun sauvola_window_grows_with_resolution() { + // A4 at roughly 150, 200 and 300 dpi + assertThat(sauvolaWindow(1189)).isEqualTo(19) + assertThat(sauvolaWindow(1682)).isEqualTo(29) + assertThat(sauvolaWindow(3508)).isEqualTo(59) + } + + @Test + fun sauvola_window_never_shrinks() { + var previous = 0 + for (maxDim in 1..8000) { + val window = sauvolaWindow(maxDim) + assertThat(window).isGreaterThanOrEqualTo(previous) + previous = window + } + } + + @Test + fun despeckle_area_grows_with_the_square_of_the_resolution() { + assertThat(despeckleMinArea(1189)).isEqualTo(2) + assertThat(despeckleMinArea(1682)).isEqualTo(3) + assertThat(despeckleMinArea(3508)).isEqualTo(12) + } + + @Test + fun despeckle_area_keeps_a_lower_bound() { + assertThat(despeckleMinArea(1)).isEqualTo(2) + } +} diff --git a/imageprocessing/src/test/java/org/fairscan/imageprocessing/BitonalPackingTest.kt b/imageprocessing/src/test/java/org/fairscan/imageprocessing/BitonalPackingTest.kt new file mode 100644 index 00000000..3e3c2af4 --- /dev/null +++ b/imageprocessing/src/test/java/org/fairscan/imageprocessing/BitonalPackingTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * 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 . + */ +package org.fairscan.imageprocessing + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import kotlin.random.Random + +private const val BLACK: Byte = 0 +private const val WHITE = 255.toByte() + +class BitonalPackingTest { + + @Test + fun single_black_pixel() { + assertThat(packBitsMsbFirst(byteArrayOf(BLACK), 1, 1)).containsExactly(0x80.toByte()) + } + + @Test + fun single_white_pixel() { + assertThat(packBitsMsbFirst(byteArrayOf(WHITE), 1, 1)).containsExactly(0x00) + } + + @Test + fun most_significant_bit_is_the_leftmost_pixel() { + val row = byteArrayOf(BLACK, WHITE, WHITE, WHITE, WHITE, WHITE, WHITE, BLACK) + assertThat(packBitsMsbFirst(row, 8, 1)).containsExactly(0x81.toByte()) + } + + @Test + fun rows_are_padded_to_whole_bytes() { + // 9 pixels: 8 white, then one black, so the ninth bit is the MSB of the second byte + val row = ByteArray(9) { if (it == 8) BLACK else WHITE } + val packed = packBitsMsbFirst(row, 9, 1) + assertThat(packed).hasSize(2) + assertThat(packed).containsExactly(0x00, 0x80.toByte()) + } + + @Test + fun rows_do_not_bleed_into_each_other() { + val pixels = ByteArray(2 * 9) { if (it < 9) WHITE else BLACK } + val packed = packBitsMsbFirst(pixels, 9, 2) + assertThat(packed).containsExactly(0x00, 0x00, 0xFF.toByte(), 0x80.toByte()) + } + + @Test + fun packed_size_only_depends_on_dimensions() { + assertThat(packBitsMsbFirst(ByteArray(37 * 23), 37, 23)).hasSize(5 * 23) + } + + @Test + fun gray_values_are_thresholded_at_the_midpoint() { + val row = byteArrayOf(127, 128.toByte()) + assertThat(packBitsMsbFirst(row, 2, 1)).containsExactly(0x80.toByte()) + } + + @Test + fun round_trip() { + val random = Random(42) + val width = 37 + val height = 23 + val pixels = ByteArray(width * height) { if (random.nextBoolean()) BLACK else WHITE } + val packed = packBitsMsbFirst(pixels, width, height) + assertThat(unpackBitsMsbFirst(packed, width, height)).isEqualTo(pixels) + } +} From fc5cadec6c6133e5461772e3814c05b6bdae020f Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:17:31 +0200 Subject: [PATCH 03/16] Partial revert: remove B&W-specific export logic --- .../fairscan/app/domain/ExportPreparation.kt | 88 +++++-------- .../org/fairscan/app/domain/ExportQuality.kt | 29 ----- .../fairscan/app/platform/AndroidPdfWriter.kt | 21 +--- .../fairscan/app/platform/ImageProcessor.kt | 104 +--------------- .../app/ui/screens/export/ExportScreen.kt | 11 -- .../app/ui/screens/export/ExportUiState.kt | 5 - .../app/ui/screens/export/ExportViewModel.kt | 7 -- app/src/main/res/values-ar/strings.xml | 2 - app/src/main/res/values-cs/strings.xml | 2 - app/src/main/res/values-de/strings.xml | 2 - app/src/main/res/values-es/strings.xml | 2 - app/src/main/res/values-et/strings.xml | 2 - app/src/main/res/values-fr/strings.xml | 2 - app/src/main/res/values-gl/strings.xml | 2 - app/src/main/res/values-hu/strings.xml | 2 - app/src/main/res/values-in/strings.xml | 2 - app/src/main/res/values-it/strings.xml | 2 - app/src/main/res/values-ja/strings.xml | 2 - app/src/main/res/values-lv/strings.xml | 2 - app/src/main/res/values-pt-rBR/strings.xml | 2 - app/src/main/res/values-ru/strings.xml | 2 - app/src/main/res/values-sv/strings.xml | 2 - app/src/main/res/values-tr/strings.xml | 2 - app/src/main/res/values-zh-rTW/strings.xml | 2 - app/src/main/res/values-zh/strings.xml | 2 - app/src/main/res/values/strings.xml | 7 -- .../org/fairscan/app/data/FileManagerTest.kt | 2 +- .../app/domain/ExportPreparationTest.kt | 116 ------------------ .../imageprocessing/DocumentDetection.kt | 7 +- .../imageprocessing/PostProcessing.kt | 4 +- 30 files changed, 41 insertions(+), 396 deletions(-) delete mode 100644 app/src/test/java/org/fairscan/app/domain/ExportPreparationTest.kt diff --git a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt index f37b1d09..dbd68428 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt @@ -15,10 +15,7 @@ package org.fairscan.app.domain import org.fairscan.app.data.ImageRepository -import org.fairscan.app.platform.bitonalFromJpeg -import org.fairscan.app.platform.processedBitonalImage import org.fairscan.app.platform.processedImage -import org.fairscan.imageprocessing.ColorMode import org.fairscan.imageprocessing.EstimatedDimensions import org.fairscan.imageprocessing.estimateRealDimensions import org.fairscan.imageprocessing.resizeForMaxPixels @@ -29,17 +26,9 @@ fun interface JpegProvider { suspend fun get(): Jpeg } -fun interface BitonalProvider { - suspend fun get(): Bitonal -} - data class PageToExport( val page: ScanPage, val jpeg: JpegProvider, - // Set for black and white pages only. The PDF writer embeds it instead of the JPEG. - val bitonal: BitonalProvider? = null, - // What OCR reads when the embedded image is not a JPEG it can use. - val ocrJpeg: JpegProvider = jpeg, ) { fun estimatedDimensions(): EstimatedDimensions? { val metadata = page.metadata @@ -67,60 +56,39 @@ private fun EstimatedDimensions.applyRotation(rotation: Rotation): EstimatedDime suspend fun pagesToExport( imageRepository: ImageRepository, exportQuality: ExportQuality -): List = imageRepository.pages().map { page -> - if (page.colorMode == ColorMode.BLACK_AND_WHITE) - bitonalPageToExport(page, imageRepository, exportQuality) - else - standardPageToExport(page, imageRepository, exportQuality) -} +): List { -private fun standardPageToExport( - page: ScanPage, - imageRepository: ImageRepository, - exportQuality: ExportQuality, -): PageToExport = when (exportQuality) { - ExportQuality.BALANCED -> PageToExport(page, jpeg = { jpeg(page, imageRepository) }) - - ExportQuality.LOW -> PageToExport(page, jpeg = { - resizeJpegBytesForMaxPixels( - jpeg = jpeg(page, imageRepository), - maxPixels = exportQuality.maxPixels.toDouble(), - jpegQuality = exportQuality.jpegQuality - ) - }) + val pages = imageRepository.pages() + return when (exportQuality) { + ExportQuality.BALANCED -> pages.map { + PageToExport(it) { jpeg(it, imageRepository) } + } - ExportQuality.HIGH -> PageToExport(page, jpeg = { - val source = imageRepository.source(page.id) - val metadata = page.metadata - val colorMode = page.colorMode - if (source != null && metadata != null && colorMode != null) { - val rotation = page.totalRotation() - processedImage(source, metadata, rotation, colorMode, exportQuality) + ExportQuality.LOW -> pages.map { page -> + PageToExport(page) { + resizeJpegBytesForMaxPixels( + jpeg = jpeg(page, imageRepository), + maxPixels = exportQuality.maxPixels.toDouble(), + jpegQuality = exportQuality.jpegQuality + ) + } } - else - jpeg(page, imageRepository) - }) -} -// Only the PDF writer looks at the bitonal provider, JPEG export keeps the standard one. -private fun bitonalPageToExport( - page: ScanPage, - imageRepository: ImageRepository, - exportQuality: ExportQuality, -): PageToExport = standardPageToExport(page, imageRepository, exportQuality).copy( - // The stored page, not the one the export quality asks for: OCR does not benefit from the - // higher resolution, and rendering it again would double the work for the page. - ocrJpeg = { jpeg(page, imageRepository) }, - bitonal = { - val source = imageRepository.source(page.id) - val metadata = page.metadata - if (source != null && metadata != null) { - processedBitonalImage(source, metadata, page.totalRotation(), exportQuality) + ExportQuality.HIGH -> pages.map { page -> + PageToExport(page) { + val source = imageRepository.source(page.id) + val metadata = page.metadata + val colorMode = page.colorMode + if (source != null && metadata != null && colorMode != null) { + val rotation = page.totalRotation() + processedImage(source, metadata, rotation, colorMode, exportQuality) + } + else + jpeg(page, imageRepository) + } } - else - bitonalFromJpeg(jpeg(page, imageRepository)) - }, -) + } +} private suspend fun jpeg(page: ScanPage, imageRepository: ImageRepository): Jpeg { val key = page.key() diff --git a/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt b/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt index dc49e8a1..0a6c5cdb 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt @@ -15,54 +15,25 @@ package org.fairscan.app.domain import org.fairscan.app.R -import org.fairscan.imageprocessing.EstimatedDimensions -import org.fairscan.imageprocessing.PaperFormats -// Black and white is sized by target resolution rather than by a pixel count: it is compressed -// losslessly, so its size follows the number of black-white transitions rather than the pixels, -// and a page only looks sharp in one bit per pixel at a high enough resolution. A pixel budget -// would also give a receipt a very different resolution than an A4 page. enum class ExportQuality( val jpegQuality: Int, val maxPixels: Long, - val bitonalDpi: Int, val labelResource: Int ) { LOW( jpegQuality = 60, maxPixels = 1_000_000, - bitonalDpi = 150, R.string.export_quality_low, ), BALANCED( jpegQuality = 75, maxPixels = 2_000_000, - bitonalDpi = 300, R.string.export_quality_balanced, ), HIGH( jpegQuality = 80, maxPixels = 4_000_000, - bitonalDpi = 450, R.string.export_quality_high, ) } - -// 450 dpi on A4 is 19.6 megapixels, which is the most the highest setting ever asks for. -private const val MAX_BITONAL_PIXELS = 20_000_000L - -// Pixels needed to reach bitonalDpi on this page. Falls back to A4 when the physical size could -// not be estimated, the same assumption the PDF writer makes for the page box. -fun ExportQuality.bitonalMaxPixels(dimensions: EstimatedDimensions): Long { - val widthMm: Double - val heightMm: Double - if (dimensions is EstimatedDimensions.Physical) { - widthMm = dimensions.widthMm - heightMm = dimensions.heightMm - } else { - widthMm = PaperFormats.A4.widthMm - heightMm = PaperFormats.A4.heightMm - } - val pixels = (widthMm / 25.4 * bitonalDpi) * (heightMm / 25.4 * bitonalDpi) - return pixels.toLong().coerceIn(maxPixels, MAX_BITONAL_PIXELS) -} diff --git a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt index 29c36a63..11a4f74f 100644 --- a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt +++ b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt @@ -15,7 +15,6 @@ package org.fairscan.app.platform import android.content.res.AssetManager -import android.graphics.Bitmap import android.util.Log import com.tom_roush.pdfbox.cos.COSArray import com.tom_roush.pdfbox.cos.COSDictionary @@ -54,22 +53,14 @@ class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : P disableOcr: Boolean, onProgress: (Int) -> Unit, ) { - // Without a language, runOcr returns nothing, and decoding a page for it is not free: - // at the highest quality it renders the page a second time. - val ocrEnabled = !disableOcr && ocrService.languageString().isNotEmpty() val doc = PDDocument() doc.documentInformation.creationDate = Calendar.getInstance() doc.documentInformation.creator = "FairScan ${BuildConfig.VERSION_NAME}" doc.use { document -> val ocrDocument = OcrDocument(document, assets) for ((index, page) in pages.withIndex()) { - val bitonal = page.bitonal?.get() - val embedded = if (bitonal == null) page.jpeg.get() else null - val ocrJpeg = page.ocrJpeg - val image = if (bitonal != null) - createCcittG4Image(document, bitonal) - else - JPEGFactory.createFromByteArray(document, requireNotNull(embedded).bytes) + val jpeg = page.jpeg.get() + val image = JPEGFactory.createFromByteArray(document, jpeg.bytes) // PDF has 72 points (units) per inch, 1 inch = 25.4 mm val pointsPerMm = 72f / 25.4f @@ -97,11 +88,9 @@ class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : P val contentStream = PDPageContentStream(document, page, AppendMode.OVERWRITE, false) contentStream.drawImage(image, 0f, 0f, widthPoints, heightPoints) - if (ocrEnabled) { - var bitmap: Bitmap? = null + if (!disableOcr) { try { - // For every mode but black and white this is the image just embedded. - bitmap = (embedded ?: ocrJpeg.get()).toBitmap() + val bitmap = jpeg.toBitmap() val ocrTextBoxes = ocrService.runOcr(bitmap) val pdfPageDimensions = PageDimensions( bitmap.width, @@ -112,8 +101,6 @@ class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : P ocrDocument.addPage(page, ocrTextBoxes, pdfPageDimensions) } catch (e: Exception) { Log.e("AndroidPdfWriter", "Failed to run OCR on page $index", e) - } finally { - bitmap?.recycle() } } contentStream.close() diff --git a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt index 1f554c40..e96ff0ec 100644 --- a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt +++ b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt @@ -19,10 +19,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import org.fairscan.app.data.ImageTransformations -import org.fairscan.app.domain.Bitonal import org.fairscan.app.domain.CapturedPage import org.fairscan.app.domain.ExportQuality -import org.fairscan.app.domain.bitonalMaxPixels import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.Rotation @@ -35,9 +33,7 @@ import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.autoColorMode import org.fairscan.imageprocessing.createQuad -import org.fairscan.imageprocessing.estimateRealDimensions import org.fairscan.imageprocessing.extractDocument -import org.fairscan.imageprocessing.packBitsMsbFirst import org.fairscan.imageprocessing.resizeForMaxPixels import org.fairscan.imageprocessing.rotate import org.fairscan.imageprocessing.scaledTo @@ -112,106 +108,15 @@ fun processedImage( try { sourceMat = source.toMat() val quad = metadata.normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height()) - page = renderPage(sourceMat, quad, rotationDegrees, colorMode, exportQuality, + page = extractDocument(sourceMat, quad, rotationDegrees, colorMode, exportQuality.maxPixels, metadata.opticalMeasures) - return Jpeg.fromMat(page, storedJpegQuality(colorMode, exportQuality)) + return Jpeg.fromMat(page, exportQuality.jpegQuality) } finally { sourceMat?.release() page?.release() } } -// A scaled down bitonal page is nothing but hard edges, which is exactly where JPEG rings. -private const val BITONAL_JPEG_QUALITY = 92 - -private fun storedJpegQuality(colorMode: ColorMode, exportQuality: ExportQuality) = - if (colorMode == ColorMode.BLACK_AND_WHITE) BITONAL_JPEG_QUALITY - else exportQuality.jpegQuality - -private fun bitonalMaxPixels( - source: Mat, - quad: Quad, - exportQuality: ExportQuality, - opticalMeasures: OpticalMeasures?, -): Long = exportQuality.bitonalMaxPixels( - estimateRealDimensions(quad, source.cols(), source.rows(), opticalMeasures) - .snapToStandardFormat() -) - -// Black and white is binarized at the export resolution and scaled down afterwards: at preview -// resolution a speck of glare merges with a glyph and can no longer be told apart from it. -private fun renderPage( - source: Mat, - quad: Quad, - rotationDegrees: Int, - colorMode: ColorMode, - exportQuality: ExportQuality, - opticalMeasures: OpticalMeasures?, -): Mat { - if (colorMode != ColorMode.BLACK_AND_WHITE) { - return extractDocument(source, quad, rotationDegrees, colorMode, - exportQuality.maxPixels, opticalMeasures) - } - val full = extractDocument(source, quad, rotationDegrees, colorMode, - bitonalMaxPixels(source, quad, exportQuality, opticalMeasures), opticalMeasures, - allowUpscaling = true) - return try { - resizeForMaxPixels(full, exportQuality.maxPixels.toDouble()) - } finally { - full.release() - } -} - -// Rebuilt from the original capture, because the stored page is a JPEG and would carry its -// compression artifacts into the PDF. -fun processedBitonalImage( - source: Jpeg, - metadata: PageMetadata, - rotation: Rotation, - exportQuality: ExportQuality, -): Bitonal { - var sourceMat: Mat? = null - var page: Mat? = null - var gray: Mat? = null - try { - sourceMat = source.toMat() - val quad = metadata.normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height()) - page = extractDocument(sourceMat, quad, rotation.degrees, ColorMode.BLACK_AND_WHITE, - bitonalMaxPixels(sourceMat, quad, exportQuality, metadata.opticalMeasures), - metadata.opticalMeasures, allowUpscaling = true) - gray = Mat() - Imgproc.cvtColor(page, gray, Imgproc.COLOR_BGR2GRAY) - return packBitonal(gray) - } finally { - sourceMat?.release() - page?.release() - gray?.release() - } -} - -// Fallback for pages whose original capture is no longer available. -fun bitonalFromJpeg(jpeg: Jpeg): Bitonal { - var mat: Mat? = null - var gray: Mat? = null - try { - mat = jpeg.toMat() - gray = Mat() - Imgproc.cvtColor(mat, gray, Imgproc.COLOR_BGR2GRAY) - return packBitonal(gray) - } finally { - mat?.release() - gray?.release() - } -} - -private fun packBitonal(gray: Mat): Bitonal { - val width = gray.width() - val height = gray.height() - val pixels = ByteArray(width * height) - gray.get(0, 0, pixels) - return Bitonal(width, height, packBitsMsbFirst(pixels, width, height)) -} - fun extractDocumentFromBitmap( source: Bitmap, quadInMask: Quad?, @@ -245,10 +150,11 @@ fun extractDocumentFromBitmap( normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1) autoColorMode = autoColorMode(bgr, mask, quad) colorMode = defaultColorMode.colorMode ?: autoColorMode - page = renderPage(bgr, quad, rotationDegrees, colorMode, exportQuality, opticalMeasures) + page = extractDocument(bgr, quad, rotationDegrees, colorMode, exportQuality.maxPixels, + opticalMeasures) } - val pageJpeg = Jpeg.fromMat(page, storedJpegQuality(colorMode, exportQuality)) + val pageJpeg = Jpeg.fromMat(page, exportQuality.jpegQuality) bgr.release() page.release() diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt index 5e2c6387..945330aa 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt @@ -300,17 +300,6 @@ private fun PdfInfos( ) } - uiState.bitonalAsJpeg?.let { pages -> - val hint = stringResource(R.string.black_and_white_jpeg_hint) - val affected = if (pages.count == pages.total) "" else " " + stringResource( - R.string.black_and_white_jpeg_pages, pages.count, pageCountText(pages.total)) - Text( - hint + affected, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - if (uiState.isGenerating) { Text( text = stringResource(R.string.creating_export), diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt index b14365fc..06897388 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt @@ -23,9 +23,6 @@ data class ExportUiState( val isGenerating: Boolean = false, val progress: ExportProgress? = null, val ocrActivation: Boolean? = null, - // Set only when exporting to JPEG, which cannot store one bit per pixel, and at least one - // page is black and white and therefore loses its size advantage. - val bitonalAsJpeg: BitonalPages? = null, val isSaving: Boolean = false, val result: ExportResult? = null, val savedBundle: SavedBundle? = null, @@ -35,8 +32,6 @@ data class ExportUiState( val hasSavedOrShared get() = savedBundle != null || hasShared } -data class BitonalPages(val count: Int, val total: Int) - data class ExportProgress( val completedPages: Int, val totalPages: Int, diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt index 17a18ab8..c2a5bd60 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt @@ -49,7 +49,6 @@ import org.fairscan.app.domain.PageViewKey import org.fairscan.app.domain.pagesToExport import org.fairscan.app.ui.screens.settings.ExportFormat import org.fairscan.app.ui.screens.settings.ExportFormat.PDF -import org.fairscan.imageprocessing.ColorMode import java.io.File import java.io.FileInputStream import java.io.IOException @@ -161,11 +160,6 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit preparationJob = launch { val ocrActivation = if (exportFormat == PDF) ocrLanguageString.isNotEmpty() else null - val bitonalCount = currentPageKeys.count { it.colorMode == ColorMode.BLACK_AND_WHITE } - val bitonalAsJpeg = - if (exportFormat == ExportFormat.JPEG && bitonalCount > 0) - BitonalPages(bitonalCount, pageCount) - else null _uiState.update { ExportUiState( filename = it.filename, @@ -173,7 +167,6 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit isGenerating = true, progress = ExportProgress(0, pageCount), ocrActivation = ocrActivation, - bitonalAsJpeg = bitonalAsJpeg, ) } val onProgress: (Int) -> Unit = { completedPages -> diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index e626911e..c0f0c2b0 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -4,8 +4,6 @@ تطبيق بسيط ومحترم لمسح مستنداتك ضوئيًا. تطبيق ارجع - استخدم PDF كصيغة إخراج للحفاظ على صفحات الأبيض والأسود صغيرة وواضحة. - (%1$d/%2$s) رُفض إذن الوصول إلى الكاميرا يتطلب التطبيق الوصول إلى الكاميرا لمسح المستندات ضوئيًا. تُخزن الصور الملتقطة على هذا الجهاز فقط، وسيتم حذفها عند إغلاق المسح الحالي. ألغِ diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index ea0c3a4d..6ba90c6a 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -4,8 +4,6 @@ Jednoduchá a respektující aplikace pro skenování vašich dokumentů Použít Zpět - Použijte PDF jako výstupní formát, aby černobílé stránky zůstaly malé a ostré. - (%1$d/%2$s) Byl odepřen přístup k fotoaparátu Aby bylo možné skenovat dokumenty, tato aplikace potřebuje přístup k fotoaparátu. Nasnímané obrázky jsou ukládány pouze do tohoto zařízení a budou smazány, když ukončíte aktuální sken. Zrušit diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2f02ca00..5be6ff8a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -4,8 +4,6 @@ Eine einfache und respektvolle App zum Scannen Ihrer Dokumente. Anwenden Zurück - Verwenden Sie PDF als Ausgabeformat, damit Schwarzweiß-Seiten kompakt und scharf bleiben. - (%1$d/%2$s) Kamerazugriff wurde verweigert Die App benötigt Zugriff auf die Kamera, um Dokumente zu scannen. Aufgenommene Bilder werden nur auf diesem Gerät gespeichert und beim Schließen des aktuellen Scans gelöscht. Abbrechen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0a5672c5..83eb0ad0 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -4,8 +4,6 @@ Una aplicación sencilla y respetuosa para escanear tus documentos. Aplicar Atrás - Use PDF como formato de salida para que las páginas en blanco y negro se mantengan compactas y nítidas. - (%1$d/%2$s) Permiso de cámara denegado La aplicación necesita acceso a la cámara para escanear documentos. Las imágenes capturadas se guardan solo en este dispositivo y se eliminarán cuando cierres el escaneo actual. Cancelar diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 80ac9787..37eca184 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -5,8 +5,6 @@ Lihtsaltkasutatav ja viisakas rakendus dokumentide skaneerimiseks. Rakenda Tagasi - Kasutage väljundvorminguna PDF-i, et mustvalged lehed jääksid kompaktseks ja teravaks. - (%1$d/%2$s) Kaamera kasutamise õigused on puudu Et dokumentide skaneerimine toimiks, vajab see rakendus õigust kasutada kaamerat. Pildihõive käigus jäädvustatud pildid salvestuvad vaid siin selles seadmes ja kustutatakse töösoleva skaneerimise lõppedes. Katkesta diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index aba5c913..65c70dd2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -5,8 +5,6 @@ Une application simple et respectueuse pour scanner vos documents. Appliquer Retour - Utilisez le PDF comme format de sortie pour garder les pages en noir et blanc compactes et nettes. - (%1$d/%2$s) L\'autorisation d\'accès à la caméra a été refusée L’application a besoin d’accéder à l’appareil photo pour scanner des documents. Les images capturées sont enregistrées uniquement sur cet appareil et seront supprimées lorsque vous fermerez le scan en cours. Annuler diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index c365c79e..f0aa0b22 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -4,8 +4,6 @@ Unha aplicación sinxela e respectuosa para escanear os teus documentos. Aplicar Atrás - Use PDF como formato de saída para que as páxinas en branco e negro se manteñan compactas e nítidas. - (%1$d/%2$s) Permiso da cámara denegado A aplicación precisa acceso á cámara para escanear documentos. As imaxes capturadas gárdanse só neste dispositivo e eliminaranse ao pechar o escaneo actual. Cancelar diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 00b472bc..a56cff73 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -4,8 +4,6 @@ Egy egyszerű, a felhasználót tiszteletben tartó dokumentum szkenner alkalmazás. Alkalmaz Vissza - Használjon PDF kimeneti formátumot, hogy a fekete-fehér oldalak tömörek és élesek maradjanak. - (%1$d/%2$s) Kamerahozzáférés megtagadva Az alkalmazásnak a dokumentumok beolvasásához kamerahozzáférésre van szüksége. A rögzített képek csak ezen az eszközön kerülnek tárolásra, és törlődnek, amikor bezárod a folyamatban lévő szkennelést. Mégse diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index ca3899c4..dc4ecd6f 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -4,8 +4,6 @@ Aplikasi sederhana dan aman untuk memindai dokumen Anda Terapkan Kembali - Gunakan PDF sebagai format keluaran agar halaman hitam putih tetap ringkas dan tajam. - (%1$d/%2$s) Izin penggunaan kamera ditolak Aplikasi ini memerlukan akses kamera untuk memindai dokumen. Gambar yang diambil hanya disimpan di perangkat ini dan akan dihapus saat Anda menutup pemindaian saat ini. Batalkan diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 7c20bb75..bcde2ba9 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -4,8 +4,6 @@ Un\'app semplice e rispettosa per scansionare i tuoi documenti. Applica Indietro - Usare il PDF come formato di output per mantenere le pagine in bianco e nero compatte e nitide. - (%1$d/%2$s) Autorizzazione fotocamera negata L\'app richiede l\'accesso alla fotocamera per scansionare documenti. Le immagini catturate sono salvate solo in questo dispositivo e verranno eliminate quando chiudi la scansione corrente. Annulla diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index ffd77a1f..d7c89ec9 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -4,8 +4,6 @@ シンプルで安心なドキュメントスキャナーです。 適用 戻る - 白黒ページを小さく鮮明に保つには、出力形式に PDF を使用してください。 - (%1$d/%2$s) カメラの権限が拒否されました ドキュメントをスキャンするためにカメラへのアクセスが必要です。撮影した画像はこのデバイスにのみ保存され、現在のスキャンを閉じると削除されます。 キャンセル diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 8b77cd5e..4f99469a 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -1,7 +1,5 @@ - Izmantojiet PDF kā izvades formātu, lai melnbaltās lapas paliktu kompaktas un asas. - (%1$d/%2$s) Melnbalts Sabalansēta Aizvērt diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 0c3e60d9..c2b3c3e2 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -4,8 +4,6 @@ Um aplicativo simples e respeitoso para digitalizar seus documentos. Aplicar Voltar - Use PDF como formato de saída para manter as páginas em preto e branco compactas e nítidas. - (%1$d/%2$s) Permissão da câmera negada O aplicativo precisa de acesso à câmera para digitalizar documentos. As imagens capturadas são armazenadas apenas neste dispositivo e serão excluídas quando você fechar a digitalização atual. Cancelar diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f316f744..5adb10ad 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -4,8 +4,6 @@ Простое и открытое приложение для сканирования Ваших документов. Применить Назад - Используйте PDF в качестве формата вывода, чтобы чёрно-белые страницы оставались компактными и чёткими. - (%1$d/%2$s) В доступе к камере отказано Для сканирования документов приложению требуется доступ к камере. Отснятые изображения хранятся только на данном устройстве и удаляются по окончании текущего сканирования. Отмена diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 4cc40cc6..882330b7 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -4,8 +4,6 @@ En enkel och respektfull app för att skanna dina dokument. Använd Tillbaka - Använd PDF som utdataformat för att hålla svartvita sidor kompakta och skarpa. - (%1$d/%2$s) Kamerabehörighet nekades Appen behöver åtkomst till kameran för att kunna skanna dokument. Tagna bilder lagras endast på den här enheten och raderas när du stänger den aktuella skanningen. Avbryt diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index b7e4b59c..9c328b87 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -4,8 +4,6 @@ Belgelerinizi taramak için basit ve saygılı bir uygulama. Uygula Geri - Siyah beyaz sayfaların küçük ve net kalması için çıktı biçimi olarak PDF kullanın. - (%1$d/%2$s) Kamera izni reddedildi Uygulama, belgeleri taramak için kamera erişimi gerektirir. Yakalanan görüntüler yalnızca bu cihazda saklanır ve geçerli taramayı kapattığınızda silinir. İptal diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index de2cc4db..a0de9347 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -4,8 +4,6 @@ 一個簡單且尊重隱私的文件掃描應用程式。 套用 返回 - 請使用 PDF 作為輸出格式,讓黑白頁面保持小巧清晰。 - (%1$d/%2$s) 已拒絕相機權限 此應用程式需要相機存取權限才能掃描文件。擷取的影像僅儲存在此裝置上,並會在您關閉目前掃描時刪除。 取消 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 01ccd2e1..a0a63eea 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -4,8 +4,6 @@ 一个简单且克制的文档扫描应用 应用 返回 - 请使用 PDF 作为输出格式,让黑白页面保持小巧清晰。 - (%1$d/%2$s) 相机权限被拒绝 应用请求相机权限访问扫描文档。捕获的图像仅存储在此设备上,并且在关闭当前扫描时将被删除。 取消 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c8d3c490..9b76e306 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,13 +16,6 @@ Apply Back - - Use PDF as the output format to keep black & white pages compact and crisp. - - (%1$d/%2$s) Camera permission was denied diff --git a/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt b/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt index 6ed2a3a5..3443f5de 100644 --- a/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt +++ b/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt @@ -87,7 +87,7 @@ class FileManagerTest { } val manager = FileManager(pdfDir, externalDir, fakePdfWriter) val pages = listOf(byteArrayOf(0x01, 0x02), byteArrayOf(0x11)) - .map { PageToExport(ScanPage("1", Rotation.R0, null, 1, null), jpeg = { Jpeg(it) }) } + .map { PageToExport(ScanPage("1", Rotation.R0, null, 1, null)) { Jpeg(it) } } val pdf = manager.generatePdf(pages, true) {} assertThat(pdf.pageCount).isEqualTo(2) assertThat(pdf.sizeInBytes).isEqualTo(3) diff --git a/app/src/test/java/org/fairscan/app/domain/ExportPreparationTest.kt b/app/src/test/java/org/fairscan/app/domain/ExportPreparationTest.kt deleted file mode 100644 index 3e2a9a00..00000000 --- a/app/src/test/java/org/fairscan/app/domain/ExportPreparationTest.kt +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright 2025-2026 The FairScan authors - * - * 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 . - */ -package org.fairscan.app.domain - -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.within -import org.fairscan.app.data.ImageRepository -import org.fairscan.app.data.ImageTransformations -import org.fairscan.app.data.Logger -import org.fairscan.imageprocessing.CameraIntrinsics -import org.fairscan.imageprocessing.ColorMode -import org.fairscan.imageprocessing.ColorMode.BLACK_AND_WHITE -import org.fairscan.imageprocessing.ColorMode.COLOR -import org.fairscan.imageprocessing.ColorMode.GRAYSCALE -import org.fairscan.imageprocessing.EstimatedDimensions -import org.fairscan.imageprocessing.ImageSize -import org.fairscan.imageprocessing.OpticalMeasures -import org.fairscan.imageprocessing.Point -import org.fairscan.imageprocessing.Quad -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder - -class ExportPreparationTest { - - @get:Rule - var folder: TemporaryFolder = TemporaryFolder() - - private val testScope = TestScope() - - private val quad = Quad(Point(.01, .02), Point(.1, .03), Point(.11, .12), Point(.03, .09)) - private val metadata = PageMetadata( - quad, Rotation.R0, COLOR, - ImageSize(1600, 1200), - OpticalMeasures(CameraIntrinsics(42.0f, 43.0f), 44.0f), - ) - - private suspend fun repoWithPage(colorMode: ColorMode): ImageRepository { - val transformations = object : ImageTransformations { - override fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg = input - override fun resizeToThumbnail(input: Jpeg): Jpeg = input - override fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg = - throw UnsupportedOperationException() - } - val repo = ImageRepository( - folder.newFolder(), transformations, testScope, Logger { _, _, _ -> }) - repo.add(Jpeg(byteArrayOf(1, 2, 3)), Jpeg(byteArrayOf(4)), metadata, colorMode) - return repo - } - - @Test - fun black_and_white_pages_carry_a_bitonal_provider() = runTest { - for (quality in ExportQuality.entries) { - val pages = pagesToExport(repoWithPage(BLACK_AND_WHITE), quality) - assertThat(pages).hasSize(1) - assertThat(pages.first().bitonal) - .describedAs("bitonal provider for %s", quality) - .isNotNull() - } - } - - @Test - fun other_color_modes_do_not() = runTest { - for (colorMode in listOf(COLOR, GRAYSCALE)) { - for (quality in ExportQuality.entries) { - val pages = pagesToExport(repoWithPage(colorMode), quality) - assertThat(pages).hasSize(1) - assertThat(pages.first().bitonal) - .describedAs("bitonal provider for %s at %s", colorMode, quality) - .isNull() - } - } - } - - @Test - fun black_and_white_targets_its_resolution_on_the_actual_page_size() { - val a4 = EstimatedDimensions.Physical(210.0, 297.0) - // 300 dpi on A4 is 2480 x 3508 pixels - assertThat(ExportQuality.BALANCED.bitonalMaxPixels(a4)) - .isCloseTo(2480L * 3508, within(10_000L)) - // A receipt at the same setting needs far fewer pixels for the same sharpness - val receipt = EstimatedDimensions.Physical(80.0, 200.0) - assertThat(ExportQuality.BALANCED.bitonalMaxPixels(receipt)) - .isLessThan(ExportQuality.BALANCED.bitonalMaxPixels(a4)) - } - - @Test - fun black_and_white_stays_between_the_normal_budget_and_the_memory_ceiling() { - val sizes = listOf( - EstimatedDimensions.Physical(210.0, 297.0), - EstimatedDimensions.Physical(50.0, 50.0), - EstimatedDimensions.Ratio(1.0, 1.41), - ) - for (quality in ExportQuality.entries) { - for (size in sizes) { - assertThat(quality.bitonalMaxPixels(size)) - .describedAs("%s at %s", quality, size) - .isBetween(quality.maxPixels, 20_000_000) - } - } - } -} diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt index 5b28a3bd..1a7bbaf2 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt @@ -166,10 +166,6 @@ fun extractDocument( colorMode: ColorMode, maxPixels: Long, opticalMeasures: OpticalMeasures? = null, - // Lets the warp interpolate up to maxPixels. One bit per pixel loses the sub-pixel edge - // position that the gray levels still carry, so a finer grid gives smoother contours even - // though it adds no detail. - allowUpscaling: Boolean = false, ): Mat { val estimatedDimensions = estimateRealDimensions( quad, @@ -197,8 +193,7 @@ fun extractDocument( Imgproc.warpPerspective(inputMat, warped, transform, outputSize) val resized = resizeForMaxPixels(warped, maxPixels.toDouble()) - val enhanced = enhanceCapturedImage( - resized, colorMode, if (allowUpscaling) maxPixels else 0L) + val enhanced = enhanceCapturedImage(resized, colorMode) val rotated = rotate(enhanced, rotationDegrees) warped.release() diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt index 72165392..744fa5a9 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt @@ -34,11 +34,11 @@ enum class ColorMode { BLACK_AND_WHITE, } -fun enhanceCapturedImage(img: Mat, colorMode: ColorMode, upscaleTo: Long = 0L): Mat { +fun enhanceCapturedImage(img: Mat, colorMode: ColorMode): Mat { return when (colorMode) { ColorMode.COLOR -> multiScaleRetinexOnL(img) ColorMode.GRAYSCALE -> enhanceGrayscaleImage(img) - ColorMode.BLACK_AND_WHITE -> binarizeDocument(img, upscaleTo) + ColorMode.BLACK_AND_WHITE -> binarizeDocument(img) } } From 05217a0f46b2a2cd689b5089460ec633c2e5ae44 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:39:21 +0200 Subject: [PATCH 04/16] [B&W] Plug JPEG to CCITT G4 --- .../main/java/org/fairscan/app/domain/Image.kt | 17 +++++++++++++++++ .../fairscan/app/platform/AndroidPdfWriter.kt | 7 ++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/fairscan/app/domain/Image.kt b/app/src/main/java/org/fairscan/app/domain/Image.kt index 2c3a9695..45e08c28 100644 --- a/app/src/main/java/org/fairscan/app/domain/Image.kt +++ b/app/src/main/java/org/fairscan/app/domain/Image.kt @@ -19,7 +19,9 @@ import android.graphics.BitmapFactory import android.net.Uri import org.fairscan.imageprocessing.decodeJpeg import org.fairscan.imageprocessing.encodeJpeg +import org.fairscan.imageprocessing.packBitsMsbFirst import org.opencv.core.Mat +import org.opencv.imgproc.Imgproc class Jpeg(val bytes: ByteArray) { companion object { @@ -32,6 +34,21 @@ class Jpeg(val bytes: ByteArray) { // One bit per pixel, MSB first, rows padded to whole bytes, set bit means black. class Bitonal(val width: Int, val height: Int, val bits: ByteArray) +fun packBitonal(image: Jpeg): Bitonal { + val original = image.toMat() + val gray = Mat() + Imgproc.cvtColor(original, gray, Imgproc.COLOR_BGR2GRAY) + original.release() + + val width = gray.width() + val height = gray.height() + val pixels = ByteArray(width * height) + gray.get(0, 0, pixels) + gray.release() + + return Bitonal(width, height, packBitsMsbFirst(pixels, width, height)) +} + interface ImageLoader { suspend fun load(uri: Uri): Bitmap } diff --git a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt index 11a4f74f..fd4899f5 100644 --- a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt +++ b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt @@ -36,6 +36,8 @@ import org.fairscan.app.data.PdfWriter import org.fairscan.app.domain.Bitonal import org.fairscan.app.domain.OcrService import org.fairscan.app.domain.PageToExport +import org.fairscan.app.domain.packBitonal +import org.fairscan.imageprocessing.ColorMode import org.fairscan.imageprocessing.EstimatedDimensions import org.fairscan.imageprocessing.OcrTextBox import org.fairscan.imageprocessing.PaperFormats @@ -60,7 +62,10 @@ class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : P val ocrDocument = OcrDocument(document, assets) for ((index, page) in pages.withIndex()) { val jpeg = page.jpeg.get() - val image = JPEGFactory.createFromByteArray(document, jpeg.bytes) + val image = if (page.page.colorMode == ColorMode.BLACK_AND_WHITE) + createCcittG4Image(document, packBitonal(jpeg)) + else + JPEGFactory.createFromByteArray(document, jpeg.bytes) // PDF has 72 points (units) per inch, 1 inch = 25.4 mm val pointsPerMm = 72f / 25.4f From d4833bbf8543f9d56fa92f4f9760f1ad95bbdb96 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:48:13 +0200 Subject: [PATCH 05/16] Store PNG files for black and white --- .../java/org/fairscan/app/MainViewModel.kt | 4 +- .../org/fairscan/app/data/ImageRepository.kt | 39 +++++++++++-------- .../fairscan/app/data/ImageTransformations.kt | 7 ++-- .../org/fairscan/app/domain/CapturedPage.kt | 2 +- .../fairscan/app/domain/ExportPreparation.kt | 33 +++++++++------- .../java/org/fairscan/app/domain/Image.kt | 33 +++++++++++++--- .../fairscan/app/platform/AndroidPdfWriter.kt | 8 ++-- .../fairscan/app/platform/ImageProcessor.kt | 35 +++++++++++------ .../app/ui/screens/camera/CameraScreen.kt | 2 +- .../app/ui/screens/export/ExportViewModel.kt | 3 +- .../fairscan/app/ui/state/DocumentUiModel.kt | 4 +- .../org/fairscan/app/data/FileManagerTest.kt | 2 +- .../fairscan/app/data/ImageRepositoryTest.kt | 39 ++++++++++++++----- .../org/fairscan/imageprocessing/Utils.kt | 23 ++++++++--- 14 files changed, 157 insertions(+), 77 deletions(-) diff --git a/app/src/main/java/org/fairscan/app/MainViewModel.kt b/app/src/main/java/org/fairscan/app/MainViewModel.kt index 4642eed4..962f84d8 100644 --- a/app/src/main/java/org/fairscan/app/MainViewModel.kt +++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt @@ -105,7 +105,7 @@ class MainViewModel(val imageRepository: ImageRepository, logger: Logger): ViewM val isLoading = (it.id == loadingId) val canBeCropped = page.metadata != null val bitmap = try { - imageRepository.jpegBytes(it.key())?.toBitmap() + imageRepository.image(it.key())?.toBitmap() } catch (e: Exception) { logger.e("MainViewModel", "Failed to load image for ${it.id}", e) null @@ -231,7 +231,7 @@ class MainViewModel(val imageRepository: ImageRepository, logger: Logger): ViewM val pages = withContext(Dispatchers.IO) { val sourceJpeg = capturedPage.sourceJpeg.await() imageRepository.add( - capturedPage.pageJpeg, + capturedPage.pageImage, sourceJpeg, capturedPage.metadata, capturedPage.colorMode, diff --git a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt index 82d7d5da..bfdc9ca8 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt @@ -27,9 +27,11 @@ 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.EncodedImage import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageViewKey +import org.fairscan.app.domain.Png import org.fairscan.app.domain.Rotation import org.fairscan.app.domain.ScanPage import org.fairscan.imageprocessing.ColorMode @@ -68,8 +70,8 @@ class ImageRepository( private var pages: PageStore = PageStore(loadPages()) private val processingJobs = synchronizedMap(mutableMapOf>()) - private val imageCache = createLruCache>(maxEntries = 50) - private val thumbnailCache = createLruCache>(maxEntries = 1000) + private val imageCache = createLruCache>(maxEntries = 50) + private val thumbnailCache = createLruCache>(maxEntries = 1000) private fun createLruCache(maxEntries: Int): MutableMap = synchronizedMap(object : LinkedHashMap(16, 0.75f, true) { @@ -82,7 +84,7 @@ class ImageRepository( thumbnailDir.deleteRecursively() // clean up dir that was used in older versions normalizeLegacyFiles() val filesOnDisk = processedDir.listFiles() - ?.filter { it.extension == "jpg" } + ?.filter { it.extension == "jpg" || it.extension == "png"} ?.map { it.name } ?.toSet() ?: emptySet() @@ -143,7 +145,7 @@ class ImageRepository( } } - suspend fun add(processed: Jpeg, source: Jpeg, metadata: PageMetadata, colorMode: ColorMode) = + suspend fun add(processed: EncodedImage, source: Jpeg, metadata: PageMetadata, colorMode: ColorMode) = mutex.withLock { val id = "${System.currentTimeMillis()}" val key = PageViewKey(id, Rotation.R0, colorMode, 0) @@ -253,20 +255,19 @@ class ImageRepository( saveMetadata() } - suspend fun jpegBytes(key: PageViewKey): Jpeg? = + suspend fun image(key: PageViewKey): EncodedImage? = getOrCompute(imageCache, key, ::computeProcessedImage) - - suspend fun getThumbnail(key: PageViewKey): Jpeg? = + suspend fun getThumbnail(key: PageViewKey): EncodedImage? = getOrCompute(thumbnailCache, key, ::computeThumbnail) // --- Cache compute functions --- private suspend fun getOrCompute( - cache: MutableMap>, + cache: MutableMap>, key: PageViewKey, - compute: suspend (PageViewKey) -> Jpeg? - ): Jpeg? { + compute: suspend (PageViewKey) -> EncodedImage? + ): EncodedImage? { val deferred = cache.computeIfAbsent(key) { k -> scope.async(Dispatchers.IO) { compute(k) } } @@ -278,21 +279,22 @@ class ImageRepository( } } - private suspend fun computeProcessedImage(key: PageViewKey): Jpeg? = + private suspend fun computeProcessedImage(key: PageViewKey): EncodedImage? = withContext(Dispatchers.IO) { val baseFile = processedImageFile(key) if (!baseFile.exists()) return@withContext null - val baseJpeg = Jpeg(baseFile.readBytes()) + val bytes = baseFile.readBytes() + val baseImage = if (baseFile.extension == "png") Png(bytes) else Jpeg(bytes) if (key.rotation == Rotation.R0) { - baseJpeg + baseImage } else { transformations.rotate( - baseJpeg, + baseImage, key.rotation.degrees) } } - private suspend fun computeThumbnail(key: PageViewKey): Jpeg? = + private suspend fun computeThumbnail(key: PageViewKey): EncodedImage? = withContext(Dispatchers.IO) { val processed = getOrCompute(imageCache, key, ::computeProcessedImage) ?: return@withContext null @@ -313,7 +315,12 @@ class ImageRepository( sb.append(".").append(colorMode.name.lowercase()) if (quadVersion > 0) sb.append(".q").append(quadVersion) - sb.append(".jpg") + + if (colorMode == ColorMode.BLACK_AND_WHITE) + sb.append(".png") + else + sb.append(".jpg") + return sb.toString() } diff --git a/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt b/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt index 12bbf380..2d7e67dd 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt @@ -14,20 +14,21 @@ */ package org.fairscan.app.data +import org.fairscan.app.domain.EncodedImage import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata import org.fairscan.imageprocessing.ColorMode interface ImageTransformations { - fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg + fun rotate(input: EncodedImage, rotationDegrees: Int): EncodedImage - fun resizeToThumbnail(input: Jpeg): Jpeg + fun resizeToThumbnail(input: EncodedImage): EncodedImage fun process( source: Jpeg, metadata: PageMetadata, colorMode: ColorMode - ): Jpeg + ): EncodedImage } \ No newline at end of file diff --git a/app/src/main/java/org/fairscan/app/domain/CapturedPage.kt b/app/src/main/java/org/fairscan/app/domain/CapturedPage.kt index c1676040..3aea84f6 100644 --- a/app/src/main/java/org/fairscan/app/domain/CapturedPage.kt +++ b/app/src/main/java/org/fairscan/app/domain/CapturedPage.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.Deferred import org.fairscan.imageprocessing.ColorMode data class CapturedPage( - val pageJpeg: Jpeg, + val pageImage: EncodedImage, val sourceJpeg: Deferred, val metadata: PageMetadata, val colorMode: ColorMode, diff --git a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt index dbd68428..15fe7326 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt @@ -22,13 +22,13 @@ import org.fairscan.imageprocessing.resizeForMaxPixels import org.fairscan.imageprocessing.scaledTo import org.opencv.core.Mat -fun interface JpegProvider { - suspend fun get(): Jpeg +fun interface ImageProvider { + suspend fun get(): EncodedImage } data class PageToExport( val page: ScanPage, - val jpeg: JpegProvider, + val image: ImageProvider, ) { fun estimatedDimensions(): EstimatedDimensions? { val metadata = page.metadata @@ -61,13 +61,13 @@ suspend fun pagesToExport( val pages = imageRepository.pages() return when (exportQuality) { ExportQuality.BALANCED -> pages.map { - PageToExport(it) { jpeg(it, imageRepository) } + PageToExport(it) { image(it, imageRepository) } } ExportQuality.LOW -> pages.map { page -> PageToExport(page) { - resizeJpegBytesForMaxPixels( - jpeg = jpeg(page, imageRepository), + resizeImageForMaxPixels( + image = image(page, imageRepository), maxPixels = exportQuality.maxPixels.toDouble(), jpegQuality = exportQuality.jpegQuality ) @@ -84,29 +84,32 @@ suspend fun pagesToExport( processedImage(source, metadata, rotation, colorMode, exportQuality) } else - jpeg(page, imageRepository) + image(page, imageRepository) } } } } -private suspend fun jpeg(page: ScanPage, imageRepository: ImageRepository): Jpeg { +private suspend fun image(page: ScanPage, imageRepository: ImageRepository): EncodedImage { val key = page.key() - return imageRepository.jpegBytes(key) - ?: throw IllegalArgumentException("JPEG not found for $key") + return imageRepository.image(key) + ?: throw IllegalArgumentException("Image not found for $key") } -private fun resizeJpegBytesForMaxPixels( - jpeg: Jpeg, +private fun resizeImageForMaxPixels( + image: EncodedImage, maxPixels: Double, jpegQuality: Int -): Jpeg { +): EncodedImage { var decoded: Mat? = null var resized: Mat? = null try { - decoded = jpeg.toMat() + decoded = image.toMat() resized = resizeForMaxPixels(decoded, maxPixels) - return Jpeg.fromMat(resized, jpegQuality) + return when (image) { + is Jpeg -> Jpeg.fromMat(resized, jpegQuality) + is Png -> Png.fromMat(resized) + } } finally { decoded?.release() resized?.release() diff --git a/app/src/main/java/org/fairscan/app/domain/Image.kt b/app/src/main/java/org/fairscan/app/domain/Image.kt index 45e08c28..47caaec8 100644 --- a/app/src/main/java/org/fairscan/app/domain/Image.kt +++ b/app/src/main/java/org/fairscan/app/domain/Image.kt @@ -17,24 +17,47 @@ package org.fairscan.app.domain import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri -import org.fairscan.imageprocessing.decodeJpeg +import org.fairscan.imageprocessing.decodeJpegOrPng import org.fairscan.imageprocessing.encodeJpeg +import org.fairscan.imageprocessing.encodePng import org.fairscan.imageprocessing.packBitsMsbFirst import org.opencv.core.Mat import org.opencv.imgproc.Imgproc -class Jpeg(val bytes: ByteArray) { +sealed class EncodedImage( + open val bytes: ByteArray +) { + fun toBitmap(): Bitmap = + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + + fun toMat(): Mat = + decodeJpegOrPng(bytes) + abstract fun toJpeg(jpegQuality: Int): Jpeg +} + +class Jpeg(override val bytes: ByteArray): EncodedImage(bytes) { companion object { fun fromMat(mat: Mat, jpegQuality: Int): Jpeg = Jpeg(encodeJpeg(mat, jpegQuality)) } - fun toBitmap() : Bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - fun toMat() : Mat = decodeJpeg(bytes) + override fun toJpeg(jpegQuality: Int): Jpeg = this +} + +class Png(override val bytes: ByteArray): EncodedImage(bytes) { + companion object { + fun fromMat(mat: Mat): Png = Png(encodePng(mat)) + } + override fun toJpeg(jpegQuality: Int): Jpeg { + val mat = toMat() + val jpeg = Jpeg.fromMat(mat, jpegQuality) + mat.release() + return jpeg + } } // One bit per pixel, MSB first, rows padded to whole bytes, set bit means black. class Bitonal(val width: Int, val height: Int, val bits: ByteArray) -fun packBitonal(image: Jpeg): Bitonal { +fun packBitonal(image: EncodedImage): Bitonal { val original = image.toMat() val gray = Mat() Imgproc.cvtColor(original, gray, Imgproc.COLOR_BGR2GRAY) diff --git a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt index fd4899f5..3db2faef 100644 --- a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt +++ b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt @@ -61,11 +61,11 @@ class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : P doc.use { document -> val ocrDocument = OcrDocument(document, assets) for ((index, page) in pages.withIndex()) { - val jpeg = page.jpeg.get() + val jpegOrPng = page.image.get() val image = if (page.page.colorMode == ColorMode.BLACK_AND_WHITE) - createCcittG4Image(document, packBitonal(jpeg)) + createCcittG4Image(document, packBitonal(jpegOrPng)) else - JPEGFactory.createFromByteArray(document, jpeg.bytes) + JPEGFactory.createFromByteArray(document, jpegOrPng.bytes) // PDF has 72 points (units) per inch, 1 inch = 25.4 mm val pointsPerMm = 72f / 25.4f @@ -95,7 +95,7 @@ class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : P if (!disableOcr) { try { - val bitmap = jpeg.toBitmap() + val bitmap = jpegOrPng.toBitmap() val ocrTextBoxes = ocrService.runOcr(bitmap) val pdfPageDimensions = PageDimensions( bitmap.width, diff --git a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt index e96ff0ec..3bd7e3af 100644 --- a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt +++ b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt @@ -20,9 +20,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import org.fairscan.app.data.ImageTransformations import org.fairscan.app.domain.CapturedPage +import org.fairscan.app.domain.EncodedImage import org.fairscan.app.domain.ExportQuality import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata +import org.fairscan.app.domain.Png import org.fairscan.app.domain.Rotation import org.fairscan.app.ui.screens.settings.DefaultColorMode import org.fairscan.imageprocessing.ColorMode @@ -46,13 +48,13 @@ import kotlin.math.min class ImageProcessor(private val thumbnailSizePx: Int) : ImageTransformations { - override fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg { + override fun rotate(input: EncodedImage, rotationDegrees: Int): EncodedImage { return transform(input, ExportQuality.BALANCED.jpegQuality) { rotate(it, rotationDegrees) } } - override fun resizeToThumbnail(input: Jpeg): Jpeg { + override fun resizeToThumbnail(input: EncodedImage): EncodedImage { val maxSize = thumbnailSizePx.toFloat() return transform(input, 85) { src -> val ratio = min(maxSize / src.width(), maxSize / src.height()) @@ -70,15 +72,18 @@ class ImageProcessor(private val thumbnailSizePx: Int) : ImageTransformations { } private fun transform( - inJpeg: Jpeg, + inputImage: EncodedImage, jpegQuality: Int, transform: (Mat) -> Mat, - ): Jpeg { - val input = inJpeg.toMat() + ): EncodedImage { + val input = inputImage.toMat() var output: Mat? = null try { output = transform.invoke(input) - return Jpeg.fromMat(output, jpegQuality) + return when (inputImage) { + is Jpeg -> Jpeg.fromMat(output, jpegQuality) + is Png -> Png.fromMat(output) + } } finally { input.release() output?.release() @@ -89,7 +94,7 @@ class ImageProcessor(private val thumbnailSizePx: Int) : ImageTransformations { source: Jpeg, metadata: PageMetadata, colorMode: ColorMode - ): Jpeg { + ): EncodedImage { val baseRotation = metadata.baseRotation return processedImage(source, metadata, baseRotation, colorMode, ExportQuality.BALANCED) } @@ -101,7 +106,7 @@ fun processedImage( rotation: Rotation, colorMode: ColorMode, exportQuality: ExportQuality, -): Jpeg { +): EncodedImage { val rotationDegrees = rotation.degrees var sourceMat: Mat? = null var page: Mat? = null @@ -110,7 +115,7 @@ fun processedImage( val quad = metadata.normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height()) page = extractDocument(sourceMat, quad, rotationDegrees, colorMode, exportQuality.maxPixels, metadata.opticalMeasures) - return Jpeg.fromMat(page, exportQuality.jpegQuality) + return encodeImage(page, colorMode, exportQuality) } finally { sourceMat?.release() page?.release() @@ -154,7 +159,8 @@ fun extractDocumentFromBitmap( opticalMeasures) } - val pageJpeg = Jpeg.fromMat(page, exportQuality.jpegQuality) + val encodedImage = encodeImage(page, colorMode, exportQuality) + bgr.release() page.release() @@ -165,7 +171,14 @@ fun extractDocumentFromBitmap( val sourceJpegDeferred = viewModelScope.async(Dispatchers.IO) { compressSource(source) } - return CapturedPage(pageJpeg, sourceJpegDeferred, metadata, colorMode) + return CapturedPage(encodedImage, sourceJpegDeferred, metadata, colorMode) +} + +private fun encodeImage(image: Mat, colorMode: ColorMode, quality: ExportQuality): EncodedImage { + return if (colorMode == ColorMode.BLACK_AND_WHITE) + Png.fromMat(image) + else + Jpeg.fromMat(image, quality.jpegQuality) } private fun compressSource(source: Bitmap): Jpeg { diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt index 826053ec..9a997093 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt @@ -328,7 +328,7 @@ private fun CameraScreenScaffold( } } if (cameraUiState.captureState is CaptureState.CapturePreview) { - val page = cameraUiState.captureState.capturedPage.pageJpeg.toBitmap() + val page = cameraUiState.captureState.capturedPage.pageImage.toBitmap() CapturedImage(page.asImageBitmap(), thumbnailCoords) } } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt index c2a5bd60..d11038ee 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt @@ -209,7 +209,8 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit preparationDir.mkdirs() val files = pageToExports.mapIndexed { index, page -> val file = File(preparationDir, "$timestamp-${index + 1}.jpg") - file.writeBytes(page.jpeg.get().bytes) + val jpeg = page.image.get().toJpeg(exportQuality.jpegQuality) + file.writeBytes(jpeg.bytes) onProgress(index + 1) file }.toList() diff --git a/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt b/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt index 13bbc604..c4480f42 100644 --- a/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt @@ -17,7 +17,7 @@ package org.fairscan.app.ui.state import android.graphics.Bitmap import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import org.fairscan.app.domain.Jpeg +import org.fairscan.app.domain.EncodedImage import org.fairscan.app.domain.PageViewKey data class DocumentUiModel( @@ -39,5 +39,5 @@ data class DocumentUiModel( data class PageThumbnail( val key: PageViewKey, - val thumbnail: Jpeg?, + val thumbnail: EncodedImage?, ) diff --git a/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt b/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt index 3443f5de..5396d96c 100644 --- a/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt +++ b/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt @@ -82,7 +82,7 @@ class FileManagerTest { onProgress: (Int) -> Unit, ) { val list = pages.toList() - list.forEach { page -> outputStream.write(page.jpeg.get().bytes) } + list.forEach { page -> outputStream.write(page.image.get().bytes) } } } val manager = FileManager(pdfDir, externalDir, fakePdfWriter) diff --git a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt index dbdef516..f7f0c2be 100644 --- a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt +++ b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt @@ -23,15 +23,18 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat +import org.fairscan.app.domain.EncodedImage import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageViewKey +import org.fairscan.app.domain.Png 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.CameraIntrinsics import org.fairscan.imageprocessing.ColorMode +import org.fairscan.imageprocessing.ColorMode.BLACK_AND_WHITE import org.fairscan.imageprocessing.ColorMode.COLOR import org.fairscan.imageprocessing.ColorMode.GRAYSCALE import org.fairscan.imageprocessing.ImageSize @@ -65,16 +68,16 @@ class ImageRepositoryTest { } fun repo( - rotate: (Jpeg, Int) -> Jpeg = { input, _ -> input }, - resizeToThumbnail: (Jpeg) -> Jpeg = { input -> jpeg(input.bytes[0]) }, + rotate: (EncodedImage, Int) -> EncodedImage = { input, _ -> input }, + resizeToThumbnail: (EncodedImage) -> Jpeg = { input -> jpeg(input.bytes[0]) }, process: (Jpeg, PageMetadata, ColorMode) -> Jpeg = { _, _, _ -> throw UnsupportedOperationException() } ): ImageRepository { val transformations = object : ImageTransformations { - override fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg = + override fun rotate(input: EncodedImage, rotationDegrees: Int): EncodedImage = rotate(input, rotationDegrees) - override fun resizeToThumbnail(input: Jpeg): Jpeg = + override fun resizeToThumbnail(input: EncodedImage): Jpeg = resizeToThumbnail(input) override fun process( source: Jpeg, @@ -95,7 +98,7 @@ class ImageRepositoryTest { assertThat(repo.imageIds()).hasSize(1) val id = repo.imageIds()[0] val key = PageViewKey(id, R0, COLOR, 0) - assertThat(repo.jpegBytes(key)).isEqualTo(jpeg) + assertThat(repo.image(key)).isEqualTo(jpeg) assertThat(repo.getThumbnail(key)?.bytes).isEqualTo(byteArrayOf(101)) val page = repo.pages().first() @@ -161,7 +164,7 @@ class ImageRepositoryTest { File(processedDir(), "1-90.jpg").writeBytes(bytes) val repo = repo() assertThat(repo.imageIds()).containsExactly("1") - assertThat(repo.jpegBytes(PageViewKey("1", R0, null, 0))?.bytes).isEqualTo(bytes) + assertThat(repo.image(PageViewKey("1", R0, null, 0))?.bytes).isEqualTo(bytes) } @Test @@ -190,14 +193,14 @@ class ImageRepositoryTest { File(processedDir(), "1-90.jpg").writeBytes(bytes) val repo = repo() assertThat(repo.imageIds()).containsExactly("1") - assertThat(repo.jpegBytes(PageViewKey("1", R0, null, 0))?.bytes).isEqualTo(bytes) + assertThat(repo.image(PageViewKey("1", R0, null, 0))?.bytes).isEqualTo(bytes) } @Test fun `should return null on invalid id`() = runTest { val repo = repo() assertThat(repo.imageIds()).isEmpty() - assertThat(repo.jpegBytes(PageViewKey("x", R0, COLOR, 0))).isNull() + assertThat(repo.image(PageViewKey("x", R0, COLOR, 0))).isNull() } @Test @@ -258,7 +261,7 @@ class ImageRepositoryTest { repo.setColorMode(id, GRAYSCALE) assertThat(repo.pages().first().colorMode).isEqualTo(GRAYSCALE) val key = PageViewKey(id, R0, GRAYSCALE, 0) - assertThat(repo.jpegBytes(key)?.bytes).isEqualTo(byteArrayOf(41)) + assertThat(repo.image(key)?.bytes).isEqualTo(byteArrayOf(41)) } @Test @@ -278,7 +281,7 @@ class ImageRepositoryTest { launch { repo.setColorMode(id, GRAYSCALE) } } val key = PageViewKey(id, R0, GRAYSCALE, 0) - assertThat(repo.jpegBytes(key)?.bytes).isEqualTo(byteArrayOf(1)) + assertThat(repo.image(key)?.bytes).isEqualTo(byteArrayOf(1)) assertThat(processCalls).isEqualTo(1) } @@ -365,6 +368,21 @@ class ImageRepositoryTest { assertThat(repo2.lastAddedSourceFile()).isNull() } + @Test + fun png_image() = runTest { + val repo = repo() + val png = png(101, 102, 103) + repo.add(png, jpeg(51), metadata1, ColorMode.BLACK_AND_WHITE) + assertThat(repo.imageIds()).hasSize(1) + val id = repo.imageIds()[0] + val key = PageViewKey(id, R0, BLACK_AND_WHITE, 0) + assertThat(repo.image(key)).isEqualTo(png) + assertThat(repo.getThumbnail(key)?.bytes).isEqualTo(byteArrayOf(101)) + + val repo2 = repo() + assertThat(repo2.imageIds()).containsExactly(id) + } + private fun processedDir(): File = File(getFilesDir(), PROCESSED_DIR_NAME) private fun sourceDir(): File = File(getFilesDir(), SOURCE_DIR_NAME) @@ -380,4 +398,5 @@ class ImageRepositoryTest { pages().map { it.id }.toPersistentList() private fun jpeg(vararg bytes: Byte) = Jpeg(bytes) + private fun png(vararg bytes: Byte) = Png(bytes) } diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt index eca3f8ad..61b08a2c 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt @@ -35,15 +35,28 @@ fun resizeForMaxPixels(img: Mat, maxPixels: Double, interpolation: Int = Imgproc return resizedImg } +enum class ImageEncoding(val fileNameExtension: String) { + JPEG(".jpg"), PNG(".png") +} + fun encodeJpeg(mat: Mat, jpegQuality: Int): ByteArray { val params = MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, jpegQuality.coerceIn(0, 100)) + return encode(mat, params, ImageEncoding.JPEG) +} + +fun encodePng(mat: Mat): ByteArray { + val params = MatOfInt(Imgcodecs.IMWRITE_PNG_COMPRESSION, 1) + return encode(mat, params, ImageEncoding.PNG) +} + +private fun encode(mat: Mat, params: MatOfInt, encoding: ImageEncoding): ByteArray { val encoded = MatOfByte() - val ok = Imgcodecs.imencode(".jpg", mat, encoded, params) + val ok = Imgcodecs.imencode(encoding.fileNameExtension, mat, encoded, params) params.release() if (!ok) { encoded.release() - throw IOException("Failed to encode JPEG") + throw IOException("Failed to encode $encoding") } val result = encoded.toArray() @@ -51,13 +64,13 @@ fun encodeJpeg(mat: Mat, jpegQuality: Int): ByteArray { return result } -fun decodeJpeg(jpegBytes: ByteArray): Mat { - val src = MatOfByte(*jpegBytes) +fun decodeJpegOrPng(bytes: ByteArray): Mat { + val src = MatOfByte(*bytes) val decoded = Imgcodecs.imdecode(src, Imgcodecs.IMREAD_COLOR) src.release() if (decoded.empty()) { decoded.release() - throw IllegalStateException("Failed to decode JPEG") + throw IllegalStateException("Failed to decode JPEG/PNG") } return decoded } From a7144d312bbb056a08024088321f5b38a8a39275 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:33:26 +0200 Subject: [PATCH 06/16] =?UTF-8?q?[B&W]=20Upscale=204=C3=97=20before=20bina?= =?UTF-8?q?rization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/fairscan/imageprocessing/PostProcessing.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt index 744fa5a9..2ed30b48 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt @@ -360,11 +360,11 @@ private const val FILL_HOLE_FACTOR = 5 // Returns BGR containing only 0 and 255, like the other color modes, so that storage and // preview stay unchanged. The export path packs it into one bit per pixel. -fun binarizeDocument(img: Mat, upscaleTo: Long = 0L): Mat { +fun binarizeDocument(img: Mat): Mat { // Flatten the illumination at the captured resolution. Interpolated pixels carry no extra // information for that step, only for where the threshold puts an edge. val flattened = flattenedGrayscale(img) - val gray = upscaleToPixels(flattened, upscaleTo) + val gray = upscaleToPixels(flattened, 4L * img.width() * img.height()) flattened.release() val window = sauvolaWindow(max(gray.cols(), gray.rows())) From ae4502354f22ccda820fc241a731449f111b86dd Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Wed, 16 Sep 2026 07:37:06 +0200 Subject: [PATCH 07/16] =?UTF-8?q?Revert=20"[B&W]=20Upscale=204=C3=97=20bef?= =?UTF-8?q?ore=20binarization"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit a7144d312bbb056a08024088321f5b38a8a39275. --- .../main/java/org/fairscan/imageprocessing/PostProcessing.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt index 2ed30b48..744fa5a9 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt @@ -360,11 +360,11 @@ private const val FILL_HOLE_FACTOR = 5 // Returns BGR containing only 0 and 255, like the other color modes, so that storage and // preview stay unchanged. The export path packs it into one bit per pixel. -fun binarizeDocument(img: Mat): Mat { +fun binarizeDocument(img: Mat, upscaleTo: Long = 0L): Mat { // Flatten the illumination at the captured resolution. Interpolated pixels carry no extra // information for that step, only for where the threshold puts an edge. val flattened = flattenedGrayscale(img) - val gray = upscaleToPixels(flattened, 4L * img.width() * img.height()) + val gray = upscaleToPixels(flattened, upscaleTo) flattened.release() val window = sauvolaWindow(max(gray.cols(), gray.rows())) From d47c0bc0ac0e607879d2e1b39d2db7b92510816a Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:22:11 +0200 Subject: [PATCH 08/16] [B&W] Simplify binarization, change Sauvola window --- .../imageprocessing/PostProcessing.kt | 140 +----------------- .../BinarizationParametersTest.kt | 19 +-- 2 files changed, 5 insertions(+), 154 deletions(-) diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt index 744fa5a9..b3af791e 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt @@ -19,13 +19,11 @@ import org.opencv.core.CvType import org.opencv.core.Mat import org.opencv.core.MatOfFloat import org.opencv.core.MatOfInt -import org.opencv.core.Rect import org.opencv.core.Scalar import org.opencv.core.Size import org.opencv.imgproc.Imgproc import kotlin.math.max import kotlin.math.min -import kotlin.math.roundToInt import kotlin.math.sqrt enum class ColorMode { @@ -348,16 +346,6 @@ private fun flattenedGrayscale(img: Mat): Mat { private const val SAUVOLA_K = 0.25 private const val SAUVOLA_R = 128.0 -// Window and deviation below which an area counts as a flat fill rather than texture. -private const val FILL_WINDOW = 9.0 -private const val FILL_DEVIATION = 12.0 - -// Step 4 of the grayscale pipeline stretches the page background to white. -private const val FILL_LEVEL = 155.0 - -// How much larger a hole may be inside a fill before it counts as content rather than glare. -private const val FILL_HOLE_FACTOR = 5 - // Returns BGR containing only 0 and 255, like the other color modes, so that storage and // preview stay unchanged. The export path packs it into one bit per pixel. fun binarizeDocument(img: Mat, upscaleTo: Long = 0L): Mat { @@ -373,15 +361,8 @@ fun binarizeDocument(img: Mat, upscaleTo: Long = 0L): Mat { gray.release() val binary = sauvolaThreshold(src, window) - val fill = flatFill(src, window) src.release() - // A local threshold has no reference point inside a flat fill of color, so the fill reads - // as background and comes out as an outline of itself. - Core.subtract(binary, fill, binary) - despeckle(binary, fill, window) - fill.release() - val bgr = Mat() Imgproc.cvtColor(binary, bgr, Imgproc.COLOR_GRAY2BGR) binary.release() @@ -431,123 +412,4 @@ private fun sauvolaThreshold(src: Mat, window: Int): Mat { return binary } -// Dark pixels belonging to a flat fill: a smooth dark spot seeds the fill, the seed grows over -// the area one local window covers, and the result is clipped back to the dark pixels. Texture -// produces almost no seeds, so photographs stay on the local threshold. -private fun flatFill(src: Mat, window: Int): Mat { - val dark = Mat() - Core.compare(src, Scalar(FILL_LEVEL), dark, Core.CMP_LT) - - val deviation = localDeviation(src, FILL_WINDOW) - val seeds = Mat() - Core.compare(deviation, Scalar(FILL_DEVIATION), seeds, Core.CMP_LT) - deviation.release() - Core.bitwise_and(seeds, dark, seeds) - - // Half the local window is enough: that is how far the threshold is disturbed around a - // bright feature sitting on the fill. - val reach = (window / 2).coerceAtLeast(3).toDouble() - val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_ELLIPSE, Size(reach, reach)) - val fill = Mat() - Imgproc.dilate(seeds, fill, kernel) - seeds.release(); kernel.release() - - Core.bitwise_and(fill, dark, fill) - dark.release() - return fill -} - -// Standard deviation of src over a square window, as CV_32F. -private fun localDeviation(src: Mat, window: Double): Mat { - val windowSize = Size(window, window) - val mean = Mat() - Imgproc.boxFilter(src, mean, CvType.CV_32F, windowSize) - val squares = Mat() - Core.multiply(src, src, squares) - val deviation = Mat() - Imgproc.boxFilter(squares, deviation, CvType.CV_32F, windowSize) - squares.release() - Core.multiply(mean, mean, mean) - Core.subtract(deviation, mean, deviation) - mean.release() - Core.max(deviation, Scalar(0.0), deviation) - Core.sqrt(deviation, deviation) - return deviation -} - -// About two to three times the cap height of body text at any of the export resolutions. -internal fun sauvolaWindow(maxDim: Int): Int = (maxDim / 60).coerceIn(15, 101) or 1 - -// Grows with the square of the resolution, anchored so that at 300 dpi anything up to 3x3 is -// removed while a full stop, about 35 px, survives. -internal fun despeckleMinArea(maxDim: Int): Int { - val scale = maxDim / 3508.0 - return max(2, (12.0 * scale * scale).roundToInt()) -} - -// Removes specks of both kinds: ink on paper, and the holes that glare punches into a fill. -private fun despeckle(binary: Mat, fill: Mat, window: Int) { - val minArea = despeckleMinArea(max(binary.cols(), binary.rows())) - removeSpecks(binary, minArea, ink = true) - removeSpecks(binary, minArea, ink = false) - - // Glare and uneven printing punch holes into a filled area that are far bigger than the - // tiny ones above, and about the size of a letter counter. A counter never sits inside a - // fill though, so within one the size limit can be raised without eating any text. - val reach = (window / 4).coerceAtLeast(3).toDouble() - val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_ELLIPSE, Size(reach, reach)) - val inside = Mat() - Imgproc.dilate(fill, inside, kernel) - kernel.release() - removeSpecks(binary, minArea * FILL_HOLE_FACTOR, ink = false, within = inside) - inside.release() -} - -// Drops connected areas below minArea, optionally only those centred in `within`. Only the -// bounding box of each speck is touched, never the whole image. -private fun removeSpecks(binary: Mat, minArea: Int, ink: Boolean, within: Mat? = null) { - val subject = Mat() - if (ink) Core.bitwise_not(binary, subject) else binary.copyTo(subject) - - val labels = Mat() - val stats = Mat() - val centroids = Mat() - val count = Imgproc.connectedComponentsWithStats( - subject, labels, stats, centroids, 8, CvType.CV_32S) - subject.release() - - if (count > 1) { - val statsData = IntArray(count * 5) - stats.get(0, 0, statsData) - val centres = DoubleArray(count * 2) - centroids.get(0, 0, centres) - val replacement = Scalar(if (ink) 255.0 else 0.0) - - for (label in 1 until count) { - val offset = label * 5 - if (statsData[offset + Imgproc.CC_STAT_AREA] >= minArea) continue - if (within != null && !isSet(within, centres[label * 2], centres[label * 2 + 1])) - continue - val box = Rect( - statsData[offset + Imgproc.CC_STAT_LEFT], - statsData[offset + Imgproc.CC_STAT_TOP], - statsData[offset + Imgproc.CC_STAT_WIDTH], - statsData[offset + Imgproc.CC_STAT_HEIGHT], - ) - val labelBox = labels.submat(box) - val speck = Mat() - Core.compare(labelBox, Scalar(label.toDouble()), speck, Core.CMP_EQ) - val target = binary.submat(box) - target.setTo(replacement, speck) - labelBox.release(); speck.release(); target.release() - } - } - - labels.release(); stats.release(); centroids.release() -} - -private fun isSet(mask: Mat, x: Double, y: Double): Boolean { - val col = x.toInt().coerceIn(0, mask.cols() - 1) - val row = y.toInt().coerceIn(0, mask.rows() - 1) - return mask.get(row, col)[0] != 0.0 -} +internal fun sauvolaWindow(maxDim: Int): Int = (maxDim / 10).coerceIn(15, 1001) or 1 diff --git a/imageprocessing/src/test/java/org/fairscan/imageprocessing/BinarizationParametersTest.kt b/imageprocessing/src/test/java/org/fairscan/imageprocessing/BinarizationParametersTest.kt index 08f4f1de..e8774be6 100644 --- a/imageprocessing/src/test/java/org/fairscan/imageprocessing/BinarizationParametersTest.kt +++ b/imageprocessing/src/test/java/org/fairscan/imageprocessing/BinarizationParametersTest.kt @@ -29,15 +29,15 @@ class BinarizationParametersTest { @Test fun sauvola_window_is_clamped_at_both_ends() { assertThat(sauvolaWindow(1)).isEqualTo(15) - assertThat(sauvolaWindow(100_000)).isEqualTo(101) + assertThat(sauvolaWindow(100_000)).isEqualTo(1001) } @Test fun sauvola_window_grows_with_resolution() { // A4 at roughly 150, 200 and 300 dpi - assertThat(sauvolaWindow(1189)).isEqualTo(19) - assertThat(sauvolaWindow(1682)).isEqualTo(29) - assertThat(sauvolaWindow(3508)).isEqualTo(59) + assertThat(sauvolaWindow(1189)).isEqualTo(119) + assertThat(sauvolaWindow(1682)).isEqualTo(169) + assertThat(sauvolaWindow(3508)).isEqualTo(351) } @Test @@ -50,15 +50,4 @@ class BinarizationParametersTest { } } - @Test - fun despeckle_area_grows_with_the_square_of_the_resolution() { - assertThat(despeckleMinArea(1189)).isEqualTo(2) - assertThat(despeckleMinArea(1682)).isEqualTo(3) - assertThat(despeckleMinArea(3508)).isEqualTo(12) - } - - @Test - fun despeckle_area_keeps_a_lower_bound() { - assertThat(despeckleMinArea(1)).isEqualTo(2) - } } From 8f61ac0863aa507522bb34f0c1dc877ebfbeebf2 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:07:48 +0200 Subject: [PATCH 09/16] [B&W] Increase resolution --- .../java/org/fairscan/app/domain/ExportPreparation.kt | 3 ++- .../java/org/fairscan/app/domain/ExportQuality.kt | 11 +++++++++-- .../java/org/fairscan/app/platform/ImageProcessor.kt | 6 +++--- .../org/fairscan/imageprocessing/DocumentDetection.kt | 2 +- .../org/fairscan/imageprocessing/PostProcessing.kt | 4 ++-- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt index 15fe7326..a2dd051d 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt @@ -16,6 +16,7 @@ package org.fairscan.app.domain import org.fairscan.app.data.ImageRepository import org.fairscan.app.platform.processedImage +import org.fairscan.imageprocessing.ColorMode import org.fairscan.imageprocessing.EstimatedDimensions import org.fairscan.imageprocessing.estimateRealDimensions import org.fairscan.imageprocessing.resizeForMaxPixels @@ -68,7 +69,7 @@ suspend fun pagesToExport( PageToExport(page) { resizeImageForMaxPixels( image = image(page, imageRepository), - maxPixels = exportQuality.maxPixels.toDouble(), + maxPixels = exportQuality.maxPixels(page.colorMode ?: ColorMode.COLOR).toDouble(), jpegQuality = exportQuality.jpegQuality ) } diff --git a/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt b/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt index 0a6c5cdb..c11295c0 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt @@ -15,10 +15,11 @@ package org.fairscan.app.domain import org.fairscan.app.R +import org.fairscan.imageprocessing.ColorMode enum class ExportQuality( val jpegQuality: Int, - val maxPixels: Long, + private val maxPixels: Long, val labelResource: Int ) { LOW( @@ -35,5 +36,11 @@ enum class ExportQuality( jpegQuality = 80, maxPixels = 4_000_000, R.string.export_quality_high, - ) + ); + fun maxPixels(colorMode: ColorMode) = + if (colorMode == ColorMode.BLACK_AND_WHITE) + maxPixels * 4 + else + maxPixels + } diff --git a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt index 3bd7e3af..918db489 100644 --- a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt +++ b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt @@ -113,7 +113,7 @@ fun processedImage( try { sourceMat = source.toMat() val quad = metadata.normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height()) - page = extractDocument(sourceMat, quad, rotationDegrees, colorMode, exportQuality.maxPixels, + page = extractDocument(sourceMat, quad, rotationDegrees, colorMode, exportQuality.maxPixels(colorMode), metadata.opticalMeasures) return encodeImage(page, colorMode, exportQuality) } finally { @@ -147,7 +147,7 @@ fun extractDocumentFromBitmap( if (mask == null || quadInMask == null) { // No document detected - val resized = resizeForMaxPixels(bgr, exportQuality.maxPixels.toDouble()) + val resized = resizeForMaxPixels(bgr, exportQuality.maxPixels(ColorMode.COLOR).toDouble()) page = rotate(resized, rotationDegrees) resized.release() } else { @@ -155,7 +155,7 @@ fun extractDocumentFromBitmap( normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1) autoColorMode = autoColorMode(bgr, mask, quad) colorMode = defaultColorMode.colorMode ?: autoColorMode - page = extractDocument(bgr, quad, rotationDegrees, colorMode, exportQuality.maxPixels, + page = extractDocument(bgr, quad, rotationDegrees, colorMode, exportQuality.maxPixels(colorMode), opticalMeasures) } diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt index 1a7bbaf2..9db528b1 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt @@ -193,7 +193,7 @@ fun extractDocument( Imgproc.warpPerspective(inputMat, warped, transform, outputSize) val resized = resizeForMaxPixels(warped, maxPixels.toDouble()) - val enhanced = enhanceCapturedImage(resized, colorMode) + val enhanced = enhanceCapturedImage(resized, colorMode, maxPixels) val rotated = rotate(enhanced, rotationDegrees) warped.release() diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt index b3af791e..9c6a681a 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt @@ -32,11 +32,11 @@ enum class ColorMode { BLACK_AND_WHITE, } -fun enhanceCapturedImage(img: Mat, colorMode: ColorMode): Mat { +fun enhanceCapturedImage(img: Mat, colorMode: ColorMode, maxPixels: Long = 0L): Mat { return when (colorMode) { ColorMode.COLOR -> multiScaleRetinexOnL(img) ColorMode.GRAYSCALE -> enhanceGrayscaleImage(img) - ColorMode.BLACK_AND_WHITE -> binarizeDocument(img) + ColorMode.BLACK_AND_WHITE -> binarizeDocument(img, maxPixels) } } From f0e71e72e3d8055513911d97f4e855548593cead Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:19:03 +0200 Subject: [PATCH 10/16] Export B&W JPEG at the same resolution as other color modes --- .../java/org/fairscan/app/data/FileManager.kt | 5 ++- .../fairscan/app/domain/ExportPreparation.kt | 37 ++++++++++++++++--- .../java/org/fairscan/app/domain/Image.kt | 8 ---- .../fairscan/app/platform/AndroidPdfWriter.kt | 3 +- .../app/ui/screens/export/ExportViewModel.kt | 8 ++-- 5 files changed, 41 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/org/fairscan/app/data/FileManager.kt b/app/src/main/java/org/fairscan/app/data/FileManager.kt index e0a8a361..734ac0af 100644 --- a/app/src/main/java/org/fairscan/app/data/FileManager.kt +++ b/app/src/main/java/org/fairscan/app/data/FileManager.kt @@ -14,6 +14,7 @@ */ package org.fairscan.app.data +import org.fairscan.app.domain.EncodedImage import org.fairscan.app.domain.PageToExport import java.io.File import java.io.FileOutputStream @@ -27,7 +28,7 @@ data class GeneratedPdf( fun interface PdfWriter { suspend fun writePdfFromJpegs( - pages: List, + pages: List>, outputStream: OutputStream, disableOcr: Boolean, onProgress: (Int) -> Unit, @@ -49,7 +50,7 @@ class FileManager( } suspend fun generatePdf( - pages: List, + pages: List>, disableOcr: Boolean, onProgress: (Int) -> Unit ): GeneratedPdf { diff --git a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt index a2dd051d..498c359e 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt @@ -23,13 +23,13 @@ import org.fairscan.imageprocessing.resizeForMaxPixels import org.fairscan.imageprocessing.scaledTo import org.opencv.core.Mat -fun interface ImageProvider { - suspend fun get(): EncodedImage +fun interface ImageProvider { + suspend fun get(): T } -data class PageToExport( +data class PageToExport( val page: ScanPage, - val image: ImageProvider, + val image: ImageProvider, ) { fun estimatedDimensions(): EstimatedDimensions? { val metadata = page.metadata @@ -57,7 +57,7 @@ private fun EstimatedDimensions.applyRotation(rotation: Rotation): EstimatedDime suspend fun pagesToExport( imageRepository: ImageRepository, exportQuality: ExportQuality -): List { +): List> { val pages = imageRepository.pages() return when (exportQuality) { @@ -91,6 +91,33 @@ suspend fun pagesToExport( } } +suspend fun jpegsToExport( + imageRepository: ImageRepository, + exportQuality: ExportQuality +): List> { + return pagesToExport(imageRepository, exportQuality).map { + PageToExport( + it.page, + { + val image = it.image.get() + when (image) { + is Jpeg -> image + is Png -> resizeToJpeg(image, exportQuality) + } + }) + } +} + +fun resizeToJpeg(png: Png, exportQuality: ExportQuality): Jpeg { + val input = png.toMat() + // For JPEG, we resize to the same size as for ColorMode.COLOR + val resized = resizeForMaxPixels(input, exportQuality.maxPixels(ColorMode.COLOR).toDouble()) + val jpeg = Jpeg.fromMat(resized, exportQuality.jpegQuality) + input.release() + resized.release() + return jpeg +} + private suspend fun image(page: ScanPage, imageRepository: ImageRepository): EncodedImage { val key = page.key() return imageRepository.image(key) diff --git a/app/src/main/java/org/fairscan/app/domain/Image.kt b/app/src/main/java/org/fairscan/app/domain/Image.kt index 47caaec8..d267961d 100644 --- a/app/src/main/java/org/fairscan/app/domain/Image.kt +++ b/app/src/main/java/org/fairscan/app/domain/Image.kt @@ -32,26 +32,18 @@ sealed class EncodedImage( fun toMat(): Mat = decodeJpegOrPng(bytes) - abstract fun toJpeg(jpegQuality: Int): Jpeg } class Jpeg(override val bytes: ByteArray): EncodedImage(bytes) { companion object { fun fromMat(mat: Mat, jpegQuality: Int): Jpeg = Jpeg(encodeJpeg(mat, jpegQuality)) } - override fun toJpeg(jpegQuality: Int): Jpeg = this } class Png(override val bytes: ByteArray): EncodedImage(bytes) { companion object { fun fromMat(mat: Mat): Png = Png(encodePng(mat)) } - override fun toJpeg(jpegQuality: Int): Jpeg { - val mat = toMat() - val jpeg = Jpeg.fromMat(mat, jpegQuality) - mat.release() - return jpeg - } } // One bit per pixel, MSB first, rows padded to whole bytes, set bit means black. diff --git a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt index 3db2faef..5abfbb31 100644 --- a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt +++ b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt @@ -34,6 +34,7 @@ import com.tom_roush.pdfbox.pdmodel.graphics.image.PDImageXObject import org.fairscan.app.BuildConfig import org.fairscan.app.data.PdfWriter import org.fairscan.app.domain.Bitonal +import org.fairscan.app.domain.EncodedImage import org.fairscan.app.domain.OcrService import org.fairscan.app.domain.PageToExport import org.fairscan.app.domain.packBitonal @@ -50,7 +51,7 @@ import java.util.Locale class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : PdfWriter { override suspend fun writePdfFromJpegs( - pages: List, + pages: List>, outputStream: OutputStream, disableOcr: Boolean, onProgress: (Int) -> Unit, diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt index d11038ee..c81b790b 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt @@ -46,6 +46,7 @@ import org.fairscan.app.data.FileManager import org.fairscan.app.data.ImageRepository import org.fairscan.app.domain.ExportQuality import org.fairscan.app.domain.PageViewKey +import org.fairscan.app.domain.jpegsToExport import org.fairscan.app.domain.pagesToExport import org.fairscan.app.ui.screens.settings.ExportFormat import org.fairscan.app.ui.screens.settings.ExportFormat.PDF @@ -204,13 +205,12 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit exportQuality: ExportQuality, onProgress: (Int) -> Unit, ): ExportResult.Jpeg = withContext(Dispatchers.IO) { - val pageToExports = pagesToExport(imageRepository, exportQuality) + val jpegPages = jpegsToExport(imageRepository, exportQuality) val timestamp = System.currentTimeMillis() preparationDir.mkdirs() - val files = pageToExports.mapIndexed { index, page -> + val files = jpegPages.mapIndexed { index, page -> val file = File(preparationDir, "$timestamp-${index + 1}.jpg") - val jpeg = page.image.get().toJpeg(exportQuality.jpegQuality) - file.writeBytes(jpeg.bytes) + file.writeBytes(page.image.get().bytes) onProgress(index + 1) file }.toList() From 7cb78976e73c2dbe13030ddbc524d04d58993721 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:56:33 +0200 Subject: [PATCH 11/16] Remove icons for color modes --- .../fairscan/app/ui/screens/document/DocumentScreen.kt | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt index 2bdab56a..788c3532 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt @@ -302,7 +302,6 @@ fun ColorModeButton( ColorMode.entries.forEach { colorMode -> DropdownMenuItem( text = { Text(stringResource(colorMode.labelResource)) }, - leadingIcon = { Icon(colorMode.icon, contentDescription = null) }, onClick = { onColorModeSelected(colorMode) expanded = false @@ -325,13 +324,6 @@ private val ColorMode.labelResource: Int BLACK_AND_WHITE -> R.string.color_mode_black_and_white } -private val ColorMode.icon: ImageVector - get() = when (this) { - COLOR -> Icons.Default.Palette - GRAYSCALE -> Icons.Default.Contrast - BLACK_AND_WHITE -> Icons.Default.FontDownload - } - @Composable private fun BottomBar( onExportClick: () -> Unit, From 8932b283ac3274a840bad84e1f4cf2199e4ad9ce Mon Sep 17 00:00:00 2001 From: toolicious <291496779+toolicious@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:21:24 +0200 Subject: [PATCH 12/16] Keep flat fills black with the large window --- .../imageprocessing/PostProcessing.kt | 147 ++++++++++++++++++ .../BinarizationParametersTest.kt | 11 ++ 2 files changed, 158 insertions(+) diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt index 9c6a681a..bd4d26f0 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt @@ -19,11 +19,13 @@ import org.opencv.core.CvType import org.opencv.core.Mat import org.opencv.core.MatOfFloat import org.opencv.core.MatOfInt +import org.opencv.core.Rect import org.opencv.core.Scalar import org.opencv.core.Size import org.opencv.imgproc.Imgproc import kotlin.math.max import kotlin.math.min +import kotlin.math.roundToInt import kotlin.math.sqrt enum class ColorMode { @@ -361,8 +363,15 @@ fun binarizeDocument(img: Mat, upscaleTo: Long = 0L): Mat { gray.release() val binary = sauvolaThreshold(src, window) + val fill = flatFill(src, window) src.release() + // A local threshold has no reference point inside a large flat fill: further than half a + // window from paper the mean is the fill itself, and the fill comes out white. + Core.subtract(binary, fill, binary) + fillHoles(binary, fill, window) + fill.release() + val bgr = Mat() Imgproc.cvtColor(binary, bgr, Imgproc.COLOR_GRAY2BGR) binary.release() @@ -413,3 +422,141 @@ private fun sauvolaThreshold(src: Mat, window: Int): Mat { } internal fun sauvolaWindow(maxDim: Int): Int = (maxDim / 10).coerceIn(15, 1001) or 1 + +// Window and deviation below which an area counts as a flat fill rather than texture. +private const val FILL_WINDOW = 9.0 +private const val FILL_DEVIATION = 12.0 + +// Step 4 of the grayscale pipeline stretches the page background to white. +private const val FILL_LEVEL = 155.0 + +// Dark pixels belonging to a flat fill: a smooth dark spot seeds the fill, the seed grows over +// the area one local window covers, and the result is clipped back to the dark pixels. Texture +// produces almost no seeds, so photographs stay on the local threshold. +private fun flatFill(src: Mat, window: Int): Mat { + val dark = Mat() + Core.compare(src, Scalar(FILL_LEVEL), dark, Core.CMP_LT) + + val deviation = localDeviation(src, FILL_WINDOW) + val seeds = Mat() + Core.compare(deviation, Scalar(FILL_DEVIATION), seeds, Core.CMP_LT) + deviation.release() + Core.bitwise_and(seeds, dark, seeds) + + // Half the local window is enough: that is how far the threshold is disturbed around a + // bright feature sitting on the fill. A rectangle keeps the dilation fast at this size. + val reach = (window / 2).coerceAtLeast(3).toDouble() + val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(reach, reach)) + val fill = Mat() + Imgproc.dilate(seeds, fill, kernel) + seeds.release(); kernel.release() + + Core.bitwise_and(fill, dark, fill) + dark.release() + return fill +} + +// Standard deviation of src over a square window, as CV_32F. +private fun localDeviation(src: Mat, window: Double): Mat { + val windowSize = Size(window, window) + val mean = Mat() + Imgproc.boxFilter(src, mean, CvType.CV_32F, windowSize) + val squares = Mat() + Core.multiply(src, src, squares) + val deviation = Mat() + Imgproc.boxFilter(squares, deviation, CvType.CV_32F, windowSize) + squares.release() + Core.multiply(mean, mean, mean) + Core.subtract(deviation, mean, deviation) + mean.release() + Core.max(deviation, Scalar(0.0), deviation) + Core.sqrt(deviation, deviation) + return deviation +} + +// Grows with the square of the resolution: at 300 dpi anything up to 3x3 counts as a speck. +internal fun despeckleMinArea(maxDim: Int): Int { + val scale = maxDim / 3508.0 + return max(2, (12.0 * scale * scale).roundToInt()) +} + +// How much larger than a speck a hole in a fill may be before it counts as content. +private const val FILL_HOLE_FACTOR = 5 + +// Smallest hole, in multiples of the speck size, that counts as a letter of light print. +private const val LETTER_AREA_FACTOR = 4 + +// Glare and uneven printing punch small holes into a filled area. The dot on an i in light +// print is just as small, but it sits right next to its letter, so only holes with no letter +// close by are closed. +private fun fillHoles(binary: Mat, fill: Mat, window: Int) { + val minArea = despeckleMinArea(max(binary.cols(), binary.rows())) + + val letters = binary.clone() + removeSpecks(letters, minArea * LETTER_AREA_FACTOR, ink = false) + val nearLetters = Mat() + // The dot on an i sits about its own size above the stem: reach twice that far. + val dotGap = (8 * sqrt(minArea.toDouble())).coerceAtLeast(3.0) + Imgproc.dilate(letters, nearLetters, Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(dotGap, dotGap))) + letters.release() + + val reach = (window / 4).coerceAtLeast(3).toDouble() + val inside = Mat() + Imgproc.dilate(fill, inside, Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(reach, reach))) + val lonely = Mat() + Core.bitwise_not(nearLetters, lonely) + Core.bitwise_and(inside, lonely, inside) + nearLetters.release(); lonely.release() + + removeSpecks(binary, minArea * FILL_HOLE_FACTOR, ink = false, within = inside) + inside.release() +} + +// Drops connected areas below minArea, optionally only those centred in `within`. Only the +// bounding box of each speck is touched, never the whole image. +private fun removeSpecks(binary: Mat, minArea: Int, ink: Boolean, within: Mat? = null) { + val subject = Mat() + if (ink) Core.bitwise_not(binary, subject) else binary.copyTo(subject) + + val labels = Mat() + val stats = Mat() + val centroids = Mat() + val count = Imgproc.connectedComponentsWithStats( + subject, labels, stats, centroids, 8, CvType.CV_32S) + subject.release() + + if (count > 1) { + val statsData = IntArray(count * 5) + stats.get(0, 0, statsData) + val centres = DoubleArray(count * 2) + centroids.get(0, 0, centres) + val replacement = Scalar(if (ink) 255.0 else 0.0) + + for (label in 1 until count) { + val offset = label * 5 + if (statsData[offset + Imgproc.CC_STAT_AREA] >= minArea) continue + if (within != null && !isSet(within, centres[label * 2], centres[label * 2 + 1])) + continue + val box = Rect( + statsData[offset + Imgproc.CC_STAT_LEFT], + statsData[offset + Imgproc.CC_STAT_TOP], + statsData[offset + Imgproc.CC_STAT_WIDTH], + statsData[offset + Imgproc.CC_STAT_HEIGHT], + ) + val labelBox = labels.submat(box) + val speck = Mat() + Core.compare(labelBox, Scalar(label.toDouble()), speck, Core.CMP_EQ) + val target = binary.submat(box) + target.setTo(replacement, speck) + labelBox.release(); speck.release(); target.release() + } + } + + labels.release(); stats.release(); centroids.release() +} + +private fun isSet(mask: Mat, x: Double, y: Double): Boolean { + val col = x.toInt().coerceIn(0, mask.cols() - 1) + val row = y.toInt().coerceIn(0, mask.rows() - 1) + return mask.get(row, col)[0] != 0.0 +} diff --git a/imageprocessing/src/test/java/org/fairscan/imageprocessing/BinarizationParametersTest.kt b/imageprocessing/src/test/java/org/fairscan/imageprocessing/BinarizationParametersTest.kt index e8774be6..9a32cb29 100644 --- a/imageprocessing/src/test/java/org/fairscan/imageprocessing/BinarizationParametersTest.kt +++ b/imageprocessing/src/test/java/org/fairscan/imageprocessing/BinarizationParametersTest.kt @@ -50,4 +50,15 @@ class BinarizationParametersTest { } } + @Test + fun despeckle_area_grows_with_the_square_of_the_resolution() { + assertThat(despeckleMinArea(1189)).isEqualTo(2) + assertThat(despeckleMinArea(1682)).isEqualTo(3) + assertThat(despeckleMinArea(3508)).isEqualTo(12) + } + + @Test + fun despeckle_area_keeps_a_lower_bound() { + assertThat(despeckleMinArea(1)).isEqualTo(2) + } } From f8fff26b4778083a053f55c02c46f797b00b47fc Mon Sep 17 00:00:00 2001 From: toolicious <291496779+toolicious@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:05:46 +0200 Subject: [PATCH 13/16] Run text recognition on black and white pages at the usual size --- .../org/fairscan/app/platform/AndroidPdfWriter.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt index 5abfbb31..c744e7ce 100644 --- a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt +++ b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt @@ -15,6 +15,7 @@ package org.fairscan.app.platform import android.content.res.AssetManager +import android.graphics.Bitmap import android.util.Log import com.tom_roush.pdfbox.cos.COSArray import com.tom_roush.pdfbox.cos.COSDictionary @@ -63,7 +64,8 @@ class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : P val ocrDocument = OcrDocument(document, assets) for ((index, page) in pages.withIndex()) { val jpegOrPng = page.image.get() - val image = if (page.page.colorMode == ColorMode.BLACK_AND_WHITE) + val blackAndWhite = page.page.colorMode == ColorMode.BLACK_AND_WHITE + val image = if (blackAndWhite) createCcittG4Image(document, packBitonal(jpegOrPng)) else JPEGFactory.createFromByteArray(document, jpegOrPng.bytes) @@ -96,7 +98,10 @@ class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : P if (!disableOcr) { try { - val bitmap = jpegOrPng.toBitmap() + // Black and white pages carry four times the pixels for the PDF. Text + // recognition gets the same size as with the other modes. + val bitmap = if (blackAndWhite) halfSize(jpegOrPng.toBitmap()) + else jpegOrPng.toBitmap() val ocrTextBoxes = ocrService.runOcr(bitmap) val pdfPageDimensions = PageDimensions( bitmap.width, @@ -144,6 +149,12 @@ private fun createCcittG4Image(document: PDDocument, bitonal: Bitonal): PDImageX return image } +private fun halfSize(bitmap: Bitmap): Bitmap { + val scaled = Bitmap.createScaledBitmap(bitmap, bitmap.width / 2, bitmap.height / 2, true) + bitmap.recycle() + return scaled +} + fun constrainToMaxFormat(widthMm: Double, heightMm: Double): Pair { val maxDim = 297.0 // A4 height val minDim = 215.9 // Letter width From fbd8de0f68e1a6a5f915973a81a8c5224914aca5 Mon Sep 17 00:00:00 2001 From: toolicious <291496779+toolicious@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:02:55 +0200 Subject: [PATCH 14/16] Store black and white pages with one channel --- .../org/fairscan/imageprocessing/PostProcessing.kt | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt index bd4d26f0..48382b5e 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt @@ -348,8 +348,9 @@ private fun flattenedGrayscale(img: Mat): Mat { private const val SAUVOLA_K = 0.25 private const val SAUVOLA_R = 128.0 -// Returns BGR containing only 0 and 255, like the other color modes, so that storage and -// preview stay unchanged. The export path packs it into one bit per pixel. +// Returns a single channel containing only 0 and 255. Black and white pages are stored as +// PNG, where two more copies of that channel would only take space. The export path packs +// it into one bit per pixel. fun binarizeDocument(img: Mat, upscaleTo: Long = 0L): Mat { // Flatten the illumination at the captured resolution. Interpolated pixels carry no extra // information for that step, only for where the threshold puts an edge. @@ -371,11 +372,7 @@ fun binarizeDocument(img: Mat, upscaleTo: Long = 0L): Mat { Core.subtract(binary, fill, binary) fillHoles(binary, fill, window) fill.release() - - val bgr = Mat() - Imgproc.cvtColor(binary, bgr, Imgproc.COLOR_GRAY2BGR) - binary.release() - return bgr + return binary } private fun upscaleToPixels(img: Mat, targetPixels: Long): Mat { From 5565c16ed6ff3f9c1fafceecbd4247aeb8208c49 Mon Sep 17 00:00:00 2001 From: toolicious <291496779+toolicious@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:22:03 +0200 Subject: [PATCH 15/16] Fix FileManagerTest for the generic PageToExport --- app/src/test/java/org/fairscan/app/data/FileManagerTest.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt b/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt index 5396d96c..7a2c0234 100644 --- a/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt +++ b/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt @@ -16,6 +16,7 @@ package org.fairscan.app.data import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat +import org.fairscan.app.domain.EncodedImage import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageToExport import org.fairscan.app.domain.Rotation @@ -76,7 +77,7 @@ class FileManagerTest { fun generatePdf() = runTest { val fakePdfWriter = object : PdfWriter { override suspend fun writePdfFromJpegs( - pages: List, + pages: List>, outputStream: OutputStream, disableOcr: Boolean, onProgress: (Int) -> Unit, @@ -87,7 +88,7 @@ class FileManagerTest { } val manager = FileManager(pdfDir, externalDir, fakePdfWriter) val pages = listOf(byteArrayOf(0x01, 0x02), byteArrayOf(0x11)) - .map { PageToExport(ScanPage("1", Rotation.R0, null, 1, null)) { Jpeg(it) } } + .map { PageToExport(ScanPage("1", Rotation.R0, null, 1, null)) { Jpeg(it) } } val pdf = manager.generatePdf(pages, true) {} assertThat(pdf.pageCount).isEqualTo(2) assertThat(pdf.sizeInBytes).isEqualTo(3) From 1a7d042683bb20dfdc33dc641bf8a622f1336590 Mon Sep 17 00:00:00 2001 From: toolicious <291496779+toolicious@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:20:26 +0200 Subject: [PATCH 16/16] Mention on the export screen that black and white pages are smaller as PDF --- .../org/fairscan/app/ui/screens/export/ExportScreen.kt | 7 +++++++ .../org/fairscan/app/ui/screens/export/ExportUiState.kt | 3 +++ .../org/fairscan/app/ui/screens/export/ExportViewModel.kt | 4 ++++ app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-et/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-gl/strings.xml | 1 + app/src/main/res/values-hu/strings.xml | 1 + app/src/main/res/values-in/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-lv/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-sv/strings.xml | 1 + app/src/main/res/values-tr/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 2 ++ 22 files changed, 34 insertions(+) diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt index 945330aa..86d1dfbf 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt @@ -300,6 +300,13 @@ private fun PdfInfos( ) } + if (uiState.blackAndWhiteAsJpeg) { + Text( + stringResource(R.string.black_and_white_jpeg_hint), + style = MaterialTheme.typography.bodySmall, + ) + } + if (uiState.isGenerating) { Text( text = stringResource(R.string.creating_export), diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt index 06897388..69ca46f4 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt @@ -23,6 +23,9 @@ data class ExportUiState( val isGenerating: Boolean = false, val progress: ExportProgress? = null, val ocrActivation: Boolean? = null, + // The output format is JPEG and the document has black and white pages, which are smaller + // and sharper as PDF. + val blackAndWhiteAsJpeg: Boolean = false, val isSaving: Boolean = false, val result: ExportResult? = null, val savedBundle: SavedBundle? = null, diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt index c81b790b..00c08cfc 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt @@ -50,6 +50,7 @@ import org.fairscan.app.domain.jpegsToExport import org.fairscan.app.domain.pagesToExport import org.fairscan.app.ui.screens.settings.ExportFormat import org.fairscan.app.ui.screens.settings.ExportFormat.PDF +import org.fairscan.imageprocessing.ColorMode import java.io.File import java.io.FileInputStream import java.io.IOException @@ -161,6 +162,8 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit preparationJob = launch { val ocrActivation = if (exportFormat == PDF) ocrLanguageString.isNotEmpty() else null + val blackAndWhiteAsJpeg = exportFormat == ExportFormat.JPEG && + currentPageKeys.any { it.colorMode == ColorMode.BLACK_AND_WHITE } _uiState.update { ExportUiState( filename = it.filename, @@ -168,6 +171,7 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit isGenerating = true, progress = ExportProgress(0, pageCount), ocrActivation = ocrActivation, + blackAndWhiteAsJpeg = blackAndWhiteAsJpeg, ) } val onProgress: (Int) -> Unit = { completedPages -> diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index c0f0c2b0..782dc70c 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -4,6 +4,7 @@ تطبيق بسيط ومحترم لمسح مستنداتك ضوئيًا. تطبيق ارجع + الصفحات بالأبيض والأسود أصغر حجمًا وأكثر وضوحًا بصيغة PDF. رُفض إذن الوصول إلى الكاميرا يتطلب التطبيق الوصول إلى الكاميرا لمسح المستندات ضوئيًا. تُخزن الصور الملتقطة على هذا الجهاز فقط، وسيتم حذفها عند إغلاق المسح الحالي. ألغِ diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 6ba90c6a..f0c5745e 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -4,6 +4,7 @@ Jednoduchá a respektující aplikace pro skenování vašich dokumentů Použít Zpět + Černobílé stránky jsou v PDF menší a ostřejší. Byl odepřen přístup k fotoaparátu Aby bylo možné skenovat dokumenty, tato aplikace potřebuje přístup k fotoaparátu. Nasnímané obrázky jsou ukládány pouze do tohoto zařízení a budou smazány, když ukončíte aktuální sken. Zrušit diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5be6ff8a..4af1602b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -4,6 +4,7 @@ Eine einfache und respektvolle App zum Scannen Ihrer Dokumente. Anwenden Zurück + Schwarzweiß-Seiten sind als PDF kleiner und schärfer. Kamerazugriff wurde verweigert Die App benötigt Zugriff auf die Kamera, um Dokumente zu scannen. Aufgenommene Bilder werden nur auf diesem Gerät gespeichert und beim Schließen des aktuellen Scans gelöscht. Abbrechen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 83eb0ad0..7cdac2a4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -4,6 +4,7 @@ Una aplicación sencilla y respetuosa para escanear tus documentos. Aplicar Atrás + Las páginas en blanco y negro son más pequeñas y nítidas en PDF. Permiso de cámara denegado La aplicación necesita acceso a la cámara para escanear documentos. Las imágenes capturadas se guardan solo en este dispositivo y se eliminarán cuando cierres el escaneo actual. Cancelar diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 37eca184..655369f7 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -5,6 +5,7 @@ Lihtsaltkasutatav ja viisakas rakendus dokumentide skaneerimiseks. Rakenda Tagasi + Mustvalged leheküljed on PDF-ina väiksemad ja teravamad. Kaamera kasutamise õigused on puudu Et dokumentide skaneerimine toimiks, vajab see rakendus õigust kasutada kaamerat. Pildihõive käigus jäädvustatud pildid salvestuvad vaid siin selles seadmes ja kustutatakse töösoleva skaneerimise lõppedes. Katkesta diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 65c70dd2..b46cc05c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -5,6 +5,7 @@ Une application simple et respectueuse pour scanner vos documents. Appliquer Retour + Les pages en noir et blanc sont plus légères et plus nettes en PDF. L\'autorisation d\'accès à la caméra a été refusée L’application a besoin d’accéder à l’appareil photo pour scanner des documents. Les images capturées sont enregistrées uniquement sur cet appareil et seront supprimées lorsque vous fermerez le scan en cours. Annuler diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index f0aa0b22..730be9bb 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -4,6 +4,7 @@ Unha aplicación sinxela e respectuosa para escanear os teus documentos. Aplicar Atrás + As páxinas en branco e negro son máis pequenas e nítidas en PDF. Permiso da cámara denegado A aplicación precisa acceso á cámara para escanear documentos. As imaxes capturadas gárdanse só neste dispositivo e eliminaranse ao pechar o escaneo actual. Cancelar diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index a56cff73..81125c9e 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -4,6 +4,7 @@ Egy egyszerű, a felhasználót tiszteletben tartó dokumentum szkenner alkalmazás. Alkalmaz Vissza + A fekete-fehér oldalak PDF-ként kisebbek és élesebbek. Kamerahozzáférés megtagadva Az alkalmazásnak a dokumentumok beolvasásához kamerahozzáférésre van szüksége. A rögzített képek csak ezen az eszközön kerülnek tárolásra, és törlődnek, amikor bezárod a folyamatban lévő szkennelést. Mégse diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index dc4ecd6f..c4b025c0 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -4,6 +4,7 @@ Aplikasi sederhana dan aman untuk memindai dokumen Anda Terapkan Kembali + Halaman hitam putih lebih kecil dan lebih tajam sebagai PDF. Izin penggunaan kamera ditolak Aplikasi ini memerlukan akses kamera untuk memindai dokumen. Gambar yang diambil hanya disimpan di perangkat ini dan akan dihapus saat Anda menutup pemindaian saat ini. Batalkan diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index bcde2ba9..5b21c859 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -4,6 +4,7 @@ Un\'app semplice e rispettosa per scansionare i tuoi documenti. Applica Indietro + Le pagine in bianco e nero sono più piccole e nitide in PDF. Autorizzazione fotocamera negata L\'app richiede l\'accesso alla fotocamera per scansionare documenti. Le immagini catturate sono salvate solo in questo dispositivo e verranno eliminate quando chiudi la scansione corrente. Annulla diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d7c89ec9..19a82176 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -4,6 +4,7 @@ シンプルで安心なドキュメントスキャナーです。 適用 戻る + 白黒ページは PDF のほうが小さく鮮明になります。 カメラの権限が拒否されました ドキュメントをスキャンするためにカメラへのアクセスが必要です。撮影した画像はこのデバイスにのみ保存され、現在のスキャンを閉じると削除されます。 キャンセル diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 4f99469a..02dc4cbb 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -28,6 +28,7 @@ Teksta atpazīšana: atspējota Atpakaļ + Melnbaltas lapas PDF formātā ir mazākas un asākas. Dzēst Atbalsts Versija diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index c2b3c3e2..da5938eb 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -4,6 +4,7 @@ Um aplicativo simples e respeitoso para digitalizar seus documentos. Aplicar Voltar + Páginas em preto e branco ficam menores e mais nítidas em PDF. Permissão da câmera negada O aplicativo precisa de acesso à câmera para digitalizar documentos. As imagens capturadas são armazenadas apenas neste dispositivo e serão excluídas quando você fechar a digitalização atual. Cancelar diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5adb10ad..a9c6901e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -4,6 +4,7 @@ Простое и открытое приложение для сканирования Ваших документов. Применить Назад + Чёрно-белые страницы в PDF меньше и чётче. В доступе к камере отказано Для сканирования документов приложению требуется доступ к камере. Отснятые изображения хранятся только на данном устройстве и удаляются по окончании текущего сканирования. Отмена diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 882330b7..7febaf59 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -4,6 +4,7 @@ En enkel och respektfull app för att skanna dina dokument. Använd Tillbaka + Svartvita sidor blir mindre och skarpare som PDF. Kamerabehörighet nekades Appen behöver åtkomst till kameran för att kunna skanna dokument. Tagna bilder lagras endast på den här enheten och raderas när du stänger den aktuella skanningen. Avbryt diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 9c328b87..4701f36e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -4,6 +4,7 @@ Belgelerinizi taramak için basit ve saygılı bir uygulama. Uygula Geri + Siyah beyaz sayfalar PDF olarak daha küçük ve daha net olur. Kamera izni reddedildi Uygulama, belgeleri taramak için kamera erişimi gerektirir. Yakalanan görüntüler yalnızca bu cihazda saklanır ve geçerli taramayı kapattığınızda silinir. İptal diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a0de9347..bfecff8b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -4,6 +4,7 @@ 一個簡單且尊重隱私的文件掃描應用程式。 套用 返回 + 黑白頁面匯出為 PDF 時更小、更清晰。 已拒絕相機權限 此應用程式需要相機存取權限才能掃描文件。擷取的影像僅儲存在此裝置上,並會在您關閉目前掃描時刪除。 取消 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index a0a63eea..f5ccc091 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -4,6 +4,7 @@ 一个简单且克制的文档扫描应用 应用 返回 + 黑白页面导出为 PDF 时更小、更清晰。 相机权限被拒绝 应用请求相机权限访问扫描文档。捕获的图像仅存储在此设备上,并且在关闭当前扫描时将被删除。 取消 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b76e306..07af5573 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,8 @@ Apply Back + + Black & white pages are smaller and sharper as PDF. Camera permission was denied