Compare commits
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="externalSystemId" value="Gradle" />
|
||||
<option name="version" value="2.3.10" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,3 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
|
||||
@@ -12,8 +12,14 @@ Recommended reading: https://fairscan.org/blog/fairscan-purpose/
|
||||
|
||||
### Issues
|
||||
|
||||
- Bug reports are very welcome, especially with reproducible examples or images. When the automatic processing gives bad results, you can share the captured image by tapping "Report a problem with last captured image" in the "About" screen.
|
||||
- Before opening a "feature request", please consider whether the issue can be described more generally. Focus on describing problems rather than solutions, and provide as much context as possible. Try to explain **why** something should be changed, and the use case you face.
|
||||
You're very welcome to describe the problems you face when using FairScan.
|
||||
|
||||
When the automatic processing gives a bad result, please share the original image whenever possible. You can get it by tapping "Report a problem with last captured image" in the "About" screen. You can either attach the image to the GitHub issue or send it privately to contact@fairscan.org. Screenshots are usually not helpful to understand image-processing issues.
|
||||
|
||||
The most useful issues focus on the problem rather than on a proposed solution. Understanding the underlying problem is essential to finding the right solution. A detailed description of the problem and its context is therefore usually much more valuable than discussing a specific change to the app.
|
||||
|
||||
- ❌ "Please add a button or an option to..."
|
||||
- ✅ "Here is the problem I face, the context in which it occurs, and why it is problematic..."
|
||||
|
||||
### Pull Requests
|
||||
|
||||
|
||||
@@ -38,6 +38,17 @@ Contributions are welcome, but please read the guidelines first: [CONTRIBUTING.m
|
||||
|
||||
---
|
||||
|
||||
## Translations
|
||||
|
||||
Translations are managed on Weblate, and everyone is welcome to contribute.
|
||||
|
||||
[](https://hosted.weblate.org/engage/fairscan/)
|
||||
|
||||
Start translating:
|
||||
https://hosted.weblate.org/projects/fairscan/
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Scan | Preview | Save & Share |
|
||||
@@ -54,7 +65,7 @@ Contributions are welcome, but please read the guidelines first: [CONTRIBUTING.m
|
||||
- **Automatic perspective correction**
|
||||
- **Automatic image enhancement**
|
||||
- **Fast PDF generation** with no manual adjustments
|
||||
- **Fully offline** – the app has *no* internet permission
|
||||
- **All document processing happens locally on your device**, no cloud processing
|
||||
- **Minimal permissions**
|
||||
- **Open source**, GPLv3
|
||||
|
||||
@@ -110,6 +121,7 @@ FairScan uses:
|
||||
- [CameraX](https://developer.android.com/media/camera/camerax) for image capture
|
||||
- [LiteRT](https://ai.google.dev/edge/litert) to run the custom segmentation model for automatic document detection
|
||||
- [OpenCV](https://opencv.org/) for perspective correction and image enhancement
|
||||
- [Tesseract](https://github.com/tesseract-ocr/tesseract) for text recognition (OCR)
|
||||
- [PDFBox-Android](https://github.com/TomRoush/PdfBox-Android) for PDF generation
|
||||
|
||||
---
|
||||
|
||||
@@ -25,8 +25,8 @@ android {
|
||||
// https://ai.google.dev/edge/litert/android/index
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 81 // increment by 3 because of ABI-specific APKs
|
||||
versionName = "1.22.1"
|
||||
versionCode = 93 // increment by 3 because of ABI-specific APKs
|
||||
versionName = "2.2.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -136,6 +136,7 @@ dependencies {
|
||||
implementation(libs.reorderable)
|
||||
implementation(libs.aboutlibraries.compose.m3)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.tesseract4android)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.assertj)
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<!-- Required (on Android 9 and lower) to save files -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
|
||||
<!-- To download Tesseract language data -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- REMOVE ACCESS_NETWORK_STATE -->
|
||||
<!-- cameraX 1.6.1 depends on androidx.media3:media3-common which adds ACCESS_NETWORK_STATE -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
|
||||
@@ -36,6 +38,12 @@
|
||||
<action android:name="org.fairscan.app.action.SCAN_TO_PDF" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.GET_CONTENT"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.OPENABLE"/>
|
||||
<data android:mimeType="application/pdf"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -17,13 +17,20 @@ package org.fairscan.app
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fairscan.app.data.FileLogger
|
||||
import org.fairscan.app.data.FileManager
|
||||
import org.fairscan.app.data.LogRepository
|
||||
import org.fairscan.app.data.OcrLanguageRepository
|
||||
import org.fairscan.app.domain.ImageSegmentationService
|
||||
import org.fairscan.app.domain.OcrService
|
||||
import org.fairscan.app.platform.AndroidImageLoader
|
||||
import org.fairscan.app.platform.AndroidPdfWriter
|
||||
import org.fairscan.app.ui.screens.camera.CameraViewModel
|
||||
@@ -43,19 +50,31 @@ class FairScanApp : Application() {
|
||||
|
||||
const val THUMBNAIL_SIZE_DP = 120
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(name = "fairscan_settings")
|
||||
|
||||
class AppContainer(context: Context) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val cacheDir = context.cacheDir
|
||||
private val dataStore = context.dataStore
|
||||
val preparationDir = File(context.cacheDir, "pdfs")
|
||||
val ocrLanguageRepository =
|
||||
OcrLanguageRepository(dataStore, File(context.filesDir, "tesseract/tessdata"))
|
||||
val ocrService = OcrService(ocrLanguageRepository, scope)
|
||||
val fileManager = FileManager(
|
||||
preparationDir,
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
AndroidPdfWriter()
|
||||
AndroidPdfWriter(ocrService, context.assets)
|
||||
)
|
||||
val logRepository = LogRepository(File(context.filesDir, "logs.txt"))
|
||||
val logger = FileLogger(logRepository)
|
||||
val imageSegmentationService = ImageSegmentationService(context, logger)
|
||||
val imageLoader = AndroidImageLoader(context.contentResolver)
|
||||
val settingsRepository = SettingsRepository(context)
|
||||
val settingsRepository = SettingsRepository(context, dataStore)
|
||||
|
||||
init {
|
||||
scope.launch { imageSegmentationService.initialize() }
|
||||
scope.launch { ocrService.initialize() }
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <reified VM : ViewModel> viewModelFactory(
|
||||
|
||||
@@ -27,13 +27,13 @@ import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Build.VERSION_CODES.Q
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -54,9 +54,8 @@ import org.fairscan.app.data.ImageRepository
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.Screen
|
||||
import org.fairscan.app.ui.components.rememberCameraPermissionState
|
||||
import org.fairscan.app.ui.screens.document.DocumentScreen
|
||||
import org.fairscan.app.ui.screens.crop.CropScreen
|
||||
import org.fairscan.app.ui.screens.LibrariesScreen
|
||||
import org.fairscan.app.ui.screens.ResumeScanScreen
|
||||
import org.fairscan.app.ui.screens.about.AboutEvent
|
||||
import org.fairscan.app.ui.screens.about.AboutScreen
|
||||
import org.fairscan.app.ui.screens.about.AboutViewModel
|
||||
@@ -64,12 +63,16 @@ import org.fairscan.app.ui.screens.about.createEmailWithImageIntent
|
||||
import org.fairscan.app.ui.screens.camera.CameraEvent
|
||||
import org.fairscan.app.ui.screens.camera.CameraScreen
|
||||
import org.fairscan.app.ui.screens.camera.CameraViewModel
|
||||
import org.fairscan.app.ui.screens.crop.CropScreen
|
||||
import org.fairscan.app.ui.screens.document.DocumentScreen
|
||||
import org.fairscan.app.ui.screens.export.ExportActions
|
||||
import org.fairscan.app.ui.screens.export.ExportEvent
|
||||
import org.fairscan.app.ui.screens.export.ExportResult
|
||||
import org.fairscan.app.ui.screens.export.ExportScreenWrapper
|
||||
import org.fairscan.app.ui.screens.export.ExportViewModel
|
||||
import org.fairscan.app.ui.screens.settings.OcrLanguagesScreen
|
||||
import org.fairscan.app.ui.screens.settings.SettingsScreen
|
||||
import org.fairscan.app.ui.screens.settings.SettingsUiState
|
||||
import org.fairscan.app.ui.screens.settings.SettingsViewModel
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import org.opencv.android.OpenCVLoader
|
||||
@@ -77,6 +80,9 @@ import java.io.File
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private lateinit var cameraViewModel: CameraViewModel
|
||||
private lateinit var viewModel: MainViewModel
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
initLibraries()
|
||||
@@ -93,11 +99,11 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
val imageRepository = sessionViewModel.imageRepository
|
||||
val viewModel: MainViewModel by viewModels {
|
||||
viewModel = viewModels<MainViewModel> {
|
||||
appContainer.viewModelFactory {
|
||||
MainViewModel(imageRepository)
|
||||
MainViewModel(imageRepository, appContainer.logger)
|
||||
}
|
||||
}
|
||||
}.value
|
||||
val exportViewModel: ExportViewModel by viewModels {
|
||||
appContainer.viewModelFactory {
|
||||
ExportViewModel(appContainer, imageRepository)
|
||||
@@ -108,7 +114,7 @@ class MainActivity : ComponentActivity() {
|
||||
AboutViewModel(appContainer, imageRepository)
|
||||
}
|
||||
}
|
||||
val cameraViewModel: CameraViewModel by viewModels { appContainer.cameraViewModelFactory }
|
||||
cameraViewModel = viewModels<CameraViewModel> { appContainer.cameraViewModelFactory }.value
|
||||
|
||||
val settingsViewModel: SettingsViewModel
|
||||
by viewModels { appContainer.settingsViewModelFactory }
|
||||
@@ -126,6 +132,7 @@ class MainActivity : ComponentActivity() {
|
||||
val documentUiState by viewModel.documentUiState.collectAsStateWithLifecycle()
|
||||
val cropInitialState by viewModel.cropInitState.collectAsStateWithLifecycle()
|
||||
val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val settingsUiState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val cameraPermission = rememberCameraPermissionState()
|
||||
CollectCameraEvents(cameraViewModel, viewModel)
|
||||
CollectExportEvents(context, exportViewModel)
|
||||
@@ -158,11 +165,21 @@ class MainActivity : ComponentActivity() {
|
||||
null -> {
|
||||
// waiting to load pages to get an initial screen
|
||||
}
|
||||
is Screen.Main.ResumeScan -> {
|
||||
ResumeScanScreen(
|
||||
currentDocument = documentUiState,
|
||||
onResumeScan = navigation.toCameraScreen,
|
||||
onStartNewScan = {
|
||||
viewModel.startNewDocument()
|
||||
navigation.toCameraScreen()
|
||||
}
|
||||
)
|
||||
}
|
||||
is Screen.Main.Camera -> {
|
||||
val pickMultiple = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.PickMultipleVisualMedia(10)) {
|
||||
uris -> cameraViewModel.importPhotos(uris)
|
||||
}
|
||||
ActivityResultContracts.GetMultipleContents()) {
|
||||
uris -> cameraViewModel.importPhotos(uris)
|
||||
}
|
||||
CameraScreen(
|
||||
viewModel,
|
||||
cameraViewModel,
|
||||
@@ -174,8 +191,7 @@ class MainActivity : ComponentActivity() {
|
||||
cameraPermission = cameraPermission,
|
||||
onImportClicked = {
|
||||
cameraViewModel.onImportClicked()
|
||||
pickMultiple.launch(PickVisualMediaRequest(
|
||||
ActivityResultContracts.PickVisualMedia.ImageOnly))
|
||||
pickMultiple.launch("image/*")
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -205,12 +221,13 @@ class MainActivity : ComponentActivity() {
|
||||
navigation = navigation,
|
||||
uiState = exportUiState,
|
||||
currentDocument = document,
|
||||
pdfActions = ExportActions(
|
||||
exportActions = ExportActions(
|
||||
prepareExportIfNeeded = exportViewModel::prepareExportIfNeeded,
|
||||
setFilename = exportViewModel::setFilename,
|
||||
share = { exportViewModel.onShareClicked() },
|
||||
save = { exportViewModel.onSaveClicked() },
|
||||
open = { item -> openUri(item.uri, item.format.mimeType, logger) },
|
||||
cancelPreparationJob = exportViewModel::cancelPreparationJob,
|
||||
),
|
||||
onCloseScan = {
|
||||
exportViewModel.resetFilename()
|
||||
@@ -230,13 +247,29 @@ class MainActivity : ComponentActivity() {
|
||||
onCopyLogs = { aboutViewModel.onCopyLogsClicked() },
|
||||
onContactWithLastImageClicked =
|
||||
{ aboutViewModel.onContactWithLastImageClicked() },
|
||||
onStartActivity = { intent ->
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
showToast(getString(R.string.error_occurred))
|
||||
}
|
||||
},
|
||||
onViewLibraries = navigation.toLibrariesScreen)
|
||||
}
|
||||
is Screen.Overlay.Libraries -> {
|
||||
LibrariesScreen(onBack = navigation.back)
|
||||
}
|
||||
is Screen.Overlay.Settings -> {
|
||||
SettingsScreenWrapper(settingsViewModel, navigation, logger)
|
||||
SettingsScreenWrapper(settingsUiState, settingsViewModel, navigation, logger)
|
||||
}
|
||||
is Screen.Overlay.OcrLanguages -> {
|
||||
OcrLanguagesScreen(
|
||||
uiState = settingsUiState,
|
||||
onBack = navigation.back,
|
||||
onLanguageClick = settingsViewModel::onLanguageClick,
|
||||
onRemoveLanguage = settingsViewModel::onRemoveLanguage,
|
||||
onCancelOcrDownload = settingsViewModel::cancelOcrDownload,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,6 +279,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun resolveLaunchMode(intent: Intent?): LaunchMode {
|
||||
return when (intent?.action) {
|
||||
"org.fairscan.app.action.SCAN_TO_PDF" -> LaunchMode.EXTERNAL_SCAN_TO_PDF
|
||||
"android.intent.action.GET_CONTENT" -> LaunchMode.EXTERNAL_SCAN_TO_PDF
|
||||
else -> LaunchMode.NORMAL
|
||||
}
|
||||
}
|
||||
@@ -256,6 +290,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
@Composable
|
||||
private fun SettingsScreenWrapper(
|
||||
settingsUiState: SettingsUiState,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
nav: Navigation,
|
||||
logger: FileLogger,
|
||||
@@ -275,7 +310,6 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
val settingsUiState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(Unit) {
|
||||
settingsViewModel.refreshExportDirName()
|
||||
}
|
||||
@@ -294,7 +328,7 @@ class MainActivity : ComponentActivity() {
|
||||
onResetExportDirClick = { settingsViewModel.setExportDirUri(null) },
|
||||
onExportFormatChanged = { format -> settingsViewModel.setExportFormat(format) },
|
||||
onExportQualityChanged = { quality -> settingsViewModel.setExportQuality(quality) },
|
||||
onBack = nav.back,
|
||||
navigation = nav,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -436,23 +470,23 @@ class MainActivity : ComponentActivity() {
|
||||
} else {
|
||||
uriForFile(fileUri.toFile())
|
||||
}
|
||||
val openIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uriToOpen, mimeType)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
clipData = ClipData.newUri(contentResolver, "file", uriToOpen)
|
||||
}
|
||||
val chooser = Intent.createChooser(openIntent, getString(R.string.open_file)).apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
try {
|
||||
val openIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uriToOpen, mimeType)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
clipData = ClipData.newUri(contentResolver, "file", uriToOpen)
|
||||
}
|
||||
val chooser = Intent.createChooser(openIntent, getString(R.string.open_file)).apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(chooser)
|
||||
} catch (e: SecurityException) {
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
showToast(getString(R.string.error_no_app))
|
||||
} catch (e: Exception) {
|
||||
val errorMessage =
|
||||
"Failed to open URI, scheme=${uriToOpen.scheme}, authority=${uriToOpen.authority}"
|
||||
logger.e("OpenUri", errorMessage, e)
|
||||
throw OpenUriException(errorMessage, e)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
showToast(getString(R.string.error_no_app))
|
||||
showToast(getString(R.string.error_occurred))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,6 +512,7 @@ class MainActivity : ComponentActivity() {
|
||||
viewModel.navigateTo(Screen.Overlay.Settings)
|
||||
}
|
||||
},
|
||||
toOcrLanguagesScreen = { viewModel.navigateTo(Screen.Overlay.OcrLanguages) },
|
||||
back = {
|
||||
val origin = viewModel.currentScreen.value
|
||||
viewModel.navigateBack()
|
||||
@@ -492,6 +527,15 @@ class MainActivity : ComponentActivity() {
|
||||
|| launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
class OpenUriException(message: String, cause: Throwable) : RuntimeException(message, cause)
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (keyCode == KeyEvent.KEYCODE_VOLUME_UP ||
|
||||
keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
|
||||
if (viewModel.currentScreen.value is Screen.Main.Camera) {
|
||||
cameraViewModel.onVolumeKeyPressed()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,14 +37,15 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.data.ImageRepository
|
||||
import org.fairscan.app.data.Logger
|
||||
import org.fairscan.app.domain.CapturedPage
|
||||
import org.fairscan.app.domain.Rotation
|
||||
import org.fairscan.app.domain.ScanPage
|
||||
import org.fairscan.app.ui.NavigationState
|
||||
import org.fairscan.app.ui.Screen
|
||||
import org.fairscan.app.ui.screens.crop.CropInitState
|
||||
import org.fairscan.app.ui.screens.document.CurrentPageUiState
|
||||
import org.fairscan.app.ui.screens.document.DocumentUiState
|
||||
import org.fairscan.app.ui.screens.crop.CropInitState
|
||||
import org.fairscan.app.ui.state.DocumentUiModel
|
||||
import org.fairscan.app.ui.state.PageThumbnail
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
@@ -53,7 +54,7 @@ import org.fairscan.imageprocessing.Quad
|
||||
import kotlin.math.min
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainViewModel(val imageRepository: ImageRepository): ViewModel() {
|
||||
class MainViewModel(val imageRepository: ImageRepository, logger: Logger): ViewModel() {
|
||||
|
||||
private val _navigationState = MutableStateFlow<NavigationState?>(null)
|
||||
val currentScreen: StateFlow<Screen?> = _navigationState.map { it?.current }
|
||||
@@ -72,7 +73,7 @@ class MainViewModel(val imageRepository: ImageRepository): ViewModel() {
|
||||
if (pages.isEmpty()) {
|
||||
NavigationState.initial()
|
||||
} else {
|
||||
NavigationState.initial().navigateTo(Screen.Main.Export)
|
||||
NavigationState.initial().navigateTo(Screen.Main.ResumeScan)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,8 +103,13 @@ class MainViewModel(val imageRepository: ImageRepository): ViewModel() {
|
||||
.mapLatest { (page,loadingId) ->
|
||||
page?.let {
|
||||
val isLoading = (it.id == loadingId)
|
||||
val bitmap = imageRepository.jpegBytes(it.key())?.toBitmap()
|
||||
val canBeCropped = page.metadata != null
|
||||
val bitmap = try {
|
||||
imageRepository.jpegBytes(it.key())?.toBitmap()
|
||||
} catch (e: Exception) {
|
||||
logger.e("MainViewModel", "Failed to load image for ${it.id}", e)
|
||||
null
|
||||
}
|
||||
CurrentPageUiState(it.key(), bitmap, it.colorMode, canBeCropped, isLoading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.fairscan.app.data.ImageRepository
|
||||
import org.fairscan.app.data.Logger
|
||||
import org.fairscan.app.platform.ImageProcessor
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
@@ -43,6 +44,7 @@ class SessionViewModel(
|
||||
context = app,
|
||||
scanRootDir = sessionDir,
|
||||
scope = viewModelScope,
|
||||
logger = appContainer.logger,
|
||||
)
|
||||
|
||||
val imageRepository: ImageRepository = sessionContainer.imageRepository
|
||||
@@ -60,6 +62,7 @@ class ScanSessionContainer(
|
||||
context: Context,
|
||||
scanRootDir: File,
|
||||
scope: CoroutineScope,
|
||||
logger: Logger,
|
||||
) {
|
||||
private val density = context.resources.displayMetrics.density
|
||||
private val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt()
|
||||
@@ -68,5 +71,6 @@ class ScanSessionContainer(
|
||||
scanRootDir,
|
||||
ImageProcessor(thumbnailSizePx),
|
||||
scope,
|
||||
logger,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,12 @@ data class GeneratedPdf(
|
||||
)
|
||||
|
||||
fun interface PdfWriter {
|
||||
suspend fun writePdfFromJpegs(pages: List<PageToExport>, outputStream: OutputStream): Int
|
||||
suspend fun writePdfFromJpegs(
|
||||
pages: List<PageToExport>,
|
||||
outputStream: OutputStream,
|
||||
disableOcr: Boolean,
|
||||
onProgress: (Int) -> Unit,
|
||||
)
|
||||
}
|
||||
|
||||
class FileManager(
|
||||
@@ -43,15 +48,19 @@ class FileManager(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun generatePdf(pages: List<PageToExport>): GeneratedPdf {
|
||||
suspend fun generatePdf(
|
||||
pages: List<PageToExport>,
|
||||
disableOcr: Boolean,
|
||||
onProgress: (Int) -> Unit
|
||||
): GeneratedPdf {
|
||||
pdfDir.mkdirs()
|
||||
require(pdfDir.exists() && pdfDir.isDirectory) { "Invalid pdfDir: $pdfDir" }
|
||||
val file = File(pdfDir, "${System.currentTimeMillis()}.pdf")
|
||||
val pageCount = FileOutputStream(file).use {
|
||||
pdfWriter.writePdfFromJpegs(pages, it)
|
||||
FileOutputStream(file).use {
|
||||
pdfWriter.writePdfFromJpegs(pages, it, disableOcr, onProgress)
|
||||
}
|
||||
val sizeBytes = file.length()
|
||||
return GeneratedPdf(file, sizeBytes, pageCount)
|
||||
return GeneratedPdf(file, sizeBytes, pages.size)
|
||||
}
|
||||
|
||||
fun copyToExternalDir(original: File): File {
|
||||
|
||||
@@ -55,6 +55,7 @@ class ImageRepository(
|
||||
scanRootDir: File,
|
||||
val transformations: ImageTransformations,
|
||||
private val scope: CoroutineScope,
|
||||
private val logger: Logger,
|
||||
) {
|
||||
private val sourceDir = File(scanRootDir, SOURCE_DIR_NAME).apply { mkdirs() }
|
||||
private val processedDir = File(scanRootDir, PROCESSED_DIR_NAME).apply { mkdirs() }
|
||||
@@ -295,7 +296,13 @@ class ImageRepository(
|
||||
withContext(Dispatchers.IO) {
|
||||
val processed = getOrCompute(imageCache, key, ::computeProcessedImage)
|
||||
?: return@withContext null
|
||||
transformations.resizeToThumbnail(processed)
|
||||
try {
|
||||
transformations.resizeToThumbnail(processed)
|
||||
} catch (e: Exception) {
|
||||
val message = "Failed to compute thumbnail for ${key.pageId}"
|
||||
logger.e("ImageRepository", message, e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Other operations ---
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.data
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
data class OcrLanguage(
|
||||
val code: String,
|
||||
) {
|
||||
val locale: Locale by lazy {
|
||||
resolveLocale(code)
|
||||
}
|
||||
|
||||
fun displayName(displayLocale: Locale): String =
|
||||
locale.getDisplayName(displayLocale)
|
||||
|
||||
companion object {
|
||||
private fun verticalVariant(locale: Locale) =
|
||||
Locale.Builder().setLocale(locale).setVariant("vertical").build()
|
||||
|
||||
private fun resolveLocale(code: String): Locale {
|
||||
val firstPart = code.substringBefore("_")
|
||||
return LOCALE_OVERRIDES[code] ?: Locale.Builder().setLanguage(firstPart).build()
|
||||
}
|
||||
|
||||
val AVAILABLE_LANGUAGE_CODES = listOf(
|
||||
"afr", "amh", "ara", "asm", "aze_cyrl", "aze", "bel", "ben", "bod", "bos",
|
||||
"bre", "bul", "cat", "ceb", "ces", "chi_sim", "chi_sim_vert", "chi_tra",
|
||||
"chi_tra_vert", "chr", "cos", "cym", "dan", "deu", "div", "dzo",
|
||||
"ell", "eng", "enm", "epo", "est", "eus", "fao", "fas", "fil", "fin", "fra",
|
||||
"frm", "fry", "gla", "gle", "glg", "grc", "guj", "hat", "heb", "hin", "hrv",
|
||||
"hun", "hye", "iku", "ind", "isl", "ita", "jav", "jpn", "jpn_vert",
|
||||
"kan", "kat_old", "kat", "kaz", "khm", "kir", "kmr", "kor", "kor_vert", "lao",
|
||||
"lat", "lav", "lit", "ltz", "mal", "mar", "mkd", "mlt", "mon", "mri", "msa",
|
||||
"mya", "nep", "nld", "nor", "oci", "ori", "pan", "pol", "por", "pus", "que",
|
||||
"ron", "rus", "san", "sin", "slk", "slv", "snd", "spa", "sqi",
|
||||
"srp_latn", "srp", "sun", "swa", "swe", "syr", "tam", "tat", "tel", "tgk",
|
||||
"tha", "tir", "ton", "tur", "uig", "ukr", "urd", "uzb_cyrl", "uzb", "vie",
|
||||
"yid", "yor",
|
||||
)
|
||||
|
||||
private val LOCALE_OVERRIDES = mapOf(
|
||||
// Codes not recognized by Locale.Builder
|
||||
"bod" to Locale.forLanguageTag("bo"), // Tibetan
|
||||
"ces" to Locale.forLanguageTag("cs"), // Czech
|
||||
"cym" to Locale.forLanguageTag("cy"), // Welsh
|
||||
"deu" to Locale.GERMAN,
|
||||
"ell" to Locale.forLanguageTag("el"), // Greek
|
||||
"eus" to Locale.forLanguageTag("eu"), // Basque
|
||||
"fas" to Locale.forLanguageTag("fa"), // Persian
|
||||
"fra" to Locale.FRENCH,
|
||||
"hye" to Locale.forLanguageTag("hy"), // Armenian
|
||||
"isl" to Locale.forLanguageTag("is"), // Icelandic
|
||||
"kat" to Locale.forLanguageTag("ka"), // Georgian
|
||||
"kmr" to Locale.forLanguageTag("ku"), // Kurdish
|
||||
"mkd" to Locale.forLanguageTag("mk"), // Macedonian
|
||||
"mri" to Locale.forLanguageTag("mi"), // Maori
|
||||
"msa" to Locale.forLanguageTag("ms"), // Malay
|
||||
"mya" to Locale.forLanguageTag("my"), // Burmese
|
||||
"nld" to Locale.forLanguageTag("nl"), // Dutch
|
||||
"ron" to Locale.forLanguageTag("ro"), // Romanian
|
||||
"slk" to Locale.forLanguageTag("sk"), // Slovak
|
||||
"sqi" to Locale.forLanguageTag("sq"), // Albanian
|
||||
// Variants pointing to same Locale
|
||||
"chi_sim" to Locale.SIMPLIFIED_CHINESE,
|
||||
"chi_tra" to Locale.TRADITIONAL_CHINESE,
|
||||
"chi_sim_vert" to verticalVariant(Locale.SIMPLIFIED_CHINESE),
|
||||
"chi_tra_vert" to verticalVariant(Locale.TRADITIONAL_CHINESE),
|
||||
"jpn_vert" to verticalVariant(Locale.JAPANESE),
|
||||
"kor_vert" to verticalVariant(Locale.forLanguageTag("ko")),
|
||||
"aze_cyrl" to Locale.forLanguageTag("az-Cyrl"),
|
||||
"uzb_cyrl" to Locale.forLanguageTag("uz-Cyrl"),
|
||||
"srp_latn" to Locale.forLanguageTag("sr-Latn"),
|
||||
"deu_latf" to Locale.forLanguageTag("de-1901"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.data.OcrLanguage.Companion.AVAILABLE_LANGUAGE_CODES
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class OcrLanguageRepository(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
val tessdataDir: File,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val ENABLED_LANGUAGES = stringSetPreferencesKey("enabled_ocr_languages")
|
||||
}
|
||||
|
||||
val enabledLanguages: Flow<Set<String>> =
|
||||
dataStore.data.map { prefs ->
|
||||
prefs[ENABLED_LANGUAGES] ?: emptySet()
|
||||
}
|
||||
|
||||
suspend fun getInstalledLanguages(): Set<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!tessdataDir.exists()) {
|
||||
return@withContext emptySet()
|
||||
}
|
||||
return@withContext tessdataDir
|
||||
.listFiles { _, name -> name.endsWith(".traineddata") }
|
||||
?.map { f -> f.nameWithoutExtension }
|
||||
?.filter { it in AVAILABLE_LANGUAGE_CODES }
|
||||
?.toSet()
|
||||
?: emptySet()
|
||||
}
|
||||
|
||||
suspend fun getActiveLanguages(): Set<String> {
|
||||
val enabled = enabledLanguagesValue()
|
||||
val installed = getInstalledLanguages()
|
||||
return enabled.intersect(installed)
|
||||
}
|
||||
|
||||
suspend fun setLanguageEnabled(
|
||||
code: String,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
require(code in AVAILABLE_LANGUAGE_CODES)
|
||||
if (enabled && code !in getInstalledLanguages()) {
|
||||
return
|
||||
}
|
||||
dataStore.edit { prefs ->
|
||||
val current = prefs[ENABLED_LANGUAGES]?.toMutableSet() ?: mutableSetOf()
|
||||
if (enabled) {
|
||||
current.add(code)
|
||||
} else {
|
||||
current.remove(code)
|
||||
}
|
||||
prefs[ENABLED_LANGUAGES] = current
|
||||
}
|
||||
}
|
||||
|
||||
data class DownloadProgress(
|
||||
val downloadedBytes: Long,
|
||||
val totalBytes: Long?,
|
||||
)
|
||||
|
||||
suspend fun downloadLanguage(code: String, onProgress: (DownloadProgress) -> Unit) =
|
||||
withContext(Dispatchers.IO) {
|
||||
require(code in AVAILABLE_LANGUAGE_CODES)
|
||||
tessdataDir.mkdirs()
|
||||
|
||||
val url = URL("https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/main/$code.traineddata")
|
||||
val targetFile = File(tessdataDir, "$code.traineddata")
|
||||
val tempFile = File(tessdataDir, "$code.download")
|
||||
|
||||
val connection = (url.openConnection() as HttpURLConnection)
|
||||
try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 30_000
|
||||
connection.instanceFollowRedirects = true
|
||||
connection.connect()
|
||||
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||
throw IOException("HTTP ${connection.responseCode}")
|
||||
}
|
||||
connection.inputStream.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
val totalBytes = connection.contentLengthLong.takeIf { it > 0 }
|
||||
val buffer = ByteArray(8192)
|
||||
var downloadedBytes = 0L
|
||||
input.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
while (true) {
|
||||
coroutineContext.ensureActive()
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
output.write(buffer, 0, read)
|
||||
downloadedBytes += read
|
||||
onProgress(DownloadProgress(downloadedBytes, totalBytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tempFile.length() < 300_000) {
|
||||
throw IOException("Downloaded file too small")
|
||||
}
|
||||
if (!tempFile.renameTo(targetFile)) {
|
||||
throw IOException("Failed to move downloaded file")
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteLanguage(code: String) {
|
||||
val file = File(tessdataDir, "$code.traineddata")
|
||||
file.delete()
|
||||
setLanguageEnabled(code, false)
|
||||
}
|
||||
|
||||
suspend fun buildTesseractLanguageString(): String {
|
||||
return getActiveLanguages()
|
||||
.sorted()
|
||||
.joinToString("+")
|
||||
}
|
||||
|
||||
private suspend fun enabledLanguagesValue(): Set<String> {
|
||||
return enabledLanguages.map { it }.first()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.domain
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import com.googlecode.tesseract.android.TessBaseAPI
|
||||
import com.googlecode.tesseract.android.TessBaseAPI.PageIteratorLevel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.fairscan.app.data.OcrLanguageRepository
|
||||
import org.fairscan.imageprocessing.ImageRect
|
||||
import org.fairscan.imageprocessing.OcrTextBox
|
||||
|
||||
class OcrService(
|
||||
private val ocrLanguageRepository: OcrLanguageRepository,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private var tess: TessBaseAPI? = null
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
private var languageString = ""
|
||||
fun languageString() = languageString
|
||||
|
||||
fun initialize() {
|
||||
scope.launch {
|
||||
ocrLanguageRepository.enabledLanguages.collect { _ -> reinitialize() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reinitialize() {
|
||||
mutex.withLock {
|
||||
tess?.recycle()
|
||||
tess = null
|
||||
|
||||
languageString = ocrLanguageRepository.buildTesseractLanguageString()
|
||||
if (languageString.isEmpty()) return
|
||||
|
||||
val dataPath = ocrLanguageRepository.tessdataDir.parent!!
|
||||
val newTess = TessBaseAPI()
|
||||
if (!newTess.init(dataPath, languageString)) {
|
||||
newTess.recycle()
|
||||
return
|
||||
}
|
||||
tess = newTess
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun runOcr(bitmap: Bitmap): List<OcrTextBox> {
|
||||
mutex.withLock {
|
||||
val tess = this.tess ?: return listOf()
|
||||
val textBoxes = mutableListOf<OcrTextBox>()
|
||||
tess.setImage(bitmap)
|
||||
tess.getUTF8Text() // Trigger text recognition
|
||||
val iterator = tess.resultIterator
|
||||
iterator.begin()
|
||||
do {
|
||||
val word = iterator.getUTF8Text(PageIteratorLevel.RIL_WORD) ?: continue
|
||||
val wordBox = iterator.getBoundingRect(PageIteratorLevel.RIL_WORD)
|
||||
val lineBox = iterator.getBoundingRect(PageIteratorLevel.RIL_TEXTLINE)
|
||||
val confidence = iterator.confidence(PageIteratorLevel.RIL_WORD)
|
||||
if (confidence > 50) {
|
||||
val rect = wordBox.toImageRect()
|
||||
textBoxes.add(OcrTextBox(word, rect, lineBox.height(), lineBox.bottom))
|
||||
}
|
||||
} while (iterator.next(PageIteratorLevel.RIL_WORD))
|
||||
iterator.delete()
|
||||
return textBoxes
|
||||
}
|
||||
}
|
||||
|
||||
private fun Rect.toImageRect(): ImageRect = ImageRect(left, top, right, bottom)
|
||||
}
|
||||
@@ -14,28 +14,47 @@
|
||||
*/
|
||||
package org.fairscan.app.platform
|
||||
|
||||
import android.content.res.AssetManager
|
||||
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.pdmodel.PDDocument
|
||||
import com.tom_roush.pdfbox.pdmodel.PDPage
|
||||
import com.tom_roush.pdfbox.pdmodel.PDPageContentStream
|
||||
import com.tom_roush.pdfbox.pdmodel.PDPageContentStream.AppendMode
|
||||
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.image.JPEGFactory
|
||||
import org.fairscan.app.BuildConfig
|
||||
import org.fairscan.app.data.PdfWriter
|
||||
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.OutputStream
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
class AndroidPdfWriter : PdfWriter {
|
||||
override suspend fun writePdfFromJpegs(pages: List<PageToExport>, outputStream: OutputStream): Int {
|
||||
class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : PdfWriter {
|
||||
|
||||
override suspend fun writePdfFromJpegs(
|
||||
pages: List<PageToExport>,
|
||||
outputStream: OutputStream,
|
||||
disableOcr: Boolean,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
val doc = PDDocument()
|
||||
doc.documentInformation.creationDate = Calendar.getInstance()
|
||||
doc.documentInformation.creator = "FairScan ${BuildConfig.VERSION_NAME}"
|
||||
doc.use { document ->
|
||||
for (page in pages) {
|
||||
val image = JPEGFactory.createFromByteArray(document, page.jpeg.get().bytes)
|
||||
val ocrDocument = OcrDocument(document, assets)
|
||||
for ((index, page) in pages.withIndex()) {
|
||||
val jpeg = page.jpeg.get()
|
||||
val image = JPEGFactory.createFromByteArray(document, jpeg.bytes)
|
||||
|
||||
// PDF has 72 points (units) per inch, 1 inch = 25.4 mm
|
||||
val pointsPerMm = 72f / 25.4f
|
||||
@@ -62,12 +81,29 @@ class AndroidPdfWriter : PdfWriter {
|
||||
|
||||
val contentStream = PDPageContentStream(document, page, AppendMode.OVERWRITE, false)
|
||||
contentStream.drawImage(image, 0f, 0f, widthPoints, heightPoints)
|
||||
|
||||
if (!disableOcr) {
|
||||
try {
|
||||
val bitmap = jpeg.toBitmap()
|
||||
val ocrTextBoxes = ocrService.runOcr(bitmap)
|
||||
val pdfPageDimensions = PageDimensions(
|
||||
bitmap.width,
|
||||
bitmap.height,
|
||||
widthPoints,
|
||||
heightPoints
|
||||
)
|
||||
ocrDocument.addPage(page, ocrTextBoxes, pdfPageDimensions)
|
||||
} catch (e: Exception) {
|
||||
Log.e("AndroidPdfWriter", "Failed to run OCR on page $index", e)
|
||||
}
|
||||
}
|
||||
contentStream.close()
|
||||
|
||||
onProgress(index + 1)
|
||||
}
|
||||
// TODO So the whole document is in memory before this line...
|
||||
document.save(outputStream)
|
||||
}
|
||||
return doc.numberOfPages
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,3 +118,179 @@ fun constrainToMaxFormat(widthMm: Double, heightMm: Double): Pair<Double, Double
|
||||
)
|
||||
return widthMm * scale to heightMm * scale
|
||||
}
|
||||
|
||||
data class PageDimensions(
|
||||
val imageWidth: Int,
|
||||
val imageHeight: Int,
|
||||
val pageWidth: Float,
|
||||
val pageHeight: Float,
|
||||
)
|
||||
|
||||
// Adapted from https://github.com/tesseract-ocr/tesseract/blob/main/src/api/pdfrenderer.cpp
|
||||
class OcrDocument(
|
||||
private val document: PDDocument,
|
||||
private val assets: AssetManager,
|
||||
) {
|
||||
private val fontBytes: ByteArray by lazy {
|
||||
assets.open("fonts/TesseractGlyphLessFont.ttf").readBytes()
|
||||
}
|
||||
|
||||
private val cidToGidMap: ByteArray by lazy {
|
||||
ByteArray(65536 * 2) { i -> if (i % 2 == 0) 0x00.toByte() else 0x01.toByte() }
|
||||
}
|
||||
|
||||
private var fontDict: COSDictionary? = null
|
||||
|
||||
fun ensureResourcesAreCreated() {
|
||||
if (fontDict != null)
|
||||
return
|
||||
|
||||
// -- Object 1 : embedded TTF --
|
||||
val fontFileStream = PDStream(document)
|
||||
fontFileStream.createOutputStream().use { it.write(fontBytes) }
|
||||
fontFileStream.cosObject.setInt(COSName.LENGTH1, fontBytes.size)
|
||||
|
||||
// -- Object 2 : CIDToGIDMap stream --
|
||||
val cidToGidStream = PDStream(document)
|
||||
cidToGidStream.createOutputStream(COSName.FLATE_DECODE).use {
|
||||
it.write(cidToGidMap)
|
||||
}
|
||||
|
||||
// -- Object 3 : FontDescriptor --
|
||||
val fontDescriptor = PDFontDescriptor(COSDictionary())
|
||||
fontDescriptor.fontName = "GlyphLessFont"
|
||||
fontDescriptor.flags = 5
|
||||
fontDescriptor.fontBoundingBox = PDRectangle(0f, 0f, 500f, 750f)
|
||||
fontDescriptor.italicAngle = 0f
|
||||
fontDescriptor.ascent = 750f
|
||||
fontDescriptor.descent = 0f
|
||||
fontDescriptor.capHeight = 750f
|
||||
fontDescriptor.stemV = 80f
|
||||
fontDescriptor.cosObject.setItem(COSName.FONT_FILE2, fontFileStream)
|
||||
|
||||
// -- Object 4 : CIDFont descendant --
|
||||
val cidFont = COSDictionary()
|
||||
cidFont.setName(COSName.TYPE, "Font")
|
||||
cidFont.setName(COSName.SUBTYPE, "CIDFontType2")
|
||||
cidFont.setName(COSName.BASE_FONT, "GlyphLessFont")
|
||||
val cidSystemInfo = COSDictionary()
|
||||
cidSystemInfo.setString(COSName.getPDFName("Registry"), "Adobe")
|
||||
cidSystemInfo.setString(COSName.getPDFName("Ordering"), "Identity")
|
||||
cidSystemInfo.setInt(COSName.getPDFName("Supplement"), 0)
|
||||
cidFont.setItem(COSName.getPDFName("CIDSystemInfo"), cidSystemInfo)
|
||||
cidFont.setItem(COSName.FONT_DESC, fontDescriptor)
|
||||
cidFont.setInt(COSName.getPDFName("DW"), 500)
|
||||
cidFont.setItem(COSName.getPDFName("CIDToGIDMap"), cidToGidStream)
|
||||
|
||||
// -- Object 5 : ToUnicode CMap --
|
||||
val toUnicode = buildToUnicodeCMap()
|
||||
val toUnicodeStream = PDStream(document)
|
||||
toUnicodeStream.createOutputStream().use {
|
||||
it.write(toUnicode.toByteArray(Charsets.US_ASCII))
|
||||
}
|
||||
|
||||
// -- Object 6 : Font Type0 --
|
||||
val fontDict = COSDictionary()
|
||||
fontDict.setName(COSName.TYPE, "Font")
|
||||
fontDict.setName(COSName.SUBTYPE, "Type0")
|
||||
fontDict.setName(COSName.BASE_FONT, "GlyphLessFont")
|
||||
fontDict.setName(COSName.ENCODING, "Identity-H")
|
||||
val descendants = COSArray()
|
||||
descendants.add(cidFont)
|
||||
fontDict.setItem(COSName.DESCENDANT_FONTS, descendants)
|
||||
fontDict.setItem(COSName.TO_UNICODE, toUnicodeStream)
|
||||
this.fontDict = fontDict
|
||||
}
|
||||
|
||||
private fun buildToUnicodeCMap(): String = buildString {
|
||||
append("/CIDInit /ProcSet findresource begin\n")
|
||||
append("12 dict begin\n")
|
||||
append("begincmap\n")
|
||||
append("/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n")
|
||||
append("/CMapName /Adobe-Identify-UCS def\n")
|
||||
append("/CMapType 2 def\n")
|
||||
append("1 begincodespacerange\n")
|
||||
append("<0000> <FFFF>\n")
|
||||
append("endcodespacerange\n")
|
||||
append("1 beginbfrange\n")
|
||||
append("<0000> <FFFF> <0000>\n")
|
||||
append("endbfrange\n")
|
||||
append("endcmap\n")
|
||||
append("CMapName currentdict /CMap defineresource pop\n")
|
||||
append("end\nend\n")
|
||||
}
|
||||
|
||||
fun addPage(
|
||||
page: PDPage,
|
||||
ocrTextBoxes: List<OcrTextBox>,
|
||||
dimensions: PageDimensions,
|
||||
) {
|
||||
if (ocrTextBoxes.isEmpty()) return
|
||||
|
||||
ensureResourcesAreCreated()
|
||||
val resources = page.resources ?: PDResources().also { page.resources = it }
|
||||
val fontResources = resources.cosObject
|
||||
.getDictionaryObject(COSName.FONT) as? COSDictionary
|
||||
?: COSDictionary().also {
|
||||
resources.cosObject.setItem(COSName.FONT, it)
|
||||
}
|
||||
fontResources.setItem(COSName.getPDFName("F1"), fontDict)
|
||||
|
||||
val textStream = buildTextStream(ocrTextBoxes, dimensions)
|
||||
val pdTextStream = PDStream(document)
|
||||
pdTextStream.createOutputStream(COSName.FLATE_DECODE).use {
|
||||
it.write(textStream.toByteArray(Charsets.US_ASCII))
|
||||
}
|
||||
|
||||
val contentsArray = COSArray()
|
||||
val iter = page.contentStreams
|
||||
while (iter.hasNext()) contentsArray.add(iter.next().cosObject)
|
||||
contentsArray.add(pdTextStream.cosObject)
|
||||
page.cosObject.setItem(COSName.CONTENTS, contentsArray)
|
||||
}
|
||||
|
||||
private fun buildTextStream(
|
||||
ocrTextBoxes: List<OcrTextBox>,
|
||||
dimensions: PageDimensions,
|
||||
): String {
|
||||
val scaleX = dimensions.pageWidth / dimensions.imageWidth
|
||||
val scaleY = dimensions.pageHeight / dimensions.imageHeight
|
||||
val sb = StringBuilder()
|
||||
|
||||
for (textBox in ocrTextBoxes) {
|
||||
val x = textBox.box.left * scaleX
|
||||
val wordWidth = textBox.box.width * scaleX
|
||||
val fontSize = textBox.lineHeight * scaleY * 0.8f
|
||||
// nominal width: fontSize / kCharWidth (Tesseract convention)
|
||||
val nominalWidth = textBox.text.length * fontSize / 2f
|
||||
val hScale = if (nominalWidth > 0f) (wordWidth / nominalWidth) * 100f else 100f
|
||||
val baselineY = dimensions.pageHeight - (textBox.lineBottom * scaleY) + fontSize * 0.2f
|
||||
|
||||
val utf16hex = buildString {
|
||||
textBox.text.codePoints().forEach { cp ->
|
||||
append(codepointToUtf16beHex(cp))
|
||||
}
|
||||
}
|
||||
|
||||
sb.append("BT\n")
|
||||
sb.append(String.format(Locale.US, "/F1 %.3f Tf\n", fontSize))
|
||||
sb.append(String.format(Locale.US,"%.3f Tz\n", hScale))
|
||||
sb.append("3 Tr\n")
|
||||
sb.append(String.format(Locale.US,"%.3f %.3f Td\n", x, baselineY))
|
||||
sb.append("<${utf16hex}0020> Tj\n")
|
||||
sb.append("ET\n")
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun codepointToUtf16beHex(cp: Int): String {
|
||||
return if (cp < 0x10000) {
|
||||
"%04X".format(cp)
|
||||
} else {
|
||||
val a = cp - 0x10000
|
||||
val high = (a shr 10) + 0xD800
|
||||
val low = (a and 0x3FF) + 0xDC00
|
||||
"%04X%04X".format(high, low)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import org.fairscan.imageprocessing.resizeForMaxPixels
|
||||
import org.fairscan.imageprocessing.rotate
|
||||
import org.fairscan.imageprocessing.scaledTo
|
||||
import org.opencv.android.Utils
|
||||
import org.opencv.core.CvException
|
||||
import org.opencv.core.Mat
|
||||
import org.opencv.core.Size
|
||||
import org.opencv.imgproc.Imgproc
|
||||
@@ -47,7 +48,7 @@ class ImageProcessor(private val thumbnailSizePx: Int) : ImageTransformations {
|
||||
|
||||
override fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg {
|
||||
return transform(input, ExportQuality.BALANCED.jpegQuality) {
|
||||
org.fairscan.imageprocessing.rotate(it, rotationDegrees)
|
||||
rotate(it, rotationDegrees)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +59,12 @@ class ImageProcessor(private val thumbnailSizePx: Int) : ImageTransformations {
|
||||
val newW = (src.width() * ratio).toDouble()
|
||||
val newH = (src.height() * ratio).toDouble()
|
||||
val scaled = Mat()
|
||||
Imgproc.resize(src, scaled, Size(newW, newH))
|
||||
try {
|
||||
Imgproc.resize(src, scaled, Size(newW, newH))
|
||||
} catch (e: CvException) {
|
||||
val msg = "Resize failed. src=${src.width()}x${src.height()} dst=${newW}x${newH}"
|
||||
throw IllegalStateException(msg, e)
|
||||
}
|
||||
scaled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,13 @@ sealed class Screen {
|
||||
object EditImage : Main()
|
||||
data class Document(val initialPage: Int = 0) : Main()
|
||||
object Export : Main()
|
||||
object ResumeScan : Main()
|
||||
}
|
||||
sealed class Overlay : Screen() {
|
||||
object About : Overlay()
|
||||
object Libraries : Overlay()
|
||||
object Settings : Overlay()
|
||||
object OcrLanguages : Overlay()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +38,7 @@ data class Navigation(
|
||||
val toAboutScreen: () -> Unit,
|
||||
val toLibrariesScreen: () -> Unit,
|
||||
val toSettingsScreen: (() -> Unit)?,
|
||||
val toOcrLanguagesScreen: () -> Unit,
|
||||
val back: () -> Unit,
|
||||
val shouldDisplayBackButton: () -> Boolean,
|
||||
)
|
||||
@@ -63,6 +66,7 @@ data class NavigationState private constructor(val stack: List<Screen>, val root
|
||||
fun navigateBack(): NavigationState {
|
||||
return when (current) {
|
||||
root -> this // Back handled by system
|
||||
is Screen.Main.ResumeScan -> this // Back handled by system
|
||||
is Screen.Main.Camera -> this // Back handled by system
|
||||
is Screen.Main.Document -> copy(stack = listOf(Screen.Main.Camera))
|
||||
is Screen.Main.EditImage -> copy(stack = listOf(Screen.Main.Document()))
|
||||
|
||||
@@ -25,7 +25,7 @@ import org.fairscan.app.ui.state.PageThumbnail
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
fun dummyNavigation(): Navigation {
|
||||
return Navigation({}, {}, {}, {}, {}, {}, {}, {}, { -> true})
|
||||
return Navigation({}, {}, {}, {}, {}, {}, {}, {}, {}, { -> true})
|
||||
}
|
||||
|
||||
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {
|
||||
|
||||
@@ -25,9 +25,3 @@ import java.util.Date
|
||||
fun pageCountText(quantity: Int): String {
|
||||
return LocalResources.current.getQuantityString(R.plurals.page_count, quantity, quantity)
|
||||
}
|
||||
|
||||
fun formatDate(timestamp: Long, context: Context): String {
|
||||
val date = Date(timestamp)
|
||||
return DateFormat.getMediumDateFormat(context).format(date) + " " +
|
||||
DateFormat.getTimeFormat(context).format(date)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.ui.screens
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
import org.fairscan.app.domain.Rotation
|
||||
import org.fairscan.app.ui.components.isLandscape
|
||||
import org.fairscan.app.ui.components.pageCountText
|
||||
import org.fairscan.app.ui.fakeDocument
|
||||
import org.fairscan.app.ui.fakeImage
|
||||
import org.fairscan.app.ui.screens.document.CurrentPageUiState
|
||||
import org.fairscan.app.ui.screens.document.DocumentUiState
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
|
||||
@Composable
|
||||
fun ResumeScanScreen(
|
||||
currentDocument: DocumentUiState,
|
||||
onResumeScan: () -> Unit,
|
||||
onStartNewScan: () -> Unit,
|
||||
) {
|
||||
val pageCount = currentDocument.document.pageCount()
|
||||
val firstPageThumbnail = currentDocument.currentPage?.bitmap
|
||||
|
||||
val resumeScanModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onResumeScan)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
||||
.padding(horizontal = 16.dp, vertical = 24.dp)
|
||||
|
||||
Scaffold { innerPadding ->
|
||||
if (!isLandscape(LocalConfiguration.current)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
Column(
|
||||
modifier = resumeScanModifier.weight(2f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
FirstPageThumbnail(firstPageThumbnail, Modifier.weight(1f))
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
ResumeScanActions(pageCount, onResumeScan)
|
||||
}
|
||||
HorizontalDivider()
|
||||
NewScanArea(onStartNewScan, Modifier.weight(1f))
|
||||
}
|
||||
} else {
|
||||
Row {
|
||||
Row(
|
||||
modifier = resumeScanModifier.weight(1.8f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
FirstPageThumbnail(firstPageThumbnail, Modifier.weight(1f))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
ResumeScanActions(pageCount, onResumeScan)
|
||||
}
|
||||
}
|
||||
VerticalDivider()
|
||||
NewScanArea(onStartNewScan, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun FirstPageThumbnail(firstPageThumbnail: Bitmap?, modifier: Modifier) {
|
||||
firstPageThumbnail?.let { bmp ->
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
bitmap = bmp.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(1.dp, Color.Gray, RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResumeScanActions(pageCount: Int, onResumeScan: () -> Unit) {
|
||||
|
||||
FlowRow {
|
||||
val style = MaterialTheme.typography.bodyMedium
|
||||
Text(stringResource(R.string.scan_current), style = style)
|
||||
Text(" • ", style = style)
|
||||
Text(pageCountText(pageCount), style = style)
|
||||
}
|
||||
BigButton(onClick = onResumeScan, text = stringResource(R.string.scan_resume))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewScanArea(onStartNewScan: () -> Unit, modifier: Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.clickable(onClick = onStartNewScan)
|
||||
.padding(horizontal = 16.dp, vertical = 32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_discard_current),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
BigButton(onClick = onStartNewScan, text = stringResource(R.string.scan_new))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BigButton(onClick: () -> Unit, text: String) {
|
||||
Button(onClick = onClick, modifier = Modifier.padding(vertical = 16.dp)) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, showSystemUi = true)
|
||||
@Preview(showBackground = true, widthDp = 320, heightDp = 640)
|
||||
@Preview(showBackground = true, widthDp = 640, heightDp = 320)
|
||||
@Composable
|
||||
fun ResumeScanScreenPreview() {
|
||||
val image = fakeImage("gallica.bnf.fr-bpt6k5530456s-1", LocalContext.current).toBitmap()
|
||||
val document = fakeDocument(
|
||||
listOf(1, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it" }.toImmutableList(),
|
||||
LocalContext.current
|
||||
)
|
||||
val key = PageViewKey("123", Rotation.R0, null, 0)
|
||||
FairScanTheme {
|
||||
ResumeScanScreen(
|
||||
DocumentUiState(1, CurrentPageUiState(key,image, ColorMode.COLOR, true), document),
|
||||
{}, {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ fun AboutScreen(
|
||||
onBack: () -> Unit,
|
||||
onCopyLogs: () -> Unit,
|
||||
onContactWithLastImageClicked: () -> Unit,
|
||||
onStartActivity: (Intent) -> Unit,
|
||||
onViewLibraries: () -> Unit,
|
||||
) {
|
||||
val showLicenseDialog = rememberSaveable { mutableStateOf(false) }
|
||||
@@ -99,6 +100,7 @@ fun AboutScreen(
|
||||
aboutUiState,
|
||||
onCopyLogs,
|
||||
onContactWithLastImageClicked,
|
||||
onStartActivity,
|
||||
showLicenseDialog,
|
||||
onViewLibraries)
|
||||
}
|
||||
@@ -113,6 +115,7 @@ fun AboutContent(
|
||||
aboutUiState: AboutUiState,
|
||||
onCopyLogs: () -> Unit,
|
||||
onContactWithLastImageClicked: () -> Unit,
|
||||
onStartActivity: (Intent) -> Unit,
|
||||
showLicenseDialog: MutableState<Boolean>,
|
||||
onViewLibraries: () -> Unit,
|
||||
) {
|
||||
@@ -155,7 +158,7 @@ fun AboutContent(
|
||||
ContactLink(
|
||||
icon = Icons.Default.Email,
|
||||
text = EMAIL_ADDRESS,
|
||||
onClick = { context.startActivity(createContactEmailIntent()) }
|
||||
onClick = { onStartActivity(createContactEmailIntent()) }
|
||||
)
|
||||
val websiteUrl = "https://fairscan.org"
|
||||
ContactLink(
|
||||
@@ -163,7 +166,7 @@ fun AboutContent(
|
||||
text = websiteUrl,
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, websiteUrl.toUri())
|
||||
context.startActivity(intent)
|
||||
onStartActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -327,6 +330,6 @@ fun EmailImageButton(
|
||||
fun AboutScreenPreview() {
|
||||
FairScanTheme {
|
||||
val state = AboutUiState(true)
|
||||
AboutScreen(state, {}, {}, {}, {})
|
||||
AboutScreen(state, {}, {}, {}, {}, {})
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,24 @@ fun CameraScreen(
|
||||
listState.animateScrollToItem(document.lastIndex())
|
||||
}
|
||||
}
|
||||
|
||||
val onCapture = {
|
||||
previewView?.bitmap?.let {
|
||||
Log.i("FairScan", "Pressed <Capture>")
|
||||
cameraViewModel.onCapturePressed(it)
|
||||
captureController.takePicture(
|
||||
onImageCaptured = { imageProxy, opticalMeasures ->
|
||||
cameraViewModel.onImageCaptured(imageProxy, opticalMeasures)
|
||||
}
|
||||
)
|
||||
}?: Unit
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
cameraViewModel.volumeKeyEvent.collect {
|
||||
onCapture()
|
||||
}
|
||||
}
|
||||
|
||||
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
CameraScreenScaffold(
|
||||
cameraPreview = {
|
||||
@@ -224,16 +242,7 @@ fun CameraScreen(
|
||||
isLandscape = isLandscape,
|
||||
isDebugMode,
|
||||
isTorchEnabled),
|
||||
onCapture = {
|
||||
previewView?.bitmap?.let {
|
||||
Log.i("FairScan", "Pressed <Capture>")
|
||||
cameraViewModel.onCapturePressed(it)
|
||||
captureController.takePicture(
|
||||
onImageCaptured = { imageProxy, opticalMeasures ->
|
||||
cameraViewModel.onImageCaptured(imageProxy, opticalMeasures) }
|
||||
)
|
||||
}
|
||||
},
|
||||
onCapture = onCapture,
|
||||
onFinalizePressed = onFinalizePressed,
|
||||
onDebugModeSwitched = { isDebugMode = !isDebugMode },
|
||||
onTorchSwitched = {
|
||||
|
||||
@@ -34,8 +34,8 @@ import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.AppContainer
|
||||
import org.fairscan.app.domain.CapturedPage
|
||||
import org.fairscan.app.platform.extractDocumentFromBitmap
|
||||
import org.fairscan.imageprocessing.CameraIntrinsics
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.Mode
|
||||
import org.fairscan.imageprocessing.OpticalMeasures
|
||||
import org.fairscan.imageprocessing.detectDocumentQuad
|
||||
import java.util.concurrent.CancellationException
|
||||
@@ -68,10 +68,11 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
private val _isTorchEnabled = MutableStateFlow(false)
|
||||
val isTorchEnabled: StateFlow<Boolean> = _isTorchEnabled
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
imageSegmentationService.initialize()
|
||||
}
|
||||
private val _volumeKeyEvent = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val volumeKeyEvent = _volumeKeyEvent.asSharedFlow()
|
||||
|
||||
fun onVolumeKeyPressed() {
|
||||
_volumeKeyEvent.tryEmit(Unit)
|
||||
}
|
||||
|
||||
fun resetLiveAnalysis() {
|
||||
@@ -112,7 +113,7 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
val maskSize = segmentation.maskSize()
|
||||
val originalSize = ImageSize(imageProxy.width, imageProxy.height)
|
||||
val rawQuad = withContext(Dispatchers.Default) {
|
||||
detectDocumentQuad(segmentation, originalSize, isLiveAnalysis = true)
|
||||
detectDocumentQuad(segmentation, originalSize, Mode.LIVE_ANALYSIS)
|
||||
?.rotate90(rotationDegrees / 90, maskSize)
|
||||
}
|
||||
val binaryMaskProvider = { ->
|
||||
@@ -141,7 +142,8 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
try {
|
||||
val source = imageProxy.toBitmap()
|
||||
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
|
||||
val page = processCapturedImage(source, rotationDegrees, opticalMeasures)
|
||||
val page =
|
||||
processCapturedImage(source, rotationDegrees, opticalMeasures, Mode.CAPTURE)
|
||||
imageProxy.close()
|
||||
onCaptureProcessed(page)
|
||||
} catch (e: RuntimeException) {
|
||||
@@ -158,11 +160,12 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
source: Bitmap,
|
||||
rotationDegrees: Int,
|
||||
opticalMeasures: OpticalMeasures?,
|
||||
mode: Mode,
|
||||
): CapturedPage = withContext(Dispatchers.IO) {
|
||||
val segmentation = imageSegmentationService.runSegmentationAndReturn(source)
|
||||
val mask = segmentation?.segmentation
|
||||
val originalSize = ImageSize(source.width, source.height)
|
||||
val quad = mask?.let { detectDocumentQuad(mask, originalSize, isLiveAnalysis = false) }
|
||||
val quad = mask?.let { detectDocumentQuad(mask, originalSize, mode) }
|
||||
val defaultColorMode = settingsRepository.defaultColorMode.first()
|
||||
val result = extractDocumentFromBitmap(
|
||||
source, quad, rotationDegrees, mask, viewModelScope, defaultColorMode, opticalMeasures)
|
||||
@@ -206,7 +209,7 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
try {
|
||||
val photoToImport = imageLoader.load(uri)
|
||||
ensureActive()
|
||||
val page = processCapturedImage(photoToImport, 0, null)
|
||||
val page = processCapturedImage(photoToImport, 0, null, Mode.IMPORT)
|
||||
ensureActive()
|
||||
_events.emit(CameraEvent.ImageCaptured(page))
|
||||
} catch (e: CancellationException) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.mandatorySystemGestures
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
@@ -35,8 +36,12 @@ import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@@ -70,17 +75,28 @@ fun CropScreen(
|
||||
navigation: Navigation,
|
||||
onUpdatePageQuad: (Quad) -> Unit,
|
||||
) {
|
||||
val state = remember(pageId) { CropScreenState() }
|
||||
val quadHandler = remember { QuadEditingHandler() }
|
||||
|
||||
if (initState is CropInitState.Ready && initState.pageId == pageId && state.bitmap == null) {
|
||||
state.bitmap = initState.bitmap
|
||||
state.setInitialQuad(initState.quad)
|
||||
val (bitmap, initialQuad) = if (initState is CropInitState.Ready && initState.pageId == pageId) {
|
||||
Pair(initState.bitmap, initState.quad)
|
||||
} else {
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
val editableQuad = rememberSaveable(pageId, saver = QuadSaver) {
|
||||
mutableStateOf(initialQuad)
|
||||
}
|
||||
val state = remember(pageId) {
|
||||
CropScreenState(editableQuad)
|
||||
}
|
||||
|
||||
BackHandler { navigation.back() }
|
||||
|
||||
val isLandscape = isLandscape(LocalConfiguration.current)
|
||||
val density = LocalDensity.current
|
||||
val bottomInsetForSystemGestures = with(density) {
|
||||
WindowInsets.mandatorySystemGestures.getBottom(density).toDp()
|
||||
}
|
||||
|
||||
MyScaffold(
|
||||
navigation = navigation,
|
||||
@@ -88,7 +104,7 @@ fun CropScreen(
|
||||
) { modifier ->
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
state.bitmap?.let { bmp ->
|
||||
bitmap?.let { bmp ->
|
||||
val imageBitmap = remember(bmp) { bmp.asImageBitmap() }
|
||||
|
||||
Box(
|
||||
@@ -102,6 +118,7 @@ fun CropScreen(
|
||||
contentDescription = "Image to edit",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = bottomInsetForSystemGestures)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
state.containerSize = coordinates.size
|
||||
},
|
||||
@@ -112,7 +129,7 @@ fun CropScreen(
|
||||
}
|
||||
}
|
||||
|
||||
DragMagnifyingGlass(state)
|
||||
DragMagnifyingGlass(state, bitmap)
|
||||
|
||||
ActionButtons(
|
||||
modifier = Modifier
|
||||
@@ -238,7 +255,7 @@ private fun DragQuadOverlay(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DragMagnifyingGlass(state: CropScreenState) {
|
||||
private fun DragMagnifyingGlass(state: CropScreenState, bitmap: Bitmap?) {
|
||||
// showLoupe becomes true immediately on touch-down and stays true for
|
||||
// one additional second after the finger is lifted.
|
||||
val showLoupe = remember { mutableStateOf(false) }
|
||||
@@ -257,7 +274,7 @@ private fun DragMagnifyingGlass(state: CropScreenState) {
|
||||
|
||||
if (!showLoupe.value || state.dragPosition == null || state.containerSize == null) return
|
||||
|
||||
val bmp = state.bitmap ?: return
|
||||
val bmp = bitmap ?: return
|
||||
val containerSize = state.containerSize!!
|
||||
val displaySize = QuadCoordinateUtils.calculateDisplaySize(
|
||||
bmp.width, bmp.height, containerSize
|
||||
@@ -303,6 +320,30 @@ private fun DragMagnifyingGlass(state: CropScreenState) {
|
||||
)
|
||||
}
|
||||
|
||||
val QuadSaver: Saver<MutableState<Quad?>, *> = listSaver(
|
||||
save = { state ->
|
||||
state.value?.let {
|
||||
listOf(
|
||||
it.topLeft.x, it.topLeft.y,
|
||||
it.topRight.x, it.topRight.y,
|
||||
it.bottomRight.x, it.bottomRight.y,
|
||||
it.bottomLeft.x, it.bottomLeft.y,
|
||||
)
|
||||
} ?: listOf()
|
||||
},
|
||||
restore = { list ->
|
||||
val quad = if (list.size == 8) {
|
||||
Quad(
|
||||
topLeft = Point(list[0], list[1]),
|
||||
topRight = Point(list[2], list[3]),
|
||||
bottomRight = Point(list[4], list[5]),
|
||||
bottomLeft = Point(list[6], list[7]),
|
||||
)
|
||||
} else null
|
||||
mutableStateOf(quad)
|
||||
}
|
||||
)
|
||||
|
||||
@Composable
|
||||
@Preview(showSystemUi = true)
|
||||
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, showSystemUi = true)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
package org.fairscan.app.ui.screens.crop
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -34,15 +35,15 @@ sealed interface CropInitState {
|
||||
) : CropInitState
|
||||
}
|
||||
|
||||
class CropScreenState {
|
||||
class CropScreenState(editableQuad: MutableState<Quad?> = mutableStateOf(null)) {
|
||||
companion object {
|
||||
val LIFT_WIGGLE_MAX_DISTANCE = 8.dp
|
||||
const val LIFT_WIGGLE_WINDOW_MS = 70L
|
||||
}
|
||||
|
||||
var bitmap by mutableStateOf<android.graphics.Bitmap?>(null)
|
||||
var containerSize by mutableStateOf<IntSize?>(null)
|
||||
var editableQuad by mutableStateOf<Quad?>(null)
|
||||
var editableQuad: Quad? by editableQuad
|
||||
private set
|
||||
var draggedCornerIndex by mutableIntStateOf(-1)
|
||||
var dragPosition by mutableStateOf<Offset?>(null)
|
||||
/** True from the moment the finger touches a drag handle until it is lifted. */
|
||||
@@ -58,7 +59,6 @@ class CropScreenState {
|
||||
private var quadBeforeLastDragStep: Quad? = null
|
||||
private var lastDragStepDistancePx: Float = Float.MAX_VALUE
|
||||
private var lastDragStepAtMs: Long = 0L
|
||||
private var initialQuad: Quad? = null
|
||||
|
||||
fun updateQuad(newQuad: Quad) {
|
||||
editableQuad = newQuad
|
||||
@@ -122,9 +122,4 @@ class CropScreenState {
|
||||
}
|
||||
|
||||
fun isDragging(): Boolean = draggedCornerIndex >= 0
|
||||
|
||||
fun setInitialQuad(quad: Quad) {
|
||||
initialQuad = quad
|
||||
editableQuad = quad
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,14 +16,18 @@ package org.fairscan.app.ui.screens.crop
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.systemGestureExclusion
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
import org.fairscan.imageprocessing.scaledTo
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
fun QuadOverlay(
|
||||
@@ -35,7 +39,15 @@ fun QuadOverlay(
|
||||
val quadColor = MaterialTheme.colorScheme.primary
|
||||
val handleColor = quadColor.copy(alpha = 0.5f)
|
||||
|
||||
Canvas(modifier = modifier.fillMaxSize()) {
|
||||
Canvas(modifier = modifier
|
||||
.fillMaxSize()
|
||||
// Android limits the exclusion areas, so we cannot simply exclude the whole quad/image.
|
||||
// Instead, the corners must get excluded individually.
|
||||
.systemGestureExclusion { cornerExclusionRect(quad.topLeft, containerSize, displaySize) }
|
||||
.systemGestureExclusion { cornerExclusionRect(quad.topRight, containerSize, displaySize) }
|
||||
.systemGestureExclusion { cornerExclusionRect(quad.bottomRight, containerSize, displaySize) }
|
||||
.systemGestureExclusion { cornerExclusionRect(quad.bottomLeft, containerSize, displaySize) }
|
||||
) {
|
||||
val scaledQuad = quad.scaledTo(
|
||||
fromWidth = 1,
|
||||
fromHeight = 1,
|
||||
@@ -74,3 +86,17 @@ fun QuadOverlay(
|
||||
}
|
||||
|
||||
fun Point.toOffset() = Offset(x.toFloat(), y.toFloat())
|
||||
|
||||
private fun cornerExclusionRect(
|
||||
corner: Point,
|
||||
containerSize: IntSize,
|
||||
displaySize: IntSize
|
||||
): Rect {
|
||||
val pos = QuadCoordinateUtils.normalizedToScreen(corner, containerSize, displaySize)
|
||||
val r = QuadEditingHandler.CORNER_TOUCH_RADIUS
|
||||
return Rect(max(.0f, pos.x - r),
|
||||
max(.0f, pos.y - r),
|
||||
min(containerSize.width.toFloat(), pos.x + r),
|
||||
min(containerSize.height.toFloat(), pos.y + r)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -63,6 +64,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -107,7 +109,7 @@ fun ExportScreenWrapper(
|
||||
navigation: Navigation,
|
||||
uiState: ExportUiState,
|
||||
currentDocument: DocumentUiModel,
|
||||
pdfActions: ExportActions,
|
||||
exportActions: ExportActions,
|
||||
onCloseScan: () -> Unit,
|
||||
) {
|
||||
BackHandler { navigation.back() }
|
||||
@@ -115,11 +117,16 @@ fun ExportScreenWrapper(
|
||||
val showConfirmationDialog = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
pdfActions.prepareExportIfNeeded()
|
||||
exportActions.prepareExportIfNeeded()
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
exportActions.cancelPreparationJob()
|
||||
}
|
||||
}
|
||||
|
||||
val onFilenameChange = { newName:String ->
|
||||
pdfActions.setFilename(newName)
|
||||
exportActions.setFilename(newName)
|
||||
}
|
||||
|
||||
ExportScreen(
|
||||
@@ -129,18 +136,18 @@ fun ExportScreenWrapper(
|
||||
navigation = navigation,
|
||||
onShare = {
|
||||
if (!uiState.isSaving) {
|
||||
pdfActions.share()
|
||||
exportActions.share()
|
||||
}
|
||||
},
|
||||
onSave = {
|
||||
if (!uiState.isSaving) {
|
||||
pdfActions.save()
|
||||
exportActions.save()
|
||||
}
|
||||
},
|
||||
onOpen = pdfActions.open,
|
||||
onOpen = exportActions.open,
|
||||
onCloseScan = {
|
||||
if (!uiState.isSaving) {
|
||||
if (uiState.hasSavedOrShared || uiState.isResumedScan)
|
||||
if (uiState.hasSavedOrShared)
|
||||
onCloseScan()
|
||||
else
|
||||
showConfirmationDialog.value = true
|
||||
@@ -149,7 +156,7 @@ fun ExportScreenWrapper(
|
||||
)
|
||||
|
||||
if (showConfirmationDialog.value) {
|
||||
NewDocumentDialog(onCloseScan, showConfirmationDialog, stringResource(R.string.scan_button))
|
||||
NewDocumentDialog(onCloseScan, showConfirmationDialog, stringResource(R.string.scan_new))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,25 +287,35 @@ private fun PdfInfos(
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
val pageCount = result?.pageCount ?: uiState.progress?.totalPages
|
||||
pageCount?.let { count -> Text(text = pageCountText(count)) }
|
||||
|
||||
uiState.ocrActivation?.let { activated ->
|
||||
Text(
|
||||
if (activated)
|
||||
stringResource(R.string.text_recognition_enabled)
|
||||
else
|
||||
stringResource(R.string.text_recognition_disabled),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.isGenerating) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.creating_export),
|
||||
fontStyle = FontStyle.Italic
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.creating_export),
|
||||
fontStyle = FontStyle.Italic,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
uiState.progress?.let{ p ->
|
||||
if (p.totalPages == 1) {
|
||||
LinearProgressIndicator()
|
||||
} else {
|
||||
LinearProgressIndicator({ p.progress })
|
||||
}
|
||||
}
|
||||
} else if (result != null) {
|
||||
val context = LocalContext.current
|
||||
val formattedFileSize = formatFileSize(result.sizeInBytes, context)
|
||||
Text(text = pageCountText(result.pageCount))
|
||||
val sizeMessageKey =
|
||||
if (result.files.size == 1) R.string.file_size else R.string.file_size_total
|
||||
Text(
|
||||
@@ -396,7 +413,7 @@ private fun MainActions(
|
||||
}
|
||||
ExportButton(
|
||||
icon = Icons.Default.Done,
|
||||
text = stringResource(R.string.scan_button),
|
||||
text = stringResource(R.string.scan_new),
|
||||
onClick = onCloseScan,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isPrimary = uiState.hasSavedOrShared,
|
||||
@@ -650,7 +667,11 @@ fun formatFileSize(sizeInBytes: Long?, context: Context): String {
|
||||
@Composable
|
||||
fun PreviewExportScreenDuringGeneration() {
|
||||
ExportPreviewToCustomize(
|
||||
uiState = ExportUiState(isGenerating = true, filename = "Scan 2025-12-15 07:00:51")
|
||||
uiState = ExportUiState(
|
||||
isGenerating = true,
|
||||
progress = ExportProgress(1, 3),
|
||||
ocrActivation = true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -661,6 +682,7 @@ fun PreviewExportScreenAfterGeneration() {
|
||||
ExportPreviewToCustomize(
|
||||
uiState = ExportUiState(
|
||||
result = ExportResult.Pdf(file, 442897L, 3),
|
||||
ocrActivation = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,16 +21,24 @@ data class ExportUiState(
|
||||
val format: ExportFormat = ExportFormat.PDF,
|
||||
val filename: String = "",
|
||||
val isGenerating: Boolean = false,
|
||||
val progress: ExportProgress? = null,
|
||||
val ocrActivation: Boolean? = null,
|
||||
val isSaving: Boolean = false,
|
||||
val result: ExportResult? = null,
|
||||
val savedBundle: SavedBundle? = null,
|
||||
val hasShared: Boolean = false,
|
||||
val error: ExportError? = null,
|
||||
val isResumedScan: Boolean = false,
|
||||
) {
|
||||
val hasSavedOrShared get() = savedBundle != null || hasShared
|
||||
}
|
||||
|
||||
data class ExportProgress(
|
||||
val completedPages: Int,
|
||||
val totalPages: Int,
|
||||
) {
|
||||
val progress = completedPages.toFloat() / totalPages
|
||||
}
|
||||
|
||||
data class SavedItem(
|
||||
val uri: Uri,
|
||||
val fileName: String,
|
||||
|
||||
@@ -48,6 +48,7 @@ import org.fairscan.app.domain.ExportQuality
|
||||
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 java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
@@ -68,32 +69,34 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
|
||||
private val preparationDir = container.preparationDir
|
||||
private val fileManager = container.fileManager
|
||||
private val settingsRepository = container.settingsRepository
|
||||
private val ocrService = container.ocrService
|
||||
private val logger = container.logger
|
||||
|
||||
private val _events = MutableSharedFlow<ExportEvent>()
|
||||
val events = _events.asSharedFlow()
|
||||
|
||||
private suspend fun generatePdf(
|
||||
exportQuality: ExportQuality
|
||||
exportQuality: ExportQuality,
|
||||
disableOcr: Boolean,
|
||||
onProgress: (Int) -> Unit,
|
||||
): ExportResult.Pdf = withContext(Dispatchers.IO) {
|
||||
val pageToExports = pagesToExport(imageRepository, exportQuality)
|
||||
val pdf = fileManager.generatePdf(pageToExports)
|
||||
val pdf = fileManager.generatePdf(pageToExports, disableOcr, onProgress)
|
||||
return@withContext ExportResult.Pdf(pdf.file, pdf.sizeInBytes, pdf.pageCount)
|
||||
}
|
||||
|
||||
suspend fun generatePdfForExternalCall(): ExportResult.Pdf {
|
||||
return generatePdf(ExportQuality.BALANCED)
|
||||
val pdf = generatePdf(ExportQuality.BALANCED, true) {}
|
||||
val sourceFile = pdf.file
|
||||
val targetFile = File(sourceFile.parentFile, defaultFilename() + ".pdf")
|
||||
if (sourceFile.absolutePath == targetFile.absolutePath) return pdf
|
||||
if (targetFile.exists() || !sourceFile.renameTo(targetFile)) return pdf
|
||||
return pdf.copy(file = targetFile)
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow(ExportUiState())
|
||||
val uiState: StateFlow<ExportUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var resumedScanKeys: List<PageViewKey> = emptyList()
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
resumedScanKeys = currentPageKeys()
|
||||
}
|
||||
}
|
||||
private var lastPreparationKey: ExportPreparationKey? = null
|
||||
private var preparationJob: Job? = null
|
||||
|
||||
@@ -126,41 +129,60 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
|
||||
private suspend fun currentPageKeys(): ImmutableList<PageViewKey> =
|
||||
imageRepository.pages().map { it.key() }.toImmutableList()
|
||||
|
||||
fun cancelPreparationJob() {
|
||||
preparationJob?.let {
|
||||
// keep result if job is not active
|
||||
if (it.isActive) {
|
||||
_uiState.update { ExportUiState() }
|
||||
}
|
||||
it.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareExportIfNeeded() {
|
||||
ensureValidFilename()
|
||||
|
||||
viewModelScope.launch {
|
||||
val exportQuality = settingsRepository.exportQuality.first()
|
||||
val exportFormat = settingsRepository.exportFormat.first()
|
||||
val ocrLanguageString = ocrService.languageString()
|
||||
|
||||
val currentPageKeys = currentPageKeys()
|
||||
val key = ExportPreparationKey(currentPageKeys, exportFormat, exportQuality)
|
||||
if (key == lastPreparationKey) {
|
||||
val key = ExportPreparationKey(
|
||||
currentPageKeys, exportFormat, exportQuality, ocrLanguageString)
|
||||
if (key == lastPreparationKey && _uiState.value.result != null) {
|
||||
return@launch
|
||||
}
|
||||
val pageCount = currentPageKeys.size
|
||||
|
||||
lastPreparationKey = key
|
||||
preparationJob?.cancel()
|
||||
|
||||
preparationJob = launch {
|
||||
val ocrActivation = if (exportFormat == PDF) ocrLanguageString.isNotEmpty() else null
|
||||
_uiState.update {
|
||||
ExportUiState(
|
||||
filename = it.filename,
|
||||
format = exportFormat,
|
||||
isGenerating = true,
|
||||
isResumedScan = resumedScanKeys == currentPageKeys
|
||||
progress = ExportProgress(0, pageCount),
|
||||
ocrActivation = ocrActivation,
|
||||
)
|
||||
}
|
||||
val onProgress: (Int) -> Unit = { completedPages ->
|
||||
_uiState.update {
|
||||
it.copy(progress = ExportProgress(completedPages, pageCount))
|
||||
}
|
||||
}
|
||||
try {
|
||||
val t1 = System.currentTimeMillis()
|
||||
val result = if (exportFormat == ExportFormat.JPEG) {
|
||||
generateJpegs(exportQuality)
|
||||
generateJpegs(exportQuality, onProgress)
|
||||
} else {
|
||||
generatePdf(exportQuality)
|
||||
generatePdf(exportQuality, false, onProgress)
|
||||
}
|
||||
_uiState.update { it.copy(result = result) }
|
||||
val t2 = System.currentTimeMillis()
|
||||
val pageCount = result.pageCount
|
||||
Log.i("Export", "Generation: $pageCount pages, $exportQuality, ${t2 - t1} ms")
|
||||
} catch (e: CancellationException) {
|
||||
// Preparation cancelled: do nothing
|
||||
@@ -179,7 +201,8 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
|
||||
}
|
||||
|
||||
private suspend fun generateJpegs(
|
||||
exportQuality: ExportQuality
|
||||
exportQuality: ExportQuality,
|
||||
onProgress: (Int) -> Unit,
|
||||
): ExportResult.Jpeg = withContext(Dispatchers.IO) {
|
||||
val pageToExports = pagesToExport(imageRepository, exportQuality)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
@@ -187,6 +210,7 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
|
||||
val files = pageToExports.mapIndexed { index, page ->
|
||||
val file = File(preparationDir, "$timestamp-${index + 1}.jpg")
|
||||
file.writeBytes(page.jpeg.get().bytes)
|
||||
onProgress(index + 1)
|
||||
file
|
||||
}.toList()
|
||||
val sizeInBytes = files.sumOf { it.length() }
|
||||
@@ -404,7 +428,8 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
|
||||
data class ExportPreparationKey(
|
||||
val pages: ImmutableList<PageViewKey>,
|
||||
val format: ExportFormat,
|
||||
val quality: ExportQuality
|
||||
val quality: ExportQuality,
|
||||
val ocrLanguageString: String,
|
||||
)
|
||||
|
||||
sealed class ExportResult {
|
||||
@@ -419,7 +444,7 @@ sealed class ExportResult {
|
||||
override val pageCount: Int,
|
||||
) : ExportResult() {
|
||||
override val files get() = listOf(file)
|
||||
override val format: ExportFormat = ExportFormat.PDF
|
||||
override val format: ExportFormat = PDF
|
||||
}
|
||||
|
||||
data class Jpeg(
|
||||
@@ -438,6 +463,7 @@ data class ExportActions(
|
||||
val share: () -> Unit,
|
||||
val save: () -> Unit,
|
||||
val open: (SavedItem) -> Unit,
|
||||
val cancelPreparationJob: () -> Unit,
|
||||
)
|
||||
|
||||
class MissingExportDirPermissionException(
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.ui.screens.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.data.OcrLanguage
|
||||
import org.fairscan.app.ui.components.BackButton
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
enum class LanguageState {
|
||||
ACTIVE,
|
||||
INACTIVE,
|
||||
NOT_INSTALLED,
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OcrLanguagesScreen(
|
||||
uiState: SettingsUiState,
|
||||
onBack: () -> Unit,
|
||||
onLanguageClick: (String) -> Unit,
|
||||
onRemoveLanguage: (String) -> Unit,
|
||||
onCancelOcrDownload: () -> Unit,
|
||||
) {
|
||||
BackHandler { onBack() }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.settings_ocr_languages)) },
|
||||
navigationIcon = { BackButton(onBack) },
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
OcrLanguagesContent(
|
||||
uiState = uiState,
|
||||
onLanguageClick = onLanguageClick,
|
||||
onCancelOcrDownload = onCancelOcrDownload,
|
||||
onRemoveLanguage = onRemoveLanguage,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OcrLanguagesContent(
|
||||
uiState: SettingsUiState,
|
||||
onLanguageClick: (String) -> Unit,
|
||||
onRemoveLanguage: (String) -> Unit,
|
||||
onCancelOcrDownload: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val locale = Locale.current.platformLocale
|
||||
|
||||
val installed = remember(
|
||||
uiState.installedOcrLanguages,
|
||||
uiState.enabledOcrLanguages,
|
||||
locale,
|
||||
) {
|
||||
uiState.installedOcrLanguages
|
||||
.map { OcrLanguage(it) }
|
||||
.sortedWith(compareBy { it.displayName(locale) })
|
||||
}
|
||||
|
||||
val available = remember(
|
||||
uiState.installedOcrLanguages,
|
||||
locale,
|
||||
) {
|
||||
OcrLanguage.AVAILABLE_LANGUAGE_CODES
|
||||
.filterNot { it in uiState.installedOcrLanguages }
|
||||
.map { OcrLanguage(it) }
|
||||
.sortedBy { it.displayName(locale) }
|
||||
}
|
||||
|
||||
val suggested = available.firstOrNull {
|
||||
it.locale.displayLanguage == locale.displayLanguage
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_ocr_download_intro),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (installed.isNotEmpty()) {
|
||||
item {
|
||||
SectionTitle(
|
||||
stringResource(R.string.settings_ocr_languages_installed)
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
items = installed,
|
||||
key = { it.code }
|
||||
) { lang ->
|
||||
LanguageItem(
|
||||
language = lang,
|
||||
state = if (lang.code in uiState.enabledOcrLanguages) {
|
||||
LanguageState.ACTIVE
|
||||
} else {
|
||||
LanguageState.INACTIVE
|
||||
},
|
||||
onClick = { onLanguageClick(lang.code) },
|
||||
onRemove = { onRemoveLanguage(lang.code) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (suggested != null) {
|
||||
item {
|
||||
SectionTitle(
|
||||
stringResource(R.string.settings_ocr_suggested)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
LanguageItem(
|
||||
language = suggested,
|
||||
state = LanguageState.NOT_INSTALLED,
|
||||
onClick = {
|
||||
onLanguageClick(suggested.code)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SectionTitle(
|
||||
stringResource(R.string.settings_ocr_languages_available)
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
available.filter { it != suggested },
|
||||
key = { it.code }
|
||||
) { lang ->
|
||||
LanguageItem(
|
||||
language = lang,
|
||||
state = LanguageState.NOT_INSTALLED,
|
||||
onClick = {
|
||||
onLanguageClick(lang.code)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
uiState.currentDownload?.let { download ->
|
||||
OcrDownloadDialog(
|
||||
state = download,
|
||||
onCancel = onCancelOcrDownload,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LanguageItem(
|
||||
language: OcrLanguage,
|
||||
state: LanguageState,
|
||||
onClick: () -> Unit,
|
||||
onRemove: (() -> Unit)? = null,
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
val locale = Locale.current.platformLocale
|
||||
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(language.displayName(locale))
|
||||
},
|
||||
leadingContent = {
|
||||
when (state) {
|
||||
LanguageState.ACTIVE ->
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null
|
||||
)
|
||||
|
||||
LanguageState.INACTIVE ->
|
||||
Icon(
|
||||
Icons.Default.RadioButtonUnchecked,
|
||||
contentDescription = null
|
||||
)
|
||||
|
||||
LanguageState.NOT_INSTALLED ->
|
||||
Icon(
|
||||
Icons.Default.Download,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
if (onRemove != null) {
|
||||
Box {
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = null)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.delete)) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onRemove()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = onClick)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(
|
||||
text: String,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = 16.dp,
|
||||
vertical = 16.dp,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OcrDownloadDialog(
|
||||
state: OcrDownloadUiState,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {}, // tapping outside the dialog should not cancel
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.settings_ocr_downloading),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
},
|
||||
text = {
|
||||
if (state.failed) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_ocr_download_error),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
} else {
|
||||
Column {
|
||||
Text(state.language.displayName(Locale.current.platformLocale))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
val progress =
|
||||
state.totalBytes?.let { total ->
|
||||
state.downloadedBytes.toFloat() / total
|
||||
}
|
||||
|
||||
if (progress != null) {
|
||||
LinearProgressIndicator(progress = { progress }, Modifier.fillMaxWidth())
|
||||
} else {
|
||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
buildProgressText(
|
||||
state.downloadedBytes,
|
||||
state.totalBytes,
|
||||
LocalContext.current
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onCancel) {
|
||||
Text(stringResource(if (state.failed) R.string.close else R.string.cancel))
|
||||
}
|
||||
},
|
||||
confirmButton = {}
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildProgressText(
|
||||
downloadedBytes: Long,
|
||||
totalBytes: Long?,
|
||||
context: Context,
|
||||
): String {
|
||||
return if (totalBytes != null) {
|
||||
listOf(
|
||||
Formatter.formatShortFileSize(context, downloadedBytes),
|
||||
Formatter.formatShortFileSize(context, totalBytes),
|
||||
).joinToString(" / ")
|
||||
} else {
|
||||
Formatter.formatShortFileSize(context, downloadedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OcrLanguagesScreenPreviewBasic() {
|
||||
OcrLanguagesScreenPreview(
|
||||
SettingsUiState(
|
||||
installedOcrLanguages = setOf("fra", "deu"),
|
||||
enabledOcrLanguages = setOf("fra")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OcrLanguagesScreenPreviewWithDownloadDialog() {
|
||||
OcrLanguagesScreenPreview(
|
||||
SettingsUiState(currentDownload =
|
||||
OcrDownloadUiState(OcrLanguage("eng"), 500_000, 1_200_000)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OcrLanguagesScreenPreviewWithDownloadError() {
|
||||
OcrLanguagesScreenPreview(
|
||||
SettingsUiState(currentDownload =
|
||||
OcrDownloadUiState(OcrLanguage("eng"), failed = true)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OcrLanguagesScreenPreview(uiState: SettingsUiState) {
|
||||
FairScanTheme {
|
||||
OcrLanguagesScreen(uiState, {}, {}, {}, {})
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,10 @@ package org.fairscan.app.ui.screens.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -26,9 +27,10 @@ import org.fairscan.app.R
|
||||
import org.fairscan.app.domain.ExportQuality
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(name = "fairscan_settings")
|
||||
|
||||
class SettingsRepository(private val context: Context) {
|
||||
class SettingsRepository(
|
||||
private val context: Context,
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
private val DEFAULT_COLOR_MODE = stringPreferencesKey("default_color_mode")
|
||||
private val EXPORT_DIR_URI = stringPreferencesKey("export_dir_uri")
|
||||
@@ -36,7 +38,7 @@ class SettingsRepository(private val context: Context) {
|
||||
private val EXPORT_QUALITY = stringPreferencesKey("export_quality")
|
||||
|
||||
val defaultColorMode: Flow<DefaultColorMode> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
dataStore.data.map { prefs ->
|
||||
when (prefs[DEFAULT_COLOR_MODE]) {
|
||||
"AUTO" -> DefaultColorMode.AUTO
|
||||
"COLOR" -> DefaultColorMode.COLOR
|
||||
@@ -46,7 +48,7 @@ class SettingsRepository(private val context: Context) {
|
||||
}
|
||||
|
||||
val exportDirUri: Flow<String?> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
dataStore.data.map { prefs ->
|
||||
prefs[EXPORT_DIR_URI]
|
||||
}
|
||||
|
||||
@@ -55,7 +57,7 @@ class SettingsRepository(private val context: Context) {
|
||||
}
|
||||
|
||||
val exportFormat: Flow<ExportFormat> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
dataStore.data.map { prefs ->
|
||||
when (prefs[EXPORT_FORMAT]) {
|
||||
"JPEG" -> ExportFormat.JPEG
|
||||
"PDF", null -> ExportFormat.PDF
|
||||
@@ -64,7 +66,7 @@ class SettingsRepository(private val context: Context) {
|
||||
}
|
||||
|
||||
val exportQuality: Flow<ExportQuality> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
dataStore.data.map { prefs ->
|
||||
when (prefs[EXPORT_QUALITY]) {
|
||||
"LOW" -> ExportQuality.LOW
|
||||
"HIGH" -> ExportQuality.HIGH
|
||||
@@ -74,13 +76,13 @@ class SettingsRepository(private val context: Context) {
|
||||
}
|
||||
|
||||
suspend fun setDefaultColorMode(mode: DefaultColorMode) {
|
||||
context.dataStore.edit { prefs ->
|
||||
dataStore.edit { prefs ->
|
||||
prefs[DEFAULT_COLOR_MODE] = mode.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setExportDirUri(uri: String?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
dataStore.edit { prefs ->
|
||||
if (uri == null) {
|
||||
prefs.remove(EXPORT_DIR_URI)
|
||||
} else {
|
||||
@@ -90,13 +92,13 @@ class SettingsRepository(private val context: Context) {
|
||||
}
|
||||
|
||||
suspend fun setExportFormat(format: ExportFormat) {
|
||||
context.dataStore.edit { prefs ->
|
||||
dataStore.edit { prefs ->
|
||||
prefs[EXPORT_FORMAT] = format.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setExportQuality(quality: ExportQuality) {
|
||||
context.dataStore.edit { prefs ->
|
||||
dataStore.edit { prefs ->
|
||||
prefs[EXPORT_QUALITY] = quality.name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,28 +28,40 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.data.OcrLanguage
|
||||
import org.fairscan.app.domain.ExportQuality
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.components.BackButton
|
||||
import org.fairscan.app.ui.dummyNavigation
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -61,14 +73,14 @@ fun SettingsScreen(
|
||||
onResetExportDirClick: () -> Unit,
|
||||
onExportFormatChanged: (ExportFormat) -> Unit,
|
||||
onExportQualityChanged: (ExportQuality) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
navigation: Navigation,
|
||||
) {
|
||||
BackHandler { onBack() }
|
||||
BackHandler { navigation.back() }
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.settings)) },
|
||||
navigationIcon = { BackButton(onBack) },
|
||||
navigationIcon = { BackButton(navigation.back) },
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
@@ -79,10 +91,13 @@ fun SettingsScreen(
|
||||
onResetExportDirClick,
|
||||
onExportFormatChanged,
|
||||
onExportQualityChanged,
|
||||
modifier = Modifier.padding(paddingValues))
|
||||
navigation,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SettingsContent(
|
||||
uiState: SettingsUiState,
|
||||
@@ -91,15 +106,18 @@ private fun SettingsContent(
|
||||
onResetExportDirClick: () -> Unit,
|
||||
onExportFormatChanged: (ExportFormat) -> Unit,
|
||||
onExportQualityChanged: (ExportQuality) -> Unit,
|
||||
navigation: Navigation,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val displayLocale = Locale.current.platformLocale
|
||||
val export = uiState.export
|
||||
val (folderLabel, folderLabelColor) = when {
|
||||
uiState.exportDirUri == null ->
|
||||
export.dirUri == null ->
|
||||
stringResource(R.string.download_dirname) to
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
|
||||
uiState.exportDirName != null ->
|
||||
uiState.exportDirName to
|
||||
export.dirName != null ->
|
||||
export.dirName to
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
|
||||
else ->
|
||||
@@ -116,14 +134,13 @@ private fun SettingsContent(
|
||||
val context = LocalResources.current
|
||||
|
||||
Text(stringResource(R.string.settings_section_scan), style = MaterialTheme.typography.titleLarge)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
RadioButtonGroup(
|
||||
R.string.color_mode_default,
|
||||
DefaultColorMode.entries,
|
||||
onClick = onDefaultColorModeChanged,
|
||||
SingleChoiceSetting(
|
||||
title = stringResource(R.string.color_mode_default),
|
||||
entries = DefaultColorMode.entries,
|
||||
onValueChanged = onDefaultColorModeChanged,
|
||||
label = { t -> context.getString(t.labelResource) },
|
||||
selectedValue = uiState.defaultColorMode
|
||||
selectedValue = uiState.defaultColorMode,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@@ -140,63 +157,130 @@ private fun SettingsContent(
|
||||
onClick = onChooseDirectoryClick,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
if (uiState.exportDirUri != null) {
|
||||
OutlinedButton(
|
||||
if (export.dirUri != null) {
|
||||
TextButton(
|
||||
onClick = onResetExportDirClick,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary),
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.reset_to_default))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
RadioButtonGroup(
|
||||
R.string.export_quality,
|
||||
ExportQuality.entries.reversed(),
|
||||
onClick = onExportQualityChanged,
|
||||
SingleChoiceSetting(
|
||||
title = stringResource(R.string.export_quality),
|
||||
entries = ExportQuality.entries.reversed(),
|
||||
selectedValue = export.quality,
|
||||
onValueChanged = onExportQualityChanged,
|
||||
label = { t -> context.getString(t.labelResource) },
|
||||
selectedValue = uiState.exportQuality
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
SingleChoiceSetting(
|
||||
title = stringResource(R.string.export_format),
|
||||
entries = ExportFormat.entries,
|
||||
selectedValue = export.format,
|
||||
onValueChanged = onExportFormatChanged,
|
||||
label = { it.name },
|
||||
)
|
||||
|
||||
RadioButtonGroup(
|
||||
R.string.export_format,
|
||||
ExportFormat.entries,
|
||||
onClick = onExportFormatChanged,
|
||||
label = { t -> t.name},
|
||||
selectedValue = uiState.exportFormat
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
stringResource(R.string.settings_section_ocr),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_ocr_languages)) },
|
||||
supportingContent = {
|
||||
Text(uiState.enabledOcrLanguages
|
||||
.map { OcrLanguage(it).displayName(displayLocale) }
|
||||
.sorted()
|
||||
.joinToString(" • ")
|
||||
.ifEmpty { stringResource(R.string.settings_ocr_languages_disabled) }
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { navigation.toOcrLanguagesScreen() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T: Enum<T>> RadioButtonGroup(
|
||||
title: Int,
|
||||
fun <T> SingleChoiceSetting(
|
||||
title: String,
|
||||
entries: List<T>,
|
||||
onClick: (T) -> Unit,
|
||||
label: (T) -> String,
|
||||
selectedValue: T,
|
||||
onValueChanged: (T) -> Unit,
|
||||
label: (T) -> String,
|
||||
) {
|
||||
Text(stringResource(title), style = MaterialTheme.typography.titleMedium)
|
||||
entries.forEach { t ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick(t) }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedValue == t,
|
||||
onClick = null,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp)
|
||||
var showDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(title)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(label(selectedValue))
|
||||
},
|
||||
trailingContent = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null
|
||||
)
|
||||
Text(label(t))
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
showDialog = true
|
||||
}
|
||||
)
|
||||
|
||||
if (showDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showDialog = false
|
||||
},
|
||||
title = {
|
||||
Text(title)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
entries.forEach { entry ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onValueChanged(entry)
|
||||
showDialog = false
|
||||
}
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedValue == entry,
|
||||
onClick = {
|
||||
onValueChanged(entry)
|
||||
showDialog = false
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
text = label(entry),
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,10 +292,12 @@ fun DirectorySettingItem(
|
||||
onClick: () -> Unit,
|
||||
|
||||
) {
|
||||
Column {
|
||||
Column (
|
||||
modifier = Modifier.padding(vertical = 0.dp, horizontal = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -220,7 +306,9 @@ fun DirectorySettingItem(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
|
||||
border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -248,13 +336,18 @@ fun DirectorySettingItem(
|
||||
@Preview(heightDp = 780)
|
||||
@Composable
|
||||
fun SettingsScreenPreviewWithoutDir() {
|
||||
SettingsScreenPreview(SettingsUiState())
|
||||
SettingsScreenPreview(SettingsUiState(
|
||||
installedOcrLanguages = setOf("fra", "eng", "deu"),
|
||||
enabledOcrLanguages = setOf("fra", "eng")
|
||||
))
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SettingsScreenPreviewWithDir() {
|
||||
SettingsScreenPreview(SettingsUiState(exportDirUri = "content://root/dir"))
|
||||
SettingsScreenPreview(
|
||||
SettingsUiState(export= ExportSettingsUiState(dirUri = "content://root/dir"))
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -267,7 +360,7 @@ fun SettingsScreenPreview(uiState: SettingsUiState) {
|
||||
onResetExportDirClick = {},
|
||||
onExportFormatChanged = {},
|
||||
onExportQualityChanged = {},
|
||||
onBack = {}
|
||||
navigation = dummyNavigation(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ package org.fairscan.app.ui.screens.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -24,36 +26,67 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fairscan.app.AppContainer
|
||||
import org.fairscan.app.data.OcrLanguage
|
||||
import org.fairscan.app.domain.ExportQuality
|
||||
|
||||
data class SettingsUiState(
|
||||
val defaultColorMode: DefaultColorMode = DefaultColorMode.AUTO,
|
||||
val exportDirUri: String? = null,
|
||||
val exportDirName: String? = null,
|
||||
val exportFormat: ExportFormat = ExportFormat.PDF,
|
||||
val exportQuality: ExportQuality = ExportQuality.BALANCED,
|
||||
val export: ExportSettingsUiState = ExportSettingsUiState(),
|
||||
val installedOcrLanguages: Set<String> = emptySet(),
|
||||
val enabledOcrLanguages: Set<String> = emptySet(),
|
||||
val currentDownload: OcrDownloadUiState? = null
|
||||
)
|
||||
|
||||
data class ExportSettingsUiState(
|
||||
val dirUri: String? = null,
|
||||
val dirName: String? = null,
|
||||
val format: ExportFormat = ExportFormat.PDF,
|
||||
val quality: ExportQuality = ExportQuality.BALANCED,
|
||||
)
|
||||
|
||||
data class OcrDownloadUiState(
|
||||
val language: OcrLanguage,
|
||||
val downloadedBytes: Long = 0,
|
||||
val totalBytes: Long? = null,
|
||||
val failed: Boolean = false,
|
||||
)
|
||||
|
||||
class SettingsViewModel(container: AppContainer) : ViewModel() {
|
||||
|
||||
private val repo = container.settingsRepository
|
||||
private val ocrLanguageRepo = container.ocrLanguageRepository
|
||||
private val logger = container.logger
|
||||
|
||||
private val _installedLanguages = MutableStateFlow<Set<String>>(emptySet())
|
||||
private val _ocrDownload = MutableStateFlow<OcrDownloadUiState?>(null)
|
||||
private var downloadJob: Job? = null
|
||||
|
||||
private val _dirName = MutableStateFlow<String?>(null)
|
||||
val dirName: StateFlow<String?> = _dirName
|
||||
|
||||
private val exportSettingsState =
|
||||
combine(
|
||||
repo.exportDirUri,
|
||||
dirName,
|
||||
repo.exportFormat,
|
||||
repo.exportQuality,
|
||||
) {
|
||||
dirUri, dirName, format, quality ->
|
||||
ExportSettingsUiState(dirUri, dirName, format, quality)
|
||||
}
|
||||
val uiState = combine(
|
||||
repo.defaultColorMode,
|
||||
repo.exportDirUri,
|
||||
dirName,
|
||||
repo.exportFormat,
|
||||
repo.exportQuality,
|
||||
) { colorMode, uri, name, format, quality ->
|
||||
exportSettingsState,
|
||||
_installedLanguages,
|
||||
ocrLanguageRepo.enabledLanguages,
|
||||
_ocrDownload,
|
||||
) { colorMode, exportSettings, installed, enabled, download ->
|
||||
SettingsUiState(
|
||||
defaultColorMode = colorMode,
|
||||
exportDirUri = uri,
|
||||
exportDirName = name,
|
||||
exportFormat = format,
|
||||
exportQuality = quality,
|
||||
export = exportSettings,
|
||||
installedOcrLanguages = installed,
|
||||
enabledOcrLanguages = enabled,
|
||||
currentDownload = download,
|
||||
)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
@@ -61,6 +94,17 @@ class SettingsViewModel(container: AppContainer) : ViewModel() {
|
||||
SettingsUiState()
|
||||
)
|
||||
|
||||
private suspend fun refreshInstalledLanguages() {
|
||||
_installedLanguages.value =
|
||||
ocrLanguageRepo.getInstalledLanguages()
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
refreshInstalledLanguages()
|
||||
}
|
||||
}
|
||||
|
||||
fun setDefaultColorMode(pref: DefaultColorMode) {
|
||||
viewModelScope.launch {
|
||||
repo.setDefaultColorMode(pref)
|
||||
@@ -92,4 +136,54 @@ class SettingsViewModel(container: AppContainer) : ViewModel() {
|
||||
_dirName.value = uri?.let { repo.resolveExportDirName(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun onLanguageClick(code: String) {
|
||||
if (uiState.value.installedOcrLanguages.contains(code)) {
|
||||
setOcrLanguageEnabled(code, !uiState.value.enabledOcrLanguages.contains(code))
|
||||
} else {
|
||||
installLanguage(code)
|
||||
}
|
||||
}
|
||||
|
||||
fun installLanguage(code: String) {
|
||||
downloadJob?.cancel()
|
||||
downloadJob = viewModelScope.launch {
|
||||
_ocrDownload.value = OcrDownloadUiState(OcrLanguage(code))
|
||||
try {
|
||||
ocrLanguageRepo.downloadLanguage(code) { progress ->
|
||||
_ocrDownload.value =
|
||||
_ocrDownload.value?.copy(
|
||||
downloadedBytes = progress.downloadedBytes,
|
||||
totalBytes = progress.totalBytes,
|
||||
)
|
||||
}
|
||||
ocrLanguageRepo.setLanguageEnabled(code, true)
|
||||
refreshInstalledLanguages()
|
||||
_ocrDownload.value = null
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e("OcrDownload", "Download failed for $code", e)
|
||||
_ocrDownload.value = _ocrDownload.value?.copy(failed = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelOcrDownload() {
|
||||
downloadJob?.cancel()
|
||||
_ocrDownload.value = null
|
||||
}
|
||||
|
||||
fun setOcrLanguageEnabled(code: String, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
ocrLanguageRepo.setLanguageEnabled(code, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun onRemoveLanguage(code: String) {
|
||||
viewModelScope.launch {
|
||||
ocrLanguageRepo.deleteLanguage(code)
|
||||
refreshInstalledLanguages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<string name="apply">تطبيق</string>
|
||||
<string name="back">ارجع</string>
|
||||
<string name="camera_permission_denied">رُفض إذن الوصول إلى الكاميرا</string>
|
||||
<string name="camera_permission_rationale">يتطلب التطبيق الوصول إلى الكاميرا لمسح المستندات ضوئيًا. تُخزن الصور الملتقطة على هذا الجهاز فقط، وسيتم حذفها عند إغلاق المستند الحالي.</string>
|
||||
<string name="camera_permission_rationale">يتطلب التطبيق الوصول إلى الكاميرا لمسح المستندات ضوئيًا. تُخزن الصور الملتقطة على هذا الجهاز فقط، وسيتم حذفها عند إغلاق المسح الحالي.</string>
|
||||
<string name="cancel">ألغِ</string>
|
||||
<string name="change_directory">غيّر المجلد</string>
|
||||
<string name="clear_text">امحُ النص</string>
|
||||
<string name="close">إغلاق</string>
|
||||
<string name="color_mode">فلتر</string>
|
||||
<string name="color_mode_auto">تلقائي</string>
|
||||
<string name="color_mode_color">ألوان</string>
|
||||
@@ -19,6 +20,7 @@
|
||||
<string name="copy_logs">انسخ السجلات</string>
|
||||
<string name="creating_export">يجهز التصدير…</string>
|
||||
<string name="crop">اقتصاص</string>
|
||||
<string name="delete">حذف</string>
|
||||
<string name="delete_page">احذف الصفحة</string>
|
||||
<string name="delete_page_warning">هل تريد حذف هذه الصفحة؟</string>
|
||||
<string name="developer">مطوّر</string>
|
||||
@@ -55,8 +57,20 @@
|
||||
<string name="rotate_left">تدوير إلى اليسار</string>
|
||||
<string name="rotate_right">تدوير إلى اليمين</string>
|
||||
<string name="save">احفظ</string>
|
||||
<string name="scan_button">مسح جديد</string>
|
||||
<string name="scan_new">مسح جديد</string>
|
||||
<string name="scan_current">المسح الحالي</string>
|
||||
<string name="scan_discard_current">تجاهل المسح الحالي</string>
|
||||
<string name="scan_resume">متابعة المسح</string>
|
||||
<string name="settings">الإعدادات</string>
|
||||
<string name="settings_ocr_download_error">فشل التنزيل. تحقق من اتصال الإنترنت وتأكد من السماح لهذا التطبيق بالوصول إلى الشبكة.</string>
|
||||
<string name="settings_ocr_download_intro">يتم تنزيل ملفات اللغة من مشروع Tesseract الرسمي. تبقى عملية التعرّف على النصوص دون اتصال بالإنترنت بالكامل.</string>
|
||||
<string name="settings_ocr_downloading">جارٍ تنزيل ملف اللغة</string>
|
||||
<string name="settings_ocr_languages">اللغات</string>
|
||||
<string name="settings_ocr_languages_available">اللغات المتاحة</string>
|
||||
<string name="settings_ocr_languages_disabled">معطّل</string>
|
||||
<string name="settings_ocr_languages_installed">مثبّتة</string>
|
||||
<string name="settings_ocr_suggested">مقترح</string>
|
||||
<string name="settings_section_ocr">التعرّف على النصوص</string>
|
||||
<string name="settings_section_scan">المسح</string>
|
||||
<string name="settings_section_export">التصدير</string>
|
||||
<string name="share">شارك</string>
|
||||
@@ -64,6 +78,8 @@
|
||||
<string name="storage_permission_denied">تعذر حفظ الملف: رُفض الإذن</string>
|
||||
<string name="support">المساعدة</string>
|
||||
<string name="support_last_image">أبلِغ عن مشكلة في آخر صورة مُلتقطَة </string>
|
||||
<string name="text_recognition_disabled">التعرّف على النصوص: معطّل</string>
|
||||
<string name="text_recognition_enabled">التعرّف على النصوص: مفعّل</string>
|
||||
<string name="turn_off_torch">أطفئ الكشاف</string>
|
||||
<string name="turn_on_torch">شغّل الكشاف</string>
|
||||
<string name="unknown_size">حجم مجهول</string>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<string name="apply">Použít</string>
|
||||
<string name="back">Zpět</string>
|
||||
<string name="camera_permission_denied">Byl odepřen přístup k fotoaparátu</string>
|
||||
<string name="camera_permission_rationale">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ž zavřete stávající dokument.</string>
|
||||
<string name="camera_permission_rationale">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.</string>
|
||||
<string name="cancel">Zrušit</string>
|
||||
<string name="change_directory">Změnit složku</string>
|
||||
<string name="clear_text">Smazat text</string>
|
||||
<string name="close">Zavřít</string>
|
||||
<string name="color_mode">Filtr</string>
|
||||
<string name="color_mode_auto">Automaticky</string>
|
||||
<string name="color_mode_color">Barva</string>
|
||||
@@ -19,6 +20,7 @@
|
||||
<string name="copy_logs">Kopírovat protokoly</string>
|
||||
<string name="creating_export">Příprava exportu…</string>
|
||||
<string name="crop">Oříznout</string>
|
||||
<string name="delete">Smazat</string>
|
||||
<string name="delete_page">Smazat stránku</string>
|
||||
<string name="delete_page_warning">Chcete smazat tuto stránku?</string>
|
||||
<string name="developer">Vývojář</string>
|
||||
@@ -55,8 +57,20 @@
|
||||
<string name="rotate_left">Otočit doleva</string>
|
||||
<string name="rotate_right">Otočit doprava</string>
|
||||
<string name="save">Uložit</string>
|
||||
<string name="scan_button">Nové skenování</string>
|
||||
<string name="scan_new">Nové skenování</string>
|
||||
<string name="scan_current">Aktuální sken</string>
|
||||
<string name="scan_discard_current">Zahodit aktuální sken</string>
|
||||
<string name="scan_resume">Pokračovat ve skenování</string>
|
||||
<string name="settings">Nastavení</string>
|
||||
<string name="settings_ocr_download_error">Stahování se nezdařilo. Zkontrolujte připojení k internetu a ujistěte se, že má tato aplikace povolený přístup k síti.</string>
|
||||
<string name="settings_ocr_download_intro">Jazykové soubory jsou stahovány z oficiálního projektu Tesseract. Rozpoznávání textu zůstává zcela offline.</string>
|
||||
<string name="settings_ocr_downloading">Stahování jazykového souboru</string>
|
||||
<string name="settings_ocr_languages">Jazyky</string>
|
||||
<string name="settings_ocr_languages_available">Dostupné jazyky</string>
|
||||
<string name="settings_ocr_languages_disabled">Vypnuto</string>
|
||||
<string name="settings_ocr_languages_installed">Nainstalované</string>
|
||||
<string name="settings_ocr_suggested">Doporučeno</string>
|
||||
<string name="settings_section_ocr">Rozpoznávání textu</string>
|
||||
<string name="settings_section_scan">Skenování</string>
|
||||
<string name="settings_section_export">Export</string>
|
||||
<string name="share">Sdílet</string>
|
||||
@@ -64,6 +78,8 @@
|
||||
<string name="storage_permission_denied">Nelze uložit soubor: oprávnění bylo odmítnuto</string>
|
||||
<string name="support">Podpora</string>
|
||||
<string name="support_last_image">Nahlásit problém s posledním zachyceným obrázkem</string>
|
||||
<string name="text_recognition_disabled">Rozpoznávání textu: vypnuto</string>
|
||||
<string name="text_recognition_enabled">Rozpoznávání textu: zapnuto</string>
|
||||
<string name="turn_off_torch">Vypnout svítilnu</string>
|
||||
<string name="turn_on_torch">Zapnout svítilnu</string>
|
||||
<string name="unknown_size">Neznámá velikost</string>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<string name="apply">Anwenden</string>
|
||||
<string name="back">Zurück</string>
|
||||
<string name="camera_permission_denied">Kamerazugriff wurde verweigert</string>
|
||||
<string name="camera_permission_rationale">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 Dokuments gelöscht.</string>
|
||||
<string name="camera_permission_rationale">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.</string>
|
||||
<string name="cancel">Abbrechen</string>
|
||||
<string name="change_directory">Ordner ändern</string>
|
||||
<string name="clear_text">Text löschen</string>
|
||||
<string name="close">Schließen</string>
|
||||
<string name="color_mode">Filter</string>
|
||||
<string name="color_mode_auto">Automatisch</string>
|
||||
<string name="color_mode_color">Farbe</string>
|
||||
@@ -19,6 +20,7 @@
|
||||
<string name="copy_logs">Logs kopieren</string>
|
||||
<string name="creating_export">Export wird vorbereitet…</string>
|
||||
<string name="crop">Zuschneiden</string>
|
||||
<string name="delete">Löschen</string>
|
||||
<string name="delete_page">Seite löschen</string>
|
||||
<string name="delete_page_warning">Möchten Sie diese Seite löschen?</string>
|
||||
<string name="developer">Entwickler</string>
|
||||
@@ -55,8 +57,20 @@
|
||||
<string name="rotate_left">Nach links drehen</string>
|
||||
<string name="rotate_right">Nach rechts drehen</string>
|
||||
<string name="save">Speichern</string>
|
||||
<string name="scan_button">Neuer Scan</string>
|
||||
<string name="scan_new">Neuer Scan</string>
|
||||
<string name="scan_current">Aktueller Scan</string>
|
||||
<string name="scan_discard_current">Aktuellen Scan verwerfen</string>
|
||||
<string name="scan_resume">Scan fortsetzen</string>
|
||||
<string name="settings">Einstellungen</string>
|
||||
<string name="settings_ocr_download_error">Download fehlgeschlagen. Überprüfen Sie Ihre Internetverbindung und stellen Sie sicher, dass der Netzwerkzugriff für diese App erlaubt ist.</string>
|
||||
<string name="settings_ocr_download_intro">Sprachdateien werden vom offiziellen Tesseract-Projekt heruntergeladen. Die Texterkennung bleibt vollständig offline.</string>
|
||||
<string name="settings_ocr_downloading">Sprachdatei wird heruntergeladen</string>
|
||||
<string name="settings_ocr_languages">Sprachen</string>
|
||||
<string name="settings_ocr_languages_available">Verfügbare Sprachen</string>
|
||||
<string name="settings_ocr_languages_disabled">Deaktiviert</string>
|
||||
<string name="settings_ocr_languages_installed">Installiert</string>
|
||||
<string name="settings_ocr_suggested">Empfohlen</string>
|
||||
<string name="settings_section_ocr">Texterkennung</string>
|
||||
<string name="settings_section_scan">Scan</string>
|
||||
<string name="settings_section_export">Export</string>
|
||||
<string name="share">Teilen</string>
|
||||
@@ -64,6 +78,8 @@
|
||||
<string name="storage_permission_denied">Datei kann nicht gespeichert werden: Berechtigung verweigert</string>
|
||||
<string name="support">Support</string>
|
||||
<string name="support_last_image">Problem mit dem zuletzt aufgenommenen Bild melden</string>
|
||||
<string name="text_recognition_disabled">Texterkennung: deaktiviert</string>
|
||||
<string name="text_recognition_enabled">Texterkennung: aktiviert</string>
|
||||
<string name="turn_off_torch">Taschenlampe ausschalten</string>
|
||||
<string name="turn_on_torch">Taschenlampe einschalten</string>
|
||||
<string name="unknown_size">Unbekannte Größe</string>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<string name="apply">Aplicar</string>
|
||||
<string name="back">Atrás</string>
|
||||
<string name="camera_permission_denied">Permiso de cámara denegado</string>
|
||||
<string name="camera_permission_rationale">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 documento actual.</string>
|
||||
<string name="camera_permission_rationale">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.</string>
|
||||
<string name="cancel">Cancelar</string>
|
||||
<string name="change_directory">Cambiar carpeta</string>
|
||||
<string name="clear_text">Borrar texto</string>
|
||||
<string name="close">Cerrar</string>
|
||||
<string name="color_mode">Filtro</string>
|
||||
<string name="color_mode_auto">Automático</string>
|
||||
<string name="color_mode_color">Color</string>
|
||||
@@ -19,6 +20,7 @@
|
||||
<string name="copy_logs">Copiar registros</string>
|
||||
<string name="creating_export">Preparando la exportación…</string>
|
||||
<string name="crop">Recortar</string>
|
||||
<string name="delete">Eliminar</string>
|
||||
<string name="delete_page">Eliminar página</string>
|
||||
<string name="delete_page_warning">¿Quieres eliminar esta página?</string>
|
||||
<string name="developer">Desarrollador</string>
|
||||
@@ -55,8 +57,20 @@
|
||||
<string name="rotate_left">Girar a la izquierda</string>
|
||||
<string name="rotate_right">Girar a la derecha</string>
|
||||
<string name="save">Guardar</string>
|
||||
<string name="scan_button">Nuevo escaneo</string>
|
||||
<string name="scan_new">Nuevo escaneo</string>
|
||||
<string name="scan_current">Escaneo actual</string>
|
||||
<string name="scan_discard_current">Descartar el escaneo actual</string>
|
||||
<string name="scan_resume">Reanudar escaneo</string>
|
||||
<string name="settings">Ajustes</string>
|
||||
<string name="settings_ocr_download_error">Descarga fallida. Comprueba tu conexión a Internet y asegúrate de que esta aplicación tenga permitido el acceso a la red.</string>
|
||||
<string name="settings_ocr_download_intro">Los archivos de idioma se descargan desde el proyecto oficial Tesseract. El reconocimiento de texto sigue siendo completamente local.</string>
|
||||
<string name="settings_ocr_downloading">Descargando archivo de idioma</string>
|
||||
<string name="settings_ocr_languages">Idiomas</string>
|
||||
<string name="settings_ocr_languages_available">Idiomas disponibles</string>
|
||||
<string name="settings_ocr_languages_disabled">Desactivado</string>
|
||||
<string name="settings_ocr_languages_installed">Instalados</string>
|
||||
<string name="settings_ocr_suggested">Sugerido</string>
|
||||
<string name="settings_section_ocr">Reconocimiento de texto</string>
|
||||
<string name="settings_section_scan">Escaneo</string>
|
||||
<string name="settings_section_export">Exportación</string>
|
||||
<string name="share">Compartir</string>
|
||||
@@ -64,6 +78,8 @@
|
||||
<string name="storage_permission_denied">No se puede guardar el archivo: permiso denegado</string>
|
||||
<string name="support">Soporte</string>
|
||||
<string name="support_last_image">Informar de un problema con la última imagen capturada</string>
|
||||
<string name="text_recognition_disabled">Reconocimiento de texto: desactivado</string>
|
||||
<string name="text_recognition_enabled">Reconocimiento de texto: activado</string>
|
||||
<string name="turn_off_torch">Apagar linterna</string>
|
||||
<string name="turn_on_torch">Encender linterna</string>
|
||||
<string name="unknown_size">Tamaño desconocido</string>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="about">Rakenduse teave</string>
|
||||
<string name="add_page">Lisa leht</string>
|
||||
<string name="app_tagline">Lihtsaltkasutatav ja viisakas rakendus dokumentide skaneerimiseks.</string>
|
||||
<string name="apply">Rakenda</string>
|
||||
<string name="back">Tagasi</string>
|
||||
<string name="camera_permission_denied">Kaamera kasutamise õigused on puudu</string>
|
||||
<string name="camera_permission_rationale">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.</string>
|
||||
<string name="cancel">Katkesta</string>
|
||||
<string name="change_directory">Muuda kausta</string>
|
||||
<string name="clear_text">Eemalda tekst</string>
|
||||
<string name="close">Sulge</string>
|
||||
<string name="color_mode">Filtreeri</string>
|
||||
<string name="color_mode_auto">Automaatne</string>
|
||||
<string name="color_mode_color">Värvid</string>
|
||||
<string name="color_mode_default">Vaikimisi filter</string>
|
||||
<string name="color_mode_grayscale">Halltoonides</string>
|
||||
<string name="contact">Kontakt</string>
|
||||
<string name="copied_logs">Logid on kopeeritud lõikelauale</string>
|
||||
<string name="copy_logs">Kopeeri logid</string>
|
||||
<string name="creating_export">Valmistun eksportima…</string>
|
||||
<string name="crop">Kadreeri</string>
|
||||
<string name="delete">Kustuta</string>
|
||||
<string name="delete_page">Lehe kustutamine</string>
|
||||
<string name="delete_page_warning">Kas kindlasti soovid selle lehe kustutada?</string>
|
||||
<string name="developer">Arendaja</string>
|
||||
<string name="download_dirname">Allalaadimised</string>
|
||||
<string name="error_context_folder">Ekspordikaust: %1$s</string>
|
||||
<string name="error_context_provider">Teenusepakkuja: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Valitud ekspordikaust pole enam kättesaadav. Palun vali mõnu muu kaust.</string>
|
||||
<string name="error_no_app">Salle faili avamiseks ei leidu rakendust</string>
|
||||
<string name="error_occurred">Tekkis viga</string>
|
||||
<string name="error_save">Faili salvestamine ei õnnestunud</string>
|
||||
<string name="export">Ekspordi</string>
|
||||
<string name="export_as">Eksportimise vorming: %1$s</string>
|
||||
<string name="export_directory">Ekspordikaust</string>
|
||||
<string name="export_folder_permission_lost">Ligipääs kaustale on kadunud</string>
|
||||
<string name="export_format">Ekspordivorming</string>
|
||||
<string name="export_quality">Ekspordi kvaliteet</string>
|
||||
<string name="export_quality_low">Madal</string>
|
||||
<string name="export_quality_balanced">Tasakaalustatud</string>
|
||||
<string name="export_quality_high">Kõrge</string>
|
||||
<string name="file_size">Faili suurus: %1$s</string>
|
||||
<string name="file_size_total">Maht kokku: %1$s</string>
|
||||
<string name="filename">Failinimi</string>
|
||||
<string name="grant_permission">Anna vastavad õigused</string>
|
||||
<string name="import_photos">Impordi</string>
|
||||
<string name="libraries">Teegid</string>
|
||||
<string name="libraries_open_source">Avatud lähtekoodil põhinevad teegid</string>
|
||||
<string name="license">Litsents</string>
|
||||
<string name="menu">Menüü</string>
|
||||
<string name="new_document_warning">Hetkel skaneeritud sisu läheb kaotsi. Kas kindlasti tahad jätkata?</string>
|
||||
<string name="open">Ava</string>
|
||||
<string name="open_file">Ava fail</string>
|
||||
<string name="reset_to_default">Taasta algväärtused</string>
|
||||
<string name="rotate_left">Pööra vasakule</string>
|
||||
<string name="rotate_right">Pööra paremale</string>
|
||||
<string name="save">Salvesta</string>
|
||||
<string name="scan_new">Uus skaneerimine</string>
|
||||
<string name="scan_current">Praegune skaneerimine</string>
|
||||
<string name="scan_discard_current">Loobu hetkel skaneeritust</string>
|
||||
<string name="licensed_under">Käesolev rakendus on litsentseeritud GNU Üldise Avaliku Litsentsi versiooni 3.0 alusel.</string>
|
||||
<string name="scan_resume">Jätka skaneerimisega</string>
|
||||
<string name="settings">Seadistused</string>
|
||||
<string name="settings_ocr_download_error">Allalaadimine ei õnnestunud. Palun vaata, et sinu nutiseadme internetiühendus toimiks ja ligipääs võrgule on selle rakenduse jaoks lubatud.</string>
|
||||
<string name="settings_ocr_download_intro">Keelefailid on laaditud alla Tesseracti projekti ametlikust veebisaidist. Tekstituvastus toimib täies mahus ilma võrguühendust kasutamata.</string>
|
||||
<string name="settings_ocr_downloading">Keelefaili allalaadimine</string>
|
||||
<string name="settings_ocr_languages">Keeled</string>
|
||||
<string name="settings_ocr_languages_available">Saadaval keeled</string>
|
||||
<string name="settings_ocr_languages_disabled">Pole kasutusel</string>
|
||||
<string name="settings_ocr_languages_installed">Paigaldatud</string>
|
||||
<string name="settings_ocr_suggested">Meie soovitus</string>
|
||||
<string name="settings_section_ocr">Tekstituvastus</string>
|
||||
<string name="settings_section_scan">Skaneerimine</string>
|
||||
<string name="settings_section_export">Eksport</string>
|
||||
<string name="share">Jaga</string>
|
||||
<string name="share_document">Jaga dokumenti</string>
|
||||
<string name="storage_permission_denied">Faili salvestamine ei õnnestu: õigused on puudu</string>
|
||||
<string name="support">Kasutajatugi</string>
|
||||
<string name="support_last_image">Teavita viimase pildihõivega seotud veast või probleemist</string>
|
||||
<string name="text_recognition_disabled">Tekstituvastus: pole kasutusel</string>
|
||||
<string name="text_recognition_enabled">Tekstituvastus: on kasutusel</string>
|
||||
<string name="turn_off_torch">Lülita taskulamp välja</string>
|
||||
<string name="turn_on_torch">Lülita taskulamp sisse</string>
|
||||
<string name="unknown_size">Tundmatu suurus</string>
|
||||
<string name="version">Versioon</string>
|
||||
<string name="view_full_list">Vaata täismahus loendit</string>
|
||||
<string name="view_the_full_license">Vaata täismahus litsentsi</string>
|
||||
<string name="yes">Jah</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="one">%2$s fail on salvestatud kausta %3$s</item>
|
||||
<item quantity="other">%1$d faili on salvestatud kausta %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">Importimisel on 1 foto</item>
|
||||
<item quantity="other">Importimisel on %d fotot</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d leht</item>
|
||||
<item quantity="other">%d lehte</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -1,3 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="about">À propos</string>
|
||||
<string name="add_page">Ajouter une page</string>
|
||||
@@ -5,10 +6,11 @@
|
||||
<string name="apply">Appliquer</string>
|
||||
<string name="back">Retour</string>
|
||||
<string name="camera_permission_denied">L\'autorisation d\'accès à la caméra a été refusée</string>
|
||||
<string name="camera_permission_rationale">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 document en cours.</string>
|
||||
<string name="camera_permission_rationale">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.</string>
|
||||
<string name="cancel">Annuler</string>
|
||||
<string name="change_directory">Changer de dossier</string>
|
||||
<string name="clear_text">Effacer le text</string>
|
||||
<string name="clear_text">Effacer le texte</string>
|
||||
<string name="close">Fermer</string>
|
||||
<string name="color_mode">Filtre</string>
|
||||
<string name="color_mode_auto">Automatique</string>
|
||||
<string name="color_mode_color">Couleur</string>
|
||||
@@ -19,6 +21,7 @@
|
||||
<string name="copy_logs">Copier les logs</string>
|
||||
<string name="creating_export">Préparation de l’export…</string>
|
||||
<string name="crop">Recadrer</string>
|
||||
<string name="delete">Supprimer</string>
|
||||
<string name="delete_page">Supprimer la page</string>
|
||||
<string name="delete_page_warning">Voulez-vous supprimer cette page ?</string>
|
||||
<string name="developer">Développeur</string>
|
||||
@@ -55,8 +58,20 @@
|
||||
<string name="rotate_left">Pivoter vers la gauche</string>
|
||||
<string name="rotate_right">Pivoter vers la droite</string>
|
||||
<string name="save">Enregistrer</string>
|
||||
<string name="scan_button">Nouveau scan</string>
|
||||
<string name="scan_new">Nouveau scan</string>
|
||||
<string name="scan_current">Scan en cours</string>
|
||||
<string name="scan_discard_current">Abandonner le scan en cours</string>
|
||||
<string name="scan_resume">Reprendre le scan</string>
|
||||
<string name="settings">Paramètres</string>
|
||||
<string name="settings_ocr_download_error">Échec du téléchargement. Vérifiez votre connexion Internet et assurez-vous que l\'accès au réseau est autorisé pour cette application.</string>
|
||||
<string name="settings_ocr_download_intro">Les fichiers de langue sont téléchargés depuis le projet officiel Tesseract. La reconnaissance de texte reste entièrement hors ligne.</string>
|
||||
<string name="settings_ocr_downloading">Téléchargement du fichier de langue</string>
|
||||
<string name="settings_ocr_languages">Langues</string>
|
||||
<string name="settings_ocr_languages_available">Langues disponibles</string>
|
||||
<string name="settings_ocr_languages_disabled">Désactivé</string>
|
||||
<string name="settings_ocr_languages_installed">Installées</string>
|
||||
<string name="settings_ocr_suggested">Suggéré</string>
|
||||
<string name="settings_section_ocr">Reconnaissance de texte</string>
|
||||
<string name="settings_section_scan">Scan</string>
|
||||
<string name="settings_section_export">Export</string>
|
||||
<string name="share">Partager</string>
|
||||
@@ -64,6 +79,8 @@
|
||||
<string name="storage_permission_denied">Impossible d’enregistrer le fichier : permission refusée</string>
|
||||
<string name="support">Support</string>
|
||||
<string name="support_last_image">Signaler un problème avec la dernière image capturée</string>
|
||||
<string name="text_recognition_disabled">Reconnaissance de texte : désactivée</string>
|
||||
<string name="text_recognition_enabled">Reconnaissance de texte : activée</string>
|
||||
<string name="turn_off_torch">Éteindre la torche</string>
|
||||
<string name="turn_on_torch">Allumer la torche</string>
|
||||
<string name="unknown_size">Taille inconnue</string>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<string name="apply">Aplicar</string>
|
||||
<string name="back">Atrás</string>
|
||||
<string name="camera_permission_denied">Permiso da cámara denegado</string>
|
||||
<string name="camera_permission_rationale">A aplicación precisa acceso á cámara para escanear documentos. As imaxes capturadas gárdanse só neste dispositivo e eliminaranse ao pechar o documento actual.</string>
|
||||
<string name="camera_permission_rationale">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.</string>
|
||||
<string name="cancel">Cancelar</string>
|
||||
<string name="change_directory">Trocar cartafol</string>
|
||||
<string name="clear_text">Borrar texto</string>
|
||||
<string name="close">Pechar</string>
|
||||
<string name="color_mode">Filtro</string>
|
||||
<string name="color_mode_auto">Automático</string>
|
||||
<string name="color_mode_color">Cor</string>
|
||||
@@ -19,6 +20,7 @@
|
||||
<string name="copy_logs">Copiar rexistros</string>
|
||||
<string name="creating_export">Preparando exportación…</string>
|
||||
<string name="crop">Recortar</string>
|
||||
<string name="delete">Eliminar</string>
|
||||
<string name="delete_page">Eliminar páxina</string>
|
||||
<string name="delete_page_warning">Queres eliminar esta páxina?</string>
|
||||
<string name="developer">Desenvolvedor</string>
|
||||
@@ -55,8 +57,20 @@
|
||||
<string name="rotate_left">Xirar á esquerda</string>
|
||||
<string name="rotate_right">Xirar á dereita</string>
|
||||
<string name="save">Gardar</string>
|
||||
<string name="scan_button">Novo escaneo</string>
|
||||
<string name="scan_new">Novo escaneo</string>
|
||||
<string name="scan_current">Escaneo actual</string>
|
||||
<string name="scan_discard_current">Descartar o escaneo actual</string>
|
||||
<string name="scan_resume">Retomar escaneo</string>
|
||||
<string name="settings">Configuración</string>
|
||||
<string name="settings_ocr_download_error">Produciuse un erro na descarga. Comproba a túa conexión á Internet e asegúrate de que esta aplicación ten permitido o acceso á rede.</string>
|
||||
<string name="settings_ocr_download_intro">Os ficheiros de idioma descárganse desde o proxecto oficial Tesseract. O recoñecemento de texto segue sendo completamente sen conexión.</string>
|
||||
<string name="settings_ocr_downloading">Descargando ficheiro de idioma</string>
|
||||
<string name="settings_ocr_languages">Idiomas</string>
|
||||
<string name="settings_ocr_languages_available">Idiomas dispoñibles</string>
|
||||
<string name="settings_ocr_languages_disabled">Desactivado</string>
|
||||
<string name="settings_ocr_languages_installed">Instalados</string>
|
||||
<string name="settings_ocr_suggested">Suxerido</string>
|
||||
<string name="settings_section_ocr">Recoñecemento de texto</string>
|
||||
<string name="settings_section_scan">Escaneo</string>
|
||||
<string name="settings_section_export">Exportación</string>
|
||||
<string name="share">Compartir</string>
|
||||
@@ -64,6 +78,8 @@
|
||||
<string name="storage_permission_denied">Non se pode gardar o ficheiro: permiso denegado</string>
|
||||
<string name="support">Soporte</string>
|
||||
<string name="support_last_image">Informar dun problema coa derradeira imaxe capturada</string>
|
||||
<string name="text_recognition_disabled">Recoñecemento de texto: desactivado</string>
|
||||
<string name="text_recognition_enabled">Recoñecemento de texto: activado</string>
|
||||
<string name="turn_off_torch">Apagar lanterna</string>
|
||||
<string name="turn_on_torch">Acender lanterna</string>
|
||||
<string name="unknown_size">Tamaño descoñecido</string>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<resources>
|
||||
<string name="about">Névjegy</string>
|
||||
<string name="add_page">Új oldal</string>
|
||||
<string name="app_tagline">Egy egyszerű, a felhasználót tiszteletben tartó dokumentum szkenner alkalmazás.</string>
|
||||
<string name="apply">Alkalmaz</string>
|
||||
<string name="back">Vissza</string>
|
||||
<string name="camera_permission_denied">Kamerahozzáférés megtagadva</string>
|
||||
<string name="camera_permission_rationale">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.</string>
|
||||
<string name="cancel">Mégse</string>
|
||||
<string name="change_directory">Mappaválasztás</string>
|
||||
<string name="clear_text">Szöveg törlése</string>
|
||||
<string name="close">Bezárás</string>
|
||||
<string name="color_mode">Színszűrő</string>
|
||||
<string name="color_mode_auto">Automatikus</string>
|
||||
<string name="color_mode_color">Színes</string>
|
||||
<string name="color_mode_default">Alapértelmezett színszűrő</string>
|
||||
<string name="color_mode_grayscale">Szürkeárnyalatos</string>
|
||||
<string name="contact">Kapcsolat</string>
|
||||
<string name="copied_logs">Napló vágólapra másolva</string>
|
||||
<string name="copy_logs">Napló másolása</string>
|
||||
<string name="creating_export">Dokumentum előkészítése…</string>
|
||||
<string name="crop">Levágás</string>
|
||||
<string name="delete">Törlés</string>
|
||||
<string name="delete_page">Oldal törlése</string>
|
||||
<string name="delete_page_warning">Törölni szeretnéd ezt az oldalt?</string>
|
||||
<string name="developer">Fejlesztő</string>
|
||||
<string name="download_dirname">Letöltések</string>
|
||||
<string name="error_context_folder">Exportálási mappa: %1$s</string>
|
||||
<string name="error_context_provider">Szolgáltató: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">A kijelölt exportálási mappa már nem elérhető. Kérlek válassz másik mappát.</string>
|
||||
<string name="error_no_app">Nem található alkalmazás a fájl megnyitásához</string>
|
||||
<string name="error_occurred">Hiba történt</string>
|
||||
<string name="error_save">Fájl mentése sikertelen</string>
|
||||
<string name="export">Exportálás</string>
|
||||
<string name="export_as">%1$s exportálása</string>
|
||||
<string name="export_directory">Exportálás helye</string>
|
||||
<string name="export_folder_permission_lost">Mappahozzáférés elveszett</string>
|
||||
<string name="export_format">Formátum</string>
|
||||
<string name="export_quality">Minőség</string>
|
||||
<string name="export_quality_low">Alacsony</string>
|
||||
<string name="export_quality_balanced">Kiegyensúlyozott</string>
|
||||
<string name="export_quality_high">Magas</string>
|
||||
<string name="file_size">Fájlméret: %1$s</string>
|
||||
<string name="file_size_total">Teljes méret: %1$s</string>
|
||||
<string name="filename">Fájlnév</string>
|
||||
<string name="grant_permission">Engedélyezés</string>
|
||||
<string name="import_photos">Importálás</string>
|
||||
<string name="libraries">Programkönyvtárak</string>
|
||||
<string name="libraries_open_source">Nyílt forráskódú programkönyvtárak</string>
|
||||
<string name="license">Licenc</string>
|
||||
<string name="licensed_under">Ez az alkalmazás a GNU General Public License v3.0 (GPLv3) licenc alatt érhető el.</string>
|
||||
<string name="menu">Menü</string>
|
||||
<string name="new_document_warning">A jelenlegi szkennelés el fog veszni. Biztosan folytatod?</string>
|
||||
<string name="open">Megnyitás</string>
|
||||
<string name="open_file">Fájl megnyitása</string>
|
||||
<string name="reset_to_default">Alapértelmezés visszaállítása</string>
|
||||
<string name="rotate_left">Forgatás balra</string>
|
||||
<string name="rotate_right">Forgatás jobbra</string>
|
||||
<string name="save">Mentés</string>
|
||||
<string name="scan_new">Új szkennelés</string>
|
||||
<string name="scan_current">Folyamatban lévő szkennelés</string>
|
||||
<string name="scan_discard_current">A folyamatban lévő szkennelés elvetése</string>
|
||||
<string name="scan_resume">Szkennelés folytatása</string>
|
||||
<string name="settings">Beállítások</string>
|
||||
<string name="settings_ocr_download_error">A letöltés sikertelen. Ellenőrizd az internetkapcsolatodat, valamint hogy a hálózati hozzáférés engedélyezett-e az alkalmazás számára.</string>
|
||||
<string name="settings_ocr_download_intro">"A nyelvi fájlok a hivatalos Tesseract projektből kerülnek letöltésre. A szövegfelismerés ezt követően teljes mértékben offline történik."</string>
|
||||
<string name="settings_ocr_downloading">Nyelvi fájl letöltése</string>
|
||||
<string name="settings_ocr_languages">Nyelvek</string>
|
||||
<string name="settings_ocr_languages_available">Elérhető nyelvek</string>
|
||||
<string name="settings_ocr_languages_disabled">Kikapcsolva</string>
|
||||
<string name="settings_ocr_languages_installed">Telepítve</string>
|
||||
<string name="settings_ocr_suggested">Ajánlott</string>
|
||||
<string name="settings_section_ocr">Szövegfelismerés</string>
|
||||
<string name="settings_section_scan">Szkennelés</string>
|
||||
<string name="settings_section_export">Exportálás</string>
|
||||
<string name="share">Megosztás</string>
|
||||
<string name="share_document">Dokumentum megosztása</string>
|
||||
<string name="storage_permission_denied">Fájl mentése sikertelen: engedély megtagadva</string>
|
||||
<string name="support">Támogatás</string>
|
||||
<string name="support_last_image">A legutóbb rögzített képpel kapcsolatos probléma jelentése</string>
|
||||
<string name="text_recognition_disabled">Szövegfelismerés: kikapcsolva</string>
|
||||
<string name="text_recognition_enabled">Szövegfelismerés: bekapcsolva</string>
|
||||
<string name="turn_off_torch">Zseblámpa kikapcsolása</string>
|
||||
<string name="turn_on_torch">Zseblámpa bekapcsolása</string>
|
||||
<string name="unknown_size">Ismeretlen méret</string>
|
||||
<string name="version">Verzió</string>
|
||||
<string name="view_full_list">Teljes lista megtekintése</string>
|
||||
<string name="view_the_full_license">Teljes licenc megtekintése</string>
|
||||
<string name="yes">Igen</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="one">%2$s mentve ide: %3$s</item>
|
||||
<item quantity="other">%1$d fájl mentve ide: %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">%d fénykép importálása</item>
|
||||
<item quantity="other">%d fénykép importálása</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d oldal</item>
|
||||
<item quantity="other">%d oldal</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -0,0 +1,102 @@
|
||||
<resources>
|
||||
<string name="about">Tentang</string>
|
||||
<string name="add_page">Tambahkan halaman</string>
|
||||
<string name="app_tagline">Aplikasi sederhana dan aman untuk memindai dokumen Anda</string>
|
||||
<string name="apply">Terapkan</string>
|
||||
<string name="back">Kembali</string>
|
||||
<string name="camera_permission_denied">Izin penggunaan kamera ditolak</string>
|
||||
<string name="camera_permission_rationale">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.</string>
|
||||
<string name="cancel">Batalkan</string>
|
||||
<string name="change_directory">Ganti folder</string>
|
||||
<string name="clear_text">Bersihkan teks</string>
|
||||
<string name="close">Tutup</string>
|
||||
<string name="color_mode">Filter</string>
|
||||
<string name="color_mode_auto">Otomatis</string>
|
||||
<string name="color_mode_color">Warna</string>
|
||||
<string name="color_mode_default">Filter bawaan</string>
|
||||
<string name="color_mode_grayscale">Abu-abu</string>
|
||||
<string name="contact">Kontak</string>
|
||||
<string name="copied_logs">Log disalin ke papan klip</string>
|
||||
<string name="copy_logs">Salin log</string>
|
||||
<string name="creating_export">Mempersiapkan ekspor…</string>
|
||||
<string name="crop">Potong</string>
|
||||
<string name="delete">Hapus</string>
|
||||
<string name="delete_page">Hapus halaman</string>
|
||||
<string name="delete_page_warning">Apakah Anda ingin menghapus halaman ini?</string>
|
||||
<string name="developer">Pengembang</string>
|
||||
<string name="download_dirname">Undugan</string>
|
||||
<string name="error_context_folder">Folder ekspor %1$s</string>
|
||||
<string name="error_context_provider">Penyedia: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Folder ekspor yang dipilih tidak lagi dapat diakses. Silakan pilih folder lain.</string>
|
||||
<string name="error_no_app">Tidak ditemukan aplikasi untuk membuka file ini.</string>
|
||||
<string name="error_occurred">Terjadi kesalahan</string>
|
||||
<string name="error_save">Gagal menyimpan file</string>
|
||||
<string name="export">Ekspor</string>
|
||||
<string name="export_as">Ekspor sebagai %1$s</string>
|
||||
<string name="export_directory">Folder ekspor</string>
|
||||
<string name="export_folder_permission_lost">Akses folder hilang</string>
|
||||
<string name="export_format">Format ekspor</string>
|
||||
<string name="export_quality">Kualitas ekspor</string>
|
||||
<string name="export_quality_low">Rendah</string>
|
||||
<string name="export_quality_balanced">Sedang</string>
|
||||
<string name="export_quality_high">Tinggi</string>
|
||||
<string name="file_size">Ukuran file: %1$s</string>
|
||||
<string name="file_size_total">Ukuran total: %1$s</string>
|
||||
<string name="filename">Nama file</string>
|
||||
<string name="grant_permission">Berikan izin</string>
|
||||
<string name="import_photos">Impor</string>
|
||||
<string name="libraries">Perpustakaan</string>
|
||||
<string name="libraries_open_source">Pustaka sumber terbuka</string>
|
||||
<string name="license">Lisensi</string>
|
||||
<string name="licensed_under">Aplikasi ini dilisensikan berdasarkan Lisensi Publik Umum GNU v3.0.</string>
|
||||
<string name="menu">Menu</string>
|
||||
<string name="new_document_warning">Pemindaian saat ini akan hilang. Apakah Anda ingin melanjutkan?</string>
|
||||
<string name="open">Buka</string>
|
||||
<string name="open_file">Buka file</string>
|
||||
<string name="reset_to_default">Kembalikan ke bawaan</string>
|
||||
<string name="rotate_left">Putar ke kiri</string>
|
||||
<string name="rotate_right">Putar ke kanan</string>
|
||||
<string name="save">Simpan</string>
|
||||
<string name="scan_new">Pindai baru</string>
|
||||
<string name="scan_current">Pindaian terkini</string>
|
||||
<string name="scan_discard_current">Abaikan pemindaian saat ini.</string>
|
||||
<string name="scan_resume">Ulangi pemindaian</string>
|
||||
<string name="settings">Pengaturan</string>
|
||||
<string name="settings_ocr_download_error">Pengunduhan gagal. Periksa koneksi internet Anda dan pastikan akses jaringan diizinkan untuk aplikasi ini.</string>
|
||||
<string name="settings_ocr_download_intro">"Berkas bahasa diunduh dari proyek resmi Tesseract. Pengenalan teks tetap sepenuhnya offline."</string>
|
||||
<string name="settings_ocr_downloading">Mengunduh berkas bahasa</string>
|
||||
<string name="settings_ocr_languages">Bahasa</string>
|
||||
<string name="settings_ocr_languages_available">Bahasa yang tersedia</string>
|
||||
<string name="settings_ocr_languages_disabled">Matikan</string>
|
||||
<string name="settings_ocr_languages_installed">Terpasang</string>
|
||||
<string name="settings_ocr_suggested">Disarankan</string>
|
||||
<string name="settings_section_ocr">Pengenalan teks</string>
|
||||
<string name="settings_section_scan">Pindah</string>
|
||||
<string name="settings_section_export">Ekspor</string>
|
||||
<string name="share">Bagikan</string>
|
||||
<string name="share_document">Bagikan dokumen</string>
|
||||
<string name="storage_permission_denied">Tidak dapat menyimpan file: izin ditolak.</string>
|
||||
<string name="support">Dukung</string>
|
||||
<string name="support_last_image">Laporkan masalah pada gambar yang terakhir diambil</string>
|
||||
<string name="text_recognition_disabled">Pengenalan teks: matikan</string>
|
||||
<string name="text_recognition_enabled">Pengenalan teks: diaktifkan</string>
|
||||
<string name="turn_off_torch">Matikan senter</string>
|
||||
<string name="turn_on_torch">Hidupkan senter</string>
|
||||
<string name="unknown_size">Ukuran tidak diketahui</string>
|
||||
<string name="version">Versi</string>
|
||||
<string name="view_full_list">Lihat daftar lengkap</string>
|
||||
<string name="view_the_full_license">Lihat lisensi lengkapnya</string>
|
||||
<string name="yes">Yes</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="one">%2$s disimpan ke %3$s</item>
|
||||
<item quantity="other">%1$d berkas yang disimpan ke %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">Impor 1 foto</item>
|
||||
<item quantity="other">Mengimpor %d foto</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d halaman</item>
|
||||
<item quantity="other">%d halaman</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -5,10 +5,11 @@
|
||||
<string name="apply">Applica</string>
|
||||
<string name="back">Indietro</string>
|
||||
<string name="camera_permission_denied">Autorizzazione fotocamera negata</string>
|
||||
<string name="camera_permission_rationale">L\'app richiede l\'accesso alla fotocamera per scansionare documenti. Le immagini catturate sono salvate solo in questo dispositivo e verranno eliminate quando chiudi il documento attuale.</string>
|
||||
<string name="camera_permission_rationale">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.</string>
|
||||
<string name="cancel">Annulla</string>
|
||||
<string name="change_directory">Cambia cartella</string>
|
||||
<string name="clear_text">Svuota testo</string>
|
||||
<string name="close">Chiudi</string>
|
||||
<string name="color_mode">Filtro</string>
|
||||
<string name="color_mode_auto">Automatico</string>
|
||||
<string name="color_mode_color">Colore</string>
|
||||
@@ -19,6 +20,7 @@
|
||||
<string name="copy_logs">Copia log</string>
|
||||
<string name="creating_export">Preparazione dell’esportazione…</string>
|
||||
<string name="crop">Ritaglia</string>
|
||||
<string name="delete">Elimina</string>
|
||||
<string name="delete_page">Elimina pagina</string>
|
||||
<string name="delete_page_warning">Vuoi eliminare questa pagina?</string>
|
||||
<string name="developer">Sviluppatore</string>
|
||||
@@ -55,8 +57,20 @@
|
||||
<string name="rotate_left">Ruota a sinistra</string>
|
||||
<string name="rotate_right">Ruota a destra</string>
|
||||
<string name="save">Salva</string>
|
||||
<string name="scan_button">Nuova scansione</string>
|
||||
<string name="scan_new">Nuova scansione</string>
|
||||
<string name="scan_current">Scansione corrente</string>
|
||||
<string name="scan_discard_current">Elimina la scansione corrente</string>
|
||||
<string name="scan_resume">Riprendi scansione</string>
|
||||
<string name="settings">Impostazioni</string>
|
||||
<string name="settings_ocr_download_error">Download non riuscito. Controlla la connessione a Internet e assicurati che l\'accesso alla rete sia consentito per questa app.</string>
|
||||
<string name="settings_ocr_download_intro">I file di lingua vengono scaricati dal progetto ufficiale Tesseract. Il riconoscimento del testo rimane completamente offline.</string>
|
||||
<string name="settings_ocr_downloading">Download del file di lingua</string>
|
||||
<string name="settings_ocr_languages">Lingue</string>
|
||||
<string name="settings_ocr_languages_available">Lingue disponibili</string>
|
||||
<string name="settings_ocr_languages_disabled">Disattivato</string>
|
||||
<string name="settings_ocr_languages_installed">Installate</string>
|
||||
<string name="settings_ocr_suggested">Suggerito</string>
|
||||
<string name="settings_section_ocr">Riconoscimento del testo</string>
|
||||
<string name="settings_section_scan">Scansione</string>
|
||||
<string name="settings_section_export">Esportazione</string>
|
||||
<string name="share">Condividi</string>
|
||||
@@ -64,6 +78,8 @@
|
||||
<string name="storage_permission_denied">Impossibile salvare il file: permesso negato</string>
|
||||
<string name="support">Supporto</string>
|
||||
<string name="support_last_image">Segnala un problema con l’ultima immagine acquisita</string>
|
||||
<string name="text_recognition_disabled">Riconoscimento del testo: disattivato</string>
|
||||
<string name="text_recognition_enabled">Riconoscimento del testo: attivato</string>
|
||||
<string name="turn_off_torch">Spegni la torcia</string>
|
||||
<string name="turn_on_torch">Accendi la torcia</string>
|
||||
<string name="unknown_size">Dimensione sconosciuta</string>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<string name="apply">適用</string>
|
||||
<string name="back">戻る</string>
|
||||
<string name="camera_permission_denied">カメラの権限が拒否されました</string>
|
||||
<string name="camera_permission_rationale">ドキュメントをスキャンするためにカメラへのアクセスが必要です。撮影した画像はこのデバイスにのみ保存され、現在のドキュメントを閉じると削除されます。</string>
|
||||
<string name="camera_permission_rationale">ドキュメントをスキャンするためにカメラへのアクセスが必要です。撮影した画像はこのデバイスにのみ保存され、現在のスキャンを閉じると削除されます。</string>
|
||||
<string name="cancel">キャンセル</string>
|
||||
<string name="change_directory">フォルダを変更</string>
|
||||
<string name="clear_text">テキストを消去</string>
|
||||
<string name="close">閉じる</string>
|
||||
<string name="color_mode">フィルター</string>
|
||||
<string name="color_mode_auto">自動</string>
|
||||
<string name="color_mode_color">カラー</string>
|
||||
@@ -19,6 +20,7 @@
|
||||
<string name="copy_logs">ログをコピー</string>
|
||||
<string name="creating_export">エクスポートを準備中…</string>
|
||||
<string name="crop">トリミング</string>
|
||||
<string name="delete">削除</string>
|
||||
<string name="delete_page">ページを削除</string>
|
||||
<string name="delete_page_warning">このページを削除しますか?</string>
|
||||
<string name="developer">開発者</string>
|
||||
@@ -55,8 +57,20 @@
|
||||
<string name="rotate_left">左に回転</string>
|
||||
<string name="rotate_right">右に回転</string>
|
||||
<string name="save">保存</string>
|
||||
<string name="scan_button">新規スキャン</string>
|
||||
<string name="scan_new">新規スキャン</string>
|
||||
<string name="scan_current">現在のスキャン</string>
|
||||
<string name="scan_discard_current">現在のスキャンを破棄</string>
|
||||
<string name="scan_resume">スキャンを再開</string>
|
||||
<string name="settings">設定</string>
|
||||
<string name="settings_ocr_download_error">ダウンロードに失敗しました。インターネット接続を確認し、このアプリのネットワークアクセスが許可されていることを確認してください。</string>
|
||||
<string name="settings_ocr_download_intro">言語ファイルは公式の Tesseract プロジェクトからダウンロードされます。テキスト認識は完全にオフラインで実行されます。</string>
|
||||
<string name="settings_ocr_downloading">言語ファイルをダウンロード中</string>
|
||||
<string name="settings_ocr_languages">言語</string>
|
||||
<string name="settings_ocr_languages_available">利用可能な言語</string>
|
||||
<string name="settings_ocr_languages_disabled">無効</string>
|
||||
<string name="settings_ocr_languages_installed">インストール済み</string>
|
||||
<string name="settings_ocr_suggested">おすすめ</string>
|
||||
<string name="settings_section_ocr">テキスト認識</string>
|
||||
<string name="settings_section_scan">スキャン</string>
|
||||
<string name="settings_section_export">エクスポート</string>
|
||||
<string name="share">共有</string>
|
||||
@@ -64,6 +78,8 @@
|
||||
<string name="storage_permission_denied">権限が拒否されたため、ファイルを保存できません</string>
|
||||
<string name="support">サポート</string>
|
||||
<string name="support_last_image">最後に撮影した画像の問題を報告</string>
|
||||
<string name="text_recognition_disabled">テキスト認識: 無効</string>
|
||||
<string name="text_recognition_enabled">テキスト認識: 有効</string>
|
||||
<string name="turn_off_torch">ライトをオフ</string>
|
||||
<string name="turn_on_torch">ライトをオン</string>
|
||||
<string name="unknown_size">サイズ不明</string>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="export_quality_balanced">Sabalansēta</string>
|
||||
<string name="close">Aizvērt</string>
|
||||
<string name="save">Saglabāt</string>
|
||||
<string name="add_page">Pievienot lapu</string>
|
||||
<string name="error_occurred">Radās kļūda</string>
|
||||
<string name="view_full_list">Skatīt visu sarakstu</string>
|
||||
<string name="developer">Izstrādātājs</string>
|
||||
<string name="rotate_left">Pagriezt pa kreisi</string>
|
||||
<string name="settings_ocr_languages_installed">Uzstādīta</string>
|
||||
<plurals name="page_count">
|
||||
<item quantity="zero">%d lapas</item>
|
||||
<item quantity="one">%d lapa</item>
|
||||
<item quantity="other">%d lapas</item>
|
||||
</plurals>
|
||||
<string name="delete_page">Dzēst lapu</string>
|
||||
<string name="settings_ocr_downloading">Lejupielādē valodas datni</string>
|
||||
<string name="text_recognition_enabled">Teksta atpazīšana: iespējota</string>
|
||||
<string name="yes">Jā</string>
|
||||
<string name="filename">Datnes nosaukums</string>
|
||||
<string name="camera_permission_denied">Piekļuve kamerai liegta</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="zero">%1$d datnes saglabātas %3$s mapē</item>
|
||||
<item quantity="one">%2$s saglabāts %3$s mapē</item>
|
||||
<item quantity="other">%1$d datnes saglabātas %3$s mapē</item>
|
||||
</plurals>
|
||||
<string name="text_recognition_disabled">Teksta atpazīšana: atspējota</string>
|
||||
<string name="back">Atpakaļ</string>
|
||||
<string name="delete">Dzēst</string>
|
||||
<string name="support">Atbalsts</string>
|
||||
<string name="version">Versija</string>
|
||||
<string name="export_quality_low">Zema</string>
|
||||
<string name="export_quality_high">Augsta</string>
|
||||
<string name="contact">Kontakti</string>
|
||||
<string name="libraries_open_source">Atvērtā pirmkoda bibliotēkas</string>
|
||||
<string name="error_save">Neizdevās saglabāt datni</string>
|
||||
<string name="settings_ocr_languages_disabled">Atspējotas</string>
|
||||
<string name="license">Licence</string>
|
||||
<string name="settings_ocr_languages">Valodas</string>
|
||||
<string name="about">Par lietotni</string>
|
||||
<string name="turn_on_torch">Ieslēgt zibspuldzi/lukturīti</string>
|
||||
<string name="cancel">Atcelt</string>
|
||||
<string name="delete_page_warning">Vai tiešām vēlaties dzēst šo lapu?</string>
|
||||
<string name="open">Atvērt</string>
|
||||
<string name="view_the_full_license">Skatīt pašu licenci</string>
|
||||
<string name="file_size">Datnes izmērs: %1$s</string>
|
||||
<string name="open_file">Atvērt datni</string>
|
||||
<string name="settings">Iestatījumi</string>
|
||||
<string name="libraries">Bibliotēkas</string>
|
||||
<string name="settings_section_ocr">Teksta atpazīšana</string>
|
||||
<string name="unknown_size">Nezināms izmērs</string>
|
||||
<string name="turn_off_torch">Izslēgt zibspuldzi/lukturīti</string>
|
||||
<string name="rotate_right">Pagriezt pa labi</string>
|
||||
<string name="share">Kopīgot</string>
|
||||
<string name="file_size_total">Kopējais izmērs: %1$s</string>
|
||||
<string name="crop">Apgriezt</string>
|
||||
<string name="share_document">Kopīgot dokumentu</string>
|
||||
<string name="settings_ocr_languages_available">Pieejamās valodas</string>
|
||||
<string name="settings_ocr_download_intro">Valodu datnes lejupielādē no oficiālās Tesseract projekta vietnes. Teksta atpazīšana pilnībā notiek bezsaistē.</string>
|
||||
<string name="download_dirname">Lejupielādes</string>
|
||||
<string name="menu">Izvēlne</string>
|
||||
<string name="settings_section_export">Veidošana</string>
|
||||
<string name="export">Veidot</string>
|
||||
<string name="settings_section_scan">Skenējums</string>
|
||||
<string name="change_directory">Mainīt mapi</string>
|
||||
<string name="clear_text">Notīrīt tekstu</string>
|
||||
<string name="error_context_provider">Pakalpojuma sniedzējs: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Atlasītā veidošanas mape vairs nav pieejama. Lūdzu, atlasiet citu mapi.</string>
|
||||
<string name="export_as">Veidot kā %1$s</string>
|
||||
<string name="export_format">Veidošanas formāts</string>
|
||||
<string name="export_directory">Mape, kur saglabāt</string>
|
||||
<string name="export_quality">Veidošanas kvalitāte</string>
|
||||
<string name="camera_permission_rationale">Lai varētu veikt skenējumus, lietotnei ir nepieciešama piekļuve kamerai. Uzņemtie attēli tiek saglabāti tikai šajā ierīcē un tiks dzēsti, kad aizvērsiet pašreizējo skenējumu.</string>
|
||||
<string name="color_mode_auto">Automātiski</string>
|
||||
<string name="copied_logs">Žurnāla ieraksti kopēti starpliktuvē</string>
|
||||
<string name="copy_logs">Kopēt žurnāla ierakstus</string>
|
||||
<string name="error_no_app">Nav atrasta neviena lietotne, kas varētu atvērt šo datni</string>
|
||||
<string name="export_folder_permission_lost">Zaudēta piekļuve mapei</string>
|
||||
<string name="grant_permission">Dot atļauju</string>
|
||||
<string name="licensed_under">Šī lietotne ir licencēta saskaņā ar GNU General Public License v3.0 noteikumiem.</string>
|
||||
<string name="new_document_warning">Pašreizējais skenējums tiks zaudēts. Vai tiešām vēlaties turpināt?</string>
|
||||
<string name="reset_to_default">Atiestatīt uz noklusējuma iestatījumu</string>
|
||||
<string name="scan_new">Jauns skenējums</string>
|
||||
<string name="scan_current">Pašreizējais skenējums</string>
|
||||
<string name="scan_discard_current">Atmestīs pašreizējo skenējumu</string>
|
||||
<string name="scan_resume">Turpināt skenējumu</string>
|
||||
<string name="settings_ocr_download_error">Neizdevās lejupielādēt. Pārbaudiet savu interneta savienojumu un pārliecinieties, ka šai lietotnei ir tīkla piekļuves atļauja.</string>
|
||||
<string name="settings_ocr_suggested">Iesaka</string>
|
||||
<string name="app_tagline">Vienkārša un cieņpilna lietotne, kas veic jūsu dokumentu skenējumus.</string>
|
||||
<string name="color_mode">Krāsu filtrs</string>
|
||||
<string name="color_mode_default">Noklusējuma krāsu filtrs</string>
|
||||
<string name="color_mode_grayscale">Pelēktoņu</string>
|
||||
<string name="import_photos">Pievienot</string>
|
||||
<string name="storage_permission_denied">Nevar saglabāt datni: piekļuve liegta</string>
|
||||
<string name="support_last_image">Ziņot par problēmu saistībā ar pēdējo uzņemto attēlu</string>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="zero">Pievieno %d fotoattēlus</item>
|
||||
<item quantity="one">Pievieno %d fotoattēlu</item>
|
||||
<item quantity="other">Pievieno %d fotoattēlus</item>
|
||||
</plurals>
|
||||
<string name="color_mode_color">Pilnkrāsu</string>
|
||||
<string name="creating_export">Sagatavo…</string>
|
||||
<string name="apply">Apstiprināt</string>
|
||||
<string name="error_context_folder">Mape, kur saglabā: %1$s</string>
|
||||
</resources>
|
||||
@@ -5,10 +5,11 @@
|
||||
<string name="apply">Aplicar</string>
|
||||
<string name="back">Voltar</string>
|
||||
<string name="camera_permission_denied">Permissão da câmera negada</string>
|
||||
<string name="camera_permission_rationale">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 o documento atual.</string>
|
||||
<string name="camera_permission_rationale">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.</string>
|
||||
<string name="cancel">Cancelar</string>
|
||||
<string name="change_directory">Alterar diretório</string>
|
||||
<string name="clear_text">Limpar texto</string>
|
||||
<string name="close">Fechar</string>
|
||||
<string name="color_mode">Filtro</string>
|
||||
<string name="color_mode_auto">Automático</string>
|
||||
<string name="color_mode_color">Cor</string>
|
||||
@@ -19,6 +20,7 @@
|
||||
<string name="copy_logs">Copiar registros</string>
|
||||
<string name="creating_export">Preparando exportação…</string>
|
||||
<string name="crop">Recortar</string>
|
||||
<string name="delete">Excluir</string>
|
||||
<string name="delete_page">Excluir página</string>
|
||||
<string name="delete_page_warning">Deseja excluir esta página?</string>
|
||||
<string name="developer">Desenvolvedor</string>
|
||||
@@ -55,8 +57,20 @@
|
||||
<string name="rotate_left">Girar para a esquerda</string>
|
||||
<string name="rotate_right">Girar para a direita</string>
|
||||
<string name="save">Salvar</string>
|
||||
<string name="scan_button">Nova digitalização</string>
|
||||
<string name="scan_new">Nova digitalização</string>
|
||||
<string name="scan_current">Digitalização atual</string>
|
||||
<string name="scan_discard_current">Descartar a digitalização atual</string>
|
||||
<string name="scan_resume">Retomar digitalização</string>
|
||||
<string name="settings">Configurações</string>
|
||||
<string name="settings_ocr_download_error">Falha no download. Verifique sua conexão com a Internet e certifique-se de que o acesso à rede está permitido para este aplicativo.</string>
|
||||
<string name="settings_ocr_download_intro">Os arquivos de idioma são baixados do projeto oficial Tesseract. O reconhecimento de texto permanece totalmente offline.</string>
|
||||
<string name="settings_ocr_downloading">Baixando arquivo de idioma</string>
|
||||
<string name="settings_ocr_languages">Idiomas</string>
|
||||
<string name="settings_ocr_languages_available">Idiomas disponíveis</string>
|
||||
<string name="settings_ocr_languages_disabled">Desativado</string>
|
||||
<string name="settings_ocr_languages_installed">Instalados</string>
|
||||
<string name="settings_ocr_suggested">Sugerido</string>
|
||||
<string name="settings_section_ocr">Reconhecimento de texto</string>
|
||||
<string name="settings_section_scan">Digitalização</string>
|
||||
<string name="settings_section_export">Exportação</string>
|
||||
<string name="share">Compartilhar</string>
|
||||
@@ -64,6 +78,8 @@
|
||||
<string name="storage_permission_denied">Não foi possível salvar o arquivo: permissão negada</string>
|
||||
<string name="support">Suporte</string>
|
||||
<string name="support_last_image">Relatar um problema com a última imagem capturada</string>
|
||||
<string name="text_recognition_disabled">Reconhecimento de texto: desativado</string>
|
||||
<string name="text_recognition_enabled">Reconhecimento de texto: ativado</string>
|
||||
<string name="turn_off_torch">Desligar lanterna</string>
|
||||
<string name="turn_on_torch">Ligar lanterna</string>
|
||||
<string name="unknown_size">Tamanho desconhecido</string>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<string name="apply">Применить</string>
|
||||
<string name="back">Назад</string>
|
||||
<string name="camera_permission_denied">В доступе к камере отказано</string>
|
||||
<string name="camera_permission_rationale">Для сканирования документов приложению требуется доступ к камере. Отснятые изображения хранятся только на данном устройстве и удаляются по окончании работы с текущим документом.</string>
|
||||
<string name="camera_permission_rationale">Для сканирования документов приложению требуется доступ к камере. Отснятые изображения хранятся только на данном устройстве и удаляются по окончании текущего сканирования.</string>
|
||||
<string name="cancel">Отмена</string>
|
||||
<string name="change_directory">Изменить папку</string>
|
||||
<string name="clear_text">Стереть текст</string>
|
||||
<string name="close">Закрыть</string>
|
||||
<string name="color_mode">Фильтр</string>
|
||||
<string name="color_mode_auto">Автоматически</string>
|
||||
<string name="color_mode_color">Цвет</string>
|
||||
@@ -19,6 +20,7 @@
|
||||
<string name="copy_logs">Копировать журналы</string>
|
||||
<string name="creating_export">Подготовка экспорта…</string>
|
||||
<string name="crop">Обрезать</string>
|
||||
<string name="delete">Удалить</string>
|
||||
<string name="delete_page">Удалить страницу</string>
|
||||
<string name="delete_page_warning">Вы желаете удалить эту страницу?</string>
|
||||
<string name="developer">Разработчик</string>
|
||||
@@ -55,8 +57,20 @@
|
||||
<string name="rotate_left">Повернуть влево</string>
|
||||
<string name="rotate_right">Повернуть вправо</string>
|
||||
<string name="save">Сохранить</string>
|
||||
<string name="scan_button">Начать</string>
|
||||
<string name="scan_new">Начать</string>
|
||||
<string name="scan_current">Текущее сканирование</string>
|
||||
<string name="scan_discard_current">Отменить текущее сканирование</string>
|
||||
<string name="scan_resume">Продолжить сканирование</string>
|
||||
<string name="settings">Настройки</string>
|
||||
<string name="settings_ocr_download_error">Не удалось загрузить файл. Проверьте подключение к Интернету и убедитесь, что для этого приложения разрешён доступ к сети.</string>
|
||||
<string name="settings_ocr_download_intro">Языковые файлы загружаются из официального проекта Tesseract. Распознавание текста остаётся полностью офлайн.</string>
|
||||
<string name="settings_ocr_downloading">Загрузка языкового файла</string>
|
||||
<string name="settings_ocr_languages">Языки</string>
|
||||
<string name="settings_ocr_languages_available">Доступные языки</string>
|
||||
<string name="settings_ocr_languages_disabled">Отключено</string>
|
||||
<string name="settings_ocr_languages_installed">Установленные</string>
|
||||
<string name="settings_ocr_suggested">Рекомендуется</string>
|
||||
<string name="settings_section_ocr">Распознавание текста</string>
|
||||
<string name="settings_section_scan">Сканирование</string>
|
||||
<string name="settings_section_export">Экспорт</string>
|
||||
<string name="share">Поделиться</string>
|
||||
@@ -64,6 +78,8 @@
|
||||
<string name="storage_permission_denied">Невозможно сохранить файл: доступ запрещён</string>
|
||||
<string name="support">Поддержка</string>
|
||||
<string name="support_last_image">Сообщить о проблеме с последним сделанным изображением</string>
|
||||
<string name="text_recognition_disabled">Распознавание текста: отключено</string>
|
||||
<string name="text_recognition_enabled">Распознавание текста: включено</string>
|
||||
<string name="turn_off_torch">Выключить фонарик</string>
|
||||
<string name="turn_on_torch">Включить фонарик</string>
|
||||
<string name="unknown_size">Неизвестный размер</string>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<string name="apply">Använd</string>
|
||||
<string name="back">Tillbaka</string>
|
||||
<string name="camera_permission_denied">Kamerabehörighet nekades</string>
|
||||
<string name="camera_permission_rationale">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 det aktuella dokumentet.</string>
|
||||
<string name="camera_permission_rationale">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.</string>
|
||||
<string name="cancel">Avbryt</string>
|
||||
<string name="change_directory">Byt mapp</string>
|
||||
<string name="clear_text">Rensa text</string>
|
||||
<string name="close">Stäng</string>
|
||||
<string name="color_mode">Filter</string>
|
||||
<string name="color_mode_auto">Automatiskt</string>
|
||||
<string name="color_mode_color">Färg</string>
|
||||
@@ -19,6 +20,7 @@
|
||||
<string name="copy_logs">Kopiera loggar</string>
|
||||
<string name="creating_export">Förbereder export…</string>
|
||||
<string name="crop">Beskär</string>
|
||||
<string name="delete">Ta bort</string>
|
||||
<string name="delete_page">Ta bort sida</string>
|
||||
<string name="delete_page_warning">Vill du ta bort den här sidan?</string>
|
||||
<string name="developer">Utvecklare</string>
|
||||
@@ -55,8 +57,20 @@
|
||||
<string name="rotate_left">Rotera åt vänster</string>
|
||||
<string name="rotate_right">Rotera åt höger</string>
|
||||
<string name="save">Spara</string>
|
||||
<string name="scan_button">Ny skanning</string>
|
||||
<string name="scan_new">Ny skanning</string>
|
||||
<string name="scan_current">Aktuell skanning</string>
|
||||
<string name="scan_discard_current">Kassera aktuell skanning</string>
|
||||
<string name="scan_resume">Återuppta skanning</string>
|
||||
<string name="settings">Inställningar</string>
|
||||
<string name="settings_ocr_download_error">Nedladdningen misslyckades. Kontrollera din internetanslutning och att nätverksåtkomst är tillåten för den här appen.</string>
|
||||
<string name="settings_ocr_download_intro">Språkfiler hämtas från det officiella Tesseract-projektet. Textigenkänning förblir helt offline.</string>
|
||||
<string name="settings_ocr_downloading">Hämtar språkfil</string>
|
||||
<string name="settings_ocr_languages">Språk</string>
|
||||
<string name="settings_ocr_languages_available">Tillgängliga språk</string>
|
||||
<string name="settings_ocr_languages_disabled">Inaktiverad</string>
|
||||
<string name="settings_ocr_languages_installed">Installerade</string>
|
||||
<string name="settings_ocr_suggested">Föreslagen</string>
|
||||
<string name="settings_section_ocr">Textigenkänning</string>
|
||||
<string name="settings_section_scan">Skanning</string>
|
||||
<string name="settings_section_export">Export</string>
|
||||
<string name="share">Dela</string>
|
||||
@@ -64,6 +78,8 @@
|
||||
<string name="storage_permission_denied">Det går inte att spara filen: behörighet nekades</string>
|
||||
<string name="support">Support</string>
|
||||
<string name="support_last_image">Rapportera ett problem med den senast tagna bilden</string>
|
||||
<string name="text_recognition_disabled">Textigenkänning: inaktiverad</string>
|
||||
<string name="text_recognition_enabled">Textigenkänning: aktiverad</string>
|
||||
<string name="turn_off_torch">Stäng av ficklampan</string>
|
||||
<string name="turn_on_torch">Tänd ficklampan</string>
|
||||
<string name="unknown_size">Okänd storlek</string>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<string name="apply">Uygula</string>
|
||||
<string name="back">Geri</string>
|
||||
<string name="camera_permission_denied">Kamera izni reddedildi</string>
|
||||
<string name="camera_permission_rationale">Uygulama, belgeleri taramak için kamera erişimi gerektirir. Yakalanan görüntüler yalnızca bu cihazda saklanır ve mevcut belgeyi kapattığınızda silinir.</string>
|
||||
<string name="camera_permission_rationale">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.</string>
|
||||
<string name="cancel">İptal</string>
|
||||
<string name="change_directory">Dizini değiştir</string>
|
||||
<string name="clear_text">Metni temizle</string>
|
||||
<string name="close">Kapat</string>
|
||||
<string name="color_mode">Filtre</string>
|
||||
<string name="color_mode_auto">Otomatik</string>
|
||||
<string name="color_mode_color">Renkli</string>
|
||||
@@ -19,6 +20,7 @@
|
||||
<string name="copy_logs">Günlükleri kopyala</string>
|
||||
<string name="creating_export">Dışa aktarma hazırlanıyor…</string>
|
||||
<string name="crop">Kırp</string>
|
||||
<string name="delete">Sil</string>
|
||||
<string name="delete_page">Sayfayı sil</string>
|
||||
<string name="delete_page_warning">Bu sayfayı silmek istiyor musunuz?</string>
|
||||
<string name="developer">Geliştirici</string>
|
||||
@@ -55,8 +57,20 @@
|
||||
<string name="rotate_left">Sola döndür</string>
|
||||
<string name="rotate_right">Sağa döndür</string>
|
||||
<string name="save">Kaydet</string>
|
||||
<string name="scan_button">Yeni Tarama</string>
|
||||
<string name="scan_new">Yeni Tarama</string>
|
||||
<string name="scan_current">Geçerli tarama</string>
|
||||
<string name="scan_discard_current">Geçerli taramayı sil</string>
|
||||
<string name="scan_resume">Taramaya devam et</string>
|
||||
<string name="settings">Ayarlar</string>
|
||||
<string name="settings_ocr_download_error">İndirme başarısız oldu. İnternet bağlantınızı kontrol edin ve bu uygulama için ağ erişimine izin verildiğinden emin olun.</string>
|
||||
<string name="settings_ocr_download_intro">Dil dosyaları resmi Tesseract projesinden indirilir. Metin tanıma tamamen çevrimdışı olarak çalışır.</string>
|
||||
<string name="settings_ocr_downloading">Dil dosyası indiriliyor</string>
|
||||
<string name="settings_ocr_languages">Diller</string>
|
||||
<string name="settings_ocr_languages_available">Kullanılabilir diller</string>
|
||||
<string name="settings_ocr_languages_disabled">Devre dışı</string>
|
||||
<string name="settings_ocr_languages_installed">Yüklü</string>
|
||||
<string name="settings_ocr_suggested">Önerilen</string>
|
||||
<string name="settings_section_ocr">Metin tanıma</string>
|
||||
<string name="settings_section_scan">Tarama</string>
|
||||
<string name="settings_section_export">Dışa aktarma</string>
|
||||
<string name="share">Paylaş</string>
|
||||
@@ -64,6 +78,8 @@
|
||||
<string name="storage_permission_denied">Dosya kaydedilemiyor: izin reddedildi</string>
|
||||
<string name="support">Destek</string>
|
||||
<string name="support_last_image">Son yakalanan görüntüyle ilgili bir sorunu bildir</string>
|
||||
<string name="text_recognition_disabled">Metin tanıma: devre dışı</string>
|
||||
<string name="text_recognition_enabled">Metin tanıma: etkin</string>
|
||||
<string name="turn_off_torch">El fenerini kapat</string>
|
||||
<string name="turn_on_torch">El fenerini aç</string>
|
||||
<string name="unknown_size">Bilinmeyen boyut</string>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<string name="apply">套用</string>
|
||||
<string name="back">返回</string>
|
||||
<string name="camera_permission_denied">已拒絕相機權限</string>
|
||||
<string name="camera_permission_rationale">此應用程式需要相機存取權限才能掃描文件。擷取的影像僅儲存在此裝置上,並會在您關閉目前文件時刪除。</string>
|
||||
<string name="camera_permission_rationale">此應用程式需要相機存取權限才能掃描文件。擷取的影像僅儲存在此裝置上,並會在您關閉目前掃描時刪除。</string>
|
||||
<string name="cancel">取消</string>
|
||||
<string name="change_directory">變更目錄</string>
|
||||
<string name="clear_text">清除文字</string>
|
||||
<string name="close">關閉</string>
|
||||
<string name="color_mode">濾鏡</string>
|
||||
<string name="color_mode_auto">自動</string>
|
||||
<string name="color_mode_color">彩色</string>
|
||||
@@ -19,6 +20,7 @@
|
||||
<string name="copy_logs">複製日誌</string>
|
||||
<string name="creating_export">正在準備匯出…</string>
|
||||
<string name="crop">裁切</string>
|
||||
<string name="delete">刪除</string>
|
||||
<string name="delete_page">刪除頁面</string>
|
||||
<string name="delete_page_warning">確定要刪除此頁面嗎?</string>
|
||||
<string name="developer">開發者</string>
|
||||
@@ -55,8 +57,20 @@
|
||||
<string name="rotate_left">向左旋轉</string>
|
||||
<string name="rotate_right">向右旋轉</string>
|
||||
<string name="save">儲存</string>
|
||||
<string name="scan_button">開始掃描</string>
|
||||
<string name="scan_new">開始掃描</string>
|
||||
<string name="scan_current">目前掃描</string>
|
||||
<string name="scan_discard_current">放棄目前掃描</string>
|
||||
<string name="scan_resume">繼續掃描</string>
|
||||
<string name="settings">設定</string>
|
||||
<string name="settings_ocr_download_error">下載失敗。請檢查您的網路連線,並確認已允許此應用程式存取網路。</string>
|
||||
<string name="settings_ocr_download_intro">語言檔案會從官方 Tesseract 專案下載。文字辨識始終完全離線運作。</string>
|
||||
<string name="settings_ocr_downloading">正在下載語言檔案</string>
|
||||
<string name="settings_ocr_languages">語言</string>
|
||||
<string name="settings_ocr_languages_available">可用語言</string>
|
||||
<string name="settings_ocr_languages_disabled">已停用</string>
|
||||
<string name="settings_ocr_languages_installed">已安裝</string>
|
||||
<string name="settings_ocr_suggested">推薦</string>
|
||||
<string name="settings_section_ocr">文字辨識</string>
|
||||
<string name="settings_section_scan">掃描</string>
|
||||
<string name="settings_section_export">匯出</string>
|
||||
<string name="share">分享</string>
|
||||
@@ -64,6 +78,8 @@
|
||||
<string name="storage_permission_denied">無法儲存檔案:權限遭拒</string>
|
||||
<string name="support">支援</string>
|
||||
<string name="support_last_image">回報最近拍攝影像的問題</string>
|
||||
<string name="text_recognition_disabled">文字辨識:已停用</string>
|
||||
<string name="text_recognition_enabled">文字辨識:已啟用</string>
|
||||
<string name="turn_off_torch">關閉閃光燈</string>
|
||||
<string name="turn_on_torch">開啟閃光燈</string>
|
||||
<string name="unknown_size">未知大小</string>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<string name="apply">应用</string>
|
||||
<string name="back">返回</string>
|
||||
<string name="camera_permission_denied">相机权限被拒绝</string>
|
||||
<string name="camera_permission_rationale">应用请求相机权限访问扫描文档。捕获的图像仅存储在此设备上,并且在关闭当前文档时将被删除。</string>
|
||||
<string name="camera_permission_rationale">应用请求相机权限访问扫描文档。捕获的图像仅存储在此设备上,并且在关闭当前扫描时将被删除。</string>
|
||||
<string name="cancel">取消</string>
|
||||
<string name="change_directory">更改目录</string>
|
||||
<string name="clear_text">清除文字</string>
|
||||
<string name="close">关闭</string>
|
||||
<string name="color_mode">滤镜</string>
|
||||
<string name="color_mode_auto">自动</string>
|
||||
<string name="color_mode_color">彩色</string>
|
||||
@@ -19,6 +20,7 @@
|
||||
<string name="copy_logs">复制日志</string>
|
||||
<string name="creating_export">正在准备导出…</string>
|
||||
<string name="crop">裁剪</string>
|
||||
<string name="delete">删除</string>
|
||||
<string name="delete_page">删除页面</string>
|
||||
<string name="delete_page_warning">是否要删除此页面?</string>
|
||||
<string name="developer">开发者</string>
|
||||
@@ -55,8 +57,20 @@
|
||||
<string name="rotate_left">向左旋转</string>
|
||||
<string name="rotate_right">向右旋转</string>
|
||||
<string name="save">保存</string>
|
||||
<string name="scan_button">新建扫描</string>
|
||||
<string name="scan_new">新建扫描</string>
|
||||
<string name="scan_current">当前扫描</string>
|
||||
<string name="scan_discard_current">放弃当前扫描</string>
|
||||
<string name="scan_resume">继续扫描</string>
|
||||
<string name="settings">设置</string>
|
||||
<string name="settings_ocr_download_error">下载失败。请检查您的网络连接,并确认已允许此应用访问网络。</string>
|
||||
<string name="settings_ocr_download_intro">语言文件从官方 Tesseract 项目下载。文本识别始终完全离线运行。</string>
|
||||
<string name="settings_ocr_downloading">正在下载语言文件</string>
|
||||
<string name="settings_ocr_languages">语言</string>
|
||||
<string name="settings_ocr_languages_available">可用语言</string>
|
||||
<string name="settings_ocr_languages_disabled">已禁用</string>
|
||||
<string name="settings_ocr_languages_installed">已安装</string>
|
||||
<string name="settings_ocr_suggested">推荐</string>
|
||||
<string name="settings_section_ocr">文本识别</string>
|
||||
<string name="settings_section_scan">扫描</string>
|
||||
<string name="settings_section_export">导出</string>
|
||||
<string name="share">共享</string>
|
||||
@@ -64,6 +78,8 @@
|
||||
<string name="storage_permission_denied">无法保存文件:权限被拒绝</string>
|
||||
<string name="support">支持</string>
|
||||
<string name="support_last_image">报告最近拍摄图像的问题</string>
|
||||
<string name="text_recognition_disabled">文本识别:已禁用</string>
|
||||
<string name="text_recognition_enabled">文本识别:已启用</string>
|
||||
<string name="turn_off_torch">关闭手电筒</string>
|
||||
<string name="turn_on_torch">打开手电筒</string>
|
||||
<string name="unknown_size">未知大小</string>
|
||||
|
||||
@@ -1,88 +1,201 @@
|
||||
<resources>
|
||||
<!-- About screen: title. It's also an item of the ⋮ menu -->
|
||||
<string name="about">About</string>
|
||||
<!-- Button in the Document screen -->
|
||||
<string name="add_page">Add page</string>
|
||||
<!-- Header in the About screen -->
|
||||
<string name="app_name" translatable="false">FairScan</string>
|
||||
<!--
|
||||
Appears in the About screen.
|
||||
"Simple" means easy to use, with no unnecessary complexity. You can scan a document and get a PDF in just a few seconds.
|
||||
"Respectful" is not only about privacy, but also: no ads, no tracking, no artificial limitations (e.g. watermark), minimal permissions...
|
||||
Please preserve the broad meaning of "respectful". It is a key part of FairScan's philosophy and should not be translated as "privacy-friendly" only.
|
||||
-->
|
||||
<string name="app_tagline">A simple and respectful application to scan your documents.</string>
|
||||
<!-- Button in the Crop screen -->
|
||||
<string name="apply">Apply</string>
|
||||
<!-- Icon description for ← at the top-left -->
|
||||
<string name="back">Back</string>
|
||||
<!-- Error message (as a toast) -->
|
||||
<string name="camera_permission_denied">Camera permission was denied</string>
|
||||
<string name="camera_permission_rationale">The app requires camera access to scan documents. Captured images are stored only on this device and will be deleted when you close the current document.</string>
|
||||
<!-- Appears on the Camera screen -->
|
||||
<string name="camera_permission_rationale">The app requires camera access to scan documents. Captured images are stored only on this device and will be deleted when you close the current scan.</string>
|
||||
<!-- Button in dialogs -->
|
||||
<string name="cancel">Cancel</string>
|
||||
<!-- Icon description 🗁 -->
|
||||
<string name="change_directory">Change folder</string>
|
||||
<!-- Icon description ✖ in the filename field of the Export screen -->
|
||||
<string name="clear_text">Clear text</string>
|
||||
<!-- Button in OCR download dialog -->
|
||||
<string name="close">Close</string>
|
||||
<!-- Icon description in the Document screen -->
|
||||
<string name="color_mode">Filter</string>
|
||||
<!-- Settings screen -->
|
||||
<string name="color_mode_auto">Automatic</string>
|
||||
<!-- Settings screen: value for "default filter". Document screen: dropdown menu for filter -->
|
||||
<string name="color_mode_color">Color</string>
|
||||
<!-- Settings screen -->
|
||||
<string name="color_mode_default">Default filter</string>
|
||||
<!-- Settings screen: value for "default filter". Document screen: dropdown menu for filter -->
|
||||
<string name="color_mode_grayscale">Grayscale</string>
|
||||
<!-- About screen -->
|
||||
<string name="contact">Contact</string>
|
||||
<!-- About screen: appears as a toast -->
|
||||
<string name="copied_logs">Logs copied to clipboard</string>
|
||||
<!-- About screen -->
|
||||
<string name="copy_logs">Copy logs</string>
|
||||
<!-- Export screen: progress message -->
|
||||
<string name="creating_export">Preparing export…</string>
|
||||
<!-- Icon description in the Document screen -->
|
||||
<string name="crop">Crop</string>
|
||||
<!-- Icon description in the OCR languages screen -->
|
||||
<string name="delete">Delete</string>
|
||||
<!-- Document screen: icon description and title of a confirmation dialog -->
|
||||
<string name="delete_page">Delete page</string>
|
||||
<!-- Document screen: confirmation dialog -->
|
||||
<string name="delete_page_warning">Do you want to delete this page?</string>
|
||||
<!-- About screen -->
|
||||
<string name="developer">Developer</string>
|
||||
<!-- Localized name of the Android Download folder as it appears in the Files app (it ends with an "s" in English...)-->
|
||||
<string name="download_dirname">Downloads</string>
|
||||
<!-- Export screen -->
|
||||
<string name="error_context_folder">Export folder: %1$s</string>
|
||||
<!-- Export screen, %1$s is the provider name -->
|
||||
<string name="error_context_provider">Provider: %1$s</string>
|
||||
<!-- Export screen -->
|
||||
<string name="error_export_dir_permission_lost">The selected export folder is no longer accessible. Please choose another folder.</string>
|
||||
<!-- Rare error messages should not be translated -->
|
||||
<string name="error_file_picker_launch" translatable="false">Failed to launch system file picker on this device</string>
|
||||
<string name="error_file_picker_result" translatable="false">Failed to set export directory</string>
|
||||
<!-- Export screen: appears as a toast -->
|
||||
<string name="error_no_app">No app found to open this file</string>
|
||||
<!-- Generic message for unexpected errors in various screens: appears as a toast -->
|
||||
<string name="error_occurred">An error occurred</string>
|
||||
<!-- Export screen -->
|
||||
<string name="error_save">Failed to save file</string>
|
||||
<!-- Document screen: button -->
|
||||
<string name="export">Export</string>
|
||||
<!-- Export screen: title (%1$s is the export format: PDF, JPEG...) -->
|
||||
<string name="export_as">Export as %1$s</string>
|
||||
<!-- Settings screen -->
|
||||
<string name="export_directory">Export folder</string>
|
||||
<!-- Settings screen -->
|
||||
<string name="export_folder_permission_lost">Folder access lost</string>
|
||||
<!-- Settings screen -->
|
||||
<string name="export_format">Export format</string>
|
||||
<!-- Settings screen -->
|
||||
<string name="export_quality">Export quality</string>
|
||||
<!-- Settings screen: one of the values for "export quality" -->
|
||||
<string name="export_quality_low">Low</string>
|
||||
<!-- Settings screen: one of the values for "export quality" -->
|
||||
<string name="export_quality_balanced">Balanced</string>
|
||||
<!-- Settings screen: one of the values for "export quality" -->
|
||||
<string name="export_quality_high">High</string>
|
||||
<!-- Export screen when exporting 1 file-->
|
||||
<string name="file_size">File size: %1$s</string>
|
||||
<!-- Export screen when exporting more than 1 file -->
|
||||
<string name="file_size_total">Total size: %1$s</string>
|
||||
<!-- Export screen: field label -->
|
||||
<string name="filename">Filename</string>
|
||||
<!-- Camera screen: button -->
|
||||
<string name="grant_permission">Grant permission</string>
|
||||
<!-- Camera screen: button -->
|
||||
<string name="import_photos">Import</string>
|
||||
<!-- About screen -->
|
||||
<string name="libraries">Libraries</string>
|
||||
<!-- Libraries screen title (reachable from the About screen) -->
|
||||
<string name="libraries_open_source">Open-source libraries</string>
|
||||
<!-- About screen -->
|
||||
<string name="license">License</string>
|
||||
<!-- About screen -->
|
||||
<string name="licensed_under">This application is licensed under the GNU General Public License v3.0.</string>
|
||||
<!-- Icon description for ⋮ -->
|
||||
<string name="menu">Menu</string>
|
||||
<!-- Export screen: dialog -->
|
||||
<string name="new_document_warning">The current scan will be lost. Do you want to continue?</string>
|
||||
<!-- Export screen: button -->
|
||||
<string name="open">Open</string>
|
||||
<!-- Export screen: text passed to the Android system when opening a file -->
|
||||
<string name="open_file">Open file</string>
|
||||
<!-- Settings screen: when a non-default export folder is chosen -->
|
||||
<string name="reset_to_default">Reset to default</string>
|
||||
<!-- Document screen: icon description -->
|
||||
<string name="rotate_left">Rotate left</string>
|
||||
<!-- Document screen: icon description -->
|
||||
<string name="rotate_right">Rotate right</string>
|
||||
<!-- Export screen: button -->
|
||||
<string name="save">Save</string>
|
||||
<string name="scan_button">New Scan</string>
|
||||
<!-- Export screen: button. Resume scan screen: button -->
|
||||
<string name="scan_new">New Scan</string>
|
||||
<!-- Resume scan screen -->
|
||||
<string name="scan_current">Current scan</string>
|
||||
<!-- Resume scan screen -->
|
||||
<string name="scan_discard_current">Discard the current scan</string>
|
||||
<!-- Resume scan screen: button -->
|
||||
<string name="scan_resume">Resume scan</string>
|
||||
<!-- Settings screen: title. It's also an item of the ⋮ menu -->
|
||||
<string name="settings">Settings</string>
|
||||
<!-- OCR Languages screen -->
|
||||
<string name="settings_ocr_download_error">Download failed. Check your internet connection and that network access is allowed for this app.</string>
|
||||
<!-- OCR Languages screen -->
|
||||
<string name="settings_ocr_download_intro">"Language files are downloaded from the official Tesseract project. Text recognition remains fully offline."</string>
|
||||
<!-- OCR Languages screen -->
|
||||
<string name="settings_ocr_downloading">Downloading language file</string>
|
||||
<!-- OCR Languages screen: title -->
|
||||
<string name="settings_ocr_languages">Languages</string>
|
||||
<!-- OCR Languages screen -->
|
||||
<string name="settings_ocr_languages_available">Available languages</string>
|
||||
<!-- Settings screen -->
|
||||
<string name="settings_ocr_languages_disabled">Disabled</string>
|
||||
<!-- OCR Languages screen -->
|
||||
<string name="settings_ocr_languages_installed">Installed</string>
|
||||
<!-- OCR Languages screen. Marks the language matching the user's display language. At most one language is suggested. -->
|
||||
<string name="settings_ocr_suggested">Suggested</string>
|
||||
<!-- Settings screen: section title -->
|
||||
<string name="settings_section_ocr">Text recognition</string>
|
||||
<!-- Settings screen: section title -->
|
||||
<string name="settings_section_scan">Scan</string>
|
||||
<!-- Settings screen: section title -->
|
||||
<string name="settings_section_export">Export</string>
|
||||
<!-- Export screen: button -->
|
||||
<string name="share">Share</string>
|
||||
<!-- Export screen: text passed to the Android system when sharing a file -->
|
||||
<string name="share_document">Share document</string>
|
||||
<!-- Export screen -->
|
||||
<string name="storage_permission_denied">Cannot save file: permission was denied</string>
|
||||
<!-- About screen -->
|
||||
<string name="support">Support</string>
|
||||
<!-- About screen -->
|
||||
<string name="support_last_image">Report a problem with last captured image</string>
|
||||
<!-- Export screen -->
|
||||
<string name="text_recognition_disabled">Text recognition: disabled</string>
|
||||
<!-- Export screen -->
|
||||
<string name="text_recognition_enabled">Text recognition: enabled</string>
|
||||
<!-- Camera screen -->
|
||||
<string name="turn_off_torch">Turn off torch</string>
|
||||
<!-- Camera screen -->
|
||||
<string name="turn_on_torch">Turn on torch</string>
|
||||
<!-- Export screen -->
|
||||
<string name="unknown_size">Unknown size</string>
|
||||
<!-- About screen -->
|
||||
<string name="version">Version</string>
|
||||
<!-- About screen -->
|
||||
<string name="view_full_list">View full list</string>
|
||||
<!-- About screen -->
|
||||
<string name="view_the_full_license">View the full license</string>
|
||||
<!-- Dialogs: button -->
|
||||
<string name="yes">Yes</string>
|
||||
<!-- Export screen. %1$d: number of files; %2$s: file name; %3$s: folder name -->
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="one">%2$s saved to %3$s</item>
|
||||
<item quantity="other">%1$d files saved to %3$s</item>
|
||||
</plurals>
|
||||
<!-- Camera screen. %d: number of photos -->
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">Importing 1 photo</item>
|
||||
<item quantity="other">Importing %d photos</item>
|
||||
</plurals>
|
||||
<!-- Camera screen, Export screen, Resume scan screen. %d: number of pages -->
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d page</item>
|
||||
<item quantity="other">%d pages</item>
|
||||
|
||||
@@ -29,7 +29,7 @@ class FileManagerTest {
|
||||
|
||||
val pdfDir: File = createTempDirectory().toFile()
|
||||
val externalDir: File = createTempDirectory().toFile()
|
||||
val dummyPdfWriter = PdfWriter { _,_ -> 42 }
|
||||
val dummyPdfWriter = PdfWriter { _,_,_,_ -> }
|
||||
|
||||
@Test
|
||||
fun copyToExternalDir() {
|
||||
@@ -75,16 +75,20 @@ class FileManagerTest {
|
||||
@Test
|
||||
fun generatePdf() = runTest {
|
||||
val fakePdfWriter = object : PdfWriter {
|
||||
override suspend fun writePdfFromJpegs(pages: List<PageToExport>, outputStream: OutputStream): Int {
|
||||
override suspend fun writePdfFromJpegs(
|
||||
pages: List<PageToExport>,
|
||||
outputStream: OutputStream,
|
||||
disableOcr: Boolean,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
val list = pages.toList()
|
||||
list.forEach { page -> outputStream.write(page.jpeg.get().bytes) }
|
||||
return list.size
|
||||
}
|
||||
}
|
||||
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) } }
|
||||
val pdf = manager.generatePdf(pages)
|
||||
val pdf = manager.generatePdf(pages, true) {}
|
||||
assertThat(pdf.pageCount).isEqualTo(2)
|
||||
assertThat(pdf.sizeInBytes).isEqualTo(3)
|
||||
assertThat(pdf.file.readBytes()).isEqualTo(byteArrayOf(0x01, 0x02, 0x11))
|
||||
|
||||
@@ -82,8 +82,8 @@ class ImageRepositoryTest {
|
||||
colorMode: ColorMode
|
||||
): Jpeg = process(source, metadata, colorMode)
|
||||
}
|
||||
|
||||
return ImageRepository(getFilesDir(), transformations, testScope)
|
||||
val logger = Logger { _, _, _ -> }
|
||||
return ImageRepository(getFilesDir(), transformations, testScope, logger)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.data
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.Test
|
||||
import java.util.Locale
|
||||
import java.util.Locale.ENGLISH
|
||||
import java.util.Locale.FRENCH
|
||||
import java.util.Locale.GERMAN
|
||||
|
||||
class OcrLanguageTest {
|
||||
|
||||
@Test
|
||||
fun `specific display names`() {
|
||||
assertThat(name("eng", ENGLISH)).isEqualTo("English")
|
||||
assertThat(name("eng", FRENCH)).isEqualTo("Anglais")
|
||||
assertThat(name("fra", ENGLISH)).isEqualTo("French")
|
||||
assertThat(name("chi_sim_vert", ENGLISH)).isEqualTo("Chinese (China, vertical)")
|
||||
assertThat(name("deu_latf", GERMAN)).isEqualTo("Deutsch (Alte deutsche Rechtschreibung)")
|
||||
assertThat(name("aze_cyrl", ENGLISH)).isEqualTo("Azerbaijani (Cyrillic)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all available codes resolve to a display name different from the code`() {
|
||||
val failing = OcrLanguage.AVAILABLE_LANGUAGE_CODES
|
||||
.map { code -> code to name(code, ENGLISH) }
|
||||
.filter { (code, name) -> name == code }
|
||||
.map { (code, _) -> code }
|
||||
assertThat(failing)
|
||||
.describedAs("These codes did not resolve to a proper display name")
|
||||
.isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all available codes produce unique display names`() {
|
||||
val names = OcrLanguage.AVAILABLE_LANGUAGE_CODES
|
||||
.map { code -> name(code, ENGLISH) }
|
||||
assertThat(names).doesNotHaveDuplicates()
|
||||
}
|
||||
|
||||
private fun name(code: String, displayLocale: Locale)
|
||||
= OcrLanguage(code).displayName(displayLocale)
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
package org.fairscan.app.ui.screens.crop
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.fairscan.imageprocessing.Point
|
||||
@@ -44,7 +45,6 @@ class EditPageScreenStateTest {
|
||||
fun initialState_hasCorrectDefaults() {
|
||||
val state = CropScreenState()
|
||||
|
||||
assertThat(state.bitmap).isNull()
|
||||
assertThat(state.containerSize).isNull()
|
||||
assertThat(state.editableQuad).isNull()
|
||||
assertThat(state.draggedCornerIndex).isEqualTo(-1)
|
||||
@@ -189,8 +189,7 @@ class EditPageScreenStateTest {
|
||||
|
||||
@Test
|
||||
fun endDrag_preservesDragPosition() {
|
||||
val state = CropScreenState()
|
||||
state.setInitialQuad(testQuad)
|
||||
val state = CropScreenState(mutableStateOf(testQuad))
|
||||
val pos = Offset(100f, 200f)
|
||||
state.onTouchDown(pos, cornerIndex = 0)
|
||||
state.startCornerDrag(0)
|
||||
@@ -205,8 +204,7 @@ class EditPageScreenStateTest {
|
||||
|
||||
@Test
|
||||
fun endDrag_doesNotResetTouchDownIndices() {
|
||||
val state = CropScreenState()
|
||||
state.setInitialQuad(testQuad)
|
||||
val state = CropScreenState(mutableStateOf(testQuad))
|
||||
state.onTouchDown(Offset(100f, 200f), cornerIndex = 2)
|
||||
state.startCornerDrag(2)
|
||||
|
||||
@@ -294,8 +292,7 @@ class EditPageScreenStateTest {
|
||||
|
||||
@Test
|
||||
fun dragCycle_corner_leavesStateConsistent() {
|
||||
val state = CropScreenState()
|
||||
state.setInitialQuad(testQuad)
|
||||
val state = CropScreenState(mutableStateOf(testQuad))
|
||||
val pos = Offset(100f, 200f)
|
||||
|
||||
state.onTouchDown(pos, cornerIndex = 1)
|
||||
@@ -321,8 +318,7 @@ class EditPageScreenStateTest {
|
||||
fun dragCycle_edge_leavesStateConsistent() {
|
||||
// Edge dragging is no longer supported; this test verifies that a touch
|
||||
// without a valid corner index simply does not trigger a drag.
|
||||
val state = CropScreenState()
|
||||
state.setInitialQuad(testQuad)
|
||||
val state = CropScreenState(mutableStateOf(testQuad))
|
||||
val pos = Offset(150f, 80f)
|
||||
|
||||
state.onTouchDown(pos)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
package org.fairscan.evaluation
|
||||
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
import org.fairscan.imageprocessing.Mode
|
||||
import org.fairscan.imageprocessing.detectDocumentQuad
|
||||
import org.fairscan.imageprocessing.extractDocument
|
||||
import org.fairscan.imageprocessing.autoColorMode
|
||||
@@ -59,7 +60,7 @@ object ColorDetectionEvaluator {
|
||||
|
||||
val mask = MatMask(maskMat)
|
||||
|
||||
val quad = detectDocumentQuad(mask, mat.size().toImageSize(), isLiveAnalysis = false)
|
||||
val quad = detectDocumentQuad(mask, mat.size().toImageSize(), Mode.CAPTURE)
|
||||
?.scaledTo(mask.width, mask.height, mat.width(), mat.height())
|
||||
|
||||
if (quad == null) continue
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
package org.fairscan.evaluation
|
||||
|
||||
import org.fairscan.imageprocessing.Mask
|
||||
import org.fairscan.imageprocessing.Mode
|
||||
import org.fairscan.imageprocessing.autoColorMode
|
||||
import org.fairscan.imageprocessing.detectDocumentQuad
|
||||
import org.fairscan.imageprocessing.extractDocument
|
||||
@@ -70,13 +71,15 @@ object DatasetEvaluator {
|
||||
val mask = MatMask(maskMat)
|
||||
|
||||
val originalSize = inputMat.size().toImageSize()
|
||||
val quad = detectDocumentQuad(mask, originalSize, isLiveAnalysis = false)
|
||||
val quad = detectDocumentQuad(mask, originalSize, Mode.CAPTURE)
|
||||
?.scaledTo(mask.width, mask.height, inputMat.width(), inputMat.height())
|
||||
|
||||
if (quad == null) continue
|
||||
|
||||
val colorMode = autoColorMode(inputMat, mask, quad)
|
||||
val corrected = extractDocument(inputMat, quad = quad, rotationDegrees = 0, colorMode, 2_000_000)
|
||||
val corrected = if (quad == null)
|
||||
inputMat.clone()
|
||||
else {
|
||||
val colorMode = autoColorMode(inputMat, mask, quad)
|
||||
extractDocument(inputMat, quad = quad, rotationDegrees = 0, colorMode, 2_000_000)
|
||||
}
|
||||
|
||||
val inputOut = File(outputDir, "${e.name}_input.jpg")
|
||||
Imgcodecs.imwrite(inputOut.absolutePath, inputMat)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
package org.fairscan.evaluation
|
||||
|
||||
import org.fairscan.imageprocessing.Mode
|
||||
import org.fairscan.imageprocessing.detectDocumentQuad
|
||||
import org.fairscan.imageprocessing.extractDocument
|
||||
import org.fairscan.imageprocessing.autoColorMode
|
||||
@@ -58,7 +59,7 @@ object ExportQualityEvaluator {
|
||||
val mask = MatMask(maskMat)
|
||||
|
||||
val originalSize = sourceMat.size().toImageSize()
|
||||
val quad = detectDocumentQuad(mask, originalSize, isLiveAnalysis = false)
|
||||
val quad = detectDocumentQuad(mask, originalSize, Mode.CAPTURE)
|
||||
?.scaledTo(mask.width, mask.height, sourceMat.width(), sourceMat.height())
|
||||
if (quad == null) {
|
||||
println("Failed to detect quad for $imgName")
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
package org.fairscan.evaluation
|
||||
|
||||
import nu.pattern.OpenCV
|
||||
import org.fairscan.imageprocessing.Mode
|
||||
import org.fairscan.imageprocessing.detectDocumentQuad
|
||||
import org.fairscan.imageprocessing.scaledTo
|
||||
import org.fairscan.imageprocessing.toCv
|
||||
@@ -65,7 +66,7 @@ object QuadDetectionEvaluator {
|
||||
val mask = MatMask(maskMat)
|
||||
|
||||
val originalSize = inputMat.size().toImageSize()
|
||||
val quad = detectDocumentQuad(mask, originalSize, isLiveAnalysis = false)
|
||||
val quad = detectDocumentQuad(mask, originalSize, Mode.CAPTURE)
|
||||
?.scaledTo(mask.width, mask.height, inputMat.width(), inputMat.height())
|
||||
|
||||
val inputOut = File(outputDir, "${e.name}_input.jpg")
|
||||
|
||||
@@ -23,6 +23,7 @@ kotlinSerialization = "1.10.0"
|
||||
reorderable = "3.0.0"
|
||||
jetbrainsKotlinJvm = "2.3.10"
|
||||
coroutines-test = "1.10.2"
|
||||
tesseract4android = "4.9.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -60,7 +61,7 @@ reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reo
|
||||
aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutLibraries" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSerialization" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines-test" }
|
||||
|
||||
tesseract4android = { group = "cz.adaptech.tesseract4android", name = "tesseract4android-openmp", version.ref = "tesseract4android" }
|
||||
|
||||
assertj = { group="org.assertj", name="assertj-core", version.ref = "assertj" }
|
||||
|
||||
|
||||
@@ -33,14 +33,19 @@ interface Mask {
|
||||
fun toMat(): Mat
|
||||
}
|
||||
|
||||
fun detectDocumentQuad(mask: Mask, originalSize: ImageSize, isLiveAnalysis: Boolean): Quad? {
|
||||
enum class Mode {
|
||||
CAPTURE, IMPORT, LIVE_ANALYSIS
|
||||
}
|
||||
|
||||
fun detectDocumentQuad(mask: Mask, originalSize: ImageSize, mode: Mode): Quad? {
|
||||
val mat = mask.toMat()
|
||||
// Best thresholds on test dataset: {0.95=146, 0.85=39, 0.75=35, 0.90=8, 0.70=1, 0.35=1}
|
||||
val thresholds =
|
||||
if (isLiveAnalysis) listOf(0.9) else listOf(0.5, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95)
|
||||
if (mode != Mode.CAPTURE) listOf(0.9) else listOf(0.5, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95)
|
||||
var vertices = findQuadFromOrientationWithAdaptiveThreshold(mat, originalSize, thresholds)
|
||||
?.map { Point(it.x, it.y) }
|
||||
if (vertices == null && !isLiveAnalysis) {
|
||||
|
||||
if (vertices == null && mode == Mode.CAPTURE) {
|
||||
// Fallback: bounding rectangle
|
||||
val biggest = biggestContour(mat)
|
||||
if (biggest != null) {
|
||||
@@ -48,7 +53,10 @@ fun detectDocumentQuad(mask: Mask, originalSize: ImageSize, isLiveAnalysis: Bool
|
||||
vertices = minAreaRect(polygon, mask.width, mask.height)
|
||||
}
|
||||
}
|
||||
return if (vertices?.size == 4) createQuad(vertices) else null
|
||||
val maskSize = ImageSize(mask.width, mask.height)
|
||||
return if (vertices?.size == 4 && vertices.all { isInsideImage(it, maskSize) })
|
||||
createQuad(vertices)
|
||||
else null
|
||||
}
|
||||
|
||||
fun findQuadFromOrientationWithAdaptiveThreshold(
|
||||
@@ -68,7 +76,7 @@ fun findQuadFromOrientationWithAdaptiveThreshold(
|
||||
val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_ELLIPSE, Size(5.0, 5.0))
|
||||
Imgproc.morphologyEx(bin, bin, Imgproc.MORPH_CLOSE, kernel)
|
||||
val quad = findQuadFromOrientation(bin, originalSize)
|
||||
if (quad != null && isValidQuad(quad, originalSize)) {
|
||||
if (quad != null) {
|
||||
val probFloat = Mat()
|
||||
probmap.convertTo(probFloat, CvType.CV_32F)
|
||||
val score = scoreQuadAgainstProbmap(quad, probFloat, minQuadAreaRatio = 0.02)
|
||||
@@ -85,11 +93,9 @@ fun findQuadFromOrientationWithAdaptiveThreshold(
|
||||
return bestQuad
|
||||
}
|
||||
|
||||
fun isValidQuad(quad: List<org.opencv.core.Point>, originalSize: ImageSize): Boolean {
|
||||
return quad.all {
|
||||
it.x >= 0 && it.x <= originalSize.width
|
||||
&& it.y >= 0 && it.y <= originalSize.height
|
||||
}
|
||||
fun isInsideImage(p: Point, imageSize: ImageSize): Boolean {
|
||||
return p.x >= 0 && p.x <= imageSize.width
|
||||
&& p.y >= 0 && p.y <= imageSize.height
|
||||
}
|
||||
|
||||
fun findQuadFromOrientation(maskMat: Mat, originalSize: ImageSize): List<org.opencv.core.Point>? {
|
||||
@@ -99,6 +105,8 @@ fun findQuadFromOrientation(maskMat: Mat, originalSize: ImageSize): List<org.ope
|
||||
val scaleX = originalSize.width / maskMat.size().width
|
||||
val scaleY = originalSize.height / maskMat.size().height
|
||||
|
||||
// The mask may have a different width/height ratio than the original image.
|
||||
// It's crucial for angles that the width/height ratio is the one of the original image.
|
||||
return findQuadFromContourOrientation(
|
||||
contour.toList().map { org.opencv.core.Point(it.x * scaleX, it.y * scaleY) }
|
||||
)?.map { org.opencv.core.Point(it.x / scaleX, it.y / scaleY) }
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.imageprocessing
|
||||
|
||||
data class OcrTextBox(
|
||||
val text: String,
|
||||
val box: ImageRect,
|
||||
val lineHeight: Int,
|
||||
val lineBottom: Int,
|
||||
)
|
||||
|
||||
data class ImageRect(
|
||||
val left: Int,
|
||||
val top: Int,
|
||||
val right: Int, // coordinate of the right side (from the left of the image)
|
||||
val bottom: Int, // coordinate of the bottom side (from the top of the image)
|
||||
) {
|
||||
val width get() = right - left
|
||||
val height get() = bottom - top
|
||||
}
|
||||
|
||||
data class PdfRect(
|
||||
val x: Float, // in points, from left side
|
||||
val y: Float, // in points, from bottom side (PDF convention)
|
||||
val width: Float,
|
||||
val height: Float
|
||||
)
|
||||
|
||||
class OcrCoordinateConverter(
|
||||
private val imageWidth: Int,
|
||||
private val imageHeight: Int,
|
||||
private val pageWidth: Float, // in PDF points
|
||||
private val pageHeight: Float // in PDF points
|
||||
) {
|
||||
fun convert(rect: ImageRect): PdfRect {
|
||||
val scaleX = pageWidth / imageWidth
|
||||
val scaleY = pageHeight / imageHeight
|
||||
|
||||
val x = rect.left * scaleX
|
||||
val y = pageHeight - (rect.bottom * scaleY) // Y axis is inverted
|
||||
val width = rect.width * scaleX
|
||||
val height = rect.height * scaleY
|
||||
|
||||
return PdfRect(x, y, width, height)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ fun findQuadFromContourOrientation(
|
||||
smoothWindow: Int = 5,
|
||||
maxAngleVar: Double = Math.toRadians(5.0),
|
||||
mergeAngle: Double = Math.toRadians(7.0),
|
||||
minSideLengthRatio: Double = 0.02
|
||||
minSideLengthRatio: Double = 0.03
|
||||
): List<Point>? {
|
||||
|
||||
if (contour.size < 20) return null
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.imageprocessing
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class OcrTest {
|
||||
|
||||
@Test
|
||||
fun `top left in image becomes bottom left in PDF`() {
|
||||
val converter = OcrCoordinateConverter(
|
||||
imageWidth = 1000, imageHeight = 2000,
|
||||
pageWidth = 500f, pageHeight = 1000f
|
||||
)
|
||||
val result = converter.convert(ImageRect(0, 0, 100, 200))
|
||||
assertThat(result.x).isEqualTo(0f)
|
||||
assertThat(result.y).isEqualTo(1000f - 100f)
|
||||
assertThat(result.width).isEqualTo(50f)
|
||||
assertThat(result.height).isEqualTo(100f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bottom right in image becomes top right in PDF`() {
|
||||
val converter = OcrCoordinateConverter(
|
||||
imageWidth = 1000, imageHeight = 2000,
|
||||
pageWidth = 500f, pageHeight = 1000f
|
||||
)
|
||||
val result = converter.convert(ImageRect(900, 1800, 1000, 2000))
|
||||
assertThat(result.x).isEqualTo(450f)
|
||||
assertThat(result.y).isEqualTo(0f)
|
||||
assertThat(result.width).isEqualTo(50f)
|
||||
assertThat(result.height).isEqualTo(100f)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Scan documents directly from compatible apps and web forms.
|
||||
@@ -0,0 +1 @@
|
||||
Scan documents directly from compatible apps and web forms.
|
||||
@@ -0,0 +1 @@
|
||||
Scan documents directly from compatible apps and web forms.
|
||||
@@ -0,0 +1 @@
|
||||
Version 2.0 adds text recognition (OCR), allowing FairScan to create searchable PDFs.
|
||||
@@ -0,0 +1 @@
|
||||
Version 2.0 adds text recognition (OCR), allowing FairScan to create searchable PDFs.
|
||||
@@ -0,0 +1 @@
|
||||
Version 2.0 adds text recognition (OCR), allowing FairScan to create searchable PDFs.
|
||||
@@ -0,0 +1,2 @@
|
||||
- On startup, you can now explicitly choose to resume the current scan or start a new one
|
||||
- Volume buttons now trigger page capture
|
||||
@@ -0,0 +1,2 @@
|
||||
- On startup, you can now explicitly choose to resume the current scan or start a new one
|
||||
- Volume buttons now trigger page capture
|
||||
@@ -0,0 +1,2 @@
|
||||
- On startup, you can now explicitly choose to resume the current scan or start a new one
|
||||
- Volume buttons now trigger page capture
|
||||
@@ -0,0 +1 @@
|
||||
New app translations: Hungarian, Estonian, Latvian, and Indonesian.
|
||||
@@ -0,0 +1 @@
|
||||
New app translations: Hungarian, Estonian, Latvian, and Indonesian.
|
||||
@@ -0,0 +1 @@
|
||||
New app translations: Hungarian, Estonian, Latvian, and Indonesian.
|
||||
@@ -7,8 +7,9 @@ With FairScan, it takes only a few seconds to get a clean PDF you can share or s
|
||||
• Automatic crop, perspective correction, and enhancement
|
||||
• Export to shareable PDFs
|
||||
|
||||
FairScan works entirely offline.
|
||||
All document scanning and processing happen locally on your device.
|
||||
|
||||
Minimal permissions required:
|
||||
• Camera
|
||||
• Internet: used only for optional OCR language downloads
|
||||
• On Android 9 and below: storage (to save PDFs to Downloads)
|
||||
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -7,8 +7,9 @@ FairScan — это сканер документов, сделанный для
|
||||
• Автоматическая обрезка, коррекция перспективы и улучшение
|
||||
• Экспорт в PDF-файлы, которыми можно поделиться
|
||||
|
||||
FairScan работает полностью в автономном режиме.
|
||||
Всё сканирование и обработка документов выполняются локально на устройстве.
|
||||
|
||||
Требуются минимальные разрешения:
|
||||
• Камера
|
||||
• Интернет: только для загрузки дополнительных языковых файлов OCR
|
||||
• На Android 9 и ниже: хранилище (для сохранения PDF-файлов в папку «Загрузки»)
|
||||
|
||||
@@ -16,6 +16,7 @@ dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||