From c3bd144681040544581e2f60483145ee65196608 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] 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) + } +}