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..d40479cd30 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,17 @@ import android.text.StaticLayout import org.groundplatform.feature.pdf.render.image.PdfImage internal class FakePdfCanvas : PdfCanvas { - val startedPages = mutableListOf() + val startedPageNumbers = mutableListOf() + val startedPageSizes = mutableListOf() var finishedPages = 0 val drawnText = mutableListOf() val drawnImages = mutableListOf() val drawnLines = mutableListOf() val drawnOverlays = mutableListOf>() - override fun startPage(pageNumber: Int) { - startedPages += pageNumber + override fun startPage(pageNumber: Int, pageSize: PdfPageSize) { + startedPageNumbers += pageNumber + startedPageSizes += pageSize } override fun finishPage() { 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..c8ab98a852 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 @@ -46,16 +46,43 @@ class PdfWriterTest { newPdfWriter(EMPTY_DOCUMENT, PdfImageSet(emptyMap()), canvas).drawDocument(EMPTY_DOCUMENT) - assertEquals(0, canvas.startedPages.size) + assertEquals(0, canvas.startedPageNumbers.size) assertEquals(0, canvas.finishedPages) } @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.startedPageSizes, + ) + } + + @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.startedPageSizes) + } + + @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.submissionName, 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.bodyPageCount > 1) + assertEquals(pdfWriter.bodyPageCount, canvas.drawnText.count { it == HEADER.surveyName }) assertEquals( - pdfWriter.pageCount, + pdfWriter.bodyPageCount, canvas.drawnText.count { it == "${FOOTER.dataCollectorLabel}: ${FOOTER.dataCollectorName}, ${FOOTER.userEmail}" }, @@ -97,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_BLOCK.submissionName)) } @Test @@ -144,7 +172,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 +203,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.bodyPageCount > 1) + assertEquals(pdfWriter.bodyPageCount, canvas.topBorderCount()) } @Test @@ -186,7 +214,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 +228,7 @@ class PdfWriterTest { private fun renderPageCount(totalPages: Int?): Int = newPdfWriter(TEST_PDF_DOCUMENT, PdfImageSet(emptyMap()), MeasurementPdfCanvas, totalPages) .apply { drawDocument(TEST_PDF_DOCUMENT) } - .pageCount + .bodyPageCount private fun newPdfWriter( document: SubmissionPdfDocument, @@ -252,7 +280,7 @@ class PdfWriterTest { userEmail = "user@gmail.com", ) - val QR_BLOCK = SubmissionPdfDocument.QrBlock(scanCaption = "Scan") + val QR_BLOCK = SubmissionPdfDocument.QrBlock(submissionName = "Plot 42", scanCaption = "Scan") val TABLE = SubmissionPdfDocument.Table( 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..037e632407 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 + .bodyPageCount 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/PdfTextPaints.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt index be8defef4b..b41fef74c6 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt @@ -25,6 +25,7 @@ import org.groundplatform.feature.pdf.render.PdfConfig.TITLE_SIZE internal class PdfTextPaints { val title: TextPaint = textPaint(TITLE_SIZE, bold = false) val body: TextPaint = textPaint(BODY_SIZE, bold = false) + val bodyLabel: TextPaint = textPaint(BODY_SIZE, bold = true) val metaLabel: TextPaint = textPaint(CAPTION_SIZE, bold = true, textColor = Color.GRAY) val meta: TextPaint = textPaint(CAPTION_SIZE, bold = false, textColor = Color.GRAY) val caption: TextPaint = textPaint(CAPTION_SIZE, bold = false) 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..f11adcb4d5 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 /** @@ -53,16 +53,19 @@ internal class PdfWriter( ) : PdfPageController.PageLifecycle { private val paints = PdfTextPaints() + private val coverImage = images[PdfImageSet.ImageRef.Qr] + private val footerLayout: StaticLayout = buildFooterLayout(footer) private val cursor = PdfCursor(footerReserve = PageFooterLayout.reserve(footerLayout.height.toFloat())) - private val pageController = PdfPageController(cursor, this) + private val pageController = + PdfPageController(cursor, this, coverPages = if (coverImage == null) 0 else 1) - val pageCount: Int - get() = pageController.pageCount + val bodyPageCount: Int + get() = pageController.bodyPageCount override fun onPageStarted(pageNumber: Int) { - pdfCanvas.startPage(pageNumber) + pdfCanvas.startPage(pageNumber, PdfConfig.REPORT_PAGE_SIZE) drawPageHeader() } @@ -72,26 +75,34 @@ internal class PdfWriter( } fun drawDocument(document: SubmissionPdfDocument) { - drawQrBlock(document.qrBlock) + drawQrPage(document.qrBlock) drawTable(document.table) finalizePage() } - private fun drawQrBlock(block: QrBlock) { - val qr = images[PdfImageSet.ImageRef.Qr] ?: return - pageController.ensurePage() - val captionLayout = + private fun drawQrPage(block: QrBlock) { + val qr = coverImage ?: return + val blockWidth = QrPageLayout.QR_SIZE.toInt() + val titleLayout = staticLayout( - block.scanCaption, - paints.caption, - QrBlockLayout.QR_SIZE.toInt(), - Layout.Alignment.ALIGN_CENTER, + text = block.submissionName, + paint = paints.bodyLabel, + 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 = - QrBlockLayout.compute(top = cursor.y, captionHeight = captionLayout.height.toFloat()) + QrPageLayout.compute( + titleHeight = titleLayout.height.toFloat(), + captionHeight = captionLayout.height.toFloat(), + ) + pdfCanvas.startPage(COVER_PAGE_NUMBER, PdfConfig.QR_PAGE_SIZE) + drawStaticLayoutAt(titleLayout, layout.titleOffset) drawImage(qr, layout.qrFrame, smoothScaling = false) drawStaticLayoutAt(captionLayout, layout.captionOffset) - cursor.moveTo(layout.nextCursorY) + pdfCanvas.finishPage() } private fun drawTable(table: SubmissionPdfDocument.Table) { @@ -183,7 +194,7 @@ internal class PdfWriter( totalPages?.let { total -> val pageNumber = staticLayout( - "${pageController.pageCount}/$total", + "${pageController.bodyPageCount}/$total", paints.meta, layout.pageNumberMaxWidth, alignment = Layout.Alignment.ALIGN_OPPOSITE, @@ -238,19 +249,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 `"