diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 01f50bc..07624ae 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -146,6 +146,7 @@ dependencies { implementation(libs.androidx.material.icons.core) implementation(libs.androidx.constraintlayout.compose) implementation(libs.itext7.core) + implementation(libs.mlkit.text.recognition) implementation(libs.ktor.okhttp) implementation(libs.ktor.logging) "playImplementation"(project(":lvl_library")) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt index 7ae8242..f56e7d1 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/AppSettingsScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedIconButton @@ -60,12 +61,14 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import androidx.core.net.toUri import io.github.chrisimx.scanbridge.datastore.* import io.github.chrisimx.scanbridge.proto.ScanBridgeSettings +import io.github.chrisimx.scanbridge.proto.defaultScanDpiOrNull import io.github.chrisimx.scanbridge.proto.rememberScanSettingsOrNull import io.github.chrisimx.scanbridge.services.DebugLogService import io.github.chrisimx.scanbridge.uicomponents.TitledCard @@ -74,6 +77,9 @@ import io.github.chrisimx.scanbridge.uicomponents.settings.CheckboxSetting import io.github.chrisimx.scanbridge.uicomponents.settings.MoreInformationButton import io.github.chrisimx.scanbridge.uicomponents.settings.UIntSetting import io.github.chrisimx.scanbridge.uicomponents.settings.VersionComposable +import io.github.chrisimx.scanbridge.util.DEFAULT_SCAN_DPI_MAX +import io.github.chrisimx.scanbridge.util.defaultScanDpiPresets +import io.github.chrisimx.scanbridge.util.normalizeDefaultScanDpiPreference import java.io.File import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch @@ -91,6 +97,76 @@ fun DisableCertChecksSetting(onInformationRequested: (Int) -> Unit, checked: Boo } } +@Composable +fun DefaultScanDpiSetting( + selectedValue: UInt, + onInformationRequested: (Int) -> Unit, + setValue: (UInt) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp) + ) { + ConstraintLayout( + Modifier.fillMaxWidth() + ) { + val (title, informationButton) = createRefs() + + Text( + text = stringResource(R.string.default_scan_dpi), + modifier = Modifier.constrainAs(title) { + start.linkTo(parent.start) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(informationButton.start, 12.dp) + width = Dimension.fillToConstraints + }, + style = MaterialTheme.typography.bodyMedium + ) + + Box( + modifier = Modifier.constrainAs(informationButton) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(parent.end) + } + ) { + MoreInformationButton { + onInformationRequested(R.string.default_scan_dpi_info) + } + } + } + + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + defaultScanDpiPresets.forEach { preset -> + val label = if (preset == DEFAULT_SCAN_DPI_MAX) { + stringResource(R.string.max_short) + } else { + preset.toString() + } + + InputChip( + selected = selectedValue == preset, + onClick = { setValue(preset) }, + label = { + Text( + label, + textAlign = TextAlign.Center + ) + } + ) + } + } + } +} + fun exportDebugLog(context: Context, debugLogService: DebugLogService, saveDebugLogLauncher: ActivityResultLauncher) { debugLogService.flush() @@ -267,6 +343,16 @@ fun AppSettingsScreen(innerPadding: PaddingValues) { information = it } + DefaultScanDpiSetting( + normalizeDefaultScanDpiPreference(appSettings.defaultScanDpiOrNull?.value?.toUInt()), + setInformationRequested, + { selectedValue -> + coroutineScope.launch { + appSettingsStore.setDefaultScanDpi(selectedValue) + } + } + ) + // Timeout setting UIntSetting( { diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index 0e695d2..964f9a1 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -496,6 +496,21 @@ fun ScanningScreen( exportAlpha, onDismiss = { scanningViewModel.setShowExportOptionsPopup(false) }, updateWidth = { exportOptionsWidth = it }, + onExportOcrPdf = { + scanningViewModel.setShowExportOptionsPopup(false) + scanningViewModel.doPdfExportWithOcr( + context, + { error -> + snackBarError( + error, + scope, + context, + snackbarHostState, + false + ) + } + ) + }, onExportPdf = { scanningViewModel.setShowExportOptionsPopup(false) scanningViewModel.doPdfExport( @@ -535,7 +550,23 @@ fun ScanningScreen( saveOptionsWidth, saveOptionsAlpha, onDismiss = { scanningViewModel.setShowSaveOptionsPopup(false) }, - updateWidth = { exportOptionsWidth = it }, + updateWidth = { saveOptionsWidth = it }, + onExportOcrPdf = { + scanningViewModel.setShowSaveOptionsPopup(false) + scanningViewModel.doPdfExportWithOcr( + context, + { error -> + snackBarError( + error, + scope, + context, + snackbarHostState, + false + ) + }, + saveFileLauncher + ) + }, onExportPdf = { scanningViewModel.setShowSaveOptionsPopup(false) scanningViewModel.doPdfExport( diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt index 1d122b6..45a4605 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableStateHolder.kt @@ -42,7 +42,7 @@ import io.github.chrisimx.esclkt.threeHundredthsOfInch import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.services.LocaleProvider import io.github.chrisimx.scanbridge.util.derived -import io.github.chrisimx.scanbridge.util.getMaxResolution +import io.github.chrisimx.scanbridge.util.pickClosestResolution import io.github.chrisimx.scanbridge.util.toDoubleLocalized import java.util.* import kotlinx.coroutines.CoroutineScope @@ -325,9 +325,10 @@ class ScanSettingsComposableStateHolder( !supportedResolutions.contains(DiscreteResolution(xRes, yRes)) val replacementResolution = if (invalidResolutionSetting) { - val highestScanResolution = uiState.capabilities.getMaxResolution(inputSource) + val preferredDpi = maxOf(xRes, yRes) + val closestResolution = pickClosestResolution(supportedResolutions, preferredDpi) - Pair(highestScanResolution.xResolution, highestScanResolution.yResolution) + Pair(closestResolution?.xResolution, closestResolution?.yResolution) } else { Pair(xRes, yRes) } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 3d88cc5..bb76421 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -30,14 +30,20 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.application import androidx.lifecycle.viewModelScope import androidx.room.withTransaction +import com.itextpdf.io.font.constants.StandardFonts import com.itextpdf.io.image.ImageDataFactory import com.itextpdf.kernel.geom.PageSize +import com.itextpdf.kernel.font.PdfFontFactory import com.itextpdf.kernel.pdf.PdfDocument import com.itextpdf.kernel.pdf.PdfWriter import com.itextpdf.layout.Document import com.itextpdf.layout.element.Image +import com.itextpdf.layout.element.Paragraph +import com.google.mlkit.vision.text.TextRecognition +import com.google.mlkit.vision.text.latin.TextRecognizerOptions import getTrustAllTM import io.github.chrisimx.esclkt.ESCLRequestClient +import io.github.chrisimx.esclkt.DiscreteResolution import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.ScanRegion import io.github.chrisimx.esclkt.ScanSettings @@ -56,11 +62,16 @@ import io.github.chrisimx.scanbridge.db.entities.ScannedPage import io.github.chrisimx.scanbridge.db.entities.Session import io.github.chrisimx.scanbridge.db.entities.TempFile import io.github.chrisimx.scanbridge.proto.chunkSizePdfExportOrNull +import io.github.chrisimx.scanbridge.proto.defaultScanDpiOrNull import io.github.chrisimx.scanbridge.services.ScanJobRepository import io.github.chrisimx.scanbridge.stores.DefaultScanSettingsStore import io.github.chrisimx.scanbridge.util.calculateDefaultESCLScanSettingsState +import io.github.chrisimx.scanbridge.util.getClosestResolution import io.github.chrisimx.scanbridge.util.getEditedImageName import io.github.chrisimx.scanbridge.util.getMaxResolution +import io.github.chrisimx.scanbridge.util.pickClosestResolution +import io.github.chrisimx.scanbridge.util.recognizePageText +import io.github.chrisimx.scanbridge.util.resolveDefaultScanDpiPreference import io.github.chrisimx.scanbridge.util.rotateBy90 import io.github.chrisimx.scanbridge.util.saveAsJPEG import io.github.chrisimx.scanbridge.util.snackbarErrorRetrievingPage @@ -317,6 +328,9 @@ class ScanningScreenViewModel( suspend fun setScannerCapabilities(caps: ScannerCapabilities) { _scanningScreenData.capabilities.value = caps val storedSession = sessionDao.getSessionById(sessionID) + val defaultScanDpi = resolveDefaultScanDpiPreference( + application.appSettingsStore.data.first().defaultScanDpiOrNull?.value?.toUInt() + ) Timber.d("Stored session: $storedSession") @@ -380,6 +394,12 @@ class ScanningScreenViewModel( validatedInputSource ?: caps.getInputSourceOptions().first(), duplex ?: false ) + val supportedResolutions = selectedInputSourceCaps + .settingProfiles + .firstOrNull() + ?.supportedResolutions + ?.discreteResolutions + .orEmpty() val intent = if (savedSettings.intent != null && !selectedInputSourceCaps.supportedIntents.contains(savedSettings.intent) @@ -439,20 +459,46 @@ class ScanningScreenViewModel( null } + val savedXResolution = savedSettings.xResolution + val savedYResolution = savedSettings.yResolution + val preferredResolution = when { + savedXResolution != null && savedYResolution != null -> { + val savedResolution = DiscreteResolution(savedXResolution, savedYResolution) + if (supportedResolutions.contains(savedResolution)) { + savedResolution + } else { + pickClosestResolution( + supportedResolutions, + maxOf(savedXResolution, savedYResolution) + ) + } + } + + else -> { + if (defaultScanDpi == null) { + supportedResolutions.maxByOrNull { it.xResolution * it.yResolution } + } else { + pickClosestResolution(supportedResolutions, defaultScanDpi) + } + } + } ?: caps.getClosestResolution(validatedInputSource ?: caps.getInputSourceOptions().first(), defaultScanDpi) + val validatedSettings = savedSettings.copy( inputSource = validatedInputSource, duplex = duplex, intent = intent, - scanRegions = scanRegion + scanRegions = scanRegion, + xResolution = preferredResolution.xResolution, + yResolution = preferredResolution.yResolution ) validatedSettings } catch (e: Exception) { Timber.e(e, "Error applying saved settings, using defaults") - caps.calculateDefaultESCLScanSettingsState() + caps.calculateDefaultESCLScanSettingsState(defaultScanDpi) } } else { - caps.calculateDefaultESCLScanSettingsState() + caps.calculateDefaultESCLScanSettingsState(defaultScanDpi) } sessionDao.insertAll(Session(sessionID, initialSettings, savedSettingsUiState)) @@ -551,14 +597,25 @@ class ScanningScreenViewModel( fun doPdfExport(context: Context, onError: (String) -> Unit, saveFileLauncher: ActivityResultLauncher? = null) { viewModelScope.launch { - doPdfExportInternal(context, onError, saveFileLauncher) + doPdfExportInternal(context, onError, saveFileLauncher, false) } } - private suspend fun doPdfExportInternal( + fun doPdfExportWithOcr( context: Context, onError: (String) -> Unit, saveFileLauncher: ActivityResultLauncher? = null + ) { + viewModelScope.launch { + doPdfExportInternal(context, onError, saveFileLauncher, true) + } + } + + private suspend fun doPdfExportInternal( + context: Context, + onError: (String) -> Unit, + saveFileLauncher: ActivityResultLauncher? = null, + withOcr: Boolean ) { val currentScans = scannedPages.value val scannerCapsNullable = scanningScreenData.capabilities @@ -578,14 +635,14 @@ class ScanningScreenViewModel( return } - setLoadingText(R.string.exporting) + setLoadingText(if (withOcr) R.string.exporting_with_ocr else R.string.exporting) val parentDir = File(application.filesDir, "exports") if (!parentDir.exists()) { parentDir.mkdir() } - val nameRoot = "pdfexport-${ + val nameRoot = "${if (withOcr) "pdfocr-export" else "pdfexport"}-${ LocalDateTime.now() .format(DateTimeFormatter.ofPattern("uuuu-MM-dd HH_mm_ss_SSS")) }" @@ -601,63 +658,97 @@ class ScanningScreenViewModel( val chunks = currentScans.chunked(chunkSize) - chunks.forEachIndexed { index, chunk -> - val pdfFile = File( - parentDir, - "$nameRoot-$index.pdf" - ) - PdfWriter(pdfFile).use { writer -> - PdfDocument(writer).use { pdf -> - Document(pdf).use { document -> - chunk.forEachIndexed { i, scan -> - val scanRegion = - scan.originalScanSettings.scanRegions?.regions?.first() ?: ScanRegion( - 297.millimeters().toThreeHundredthsOfInch(), - 210.millimeters().toThreeHundredthsOfInch(), - 0.threeHundredthsOfInch(), - 0.threeHundredthsOfInch() - ) - - val imageData = ImageDataFactory.create(scan.filePath) - - val rotated = scan.rotation == ScanRelativeRotation.Rotated - - val inputSource = scan.originalScanSettings.inputSource ?: InputSource.Platen - - val fallbackResolution = scannerCaps.getMaxResolution(inputSource) - val scannerXResolution = scan.originalScanSettings.xResolution ?: fallbackResolution.xResolution - val scannerYResolution = scan.originalScanSettings.yResolution ?: fallbackResolution.yResolution - - val rotationCorrectedXRes = if (rotated) scannerYResolution else scannerXResolution - val rotationCorrectedYRes = if (rotated) scannerXResolution else scannerYResolution - - // pts are 1/72th inch - val widthPts = (imageData.width / rotationCorrectedXRes.toFloat()).inches().toPoints().value - val heightPts = (imageData.height / rotationCorrectedYRes.toFloat()).inches().toPoints().value + val digitsNeeded = chunks.size.toString().length + val recognizer = if (withOcr) { + TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) + } else { + null + } - pdf.addNewPage( - PageSize( - widthPts.toFloat(), - heightPts.toFloat() + try { + chunks.forEachIndexed { index, chunk -> + val pdfFile = File( + parentDir, + "$nameRoot-${index.toString().padStart(digitsNeeded, '0')}.pdf" + ) + PdfWriter(pdfFile).use { writer -> + PdfDocument(writer).use { pdf -> + Document(pdf).use { document -> + val ocrFont = if (withOcr) PdfFontFactory.createFont(StandardFonts.HELVETICA) else null + + chunk.forEachIndexed { i, scan -> + val scanRegion = + scan.originalScanSettings.scanRegions?.regions?.first() ?: ScanRegion( + 297.millimeters().toThreeHundredthsOfInch(), + 210.millimeters().toThreeHundredthsOfInch(), + 0.threeHundredthsOfInch(), + 0.threeHundredthsOfInch() + ) + + val imageData = ImageDataFactory.create(scan.filePath) + + val rotated = scan.rotation == ScanRelativeRotation.Rotated + + val inputSource = scan.originalScanSettings.inputSource ?: InputSource.Platen + + val fallbackResolution = scannerCaps.getMaxResolution(inputSource) + val scannerXResolution = scan.originalScanSettings.xResolution ?: fallbackResolution.xResolution + val scannerYResolution = scan.originalScanSettings.yResolution ?: fallbackResolution.yResolution + + val rotationCorrectedXRes = if (rotated) scannerYResolution else scannerXResolution + val rotationCorrectedYRes = if (rotated) scannerXResolution else scannerYResolution + + // pts are 1/72th inch + val widthPts = (imageData.width / rotationCorrectedXRes.toFloat()).inches().toPoints().value + val heightPts = (imageData.height / rotationCorrectedYRes.toFloat()).inches().toPoints().value + + val pageWidth = widthPts.toFloat() + val pageHeight = heightPts.toFloat() + val pageNumber = i + 1 + + pdf.addNewPage( + PageSize( + pageWidth, + pageHeight + ) ) - ) - val imageElem = Image(imageData) - imageElem.setFixedPosition(i + 1, 0f, 0f) - imageElem.setHeight(heightPts.toFloat()) - imageElem.setWidth(widthPts.toFloat()) + if (withOcr && recognizer != null && ocrFont != null) { + val recognizedPage = recognizePageText(File(scan.filePath), recognizer) + recognizedPage.lines.forEach { line -> + val x = (pageWidth * line.leftRatio).coerceIn(0f, pageWidth) + val y = (pageHeight * line.bottomRatio).coerceIn(0f, pageHeight) + val availableWidth = (pageWidth * line.widthRatio).coerceAtLeast(1f) + val fontSize = (pageHeight * line.heightRatio).coerceIn(6f, 48f) + + document.add( + Paragraph(line.text) + .setFont(ocrFont) + .setFontSize(fontSize) + .setMargin(0f) + .setFixedPosition(pageNumber, x, y, availableWidth) + ) + } + } - document.add(imageElem) + val imageElem = Image(imageData) + imageElem.setFixedPosition(pageNumber, 0f, 0f) + imageElem.setHeight(pageHeight) + imageElem.setWidth(pageWidth) - pageCounter++ - Timber.d("Added page $pageCounter to PDF") + document.add(imageElem) + + pageCounter++ + Timber.d("Added page $pageCounter to PDF") + } } } } } + } finally { + recognizer?.close() } - val digitsNeeded = chunks.size.toString().length val tempPdfFiles = List(chunks.size) { index -> File(parentDir, "$nameRoot-${index.toString().padStart(digitsNeeded, '0')}.pdf") } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt b/app/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt index 8ca88fc..387d4ad 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/datastore/DataStores.kt @@ -29,6 +29,7 @@ val Context.appSettingsStore: DataStore by dataStore( } writeDebug = sharedPrefs.getBoolean("write_debug", false) scanningResponseTimeout = UInt32Value.of(sharedPrefs.getInt("scanning_response_timeout", 25)) + defaultScanDpi = UInt32Value.of(sharedPrefs.getInt("default_scan_dpi", 300)) chunkSizePdfExport = UInt32Value.of(sharedPrefs.getInt("chunk_size_pdf_export", 50)) rememberScanSettings = BoolValue.of(sharedPrefs.getBoolean("remember_scan_settings", true)) }.build() diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/datastore/ScanBridgeSettingsSerializer.kt b/app/src/main/java/io/github/chrisimx/scanbridge/datastore/ScanBridgeSettingsSerializer.kt index 64f97cc..6597f8c 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/datastore/ScanBridgeSettingsSerializer.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/datastore/ScanBridgeSettingsSerializer.kt @@ -13,6 +13,7 @@ import java.io.OutputStream object ScanBridgeSettingsSerializer : Serializer { override val defaultValue: ScanBridgeSettings = scanBridgeSettings { scanningResponseTimeout = UInt32Value.of(25) + defaultScanDpi = UInt32Value.of(300) rememberScanSettings = BoolValue.of(true) chunkSizePdfExport = UInt32Value.of(50) } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/datastore/ScanSettingsDataStoreHelpers.kt b/app/src/main/java/io/github/chrisimx/scanbridge/datastore/ScanSettingsDataStoreHelpers.kt index 3c8e482..24ad3c3 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/datastore/ScanSettingsDataStoreHelpers.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/datastore/ScanSettingsDataStoreHelpers.kt @@ -29,6 +29,12 @@ suspend fun DataStore.setTimeoutSetting(value: UInt) { } } +suspend fun DataStore.setDefaultScanDpi(value: UInt) { + this.updateSettings { + defaultScanDpi = UInt32Value.of(value.toInt()) + } +} + suspend fun DataStore.setChunkSize(value: UInt) { this.updateSettings { chunkSizePdfExport = UInt32Value.of(value.toInt()) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt index 8ac9b89..97dfdaa 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/ExportSettingsPopup.kt @@ -1,10 +1,15 @@ package io.github.chrisimx.scanbridge.uicomponents +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -24,6 +29,7 @@ fun ExportSettingsPopup( alpha: Float, onDismiss: () -> Unit, updateWidth: (Int) -> Unit, + onExportOcrPdf: () -> Unit, onExportPdf: () -> Unit, onExportArchive: () -> Unit ) { @@ -45,20 +51,50 @@ fun ExportSettingsPopup( }, shape = RoundedCornerShape(30.dp) ) { - Row { - IconButton(onClick = { onExportPdf() }) { - Icon( - painterResource(R.drawable.baseline_picture_as_pdf_24), - contentDescription = stringResource(R.string.export_pdf) - ) - } - IconButton(onClick = { onExportArchive() }) { - Icon( - painterResource(R.drawable.baseline_image_24), - contentDescription = stringResource(R.string.export_as_archive) - ) - } + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + ExportActionButton( + iconRes = R.drawable.baseline_description_24, + label = stringResource(R.string.export_pdf_with_ocr_short), + contentDescription = stringResource(R.string.export_pdf_with_ocr), + onClick = onExportOcrPdf + ) + ExportActionButton( + iconRes = R.drawable.baseline_picture_as_pdf_24, + label = stringResource(R.string.export_pdf_short), + contentDescription = stringResource(R.string.export_pdf), + onClick = onExportPdf + ) + ExportActionButton( + iconRes = R.drawable.baseline_image_24, + label = stringResource(R.string.export_archive_short), + contentDescription = stringResource(R.string.export_as_archive), + onClick = onExportArchive + ) } } } } + +@Composable +private fun ExportActionButton( + iconRes: Int, + label: String, + contentDescription: String, + onClick: () -> Unit +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + IconButton(onClick = onClick) { + Icon( + painterResource(iconRes), + contentDescription = contentDescription + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelSmall + ) + } +} diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/util/DefaultScanDpiPreferenceUtil.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/DefaultScanDpiPreferenceUtil.kt new file mode 100644 index 0000000..6a43e50 --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/util/DefaultScanDpiPreferenceUtil.kt @@ -0,0 +1,25 @@ +package io.github.chrisimx.scanbridge.util + +const val DEFAULT_SCAN_DPI_MAX = 0u + +val defaultScanDpiPresets = listOf(150u, 300u, 600u, DEFAULT_SCAN_DPI_MAX) + +fun normalizeDefaultScanDpiPreference(value: UInt?): UInt { + val resolvedValue = value ?: 300u + if (resolvedValue == DEFAULT_SCAN_DPI_MAX) { + return DEFAULT_SCAN_DPI_MAX + } + + return defaultScanDpiPresets + .filterNot { it == DEFAULT_SCAN_DPI_MAX } + .minBy { kotlin.math.abs(it.toLong() - resolvedValue.toLong()) } +} + +fun resolveDefaultScanDpiPreference(value: UInt?): UInt? { + val normalizedValue = normalizeDefaultScanDpiPreference(value) + return if (normalizedValue == DEFAULT_SCAN_DPI_MAX) { + null + } else { + normalizedValue + } +} diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/util/DocumentOcrUtil.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/DocumentOcrUtil.kt new file mode 100644 index 0000000..68dec51 --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/util/DocumentOcrUtil.kt @@ -0,0 +1,104 @@ +package io.github.chrisimx.scanbridge.util + +import android.graphics.BitmapFactory +import com.google.android.gms.tasks.Task +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.text.TextRecognizer +import java.io.File +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext + +private const val OCR_TARGET_MAX_SIDE_PX = 4000 + +data class RecognizedOcrLine( + val text: String, + val leftRatio: Float, + val bottomRatio: Float, + val heightRatio: Float, + val widthRatio: Float +) + +data class RecognizedOcrPage( + val lines: List +) + +suspend fun recognizePageText(file: File, recognizer: TextRecognizer): RecognizedOcrPage = withContext(Dispatchers.Default) { + val boundsOnlyOptions = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeFile(file.absolutePath, boundsOnlyOptions) + + val sampledBitmapOptions = BitmapFactory.Options().apply { + inSampleSize = calculateOcrSampleSize( + boundsOnlyOptions.outWidth, + boundsOnlyOptions.outHeight + ) + } + + val bitmap = BitmapFactory.decodeFile(file.absolutePath, sampledBitmapOptions) + ?: return@withContext RecognizedOcrPage(emptyList()) + + try { + val image = InputImage.fromBitmap(bitmap, 0) + val recognizedText = recognizer.process(image).await() + val width = bitmap.width.toFloat().coerceAtLeast(1f) + val height = bitmap.height.toFloat().coerceAtLeast(1f) + + RecognizedOcrPage( + recognizedText.textBlocks.flatMap { block -> + block.lines.mapNotNull { line -> + val boundingBox = line.boundingBox ?: return@mapNotNull null + val trimmedText = line.text.trim() + if (trimmedText.isEmpty()) { + return@mapNotNull null + } + + RecognizedOcrLine( + text = trimmedText, + leftRatio = boundingBox.left / width, + bottomRatio = (height - boundingBox.bottom) / height, + heightRatio = boundingBox.height() / height, + widthRatio = boundingBox.width() / width + ) + } + } + ) + } finally { + bitmap.recycle() + } +} + +private fun calculateOcrSampleSize(width: Int, height: Int): Int { + val longestSide = maxOf(width, height) + if (longestSide <= OCR_TARGET_MAX_SIDE_PX || longestSide <= 0) { + return 1 + } + + var sampleSize = 1 + while ((longestSide / sampleSize) > OCR_TARGET_MAX_SIDE_PX) { + sampleSize *= 2 + } + + return sampleSize +} + +private suspend fun Task.await(): T = suspendCancellableCoroutine { continuation -> + addOnSuccessListener { result -> + if (continuation.isActive) { + continuation.resume(result) + } + } + addOnFailureListener { exception -> + if (continuation.isActive) { + continuation.resumeWithException(exception) + } + } + addOnCanceledListener { + if (continuation.isActive) { + continuation.cancel() + } + } +} diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt index e4c07d3..fc3d9cd 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt @@ -88,10 +88,39 @@ fun ScannerCapabilities.getBestColorMode(inputSource: InputSource): ColorModeEnu return chosenColorMode } -fun ScannerCapabilities.calculateDefaultESCLScanSettingsState(): ScanSettings { +fun pickClosestResolution(resolutions: List, preferredDpi: UInt): DiscreteResolution? = resolutions.minWithOrNull( + compareBy( + { + kotlin.math.abs(it.xResolution.toLong() - preferredDpi.toLong()) + + kotlin.math.abs(it.yResolution.toLong() - preferredDpi.toLong()) + }, + { + if (it.xResolution <= preferredDpi && it.yResolution <= preferredDpi) { + 0 + } else { + 1 + } + }, + { it.xResolution.toLong() * it.yResolution.toLong() } + ) +) + +fun ScannerCapabilities.getClosestResolution(inputSource: InputSource, preferredDpi: UInt?): DiscreteResolution { + val inputCaps = this.getInputSourceCaps(inputSource) + if (preferredDpi == null) { + return getMaxResolution(inputSource) + } + + return pickClosestResolution( + inputCaps.settingProfiles.first().supportedResolutions.discreteResolutions, + preferredDpi + ) ?: getMaxResolution(inputSource) +} + +fun ScannerCapabilities.calculateDefaultESCLScanSettingsState(preferredDpi: UInt? = 300u): ScanSettings { val inputSource = this.getInputSourceOptions().firstOrNull() ?: InputSource.Platen - val maxResolution = getMaxResolution(inputSource) + val defaultResolution = getClosestResolution(inputSource, preferredDpi) val inputSourceCaps = this.getInputSourceCaps(inputSource, false) @@ -106,8 +135,8 @@ fun ScannerCapabilities.calculateDefaultESCLScanSettingsState(): ScanSettings { version = this.interfaceVersion, inputSource = inputSource, scanRegions = maxScanRegion, - xResolution = maxResolution.xResolution, - yResolution = maxResolution.yResolution, + xResolution = defaultResolution.xResolution, + yResolution = defaultResolution.yResolution, colorMode = bestColorMode, documentFormatExt = "image/jpeg" ) diff --git a/app/src/main/proto/app_settings.proto b/app/src/main/proto/app_settings.proto index fa5eee4..eb61d1f 100644 --- a/app/src/main/proto/app_settings.proto +++ b/app/src/main/proto/app_settings.proto @@ -15,4 +15,5 @@ message ScanBridgeSettings { google.protobuf.StringValue last_used_scan_settings = 7; google.protobuf.StringValue last_used_scan_settings_ui_state = 9; bool migrated_session_files = 8; -} \ No newline at end of file + google.protobuf.UInt32Value default_scan_dpi = 10; +} diff --git a/app/src/main/res/drawable/baseline_description_24.xml b/app/src/main/res/drawable/baseline_description_24.xml new file mode 100644 index 0000000..c55089e --- /dev/null +++ b/app/src/main/res/drawable/baseline_description_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a56588b..306a023 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -72,7 +72,11 @@ Gib bitte eine URL ein Benutzerdefinierter Scanner Als PDF exportieren + PDF + Als OCR-PDF exportieren + OCR-PDF Als JPEG-Archiv exportieren + JPEG-ZIP Keine weiteren Seiten. %1$s Fehler beim Herunterladen des empfangenen Bilds als Datei: %1$s Empfangenes Bild konnte nicht dekodiert werden. %1$s @@ -85,6 +89,9 @@ Debugprotokoll exportieren Diese Einstellung legt fest wie lange ScanBridge maximal auf eine Antwort vom Scanner wartet bevor es den Scanversuch abbricht.\n\nDiese Einstellung ist für langsame Scanner und große Scanbereiche nützlich. Timeoutlänge für Scannerantwort (in Sekunden) + Standard-Scanauflösung (DPI) + Lege fest, mit welchem DPI-Preset neue Scansitzungen starten sollen, wenn gemerkte Scaneinstellungen deaktiviert sind.\n\nDas Preset Max verwendet immer die höchste vom aktuellen Scanner unterstützte Auflösung. + Max Erweiterte Einstellungen Name @@ -96,10 +103,11 @@ HTTPS-Zertifikatsüberprüfung umgehen Diese Einstellung deaktiviert alle Zertifikatsprüfungen bei HTTPS-Verbindungen. Jedes Zertifikat wird folglich akzeptiert, egal ob es ungültig oder selbstsigniert ist. Diese Einstellung sollte nicht in unsicheren Netwerken verwendet werden, denn durch sie wird ein Man-in-the-Middle-Angriff leicht durchführbar.\n\nIn den meisten Fällen werden Scanner in privaten Netzwerken oder über VPN-Verbindungen verwendet, wo Authentizität in der Regel keine große Rolle spielt. In solchen Umgebungen ist es unwahrscheinlich, dass das Deaktivieren der Zertifikatsprüfung ein echtes Sicherheitsrisiko darstellt. Und falls Vertraulichkeit oder Authentizität doch eine Rolle spielen, hilft selbst HTTPS nur begrenzt – denn eSCL bietet keinerlei Client-Authentifizierung. Jeder mit Netzwerkzugriff auf den Scanner kann potenziell auf gescannte Seiten zugreifen. Speichern der zuletzt verwendeten Scaneinstellungen - Wenn diese Option aktiviert ist, werden die zuletzt verwendeten Scaneinstellungen (z.B. Quelle, Duplex usw.) beim Erstellen einer neuen Scansitzung wiederhergestellt.\n\nWenn sie deaktiviert ist, beginnt jede Sitzung mit den standardmäßigen Scaneinstellungen (höchste Auflösung, größtmöglicher Scanbereich). + Wenn diese Option aktiviert ist, werden die zuletzt verwendeten Scaneinstellungen (z.B. Quelle, Duplex usw.) beim Erstellen einer neuen Scansitzung wiederhergestellt.\n\nWenn sie deaktiviert ist, beginnt jede Sitzung mit der konfigurierten Standard-DPI und dem größtmöglichen Scanbereich. Für ScanBridge spenden Den Quellcode anschauen In Datei speichern + OCR wird ausgefuehrt und PDF exportiert... Bild wird geladen… Seite zuschneiden Zuschneiden @@ -124,4 +132,4 @@ Benutzerdefinierten Scanner bearbeiten Willst du den benutzerdefinierten Scanner wirklich löschen? Dieser Vorgang ist unwiderruflich. Benutzerdefinierten Scanner löschen - \ No newline at end of file + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 7ebb1aa..69bf87a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -83,6 +83,9 @@ Esporta registro di debug Questo timeout indica per quanto tempo ScanBridge aspetta la risposta dello scanner prima di interrompere la scansione.\n\nÈ particolarmente utile quando si usano scanner lenti o si devono scansionare aree molto grandi, che richiedono più tempo per la scansione. Timeout di risposta (in secondi) + Risoluzione di scansione predefinita (DPI) + Scegli con quale preset DPI devono iniziare le nuove sessioni di scansione quando le impostazioni ricordate sono disattivate.\n\nIl preset Max usa sempre la risoluzione piu alta supportata dallo scanner corrente. + Max Impostazioni avanzate Nome @@ -94,7 +97,7 @@ Disabilita controlli certificati Questa opzione disabilita la convalida di tutti i certificati HTTPS. Verrà accettato qualsiasi certificato, inclusi quelli autofirmati, scaduti o non validi. Non utilizzare questa opzione su reti pubbliche o non sicure, poiché rende possibili attacchi man-in-the-middle (MitM). Nella maggior parte dei casi, gli scanner vengono utilizzati su reti private o tramite connessioni VPN, dove le criticità sull\'autenticazione sono minime. In tali ambienti, è improbabile che la disabilitazione dei controlli dei certificati rappresenti un rischio reale per la sicurezza. E nel caso in cui la riservatezza o l\'autenticità siano un problema, anche HTTPS non sarà di grande aiuto, poiché eSCL non dispone di autenticazione client. Chiunque abbia accesso alla rete può potenzialmente recuperare le pagine scansionate. Ricorda le impostazioni di scansione - Se abilitata, quando si crea una nuova sessione di scansione verranno ripristinate le ultime impostazioni di scansione utilizzate (sorgente di input, duplex, ecc.). Se disabilitata, ogni sessione inizia con le impostazioni di scansione predefinite (massima risoluzione, area di scansione più ampia possibile). + Se abilitata, quando si crea una nuova sessione di scansione verranno ripristinate le ultime impostazioni di scansione utilizzate (sorgente di input, duplex, ecc.). Se disabilitata, ogni sessione inizia con la DPI predefinita configurata e la massima area di scansione possibile. Dona a ScanBridge Codice sorgente Annulla @@ -123,4 +126,4 @@ Modifica scanner personalizzato Vuoi davvero eliminare lo scanner personalizzato? Questa operazione è irreversibile. Elimina scanner personalizzato - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1d72524..9ea1a41 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -72,7 +72,11 @@ Please enter an URL Custom scanner Export as PDF + PDF + Export as searchable OCR PDF + OCR PDF Export as archive of JPEGs + JPEG ZIP No further pages. %1$s Error while copying received image to file: %1$s Couldn\'t decode received image. %1$s @@ -85,6 +89,9 @@ Export debug log This timeout determines how long ScanBridge waits at maximum for the scanner to respond to requests until it aborts the scan job.\n\nThis is mostly useful for slow scanners and large scan areas which take longer to scan. Response Timeout (in seconds) + Default scan resolution (DPI) + Choose which DPI preset new scan sessions should start with when remembered scan settings are disabled.\n\nThe Max preset always uses the highest resolution supported by the current scanner. + Max Advanced Settings Name @@ -96,12 +103,13 @@ Disable certificate checks This option disables all HTTPS certificate validation. Any certificate will be accepted, including self-signed, expired, or otherwise invalid ones. Do not use this option on insecure or public networks, as it makes man-in-the-middle (MitM) attacks possible.\n\nIn most cases, scanners are used on private networks or over VPN connections, where concerns about authenticity are minimal. In such environments, disabling certificate checks is unlikely to pose a real security risk. And in case confidentiality or authenticity is a concern, even HTTPS won\'t help much, as eSCL lacks client authentication altogether. Anyone with access to the network can potentially retrieve scanned pages. Remember scan settings - When enabled, your last used scan settings (input source, duplex, etc.) will be restored when you create a new scan session.\n\nWhen disabled, each session starts with default scan settings (highest resolution, largest possible scan area). + When enabled, your last used scan settings (input source, duplex, etc.) will be restored when you create a new scan session.\n\nWhen disabled, each session starts with the configured default DPI and the largest possible scan area. Support development View source code F-Droid Play Store Save to File + Running OCR and exporting PDF... Loading image… Crop current page Crop @@ -126,4 +134,4 @@ Edit custom scanner Do you really want to delete this custom scanner? This action cannot be undone. Delete custom scanner - \ No newline at end of file + diff --git a/app/src/test/java/org/github/chrisimx/scanbridge/DefaultScanDpiPreferenceUtilTest.kt b/app/src/test/java/org/github/chrisimx/scanbridge/DefaultScanDpiPreferenceUtilTest.kt new file mode 100644 index 0000000..6d031a7 --- /dev/null +++ b/app/src/test/java/org/github/chrisimx/scanbridge/DefaultScanDpiPreferenceUtilTest.kt @@ -0,0 +1,23 @@ +package org.github.chrisimx.scanbridge + +import io.github.chrisimx.scanbridge.util.DEFAULT_SCAN_DPI_MAX +import io.github.chrisimx.scanbridge.util.normalizeDefaultScanDpiPreference +import io.github.chrisimx.scanbridge.util.resolveDefaultScanDpiPreference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DefaultScanDpiPreferenceUtilTest { + + @Test + fun normalizeDefaultScanDpiPreference_snapsLegacyValueToNearestPreset() { + assertEquals(300u, normalizeDefaultScanDpiPreference(400u)) + assertEquals(600u, normalizeDefaultScanDpiPreference(700u)) + } + + @Test + fun resolveDefaultScanDpiPreference_mapsMaxSentinelToNull() { + assertNull(resolveDefaultScanDpiPreference(DEFAULT_SCAN_DPI_MAX)) + assertEquals(300u, resolveDefaultScanDpiPreference(300u)) + } +} diff --git a/app/src/test/java/org/github/chrisimx/scanbridge/ScanResolutionPreferenceUtilTest.kt b/app/src/test/java/org/github/chrisimx/scanbridge/ScanResolutionPreferenceUtilTest.kt new file mode 100644 index 0000000..c5d5a95 --- /dev/null +++ b/app/src/test/java/org/github/chrisimx/scanbridge/ScanResolutionPreferenceUtilTest.kt @@ -0,0 +1,36 @@ +package org.github.chrisimx.scanbridge + +import io.github.chrisimx.esclkt.DiscreteResolution +import io.github.chrisimx.scanbridge.util.pickClosestResolution +import org.junit.Assert.assertEquals +import org.junit.Test + +class ScanResolutionPreferenceUtilTest { + + @Test + fun pickClosestResolution_returnsExactMatchWhenAvailable() { + val resolutions = listOf( + DiscreteResolution(200u, 200u), + DiscreteResolution(300u, 300u), + DiscreteResolution(600u, 600u) + ) + + assertEquals( + DiscreteResolution(300u, 300u), + pickClosestResolution(resolutions, 300u) + ) + } + + @Test + fun pickClosestResolution_prefersNearestSupportedValue() { + val resolutions = listOf( + DiscreteResolution(200u, 200u), + DiscreteResolution(600u, 600u) + ) + + assertEquals( + DiscreteResolution(200u, 200u), + pickClosestResolution(resolutions, 300u) + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e546184..0642795 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ timber = "5.0.1" zoomable = "0.18.0" material3 = "1.5.0-alpha06" materialIcons = "1.7.8" +mlkitTextRecognition = "16.0.1" navigationCompose = "2.9.5" versionsPlugin = "0.53.0" screengrab = "2.1.1" @@ -80,6 +81,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +mlkit-text-recognition = { module = "com.google.mlkit:text-recognition", version.ref = "mlkitTextRecognition" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinReflect" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } screengrab = { module = "tools.fastlane:screengrab", version.ref = "screengrab" } @@ -97,4 +99,4 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi versions = { id = "com.github.ben-manes.versions", version.ref = "versionsPlugin" } koin = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" } protobuf = { id = "com.google.protobuf", version.ref = "protobuf-plugin" } -room = { id = "androidx.room", version.ref = "room"} \ No newline at end of file +room = { id = "androidx.room", version.ref = "room"}