Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/src/main/java/org/fairscan/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 3 additions & 5 deletions app/src/main/java/org/fairscan/app/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 60 additions & 28 deletions app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -56,40 +67,61 @@ private fun EstimatedDimensions.applyRotation(rotation: Rotation): EstimatedDime
suspend fun pagesToExport(
imageRepository: ImageRepository,
exportQuality: ExportQuality
): List<PageToExport> {
): List<PageToExport> = 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)
Expand Down
29 changes: 29 additions & 0 deletions app/src/main/java/org/fairscan/app/domain/ExportQuality.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
3 changes: 3 additions & 0 deletions app/src/main/java/org/fairscan/app/domain/Image.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
52 changes: 48 additions & 4 deletions app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand All @@ -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<Double, Double> {
val maxDim = 297.0 // A4 height
val minDim = 215.9 // Letter width
Expand Down
Loading