From 471f0c6e117358eb32b820becea5c8b916aeff9a 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] 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 2e8368e4..e3053512 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) } } @@ -200,6 +205,16 @@ fun percentileL(l: Mat, p: Double): Double { } fun enhanceGrayscaleImage(img: Mat): Mat { + val gray = flattenedGrayscale(img) + val finalBgr = Mat() + Imgproc.cvtColor(gray, finalBgr, Imgproc.COLOR_GRAY2BGR) + gray.release() + return finalBgr +} + +// Steps 1 to 5 of enhanceGrayscaleImage, without the conversion back to BGR. +// Binarization reuses it for the same illumination flattening. +private fun flattenedGrayscale(img: Mat): Mat { // -- 1. Convert to grayscale -------- val gray = Mat() @@ -320,14 +335,218 @@ fun enhanceGrayscaleImage(img: Mat): Mat { val denoised = Mat() Imgproc.bilateralFilter(stretched8u, denoised, 9, 20.0, 10.0) - val finalBgr = Mat() - Imgproc.cvtColor(denoised, finalBgr, Imgproc.COLOR_GRAY2BGR) - // -- Cleanup ----------- gray.release(); imgFloat.release(); logImg.release() blur.release(); logBlur.release(); diff.release() retinex.release(); result8u.release() - stretched8u.release(); denoised.release() + stretched8u.release() - return finalBgr + return denoised +} + +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 { + // 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) + flattened.release() + val window = sauvolaWindow(max(gray.cols(), gray.rows())) + + val src = Mat() + gray.convertTo(src, CvType.CV_32F) + 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() + return bgr +} + +private fun upscaleToPixels(img: Mat, targetPixels: Long): Mat { + val pixels = img.width().toLong() * img.height() + if (targetPixels <= pixels) return img.clone() + val scale = sqrt(targetPixels.toDouble() / pixels) + val out = Mat() + Imgproc.resize(img, out, Size(img.width() * scale, img.height() * scale), + 0.0, 0.0, Imgproc.INTER_CUBIC) + return out +} + +// Sauvola local thresholding: t = mean * (1 + k * (stdDev / r - 1)) +private fun sauvolaThreshold(src: Mat, window: Int): Mat { + val windowSize = Size(window.toDouble(), window.toDouble()) + + 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() + + val meanSquared = Mat() + Core.multiply(mean, mean, meanSquared) + Core.subtract(deviation, meanSquared, deviation) + meanSquared.release() + Core.max(deviation, Scalar(0.0), deviation) + Core.sqrt(deviation, deviation) + + Core.multiply(deviation, Scalar(SAUVOLA_K / SAUVOLA_R), deviation) + Core.add(deviation, Scalar(1.0 - SAUVOLA_K), deviation) + val threshold = Mat() + Core.multiply(mean, deviation, threshold) + mean.release(); deviation.release() + + val binary = Mat() + Core.compare(src, threshold, binary, Core.CMP_GT) + threshold.release() + + 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 } 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) + } +}