Compare commits

..
59 Commits
Author SHA1 Message Date
Pierre-Yves NicolasandGitHub 22973418c5 Prepare release: v2.2.0 (#226)
Android CI / build (push) Canceled after 0s
2026-07-19 17:29:03 +02:00
Pierre-Yves NicolasandGitHub cd78e9bff2 Reduce false positive quads on import (#225)
On import, users have no live feedback, so images are more likely to not contain quad edges.
- Remove quad fallbacks on import
- Adjust minSideLengthRatio for all modes
2026-07-19 16:13:50 +02:00
Pierre-Yves NicolasandGitHub f5de6bb5e1 Remove comments and non-translatable strings in Indonesian translation (#224) 2026-07-14 14:12:56 +02:00
IhfandiandGitHub c4705a24a2 New translation: Indonesian (#223) 2026-07-14 13:52:19 +02:00
Pierre-Yves NicolasandGitHub a6e4b349d7 Mention Weblate in README.md (#222) 2026-07-13 13:43:45 +02:00
Pierre-Yves NicolasandPierre-Yves Nicolas b7787b64f1 chore(l10n): update French translation
Translation: FairScan/Android app
Language: French
Progress: 100.0% (94 of 94 strings)
Translate-URL: https://hosted.weblate.org/projects/fairscan/android-app/fr/
2026-07-06 21:36:29 +02:00
ℂ𝕠𝕠𝕠𝕝 (𝕘𝕚𝕥𝕙𝕦𝕓.𝕔𝕠𝕞/ℂ𝕠𝕠𝕠𝕝)andPierre-Yves Nicolas e99d015675 Add Latvian translation
Translation: FairScan/Android app
Language: Latvian
Progress: 100.0% (94 of 94 strings)
Translate-URL: https://hosted.weblate.org/projects/fairscan/android-app/lv/
2026-07-06 21:23:29 +02:00
Pierre-Yves Nicolas 26428a6a7c Exclude quad when a corner is out of the frame (#216)
There was a bug in the previous validation logic: it checked the quad in the mask against the dimensions of the original image.
2026-07-04 17:37:44 +02:00
Priit JõerüütandPierre-Yves Nicolas 56f46a54cc Add Estonian translation
Translation: FairScan/Android app
Language: Estonian
Progress: 100.0% (94 of 94 strings)
Translate-URL: https://hosted.weblate.org/projects/fairscan/android-app/et/
2026-07-03 11:26:01 +02:00
Pierre-Yves Nicolas 7e7f14214c strings.xml: comments to help translations 2026-07-02 16:30:19 +02:00
Pierre-Yves Nicolas 7559055022 camera_permission_rationale: replace "current document" by "current scan" 2026-07-02 16:30:19 +02:00
Pierre-Yves Nicolas 7034551c57 Single string for "New scan" 2026-07-02 16:30:19 +02:00
Pierre-Yves NicolasandGitHub 306cde9537 strings.xml: provide context for some strings for translations (#211) 2026-07-01 21:05:12 +02:00
Pierre-Yves Nicolas ca8dde33ec Fix lint errors in Hungarian translation 2026-06-29 17:27:39 +02:00
István ElgesandPierre-Yves Nicolas 0b1a2be120 Add Hungarian translation 2026-06-29 17:15:15 +02:00
Pierre-Yves Nicolas 12e8f98252 Prepare release: v2.1.0 2026-06-26 14:49:10 +02:00
Pierre-Yves Nicolas 98380c098a ExportScreen: remove condition on resumed scan
With the new ResumeScanScreen, users don't reach the ExportScreen directly on startup. See #204.
2026-06-26 14:18:55 +02:00
Pierre-Yves NicolasandGitHub 28d1144da3 CropScreen: save quad state across device rotations (#208) 2026-06-26 13:34:55 +02:00
Pierre-Yves NicolasandGitHub 8bc930a235 GetMultipleContents: photo picker with a "Browse" option (#207) 2026-06-25 20:31:56 +02:00
Pierre-Yves NicolasandGitHub e74308d9a4 Trigger capture when a volume button is pressed (#205) 2026-06-25 14:22:13 +02:00
Pierre-Yves Nicolas 522dd5a508 AboutScreen: avoid crash when opening contact link 2026-06-25 06:31:40 +02:00
Pierre-Yves Nicolas 2ecf15bb4c openUri: avoid crash on call to newUri 2026-06-25 05:50:36 +02:00
Pierre-Yves Nicolas 7f5befc581 Adjust message when OCR download fails (#202) 2026-06-24 21:17:04 +02:00
Pierre-Yves NicolasandGitHub 28087c4449 Explicit choice on startup: resume current scan or start new (#204)
It seems that some app concepts are unclear to some users (current scan, scan ≠ page, see #201). This is probably linked to the removal of the Home screen (#170) which also removed the explicit choice on startup when a current scan exists: either resume the current scan or start a new one. The explicit choice at least clarified the concept of current scan. This PR creates a new screen that is displayed only on startup when a current scan exists. When the user wants to start a new scan (probably the most common case), it takes one tap, exactly the same as currently.
2026-06-24 20:46:21 +02:00
Pierre-Yves Nicolas bed27ca653 Prepare release: v2.0.0 2026-06-13 21:36:43 +02:00
Pierre-Yves Nicolas 161bd63297 Mention Tesseract in README.md 2026-06-13 21:36:43 +02:00
Pierre-Yves Nicolas c221b21199 Update screenshots 2026-06-13 21:36:43 +02:00
Pierre-Yves Nicolas d1284f3f77 Update descriptions about internet permission 2026-06-13 21:36:43 +02:00
Pierre-Yves Nicolas 25b73c21c9 Update Android Studio config files 2026-06-13 11:21:24 +02:00
Pierre-Yves Nicolas 5d86bdd7ab Improve handling of corrupted images 2026-06-13 07:47:20 +02:00
Pierre-Yves Nicolas 974ee5f913 openUri: avoid crash on unexpected exception 2026-06-13 07:46:59 +02:00
Pierre-Yves Nicolas 2ab5dcf7d9 Separate screen for OCR languages 2026-06-12 16:25:54 +02:00
Pierre-Yves Nicolas 991ddc8e1d Settings screen: more compact style for radio buttons 2026-06-12 16:25:54 +02:00
Pierre-Yves Nicolas 96c80fc977 Settings screen: adjust style for export folder 2026-06-12 16:25:54 +02:00
Pierre-Yves Nicolas ec4b5cf104 Export: cancel job when leaving the screen 2026-06-11 11:11:58 +02:00
Pierre-Yves Nicolas d3c39b03fa ExportScreen: adjust progress indicator for 1 page 2026-06-11 08:26:46 +02:00
Pierre-Yves Nicolas 0be14e5e8d Disable OCR on external call 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas cf96095938 OCR: handle download failure 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas f3d0e11651 OCR: GlyphLessFont->TesseractGlyphLessFont 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas a319292045 OCR: avoid duplicate resources in PDF 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas 028316147c OCR: fix perf issue with fonts and invalid text stream 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas 6de0e2addf OCR: externalize strings 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas e23ce044f9 OCR: fix PDF generation for non-latin characters 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas 104c60e073 OCR: improve text positioning 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas dfa35c24f2 ExportScreen: display OCR activation 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas cdd44acd2b Restart export on OCR language change 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas b5ed935ad2 ExportScreen: progress indicator 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas 75bcda22cc OCR: progress indicator for download 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas f005eefdeb OCR language management 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas 8d11244374 Initialize segmentation and OCR asynchronously 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas 90413cd97f OCR: basic integration of Tesseract 2026-06-10 19:02:02 +02:00
Philipp HasperandPierre-Yves Nicolas 6ad21eeb59 Fix system gesture triggering when editing quad corners.
On devices without a back button (e.g. Pixel10), dragging corners when
close to the screen borders triggered the system back gesture.

Adding exclusion areas for each of the corner handles fixes this.
It was not possible to exclude the whole image area, as Android seems to
silently limit the area of allowed exclusion.

The mandatory system gestures like the home or quick-switch gesture cannot
be excluded via areas. The only solution here is to introduce a bottom
padding which moves the image view upwards, out of the gesture area.

Both changes are no-ops for devices without system gestures, so no change
there.

Closes #193
2026-06-07 10:54:24 +02:00
Pierre-Yves NicolasandGitHub dd8002009d Update the "Issues" section in CONTRIBUTING.md 2026-06-01 07:58:55 +02:00
Pierre-Yves NicolasandGitHub 8ba049eed5 Update the "Issues" section in CONTRIBUTING.md 2026-05-31 22:17:09 +02:00
Pierre-Yves Nicolas afb7e388a6 Prepare release: v1.23.0 2026-05-31 06:56:07 +02:00
Pierre-Yves NicolasandGitHub e4cf65aaf9 Human-friendly file name when called through an intent (#188) 2026-05-31 06:18:54 +02:00
Pierre-Yves Nicolas b9a9255a5c More detailed exception when resizeToThumbnail fails (#185) 2026-05-30 21:52:33 +02:00
Pierre-Yves Nicolas eaaf5b01a2 openUri: avoid crash on SecurityException 2026-05-30 19:12:21 +02:00
Pierre-Yves NicolasandGitHub a38909fb52 Make FairScan callable through the GET_CONTENT intent (#187) 2026-05-30 15:52:13 +02:00
86 changed files with 2834 additions and 292 deletions
+7
View File
@@ -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
View File
@@ -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">
+8 -2
View File
@@ -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
+13 -1
View File
@@ -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.
[![Translation status](https://hosted.weblate.org/widget/fairscan/android-app/language-badge.svg?threshold=95)](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
---
+3 -2
View File
@@ -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
View File
@@ -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"
Binary file not shown.
@@ -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()
}
}
}
+18 -2
View File
@@ -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>
+18 -2
View File
@@ -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>
+18 -2
View File
@@ -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>
+18 -2
View File
@@ -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>
+103
View File
@@ -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>
+20 -3
View File
@@ -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">Lapplication a besoin daccéder à lappareil 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">Lapplication a besoin daccéder à lappareil 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 lexport…</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 denregistrer 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>
+18 -2
View File
@@ -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>
+102
View File
@@ -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>
+102
View File
@@ -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>
+18 -2
View File
@@ -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 dellesportazione…</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 lultima 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>
+18 -2
View File
@@ -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>
+106
View File
@@ -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"></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>
+18 -2
View File
@@ -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>
+18 -2
View File
@@ -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>
+18 -2
View File
@@ -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>
+18 -2
View File
@@ -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>
+18 -2
View File
@@ -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>
+18 -2
View File
@@ -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>
+115 -2
View File
@@ -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")
+2 -1
View File
@@ -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)
}
}
+1
View File
@@ -0,0 +1 @@
Scan documents directly from compatible apps and web forms.
+1
View File
@@ -0,0 +1 @@
Scan documents directly from compatible apps and web forms.
+1
View File
@@ -0,0 +1 @@
Scan documents directly from compatible apps and web forms.
+1
View File
@@ -0,0 +1 @@
Version 2.0 adds text recognition (OCR), allowing FairScan to create searchable PDFs.
+1
View File
@@ -0,0 +1 @@
Version 2.0 adds text recognition (OCR), allowing FairScan to create searchable PDFs.
+1
View File
@@ -0,0 +1 @@
Version 2.0 adds text recognition (OCR), allowing FairScan to create searchable PDFs.
+2
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
New app translations: Hungarian, Estonian, Latvian, and Indonesian.
+1
View File
@@ -0,0 +1 @@
New app translations: Hungarian, Estonian, Latvian, and Indonesian.
+1
View File
@@ -0,0 +1 @@
New app translations: Hungarian, Estonian, Latvian, and Indonesian.
+2 -1
View File
@@ -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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+2 -1
View File
@@ -7,8 +7,9 @@ FairScan — это сканер документов, сделанный для
• Автоматическая обрезка, коррекция перспективы и улучшение
• Экспорт в PDF-файлы, которыми можно поделиться
FairScan работает полностью в автономном режиме.
Всё сканирование и обработка документов выполняются локально на устройстве.
Требуются минимальные разрешения:
• Камера
• Интернет: только для загрузки дополнительных языковых файлов OCR
• На Android 9 и ниже: хранилище (для сохранения PDF-файлов в папку «Загрузки»)
+1
View File
@@ -16,6 +16,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}