Snap to standard formats: A4, Letter...
This commit is contained in:
@@ -39,7 +39,7 @@ data class PageToExport(
|
|||||||
val quad = metadata.normalizedQuad.scaledTo(1.0, 1.0, size.width, size.height)
|
val quad = metadata.normalizedQuad.scaledTo(1.0, 1.0, size.width, size.height)
|
||||||
val realDimensions = estimateRealDimensions(
|
val realDimensions = estimateRealDimensions(
|
||||||
quad, size.width.toInt(), size.height.toInt(), metadata.opticalMeasures
|
quad, size.width.toInt(), size.height.toInt(), metadata.opticalMeasures
|
||||||
)
|
).snapToStandardFormat()
|
||||||
return realDimensions.applyRotation(metadata.baseRotation)
|
return realDimensions.applyRotation(metadata.baseRotation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import org.fairscan.app.BuildConfig
|
|||||||
import org.fairscan.app.data.PdfWriter
|
import org.fairscan.app.data.PdfWriter
|
||||||
import org.fairscan.app.domain.PageToExport
|
import org.fairscan.app.domain.PageToExport
|
||||||
import org.fairscan.imageprocessing.EstimatedDimensions
|
import org.fairscan.imageprocessing.EstimatedDimensions
|
||||||
|
import org.fairscan.imageprocessing.PaperFormats
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
|
||||||
@@ -43,17 +44,18 @@ class AndroidPdfWriter : PdfWriter {
|
|||||||
val heightPx = image.height.toFloat()
|
val heightPx = image.height.toFloat()
|
||||||
|
|
||||||
val dimensions = page.estimatedDimensions()
|
val dimensions = page.estimatedDimensions()
|
||||||
val (widthPoints, heightPoints) = when (dimensions) {
|
val (widthMm, heightMm) = when (dimensions) {
|
||||||
is EstimatedDimensions.Physical -> {
|
is EstimatedDimensions.Physical ->
|
||||||
dimensions.widthMm.toFloat() * pointsPerMm to dimensions.heightMm.toFloat() * pointsPerMm
|
clipToMaxFormat(dimensions.widthMm, dimensions.heightMm)
|
||||||
}
|
|
||||||
else -> {
|
else -> {
|
||||||
// No physical dimensions available: approximate using US Letter max dimension
|
// No physical dimensions available
|
||||||
val maxDimInMm = 279.4f
|
val maxDimMm = PaperFormats.A4.heightMm
|
||||||
val scalePxToMm = maxDimInMm / maxOf(widthPx, heightPx)
|
val scalePxToMm = maxDimMm / maxOf(widthPx, heightPx)
|
||||||
widthPx * scalePxToMm * pointsPerMm to heightPx * scalePxToMm * pointsPerMm
|
clipToMaxFormat(widthPx * scalePxToMm, heightPx * scalePxToMm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val widthPoints = widthMm.toFloat() * pointsPerMm
|
||||||
|
val heightPoints = heightMm.toFloat() * pointsPerMm
|
||||||
|
|
||||||
val page = PDPage(PDRectangle(widthPoints, heightPoints))
|
val page = PDPage(PDRectangle(widthPoints, heightPoints))
|
||||||
document.addPage(page)
|
document.addPage(page)
|
||||||
@@ -68,3 +70,14 @@ class AndroidPdfWriter : PdfWriter {
|
|||||||
return doc.numberOfPages
|
return doc.numberOfPages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clipToMaxFormat(widthMm: Double, heightMm: Double): Pair<Double, Double> {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.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))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -164,7 +164,7 @@ fun extractDocument(
|
|||||||
inputMat.cols(),
|
inputMat.cols(),
|
||||||
inputMat.rows(),
|
inputMat.rows(),
|
||||||
opticalMeasures,
|
opticalMeasures,
|
||||||
)
|
).snapToStandardFormat()
|
||||||
val (targetWidth, targetHeight) = estimatedDimensions.toPixelDimensions(quad)
|
val (targetWidth, targetHeight) = estimatedDimensions.toPixelDimensions(quad)
|
||||||
val srcPoints = MatOfPoint2f(
|
val srcPoints = MatOfPoint2f(
|
||||||
quad.topLeft.toCv(),
|
quad.topLeft.toCv(),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.fairscan.imageprocessing
|
package org.fairscan.imageprocessing
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
@@ -63,6 +64,40 @@ sealed class EstimatedDimensions {
|
|||||||
is Physical -> heightMm / widthMm
|
is Physical -> heightMm / widthMm
|
||||||
is Ratio -> height / width
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user