From d64311e2a98de2ada74d1a3331146f9ffd4f9175 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Sat, 23 May 2026 12:22:37 +0200 Subject: [PATCH 1/2] Use focus distance to estimate physical size --- .../org/fairscan/app/data/DocumentMetadata.kt | 4 +- .../java/org/fairscan/app/data/FileManager.kt | 8 +-- .../org/fairscan/app/data/ImageRepository.kt | 18 ++++- .../fairscan/app/domain/ExportPreparation.kt | 39 +++++++++-- .../main/java/org/fairscan/app/domain/Page.kt | 6 +- .../fairscan/app/platform/AndroidPdfWriter.kt | 29 ++++---- .../fairscan/app/platform/ImageProcessor.kt | 13 ++-- .../app/ui/screens/camera/CameraPreview.kt | 50 ++++++++++++-- .../app/ui/screens/camera/CameraScreen.kt | 6 +- .../app/ui/screens/camera/CameraViewModel.kt | 9 +-- .../app/ui/screens/export/ExportViewModel.kt | 12 ++-- .../org/fairscan/app/data/FileManagerTest.kt | 13 ++-- .../fairscan/app/data/ImageRepositoryTest.kt | 8 ++- gradle/libs.versions.toml | 2 +- .../imageprocessing/DocumentDetection.kt | 19 +++++- .../fairscan/imageprocessing/Perspective.kt | 66 ++++++++++++++----- 16 files changed, 221 insertions(+), 81 deletions(-) diff --git a/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt b/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt index c3c0fca..2fe17aa 100644 --- a/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt +++ b/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt @@ -15,7 +15,6 @@ package org.fairscan.app.data import kotlinx.serialization.Serializable -import org.fairscan.imageprocessing.CameraIntrinsics import org.fairscan.imageprocessing.ColorMode @Serializable @@ -47,6 +46,9 @@ data class PageV2( val colorMode: ColorMode? = null, val focalLength: Float? = null, val sensorWidth: Float? = null, + val subjectDistance: Float? = null, + val sourceWidth: Int? = null, + val sourceHeight: Int? = null, ) @Serializable diff --git a/app/src/main/java/org/fairscan/app/data/FileManager.kt b/app/src/main/java/org/fairscan/app/data/FileManager.kt index 9f5405f..7a78286 100644 --- a/app/src/main/java/org/fairscan/app/data/FileManager.kt +++ b/app/src/main/java/org/fairscan/app/data/FileManager.kt @@ -14,7 +14,7 @@ */ package org.fairscan.app.data -import org.fairscan.app.domain.JpegProvider +import org.fairscan.app.domain.PageToExport import java.io.File import java.io.FileOutputStream import java.io.OutputStream @@ -26,7 +26,7 @@ data class GeneratedPdf( ) fun interface PdfWriter { - suspend fun writePdfFromJpegs(jpegs: List, outputStream: OutputStream): Int + suspend fun writePdfFromJpegs(pages: List, outputStream: OutputStream): Int } class FileManager( @@ -43,12 +43,12 @@ class FileManager( } } - suspend fun generatePdf(jpegs: List): GeneratedPdf { + suspend fun generatePdf(pages: List): 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(jpegs, it) + pdfWriter.writePdfFromJpegs(pages, it) } val sizeBytes = file.length() return GeneratedPdf(file, sizeBytes, pageCount) diff --git a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt index 86dbf8b..8f5f938 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt @@ -33,6 +33,8 @@ import org.fairscan.app.domain.PageViewKey import org.fairscan.app.domain.Rotation import org.fairscan.app.domain.ScanPage import org.fairscan.imageprocessing.ColorMode +import org.fairscan.imageprocessing.ImageSize +import org.fairscan.imageprocessing.OpticalMeasures import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.cameraIntrinsics @@ -154,8 +156,11 @@ class ImageRepository( manualRotationDegrees = Rotation.R0.degrees, isColored = metadata.autoColorMode == ColorMode.COLOR, colorMode = colorMode, - focalLength = metadata.cameraIntrinsics?.focalLength, - sensorWidth = metadata.cameraIntrinsics?.sensorWidth, + focalLength = metadata.opticalMeasures?.cameraIntrinsics?.focalLength, + sensorWidth = metadata.opticalMeasures?.cameraIntrinsics?.sensorWidth, + subjectDistance = metadata.opticalMeasures?.subjectDistance, + sourceWidth = metadata.sourceSize?.width?.toInt(), + sourceHeight = metadata.sourceSize?.height?.toInt(), ) ) saveMetadata() @@ -402,10 +407,17 @@ fun NormalizedQuad.toQuad(): Quad = fun PageV2.toMetadata(): PageMetadata? { if (quad == null || isColored == null) return null + val cameraIntrinsics = cameraIntrinsics(focalLength, sensorWidth) + val sourceSize = + if (sourceWidth != null && sourceHeight != null) + ImageSize(sourceWidth, sourceHeight) + else + null return PageMetadata( (userQuad ?: quad).toQuad(), Rotation.fromDegrees(baseRotationDegrees), if (isColored) ColorMode.COLOR else ColorMode.GRAYSCALE, - cameraIntrinsics(focalLength, sensorWidth) + sourceSize, + cameraIntrinsics?.let { OpticalMeasures(it, subjectDistance) }, ) } diff --git a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt index bb22b58..29eedc1 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt @@ -16,26 +16,55 @@ package org.fairscan.app.domain import org.fairscan.app.data.ImageRepository import org.fairscan.app.platform.processedImage +import org.fairscan.imageprocessing.EstimatedDimensions +import org.fairscan.imageprocessing.estimateRealDimensions import org.fairscan.imageprocessing.resizeForMaxPixels +import org.fairscan.imageprocessing.scaledTo import org.opencv.core.Mat fun interface JpegProvider { suspend fun get(): Jpeg } -suspend fun jpegsForExport( +data class PageToExport( + val metadata: PageMetadata?, + val jpeg: JpegProvider, +) { + fun estimatedDimensions(): EstimatedDimensions? { + if (metadata == null) + return null + val size = metadata.sourceSize + if (size == null) + return null + val quad = metadata.normalizedQuad.scaledTo(1.0, 1.0, size.width, size.height) + val realDimensions = estimateRealDimensions( + quad, size.width.toInt(), size.height.toInt(), metadata.opticalMeasures + ) + return realDimensions.applyRotation(metadata.baseRotation) + } +} + +private fun EstimatedDimensions.applyRotation(rotation: Rotation): EstimatedDimensions { + if ((rotation == Rotation.R90 || rotation == Rotation.R270) + && this is EstimatedDimensions.Physical) { + return EstimatedDimensions.Physical(heightMm, widthMm) + } + return this +} + +suspend fun pagesToExport( imageRepository: ImageRepository, exportQuality: ExportQuality -): List { +): List { val pages = imageRepository.pages() return when (exportQuality) { ExportQuality.BALANCED -> pages.map { - JpegProvider { jpeg(it, imageRepository) } + PageToExport(it.metadata) { jpeg(it, imageRepository) } } ExportQuality.LOW -> pages.map { page -> - JpegProvider { + PageToExport(page.metadata) { resizeJpegBytesForMaxPixels( jpeg = jpeg(page, imageRepository), maxPixels = exportQuality.maxPixels.toDouble(), @@ -45,7 +74,7 @@ suspend fun jpegsForExport( } ExportQuality.HIGH -> pages.map { page -> - JpegProvider { + PageToExport(page.metadata) { val source = imageRepository.source(page.id) val metadata = page.metadata val colorMode = page.colorMode diff --git a/app/src/main/java/org/fairscan/app/domain/Page.kt b/app/src/main/java/org/fairscan/app/domain/Page.kt index 09dfa13..254cad1 100644 --- a/app/src/main/java/org/fairscan/app/domain/Page.kt +++ b/app/src/main/java/org/fairscan/app/domain/Page.kt @@ -14,15 +14,17 @@ */ package org.fairscan.app.domain -import org.fairscan.imageprocessing.CameraIntrinsics import org.fairscan.imageprocessing.ColorMode +import org.fairscan.imageprocessing.ImageSize +import org.fairscan.imageprocessing.OpticalMeasures import org.fairscan.imageprocessing.Quad data class PageMetadata( val normalizedQuad: Quad, val baseRotation: Rotation, val autoColorMode: ColorMode, - val cameraIntrinsics: CameraIntrinsics?, + val sourceSize: ImageSize?, + val opticalMeasures: OpticalMeasures?, ) data class ScanPage( diff --git a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt index eaed24c..57fc97d 100644 --- a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt +++ b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt @@ -22,33 +22,38 @@ import com.tom_roush.pdfbox.pdmodel.common.PDRectangle 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.JpegProvider +import org.fairscan.app.domain.PageToExport +import org.fairscan.imageprocessing.EstimatedDimensions import java.io.OutputStream import java.util.Calendar class AndroidPdfWriter : PdfWriter { - override suspend fun writePdfFromJpegs(jpegs: List, outputStream: OutputStream): Int { + override suspend fun writePdfFromJpegs(pages: List, outputStream: OutputStream): Int { val doc = PDDocument() doc.documentInformation.creationDate = Calendar.getInstance() doc.documentInformation.creator = "FairScan ${BuildConfig.VERSION_NAME}" doc.use { document -> - for (jpegBytes in jpegs) { - val image = JPEGFactory.createFromByteArray(document, jpegBytes.get().bytes) + for (page in pages) { + val image = JPEGFactory.createFromByteArray(document, page.jpeg.get().bytes) - // Let's say that the physical dimensions of the page are close to US Letter - // US Letter: 215.9×279.4 mm (A4: 210×297 mm) - val maxDimInMm = 279.4f // PDF has 72 points (units) per inch, 1 inch = 25.4 mm val pointsPerMm = 72f / 25.4f val widthPx = image.width.toFloat() val heightPx = image.height.toFloat() - val maxPx = maxOf(widthPx, heightPx) - val scalePxToMm = maxDimInMm / maxPx - - val widthPoints = widthPx * scalePxToMm * pointsPerMm - val heightPoints = heightPx * scalePxToMm * pointsPerMm + val dimensions = page.estimatedDimensions() + val (widthPoints, heightPoints) = when (dimensions) { + is EstimatedDimensions.Physical -> { + dimensions.widthMm.toFloat() * pointsPerMm to dimensions.heightMm.toFloat() * pointsPerMm + } + else -> { + // No physical dimensions available: approximate using US Letter max dimension + val maxDimInMm = 279.4f + val scalePxToMm = maxDimInMm / maxOf(widthPx, heightPx) + widthPx * scalePxToMm * pointsPerMm to heightPx * scalePxToMm * pointsPerMm + } + } val page = PDPage(PDRectangle(widthPoints, heightPoints)) document.addPage(page) diff --git a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt index d8fd602..edbf8b2 100644 --- a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt +++ b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt @@ -25,9 +25,10 @@ import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.Rotation import org.fairscan.app.ui.screens.settings.DefaultColorMode -import org.fairscan.imageprocessing.CameraIntrinsics import org.fairscan.imageprocessing.ColorMode +import org.fairscan.imageprocessing.ImageSize import org.fairscan.imageprocessing.Mask +import org.fairscan.imageprocessing.OpticalMeasures import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.autoColorMode @@ -102,7 +103,7 @@ fun processedImage( sourceMat = source.toMat() val quad = metadata.normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height()) page = extractDocument(sourceMat, quad, rotationDegrees, colorMode, exportQuality.maxPixels, - metadata.cameraIntrinsics) + metadata.opticalMeasures) return Jpeg.fromMat(page, exportQuality.jpegQuality) } finally { sourceMat?.release() @@ -117,7 +118,7 @@ fun extractDocumentFromBitmap( mask: Mask?, viewModelScope: CoroutineScope, defaultColorMode: DefaultColorMode = DefaultColorMode.AUTO, - cameraIntrinsics: CameraIntrinsics?, + opticalMeasures: OpticalMeasures?, ): CapturedPage { val exportQuality = ExportQuality.BALANCED var colorMode = ColorMode.COLOR @@ -144,7 +145,7 @@ fun extractDocumentFromBitmap( autoColorMode = autoColorMode(bgr, mask, quad) colorMode = defaultColorMode.colorMode ?: autoColorMode page = extractDocument(bgr, quad, rotationDegrees, colorMode, exportQuality.maxPixels, - cameraIntrinsics) + opticalMeasures) } val pageJpeg = Jpeg.fromMat(page, exportQuality.jpegQuality) @@ -152,7 +153,9 @@ fun extractDocumentFromBitmap( page.release() val baseRotation = Rotation.fromDegrees(rotationDegrees) - val metadata = PageMetadata(normalizedQuad, baseRotation, autoColorMode, cameraIntrinsics) + val sourceSize = ImageSize(source.width, source.height) + val metadata = + PageMetadata(normalizedQuad, baseRotation, autoColorMode, sourceSize, opticalMeasures) val sourceJpegDeferred = viewModelScope.async(Dispatchers.IO) { compressSource(source) } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraPreview.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraPreview.kt index 4d96547..79eb177 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraPreview.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraPreview.kt @@ -15,12 +15,18 @@ package org.fairscan.app.ui.screens.camera import android.graphics.Bitmap +import android.hardware.camera2.CameraCaptureSession +import android.hardware.camera2.CameraMetadata +import android.hardware.camera2.CaptureRequest +import android.hardware.camera2.CaptureResult +import android.hardware.camera2.TotalCaptureResult import android.util.Log import android.util.Size import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.LinearLayout import androidx.annotation.OptIn import androidx.camera.camera2.interop.Camera2CameraInfo +import androidx.camera.camera2.interop.Camera2Interop import androidx.camera.camera2.interop.ExperimentalCamera2Interop import androidx.camera.core.CameraControl import androidx.camera.core.CameraSelector @@ -69,6 +75,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner import org.fairscan.app.ui.components.CameraPermissionState import org.fairscan.imageprocessing.CameraIntrinsics +import org.fairscan.imageprocessing.OpticalMeasures import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.cameraIntrinsics @@ -193,7 +200,7 @@ fun bindCameraUseCases( .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888).build() imageAnalysis.setAnalyzer(executor, onImageAnalyzed) - val imageCapture = ImageCapture.Builder() + val imageCaptureBuilder = ImageCapture.Builder() .setResolutionSelector( ResolutionSelector.Builder() .setResolutionStrategy( @@ -208,7 +215,21 @@ fun bindCameraUseCases( .build() ) .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) - .build() + + Camera2Interop.Extender(imageCaptureBuilder) + .setSessionCaptureCallback(object : CameraCaptureSession.CaptureCallback() { + override fun onCaptureCompleted( + session: CameraCaptureSession, + request: CaptureRequest, + result: TotalCaptureResult + ) { + result.get(CaptureResult.LENS_FOCUS_DISTANCE)?.let { + captureController.lastFocusDistanceDiopters = it + } + } + }) + + val imageCapture = imageCaptureBuilder.build() captureController.imageCapture = imageCapture val camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, @@ -296,21 +317,34 @@ class CameraCaptureController { private val executor = Executors.newSingleThreadExecutor() var previewView: PreviewView? = null var cameraIntrinsics: CameraIntrinsics? = null + var canUseFocusDistance = false + + @Volatile + var lastFocusDistanceDiopters: Float? = null fun shutdown() { executor.shutdown() } - fun takePicture(onImageCaptured: (ImageProxy?, CameraIntrinsics?) -> Unit) { + fun takePicture(onImageCaptured: (ImageProxy?, OpticalMeasures?) -> Unit) { imageCapture?.takePicture( executor, object : ImageCapture.OnImageCapturedCallback() { override fun onCaptureSuccess(imageProxy: ImageProxy) { - onImageCaptured(imageProxy, cameraIntrinsics) + val diopters = lastFocusDistanceDiopters + val subjectDistanceInMm = + if (canUseFocusDistance && diopters != null && diopters != 0.0f) { + 1000 / diopters + } else { + null + } + onImageCaptured( + imageProxy, + cameraIntrinsics?.let { OpticalMeasures(it, subjectDistanceInMm) }) } override fun onError(exception: ImageCaptureException) { Log.e("CameraCapture", "Image capture failed: ${exception.message}", exception) - onImageCaptured(null, cameraIntrinsics) + onImageCaptured(null, null) } } ) @@ -344,6 +378,12 @@ class CameraCaptureController { } else { cameraIntrinsics(focalLengths[0], max(sensorSize.width, sensorSize.height)) } + val calibration = cameraInfo.getCameraCharacteristic( + android.hardware.camera2.CameraCharacteristics.LENS_INFO_FOCUS_DISTANCE_CALIBRATION + ) + canUseFocusDistance = + calibration == CameraMetadata.LENS_INFO_FOCUS_DISTANCE_CALIBRATION_CALIBRATED + || calibration == CameraMetadata.LENS_INFO_FOCUS_DISTANCE_CALIBRATION_APPROXIMATE } } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt index a409d79..873bfd5 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt @@ -229,8 +229,8 @@ fun CameraScreen( Log.i("FairScan", "Pressed ") cameraViewModel.onCapturePressed(it) captureController.takePicture( - onImageCaptured = { imageProxy, cameraCharacteristics -> - cameraViewModel.onImageCaptured(imageProxy, cameraCharacteristics) } + onImageCaptured = { imageProxy, opticalMeasures -> + cameraViewModel.onImageCaptured(imageProxy, opticalMeasures) } ) } }, @@ -648,7 +648,7 @@ fun CameraScreenPreviewWithProcessedImage() { CapturedPage( debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"), CompletableDeferred(Jpeg(ByteArray(0))), - PageMetadata(quad, R0, ColorMode.COLOR, null), + PageMetadata(quad, R0, ColorMode.COLOR, null, null), ColorMode.COLOR))) } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt index 040d555..45e9424 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt @@ -36,6 +36,7 @@ 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.OpticalMeasures import org.fairscan.imageprocessing.detectDocumentQuad import java.util.concurrent.CancellationException @@ -134,13 +135,13 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() { } } - fun onImageCaptured(imageProxy: ImageProxy?, cameraIntrinsics: CameraIntrinsics?) { + fun onImageCaptured(imageProxy: ImageProxy?, opticalMeasures: OpticalMeasures?) { if (imageProxy != null) { viewModelScope.launch { try { val source = imageProxy.toBitmap() val rotationDegrees = imageProxy.imageInfo.rotationDegrees - val page = processCapturedImage(source, rotationDegrees, cameraIntrinsics) + val page = processCapturedImage(source, rotationDegrees, opticalMeasures) imageProxy.close() onCaptureProcessed(page) } catch (e: RuntimeException) { @@ -156,7 +157,7 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() { private suspend fun processCapturedImage( source: Bitmap, rotationDegrees: Int, - cameraIntrinsics: CameraIntrinsics?, + opticalMeasures: OpticalMeasures?, ): CapturedPage = withContext(Dispatchers.IO) { val segmentation = imageSegmentationService.runSegmentationAndReturn(source) val mask = segmentation?.segmentation @@ -164,7 +165,7 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() { val quad = mask?.let { detectDocumentQuad(mask, originalSize, isLiveAnalysis = false) } val defaultColorMode = settingsRepository.defaultColorMode.first() val result = extractDocumentFromBitmap( - source, quad, rotationDegrees, mask, viewModelScope, defaultColorMode, cameraIntrinsics) + source, quad, rotationDegrees, mask, viewModelScope, defaultColorMode, opticalMeasures) return@withContext result } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt index 5c3bb97..5bba0ea 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt @@ -46,7 +46,7 @@ import org.fairscan.app.data.FileManager import org.fairscan.app.data.ImageRepository import org.fairscan.app.domain.ExportQuality import org.fairscan.app.domain.PageViewKey -import org.fairscan.app.domain.jpegsForExport +import org.fairscan.app.domain.pagesToExport import org.fairscan.app.ui.screens.settings.ExportFormat import java.io.File import java.io.FileInputStream @@ -76,8 +76,8 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit private suspend fun generatePdf( exportQuality: ExportQuality ): ExportResult.Pdf = withContext(Dispatchers.IO) { - val jpegs = jpegsForExport(imageRepository, exportQuality) - val pdf = fileManager.generatePdf(jpegs) + val pageToExports = pagesToExport(imageRepository, exportQuality) + val pdf = fileManager.generatePdf(pageToExports) return@withContext ExportResult.Pdf(pdf.file, pdf.sizeInBytes, pdf.pageCount) } @@ -181,12 +181,12 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit private suspend fun generateJpegs( exportQuality: ExportQuality ): ExportResult.Jpeg = withContext(Dispatchers.IO) { - val jpegs = jpegsForExport(imageRepository, exportQuality) + val pageToExports = pagesToExport(imageRepository, exportQuality) val timestamp = System.currentTimeMillis() preparationDir.mkdirs() - val files = jpegs.mapIndexed { index, jpeg -> + val files = pageToExports.mapIndexed { index, page -> val file = File(preparationDir, "$timestamp-${index + 1}.jpg") - file.writeBytes(jpeg.get().bytes) + file.writeBytes(page.jpeg.get().bytes) file }.toList() val sizeInBytes = files.sumOf { it.length() } diff --git a/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt b/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt index 516589e..62b961d 100644 --- a/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt +++ b/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt @@ -17,7 +17,7 @@ package org.fairscan.app.data import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.fairscan.app.domain.Jpeg -import org.fairscan.app.domain.JpegProvider +import org.fairscan.app.domain.PageToExport import org.junit.Test import java.io.File import java.io.OutputStream @@ -73,15 +73,16 @@ class FileManagerTest { @Test fun generatePdf() = runTest { val fakePdfWriter = object : PdfWriter { - override suspend fun writePdfFromJpegs(jpegs: List, outputStream: OutputStream): Int { - val list = jpegs.toList() - list.forEach { bytes -> outputStream.write(bytes.get().bytes) } + override suspend fun writePdfFromJpegs(pages: List, outputStream: OutputStream): Int { + val list = pages.toList() + list.forEach { page -> outputStream.write(page.jpeg.get().bytes) } return list.size } } val manager = FileManager(pdfDir, externalDir, fakePdfWriter) - val jpegs = listOf(byteArrayOf(0x01, 0x02), byteArrayOf(0x11)).map { JpegProvider { Jpeg(it) } } - val pdf = manager.generatePdf(jpegs) + val pages = listOf(byteArrayOf(0x01, 0x02), byteArrayOf(0x11)) + .map { PageToExport(null) { Jpeg(it) } } + val pdf = manager.generatePdf(pages) assertThat(pdf.pageCount).isEqualTo(2) assertThat(pdf.sizeInBytes).isEqualTo(3) assertThat(pdf.file.readBytes()).isEqualTo(byteArrayOf(0x01, 0x02, 0x11)) diff --git a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt index 88067a9..48b2c9d 100644 --- a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt +++ b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt @@ -26,7 +26,6 @@ import org.assertj.core.api.Assertions.assertThat import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageViewKey -import org.fairscan.app.domain.Rotation import org.fairscan.app.domain.Rotation.R0 import org.fairscan.app.domain.Rotation.R180 import org.fairscan.app.domain.Rotation.R270 @@ -35,6 +34,8 @@ import org.fairscan.imageprocessing.CameraIntrinsics import org.fairscan.imageprocessing.ColorMode import org.fairscan.imageprocessing.ColorMode.COLOR import org.fairscan.imageprocessing.ColorMode.GRAYSCALE +import org.fairscan.imageprocessing.ImageSize +import org.fairscan.imageprocessing.OpticalMeasures import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Quad import org.junit.Rule @@ -52,8 +53,9 @@ class ImageRepositoryTest { private val testScope = TestScope() val quad1 = Quad(Point(.01, .02), Point(.1, .03), Point(.11, .12), Point(.03, .09)) - val intrinsics = CameraIntrinsics(42.0f, 43.0f) - val metadata1 = PageMetadata(quad1, R90, COLOR, intrinsics) + val opticalMeasures = OpticalMeasures(CameraIntrinsics(42.0f, 43.0f), 44.0f) + val sourceSize = ImageSize(1600, 1200) + val metadata1 = PageMetadata(quad1, R90, COLOR, sourceSize, opticalMeasures) fun getFilesDir(): File { if (_filesDir == null) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 00f16d8..3a27a38 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" composeBom = "2026.03.00" -camerax = "1.5.3" +camerax = "1.6.1" datastore = "1.2.1" documentfile = "1.1.0" litert = "1.4.1" diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt index 9698b3d..f25f27b 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt @@ -25,6 +25,7 @@ import org.opencv.core.MatOfPoint2f import org.opencv.core.Size import org.opencv.imgproc.Imgproc import kotlin.math.abs +import kotlin.math.sqrt interface Mask { val width: Int @@ -156,14 +157,15 @@ fun extractDocument( rotationDegrees: Int, colorMode: ColorMode, maxPixels: Long, - cameraIntrinsics: CameraIntrinsics? = null, + opticalMeasures: OpticalMeasures? = null, ): Mat { - val (targetWidth, targetHeight) = estimateRealDimensions( + val estimatedDimensions = estimateRealDimensions( quad, inputMat.cols(), inputMat.rows(), - cameraIntrinsics + opticalMeasures, ) + val (targetWidth, targetHeight) = estimatedDimensions.toPixelDimensions(quad) val srcPoints = MatOfPoint2f( quad.topLeft.toCv(), quad.topRight.toCv(), @@ -193,6 +195,17 @@ fun extractDocument( return rotated } +fun EstimatedDimensions.toPixelDimensions(quad: Quad): Pair { + val w = (norm(quad.topLeft, quad.topRight) + norm(quad.bottomLeft, quad.bottomRight)) / 2 + val h = (norm(quad.topLeft, quad.bottomLeft) + norm(quad.topRight, quad.bottomRight)) / 2 + val projectedArea = w * h + + val ratio = aspectRatio + val targetWidth = sqrt(projectedArea / ratio) + val targetHeight = targetWidth * ratio + return Pair(targetWidth, targetHeight) +} + fun rotate(input: Mat, degrees: Int): Mat { val output = Mat() when ((degrees % 360 + 360) % 360) { diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Perspective.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Perspective.kt index 3f3955e..9acd278 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Perspective.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Perspective.kt @@ -47,6 +47,24 @@ fun cameraIntrinsics(focalLengthInMm: Float?, sensorWidthInMm: Float?): CameraIn return CameraIntrinsics(focalLengthInMm, sensorWidthInMm) } +data class OpticalMeasures( + val cameraIntrinsics: CameraIntrinsics, + // in millimeters + val subjectDistance: Float?, +) + +sealed class EstimatedDimensions { + // Dimensions in mm, when subject distance is available + data class Physical(val widthMm: Double, val heightMm: Double) : EstimatedDimensions() + // Dimensions in arbitrary units, only ratio is meaningful + data class Ratio(val width: Double, val height: Double) : EstimatedDimensions() + + val aspectRatio: Double get() = when (this) { + is Physical -> heightMm / widthMm + is Ratio -> height / width + } +} + /** * Estimates the true width and height of the document in the output image, * correcting for perspective distortion using projective geometry. @@ -63,13 +81,13 @@ fun estimateRealDimensions( quad: Quad, imageWidth: Int, imageHeight: Int, - cameraIntrinsics: CameraIntrinsics? -): Pair { + opticalMeasures: OpticalMeasures?, +): EstimatedDimensions { - fun averageSides(): Pair { + fun averageSides(): EstimatedDimensions.Ratio { val w = (norm(quad.topLeft, quad.topRight) + norm(quad.bottomLeft, quad.bottomRight)) / 2 val h = (norm(quad.topLeft, quad.bottomLeft) + norm(quad.topRight, quad.bottomRight)) / 2 - return Pair(w, h) + return EstimatedDimensions.Ratio(w, h) } // Homogeneous 2D point @@ -97,8 +115,9 @@ fun estimateRealDimensions( val v1 = Point(v1h.x / v1h.z - cx, v1h.y / v1h.z - cy) val v2 = Point(v2h.x / v2h.z - cx, v2h.y / v2h.z - cy) - val f = if (cameraIntrinsics != null) { - cameraIntrinsics.focalLengthInPixels(max(imageWidth, imageHeight)).toDouble() + val f = if (opticalMeasures != null) { + opticalMeasures.cameraIntrinsics + .focalLengthInPixels(max(imageWidth, imageHeight)).toDouble() } else { // Focal length estimated assuming zero skew and principal point at image center. // Under these assumptions, the Image of the Absolute Conic (IAC) simplifies, @@ -131,26 +150,37 @@ fun estimateRealDimensions( // Camera ray through a corner: K⁻¹ · (u, v, 1) fun ray(p: Point) = Vector3D((p.x - cx) / f, (p.y - cy) / f, 1.0) - // Intersect ray with document plane: X = t·r where t = 1 / (n·r) - // We assume an arbitrary plane distance (d = 1). Absolute scale is wrong, - // but cancels out when computing length ratios. + // Scale factor: either from subject distance, or arbitrary (ratio only) + val subjectDistance = opticalMeasures?.subjectDistance?.toDouble() + val scale: Double? = if (subjectDistance != null) { + // Project subject distance onto the plane normal to get perpendicular distance + val centerX = (quad.topLeft.x + quad.topRight.x + quad.bottomLeft.x + quad.bottomRight.x) / 4.0 + val centerY = (quad.topLeft.y + quad.topRight.y + quad.bottomLeft.y + quad.bottomRight.y) / 4.0 + val centerRay = ray(Point(centerX, centerY)).let { it * (1.0 / it.norm()) } + val cosAngle = centerRay.dotProduct(n).absoluteValue + if (cosAngle < 0.1) null // document too tilted, unreliable + else subjectDistance * cosAngle + } else null + + // Intersect ray with document plane: X = t·r where t = d / (n·r) + // When subjectDistance is unavailable, we assume an arbitrary plane distance (d = 1): absolute + // scale is wrong, but cancels out when computing length ratios. fun corner3D(p: Point): Vector3D { val r = ray(p) - return r * (1.0 / n.dotProduct(r)) + val t = if (scale != null) scale / n.dotProduct(r) else 1.0 / n.dotProduct(r) + return r * t } val xTL = corner3D(quad.topLeft); val xTR = corner3D(quad.topRight) val xBR = corner3D(quad.bottomRight); val xBL = corner3D(quad.bottomLeft) - // Side lengths in reconstructed 3D space (up to an unknown global scale) + // Side lengths in reconstructed 3D space val realW = ((xTR - xTL).norm() + (xBR - xBL).norm()) / 2 val realH = ((xBL - xTL).norm() + (xBR - xTR).norm()) / 2 - // Output dimensions: preserve projected area, apply corrected aspect ratio - val ratio = realH / realW - val (projW, projH) = averageSides() - val targetWidth = sqrt(projW * projH / ratio) - val targetHeight = targetWidth * ratio - - return Pair(targetWidth, targetHeight) + return if (opticalMeasures != null && scale != null) { + EstimatedDimensions.Physical(realW, realH) + } else { + EstimatedDimensions.Ratio(realW, realH) + } } From e1dea3eafa639bd976a47cf8ef2d02e59a7793d9 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Sat, 23 May 2026 20:10:51 +0200 Subject: [PATCH 2/2] Snap to standard formats: A4, Letter... --- .../fairscan/app/domain/ExportPreparation.kt | 2 +- .../fairscan/app/platform/AndroidPdfWriter.kt | 29 ++++++--- .../app/platform/AndroidPdfWriterTest.kt | 58 +++++++++++++++++ .../imageprocessing/DocumentDetection.kt | 2 +- .../fairscan/imageprocessing/Perspective.kt | 35 +++++++++++ .../EstimatedDimensionsTest.kt | 63 +++++++++++++++++++ 6 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 app/src/test/java/org/fairscan/app/platform/AndroidPdfWriterTest.kt create mode 100644 imageprocessing/src/test/java/org/fairscan/imageprocessing/EstimatedDimensionsTest.kt diff --git a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt index 29eedc1..82d53d3 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt @@ -39,7 +39,7 @@ data class PageToExport( val quad = metadata.normalizedQuad.scaledTo(1.0, 1.0, size.width, size.height) val realDimensions = estimateRealDimensions( quad, size.width.toInt(), size.height.toInt(), metadata.opticalMeasures - ) + ).snapToStandardFormat() return realDimensions.applyRotation(metadata.baseRotation) } } diff --git a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt index 57fc97d..d4d94a5 100644 --- a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt +++ b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt @@ -24,6 +24,7 @@ import org.fairscan.app.BuildConfig import org.fairscan.app.data.PdfWriter import org.fairscan.app.domain.PageToExport import org.fairscan.imageprocessing.EstimatedDimensions +import org.fairscan.imageprocessing.PaperFormats import java.io.OutputStream import java.util.Calendar @@ -43,17 +44,18 @@ class AndroidPdfWriter : PdfWriter { val heightPx = image.height.toFloat() val dimensions = page.estimatedDimensions() - val (widthPoints, heightPoints) = when (dimensions) { - is EstimatedDimensions.Physical -> { - dimensions.widthMm.toFloat() * pointsPerMm to dimensions.heightMm.toFloat() * pointsPerMm - } + val (widthMm, heightMm) = when (dimensions) { + is EstimatedDimensions.Physical -> + clipToMaxFormat(dimensions.widthMm, dimensions.heightMm) else -> { - // No physical dimensions available: approximate using US Letter max dimension - val maxDimInMm = 279.4f - val scalePxToMm = maxDimInMm / maxOf(widthPx, heightPx) - widthPx * scalePxToMm * pointsPerMm to heightPx * scalePxToMm * pointsPerMm + // No physical dimensions available + val maxDimMm = PaperFormats.A4.heightMm + val scalePxToMm = maxDimMm / maxOf(widthPx, heightPx) + clipToMaxFormat(widthPx * scalePxToMm, heightPx * scalePxToMm) } } + val widthPoints = widthMm.toFloat() * pointsPerMm + val heightPoints = heightMm.toFloat() * pointsPerMm val page = PDPage(PDRectangle(widthPoints, heightPoints)) document.addPage(page) @@ -68,3 +70,14 @@ class AndroidPdfWriter : PdfWriter { return doc.numberOfPages } } + +fun clipToMaxFormat(widthMm: Double, heightMm: Double): Pair { + // Normalize to portrait for comparison + val (w, h) = if (widthMm <= heightMm) widthMm to heightMm else heightMm to widthMm + val portrait = widthMm <= heightMm + + val maxFormat = PaperFormats.A4 + val scale = minOf(maxFormat.widthMm / w, maxFormat.heightMm / h, 1.0) + val clipped = w * scale to h * scale + return if (portrait) clipped else clipped.second to clipped.first +} diff --git a/app/src/test/java/org/fairscan/app/platform/AndroidPdfWriterTest.kt b/app/src/test/java/org/fairscan/app/platform/AndroidPdfWriterTest.kt new file mode 100644 index 0000000..62cdf75 --- /dev/null +++ b/app/src/test/java/org/fairscan/app/platform/AndroidPdfWriterTest.kt @@ -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 . + */ +package org.fairscan.app.platform + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.offset +import org.junit.Test + +class AndroidPdfWriterTest { + + @Test fun `portrait smaller than A4 is unchanged`() { + val (w, h) = clipToMaxFormat(100.0, 150.0) + assertThat(w).isEqualTo(100.0) + assertThat(h).isEqualTo(150.0) + } + + @Test fun `portrait taller than A4 is clipped preserving ratio`() { + val (w, h) = clipToMaxFormat(210.0, 400.0) + assertThat(h).isCloseTo(297.0, offset(0.1)) + assertThat(w / h).isCloseTo(210.0 / 400.0, offset(0.001)) + } + + @Test fun `landscape wider than A4 is clipped preserving ratio`() { + val (w, h) = clipToMaxFormat(300.0, 200.0) + assertThat(w).isCloseTo(297.0, offset(0.1)) + assertThat(w / h).isCloseTo(300.0 / 200.0, offset(0.001)) + } + + @Test fun `exactly A4 is unchanged`() { + val (w, h) = clipToMaxFormat(210.0, 297.0) + assertThat(w).isCloseTo(210.0, offset(0.001)) + assertThat(h).isCloseTo(297.0, offset(0.001)) + } + + @Test fun `landscape orientation is preserved after clip`() { + val (w, h) = clipToMaxFormat(400.0, 250.0) + assertThat(w).isGreaterThan(h) + } + + @Test fun `landscape smaller than A4 is unchanged`() { + val (w, h) = clipToMaxFormat(297.0, 150.0) + assertThat(w).isCloseTo(297.0, offset(0.001)) + assertThat(h).isCloseTo(150.0, offset(0.001)) + } + +} diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt index f25f27b..28f7ccd 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt @@ -164,7 +164,7 @@ fun extractDocument( inputMat.cols(), inputMat.rows(), opticalMeasures, - ) + ).snapToStandardFormat() val (targetWidth, targetHeight) = estimatedDimensions.toPixelDimensions(quad) val srcPoints = MatOfPoint2f( quad.topLeft.toCv(), diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Perspective.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Perspective.kt index 9acd278..6f62a84 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Perspective.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Perspective.kt @@ -14,6 +14,7 @@ */ package org.fairscan.imageprocessing +import kotlin.math.abs import kotlin.math.absoluteValue import kotlin.math.max import kotlin.math.sqrt @@ -63,6 +64,40 @@ sealed class EstimatedDimensions { is Physical -> heightMm / widthMm is Ratio -> height / width } + + fun snapToStandardFormat( + ratioTolerance: Double = 0.04, + dimensionTolerance: Double = 0.20, + ): EstimatedDimensions { + if (this !is Physical) return this + + // Normalize to portrait for comparison + val (w, h) = if (widthMm <= heightMm) widthMm to heightMm + else heightMm to widthMm + val portrait = widthMm <= heightMm + + for (format in PaperFormats.all) { + val (fw, fh) = format.widthMm to format.heightMm // format is always portrait + val ratioError = abs((h / w) - (fh / fw)) / (fh / fw) + val dimError = maxOf(abs(w - fw) / fw, abs(h - fh) / fh) + if (ratioError < ratioTolerance && dimError < dimensionTolerance) { + return if (portrait) format + else Physical(format.heightMm, format.widthMm) + } + } + + return this + } +} + +object PaperFormats { + val A3 = EstimatedDimensions.Physical(297.0, 420.0) + val A4 = EstimatedDimensions.Physical(210.0, 297.0) + val A5 = EstimatedDimensions.Physical(148.0, 210.0) + val Letter = EstimatedDimensions.Physical(215.9, 279.4) + val Legal = EstimatedDimensions.Physical(215.9, 355.6) + + val all = listOf(A4, Letter, Legal, A5, A3) } /** diff --git a/imageprocessing/src/test/java/org/fairscan/imageprocessing/EstimatedDimensionsTest.kt b/imageprocessing/src/test/java/org/fairscan/imageprocessing/EstimatedDimensionsTest.kt new file mode 100644 index 0000000..b9be848 --- /dev/null +++ b/imageprocessing/src/test/java/org/fairscan/imageprocessing/EstimatedDimensionsTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.imageprocessing + +import org.assertj.core.api.Assertions.assertThat +import org.fairscan.imageprocessing.EstimatedDimensions.Physical +import org.fairscan.imageprocessing.EstimatedDimensions.Ratio +import org.junit.Test + +class EstimatedDimensionsTest { + + @Test fun `ratio is returned unchanged`() { + val input = Ratio(212.0, 300.0) + assertThat(input.snapToStandardFormat()).isEqualTo(input) + } + + @Test fun `A4-sized physical dims snap to exact A4`() { + val input = Physical(212.0, 300.0) + assertThat(input.snapToStandardFormat()).isEqualTo(PaperFormats.A4) + } + + @Test fun `A3-sized physical dims snap to exact A3`() { + val input = Physical(297.0, 420.0) + assertThat(input.snapToStandardFormat()).isEqualTo(PaperFormats.A3) + } + + @Test fun `A5-sized physical dims snap to exact A5`() { + val input = Physical(149.0, 211.0) + assertThat(input.snapToStandardFormat()).isEqualTo(PaperFormats.A5) + } + + @Test fun `landscape A4 snaps to landscape A4`() { + val input = Physical(300.0, 212.0) + assertThat(input.snapToStandardFormat()).isEqualTo(Physical(297.0, 210.0)) + } + + @Test fun `Letter-sized dims snap to exact Letter`() { + val input = Physical(218.0, 281.0) + assertThat(input.snapToStandardFormat()).isEqualTo(PaperFormats.Letter) + } + + @Test fun `dims far from any standard format are unchanged`() { + val input = Physical(150.0, 400.0) + assertThat(input.snapToStandardFormat()).isEqualTo(input) + } + + @Test fun `aspect ratio`() { + assertThat(Physical(100.0, 150.0).aspectRatio).isEqualTo(1.5) + assertThat(Ratio(100.0, 150.0).aspectRatio).isEqualTo(1.5) + } +}