From 8dc5249824247424dd9e09b4abe715bf6ad6605c Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 27 Jul 2026 18:57:07 +0200 Subject: [PATCH 1/8] extract QR code to a sepparate a7 page --- .../feature/pdf/render/FakePdfCanvas.kt | 11 ++- .../feature/pdf/render/PdfCanvasTest.kt | 2 +- .../feature/pdf/render/PdfWriterTest.kt | 49 ++++++++++--- .../feature/pdf/AndroidPdfImageProvider.kt | 4 +- .../feature/pdf/AndroidPdfRenderer.kt | 2 +- .../feature/pdf/render/DocumentPdfCanvas.kt | 5 +- .../feature/pdf/render/PdfCanvas.kt | 4 +- .../feature/pdf/render/PdfWriter.kt | 31 ++++---- .../feature/pdf/render/PdfConfig.kt | 12 ++++ .../feature/pdf/render/PdfGeometry.kt | 3 + .../feature/pdf/render/PdfPageController.kt | 31 +++++++- .../{QrBlockLayout.kt => QrPageLayout.kt} | 35 +++++---- .../pdf/render/PdfPageControllerTest.kt | 46 ++++++++++++ .../pdf/render/layout/QrBlockLayoutTest.kt | 68 ------------------ .../pdf/render/layout/QrPageLayoutTest.kt | 71 +++++++++++++++++++ 15 files changed, 247 insertions(+), 127 deletions(-) rename feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/{QrBlockLayout.kt => QrPageLayout.kt} (51%) delete mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/QrBlockLayoutTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/QrPageLayoutTest.kt diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt index e0dc2efe08..2d27513a0d 100644 --- a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt @@ -20,15 +20,18 @@ import android.text.StaticLayout import org.groundplatform.feature.pdf.render.image.PdfImage internal class FakePdfCanvas : PdfCanvas { - val startedPages = mutableListOf() + val startedPages = mutableListOf() var finishedPages = 0 val drawnText = mutableListOf() val drawnImages = mutableListOf() val drawnLines = mutableListOf() val drawnOverlays = mutableListOf>() - override fun startPage(pageNumber: Int) { - startedPages += pageNumber + val startedPageNumbers: List + get() = startedPages.map { it.pageNumber } + + override fun startPage(pageNumber: Int, pageSize: PdfPageSize) { + startedPages += StartedPage(pageNumber, pageSize) } override fun finishPage() { @@ -50,4 +53,6 @@ internal class FakePdfCanvas : PdfCanvas { override fun drawMapOverlay(overlay: MapOverlay, darkBasemap: Boolean) { drawnOverlays += overlay to darkBasemap } + + data class StartedPage(val pageNumber: Int, val pageSize: PdfPageSize) } diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt index 2f0adcecc7..f19c33f755 100644 --- a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt @@ -31,7 +31,7 @@ class PdfCanvasTest { val layout = StaticLayout.Builder.obtain("body", 0, "body".length, TextPaint(), 100).build() val image = PdfImage(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) with(MeasurementPdfCanvas) { - startPage(pageNumber = 1) + startPage(pageNumber = 1, pageSize = PdfConfig.REPORT_PAGE_SIZE) drawStaticLayout(layout, x = 0f, y = 0f) drawImage(image, RectF(0f, 0f, 10f, 10f), smoothScaling = true) drawLine(0f, 0f, 10f, 10f) diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt index e0bae8dc8d..4162075ed7 100644 --- a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt @@ -51,11 +51,38 @@ class PdfWriterTest { } @Test - fun `opens and closes exactly one page for a single-page document`() { + fun `opens and closes a qr page and one content page for a single-page document`() { val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) - assertEquals(listOf(1), canvas.startedPages) - assertEquals(1, canvas.finishedPages) + assertEquals(listOf(1, 2), canvas.startedPageNumbers) + assertEquals(2, canvas.finishedPages) + } + + @Test + fun `draws the qr on an A7 page ahead of the A4 content pages`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertEquals( + listOf(PdfConfig.QR_PAGE_SIZE, PdfConfig.REPORT_PAGE_SIZE), + canvas.startedPages.map { it.pageSize }, + ) + } + + @Test + fun `does not open a qr page when no qr image is provided`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(qr = null)) + + assertEquals(listOf(PdfConfig.REPORT_PAGE_SIZE), canvas.startedPages.map { it.pageSize }) + } + + @Test + fun `draws no header or footer on the qr page`() { + val tableless = + SINGLE_PAGE_DOCUMENT.copy(table = SINGLE_PAGE_DOCUMENT.table.copy(rows = emptyList())) + + val canvas = renderDocument(tableless, pdfImageSet(qr = pdfImage())) + + assertEquals(listOf(QR_BLOCK.scanCaption), canvas.drawnText) } @Test @@ -86,10 +113,10 @@ class PdfWriterTest { pdfWriter.drawDocument(TEST_PDF_DOCUMENT) - assertTrue(pdfWriter.pageCount > 1) - assertEquals(pdfWriter.pageCount, canvas.drawnText.count { it == HEADER.surveyName }) + assertTrue(pdfWriter.contentPageCount > 1) + assertEquals(pdfWriter.contentPageCount, canvas.drawnText.count { it == HEADER.surveyName }) assertEquals( - pdfWriter.pageCount, + pdfWriter.contentPageCount, canvas.drawnText.count { it == "${FOOTER.dataCollectorLabel}: ${FOOTER.dataCollectorName}, ${FOOTER.userEmail}" }, @@ -144,7 +171,7 @@ class PdfWriterTest { } @Test - fun `includes the page number in the footer when totalPages is set`() { + fun `numbers the content pages in the footer, leaving the qr page out of the count`() { val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, totalPages = 1) assertTrue(canvas.drawnText.contains("1/1")) @@ -175,8 +202,8 @@ class PdfWriterTest { pdfWriter.drawDocument(TEST_PDF_DOCUMENT) // Every page resets the flag, so each page's first row draws exactly 1 top border. - assertTrue(pdfWriter.pageCount > 1) - assertEquals(pdfWriter.pageCount, canvas.topBorderCount()) + assertTrue(pdfWriter.contentPageCount > 1) + assertEquals(pdfWriter.contentPageCount, canvas.topBorderCount()) } @Test @@ -186,7 +213,7 @@ class PdfWriterTest { val canvas = renderDocument(tableless, pdfImageSet(qr = pdfImage())) - assertEquals(listOf(1), canvas.startedPages) + assertEquals(listOf(1), canvas.startedPageNumbers) assertFalse(canvas.drawnText.contains(TABLE.submissionLabel)) } @@ -200,7 +227,7 @@ class PdfWriterTest { private fun renderPageCount(totalPages: Int?): Int = newPdfWriter(TEST_PDF_DOCUMENT, PdfImageSet(emptyMap()), MeasurementPdfCanvas, totalPages) .apply { drawDocument(TEST_PDF_DOCUMENT) } - .pageCount + .contentPageCount private fun newPdfWriter( document: SubmissionPdfDocument, diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt index 5e41d143f8..b732d528aa 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt @@ -33,7 +33,7 @@ import kotlinx.coroutines.coroutineScope import org.groundplatform.feature.pdf.render.fitInside import org.groundplatform.feature.pdf.render.image.PdfImage import org.groundplatform.feature.pdf.render.image.PdfImageSet -import org.groundplatform.feature.pdf.render.layout.QrBlockLayout +import org.groundplatform.feature.pdf.render.layout.QrPageLayout import org.groundplatform.feature.pdf.render.layout.TableLayout import org.groundplatform.feature.pdf.render.pointsToRenderPixels import org.groundplatform.ui.components.qrcode.PDF_LOGO_SIZE_FRACTION @@ -56,7 +56,7 @@ class AndroidPdfImageProvider( @DrawableRes private val logoDrawableRes: Int, ) : PdfImageProvider { - private val qrMaxPx = pointsToRenderPixels(QrBlockLayout.QR_SIZE) + private val qrMaxPx = pointsToRenderPixels(QrPageLayout.QR_SIZE) private val photoMaxWidthPx = pointsToRenderPixels(TableLayout.ANSWER_TEXT_WIDTH.toFloat()) private val photoMaxHeightPx = pointsToRenderPixels(TableLayout.PHOTO_MAX_HEIGHT.toFloat()) diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt index 19d0949e86..6844a8f211 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt @@ -55,7 +55,7 @@ class AndroidPdfRenderer(private val ioDispatcher: CoroutineDispatcher) : PdfRen private fun measurePageCount(document: SubmissionPdfDocument, images: PdfImageSet): Int = writer(document, images, MeasurementPdfCanvas, totalPages = null) .apply { drawDocument(document) } - .pageCount + .contentPageCount private fun writer( document: SubmissionPdfDocument, diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt index 012c36bed2..5064c2ab2a 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt @@ -65,9 +65,8 @@ internal class DocumentPdfCanvas(private val pdf: PdfDocument) : PdfCanvas { private val mapOverlayTextPaint = PdfTextPaints().mapOverlay - override fun startPage(pageNumber: Int) { - val info = - PdfDocument.PageInfo.Builder(PdfConfig.PAGE_WIDTH, PdfConfig.PAGE_HEIGHT, pageNumber).create() + override fun startPage(pageNumber: Int, pageSize: PdfPageSize) { + val info = PdfDocument.PageInfo.Builder(pageSize.width, pageSize.height, pageNumber).create() currentPage = pdf.startPage(info) } diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt index e0fcc07438..da411ad61a 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt @@ -21,7 +21,7 @@ import org.groundplatform.feature.pdf.render.image.PdfImage /** Abstraction for drawing onto a PDF page. */ internal interface PdfCanvas { - fun startPage(pageNumber: Int) + fun startPage(pageNumber: Int, pageSize: PdfPageSize) fun finishPage() @@ -36,7 +36,7 @@ internal interface PdfCanvas { /** Used during the page-counting phase. Drops every drawing call. */ internal object MeasurementPdfCanvas : PdfCanvas { - override fun startPage(pageNumber: Int) = Unit + override fun startPage(pageNumber: Int, pageSize: PdfPageSize) = Unit override fun finishPage() = Unit diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt index d23bfe5a12..2823921ca5 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt @@ -36,7 +36,7 @@ import org.groundplatform.feature.pdf.render.image.PdfImage import org.groundplatform.feature.pdf.render.image.PdfImageSet import org.groundplatform.feature.pdf.render.layout.PageFooterLayout import org.groundplatform.feature.pdf.render.layout.PageHeaderLayout -import org.groundplatform.feature.pdf.render.layout.QrBlockLayout +import org.groundplatform.feature.pdf.render.layout.QrPageLayout import org.groundplatform.feature.pdf.render.layout.TableLayout /** @@ -58,11 +58,12 @@ internal class PdfWriter( PdfCursor(footerReserve = PageFooterLayout.reserve(footerLayout.height.toFloat())) private val pageController = PdfPageController(cursor, this) - val pageCount: Int - get() = pageController.pageCount + /** Number of pages carrying the header and footer; the standalone QR page is excluded. */ + val contentPageCount: Int + get() = pageController.contentPageCount override fun onPageStarted(pageNumber: Int) { - pdfCanvas.startPage(pageNumber) + pdfCanvas.startPage(pageNumber, PdfConfig.REPORT_PAGE_SIZE) drawPageHeader() } @@ -72,26 +73,28 @@ internal class PdfWriter( } fun drawDocument(document: SubmissionPdfDocument) { - drawQrBlock(document.qrBlock) + drawQrPage(document.qrBlock) drawTable(document.table) finalizePage() } - private fun drawQrBlock(block: QrBlock) { + /** Draws the QR code and its caption on a page of their own, without header or footer. */ + private fun drawQrPage(block: QrBlock) { val qr = images[PdfImageSet.ImageRef.Qr] ?: return - pageController.ensurePage() val captionLayout = staticLayout( block.scanCaption, paints.caption, - QrBlockLayout.QR_SIZE.toInt(), + QrPageLayout.QR_SIZE.toInt(), Layout.Alignment.ALIGN_CENTER, ) - val layout = - QrBlockLayout.compute(top = cursor.y, captionHeight = captionLayout.height.toFloat()) - drawImage(qr, layout.qrFrame, smoothScaling = false) - drawStaticLayoutAt(captionLayout, layout.captionOffset) - cursor.moveTo(layout.nextCursorY) + val layout = QrPageLayout.compute(captionHeight = captionLayout.height.toFloat()) + pageController.standalonePage { pageNumber -> + pdfCanvas.startPage(pageNumber, PdfConfig.QR_PAGE_SIZE) + drawImage(qr, layout.qrFrame, smoothScaling = false) + drawStaticLayoutAt(captionLayout, layout.captionOffset) + pdfCanvas.finishPage() + } } private fun drawTable(table: SubmissionPdfDocument.Table) { @@ -183,7 +186,7 @@ internal class PdfWriter( totalPages?.let { total -> val pageNumber = staticLayout( - "${pageController.pageCount}/$total", + "${pageController.contentPageCount}/$total", paints.meta, layout.pageNumberMaxWidth, alignment = Layout.Alignment.ALIGN_OPPOSITE, diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfConfig.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfConfig.kt index 488cb2e7e2..de80b58557 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfConfig.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfConfig.kt @@ -28,6 +28,18 @@ internal object PdfConfig { /** Page height in points (A4 portrait, 297mm). */ const val PAGE_HEIGHT = 842 + /** QR page width in points (A7 portrait, 74mm). */ + const val QR_PAGE_WIDTH = 210 + + /** QR page height in points (A7 portrait, 105mm). */ + const val QR_PAGE_HEIGHT = 298 + + /** Size of the report body pages. */ + val REPORT_PAGE_SIZE = PdfPageSize(PAGE_WIDTH, PAGE_HEIGHT) + + /** Size of the standalone QR page. */ + val QR_PAGE_SIZE = PdfPageSize(QR_PAGE_WIDTH, QR_PAGE_HEIGHT) + /** Page margin applied to all four edges. */ const val MARGIN = 40 diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt index 3b55da2103..345d3a3a29 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt @@ -28,6 +28,9 @@ internal fun pointsToRenderPixels(points: Float): Int = internal data class PdfItemSize(val width: Float, val height: Float) +/** Dimensions of a PDF page, in points. */ +internal data class PdfPageSize(val width: Int, val height: Int) + internal data class PdfOffset(val x: Float, val y: Float) internal data class PdfLine(val startX: Float, val startY: Float, val endX: Float, val endY: Float) diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt index 3224310098..7e36aa74e7 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt @@ -24,27 +24,51 @@ internal class PdfPageController( private val lifecycle: PageLifecycle, ) { interface PageLifecycle { - /** Called after a new page has been allocated. The header should be drawn here. */ + /** Called after a new content page has been allocated. The header should be drawn here. */ fun onPageStarted(pageNumber: Int) - /** Called before the page is closed. The footer and per-page flush should happen here. */ + /** + * Called before the content page is closed. The footer and per-page flush should happen here. + */ fun onPageEnding(pageNumber: Int) } private var pageIndex = 0 + private var contentPageIndex = 0 private var pageOpen = false var isFirstTableRowOnPage = true private set - /** Number of pages emitted so far. Equals the current page number while a page is open. */ + /** + * Number of pages emitted so far, standalone pages included. Equals the current page number while + * a page is open. + */ val pageCount: Int get() = pageIndex + /** + * Number of content pages emitted so far. Standalone pages are excluded, so this is the number + * shown in the footer of the page currently open. + */ + val contentPageCount: Int + get() = contentPageIndex + fun ensurePage() { if (!pageOpen) beginPage() } + /** + * Emits a page that carries no header or footer and is left out of the content page numbering. + * Any open content page is closed first so the standalone page keeps its place in the document. + * [draw] receives the page number and is responsible for opening and closing the page. + */ + fun standalonePage(draw: (pageNumber: Int) -> Unit) { + finalizePage() + pageIndex++ + draw(pageIndex) + } + /** Records that the first table row on the current page has been drawn. */ fun consumeFirstTableRowOnPage() { isFirstTableRowOnPage = false @@ -65,6 +89,7 @@ internal class PdfPageController( private fun beginPage() { pageIndex++ + contentPageIndex++ pageOpen = true isFirstTableRowOnPage = true cursor.reset() diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/QrBlockLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/QrPageLayout.kt similarity index 51% rename from feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/QrBlockLayout.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/QrPageLayout.kt index edf77eb30f..eccc412be1 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/QrBlockLayout.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/QrPageLayout.kt @@ -16,35 +16,32 @@ package org.groundplatform.feature.pdf.render.layout import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING -import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN -import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.QR_PAGE_HEIGHT +import org.groundplatform.feature.pdf.render.PdfConfig.QR_PAGE_WIDTH import org.groundplatform.feature.pdf.render.PdfOffset import org.groundplatform.feature.pdf.render.PdfRect /** - * Pre-computed layout for the right-aligned QR code block with its caption. Compute should only be - * called when a QR image is available; the caption is meaningless without it. + * Pre-computed layout for the QR page: the QR code and its caption centered on a page of + * [QR_PAGE_WIDTH] x [QR_PAGE_HEIGHT]. Compute should only be called when a QR image is available; + * the caption is meaningless without it. * * @param qrFrame Position and size of the QR image. * @param captionOffset Top-left position of the caption text (centered under the QR). - * @param nextCursorY Cursor Y position after this block. */ -internal data class QrBlockLayout( - val qrFrame: PdfRect, - val captionOffset: PdfOffset, - val nextCursorY: Float, -) { +internal data class QrPageLayout(val qrFrame: PdfRect, val captionOffset: PdfOffset) { companion object { - /** Target size of the QR code block. */ - const val QR_SIZE = 200f + const val MARGIN = 16f - fun compute(top: Float, captionHeight: Float): QrBlockLayout { - val x = PAGE_WIDTH - MARGIN - QR_SIZE - val captionTop = top + QR_SIZE + LINE_SPACING - return QrBlockLayout( - qrFrame = PdfRect(x, top, QR_SIZE, QR_SIZE), - captionOffset = PdfOffset(x, captionTop), - nextCursorY = captionTop + captionHeight + LINE_SPACING * 2, + /** Target size of the QR code, spanning the page width between the margins. */ + const val QR_SIZE = QR_PAGE_WIDTH - 2 * MARGIN + + fun compute(captionHeight: Float): QrPageLayout { + val blockHeight = QR_SIZE + LINE_SPACING + captionHeight + val top = (QR_PAGE_HEIGHT - blockHeight) / 2 + return QrPageLayout( + qrFrame = PdfRect(MARGIN, top, QR_SIZE, QR_SIZE), + captionOffset = PdfOffset(MARGIN, top + QR_SIZE + LINE_SPACING), ) } } diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt index bc2ebdc2a9..d0f8b6158b 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt @@ -169,6 +169,52 @@ class PdfPageControllerTest { assertEquals(2, controller.pageCount) } + @Test + fun `standalonePage emits a page without header and footer events`() { + val drawnOn = mutableListOf() + + controller.standalonePage { pageNumber -> drawnOn += pageNumber } + + assertEquals(listOf(1), drawnOn) + assertEquals(1, controller.pageCount) + assertTrue(lifecycle.events.isEmpty()) + } + + @Test + fun `standalonePage is left out of the content page count`() { + controller.standalonePage {} + controller.ensurePage() + + assertEquals(2, controller.pageCount) + assertEquals(1, controller.contentPageCount) + assertEquals(listOf(PageEvent.Started(2)), lifecycle.events) + } + + @Test + fun `standalonePage closes an open content page first`() { + controller.ensurePage() + + controller.standalonePage {} + + assertEquals(listOf(PageEvent.Started(1), PageEvent.Ending(1)), lifecycle.events) + assertEquals(2, controller.pageCount) + assertEquals(1, controller.contentPageCount) + } + + @Test + fun `content pages that follow a standalone page keep their document page numbers`() { + controller.standalonePage {} + controller.ensurePage() + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertEquals( + listOf(PageEvent.Started(2), PageEvent.Ending(2), PageEvent.Started(3)), + lifecycle.events, + ) + assertEquals(2, controller.contentPageCount) + } + @Test fun `isFirstTableRowOnPage is true until consumed`() { controller.ensurePage() diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/QrBlockLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/QrBlockLayoutTest.kt deleted file mode 100644 index eabb71ddc0..0000000000 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/QrBlockLayoutTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.groundplatform.feature.pdf.render.layout - -import kotlin.test.Test -import kotlin.test.assertEquals -import org.groundplatform.feature.pdf.render.PdfConfig -import org.groundplatform.feature.pdf.render.layout.QrBlockLayout.Companion.QR_SIZE - -class QrBlockLayoutTest { - - private val margin = PdfConfig.MARGIN - private val pageWidth = PdfConfig.PAGE_WIDTH - private val qrSize = QR_SIZE - private val lineSpacing = PdfConfig.LINE_SPACING - - private val expectedX = pageWidth - margin - qrSize - - @Test - fun `QR frame is a square anchored at the right margin`() { - val layout = QrBlockLayout.compute(top = 0f, captionHeight = 10f) - - assertEquals(expectedX, layout.qrFrame.x) - assertEquals(0f, layout.qrFrame.y) - assertEquals(qrSize, layout.qrFrame.width) - assertEquals(qrSize, layout.qrFrame.height) - assertEquals((pageWidth - margin).toFloat(), layout.qrFrame.right) - } - - @Test - fun `caption sits directly below the QR with line spacing between them`() { - val top = 100f - val layout = QrBlockLayout.compute(top = top, captionHeight = 10f) - - assertEquals(expectedX, layout.captionOffset.x) - assertEquals(top + qrSize + lineSpacing, layout.captionOffset.y) - } - - @Test - fun `caption shares its X with the QR frame`() { - val layout = QrBlockLayout.compute(top = 0f, captionHeight = 10f) - - assertEquals(layout.qrFrame.x, layout.captionOffset.x) - } - - @Test - fun `nextCursorY accounts for QR, caption, and trailing spacing`() { - val top = 50f - val captionHeight = 14f - val layout = QrBlockLayout.compute(top = top, captionHeight = captionHeight) - - val expectedCaptionTop = top + qrSize + lineSpacing - assertEquals(expectedCaptionTop + captionHeight + lineSpacing * 2, layout.nextCursorY) - } -} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/QrPageLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/QrPageLayoutTest.kt new file mode 100644 index 0000000000..aea5e419fe --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/QrPageLayoutTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.layout + +import kotlin.test.Test +import kotlin.test.assertEquals +import org.groundplatform.feature.pdf.render.PdfConfig +import org.groundplatform.feature.pdf.render.layout.QrPageLayout.Companion.MARGIN +import org.groundplatform.feature.pdf.render.layout.QrPageLayout.Companion.QR_SIZE + +class QrPageLayoutTest { + + private val pageWidth = PdfConfig.QR_PAGE_WIDTH + private val pageHeight = PdfConfig.QR_PAGE_HEIGHT + private val lineSpacing = PdfConfig.LINE_SPACING + + @Test + fun `QR frame is a square spanning the page width between the margins`() { + val layout = QrPageLayout.compute(captionHeight = 10f) + + assertEquals(MARGIN, layout.qrFrame.x) + assertEquals(QR_SIZE, layout.qrFrame.width) + assertEquals(QR_SIZE, layout.qrFrame.height) + assertEquals(pageWidth - MARGIN, layout.qrFrame.right) + } + + @Test + fun `caption sits directly below the QR with line spacing between them`() { + val layout = QrPageLayout.compute(captionHeight = 10f) + + assertEquals(layout.qrFrame.bottom + lineSpacing, layout.captionOffset.y) + } + + @Test + fun `caption shares its X with the QR frame`() { + val layout = QrPageLayout.compute(captionHeight = 10f) + + assertEquals(layout.qrFrame.x, layout.captionOffset.x) + } + + @Test + fun `QR and caption are centered vertically on the page`() { + val captionHeight = 14f + val layout = QrPageLayout.compute(captionHeight = captionHeight) + + val spaceAbove = layout.qrFrame.y + val spaceBelow = pageHeight - (layout.captionOffset.y + captionHeight) + assertEquals(spaceAbove, spaceBelow) + } + + @Test + fun `taller captions push the QR further up the page`() { + val short = QrPageLayout.compute(captionHeight = 10f) + val tall = QrPageLayout.compute(captionHeight = 30f) + + assertEquals(short.qrFrame.y - 10f, tall.qrFrame.y) + } +} From 9a554e7bf96ddcbcb272221c824fb7e2928b149d Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 27 Jul 2026 19:05:43 +0200 Subject: [PATCH 2/8] add submission name above QR code --- .../feature/pdf/render/PdfWriterTest.kt | 14 ++++-- .../feature/pdf/render/PdfWriter.kt | 44 ++++++++++++------- .../feature/pdf/mapper/LoiReportMapper.kt | 10 +++-- .../pdf/model/SubmissionPdfDocument.kt | 6 ++- .../feature/pdf/render/layout/QrPageLayout.kt | 26 +++++++---- .../feature/pdf/PdfExportServiceTest.kt | 7 ++- .../feature/pdf/mapper/LoiReportMapperTest.kt | 11 +++++ .../pdf/render/layout/QrPageLayoutTest.kt | 38 ++++++++++++---- 8 files changed, 115 insertions(+), 41 deletions(-) diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt index 4162075ed7..fb79920351 100644 --- a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt @@ -82,7 +82,7 @@ class PdfWriterTest { val canvas = renderDocument(tableless, pdfImageSet(qr = pdfImage())) - assertEquals(listOf(QR_BLOCK.scanCaption), canvas.drawnText) + assertEquals(listOf(QR_TITLE, QR_BLOCK.scanCaption), canvas.drawnText) } @Test @@ -124,12 +124,13 @@ class PdfWriterTest { } @Test - fun `draws the qr image and caption when a qr image is provided`() { + fun `draws the qr image, its submission title and caption when a qr image is provided`() { val qr = pdfImage() val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(qr = qr)) assertTrue(canvas.drawnImages.any { it.bitmap === qr.bitmap }) assertTrue(canvas.drawnText.contains(QR_BLOCK.scanCaption)) + assertTrue(canvas.drawnText.contains(QR_TITLE)) } @Test @@ -279,7 +280,14 @@ class PdfWriterTest { userEmail = "user@gmail.com", ) - val QR_BLOCK = SubmissionPdfDocument.QrBlock(scanCaption = "Scan") + val QR_BLOCK = + SubmissionPdfDocument.QrBlock( + submissionLabel = "Submission", + submissionName = "Plot 42", + scanCaption = "Scan", + ) + + val QR_TITLE = "${QR_BLOCK.submissionLabel}: ${QR_BLOCK.submissionName}" val TABLE = SubmissionPdfDocument.Table( diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt index 2823921ca5..691e202263 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt @@ -78,19 +78,31 @@ internal class PdfWriter( finalizePage() } - /** Draws the QR code and its caption on a page of their own, without header or footer. */ + /** + * Draws the submission title, the QR code and its caption on a page of their own, without header + * or footer. + */ private fun drawQrPage(block: QrBlock) { val qr = images[PdfImageSet.ImageRef.Qr] ?: return - val captionLayout = + val blockWidth = QrPageLayout.QR_SIZE.toInt() + val titleLayout = staticLayout( - block.scanCaption, - paints.caption, - QrPageLayout.QR_SIZE.toInt(), - Layout.Alignment.ALIGN_CENTER, + text = labeled(block.submissionLabel, block.submissionName), + paint = paints.body, + maxWidth = blockWidth, + alignment = Layout.Alignment.ALIGN_CENTER, + maxLines = QrPageLayout.TITLE_MAX_LINES, + ) + val captionLayout = + staticLayout(block.scanCaption, paints.caption, blockWidth, Layout.Alignment.ALIGN_CENTER) + val layout = + QrPageLayout.compute( + titleHeight = titleLayout.height.toFloat(), + captionHeight = captionLayout.height.toFloat(), ) - val layout = QrPageLayout.compute(captionHeight = captionLayout.height.toFloat()) pageController.standalonePage { pageNumber -> pdfCanvas.startPage(pageNumber, PdfConfig.QR_PAGE_SIZE) + drawStaticLayoutAt(titleLayout, layout.titleOffset) drawImage(qr, layout.qrFrame, smoothScaling = false) drawStaticLayoutAt(captionLayout, layout.captionOffset) pdfCanvas.finishPage() @@ -241,19 +253,19 @@ internal class PdfWriter( private fun drawLine(line: PdfLine) = pdfCanvas.drawLine(line.startX, line.startY, line.endX, line.endY) - private fun buildFooterLayout(footer: Footer): StaticLayout { - val footerLabel = footer.dataCollectorLabel - val footerText = - SpannableString("$footerLabel: ${footer.dataCollectorName}, ${footer.userEmail}").apply { - setSpan(StyleSpan(Typeface.BOLD), 0, footerLabel.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) - } - return staticLayout( - footerText, + private fun buildFooterLayout(footer: Footer): StaticLayout = + staticLayout( + labeled(footer.dataCollectorLabel, "${footer.dataCollectorName}, ${footer.userEmail}"), paints.meta, PageFooterLayout.TEXT_MAX_WIDTH, maxLines = PageFooterLayout.MAX_LINES, ) - } + + /** Builds a `"