From ee00650880d22ed49a4e3576224001472d3d3be5 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 2 Jun 2026 13:41:58 +0200 Subject: [PATCH 01/20] add common layout components to render --- .../feature/pdf/render/PdfConfig.kt | 51 ++++++ .../feature/pdf/render/PdfGeometry.kt | 40 +++++ .../pdf/render/components/PageFooterLayout.kt | 52 ++++++ .../pdf/render/components/PageHeaderLayout.kt | 64 +++++++ .../pdf/render/components/QrBlockLayout.kt | 52 ++++++ .../pdf/render/components/TableRowLayout.kt | 90 ++++++++++ .../render/components/PageFooterLayoutTest.kt | 91 ++++++++++ .../render/components/PageHeaderLayoutTest.kt | 107 ++++++++++++ .../render/components/QrBlockLayoutTest.kt | 74 ++++++++ .../render/components/TableRowLayoutTest.kt | 163 ++++++++++++++++++ 10 files changed, 784 insertions(+) create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfConfig.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayout.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayout.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayoutTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt 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 new file mode 100644 index 0000000000..a502c8dccd --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfConfig.kt @@ -0,0 +1,51 @@ +/* + * 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 + +/** + * Dimensional and type-scale constants shared by the Android and iOS PDF renderers. Keeping these + * in commonMain prevents the two platforms from drifting on page size, margins, or type scale. + * + * All measurements are in PDF points (1/72 inch). + */ +internal object PdfConfig { + const val PAGE_WIDTH = 595 // A4 page width + const val PAGE_HEIGHT = 842 // A4 page height + const val MARGIN = 40 + const val TITLE_SIZE = 11f + const val BODY_SIZE = 11f + const val CAPTION_SIZE = 9f + const val LINE_SPACING = 4f + const val QR_SIZE = 200 + const val HEADER_COLUMN_GAP = 16 + const val TABLE_TASK_LABEL_RATIO = 0.35f + const val CELL_PADDING = 6 + const val BORDER_WIDTH = 0.5f + const val PHOTO_MAX_HEIGHT_RATIO = 0.35f + const val HEADER_BOTTOM_GAP = 28f + const val FOOTER_TOP_GAP = 28f + const val MAX_HEADER_VALUE_LINES = 1 + const val MAX_FOOTER_LINES = 1 + const val IMAGE_RENDER_DPI = 300f + const val USABLE_WIDTH = PAGE_WIDTH - 2 * MARGIN + const val TABLE_TASK_COLUMN_WIDTH = (USABLE_WIDTH * TABLE_TASK_LABEL_RATIO).toInt() + const val TABLE_TASK_TEXT_WIDTH = TABLE_TASK_COLUMN_WIDTH - 2 * CELL_PADDING + const val TABLE_ANSWER_TEXT_WIDTH = USABLE_WIDTH - TABLE_TASK_COLUMN_WIDTH - 2 * CELL_PADDING + const val PHOTO_MAX_HEIGHT = ((PAGE_HEIGHT - 2 * MARGIN) * PHOTO_MAX_HEIGHT_RATIO).toInt() + const val PAGE_NUMBER_BAND_WIDTH = 60 + const val FOOTER_PAGE_NUMBER_GAP = 8 + const val FOOTER_TEXT_MAX_WIDTH = USABLE_WIDTH - PAGE_NUMBER_BAND_WIDTH - FOOTER_PAGE_NUMBER_GAP +} 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 new file mode 100644 index 0000000000..40b0fc8ce0 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt @@ -0,0 +1,40 @@ +/* + * 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 + +import kotlin.math.roundToInt + +internal fun fitInside(width: Int, height: Int, maxWidth: Int, maxHeight: Int): PdfItemSize { + val scale = minOf(maxWidth.toFloat() / width, maxHeight.toFloat() / height, 1f) + return PdfItemSize(width * scale, height * scale) +} + +internal fun pointsToRenderPixels(points: Float): Int = + // 1 point = 1/72 inch (standard PDF user-space unit) + (points / 72f * PdfConfig.IMAGE_RENDER_DPI).roundToInt() + +internal data class PdfItemSize(val width: Float, val height: Float) + +internal data class PdfOffset(val x: Float, val y: Float) + +/** Platform-agnostic rectangle defined by its top-left corner and dimensions. */ +internal data class PdfRect(val x: Float, val y: Float, val width: Float, val height: Float) { + val right: Float + get() = x + width + + val bottom: Float + get() = y + height +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt new file mode 100644 index 0000000000..adf5b3976d --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt @@ -0,0 +1,52 @@ +/* + * 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.components + +import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TEXT_MAX_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_HEIGHT +import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_NUMBER_BAND_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.PdfOffset + +/** + * Pre-computed layout for the page footer with separate left and right slots. + * + * @param footerTextOffset The top-left position where the footer text begins. + * @param footerMaxWidth The maximum width available for the footer text. + * @param pageNumberOffset The top-left position where the page number begins. + * @param pageNumberMaxWidth The maximum width available for the page number + */ +internal data class PageFooterLayout( + val footerTextOffset: PdfOffset, + val footerMaxWidth: Int, + val pageNumberOffset: PdfOffset, + val pageNumberMaxWidth: Int, +) { + companion object { + fun compute(footerHeight: Float): PageFooterLayout { + val top = PAGE_HEIGHT - MARGIN - footerHeight + val left = MARGIN.toFloat() + val pageNumberLeft = left + USABLE_WIDTH - PAGE_NUMBER_BAND_WIDTH + return PageFooterLayout( + footerTextOffset = PdfOffset(left, top), + footerMaxWidth = FOOTER_TEXT_MAX_WIDTH, + pageNumberOffset = PdfOffset(pageNumberLeft, top), + pageNumberMaxWidth = PAGE_NUMBER_BAND_WIDTH, + ) + } + } +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayout.kt new file mode 100644 index 0000000000..c02bbb6025 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayout.kt @@ -0,0 +1,64 @@ +/* + * 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.components + +import org.groundplatform.feature.pdf.render.PdfConfig.HEADER_BOTTOM_GAP +import org.groundplatform.feature.pdf.render.PdfConfig.HEADER_COLUMN_GAP +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.PdfOffset + +/** + * Pre-computed layout for the page header with three slots. Assumes uniform typography across + * columns. + * + * @param leftColumn Label and value positions for the left column. + * @param centerColumn Label and value positions for the center column. + * @param rightTextOffset The position where the right-aligned value begins . + * @param nextCursorY The Y position where the cursor should be positioned after the header. + */ +internal data class PageHeaderLayout( + val leftColumn: Column, + val centerColumn: Column, + val rightTextOffset: PdfOffset, + val nextCursorY: Float, +) { + companion object { + const val COLUMN_WIDTH: Int = (USABLE_WIDTH - 2 * HEADER_COLUMN_GAP) / 3 + const val LEFT_X: Float = MARGIN.toFloat() + const val CENTER_X: Float = LEFT_X + COLUMN_WIDTH + HEADER_COLUMN_GAP + const val RIGHT_X: Float = LEFT_X + 2 * (COLUMN_WIDTH + HEADER_COLUMN_GAP) + + fun compute(top: Float, labelHeight: Float, valueHeight: Float): PageHeaderLayout { + val columnBottom = top + labelHeight + LINE_SPACING + valueHeight + return PageHeaderLayout( + leftColumn = column(LEFT_X, top, labelHeight), + centerColumn = column(CENTER_X, top, labelHeight), + rightTextOffset = PdfOffset(RIGHT_X, top), + nextCursorY = columnBottom + HEADER_BOTTOM_GAP, + ) + } + + private fun column(x: Float, top: Float, labelHeight: Float) = + Column( + labelOffset = PdfOffset(x, top), + valueOffset = PdfOffset(x, top + labelHeight + LINE_SPACING), + ) + } + + data class Column(val labelOffset: PdfOffset, val valueOffset: PdfOffset) +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayout.kt new file mode 100644 index 0000000000..aafb3b964a --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayout.kt @@ -0,0 +1,52 @@ +/* + * 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.components + +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_SIZE +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. + * + * @param qrFrame Position and size of the QR image. + * @param captionOffset Top-left position of the caption text (centered under the QR). + * @param captionMaxWidth Maximum width for the caption. + * @param nextCursorY Cursor Y position after this block. + */ +internal data class QrBlockLayout( + val qrFrame: PdfRect, + val captionOffset: PdfOffset, + val captionMaxWidth: Int, + val nextCursorY: Float, +) { + companion object { + fun compute(top: Float, captionHeight: Float): QrBlockLayout { + val x = (PAGE_WIDTH - MARGIN - QR_SIZE).toFloat() + val captionTop = top + QR_SIZE + LINE_SPACING + return QrBlockLayout( + qrFrame = PdfRect(x, top, QR_SIZE.toFloat(), QR_SIZE.toFloat()), + captionOffset = PdfOffset(x, captionTop), + captionMaxWidth = QR_SIZE, + nextCursorY = captionTop + captionHeight + LINE_SPACING * 2, + ) + } + } +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt new file mode 100644 index 0000000000..cf7e2de67c --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt @@ -0,0 +1,90 @@ +/* + * 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.components + +import org.groundplatform.feature.pdf.render.PdfConfig.CELL_PADDING +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.TABLE_TASK_COLUMN_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.PdfItemSize +import org.groundplatform.feature.pdf.render.PdfOffset +import org.groundplatform.feature.pdf.render.PdfRect + +/** + * Pre-computed layout for a two-column table row. Left cell holds a single text block; right cell + * may contain either text or an image. + * + * @param totalHeight Total height of the row including vertical padding. + * @param leftRowX X coordinate of the row's left edge. + * @param rightRowX X coordinate of the row's right edge. + * @param columnDividerX X coordinate of the vertical divider between the two columns. + * @param leftTextOffset Top-left position where the left cell text should be drawn. + * @param rightTextOffset Top-left position where the right cell text should be drawn, or null if + * the right cell has no text. + * @param rightImageFrame Frame (x, y, width, height) for the right cell image, or null if no image. + */ +internal data class TableRowLayout( + val totalHeight: Float, + val leftRowX: Float, + val rightRowX: Float, + val columnDividerX: Float, + val leftTextOffset: PdfOffset, + val rightTextOffset: PdfOffset?, + val rightImageFrame: PdfRect?, +) { + companion object { + fun totalHeight( + leftTextHeight: Float, + rightTextHeight: Float, + rightImageSize: PdfItemSize?, + ): Float { + val imageHeight = rightImageSize?.height ?: 0f + val imageSpacing = if (imageHeight > 0f && rightTextHeight > 0f) LINE_SPACING else 0f + return maxOf(leftTextHeight, rightTextHeight + imageHeight + imageSpacing) + 2 * CELL_PADDING + } + + fun compute( + rowTop: Float, + leftTextHeight: Float, + rightTextHeight: Float, + rightImageSize: PdfItemSize?, + ): TableRowLayout { + val totalHeight = totalHeight(leftTextHeight, rightTextHeight, rightImageSize) + + val left = MARGIN.toFloat() + val midX = left + TABLE_TASK_COLUMN_WIDTH + val contentTop = rowTop + CELL_PADDING + val rightCellLeft = midX + CELL_PADDING + + val rightTextOffset = if (rightTextHeight > 0f) PdfOffset(rightCellLeft, contentTop) else null + val rightImageFrame = rightImageSize?.let { + val y = contentTop + (rightTextOffset?.let { rightTextHeight + LINE_SPACING } ?: 0f) + PdfRect(rightCellLeft, y, rightImageSize.width, rightImageSize.height) + } + + return TableRowLayout( + totalHeight = totalHeight, + leftRowX = left, + rightRowX = left + USABLE_WIDTH, + columnDividerX = midX, + leftTextOffset = PdfOffset(left + CELL_PADDING, contentTop), + rightTextOffset = rightTextOffset, + rightImageFrame = rightImageFrame, + ) + } + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt new file mode 100644 index 0000000000..e15a3f4c36 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt @@ -0,0 +1,91 @@ +/* + * 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.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.render.PdfConfig + +class PageFooterLayoutTest { + + private val margin = PdfConfig.MARGIN.toFloat() + private val pageHeight = PdfConfig.PAGE_HEIGHT + private val usableWidth = PdfConfig.USABLE_WIDTH + private val pageNumberBand = PdfConfig.PAGE_NUMBER_BAND_WIDTH + private val footerTextMaxWidth = PdfConfig.FOOTER_TEXT_MAX_WIDTH + + @Test + fun `footer text anchors against the bottom margin`() { + val footerHeight = 12f + val layout = PageFooterLayout.compute(footerHeight = footerHeight) + + assertEquals(margin, layout.footerTextOffset.x) + assertEquals(pageHeight - margin - footerHeight, layout.footerTextOffset.y) + } + + @Test + fun `page number sits in the right-side slot on the same baseline as the footer text`() { + val layout = PageFooterLayout.compute(footerHeight = 12f) + + assertEquals(margin + usableWidth - pageNumberBand, layout.pageNumberOffset.x) + assertEquals(layout.footerTextOffset.y, layout.pageNumberOffset.y) + } + + @Test + fun `slot widths match their respective configuration constants`() { + val layout = PageFooterLayout.compute(footerHeight = 12f) + + assertEquals(footerTextMaxWidth, layout.footerMaxWidth) + assertEquals(pageNumberBand, layout.pageNumberMaxWidth) + } + + @Test + fun `footer and page number slots do not overlap`() { + val layout = PageFooterLayout.compute(footerHeight = 12f) + + val footerRight = layout.footerTextOffset.x + layout.footerMaxWidth + assertTrue(footerRight <= layout.pageNumberOffset.x) + } + + @Test + fun `page number band ends exactly at the right page margin`() { + val layout = PageFooterLayout.compute(footerHeight = 12f) + + assertEquals( + margin + usableWidth, + layout.pageNumberOffset.x + layout.pageNumberMaxWidth, + "Page number band's right edge must align with the right page margin", + ) + } + + @Test + fun `taller footer pushes the baseline higher up the page`() { + val short = PageFooterLayout.compute(footerHeight = 12f) + val tall = PageFooterLayout.compute(footerHeight = 30f) + + assertTrue(tall.footerTextOffset.y < short.footerTextOffset.y) + assertEquals(short.footerTextOffset.y - 18f, tall.footerTextOffset.y) + } + + @Test + fun `footer height plus baseline plus bottom margin equals page height`() { + val footerHeight = 18f + val layout = PageFooterLayout.compute(footerHeight = footerHeight) + + assertEquals(pageHeight.toFloat(), layout.footerTextOffset.y + footerHeight + margin) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt new file mode 100644 index 0000000000..792fa0e62d --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt @@ -0,0 +1,107 @@ +/* + * 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.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.text.compareTo +import org.groundplatform.feature.pdf.render.PdfConfig +import org.groundplatform.feature.pdf.render.PdfOffset + +class PageHeaderLayoutTest { + + private val lineSpacing = PdfConfig.LINE_SPACING + private val headerBottomGap = PdfConfig.HEADER_BOTTOM_GAP + private val headerColumnGap = PdfConfig.HEADER_COLUMN_GAP + private val usableWidth = PdfConfig.USABLE_WIDTH + + @Test + fun `column X positions span the usable width with gaps between them`() { + val left = PageHeaderLayout.LEFT_X + val center = PageHeaderLayout.CENTER_X + val right = PageHeaderLayout.RIGHT_X + val width = PageHeaderLayout.COLUMN_WIDTH + + assertTrue(left < center) + assertTrue(center < right) + assertEquals(headerColumnGap.toFloat(), center - (left + width)) + assertEquals(headerColumnGap.toFloat(), right - (center + width)) + assertTrue(3 * width + 2 * headerColumnGap.compareTo(usableWidth) <= 0) + } + + @Test + fun `compute places survey column labels and values at LEFT_X`() { + val layout = PageHeaderLayout.compute(top = 0f, labelHeight = 10f, valueHeight = 14f) + + assertEquals(PdfOffset(PageHeaderLayout.LEFT_X, 0f), layout.leftColumn.labelOffset) + assertEquals( + PdfOffset(PageHeaderLayout.LEFT_X, 10f + lineSpacing), + layout.leftColumn.valueOffset, + ) + } + + @Test + fun `compute places job column labels and values at CENTER_X`() { + val layout = PageHeaderLayout.compute(top = 0f, labelHeight = 10f, valueHeight = 14f) + + assertEquals(PdfOffset(PageHeaderLayout.CENTER_X, 0f), layout.centerColumn.labelOffset) + assertEquals( + PdfOffset(PageHeaderLayout.CENTER_X, 10f + lineSpacing), + layout.centerColumn.valueOffset, + ) + } + + @Test + fun `compute places timestamp at RIGHT_X with the same top as labels`() { + val layout = PageHeaderLayout.compute(top = 50f, labelHeight = 10f, valueHeight = 14f) + + assertEquals(PdfOffset(PageHeaderLayout.RIGHT_X, 50f), layout.rightTextOffset) + assertEquals(layout.leftColumn.labelOffset.y, layout.rightTextOffset.y) + assertEquals(layout.centerColumn.labelOffset.y, layout.rightTextOffset.y) + } + + @Test + fun `value sits below its label by exactly line spacing`() { + val labelHeight = 12f + val layout = PageHeaderLayout.compute(top = 30f, labelHeight = labelHeight, valueHeight = 14f) + + val survey = layout.leftColumn + assertEquals(labelHeight + lineSpacing, survey.valueOffset.y - survey.labelOffset.y) + } + + @Test + fun `nextCursorY accounts for label, line spacing, value, and header bottom gap`() { + val top = 40f + val labelHeight = 10f + val valueHeight = 14f + + val layout = PageHeaderLayout.compute(top = top, labelHeight, valueHeight) + + assertEquals( + top + labelHeight + lineSpacing + valueHeight + headerBottomGap, + layout.nextCursorY, + ) + } + + @Test + fun `all three columns share the same label baseline and value baseline`() { + val layout = PageHeaderLayout.compute(top = 100f, labelHeight = 12f, valueHeight = 16f) + + assertEquals(layout.leftColumn.labelOffset.y, layout.centerColumn.labelOffset.y) + assertEquals(layout.leftColumn.valueOffset.y, layout.centerColumn.valueOffset.y) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayoutTest.kt new file mode 100644 index 0000000000..befaa6a137 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/QrBlockLayoutTest.kt @@ -0,0 +1,74 @@ +/* + * 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.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import org.groundplatform.feature.pdf.render.PdfConfig + +class QrBlockLayoutTest { + + private val margin = PdfConfig.MARGIN + private val pageWidth = PdfConfig.PAGE_WIDTH + private val qrSize = PdfConfig.QR_SIZE + private val lineSpacing = PdfConfig.LINE_SPACING + + private val expectedX = (pageWidth - margin - qrSize).toFloat() + + @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.toFloat(), layout.qrFrame.width) + assertEquals(qrSize.toFloat(), 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 maxWidth equals QR size so it stays under the QR image`() { + val layout = QrBlockLayout.compute(top = 0f, captionHeight = 10f) + + assertEquals(qrSize, layout.captionMaxWidth) + } + + @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/components/TableRowLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt new file mode 100644 index 0000000000..f8b54c350a --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt @@ -0,0 +1,163 @@ +/* + * 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.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.render.PdfConfig +import org.groundplatform.feature.pdf.render.PdfItemSize +import org.groundplatform.feature.pdf.render.PdfOffset + +class TableRowLayoutTest { + + private val cellPadding = PdfConfig.CELL_PADDING.toFloat() + private val lineSpacing = PdfConfig.LINE_SPACING + private val margin = PdfConfig.MARGIN.toFloat() + private val usableWidth = PdfConfig.USABLE_WIDTH + private val taskColumnWidth = PdfConfig.TABLE_TASK_COLUMN_WIDTH + + @Test + fun `totalHeight with only left text returns left height plus padding`() { + val height = + TableRowLayout.totalHeight(leftTextHeight = 30f, rightTextHeight = 0f, rightImageSize = null) + + assertEquals(30f + 2 * cellPadding, height) + } + + @Test + fun `totalHeight picks the taller content height`() { + val tallerLeft = + TableRowLayout.totalHeight(leftTextHeight = 50f, rightTextHeight = 20f, rightImageSize = null) + val tallerRight = + TableRowLayout.totalHeight(leftTextHeight = 10f, rightTextHeight = 60f, rightImageSize = null) + val tallerImageRight = + TableRowLayout.totalHeight( + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = PdfItemSize(width = 100f, height = 80f), + ) + + assertEquals(50f + 2 * cellPadding, tallerLeft) + assertEquals(60f + 2 * cellPadding, tallerRight) + assertEquals(80f + 2 * cellPadding, tallerImageRight) + } + + @Test + fun `totalHeight with both right text and image stacks them with line spacing`() { + val height = + TableRowLayout.totalHeight( + leftTextHeight = 10f, + rightTextHeight = 20f, + rightImageSize = PdfItemSize(width = 100f, height = 80f), + ) + + assertEquals(20f + lineSpacing + 80f + 2 * cellPadding, height) + } + + @Test + fun `compute always places left text at the row's top-left content area`() { + val layout = + TableRowLayout.compute( + rowTop = 100f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = null, + ) + + assertEquals(PdfOffset(margin + cellPadding, 100f + cellPadding), layout.leftTextOffset) + } + + @Test + fun `compute returns null right offsets when right cell has no content`() { + val layout = + TableRowLayout.compute( + rowTop = 0f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = null, + ) + + assertNull(layout.rightTextOffset) + assertNull(layout.rightImageFrame) + } + + @Test + fun `compute places right text at the right cell's top`() { + val layout = + TableRowLayout.compute( + rowTop = 50f, + leftTextHeight = 20f, + rightTextHeight = 20f, + rightImageSize = null, + ) + + val rightCellX = margin + taskColumnWidth + cellPadding + assertEquals(PdfOffset(rightCellX, 50f + cellPadding), layout.rightTextOffset) + assertNull(layout.rightImageFrame) + } + + @Test + fun `compute places image at the right cell's top`() { + val imageSize = PdfItemSize(width = 80f, height = 60f) + val layout = + TableRowLayout.compute( + rowTop = 50f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = imageSize, + ) + + val rightCellX = margin + taskColumnWidth + cellPadding + val frame = assertNotNull(layout.rightImageFrame) + assertNull(layout.rightTextOffset) + with(frame) { + assertEquals(rightCellX, x) + assertEquals(50f + cellPadding, y) + assertEquals(imageSize.width, width) + assertEquals(imageSize.height, height) + } + } + + @Test + fun `compute sets row bounds and divider from page geometry`() { + val layout = + TableRowLayout.compute( + rowTop = 0f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = null, + ) + + assertEquals(margin, layout.leftRowX) + assertEquals(margin + usableWidth, layout.rightRowX) + assertEquals(margin + taskColumnWidth, layout.columnDividerX) + assertTrue(layout.leftRowX < layout.columnDividerX) + assertTrue(layout.columnDividerX < layout.rightRowX) + } + + @Test + fun `compute totalHeight matches the static helper`() { + val left = 30f + val right = 20f + val image = PdfItemSize(width = 80f, height = 60f) + val layout = TableRowLayout.compute(rowTop = 0f, left, right, image) + + assertEquals(TableRowLayout.totalHeight(left, right, image), layout.totalHeight) + } +} From 7b27c499760bb2d3a4780f78da8d8c6b93f596d4 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 2 Jun 2026 13:59:54 +0200 Subject: [PATCH 02/20] update QrCodeGenerator to provide bitmap+logo for PDF documents --- .../qrcode/QrCodeGenerator.android.kt | 2 +- .../ui/components/qrcode/GroundQrCode.kt | 29 +------- .../ui/components/qrcode/QrCodeGenerator.kt | 68 ++++++++++++++++++- .../components/qrcode/QrCodeGenerator.ios.kt | 2 +- 4 files changed, 72 insertions(+), 29 deletions(-) diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.android.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.android.kt index d8aaa505eb..c89755a4c8 100644 --- a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.android.kt +++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.android.kt @@ -25,7 +25,7 @@ import com.google.zxing.EncodeHintType import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel -actual fun generateQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap { +actual fun encodeQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap { val hints = mapOf( EncodeHintType.ERROR_CORRECTION to diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt index 23b076a7c8..0b7f1783e4 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/GroundQrCode.kt @@ -48,29 +48,6 @@ import org.groundplatform.ui.theme.sizes @VisibleForTesting const val TEST_TAG_GROUND_QR_CODE = "TEST_TAG_GROUND_QR_CODE" -/** - * Maximum content size (in UTF-8 bytes) for which a center logo is displayed. - * - * Adding a logo in the center of a QR code covers part of the data pattern, so the QR must rely on - * error correction to remain scannable. A limit of 1,000 bytes is used as a conservative threshold - * to ensure the QR code remains reliably scannable even with a logo applied. - */ -private const val MAX_QR_BYTES_WITH_LOGO = 1000 - -/** - * The relative size of the center logo as a fraction of the QR code's total rendered size. - * - * Set to 15% to ensure the logo is clearly visible while remaining within the recovery capacity of - * high error correction (ECC level H), which can tolerate approximately 30% data loss. - */ -private const val LOGO_SIZE_FRACTION = 0.15f - -/** - * Displays a QR code generated from the given [content] string. - * - * The composable is intentionally generic, it accepts any string payload, making it reusable for - * GeoJSON, URLs, or any other data that fits within QR code capacity limits. - */ @Composable fun GroundQrCode( modifier: Modifier = Modifier, @@ -80,12 +57,12 @@ fun GroundQrCode( centerLogoPainter: Painter?, footer: String, ) { - val contentBytes = remember(content) { content.encodeToByteArray().size } - val showLogo = centerLogoPainter != null && contentBytes <= MAX_QR_BYTES_WITH_LOGO + val fitsLogo = remember(content) { fitsLogoCapacity(content) } + val showLogo = centerLogoPainter != null && fitsLogo val qrBitmap by produceState(initialValue = null, key1 = content, key2 = showLogo) { - value = withContext(Dispatchers.Default) { generateQrBitmap(content, showLogo) } + value = withContext(Dispatchers.Default) { encodeQrBitmap(content, showLogo) } } Column( diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt index 529a7e0d19..94c0a829a9 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt @@ -15,8 +15,74 @@ */ package org.groundplatform.ui.components.qrcode +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize internal const val QR_SIZE_PX = 512 -expect fun generateQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap +/** + * Maximum content size (in UTF-8 bytes) for which a center logo is displayed. + * + * Adding a logo in the center of a QR code covers part of the data pattern, so the QR must rely on + * error correction to remain scannable. A limit of 1,000 bytes is used as a conservative threshold + * to ensure the QR code remains reliably scannable even with a logo applied. + */ +const val MAX_QR_BYTES_WITH_LOGO = 1000 + +/** + * Default relative size of the center logo as a fraction of the QR code's total size. + * + * Set to 15% to ensure the logo is clearly visible while remaining within the recovery capacity of + * high error correction (ECC level H), which can tolerate approximately 30% data loss. + */ +const val LOGO_SIZE_FRACTION = 0.15f +/** + * PDF document has more space to display the QR code, so we can use a larger fraction + */ +const val PDF_LOGO_SIZE_FRACTION = 0.25f + +/** + * Encodes [content] into a bare QR bitmap, using high error correction when [useHighEcc] is set. + */ +expect fun encodeQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap + +/** + * Generates a QR bitmap for a given [content] with a [logo] in its center. The logo is only applied + * when one is supplied and the content size is below [MAX_QR_BYTES_WITH_LOGO] to keep the code + * scannable. + */ +fun generateQrBitmap( + content: String, + logo: ImageBitmap?, + logoSizeFraction: Float = LOGO_SIZE_FRACTION, +): ImageBitmap = + if (logo == null || !fitsLogoCapacity(content)) { + encodeQrBitmap(content, useHighEcc = false) + } else encodeQrBitmap(content, useHighEcc = true).withCenteredLogo(logo, logoSizeFraction) + +internal fun fitsLogoCapacity(content: String): Boolean = + content.encodeToByteArray().size <= MAX_QR_BYTES_WITH_LOGO + + +/** Draws [logo] centered over the receiver, scaled to [fraction] of its size, into a new bitmap. */ +private fun ImageBitmap.withCenteredLogo(logo: ImageBitmap, fraction: Float): ImageBitmap { + val output = ImageBitmap(width, height) + val canvas = Canvas(output) + val paint = Paint().apply { filterQuality = FilterQuality.High } + canvas.drawImage(this, Offset.Zero, paint) + + val logoWidth = (width * fraction).toInt() + val logoHeight = (height * fraction).toInt() + canvas.drawImageRect( + image = logo, + dstOffset = IntOffset((width - logoWidth) / 2, (height - logoHeight) / 2), + dstSize = IntSize(logoWidth, logoHeight), + paint = paint, + ) + return output +} diff --git a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.ios.kt b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.ios.kt index 80c2139c79..58ea1067de 100644 --- a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.ios.kt +++ b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.ios.kt @@ -56,7 +56,7 @@ private const val INPUT_CORRECTION_LEVEL_KEY = "inputCorrectionLevel" private val ciContext: CIContext = CIContext.contextWithOptions(null) -actual fun generateQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap { +actual fun encodeQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap { val ciImage = createQrCIImage(content, useHighEcc) val scaled = scaleToTargetSize(ciImage) return scaled.toComposeImageBitmap() From 87d51a404157f57e4d04740802b25727544d1fc8 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 2 Jun 2026 14:00:55 +0200 Subject: [PATCH 03/20] add android implementations for PDF platform interfaces --- feature/pdf/build.gradle.kts | 16 + .../feature/pdf/AndroidPdfImageProvider.kt | 163 ++++++++++ .../feature/pdf/AndroidPdfOutputProvider.kt | 43 +++ .../feature/pdf/AndroidPdfRenderer.kt | 72 +++++ .../feature/pdf/AndroidPdfReportLauncher.kt | 59 ++++ .../feature/pdf/render/DocumentPdfCanvas.kt | 55 ++++ .../feature/pdf/render/PdfCanvas.kt | 31 ++ .../feature/pdf/render/PdfTextPaints.kt | 24 ++ .../feature/pdf/render/PdfWriter.kt | 279 ++++++++++++++++++ .../feature/pdf/render/PdfCursor.kt | 46 +++ .../feature/pdf/render/PdfPageController.kt | 64 ++++ 11 files changed, 852 insertions(+) create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt create mode 100644 feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt diff --git a/feature/pdf/build.gradle.kts b/feature/pdf/build.gradle.kts index 02b26cfcf9..cd7e2b192a 100644 --- a/feature/pdf/build.gradle.kts +++ b/feature/pdf/build.gradle.kts @@ -20,6 +20,7 @@ plugins { } kotlin { + jvmToolchain(libs.versions.jvmToolchainVersion.get().toInt()) android { namespace = "org.groundplatform.feature.pdf" compileSdk { @@ -48,6 +49,7 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.serialization.json) + implementation(libs.compose.ui) } } @@ -58,5 +60,19 @@ kotlin { implementation(libs.kotlinx.coroutines.test) } } + + androidMain { + dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.exifinterface) + } + } + + val androidHostTest by getting { + dependencies { + implementation(libs.junit) + implementation(libs.robolectric) + } + } } } 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 new file mode 100644 index 0000000000..95bf67e109 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt @@ -0,0 +1,163 @@ +/* + * 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 + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.os.Environment +import androidx.annotation.DrawableRes +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.graphics.scale +import androidx.exifinterface.media.ExifInterface +import java.io.File +import kotlin.math.roundToInt +import org.groundplatform.feature.pdf.render.PdfConfig +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.image.PdfImageSet +import org.groundplatform.feature.pdf.render.pointsToRenderPixels +import org.groundplatform.ui.components.qrcode.PDF_LOGO_SIZE_FRACTION +import org.groundplatform.ui.components.qrcode.generateQrBitmap + +/** + * Android implementation of [PdfImageProvider]. + * + * Bitmaps are decoded and scaled to their final on-page pixel size here so the renderer can draw + * them as-is without any further bitmap work. + * + * @param context application context used for resource access and file lookups. + * @param logoDrawableRes resource id of the centre logo bitmap. Caller supplies the app's branding. + */ +class AndroidPdfImageProvider( + private val context: Context, + @DrawableRes private val logoDrawableRes: Int, +) : PdfImageProvider { + + private val qrMaxPx = pointsToRenderPixels(PdfConfig.QR_SIZE.toFloat()) + private val photoMaxWidthPx = pointsToRenderPixels(PdfConfig.TABLE_ANSWER_TEXT_WIDTH.toFloat()) + private val photoMaxHeightPx = pointsToRenderPixels(PdfConfig.PHOTO_MAX_HEIGHT.toFloat()) + + override suspend fun load(qrContent: String?, photoFilenames: Set): PdfImageSet { + val images = mutableMapOf() + val bitmapsToRelease = mutableListOf() + + qrContent?.let { content -> + generateQrCodeBitmap(content)?.let { bitmap -> + bitmapsToRelease += bitmap + images[PdfImageSet.ImageRef.Qr] = PdfImage(bitmap) + } + } + + photoFilenames + .filter { it.isNotEmpty() } + .forEach { filename -> + loadPhotoBitmap(filename)?.let { bitmap -> + bitmapsToRelease += bitmap + images[PdfImageSet.ImageRef.Photo(filename)] = PdfImage(bitmap) + } + } + + return PdfImageSet(images) { bitmapsToRelease.forEach(Bitmap::recycle) } + } + + private fun generateQrCodeBitmap(content: String): Bitmap? = + runCatching { + generateQrBitmap( + content = content, + logo = + BitmapFactory.decodeResource(context.resources, logoDrawableRes)?.asImageBitmap(), + logoSizeFraction = PDF_LOGO_SIZE_FRACTION, + ) + .asAndroidBitmap() + .downscaledTo(qrMaxPx, qrMaxPx) + } + .getOrNull() + + private fun loadPhotoBitmap(remoteFilename: String): Bitmap? { + val rootDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: return null + val filename = remoteFilename.substringAfterLast('/') + val file = File(rootDir, filename) + if (!file.exists()) return null + val decoded = runCatching { decodeSubsampled(file.absolutePath) }.getOrNull() ?: return null + val oriented = applyExifOrientation(file, decoded) + return oriented.downscaledTo(photoMaxWidthPx, photoMaxHeightPx) + } + + /** Decodes the photo subsampled to roughly the largest size it can occupy in the PDF. */ + private fun decodeSubsampled(path: String): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + // Orientation isn't known yet, so size against the larger target on both axes to be safe. + val target = maxOf(photoMaxWidthPx, photoMaxHeightPx) + val options = + BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, target) + } + return BitmapFactory.decodeFile(path, options) + } + + /** Largest power-of-two sample size that keeps both dimensions at or above [target] pixels. */ + private fun calculateInSampleSize(width: Int, height: Int, target: Int): Int { + var sampleSize = 1 + while (width / (sampleSize * 2) >= target && height / (sampleSize * 2) >= target) { + sampleSize *= 2 + } + return sampleSize + } + + /** + * Rotates [bitmap] to match the EXIF orientation in [file]. Returns the original bitmap if no + * rotation is needed. + */ + private fun applyExifOrientation(file: File, bitmap: Bitmap): Bitmap { + val degrees = runCatching { ExifInterface(file.absolutePath).rotationDegrees }.getOrDefault(0) + if (degrees == 0) return bitmap + + val matrix = Matrix().apply { postRotate(degrees.toFloat()) } + return runCatching { + Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } + .getOrNull() + ?.also { if (it != bitmap) bitmap.recycle() } ?: bitmap + } +} + +/** + * Returns a bitmap scaled down to fit [maxWidthPx] × [maxHeightPx], preserving aspect ratio and + * never upscaling. Uses progressive halving for efficiency; intermediate bitmaps are recycled. + */ +private fun Bitmap.downscaledTo(maxWidthPx: Int, maxHeightPx: Int): Bitmap { + if (width <= maxWidthPx && height <= maxHeightPx) return this + val ratio = minOf(maxWidthPx.toFloat() / width, maxHeightPx.toFloat() / height) + val targetWidth = (width * ratio).roundToInt().coerceAtLeast(1) + val targetHeight = (height * ratio).roundToInt().coerceAtLeast(1) + + var current = this + var w = width + var h = height + while (w / 2 >= targetWidth && h / 2 >= targetHeight) { + w /= 2 + h /= 2 + val halved = current.scale(w, h) + if (current !== this) current.recycle() + current = halved + } + val result = current.scale(targetWidth, targetHeight) + if (current !== this) current.recycle() + return result +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt new file mode 100644 index 0000000000..1ae8029940 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt @@ -0,0 +1,43 @@ +/* + * 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 + +import android.content.Context +import java.io.File + +private const val REPORTS_SUBDIR = "reports" + +class AndroidPdfOutputProvider(private val context: Context) : PdfOutputProvider { + + private val reportsDir + get() = File(context.cacheDir, REPORTS_SUBDIR) + + override fun newFilePath(name: String): String { + val outputDir = reportsDir.apply { mkdirs() } + return File(outputDir, "$name.pdf").absolutePath + } + + override fun exists(name: String): Boolean = File(reportsDir, "$name.pdf").exists() + + override fun listFiles(): List = + reportsDir + .listFiles { f -> f.isFile && f.extension == "pdf" } + ?.map { PdfOutputProvider.CachedPdf(it.absolutePath, it.lastModified()) } ?: emptyList() + + override fun deleteReport(path: String) { + File(path).delete() + } +} 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 new file mode 100644 index 0000000000..d237510484 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt @@ -0,0 +1,72 @@ +/* + * 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 + +import android.graphics.pdf.PdfDocument +import java.io.File +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.DocumentPdfCanvas +import org.groundplatform.feature.pdf.render.MeasurementPdfCanvas +import org.groundplatform.feature.pdf.render.PdfCanvas +import org.groundplatform.feature.pdf.render.PdfWriter +import org.groundplatform.feature.pdf.render.image.PdfImageSet + +/** + * Android [PdfRenderer] for a [SubmissionPdfDocument]. The drawing of each section lives in + * [PdfWriter]; the [PdfCanvas] decides whether each pass writes to a real [PdfDocument] or just + * counts pages. + */ +class AndroidPdfRenderer : PdfRenderer { + + override suspend fun render( + document: SubmissionPdfDocument, + images: PdfImageSet, + outputPath: String, + ) { + // Measurement first so the footer can show "page/total" + val totalPages = measurePageCount(document, images) + val pdf = PdfDocument() + try { + writer(document, images, DocumentPdfCanvas(pdf), totalPages = totalPages).draw(document) + File(outputPath).outputStream().use { pdf.writeTo(it) } + } finally { + pdf.close() + } + } + + private fun measurePageCount(document: SubmissionPdfDocument, images: PdfImageSet): Int = + writer(document, images, MeasurementPdfCanvas, totalPages = null).draw(document).pageCount + + private fun writer( + document: SubmissionPdfDocument, + images: PdfImageSet, + pdfCanvas: PdfCanvas, + totalPages: Int?, + ): PdfWriter = + PdfWriter( + pdfCanvas = pdfCanvas, + images = images, + header = document.header, + footer = document.footer, + totalPages = totalPages, + ) + + private fun PdfWriter.draw(document: SubmissionPdfDocument): PdfWriter = apply { + drawQrBlock(document.qrBlock) + drawTable(document.table) + finalizePage() + } +} \ No newline at end of file diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt new file mode 100644 index 0000000000..80f294da51 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt @@ -0,0 +1,59 @@ +/* + * 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 + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import java.io.File + +/** Launches the system share sheet or external viewer for a report file via [FileProvider]. */ +class AndroidPdfReportLauncher( + private val context: Context, + private val fileProviderAuthority: String, +) : PdfReportLauncher { + + override fun share(path: String) { + val uri = FileProvider.getUriForFile(context, fileProviderAuthority, File(path)) + val sendIntent = + Intent(Intent.ACTION_SEND).apply { + type = PDF_MIME_TYPE + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + launchChooser(sendIntent) + } + + override fun open(path: String) { + val uri = FileProvider.getUriForFile(context, fileProviderAuthority, File(path)) + val viewIntent = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, PDF_MIME_TYPE) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + launchChooser(viewIntent) + } + + private fun launchChooser(target: Intent) { + val chooser = + Intent.createChooser(target, null).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + context.startActivity(chooser) + } + + companion object { + private const val PDF_MIME_TYPE = "application/pdf" + } +} 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 new file mode 100644 index 0000000000..5bbebca4f0 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt @@ -0,0 +1,55 @@ +package org.groundplatform.feature.pdf.render + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.pdf.PdfDocument +import android.text.StaticLayout +import androidx.core.graphics.withTranslation +import org.groundplatform.feature.pdf.render.image.PdfImage + +/** + * [PdfCanvas] that draws onto a real [android.graphics.pdf.PdfDocument], one page at a time. Image bitmaps are expected + * to arrive at their on-page pixel size; the canvas does no further scaling. + */ +internal class DocumentPdfCanvas(private val pdf: PdfDocument) : PdfCanvas { + private var currentPage: PdfDocument.Page? = null + + private val strokePaint = + Paint().apply { + style = Paint.Style.STROKE + strokeWidth = PdfConfig.BORDER_WIDTH + isAntiAlias = true + } + + private val smoothImagePaint = + Paint().apply { + isFilterBitmap = true + isAntiAlias = true + isDither = true + } + + override fun startPage(pageNumber: Int) { + val info = PdfDocument.PageInfo.Builder(PdfConfig.PAGE_WIDTH, PdfConfig.PAGE_HEIGHT, pageNumber).create() + currentPage = pdf.startPage(info) + } + + override fun finishPage() { + currentPage?.also { pdf.finishPage(it) } + currentPage = null + } + + override fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) { + canvas().withTranslation(x, y) { layout.draw(this) } + } + + override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) { + canvas().drawBitmap(image.bitmap, null, frame, if (smoothScaling) smoothImagePaint else null) + } + + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) { + canvas().drawLine(x1, y1, x2, y2, strokePaint) + } + + private fun canvas(): Canvas = currentPage?.canvas ?: error("draw called with no page open") +} \ No newline at end of file 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 new file mode 100644 index 0000000000..3fa449022e --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt @@ -0,0 +1,31 @@ +package org.groundplatform.feature.pdf.render + +import android.graphics.RectF +import android.text.StaticLayout +import org.groundplatform.feature.pdf.render.image.PdfImage + +/** Abstraction for drawing onto a PDF page. */ +internal interface PdfCanvas { + fun startPage(pageNumber: Int) + + fun finishPage() + + fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) + + fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) + + fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) +} + +/** Used during the page-counting phase. Drops every drawing call. */ +internal object MeasurementPdfCanvas : PdfCanvas { + override fun startPage(pageNumber: Int) = Unit + + override fun finishPage() = Unit + + override fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) = Unit + + override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) = Unit + + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) = Unit +} \ No newline at end of file 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 new file mode 100644 index 0000000000..f60a0f41a9 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt @@ -0,0 +1,24 @@ +package org.groundplatform.feature.pdf.render + +import android.graphics.Color +import android.graphics.Typeface +import android.text.TextPaint +import org.groundplatform.feature.pdf.render.PdfConfig.BODY_SIZE +import org.groundplatform.feature.pdf.render.PdfConfig.CAPTION_SIZE +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 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) + + private fun textPaint(size: Float, bold: Boolean, textColor: Int = Color.BLACK): TextPaint = + TextPaint().apply { + textSize = size + color = textColor + isAntiAlias = true + if (bold) typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } +} 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 new file mode 100644 index 0000000000..dabba56a6f --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt @@ -0,0 +1,279 @@ +package org.groundplatform.feature.pdf.render + +import android.graphics.RectF +import android.graphics.Typeface +import android.graphics.pdf.PdfDocument +import android.text.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import android.text.TextUtils +import android.text.style.StyleSpan +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Answer +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Footer +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Header +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.QrBlock +import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TEXT_MAX_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TOP_GAP +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.MAX_FOOTER_LINES +import org.groundplatform.feature.pdf.render.PdfConfig.MAX_HEADER_VALUE_LINES +import org.groundplatform.feature.pdf.render.PdfConfig.PHOTO_MAX_HEIGHT +import org.groundplatform.feature.pdf.render.PdfConfig.QR_SIZE +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.components.PageFooterLayout +import org.groundplatform.feature.pdf.render.components.PageHeaderLayout +import org.groundplatform.feature.pdf.render.components.QrBlockLayout +import org.groundplatform.feature.pdf.render.components.TableRowLayout +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.image.PdfImageSet + +/** + * Draws a [SubmissionPdfDocument] onto a [PdfDocument], one section at a time, paginating top-down. + * Holds the mutable drawing state (current page, [PdfCursor], shared paints) shared by all + * sections. + */ +internal class PdfWriter( + private val pdfCanvas: PdfCanvas, + private val images: PdfImageSet, + private val totalPages: Int? = null, + private val header: Header, + footer: Footer, +) : PdfPageController.PageLifecycle { + private val paints = PdfTextPaints() + + private val cursor = PdfCursor() + private val pageController = PdfPageController(cursor, this) + + private var currentTableTopY: Float? = null + + private val footerLayout: StaticLayout + + init { + val footerLabel = footer.dataCollectorLabel + val footerText = + SpannableString("$footerLabel: ${footer.dataCollectorName}, ${footer.userEmail}").apply { + setSpan(StyleSpan(Typeface.BOLD), 0, footerLabel.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + } + footerLayout = + staticLayout(footerText, paints.meta, FOOTER_TEXT_MAX_WIDTH, maxLines = MAX_FOOTER_LINES) + cursor.footerReserve = footerLayout.height + FOOTER_TOP_GAP + } + + val pageCount: Int + get() = pageController.pageCount + + override fun onPageStarted(pageNumber: Int) { + pdfCanvas.startPage(pageNumber) + drawPageHeader() + } + + override fun onPageEnding(pageNumber: Int) { + flushTableDivider() + drawPageFooter() + pdfCanvas.finishPage() + } + + fun drawQrBlock(block: QrBlock) { + val qr = images[PdfImageSet.ImageRef.Qr] ?: return + pageController.ensurePage() + val captionLayout = + staticLayout(block.scanCaption, paints.caption, QR_SIZE, 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) + } + + fun drawTable(table: SubmissionPdfDocument.Table) { + val rows = table.rows.takeIf { it.isNotEmpty() } ?: return + pageController.ensurePage() + val x = MARGIN.toFloat() + cursor.advance(LINE_SPACING * 2) + val label = + SpannableString("${table.submissionLabel}: ${table.loiName}").apply { + setSpan( + StyleSpan(Typeface.BOLD), + 0, + table.submissionLabel.length, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE, + ) + } + cursor.moveTo(drawText(label, x, cursor.y, USABLE_WIDTH, paints.title)) + cursor.advance(LINE_SPACING) + rows.forEach { row -> + when (val answer = row.answer) { + is Answer.Text -> + drawTableRow( + questionText = row.question, + answerText = answer.lines.joinToString("\n"), + photo = null, + ) + is Answer.Photo -> + drawTableRow( + questionText = row.question, + answerText = "", + photo = images[PdfImageSet.ImageRef.Photo(answer.remoteFilename)], + ) + } + } + flushTableDivider() + } + + fun finalizePage() { + pageController.finalizePage() + } + + private fun drawPageHeader() { + val columnWidth = PageHeaderLayout.COLUMN_WIDTH + val surveyLabel = staticLayout(header.surveyLabel, paints.metaLabel, columnWidth) + val surveyValue = + staticLayout(header.surveyName, paints.meta, columnWidth, maxLines = MAX_HEADER_VALUE_LINES) + val jobLabel = + staticLayout(header.jobLabel, paints.metaLabel, columnWidth, Layout.Alignment.ALIGN_CENTER) + val jobValue = + staticLayout( + header.jobName, + paints.meta, + columnWidth, + alignment = Layout.Alignment.ALIGN_CENTER, + maxLines = MAX_HEADER_VALUE_LINES, + ) + val timestamp = + staticLayout( + header.timestamp, + paints.meta, + columnWidth, + alignment = Layout.Alignment.ALIGN_OPPOSITE, + maxLines = MAX_HEADER_VALUE_LINES, + ) + + val layout = + PageHeaderLayout.compute( + top = cursor.y, + labelHeight = surveyLabel.height.toFloat(), + valueHeight = surveyValue.height.toFloat(), + ) + + drawStaticLayoutAt(surveyLabel, layout.leftColumn.labelOffset) + drawStaticLayoutAt(surveyValue, layout.leftColumn.valueOffset) + drawStaticLayoutAt(jobLabel, layout.centerColumn.labelOffset) + drawStaticLayoutAt(jobValue, layout.centerColumn.valueOffset) + drawStaticLayoutAt(timestamp, layout.rightTextOffset) + cursor.moveTo(layout.nextCursorY) + } + + private fun drawPageFooter() { + val layout = PageFooterLayout.compute(footerHeight = footerLayout.height.toFloat()) + drawStaticLayoutAt(footerLayout, layout.footerTextOffset) + totalPages?.let { total -> + drawText( + text = "${pageController.pageCount}/$total", + x = layout.pageNumberOffset.x, + y = layout.pageNumberOffset.y, + maxWidth = layout.pageNumberMaxWidth, + paint = paints.meta, + alignment = Layout.Alignment.ALIGN_OPPOSITE, + maxLines = 1, + ) + } + } + + private fun drawTableRow(questionText: String, answerText: String, photo: PdfImage?) { + val questionLayout = staticLayout(questionText, paints.body, PdfConfig.TABLE_TASK_TEXT_WIDTH) + val answerLayout = + if (answerText.isEmpty()) null + else staticLayout(answerText, paints.body, PdfConfig.TABLE_ANSWER_TEXT_WIDTH) + val photoSize = photo?.let { + fitInside(it.width, it.height, PdfConfig.TABLE_ANSWER_TEXT_WIDTH, PHOTO_MAX_HEIGHT) + } + + val questionHeight = questionLayout.height.toFloat() + val answerHeight = answerLayout?.height?.toFloat() ?: 0f + pageController.newPageIfShort( + TableRowLayout.totalHeight(questionHeight, answerHeight, photoSize) + ) + val rowLayout = + TableRowLayout.compute( + rowTop = cursor.y, + leftTextHeight = questionHeight, + rightTextHeight = answerHeight, + rightImageSize = photoSize, + ) + + if (currentTableTopY == null) { + currentTableTopY = cursor.y + pdfCanvas.drawLine(rowLayout.leftRowX, cursor.y, rowLayout.rightRowX, cursor.y) + } + + drawStaticLayoutAt(questionLayout, rowLayout.leftTextOffset) + if (answerLayout != null && rowLayout.rightTextOffset != null) { + drawStaticLayoutAt(answerLayout, rowLayout.rightTextOffset) + } + if (photo != null && rowLayout.rightImageFrame != null) { + drawImage(photo, rowLayout.rightImageFrame, smoothScaling = true) + } + cursor.advance(rowLayout.totalHeight) + + pdfCanvas.drawLine(rowLayout.leftRowX, cursor.y, rowLayout.rightRowX, cursor.y) + } + + private fun flushTableDivider() { + val top = currentTableTopY ?: return + val midX = MARGIN + PdfConfig.TABLE_TASK_COLUMN_WIDTH.toFloat() + pdfCanvas.drawLine(midX, top, midX, cursor.y) + currentTableTopY = null + } + + /** + * Lays out [text] and draws it at ([x], [y]). + * + * @return the Y just below the drawn text. + */ + private fun drawText( + text: CharSequence, + x: Float, + y: Float, + maxWidth: Int, + paint: TextPaint, + alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL, + maxLines: Int = Int.MAX_VALUE, + ): Float { + if (text.isEmpty()) return y + val layout = staticLayout(text, paint, maxWidth, alignment, maxLines) + pdfCanvas.drawStaticLayout(layout, x, y) + return y + layout.height + } + + private fun drawStaticLayoutAt(layout: StaticLayout, offset: PdfOffset) = + pdfCanvas.drawStaticLayout(layout, offset.x, offset.y) + + private fun drawImage(image: PdfImage, frame: PdfRect, smoothScaling: Boolean) = + pdfCanvas.drawImage(image, RectF(frame.x, frame.y, frame.right, frame.bottom), smoothScaling) + + /** + * Lays out [text] wrapped to [maxWidth]. When [maxLines] is set, overflow is ellipsized so a + * single long value can't grow the layout unboundedly. + */ + private fun staticLayout( + text: CharSequence, + paint: TextPaint, + maxWidth: Int, + alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL, + maxLines: Int = Int.MAX_VALUE, + ): StaticLayout = + StaticLayout.Builder.obtain(text, 0, text.length, paint, maxWidth) + .setAlignment(alignment) + .setLineSpacing(LINE_SPACING, 1f) + .apply { + if (maxLines != Int.MAX_VALUE) { + setMaxLines(maxLines) + setEllipsize(TextUtils.TruncateAt.END) + } + } + .build() +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt new file mode 100644 index 0000000000..2c67ea1f02 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt @@ -0,0 +1,46 @@ +/* + * 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 + +/** Tracks the current vertical draw position on a page and the space reserved for the footer. */ +internal class PdfCursor( + private val pageHeight: Int = PdfConfig.PAGE_HEIGHT, + private val margin: Int = PdfConfig.MARGIN, +) { + /** Space kept clear above the bottom margin for the footer; set once the footer is known. */ + var footerReserve: Float = 0f + + var y: Float = margin.toFloat() + private set + + val isAtPageTop: Boolean + get() = y == margin.toFloat() + + fun reset() { + y = margin.toFloat() + } + + fun moveTo(absoluteY: Float) { + y = absoluteY + } + + fun advance(delta: Float) { + y += delta + } + + /** Whether a block of the given [height] still fits above the footer reserve on this page. */ + fun fits(height: Float): Boolean = y + height <= pageHeight - margin - footerReserve +} 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 new file mode 100644 index 0000000000..b256481cb7 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt @@ -0,0 +1,64 @@ +/* + * 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 + +/** + * Platform-agnostic page state machine for PDF rendering. Delegates the actual page allocation and + * drawing to a platform-specific [PageLifecycle] implementation. + */ +internal class PdfPageController( + private val cursor: PdfCursor, + private val lifecycle: PageLifecycle, +) { + interface PageLifecycle { + /** Called after a new 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. */ + fun onPageEnding(pageNumber: Int) + } + + private var pageIndex = 0 + private var pageOpen = false + + /** Number of pages emitted so far. Equals the current page number while a page is open. */ + val pageCount: Int + get() = pageIndex + + fun ensurePage() { + if (!pageOpen) beginPage() + } + + fun newPageIfShort(spaceNeeded: Float) { + ensurePage() + if (cursor.fits(spaceNeeded) || cursor.isAtPageTop) return + finalizePage() + beginPage() + } + + fun finalizePage() { + if (!pageOpen) return + lifecycle.onPageEnding(pageIndex) + pageOpen = false + } + + private fun beginPage() { + pageIndex++ + pageOpen = true + cursor.reset() + lifecycle.onPageStarted(pageIndex) + } +} From dcbca6f318308047bca3ed80a06e9e3a8990b176 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 2 Jun 2026 14:01:20 +0200 Subject: [PATCH 04/20] add unit tests for PdfCursor and PdfPageController --- .../feature/pdf/render/PdfCursorTest.kt | 123 ++++++++++++ .../pdf/render/PdfPageControllerTest.kt | 176 ++++++++++++++++++ gradle/libs.versions.toml | 2 + 3 files changed, 301 insertions(+) create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt new file mode 100644 index 0000000000..06bf9f85d9 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt @@ -0,0 +1,123 @@ +/* + * 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 + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PdfCursorTest { + + @Test + fun `fresh cursor starts at the top margin`() { + val cursor = PdfCursor() + + assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) + assertTrue(cursor.isAtPageTop) + } + + @Test + fun `advance moves the cursor down by the given delta`() { + val cursor = PdfCursor() + val start = cursor.y + + cursor.advance(75f) + + assertEquals(start + 75f, cursor.y) + assertFalse(cursor.isAtPageTop) + } + + @Test + fun `moveTo sets the cursor to an absolute Y`() { + val cursor = PdfCursor() + + cursor.moveTo(400f) + + assertEquals(400f, cursor.y) + } + + @Test + fun `reset returns the cursor to the top margin`() { + val cursor = PdfCursor() + cursor.advance(300f) + + cursor.reset() + + assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) + assertTrue(cursor.isAtPageTop) + } + + @Test + fun `isAtPageTop reflects whether Y matches the top margin`() { + val cursor = PdfCursor() + assertTrue(cursor.isAtPageTop) + + cursor.advance(1f) + assertFalse(cursor.isAtPageTop) + + cursor.moveTo(PdfConfig.MARGIN.toFloat()) + assertTrue(cursor.isAtPageTop) + } + + @Test + fun `fits returns true when there is room above the bottom margin`() { + val cursor = PdfCursor() + + val available = PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN + assertTrue(cursor.fits(available.toFloat())) + assertTrue(cursor.fits(10f)) + } + + @Test + fun `fits returns false when the requested height overflows the bottom margin`() { + val cursor = PdfCursor() + + val available = PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN + assertFalse(cursor.fits(available + 1f)) + } + + @Test + fun `fits subtracts the footer reserve from the available space`() { + val cursor = PdfCursor() + val available = (PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN).toFloat() + cursor.footerReserve = 50f + + assertTrue(cursor.fits(available - 50f)) + assertFalse(cursor.fits(available - 49f)) + } + + @Test + fun `fits depends on the current Y position`() { + val cursor = PdfCursor() + val available = (PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN).toFloat() + + cursor.advance(100f) + + assertTrue(cursor.fits(available - 100f)) + assertFalse(cursor.fits(available - 99f)) + } + + @Test + fun `custom page height and margin are respected`() { + val cursor = PdfCursor(pageHeight = 200, margin = 10) + + assertEquals(10f, cursor.y) + // Usable height = 200 - 2*10 = 180. + assertTrue(cursor.fits(180f)) + assertFalse(cursor.fits(181f)) + } +} 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 new file mode 100644 index 0000000000..dccda1cf90 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt @@ -0,0 +1,176 @@ +/* + * 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 + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PdfPageControllerTest { + + private val lifecycle = + object : PdfPageController.PageLifecycle { + val events: MutableList = mutableListOf() + + override fun onPageStarted(pageNumber: Int) { + events += PageEvent.Started(pageNumber) + } + + override fun onPageEnding(pageNumber: Int) { + events += PageEvent.Ending(pageNumber) + } + } + private val cursor = PdfCursor() + private val controller = PdfPageController(cursor, lifecycle) + + @Test + fun `Should have zero pages at the start`() { + assertEquals(0, controller.pageCount) + assertTrue(lifecycle.events.isEmpty()) + } + + @Test + fun `ensurePage starts the a page`() { + controller.ensurePage() + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `ensurePage is idempotent while the page is open`() { + controller.ensurePage() + controller.ensurePage() + controller.ensurePage() + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `finalizePage does nothing if there is no page open`() { + controller.finalizePage() + + assertEquals(0, controller.pageCount) + assertTrue(lifecycle.events.isEmpty()) + } + + @Test + fun `finalizePage does nothing when the page is already closed`() { + controller.ensurePage() + controller.finalizePage() + controller.finalizePage() + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1), PageEvent.Ending(1)), lifecycle.events) + } + + @Test + fun `ensurePage followed by finalize emits start and end events`() { + controller.ensurePage() + controller.finalizePage() + + assertEquals(listOf(PageEvent.Started(1), PageEvent.Ending(1)), lifecycle.events) + } + + @Test + fun `newPageIfShort starts a page if there is none open`() { + controller.newPageIfShort(spaceNeeded = 10f) + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `newPageIfShort does not emit more pages if impossible to fit content in a new page`() { + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `newPageIfShort does nothing if the content fits in the current page`() { + controller.ensurePage() + lifecycle.events.clear() + + controller.newPageIfShort(spaceNeeded = 10f) + + assertEquals(1, controller.pageCount) + assertTrue(lifecycle.events.isEmpty()) + } + + @Test + fun `newPageIfShort starts a new page if the content overflows`() { + controller.ensurePage() + cursor.advance(100f) + lifecycle.events.clear() + + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertEquals(2, controller.pageCount) + assertEquals(listOf(PageEvent.Ending(1), PageEvent.Started(2)), lifecycle.events) + } + + @Test + fun `newPageIfShort should set the cursor at the start of the new page`() { + controller.ensurePage() + cursor.advance(500f) + + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) + } + + @Test + fun `Adding multiple pages emits the correct start and end events`() { + controller.ensurePage() + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + controller.finalizePage() + + assertEquals(3, controller.pageCount) + assertEquals( + listOf( + PageEvent.Started(1), + PageEvent.Ending(1), + PageEvent.Started(2), + PageEvent.Ending(2), + PageEvent.Started(3), + PageEvent.Ending(3), + ), + lifecycle.events, + ) + } + + @Test + fun `pageCount reflects the current page number while the page is open`() { + controller.ensurePage() + assertEquals(1, controller.pageCount) + + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + assertEquals(2, controller.pageCount) + } + + private sealed interface PageEvent { + data class Started(val pageNumber: Int) : PageEvent + + data class Ending(val pageNumber: Int) : PageEvent + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4b7d853b7a..2a0b445664 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ coreTestingVersion = "1.1.1" coreVersion = "1.7.0" coroutinesVersion = "1.11.0" detektVersion = "1.23.8" +exifInterfaceVersion = "1.4.2" espressoContribVersion = "3.7.0" firebaseBomVersion = "34.14.1" firebaseCrashlyticsGradleVersion = "3.0.7" @@ -91,6 +92,7 @@ androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4 androidx-core = { module = "androidx.test:core", version.ref = "coreVersion" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtxVersion" } androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } +androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifInterfaceVersion" } androidx-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espressoContribVersion" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoContribVersion" } androidx-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espressoContribVersion" } From 5a163df6c26834dee8b586a9669bebcf14c383ad Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 3 Jun 2026 18:33:39 +0200 Subject: [PATCH 05/20] extract common logic for the footer reserve and table building --- feature/pdf/build.gradle.kts | 2 +- .../feature/pdf/render/DocumentPdfCanvas.kt | 22 +++- .../feature/pdf/render/PdfCanvas.kt | 17 ++- .../feature/pdf/render/PdfTextPaints.kt | 15 +++ .../feature/pdf/render/PdfWriter.kt | 119 +++++++---------- .../feature/pdf/render/PdfCursor.kt | 7 +- .../feature/pdf/render/PdfGeometry.kt | 2 + .../pdf/render/components/PageFooterLayout.kt | 8 ++ .../pdf/render/components/TableLayout.kt | 112 ++++++++++++++++ .../pdf/render/components/TableRowLayout.kt | 90 ------------- .../feature/pdf/render/PdfCursorTest.kt | 27 ++-- .../pdf/render/PdfPageControllerTest.kt | 2 +- .../render/components/PageFooterLayoutTest.kt | 7 + .../render/components/PageHeaderLayoutTest.kt | 2 +- ...bleRowLayoutTest.kt => TableLayoutTest.kt} | 121 ++++++++++++++---- 15 files changed, 348 insertions(+), 205 deletions(-) create mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt delete mode 100644 feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt rename feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/{TableRowLayoutTest.kt => TableLayoutTest.kt} (51%) diff --git a/feature/pdf/build.gradle.kts b/feature/pdf/build.gradle.kts index cd7e2b192a..a3338a1368 100644 --- a/feature/pdf/build.gradle.kts +++ b/feature/pdf/build.gradle.kts @@ -49,7 +49,6 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.serialization.json) - implementation(libs.compose.ui) } } @@ -65,6 +64,7 @@ kotlin { dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.exifinterface) + implementation(libs.compose.ui) } } 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 5bbebca4f0..069da903a8 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 @@ -1,3 +1,18 @@ +/* + * 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 import android.graphics.Canvas @@ -9,7 +24,7 @@ import androidx.core.graphics.withTranslation import org.groundplatform.feature.pdf.render.image.PdfImage /** - * [PdfCanvas] that draws onto a real [android.graphics.pdf.PdfDocument], one page at a time. Image bitmaps are expected + * [PdfCanvas] that draws onto a real [PdfDocument], one page at a time. Image bitmaps are expected * to arrive at their on-page pixel size; the canvas does no further scaling. */ internal class DocumentPdfCanvas(private val pdf: PdfDocument) : PdfCanvas { @@ -30,7 +45,8 @@ internal class DocumentPdfCanvas(private val pdf: PdfDocument) : PdfCanvas { } override fun startPage(pageNumber: Int) { - val info = PdfDocument.PageInfo.Builder(PdfConfig.PAGE_WIDTH, PdfConfig.PAGE_HEIGHT, pageNumber).create() + val info = + PdfDocument.PageInfo.Builder(PdfConfig.PAGE_WIDTH, PdfConfig.PAGE_HEIGHT, pageNumber).create() currentPage = pdf.startPage(info) } @@ -52,4 +68,4 @@ internal class DocumentPdfCanvas(private val pdf: PdfDocument) : PdfCanvas { } private fun canvas(): Canvas = currentPage?.canvas ?: error("draw called with no page open") -} \ No newline at end of file +} 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 3fa449022e..8f532c7a94 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 @@ -1,3 +1,18 @@ +/* + * 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 import android.graphics.RectF @@ -28,4 +43,4 @@ internal object MeasurementPdfCanvas : PdfCanvas { override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) = Unit override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) = Unit -} \ No newline at end of file +} 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 f60a0f41a9..25b8c3913e 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 @@ -1,3 +1,18 @@ +/* + * 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 import android.graphics.Color 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 dabba56a6f..ebe78bfb38 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 @@ -1,3 +1,18 @@ +/* + * 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 import android.graphics.RectF @@ -16,9 +31,7 @@ import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Footer import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Header import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.QrBlock import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TEXT_MAX_WIDTH -import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TOP_GAP import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING -import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN import org.groundplatform.feature.pdf.render.PdfConfig.MAX_FOOTER_LINES import org.groundplatform.feature.pdf.render.PdfConfig.MAX_HEADER_VALUE_LINES import org.groundplatform.feature.pdf.render.PdfConfig.PHOTO_MAX_HEIGHT @@ -27,7 +40,7 @@ import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH import org.groundplatform.feature.pdf.render.components.PageFooterLayout import org.groundplatform.feature.pdf.render.components.PageHeaderLayout import org.groundplatform.feature.pdf.render.components.QrBlockLayout -import org.groundplatform.feature.pdf.render.components.TableRowLayout +import org.groundplatform.feature.pdf.render.components.TableLayout import org.groundplatform.feature.pdf.render.image.PdfImage import org.groundplatform.feature.pdf.render.image.PdfImageSet @@ -45,24 +58,11 @@ internal class PdfWriter( ) : PdfPageController.PageLifecycle { private val paints = PdfTextPaints() - private val cursor = PdfCursor() + private val footerLayout: StaticLayout = buildFooterLayout(footer) + private val cursor = + PdfCursor(footerReserve = PageFooterLayout.reserve(footerLayout.height.toFloat())) private val pageController = PdfPageController(cursor, this) - private var currentTableTopY: Float? = null - - private val footerLayout: StaticLayout - - init { - val footerLabel = footer.dataCollectorLabel - val footerText = - SpannableString("$footerLabel: ${footer.dataCollectorName}, ${footer.userEmail}").apply { - setSpan(StyleSpan(Typeface.BOLD), 0, footerLabel.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) - } - footerLayout = - staticLayout(footerText, paints.meta, FOOTER_TEXT_MAX_WIDTH, maxLines = MAX_FOOTER_LINES) - cursor.footerReserve = footerLayout.height + FOOTER_TOP_GAP - } - val pageCount: Int get() = pageController.pageCount @@ -72,7 +72,6 @@ internal class PdfWriter( } override fun onPageEnding(pageNumber: Int) { - flushTableDivider() drawPageFooter() pdfCanvas.finishPage() } @@ -92,8 +91,6 @@ internal class PdfWriter( fun drawTable(table: SubmissionPdfDocument.Table) { val rows = table.rows.takeIf { it.isNotEmpty() } ?: return pageController.ensurePage() - val x = MARGIN.toFloat() - cursor.advance(LINE_SPACING * 2) val label = SpannableString("${table.submissionLabel}: ${table.loiName}").apply { setSpan( @@ -103,8 +100,10 @@ internal class PdfWriter( Spanned.SPAN_INCLUSIVE_EXCLUSIVE, ) } - cursor.moveTo(drawText(label, x, cursor.y, USABLE_WIDTH, paints.title)) - cursor.advance(LINE_SPACING) + val labelLayout = staticLayout(label, paints.title, USABLE_WIDTH) + val tableLabel = TableLayout.getLabel(top = cursor.y, labelHeight = labelLayout.height.toFloat()) + drawStaticLayoutAt(labelLayout, tableLabel.labelOffset) + cursor.moveTo(tableLabel.nextCursorY) rows.forEach { row -> when (val answer = row.answer) { is Answer.Text -> @@ -121,7 +120,6 @@ internal class PdfWriter( ) } } - flushTableDivider() } fun finalizePage() { @@ -171,15 +169,15 @@ internal class PdfWriter( val layout = PageFooterLayout.compute(footerHeight = footerLayout.height.toFloat()) drawStaticLayoutAt(footerLayout, layout.footerTextOffset) totalPages?.let { total -> - drawText( - text = "${pageController.pageCount}/$total", - x = layout.pageNumberOffset.x, - y = layout.pageNumberOffset.y, - maxWidth = layout.pageNumberMaxWidth, - paint = paints.meta, - alignment = Layout.Alignment.ALIGN_OPPOSITE, - maxLines = 1, - ) + val pageNumber = + staticLayout( + "${pageController.pageCount}/$total", + paints.meta, + layout.pageNumberMaxWidth, + alignment = Layout.Alignment.ALIGN_OPPOSITE, + maxLines = 1, + ) + drawStaticLayoutAt(pageNumber, layout.pageNumberOffset) } } @@ -194,22 +192,16 @@ internal class PdfWriter( val questionHeight = questionLayout.height.toFloat() val answerHeight = answerLayout?.height?.toFloat() ?: 0f - pageController.newPageIfShort( - TableRowLayout.totalHeight(questionHeight, answerHeight, photoSize) - ) + pageController.newPageIfShort(TableLayout.getRowHeight(questionHeight, answerHeight, photoSize)) val rowLayout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = cursor.y, leftTextHeight = questionHeight, rightTextHeight = answerHeight, rightImageSize = photoSize, ) - if (currentTableTopY == null) { - currentTableTopY = cursor.y - pdfCanvas.drawLine(rowLayout.leftRowX, cursor.y, rowLayout.rightRowX, cursor.y) - } - + rowLayout.borderLines.forEach { drawLine(it) } drawStaticLayoutAt(questionLayout, rowLayout.leftTextOffset) if (answerLayout != null && rowLayout.rightTextOffset != null) { drawStaticLayoutAt(answerLayout, rowLayout.rightTextOffset) @@ -218,35 +210,6 @@ internal class PdfWriter( drawImage(photo, rowLayout.rightImageFrame, smoothScaling = true) } cursor.advance(rowLayout.totalHeight) - - pdfCanvas.drawLine(rowLayout.leftRowX, cursor.y, rowLayout.rightRowX, cursor.y) - } - - private fun flushTableDivider() { - val top = currentTableTopY ?: return - val midX = MARGIN + PdfConfig.TABLE_TASK_COLUMN_WIDTH.toFloat() - pdfCanvas.drawLine(midX, top, midX, cursor.y) - currentTableTopY = null - } - - /** - * Lays out [text] and draws it at ([x], [y]). - * - * @return the Y just below the drawn text. - */ - private fun drawText( - text: CharSequence, - x: Float, - y: Float, - maxWidth: Int, - paint: TextPaint, - alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL, - maxLines: Int = Int.MAX_VALUE, - ): Float { - if (text.isEmpty()) return y - val layout = staticLayout(text, paint, maxWidth, alignment, maxLines) - pdfCanvas.drawStaticLayout(layout, x, y) - return y + layout.height } private fun drawStaticLayoutAt(layout: StaticLayout, offset: PdfOffset) = @@ -255,6 +218,18 @@ internal class PdfWriter( private fun drawImage(image: PdfImage, frame: PdfRect, smoothScaling: Boolean) = pdfCanvas.drawImage(image, RectF(frame.x, frame.y, frame.right, frame.bottom), smoothScaling) + 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, paints.meta, FOOTER_TEXT_MAX_WIDTH, maxLines = MAX_FOOTER_LINES) + } + /** * Lays out [text] wrapped to [maxWidth]. When [maxLines] is set, overflow is ellipsized so a * single long value can't grow the layout unboundedly. diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt index 2c67ea1f02..ccd2b8139c 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt @@ -17,12 +17,13 @@ package org.groundplatform.feature.pdf.render /** Tracks the current vertical draw position on a page and the space reserved for the footer. */ internal class PdfCursor( + /** + * Space kept clear above the bottom margin for the footer. + */ + private val footerReserve: Float, private val pageHeight: Int = PdfConfig.PAGE_HEIGHT, private val margin: Int = PdfConfig.MARGIN, ) { - /** Space kept clear above the bottom margin for the footer; set once the footer is known. */ - var footerReserve: Float = 0f - var y: Float = margin.toFloat() private set 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 40b0fc8ce0..3b55da2103 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 @@ -30,6 +30,8 @@ internal data class PdfItemSize(val width: Float, val height: Float) 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) + /** Platform-agnostic rectangle defined by its top-left corner and dimensions. */ internal data class PdfRect(val x: Float, val y: Float, val width: Float, val height: Float) { val right: Float diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt index adf5b3976d..9abf5498de 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayout.kt @@ -16,6 +16,7 @@ package org.groundplatform.feature.pdf.render.components import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TEXT_MAX_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.FOOTER_TOP_GAP import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_HEIGHT import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_NUMBER_BAND_WIDTH @@ -37,6 +38,13 @@ internal data class PageFooterLayout( val pageNumberMaxWidth: Int, ) { companion object { + /** + * Vertical space the footer occupies, including the [FOOTER_TOP_GAP] separating it from page + * content. Feed this to [org.groundplatform.feature.pdf.render.PdfCursor] so pagination keeps + * the footer clear of content. + */ + fun reserve(footerHeight: Float): Float = footerHeight + FOOTER_TOP_GAP + fun compute(footerHeight: Float): PageFooterLayout { val top = PAGE_HEIGHT - MARGIN - footerHeight val left = MARGIN.toFloat() diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt new file mode 100644 index 0000000000..be1d885ae6 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt @@ -0,0 +1,112 @@ +/* + * 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.components + +import org.groundplatform.feature.pdf.render.PdfConfig.CELL_PADDING +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.TABLE_TASK_COLUMN_WIDTH +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.PdfItemSize +import org.groundplatform.feature.pdf.render.PdfLine +import org.groundplatform.feature.pdf.render.PdfOffset +import org.groundplatform.feature.pdf.render.PdfRect +internal object TableLayout { + /** + * Layout of a single two-column table row. Left cell holds a single text block; right cell may + * contain either text or an image. + * + * @param totalHeight Total height of the row including vertical padding. + * @param leftRowX X coordinate of the row's left edge. + * @param rightRowX X coordinate of the row's right edge. + * @param columnDividerX X coordinate of the vertical divider between the two columns. + * @param leftTextOffset Top-left position where the left cell text should be drawn. + * @param rightTextOffset Top-left position where the right cell text should be drawn, or null if + * the right cell has no text. + * @param rightImageFrame Frame (x, y, width, height) for the right cell image, or null if no + * image. + * @param borderLines The row's own frame: top border, bottom border, and column divider. + */ + data class Row( + val totalHeight: Float, + val leftRowX: Float, + val rightRowX: Float, + val columnDividerX: Float, + val leftTextOffset: PdfOffset, + val rightTextOffset: PdfOffset?, + val rightImageFrame: PdfRect?, + val borderLines: List, + ) + + data class Label(val labelOffset: PdfOffset, val nextCursorY: Float) + + fun getLabel(top: Float, labelHeight: Float): Label { + val labelTop = top + LINE_SPACING * 2 + return Label( + labelOffset = PdfOffset(MARGIN.toFloat(), labelTop), + nextCursorY = labelTop + labelHeight + LINE_SPACING, + ) + } + + /** Row height for the page-fit check, before the final row position is known. */ + fun getRowHeight( + leftTextHeight: Float, + rightTextHeight: Float, + rightImageSize: PdfItemSize?, + ): Float { + val imageHeight = rightImageSize?.height ?: 0f + val imageSpacing = if (imageHeight > 0f && rightTextHeight > 0f) LINE_SPACING else 0f + return maxOf(leftTextHeight, rightTextHeight + imageHeight + imageSpacing) + 2 * CELL_PADDING + } + + fun getRow( + rowTop: Float, + leftTextHeight: Float, + rightTextHeight: Float, + rightImageSize: PdfItemSize?, + ): Row { + val totalHeight = getRowHeight(leftTextHeight, rightTextHeight, rightImageSize) + + val left = MARGIN.toFloat() + val right = left + USABLE_WIDTH + val midX = left + TABLE_TASK_COLUMN_WIDTH + val rowBottom = rowTop + totalHeight + val contentTop = rowTop + CELL_PADDING + val rightCellLeft = midX + CELL_PADDING + + val rightTextOffset = if (rightTextHeight > 0f) PdfOffset(rightCellLeft, contentTop) else null + val rightImageFrame = rightImageSize?.let { + val y = contentTop + (rightTextOffset?.let { rightTextHeight + LINE_SPACING } ?: 0f) + PdfRect(rightCellLeft, y, rightImageSize.width, rightImageSize.height) + } + + return Row( + totalHeight = totalHeight, + leftRowX = left, + rightRowX = right, + columnDividerX = midX, + leftTextOffset = PdfOffset(left + CELL_PADDING, contentTop), + rightTextOffset = rightTextOffset, + rightImageFrame = rightImageFrame, + borderLines = + listOf( + PdfLine(startX = left, startY = rowTop, endX = right, endY = rowTop), + PdfLine(startX = left, startY = rowBottom, endX = right, endY = rowBottom), + PdfLine(startX = midX, startY = rowTop, endX = midX, endY = rowBottom), + ), + ) + } +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt deleted file mode 100644 index cf7e2de67c..0000000000 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayout.kt +++ /dev/null @@ -1,90 +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.components - -import org.groundplatform.feature.pdf.render.PdfConfig.CELL_PADDING -import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING -import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN -import org.groundplatform.feature.pdf.render.PdfConfig.TABLE_TASK_COLUMN_WIDTH -import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH -import org.groundplatform.feature.pdf.render.PdfItemSize -import org.groundplatform.feature.pdf.render.PdfOffset -import org.groundplatform.feature.pdf.render.PdfRect - -/** - * Pre-computed layout for a two-column table row. Left cell holds a single text block; right cell - * may contain either text or an image. - * - * @param totalHeight Total height of the row including vertical padding. - * @param leftRowX X coordinate of the row's left edge. - * @param rightRowX X coordinate of the row's right edge. - * @param columnDividerX X coordinate of the vertical divider between the two columns. - * @param leftTextOffset Top-left position where the left cell text should be drawn. - * @param rightTextOffset Top-left position where the right cell text should be drawn, or null if - * the right cell has no text. - * @param rightImageFrame Frame (x, y, width, height) for the right cell image, or null if no image. - */ -internal data class TableRowLayout( - val totalHeight: Float, - val leftRowX: Float, - val rightRowX: Float, - val columnDividerX: Float, - val leftTextOffset: PdfOffset, - val rightTextOffset: PdfOffset?, - val rightImageFrame: PdfRect?, -) { - companion object { - fun totalHeight( - leftTextHeight: Float, - rightTextHeight: Float, - rightImageSize: PdfItemSize?, - ): Float { - val imageHeight = rightImageSize?.height ?: 0f - val imageSpacing = if (imageHeight > 0f && rightTextHeight > 0f) LINE_SPACING else 0f - return maxOf(leftTextHeight, rightTextHeight + imageHeight + imageSpacing) + 2 * CELL_PADDING - } - - fun compute( - rowTop: Float, - leftTextHeight: Float, - rightTextHeight: Float, - rightImageSize: PdfItemSize?, - ): TableRowLayout { - val totalHeight = totalHeight(leftTextHeight, rightTextHeight, rightImageSize) - - val left = MARGIN.toFloat() - val midX = left + TABLE_TASK_COLUMN_WIDTH - val contentTop = rowTop + CELL_PADDING - val rightCellLeft = midX + CELL_PADDING - - val rightTextOffset = if (rightTextHeight > 0f) PdfOffset(rightCellLeft, contentTop) else null - val rightImageFrame = rightImageSize?.let { - val y = contentTop + (rightTextOffset?.let { rightTextHeight + LINE_SPACING } ?: 0f) - PdfRect(rightCellLeft, y, rightImageSize.width, rightImageSize.height) - } - - return TableRowLayout( - totalHeight = totalHeight, - leftRowX = left, - rightRowX = left + USABLE_WIDTH, - columnDividerX = midX, - leftTextOffset = PdfOffset(left + CELL_PADDING, contentTop), - rightTextOffset = rightTextOffset, - rightImageFrame = rightImageFrame, - ) - } - } -} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt index 06bf9f85d9..263343b1bd 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt @@ -24,7 +24,7 @@ class PdfCursorTest { @Test fun `fresh cursor starts at the top margin`() { - val cursor = PdfCursor() + val cursor = newCursor() assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) assertTrue(cursor.isAtPageTop) @@ -32,7 +32,7 @@ class PdfCursorTest { @Test fun `advance moves the cursor down by the given delta`() { - val cursor = PdfCursor() + val cursor = newCursor() val start = cursor.y cursor.advance(75f) @@ -43,7 +43,7 @@ class PdfCursorTest { @Test fun `moveTo sets the cursor to an absolute Y`() { - val cursor = PdfCursor() + val cursor = newCursor() cursor.moveTo(400f) @@ -52,7 +52,7 @@ class PdfCursorTest { @Test fun `reset returns the cursor to the top margin`() { - val cursor = PdfCursor() + val cursor = newCursor() cursor.advance(300f) cursor.reset() @@ -63,7 +63,7 @@ class PdfCursorTest { @Test fun `isAtPageTop reflects whether Y matches the top margin`() { - val cursor = PdfCursor() + val cursor = newCursor() assertTrue(cursor.isAtPageTop) cursor.advance(1f) @@ -75,7 +75,7 @@ class PdfCursorTest { @Test fun `fits returns true when there is room above the bottom margin`() { - val cursor = PdfCursor() + val cursor = newCursor() val available = PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN assertTrue(cursor.fits(available.toFloat())) @@ -84,7 +84,7 @@ class PdfCursorTest { @Test fun `fits returns false when the requested height overflows the bottom margin`() { - val cursor = PdfCursor() + val cursor = newCursor() val available = PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN assertFalse(cursor.fits(available + 1f)) @@ -92,9 +92,8 @@ class PdfCursorTest { @Test fun `fits subtracts the footer reserve from the available space`() { - val cursor = PdfCursor() + val cursor = newCursor(footerReserve = 50f) val available = (PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN).toFloat() - cursor.footerReserve = 50f assertTrue(cursor.fits(available - 50f)) assertFalse(cursor.fits(available - 49f)) @@ -102,7 +101,7 @@ class PdfCursorTest { @Test fun `fits depends on the current Y position`() { - val cursor = PdfCursor() + val cursor = newCursor() val available = (PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN).toFloat() cursor.advance(100f) @@ -113,11 +112,17 @@ class PdfCursorTest { @Test fun `custom page height and margin are respected`() { - val cursor = PdfCursor(pageHeight = 200, margin = 10) + val cursor = newCursor(pageHeight = 200, margin = 10) assertEquals(10f, cursor.y) // Usable height = 200 - 2*10 = 180. assertTrue(cursor.fits(180f)) assertFalse(cursor.fits(181f)) } + + private fun newCursor( + footerReserve: Float = 0f, + pageHeight: Int = PdfConfig.PAGE_HEIGHT, + margin: Int = PdfConfig.MARGIN, + ) = PdfCursor(footerReserve = footerReserve, pageHeight = pageHeight, margin = margin) } 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 dccda1cf90..4ad96927a6 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 @@ -33,7 +33,7 @@ class PdfPageControllerTest { events += PageEvent.Ending(pageNumber) } } - private val cursor = PdfCursor() + private val cursor = PdfCursor(footerReserve = 0f) private val controller = PdfPageController(cursor, lifecycle) @Test diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt index e15a3f4c36..8b56b1b98b 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageFooterLayoutTest.kt @@ -88,4 +88,11 @@ class PageFooterLayoutTest { assertEquals(pageHeight.toFloat(), layout.footerTextOffset.y + footerHeight + margin) } + + @Test + fun `reserve adds the top gap to the footer height`() { + val footerHeight = 12f + + assertEquals(footerHeight + PdfConfig.FOOTER_TOP_GAP, PageFooterLayout.reserve(footerHeight)) + } } diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt index 792fa0e62d..b15573a95a 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/PageHeaderLayoutTest.kt @@ -40,7 +40,7 @@ class PageHeaderLayoutTest { assertTrue(center < right) assertEquals(headerColumnGap.toFloat(), center - (left + width)) assertEquals(headerColumnGap.toFloat(), right - (center + width)) - assertTrue(3 * width + 2 * headerColumnGap.compareTo(usableWidth) <= 0) + assertTrue(3 * width + 2 * headerColumnGap <= usableWidth) } @Test diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableLayoutTest.kt similarity index 51% rename from feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt rename to feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableLayoutTest.kt index f8b54c350a..287a7341da 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableRowLayoutTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/components/TableLayoutTest.kt @@ -22,9 +22,10 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import org.groundplatform.feature.pdf.render.PdfConfig import org.groundplatform.feature.pdf.render.PdfItemSize +import org.groundplatform.feature.pdf.render.PdfLine import org.groundplatform.feature.pdf.render.PdfOffset -class TableRowLayoutTest { +class TableLayoutTest { private val cellPadding = PdfConfig.CELL_PADDING.toFloat() private val lineSpacing = PdfConfig.LINE_SPACING @@ -33,21 +34,47 @@ class TableRowLayoutTest { private val taskColumnWidth = PdfConfig.TABLE_TASK_COLUMN_WIDTH @Test - fun `totalHeight with only left text returns left height plus padding`() { + fun `label sits below a top gap at the left margin`() { + val layout = TableLayout.getLabel(top = 100f, labelHeight = 14f) + + assertEquals(PdfOffset(margin, 100f + 2 * lineSpacing), layout.labelOffset) + } + + @Test + fun `label leaves a bottom gap before the first row`() { + val top = 100f + val labelHeight = 14f + + val layout = TableLayout.getLabel(top = top, labelHeight = labelHeight) + + assertEquals(top + 2 * lineSpacing + labelHeight + lineSpacing, layout.nextCursorY) + } + + @Test + fun `taller label pushes the first row further down`() { + val short = TableLayout.getLabel(top = 0f, labelHeight = 10f) + val tall = TableLayout.getLabel(top = 0f, labelHeight = 30f) + + assertTrue(short.nextCursorY < tall.nextCursorY) + assertEquals(20f, tall.nextCursorY - short.nextCursorY) + } + + @Test + fun `rowHeight with only left text returns left height plus padding`() { val height = - TableRowLayout.totalHeight(leftTextHeight = 30f, rightTextHeight = 0f, rightImageSize = null) + TableLayout.getRowHeight(leftTextHeight = 30f, rightTextHeight = 0f, rightImageSize = null) assertEquals(30f + 2 * cellPadding, height) } @Test - fun `totalHeight picks the taller content height`() { + fun `rowHeight picks the taller content height`() { val tallerLeft = - TableRowLayout.totalHeight(leftTextHeight = 50f, rightTextHeight = 20f, rightImageSize = null) + TableLayout.getRowHeight(leftTextHeight = 50f, rightTextHeight = 20f, rightImageSize = null) val tallerRight = - TableRowLayout.totalHeight(leftTextHeight = 10f, rightTextHeight = 60f, rightImageSize = null) + TableLayout.getRowHeight(leftTextHeight = 10f, rightTextHeight = 60f, rightImageSize = null) val tallerImageRight = - TableRowLayout.totalHeight( + TableLayout.getRowHeight( leftTextHeight = 20f, rightTextHeight = 0f, rightImageSize = PdfItemSize(width = 100f, height = 80f), @@ -59,9 +86,9 @@ class TableRowLayoutTest { } @Test - fun `totalHeight with both right text and image stacks them with line spacing`() { + fun `rowHeight with both right text and image stacks them with line spacing`() { val height = - TableRowLayout.totalHeight( + TableLayout.getRowHeight( leftTextHeight = 10f, rightTextHeight = 20f, rightImageSize = PdfItemSize(width = 100f, height = 80f), @@ -71,9 +98,9 @@ class TableRowLayoutTest { } @Test - fun `compute always places left text at the row's top-left content area`() { + fun `row always places left text at the row's top-left content area`() { val layout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = 100f, leftTextHeight = 20f, rightTextHeight = 0f, @@ -84,9 +111,9 @@ class TableRowLayoutTest { } @Test - fun `compute returns null right offsets when right cell has no content`() { + fun `row returns null right offsets when right cell has no content`() { val layout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = 0f, leftTextHeight = 20f, rightTextHeight = 0f, @@ -98,9 +125,9 @@ class TableRowLayoutTest { } @Test - fun `compute places right text at the right cell's top`() { + fun `row places right text at the right cell's top`() { val layout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = 50f, leftTextHeight = 20f, rightTextHeight = 20f, @@ -113,10 +140,10 @@ class TableRowLayoutTest { } @Test - fun `compute places image at the right cell's top`() { + fun `row places image at the right cell's top`() { val imageSize = PdfItemSize(width = 80f, height = 60f) val layout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = 50f, leftTextHeight = 20f, rightTextHeight = 0f, @@ -135,9 +162,9 @@ class TableRowLayoutTest { } @Test - fun `compute sets row bounds and divider from page geometry`() { + fun `row sets bounds and divider from page geometry`() { val layout = - TableRowLayout.compute( + TableLayout.getRow( rowTop = 0f, leftTextHeight = 20f, rightTextHeight = 0f, @@ -152,12 +179,62 @@ class TableRowLayoutTest { } @Test - fun `compute totalHeight matches the static helper`() { + fun `row frames itself with top, bottom, and column-divider border lines`() { + val rowTop = 100f + val layout = + TableLayout.getRow( + rowTop = rowTop, + leftTextHeight = 20f, + rightTextHeight = 20f, + rightImageSize = null, + ) + + val rowBottom = rowTop + layout.totalHeight + val right = margin + usableWidth + val midX = margin + taskColumnWidth + assertEquals( + listOf( + PdfLine(margin, rowTop, right, rowTop), + PdfLine(margin, rowBottom, right, rowBottom), + PdfLine(midX, rowTop, midX, rowBottom), + ), + layout.borderLines, + ) + } + + @Test + fun `consecutive rows produce abutting borders so the divider reads as one continuous line`() { + val first = + TableLayout.getRow( + rowTop = 50f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = null, + ) + val second = + TableLayout.getRow( + rowTop = 50f + first.totalHeight, + leftTextHeight = 30f, + rightTextHeight = 0f, + rightImageSize = null, + ) + + // The first row's bottom border sits exactly where the second row's top border begins. + assertEquals(first.borderLines[1].startY, second.borderLines[0].startY) + // The per-row divider segments share an X and meet end-to-start, forming one unbroken line. + val firstDivider = first.borderLines[2] + val secondDivider = second.borderLines[2] + assertEquals(firstDivider.endX, secondDivider.startX) + assertEquals(firstDivider.endY, secondDivider.startY) + } + + @Test + fun `row totalHeight matches the rowHeight helper`() { val left = 30f val right = 20f val image = PdfItemSize(width = 80f, height = 60f) - val layout = TableRowLayout.compute(rowTop = 0f, left, right, image) + val layout = TableLayout.getRow(rowTop = 0f, left, right, image) - assertEquals(TableRowLayout.totalHeight(left, right, image), layout.totalHeight) + assertEquals(TableLayout.getRowHeight(left, right, image), layout.totalHeight) } } From 90a1d81a45039543c6d0c8e36bca17bf92254b1f Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 9 Jun 2026 15:07:22 +0200 Subject: [PATCH 06/20] fix code formatting --- .../ui/components/qrcode/QrCodeGenerator.kt | 5 +--- .../feature/pdf/AndroidPdfImageProvider.kt | 30 +++++++++++-------- .../feature/pdf/AndroidPdfRenderer.kt | 2 +- .../feature/pdf/render/PdfCursor.kt | 4 +-- .../pdf/render/components/TableLayout.kt | 1 + 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt index 94c0a829a9..3e29152b76 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.kt @@ -41,9 +41,7 @@ const val MAX_QR_BYTES_WITH_LOGO = 1000 * high error correction (ECC level H), which can tolerate approximately 30% data loss. */ const val LOGO_SIZE_FRACTION = 0.15f -/** - * PDF document has more space to display the QR code, so we can use a larger fraction - */ +/** PDF document has more space to display the QR code, so we can use a larger fraction. */ const val PDF_LOGO_SIZE_FRACTION = 0.25f /** @@ -68,7 +66,6 @@ fun generateQrBitmap( internal fun fitsLogoCapacity(content: String): Boolean = content.encodeToByteArray().size <= MAX_QR_BYTES_WITH_LOGO - /** Draws [logo] centered over the receiver, scaled to [fraction] of its size, into a new bitmap. */ private fun ImageBitmap.withCenteredLogo(logo: ImageBitmap, fraction: Float): ImageBitmap { val output = ImageBitmap(width, height) 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 95bf67e109..5472474fb9 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 @@ -92,10 +92,14 @@ class AndroidPdfImageProvider( val rootDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: return null val filename = remoteFilename.substringAfterLast('/') val file = File(rootDir, filename) - if (!file.exists()) return null - val decoded = runCatching { decodeSubsampled(file.absolutePath) }.getOrNull() ?: return null - val oriented = applyExifOrientation(file, decoded) - return oriented.downscaledTo(photoMaxWidthPx, photoMaxHeightPx) + return if (file.exists()) { + runCatching { decodeSubsampled(file.absolutePath) } + .getOrNull() + ?.let { decodedBitmap -> + val oriented = applyExifOrientation(file, decodedBitmap) + oriented.downscaledTo(photoMaxWidthPx, photoMaxHeightPx) + } + } else null } /** Decodes the photo subsampled to roughly the largest size it can occupy in the PDF. */ @@ -111,15 +115,6 @@ class AndroidPdfImageProvider( return BitmapFactory.decodeFile(path, options) } - /** Largest power-of-two sample size that keeps both dimensions at or above [target] pixels. */ - private fun calculateInSampleSize(width: Int, height: Int, target: Int): Int { - var sampleSize = 1 - while (width / (sampleSize * 2) >= target && height / (sampleSize * 2) >= target) { - sampleSize *= 2 - } - return sampleSize - } - /** * Rotates [bitmap] to match the EXIF orientation in [file]. Returns the original bitmap if no * rotation is needed. @@ -137,6 +132,15 @@ class AndroidPdfImageProvider( } } +/** Largest power-of-two sample size that keeps both dimensions at or above [target] pixels. */ +internal fun calculateInSampleSize(width: Int, height: Int, target: Int): Int { + var sampleSize = 1 + while (width / (sampleSize * 2) >= target && height / (sampleSize * 2) >= target) { + sampleSize *= 2 + } + return sampleSize +} + /** * Returns a bitmap scaled down to fit [maxWidthPx] × [maxHeightPx], preserving aspect ratio and * never upscaling. Uses progressive halving for efficiency; intermediate bitmaps are recycled. 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 d237510484..ef5670dfc5 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 @@ -69,4 +69,4 @@ class AndroidPdfRenderer : PdfRenderer { drawTable(document.table) finalizePage() } -} \ No newline at end of file +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt index ccd2b8139c..4559f1e115 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt @@ -17,9 +17,7 @@ package org.groundplatform.feature.pdf.render /** Tracks the current vertical draw position on a page and the space reserved for the footer. */ internal class PdfCursor( - /** - * Space kept clear above the bottom margin for the footer. - */ + /** Space kept clear above the bottom margin for the footer. */ private val footerReserve: Float, private val pageHeight: Int = PdfConfig.PAGE_HEIGHT, private val margin: Int = PdfConfig.MARGIN, diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt index be1d885ae6..abae4d1589 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/components/TableLayout.kt @@ -24,6 +24,7 @@ import org.groundplatform.feature.pdf.render.PdfItemSize import org.groundplatform.feature.pdf.render.PdfLine import org.groundplatform.feature.pdf.render.PdfOffset import org.groundplatform.feature.pdf.render.PdfRect + internal object TableLayout { /** * Layout of a single two-column table row. Left cell holds a single text block; right cell may From b4b1fa86f79978a09a874de7029da99bb7ba9492 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 9 Jun 2026 15:07:32 +0200 Subject: [PATCH 07/20] improve test coverage --- .../components/qrcode/QrCodeGeneratorTest.kt | 53 ++++++++++++ .../pdf/AndroidPdfImageProviderTest.kt | 51 +++++++++++ .../feature/pdf/render/PdfGeometryTest.kt | 85 +++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 core/ui/src/commonTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorTest.kt create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfGeometryTest.kt diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorTest.kt b/core/ui/src/commonTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorTest.kt new file mode 100644 index 0000000000..edd4e8891f --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorTest.kt @@ -0,0 +1,53 @@ +/* + * 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.ui.components.qrcode + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class QrCodeGeneratorTest { + + @Test + fun `fitsLogoCapacity is true for empty content`() { + assertTrue(fitsLogoCapacity("")) + } + + @Test + fun `fitsLogoCapacity is true for content below the byte limit`() { + assertTrue(fitsLogoCapacity("1".repeat(MAX_QR_BYTES_WITH_LOGO - 1))) + } + + @Test + fun `fitsLogoCapacity is true for content exactly at the byte limit`() { + assertTrue(fitsLogoCapacity("1".repeat(MAX_QR_BYTES_WITH_LOGO))) + } + + @Test + fun `fitsLogoCapacity is false for content above the byte limit`() { + assertFalse(fitsLogoCapacity("1".repeat(MAX_QR_BYTES_WITH_LOGO + 1))) + } + + @Test + fun `fitsLogoCapacity counts UTF-8 bytes rather than characters`() { + // Each "€" encodes to 3 UTF-8 bytes, so this exceeds the limit despite the smaller char count. + val charCount = MAX_QR_BYTES_WITH_LOGO / 3 + 1 + val content = "€".repeat(charCount) + + assertTrue(content.length <= MAX_QR_BYTES_WITH_LOGO) + assertFalse(fitsLogoCapacity(content)) + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt new file mode 100644 index 0000000000..c6da38dade --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt @@ -0,0 +1,51 @@ +/* + * 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 + +import kotlin.test.assertEquals +import org.junit.Test + +class AndroidPdfImageProviderTest { + + @Test + fun `calculateInSampleSize returns 1 when the image is smaller than the target`() { + assertEquals(1, calculateInSampleSize(width = 100, height = 100, target = 200)) + } + + @Test + fun `calculateInSampleSize returns 1 when the image equals the target`() { + assertEquals(1, calculateInSampleSize(width = 200, height = 200, target = 200)) + } + + @Test + fun `calculateInSampleSize halves once when both dimensions are at least double the target`() { + // 400/2 = 200 >= 200, but 400/4 = 100 < 200, so the largest valid power of two is 2. + assertEquals(2, calculateInSampleSize(width = 400, height = 400, target = 200)) + } + + @Test + fun `calculateInSampleSize returns the largest power of two that keeps both axes above target`() { + // 800/4 = 200 >= 200, 800/8 = 100 < 200, so the result is 4. + assertEquals(4, calculateInSampleSize(width = 800, height = 800, target = 200)) + } + + @Test + fun `calculateInSampleSize is limited by the smaller dimension`() { + // Width could be sampled further, but height (300) only tolerates a sample size of 1 + // because 300/2 = 150 < 200. + assertEquals(1, calculateInSampleSize(width = 1600, height = 300, target = 200)) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfGeometryTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfGeometryTest.kt new file mode 100644 index 0000000000..aecfff52c0 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfGeometryTest.kt @@ -0,0 +1,85 @@ +/* + * 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 + +import kotlin.math.roundToInt +import kotlin.test.Test +import kotlin.test.assertEquals + +class PdfGeometryTest { + + @Test + fun `fitInside scales down to fit the width constraint`() { + val result = fitInside(width = 200, height = 100, maxWidth = 100, maxHeight = 100) + + assertEquals(100f, result.width) + assertEquals(50f, result.height) + } + + @Test + fun `fitInside scales down to fit the height constraint`() { + val result = fitInside(width = 100, height = 200, maxWidth = 100, maxHeight = 100) + + assertEquals(50f, result.width) + assertEquals(100f, result.height) + } + + @Test + fun `fitInside preserves aspect ratio`() { + val result = fitInside(width = 400, height = 300, maxWidth = 200, maxHeight = 200) + + assertEquals(200f, result.width) + assertEquals(150f, result.height) + assertEquals(result.width / result.height, 400f / 300f) + } + + @Test + fun `fitInside never upscales when the item already fits`() { + val result = fitInside(width = 50, height = 30, maxWidth = 100, maxHeight = 100) + + assertEquals(50f, result.width) + assertEquals(30f, result.height) + } + + @Test + fun `fitInside returns the same size when dimensions equal the bounds`() { + val result = fitInside(width = 100, height = 100, maxWidth = 100, maxHeight = 100) + + assertEquals(100f, result.width) + assertEquals(100f, result.height) + } + + @Test + fun `pointsToRenderPixels converts points to pixels at the configured DPI`() { + // 72 points = 1 inch, which at IMAGE_RENDER_DPI yields exactly that many pixels. + assertEquals(PdfConfig.IMAGE_RENDER_DPI.roundToInt(), pointsToRenderPixels(72f)) + } + + @Test + fun `pointsToRenderPixels scales linearly and rounds to the nearest pixel`() { + assertEquals(0, pointsToRenderPixels(0f)) + assertEquals((36f / 72f * PdfConfig.IMAGE_RENDER_DPI).roundToInt(), pointsToRenderPixels(36f)) + assertEquals((10f / 72f * PdfConfig.IMAGE_RENDER_DPI).roundToInt(), pointsToRenderPixels(10f)) + } + + @Test + fun `PdfRect exposes right and bottom derived from origin and size`() { + val rect = PdfRect(x = 10f, y = 20f, width = 30f, height = 40f) + + assertEquals(40f, rect.right) + assertEquals(60f, rect.bottom) + } +} From 3772ba2be710f9ff12f6bd71e5ef30280171e208 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 9 Jun 2026 16:27:55 +0200 Subject: [PATCH 08/20] add tests for PdfWriter pagination --- .../pdf/render/PdfWriterPaginationTest.kt | 83 +++++++++++++++++++ .../feature/pdf/AndroidPdfRenderer.kt | 13 ++- .../feature/pdf/render/PdfWriter.kt | 15 +++- 3 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt new file mode 100644 index 0000000000..2913908bcf --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt @@ -0,0 +1,83 @@ +/* + * 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 + +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.image.PdfImageSet +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PdfWriterPaginationTest { + + @Test + fun `measurement and draw passes emit the same page count`() { + val measuredPages = renderPageCount(totalPages = null) + + val drawnPages = renderPageCount(totalPages = measuredPages) + + assertTrue(measuredPages > 1) + assertEquals(measuredPages, drawnPages) + } + + private fun renderPageCount(totalPages: Int?): Int = + newPdfWriter(TEST_PDF_DOCUMENT, totalPages).apply { drawDocument(TEST_PDF_DOCUMENT) }.pageCount + + private fun newPdfWriter(document: SubmissionPdfDocument, totalPages: Int?): PdfWriter = + PdfWriter( + pdfCanvas = MeasurementPdfCanvas, + images = PdfImageSet(emptyMap()), + totalPages = totalPages, + header = document.header, + footer = document.footer, + ) + + private companion object { + val TEST_PDF_DOCUMENT = + SubmissionPdfDocument( + header = + SubmissionPdfDocument.Header( + surveyLabel = "Survey", + surveyName = "Survey name", + jobLabel = "Job", + jobName = "Job name", + timestamp = "timestamp", + ), + qrBlock = SubmissionPdfDocument.QrBlock(scanCaption = "Scan"), + footer = + SubmissionPdfDocument.Footer( + dataCollectorLabel = "Collector", + dataCollectorName = "John Doe", + userEmail = "user@gmail.com", + ), + table = + SubmissionPdfDocument.Table( + submissionLabel = "Submission", + loiName = "Plot 42", + rows = + List(200) { index -> + SubmissionPdfDocument.Row( + question = "Question $index", + answer = SubmissionPdfDocument.Answer.Text(listOf("Answer $index")), + ) + }, + ), + ) + } +} 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 ef5670dfc5..0881714f32 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 @@ -40,7 +40,8 @@ class AndroidPdfRenderer : PdfRenderer { val totalPages = measurePageCount(document, images) val pdf = PdfDocument() try { - writer(document, images, DocumentPdfCanvas(pdf), totalPages = totalPages).draw(document) + writer(document, images, DocumentPdfCanvas(pdf), totalPages = totalPages) + .drawDocument(document) File(outputPath).outputStream().use { pdf.writeTo(it) } } finally { pdf.close() @@ -48,7 +49,9 @@ class AndroidPdfRenderer : PdfRenderer { } private fun measurePageCount(document: SubmissionPdfDocument, images: PdfImageSet): Int = - writer(document, images, MeasurementPdfCanvas, totalPages = null).draw(document).pageCount + writer(document, images, MeasurementPdfCanvas, totalPages = null) + .apply { drawDocument(document) } + .pageCount private fun writer( document: SubmissionPdfDocument, @@ -63,10 +66,4 @@ class AndroidPdfRenderer : PdfRenderer { footer = document.footer, totalPages = totalPages, ) - - private fun PdfWriter.draw(document: SubmissionPdfDocument): PdfWriter = apply { - drawQrBlock(document.qrBlock) - drawTable(document.table) - finalizePage() - } } 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 ebe78bfb38..eb5655d4f2 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 @@ -76,7 +76,13 @@ internal class PdfWriter( pdfCanvas.finishPage() } - fun drawQrBlock(block: QrBlock) { + fun drawDocument(document: SubmissionPdfDocument) { + drawQrBlock(document.qrBlock) + drawTable(document.table) + finalizePage() + } + + private fun drawQrBlock(block: QrBlock) { val qr = images[PdfImageSet.ImageRef.Qr] ?: return pageController.ensurePage() val captionLayout = @@ -88,7 +94,7 @@ internal class PdfWriter( cursor.moveTo(layout.nextCursorY) } - fun drawTable(table: SubmissionPdfDocument.Table) { + private fun drawTable(table: SubmissionPdfDocument.Table) { val rows = table.rows.takeIf { it.isNotEmpty() } ?: return pageController.ensurePage() val label = @@ -101,7 +107,8 @@ internal class PdfWriter( ) } val labelLayout = staticLayout(label, paints.title, USABLE_WIDTH) - val tableLabel = TableLayout.getLabel(top = cursor.y, labelHeight = labelLayout.height.toFloat()) + val tableLabel = + TableLayout.getLabel(top = cursor.y, labelHeight = labelLayout.height.toFloat()) drawStaticLayoutAt(labelLayout, tableLabel.labelOffset) cursor.moveTo(tableLabel.nextCursorY) rows.forEach { row -> @@ -122,7 +129,7 @@ internal class PdfWriter( } } - fun finalizePage() { + private fun finalizePage() { pageController.finalizePage() } From 0eb9444cb31b483ad7283347b5d6313bb9025ea1 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 9 Jun 2026 17:06:47 +0200 Subject: [PATCH 09/20] add logging for AndroidPdfImageProvider --- feature/pdf/build.gradle.kts | 1 + .../groundplatform/feature/pdf/AndroidPdfImageProvider.kt | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/feature/pdf/build.gradle.kts b/feature/pdf/build.gradle.kts index a3338a1368..26855f6373 100644 --- a/feature/pdf/build.gradle.kts +++ b/feature/pdf/build.gradle.kts @@ -65,6 +65,7 @@ kotlin { implementation(libs.androidx.core.ktx) implementation(libs.androidx.exifinterface) implementation(libs.compose.ui) + implementation(libs.timber) } } 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 5472474fb9..7bdf047f78 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,6 +33,7 @@ import org.groundplatform.feature.pdf.render.image.PdfImageSet import org.groundplatform.feature.pdf.render.pointsToRenderPixels import org.groundplatform.ui.components.qrcode.PDF_LOGO_SIZE_FRACTION import org.groundplatform.ui.components.qrcode.generateQrBitmap +import timber.log.Timber /** * Android implementation of [PdfImageProvider]. @@ -86,6 +87,7 @@ class AndroidPdfImageProvider( .asAndroidBitmap() .downscaledTo(qrMaxPx, qrMaxPx) } + .onFailure { Timber.e(it, "Failed to generate QR code bitmap for PDF report") } .getOrNull() private fun loadPhotoBitmap(remoteFilename: String): Bitmap? { @@ -94,7 +96,10 @@ class AndroidPdfImageProvider( val file = File(rootDir, filename) return if (file.exists()) { runCatching { decodeSubsampled(file.absolutePath) } - .getOrNull() + .getOrElse { + Timber.e(it, "Failed to decode subsampled photo for PDF report") + null + } ?.let { decodedBitmap -> val oriented = applyExifOrientation(file, decodedBitmap) oriented.downscaledTo(photoMaxWidthPx, photoMaxHeightPx) From d7bd17c8da12a7a85ba8d607bd6524ff031951f5 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 9 Jun 2026 17:21:41 +0200 Subject: [PATCH 10/20] add todo comment about iOS support for the implementations --- .../org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt | 2 ++ .../org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt | 2 ++ .../kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt | 2 ++ .../org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt | 2 ++ 4 files changed, 8 insertions(+) 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 7bdf047f78..2ae401b748 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 @@ -44,6 +44,8 @@ import timber.log.Timber * @param context application context used for resource access and file lookups. * @param logoDrawableRes resource id of the centre logo bitmap. Caller supplies the app's branding. */ +// TODO: Add equivalent iOS implementation +// Issue URL: https://github.com/google/ground-android/issues/3775 class AndroidPdfImageProvider( private val context: Context, @DrawableRes private val logoDrawableRes: Int, diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt index 1ae8029940..1d2e9e7f1a 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt @@ -20,6 +20,8 @@ import java.io.File private const val REPORTS_SUBDIR = "reports" +// TODO: Add equivalent iOS implementation +// Issue URL: https://github.com/google/ground-android/issues/3775 class AndroidPdfOutputProvider(private val context: Context) : PdfOutputProvider { private val reportsDir 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 0881714f32..11b4c6bf2c 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 @@ -29,6 +29,8 @@ import org.groundplatform.feature.pdf.render.image.PdfImageSet * [PdfWriter]; the [PdfCanvas] decides whether each pass writes to a real [PdfDocument] or just * counts pages. */ +// TODO: Add equivalent iOS implementation +// Issue URL: https://github.com/google/ground-android/issues/3775 class AndroidPdfRenderer : PdfRenderer { override suspend fun render( diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt index 80f294da51..60dcd93559 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt @@ -21,6 +21,8 @@ import androidx.core.content.FileProvider import java.io.File /** Launches the system share sheet or external viewer for a report file via [FileProvider]. */ +// TODO: Add equivalent iOS implementation +// Issue URL: https://github.com/google/ground-android/issues/3775 class AndroidPdfReportLauncher( private val context: Context, private val fileProviderAuthority: String, From 4762a2ad84542b12ea3ccf7bac93416e78cacf20 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 9 Jun 2026 18:40:36 +0200 Subject: [PATCH 11/20] simplify AndroidPdfImageProvider --- .../pdf/AndroidPdfImageProviderTest.kt | 79 ++++++++++--- .../feature/pdf/AndroidPdfImageProvider.kt | 111 ++++++++---------- .../feature/pdf/AndroidPdfOutputProvider.kt | 2 +- .../feature/pdf/AndroidPdfRenderer.kt | 2 +- .../feature/pdf/AndroidPdfReportLauncher.kt | 2 +- 5 files changed, 120 insertions(+), 76 deletions(-) diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt index c6da38dade..af4a8d0175 100644 --- a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt @@ -15,37 +15,88 @@ */ package org.groundplatform.feature.pdf +import android.graphics.Bitmap import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class AndroidPdfImageProviderTest { @Test - fun `calculateInSampleSize returns 1 when the image is smaller than the target`() { - assertEquals(1, calculateInSampleSize(width = 100, height = 100, target = 200)) + fun `calculateInSampleSize does not subsample when image already fits within 2x`() { + assertEquals(1, calculateInSampleSize(width = 100, height = 100, maxWidth = 60, maxHeight = 60)) } @Test - fun `calculateInSampleSize returns 1 when the image equals the target`() { - assertEquals(1, calculateInSampleSize(width = 200, height = 200, target = 200)) + fun `calculateInSampleSize halves a square image down towards the box`() { + assertEquals(2, calculateInSampleSize(width = 100, height = 100, maxWidth = 50, maxHeight = 50)) } @Test - fun `calculateInSampleSize halves once when both dimensions are at least double the target`() { - // 400/2 = 200 >= 200, but 400/4 = 100 < 200, so the largest valid power of two is 2. - assertEquals(2, calculateInSampleSize(width = 400, height = 400, target = 200)) + fun `calculateInSampleSize subsamples a typical landscape photo`() { + assertEquals( + 2, + calculateInSampleSize(width = 4000, height = 3000, maxWidth = 1346, maxHeight = 1108), + ) } @Test - fun `calculateInSampleSize returns the largest power of two that keeps both axes above target`() { - // 800/4 = 200 >= 200, 800/8 = 100 < 200, so the result is 4. - assertEquals(4, calculateInSampleSize(width = 800, height = 800, target = 200)) + fun `calculateInSampleSize subsamples a tall image on its binding axis`() { + assertEquals( + 4, + calculateInSampleSize(width = 1000, height = 5000, maxWidth = 1346, maxHeight = 1108), + ) } @Test - fun `calculateInSampleSize is limited by the smaller dimension`() { - // Width could be sampled further, but height (300) only tolerates a sample size of 1 - // because 300/2 = 150 < 200. - assertEquals(1, calculateInSampleSize(width = 1600, height = 300, target = 200)) + fun `calculateInSampleSize never upsamples a tiny image`() { + assertEquals(1, calculateInSampleSize(width = 10, height = 10, maxWidth = 50, maxHeight = 50)) } + + @Test + fun `calculateInSampleSize leaves less than a 2x downscale for any input`() { + val maxWidth = 1346 + val maxHeight = 1108 + val dimensions = listOf(5000 to 1000, 1000 to 5000, 4000 to 3000, 3000 to 4000, 8000 to 8000) + for ((width, height) in dimensions) { + val sampleSize = calculateInSampleSize(width, height, maxWidth, maxHeight) + val decodedWidth = width / sampleSize + val decodedHeight = height / sampleSize + val fitScale = minOf(maxWidth.toFloat() / decodedWidth, maxHeight.toFloat() / decodedHeight) + assertTrue(fitScale > 0.5f) + } + } + + @Test + fun `scaledToFit returns the same bitmap when it already fits`() { + val bitmap = bitmap(10, 10) + assertSame(bitmap, bitmap.scaledToFit(maxWidth = 50, maxHeight = 50)) + } + + @Test + fun `scaledToFit returns the same bitmap when it exactly matches the box`() { + val bitmap = bitmap(50, 50) + assertSame(bitmap, bitmap.scaledToFit(maxWidth = 50, maxHeight = 50)) + } + + @Test + fun `scaledToFit downscales preserving aspect ratio`() { + val result = bitmap(100, 50).scaledToFit(maxWidth = 50, maxHeight = 50) + assertEquals(50, result.width) + assertEquals(25, result.height) + } + + @Test + fun `scaledToFit fits to the binding height when the box is wide`() { + val result = bitmap(50, 100).scaledToFit(maxWidth = 50, maxHeight = 50) + assertEquals(25, result.width) + assertEquals(50, result.height) + } + + private fun bitmap(width: Int, height: Int): Bitmap = + Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) } 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 2ae401b748..e5052d9277 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 @@ -28,6 +28,7 @@ import androidx.exifinterface.media.ExifInterface import java.io.File import kotlin.math.roundToInt import org.groundplatform.feature.pdf.render.PdfConfig +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.pointsToRenderPixels @@ -44,7 +45,7 @@ import timber.log.Timber * @param context application context used for resource access and file lookups. * @param logoDrawableRes resource id of the centre logo bitmap. Caller supplies the app's branding. */ -// TODO: Add equivalent iOS implementation +// TODO: Add equivalent iOS implementations for PDF feature // Issue URL: https://github.com/google/ground-android/issues/3775 class AndroidPdfImageProvider( private val context: Context, @@ -87,88 +88,80 @@ class AndroidPdfImageProvider( logoSizeFraction = PDF_LOGO_SIZE_FRACTION, ) .asAndroidBitmap() - .downscaledTo(qrMaxPx, qrMaxPx) + .scaledToFit(qrMaxPx, qrMaxPx) } .onFailure { Timber.e(it, "Failed to generate QR code bitmap for PDF report") } .getOrNull() private fun loadPhotoBitmap(remoteFilename: String): Bitmap? { val rootDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: return null - val filename = remoteFilename.substringAfterLast('/') - val file = File(rootDir, filename) - return if (file.exists()) { - runCatching { decodeSubsampled(file.absolutePath) } - .getOrElse { - Timber.e(it, "Failed to decode subsampled photo for PDF report") - null - } - ?.let { decodedBitmap -> - val oriented = applyExifOrientation(file, decodedBitmap) - oriented.downscaledTo(photoMaxWidthPx, photoMaxHeightPx) - } - } else null + val file = File(rootDir, remoteFilename.substringAfterLast('/')) + if (!file.exists()) return null + return runCatching { decodeScaledAndOriented(file) } + .onFailure { Timber.e(it, "Failed to decode photo for PDF report") } + .getOrNull() + } + + /** + * Decodes the photo subsampled to roughly the largest size it can occupy in the PDF, scales it to + * fit the photo box, then applies the EXIF orientation. + */ + private fun decodeScaledAndOriented(file: File): Bitmap? { + val path = file.absolutePath + val degrees = runCatching { ExifInterface(path).rotationDegrees }.getOrDefault(0) + // A 90°/270° EXIF rotation swaps width and height, so size the decode box accordingly. + val swapAxes = degrees == 90 || degrees == 270 + val boxWidth = if (swapAxes) photoMaxHeightPx else photoMaxWidthPx + val boxHeight = if (swapAxes) photoMaxWidthPx else photoMaxHeightPx + + val decoded = decodeSubsampled(path, boxWidth, boxHeight) ?: return null + return decoded.scaledToFit(boxWidth, boxHeight).rotated(degrees) } - /** Decodes the photo subsampled to roughly the largest size it can occupy in the PDF. */ - private fun decodeSubsampled(path: String): Bitmap? { + /** Decodes the photo subsampled to roughly the [maxWidth] × [maxHeight] box it will occupy. */ + private fun decodeSubsampled(path: String, maxWidth: Int, maxHeight: Int): Bitmap? { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(path, bounds) - // Orientation isn't known yet, so size against the larger target on both axes to be safe. - val target = maxOf(photoMaxWidthPx, photoMaxHeightPx) val options = BitmapFactory.Options().apply { - inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, target) + inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, maxWidth, maxHeight) } return BitmapFactory.decodeFile(path, options) } - - /** - * Rotates [bitmap] to match the EXIF orientation in [file]. Returns the original bitmap if no - * rotation is needed. - */ - private fun applyExifOrientation(file: File, bitmap: Bitmap): Bitmap { - val degrees = runCatching { ExifInterface(file.absolutePath).rotationDegrees }.getOrDefault(0) - if (degrees == 0) return bitmap - - val matrix = Matrix().apply { postRotate(degrees.toFloat()) } - return runCatching { - Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) - } - .getOrNull() - ?.also { if (it != bitmap) bitmap.recycle() } ?: bitmap - } } -/** Largest power-of-two sample size that keeps both dimensions at or above [target] pixels. */ -internal fun calculateInSampleSize(width: Int, height: Int, target: Int): Int { +/** + * Largest power-of-two sample size that keeps the decoded bitmap at or above the [maxWidth] × + * [maxHeight]. + */ +internal fun calculateInSampleSize(width: Int, height: Int, maxWidth: Int, maxHeight: Int): Int { var sampleSize = 1 - while (width / (sampleSize * 2) >= target && height / (sampleSize * 2) >= target) { + while (width / (sampleSize * 2) >= maxWidth || height / (sampleSize * 2) >= maxHeight) { sampleSize *= 2 } return sampleSize } /** - * Returns a bitmap scaled down to fit [maxWidthPx] × [maxHeightPx], preserving aspect ratio and - * never upscaling. Uses progressive halving for efficiency; intermediate bitmaps are recycled. + * Returns the receiver scaled down to fit [maxWidth] × [maxHeight], preserving aspect ratio and + * never upscaling. */ -private fun Bitmap.downscaledTo(maxWidthPx: Int, maxHeightPx: Int): Bitmap { - if (width <= maxWidthPx && height <= maxHeightPx) return this - val ratio = minOf(maxWidthPx.toFloat() / width, maxHeightPx.toFloat() / height) - val targetWidth = (width * ratio).roundToInt().coerceAtLeast(1) - val targetHeight = (height * ratio).roundToInt().coerceAtLeast(1) +internal fun Bitmap.scaledToFit(maxWidth: Int, maxHeight: Int): Bitmap { + val fitted = fitInside(width, height, maxWidth, maxHeight) + val targetWidth = fitted.width.roundToInt().coerceAtLeast(1) + val targetHeight = fitted.height.roundToInt().coerceAtLeast(1) + if (targetWidth == width && targetHeight == height) return this + return scale(targetWidth, targetHeight).also { if (it !== this) recycle() } +} - var current = this - var w = width - var h = height - while (w / 2 >= targetWidth && h / 2 >= targetHeight) { - w /= 2 - h /= 2 - val halved = current.scale(w, h) - if (current !== this) current.recycle() - current = halved - } - val result = current.scale(targetWidth, targetHeight) - if (current !== this) current.recycle() - return result +/** Returns the receiver rotated [degrees] clockwise, or unchanged when no rotation is needed. */ +private fun Bitmap.rotated(degrees: Int): Bitmap { + if (degrees == 0) return this + val matrix = Matrix().apply { postRotate(degrees.toFloat()) } + return runCatching { Bitmap.createBitmap(this, 0, 0, width, height, matrix, true) } + .getOrElse { + Timber.w(it, "Failed to rotate photo by $degrees°, returning unrotated bitmap") + null + } + ?.also { if (it !== this) recycle() } ?: this } diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt index 1d2e9e7f1a..0076867b7d 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt @@ -20,7 +20,7 @@ import java.io.File private const val REPORTS_SUBDIR = "reports" -// TODO: Add equivalent iOS implementation +// TODO: Add equivalent iOS implementations for PDF feature // Issue URL: https://github.com/google/ground-android/issues/3775 class AndroidPdfOutputProvider(private val context: Context) : PdfOutputProvider { 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 11b4c6bf2c..45712dfff5 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 @@ -29,7 +29,7 @@ import org.groundplatform.feature.pdf.render.image.PdfImageSet * [PdfWriter]; the [PdfCanvas] decides whether each pass writes to a real [PdfDocument] or just * counts pages. */ -// TODO: Add equivalent iOS implementation +// TODO: Add equivalent iOS implementations for PDF feature // Issue URL: https://github.com/google/ground-android/issues/3775 class AndroidPdfRenderer : PdfRenderer { diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt index 60dcd93559..d03dfba043 100644 --- a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt @@ -21,7 +21,7 @@ import androidx.core.content.FileProvider import java.io.File /** Launches the system share sheet or external viewer for a report file via [FileProvider]. */ -// TODO: Add equivalent iOS implementation +// TODO: Add equivalent iOS implementations for PDF feature // Issue URL: https://github.com/google/ground-android/issues/3775 class AndroidPdfReportLauncher( private val context: Context, From 86bab5b7ee71886aa1d44eee7e40061d404ef08f Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 10 Jun 2026 15:12:28 +0200 Subject: [PATCH 12/20] add unit tests for PdfWriter; AndroidPdfOutputProvider; PdfImageSet and PdfCanvas --- .../pdf/AndroidPdfOutputProviderTest.kt | 111 +++++++ .../feature/pdf/render/FakePdfCanvas.kt | 45 +++ .../feature/pdf/render/PdfCanvasTest.kt | 41 +++ .../pdf/render/PdfWriterPaginationTest.kt | 83 ------ .../feature/pdf/render/PdfWriterTest.kt | 281 ++++++++++++++++++ .../pdf/render/image/PdfImageSetTest.kt | 49 +++ 6 files changed, 527 insertions(+), 83 deletions(-) create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt delete mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt new file mode 100644 index 0000000000..f933b11cbc --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt @@ -0,0 +1,111 @@ +/* + * 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 + +import android.content.Context +import java.io.File +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class AndroidPdfOutputProviderTest { + + private lateinit var context: Context + private lateinit var reportsDir: File + private lateinit var provider: AndroidPdfOutputProvider + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + reportsDir = File(context.cacheDir, "reports") + reportsDir.deleteRecursively() + provider = AndroidPdfOutputProvider(context) + } + + @Test + fun `newFilePath creates the reports directory and returns a pdf path`() { + val path = provider.newFilePath("report") + + assertTrue(reportsDir.isDirectory) + assertEquals(File(reportsDir, "report.pdf").absolutePath, path) + } + + @Test + fun `exists reflects whether the report file is present`() { + assertFalse(provider.exists("report")) + + File(provider.newFilePath("report")).writeText("pdf") + + assertTrue(provider.exists("report")) + } + + @Test + fun `listFiles returns an empty list when there is no reports directory`() { + assertTrue(provider.listFiles().isEmpty()) + } + + @Test + fun `listFiles returns only pdf files`() { + File(provider.newFilePath("a")).writeText("pdf") + File(provider.newFilePath("b")).writeText("pdf") + File(reportsDir, "notes.txt").writeText("ignore me") + + val names = provider.listFiles().map { File(it.path).name }.sorted() + + assertContentEquals(listOf("a.pdf", "b.pdf"), names) + } + + @Test + fun `listFiles returns the cached pdf files with the correct lastModified value`() { + val file = File(provider.newFilePath("report")).apply { writeText("pdf") } + file.setLastModified(987654321L) + + val entry = provider.listFiles().single() + + assertEquals(file.absolutePath, entry.path) + assertEquals(987654321L, entry.lastModifiedMillis) + } + + @Test + fun `deleteReport removes the file at the given path`() { + val path = provider.newFilePath("report") + File(path).writeText("pdf") + + provider.deleteReport(path) + + assertFalse(File(path).exists()) + } + + @Test + fun `pruneOldFiles deletes only reports older than a week`() { + val now = System.currentTimeMillis() + val fresh = File(provider.newFilePath("fresh")).apply { writeText("pdf") } + val stale = File(provider.newFilePath("stale")).apply { writeText("pdf") } + stale.setLastModified(now - 8L * 24 * 60 * 60 * 1000) + + provider.pruneOldFiles() + + assertTrue(fresh.exists()) + assertFalse(stale.exists()) + } +} 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 new file mode 100644 index 0000000000..fa15e39464 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt @@ -0,0 +1,45 @@ +/* + * 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 + +import android.graphics.RectF +import android.text.StaticLayout +import org.groundplatform.feature.pdf.render.image.PdfImage + +internal class FakePdfCanvas : PdfCanvas { + val startedPages = mutableListOf() + var finishedPages = 0 + val drawnText = mutableListOf() + val drawnImages = mutableListOf() + + override fun startPage(pageNumber: Int) { + startedPages += pageNumber + } + + override fun finishPage() { + finishedPages++ + } + + override fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) { + drawnText += layout.text.toString() + } + + override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) { + drawnImages += image + } + + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) = Unit +} \ No newline at end of file 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 new file mode 100644 index 0000000000..2f0adcecc7 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt @@ -0,0 +1,41 @@ +/* + * 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 + +import android.graphics.Bitmap +import android.graphics.RectF +import android.text.StaticLayout +import android.text.TextPaint +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PdfCanvasTest { + @Test + fun `MeasurementPdfCanvas ignores every call`() { + 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) + drawStaticLayout(layout, x = 0f, y = 0f) + drawImage(image, RectF(0f, 0f, 10f, 10f), smoothScaling = true) + drawLine(0f, 0f, 10f, 10f) + finishPage() + } + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt deleted file mode 100644 index 2913908bcf..0000000000 --- a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterPaginationTest.kt +++ /dev/null @@ -1,83 +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 - -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import org.groundplatform.feature.pdf.model.SubmissionPdfDocument -import org.groundplatform.feature.pdf.render.image.PdfImageSet -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class PdfWriterPaginationTest { - - @Test - fun `measurement and draw passes emit the same page count`() { - val measuredPages = renderPageCount(totalPages = null) - - val drawnPages = renderPageCount(totalPages = measuredPages) - - assertTrue(measuredPages > 1) - assertEquals(measuredPages, drawnPages) - } - - private fun renderPageCount(totalPages: Int?): Int = - newPdfWriter(TEST_PDF_DOCUMENT, totalPages).apply { drawDocument(TEST_PDF_DOCUMENT) }.pageCount - - private fun newPdfWriter(document: SubmissionPdfDocument, totalPages: Int?): PdfWriter = - PdfWriter( - pdfCanvas = MeasurementPdfCanvas, - images = PdfImageSet(emptyMap()), - totalPages = totalPages, - header = document.header, - footer = document.footer, - ) - - private companion object { - val TEST_PDF_DOCUMENT = - SubmissionPdfDocument( - header = - SubmissionPdfDocument.Header( - surveyLabel = "Survey", - surveyName = "Survey name", - jobLabel = "Job", - jobName = "Job name", - timestamp = "timestamp", - ), - qrBlock = SubmissionPdfDocument.QrBlock(scanCaption = "Scan"), - footer = - SubmissionPdfDocument.Footer( - dataCollectorLabel = "Collector", - dataCollectorName = "John Doe", - userEmail = "user@gmail.com", - ), - table = - SubmissionPdfDocument.Table( - submissionLabel = "Submission", - loiName = "Plot 42", - rows = - List(200) { index -> - SubmissionPdfDocument.Row( - question = "Question $index", - answer = SubmissionPdfDocument.Answer.Text(listOf("Answer $index")), - ) - }, - ), - ) - } -} 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 new file mode 100644 index 0000000000..d148f05520 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt @@ -0,0 +1,281 @@ +/* + * 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 + +import android.graphics.Bitmap +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.image.PdfImageSet +import org.groundplatform.feature.pdf.render.image.PdfImageSet.ImageRef +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PdfWriterTest { + + @Test + fun `measurement and draw passes emit the same page count`() { + val measuredPages = renderPageCount(totalPages = null) + + val drawnPages = renderPageCount(totalPages = measuredPages) + + assertTrue(measuredPages > 1) + assertEquals(measuredPages, drawnPages) + } + + @Test + fun `does not open a page for a document with no qr and no rows`() { + val canvas = FakePdfCanvas() + + newPdfWriter(EMPTY_DOCUMENT, PdfImageSet(emptyMap()), canvas).drawDocument(EMPTY_DOCUMENT) + + assertEquals(0, canvas.startedPages.size) + assertEquals(0, canvas.finishedPages) + } + + @Test + fun `opens and closes exactly one page for a single-page document`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertEquals(listOf(1), canvas.startedPages) + assertEquals(1, canvas.finishedPages) + } + + @Test + fun `draws the header values on the page`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertTrue(canvas.drawnText.contains(HEADER.surveyName)) + assertTrue(canvas.drawnText.contains(HEADER.jobName)) + assertTrue(canvas.drawnText.contains(HEADER.timestamp)) + } + + @Test + fun `draws the footer text on the page`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertTrue( + canvas.drawnText.contains( + "${FOOTER.dataCollectorLabel}: ${FOOTER.dataCollectorName}, ${FOOTER.userEmail}" + ) + ) + } + + @Test + fun `draws the header and footer on every page`() { + val canvas = FakePdfCanvas() + val pdfWriter = + newPdfWriter(TEST_PDF_DOCUMENT, PdfImageSet(emptyMap()), canvas, totalPages = null) + + pdfWriter.drawDocument(TEST_PDF_DOCUMENT) + + assertTrue(pdfWriter.pageCount > 1) + assertEquals(pdfWriter.pageCount, canvas.drawnText.count { it == HEADER.surveyName }) + assertEquals( + pdfWriter.pageCount, + canvas.drawnText.count { + it == "${FOOTER.dataCollectorLabel}: ${FOOTER.dataCollectorName}, ${FOOTER.userEmail}" + }, + ) + } + + @Test + fun `draws the qr image 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)) + } + + @Test + fun `skips the qr block when no qr image is provided`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(qr = null)) + + assertFalse(canvas.drawnText.contains(QR_BLOCK.scanCaption)) + } + + @Test + fun `draws text answers as text layouts`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertTrue(canvas.drawnText.contains(SINGLE_PAGE_DOCUMENT.table.rows[0].question)) + assertTrue( + canvas.drawnText.contains( + (SINGLE_PAGE_DOCUMENT.table.rows[0].answer as SubmissionPdfDocument.Answer.Text) + .lines + .first() + ) + ) + assertTrue(canvas.drawnText.contains(SINGLE_PAGE_DOCUMENT.table.rows[1].question)) + } + + @Test + fun `draws photo answers as images`() { + val photo = pdfImage() + val canvas = + renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(photos = mapOf("photo.jpg" to photo))) + + assertTrue(canvas.drawnImages.any { it.bitmap === photo.bitmap }) + } + + @Test + fun `does not draw a photo answer when its image is missing`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(photos = emptyMap())) + + assertTrue(canvas.drawnImages.isEmpty()) + } + + @Test + fun `includes the page number in the footer when totalPages is set`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, totalPages = 1) + + assertTrue(canvas.drawnText.contains("1/1")) + } + + @Test + fun `omits the page number from the footer when totalPages is null`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, totalPages = null) + + assertFalse(canvas.drawnText.any { it.contains("/") }) + } + + @Test + fun `skips the table when there are no rows`() { + val tableless = + SINGLE_PAGE_DOCUMENT.copy(table = SINGLE_PAGE_DOCUMENT.table.copy(rows = emptyList())) + + val canvas = renderDocument(tableless, pdfImageSet(qr = pdfImage())) + + assertEquals(listOf(1), canvas.startedPages) + assertFalse(canvas.drawnText.contains(TABLE.submissionLabel)) + } + + private fun renderDocument( + document: SubmissionPdfDocument, + images: PdfImageSet = pdfImageSet(qr = pdfImage()), + totalPages: Int? = 1, + ): FakePdfCanvas = + FakePdfCanvas().also { newPdfWriter(document, images, it, totalPages).drawDocument(document) } + + private fun renderPageCount(totalPages: Int?): Int = + newPdfWriter(TEST_PDF_DOCUMENT, PdfImageSet(emptyMap()), MeasurementPdfCanvas, totalPages) + .apply { drawDocument(TEST_PDF_DOCUMENT) } + .pageCount + + private fun newPdfWriter( + document: SubmissionPdfDocument, + images: PdfImageSet, + canvas: PdfCanvas, + totalPages: Int? = null, + ): PdfWriter = + PdfWriter( + pdfCanvas = canvas, + images = images, + totalPages = totalPages, + header = document.header, + footer = document.footer, + ) + + private fun pdfImage(): PdfImage = PdfImage(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) + + private fun pdfImageSet( + qr: PdfImage? = null, + photos: Map = emptyMap(), + ): PdfImageSet = + PdfImageSet( + buildMap { + qr?.let { put(ImageRef.Qr, it) } + photos.forEach { (name, image) -> put(ImageRef.Photo(name), image) } + } + ) + + private companion object { + val HEADER = + SubmissionPdfDocument.Header( + surveyLabel = "Survey", + surveyName = "Survey name", + jobLabel = "Job", + jobName = "Job name", + timestamp = "timestamp", + ) + val FOOTER = + SubmissionPdfDocument.Footer( + dataCollectorLabel = "Collector", + dataCollectorName = "John Doe", + userEmail = "user@gmail.com", + ) + + val QR_BLOCK = SubmissionPdfDocument.QrBlock(scanCaption = "Scan") + + val TABLE = + SubmissionPdfDocument.Table( + submissionLabel = "Submission", + loiName = "Plot 42", + rows = emptyList(), + ) + + val EMPTY_DOCUMENT = + SubmissionPdfDocument( + header = HEADER, + qrBlock = QR_BLOCK, + footer = FOOTER, + table = TABLE, + ) + + val SINGLE_PAGE_DOCUMENT = + SubmissionPdfDocument( + header = HEADER, + qrBlock = QR_BLOCK, + footer = FOOTER, + table = + TABLE.copy( + rows = + listOf( + SubmissionPdfDocument.Row( + question = "What is your name?", + answer = SubmissionPdfDocument.Answer.Text(listOf("John")), + ), + SubmissionPdfDocument.Row( + question = "Take a picture of a tree", + answer = SubmissionPdfDocument.Answer.Photo(remoteFilename = "photo.jpg"), + ), + ) + ), + ) + + val TEST_PDF_DOCUMENT = + SubmissionPdfDocument( + header = HEADER, + qrBlock = QR_BLOCK, + footer = FOOTER, + table = + TABLE.copy( + rows = + List(200) { index -> + SubmissionPdfDocument.Row( + question = "Question $index", + answer = SubmissionPdfDocument.Answer.Text(listOf("Answer $index")), + ) + } + ), + ) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt new file mode 100644 index 0000000000..78415a5adc --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt @@ -0,0 +1,49 @@ +/* + * 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.image + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.render.image.PdfImageSet.ImageRef + +class PdfImageSetTest { + + @Test + fun `get returns null for a ref that is not in the set`() { + val set = PdfImageSet(emptyMap()) + + assertNull(set[ImageRef.Qr]) + assertNull(set[ImageRef.Photo("missing.jpg")]) + } + + @Test + fun `release invokes the onRelease callback`() { + var released = 0 + val set = PdfImageSet(emptyMap(), onRelease = { released++ }) + + set.release() + + assertEquals(1, released) + } + + @Test + fun `Photo refs are equal when their filenames match`() { + assertEquals(ImageRef.Photo("a.jpg"), ImageRef.Photo("a.jpg")) + assertTrue(ImageRef.Photo("a.jpg") != ImageRef.Photo("b.jpg")) + } +} From 8d5137b9d4c4a1b91fbce6f3a5da05a63acce4f8 Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 10 Jun 2026 15:38:53 +0200 Subject: [PATCH 13/20] fix code style check --- .../pdf/AndroidPdfOutputProviderTest.kt | 30 +++++++++++-------- .../feature/pdf/render/FakePdfCanvas.kt | 2 +- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt index f933b11cbc..7023daae7a 100644 --- a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt @@ -37,14 +37,14 @@ class AndroidPdfOutputProviderTest { @Before fun setUp() { context = RuntimeEnvironment.getApplication() - reportsDir = File(context.cacheDir, "reports") + reportsDir = File(context.cacheDir, PDF_SUBDIR) reportsDir.deleteRecursively() provider = AndroidPdfOutputProvider(context) } @Test fun `newFilePath creates the reports directory and returns a pdf path`() { - val path = provider.newFilePath("report") + val path = provider.newFilePath(PDF_FILE_NAME) assertTrue(reportsDir.isDirectory) assertEquals(File(reportsDir, "report.pdf").absolutePath, path) @@ -52,11 +52,11 @@ class AndroidPdfOutputProviderTest { @Test fun `exists reflects whether the report file is present`() { - assertFalse(provider.exists("report")) + assertFalse(provider.exists(PDF_FILE_NAME)) - File(provider.newFilePath("report")).writeText("pdf") + File(provider.newFilePath(PDF_FILE_NAME)).writeText(PDF_TEXT) - assertTrue(provider.exists("report")) + assertTrue(provider.exists(PDF_FILE_NAME)) } @Test @@ -66,8 +66,8 @@ class AndroidPdfOutputProviderTest { @Test fun `listFiles returns only pdf files`() { - File(provider.newFilePath("a")).writeText("pdf") - File(provider.newFilePath("b")).writeText("pdf") + File(provider.newFilePath("a")).writeText(PDF_TEXT) + File(provider.newFilePath("b")).writeText(PDF_TEXT) File(reportsDir, "notes.txt").writeText("ignore me") val names = provider.listFiles().map { File(it.path).name }.sorted() @@ -77,7 +77,7 @@ class AndroidPdfOutputProviderTest { @Test fun `listFiles returns the cached pdf files with the correct lastModified value`() { - val file = File(provider.newFilePath("report")).apply { writeText("pdf") } + val file = File(provider.newFilePath(PDF_SUBDIR)).apply { writeText(PDF_TEXT) } file.setLastModified(987654321L) val entry = provider.listFiles().single() @@ -88,8 +88,8 @@ class AndroidPdfOutputProviderTest { @Test fun `deleteReport removes the file at the given path`() { - val path = provider.newFilePath("report") - File(path).writeText("pdf") + val path = provider.newFilePath(PDF_SUBDIR) + File(path).writeText(PDF_TEXT) provider.deleteReport(path) @@ -99,8 +99,8 @@ class AndroidPdfOutputProviderTest { @Test fun `pruneOldFiles deletes only reports older than a week`() { val now = System.currentTimeMillis() - val fresh = File(provider.newFilePath("fresh")).apply { writeText("pdf") } - val stale = File(provider.newFilePath("stale")).apply { writeText("pdf") } + val fresh = File(provider.newFilePath("fresh")).apply { writeText(PDF_TEXT) } + val stale = File(provider.newFilePath("stale")).apply { writeText(PDF_TEXT) } stale.setLastModified(now - 8L * 24 * 60 * 60 * 1000) provider.pruneOldFiles() @@ -108,4 +108,10 @@ class AndroidPdfOutputProviderTest { assertTrue(fresh.exists()) assertFalse(stale.exists()) } + + private companion object { + const val PDF_TEXT = "This is a test PDF." + const val PDF_SUBDIR = "reports" + const val PDF_FILE_NAME = "report" + } } 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 fa15e39464..93db97243e 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 @@ -42,4 +42,4 @@ internal class FakePdfCanvas : PdfCanvas { } override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) = Unit -} \ No newline at end of file +} From b7d18a61119a4bb4486f9be2ee5610012d09b15f Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 10 Jun 2026 16:45:05 +0200 Subject: [PATCH 14/20] improve AndroidPdfImageProviderTest; add tests for DocumentPdfCanvas and QrCodeGenerator --- .../qrcode/QrCodeGeneratorAndroidTest.kt | 70 +++++++++++++++++++ .../pdf/AndroidPdfImageProviderTest.kt | 48 +++++++++++++ .../pdf/render/DocumentPdfCanvasTest.kt | 57 +++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorAndroidTest.kt create mode 100644 feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvasTest.kt diff --git a/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorAndroidTest.kt b/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorAndroidTest.kt new file mode 100644 index 0000000000..1b944757ea --- /dev/null +++ b/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGeneratorAndroidTest.kt @@ -0,0 +1,70 @@ +/* + * 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.ui.components.qrcode + +import android.graphics.Bitmap +import androidx.compose.ui.graphics.asImageBitmap +import kotlin.test.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.GraphicsMode + +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@RunWith(RobolectricTestRunner::class) +class QrCodeGeneratorAndroidTest { + + @Test + fun `encodeQrBitmap produces a square bitmap at the configured size`() { + val qr = encodeQrBitmap(QR_CONTENT, useHighEcc = false) + + assertEquals(QR_SIZE_PX, qr.width) + assertEquals(QR_SIZE_PX, qr.height) + } + + @Test + fun `generateQrBitmap without a logo returns a square code`() { + val qr = generateQrBitmap(content = QR_CONTENT, logo = null) + + assertEquals(QR_SIZE_PX, qr.width) + assertEquals(QR_SIZE_PX, qr.height) + } + + @Test + fun `generateQrBitmap composites a logo when the content fits the capacity`() { + val logo = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888).asImageBitmap() + + val qr = generateQrBitmap(content = "short", logo = logo) + + assertEquals(QR_SIZE_PX, qr.width) + assertEquals(QR_SIZE_PX, qr.height) + } + + @Test + fun `generateQrBitmap skips the logo when the content exceeds the capacity`() { + val logo = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888).asImageBitmap() + val tooLong = "1".repeat(MAX_QR_BYTES_WITH_LOGO + 1) + + val qr = generateQrBitmap(content = tooLong, logo = logo) + + assertEquals(QR_SIZE_PX, qr.width) + assertEquals(QR_SIZE_PX, qr.height) + } + + private companion object { + const val QR_CONTENT = "https://google.com" + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt index af4a8d0175..fb7189f119 100644 --- a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt @@ -17,11 +17,17 @@ package org.groundplatform.feature.pdf import android.graphics.Bitmap import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.groundplatform.feature.pdf.render.image.PdfImageSet import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) class AndroidPdfImageProviderTest { @@ -97,6 +103,48 @@ class AndroidPdfImageProviderTest { assertEquals(50, result.height) } + @Test + fun `load generates a qr image when content is provided`() = runTest { + val images = newProvider().load(qrContent = "https://example.org", photoFilenames = emptySet()) + + assertNotNull(images[PdfImageSet.ImageRef.Qr]) + } + + @Test + fun `load returns no qr image when content is null`() = runTest { + val images = newProvider().load(qrContent = null, photoFilenames = emptySet()) + + assertNull(images[PdfImageSet.ImageRef.Qr]) + } + + @Test + fun `load skips empty photo filenames`() = runTest { + val images = newProvider().load(qrContent = null, photoFilenames = setOf("")) + + assertNull(images[PdfImageSet.ImageRef.Photo("")]) + } + + @Test + fun `load skips photos whose file does not exist`() = runTest { + val images = newProvider().load(qrContent = null, photoFilenames = setOf("missing.jpg")) + + assertNull(images[PdfImageSet.ImageRef.Photo("missing.jpg")]) + } + + @Test + fun `release recycles the bitmaps it loaded`() = runTest { + val images = newProvider().load(qrContent = "https://example.org", photoFilenames = emptySet()) + val qrBitmap = images[PdfImageSet.ImageRef.Qr]!!.bitmap + assertFalse(qrBitmap.isRecycled) + + images.release() + + assertTrue(qrBitmap.isRecycled) + } + + private fun newProvider(): AndroidPdfImageProvider = + AndroidPdfImageProvider(RuntimeEnvironment.getApplication(), logoDrawableRes = 0) + private fun bitmap(width: Int, height: Int): Bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) } diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvasTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvasTest.kt new file mode 100644 index 0000000000..a25fc8d176 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvasTest.kt @@ -0,0 +1,57 @@ +/* + * 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 + +import android.graphics.Bitmap +import android.graphics.RectF +import android.graphics.pdf.PdfDocument +import android.text.StaticLayout +import android.text.TextPaint +import kotlin.test.assertFailsWith +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DocumentPdfCanvasTest { + + private val canvas = DocumentPdfCanvas(PdfDocument()) + + @Test + fun `drawLine before a page is started fails`() { + assertFailsWith { canvas.drawLine(0f, 0f, 10f, 10f) } + } + + @Test + fun `drawImage before a page is started fails`() { + val image = PdfImage(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) + assertFailsWith { + canvas.drawImage(image, RectF(0f, 0f, 10f, 10f), smoothScaling = false) + } + } + + @Test + fun `drawStaticLayout before a page is started fails`() { + val layout = StaticLayout.Builder.obtain("body", 0, 4, TextPaint(), 100).build() + assertFailsWith { canvas.drawStaticLayout(layout, x = 0f, y = 0f) } + } + + @Test + fun `finishPage with no page open does nothing`() { + canvas.finishPage() + } +} From a7d119e14bb49b79309b34280a88c688549b7601 Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 11 Jun 2026 16:39:44 +0200 Subject: [PATCH 15/20] update AndroidPdfImageProvider#load to use async/awaitAll --- .../feature/pdf/AndroidPdfImageProvider.kt | 49 ++++++++++++------- 1 file changed, 31 insertions(+), 18 deletions(-) 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 f4c1ee573f..82049f1746 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 @@ -27,6 +27,9 @@ import androidx.core.graphics.scale import androidx.exifinterface.media.ExifInterface import java.io.File import kotlin.math.roundToInt +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +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 @@ -57,28 +60,38 @@ class AndroidPdfImageProvider( private val photoMaxWidthPx = pointsToRenderPixels(TableLayout.ANSWER_TEXT_WIDTH.toFloat()) private val photoMaxHeightPx = pointsToRenderPixels(TableLayout.PHOTO_MAX_HEIGHT.toFloat()) - override suspend fun load(qrContent: String?, photoFilenames: Set): PdfImageSet { - val images = mutableMapOf() - val bitmapsToRelease = mutableListOf() - - qrContent?.let { content -> - generateQrCodeBitmap(content)?.let { bitmap -> - bitmapsToRelease += bitmap - images[PdfImageSet.ImageRef.Qr] = PdfImage(bitmap) + override suspend fun load(qrContent: String?, photoFilenames: Set): PdfImageSet = + coroutineScope { + val deferredQr = qrContent?.let { content -> + async { + generateQrCodeBitmap(content)?.let { bitmap -> + PdfImageSet.ImageRef.Qr to bitmap + } + } } - } - photoFilenames - .filter { it.isNotEmpty() } - .forEach { filename -> - loadPhotoBitmap(filename)?.let { bitmap -> - bitmapsToRelease += bitmap - images[PdfImageSet.ImageRef.Photo(filename)] = PdfImage(bitmap) - } + val deferredPhotos = + photoFilenames + .filter { it.isNotEmpty() } + .map { filename -> + async { + loadPhotoBitmap(filename)?.let { bitmap -> + PdfImageSet.ImageRef.Photo(filename) to bitmap + } + } + } + + val results = (listOfNotNull(deferredQr) + deferredPhotos).awaitAll().filterNotNull() + + val images = mutableMapOf() + val bitmapsToRelease = mutableListOf() + results.forEach { (ref, bitmap) -> + bitmapsToRelease += bitmap + images[ref] = PdfImage(bitmap) } - return PdfImageSet(images) { bitmapsToRelease.forEach(Bitmap::recycle) } - } + PdfImageSet(images = images, onRelease = { bitmapsToRelease.forEach(Bitmap::recycle) }) + } private fun generateQrCodeBitmap(content: String): Bitmap? = runCatching { From 14fc959b53e7493239cf794b017ae062c637864a Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 11 Jun 2026 17:01:15 +0200 Subject: [PATCH 16/20] simplify calculateInSampleSize --- .../feature/pdf/AndroidPdfImageProvider.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 82049f1746..5e41d143f8 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 @@ -149,8 +149,15 @@ class AndroidPdfImageProvider( * [maxHeight]. */ internal fun calculateInSampleSize(width: Int, height: Int, maxWidth: Int, maxHeight: Int): Int { + // True if at least one dimension is still larger than the target when downsampled. + fun canDownsample(sampleSize: Int): Boolean { + val meetsTargetWidth = width / sampleSize >= maxWidth + val meetsTargetHeight = height / sampleSize >= maxHeight + return meetsTargetWidth || meetsTargetHeight + } + var sampleSize = 1 - while (width / (sampleSize * 2) >= maxWidth || height / (sampleSize * 2) >= maxHeight) { + while (canDownsample(sampleSize * 2)) { sampleSize *= 2 } return sampleSize From 2f7b3ba30e5563e3ed610e3deaccbbf3efef500c Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 11 Jun 2026 17:02:18 +0200 Subject: [PATCH 17/20] apply suggestion to PdfWriter --- .../org/groundplatform/feature/pdf/render/PdfWriter.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 850fa3838d..d31dd0d5f8 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 @@ -196,8 +196,9 @@ internal class PdfWriter( private fun drawTableRow(questionText: String, answerText: String, photo: PdfImage?) { val questionLayout = staticLayout(questionText, paints.body, TableLayout.TASK_TEXT_WIDTH) val answerLayout = - if (answerText.isEmpty()) null - else staticLayout(answerText, paints.body, TableLayout.ANSWER_TEXT_WIDTH) + answerText + .takeIf { it.isNotEmpty() } + ?.let { staticLayout(it, paints.body, TableLayout.ANSWER_TEXT_WIDTH) } val photoSize = photo?.let { fitInside(it.width, it.height, TableLayout.ANSWER_TEXT_WIDTH, TableLayout.PHOTO_MAX_HEIGHT) } From d388ce7feb8117cdd4aaa3a5f3ed1ed5ecb682fa Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 11 Jun 2026 17:46:12 +0200 Subject: [PATCH 18/20] add IO dispatcher to AndroidPdfRenderer file operation --- .../org/groundplatform/feature/pdf/AndroidPdfRenderer.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 45712dfff5..19d0949e86 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 @@ -17,6 +17,8 @@ package org.groundplatform.feature.pdf import android.graphics.pdf.PdfDocument import java.io.File +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext import org.groundplatform.feature.pdf.model.SubmissionPdfDocument import org.groundplatform.feature.pdf.render.DocumentPdfCanvas import org.groundplatform.feature.pdf.render.MeasurementPdfCanvas @@ -31,7 +33,7 @@ import org.groundplatform.feature.pdf.render.image.PdfImageSet */ // TODO: Add equivalent iOS implementations for PDF feature // Issue URL: https://github.com/google/ground-android/issues/3775 -class AndroidPdfRenderer : PdfRenderer { +class AndroidPdfRenderer(private val ioDispatcher: CoroutineDispatcher) : PdfRenderer { override suspend fun render( document: SubmissionPdfDocument, @@ -44,7 +46,7 @@ class AndroidPdfRenderer : PdfRenderer { try { writer(document, images, DocumentPdfCanvas(pdf), totalPages = totalPages) .drawDocument(document) - File(outputPath).outputStream().use { pdf.writeTo(it) } + withContext(ioDispatcher) { File(outputPath).outputStream().use { pdf.writeTo(it) } } } finally { pdf.close() } From 8948a19b7648da4bed80600640006e7f6856a6b5 Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 11 Jun 2026 18:04:26 +0200 Subject: [PATCH 19/20] update top border drawing logic for the table --- .../feature/pdf/render/PdfWriter.kt | 4 ++++ .../feature/pdf/render/PdfPageController.kt | 9 ++++++++ .../pdf/render/PdfPageControllerTest.kt | 22 +++++++++++++++++++ 3 files changed, 35 insertions(+) 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 d31dd0d5f8..d23bfe5a12 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 @@ -212,7 +212,11 @@ internal class PdfWriter( leftTextHeight = questionHeight, rightTextHeight = answerHeight, rightImageSize = photoSize, + includeTopBorder = pageController.isFirstTableRowOnPage, ) + if (pageController.isFirstTableRowOnPage) { + pageController.consumeFirstTableRowOnPage() + } rowLayout.borders.drawableLines.forEach { drawLine(it) } drawStaticLayoutAt(questionLayout, rowLayout.content.leftTextOffset) 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 b256481cb7..3224310098 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 @@ -34,6 +34,9 @@ internal class PdfPageController( private var pageIndex = 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. */ val pageCount: Int get() = pageIndex @@ -42,6 +45,11 @@ internal class PdfPageController( if (!pageOpen) beginPage() } + /** Records that the first table row on the current page has been drawn. */ + fun consumeFirstTableRowOnPage() { + isFirstTableRowOnPage = false + } + fun newPageIfShort(spaceNeeded: Float) { ensurePage() if (cursor.fits(spaceNeeded) || cursor.isAtPageTop) return @@ -58,6 +66,7 @@ internal class PdfPageController( private fun beginPage() { pageIndex++ pageOpen = true + isFirstTableRowOnPage = true cursor.reset() lifecycle.onPageStarted(pageIndex) } 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 4ad96927a6..bc2ebdc2a9 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 @@ -17,6 +17,7 @@ package org.groundplatform.feature.pdf.render import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class PdfPageControllerTest { @@ -168,6 +169,27 @@ class PdfPageControllerTest { assertEquals(2, controller.pageCount) } + @Test + fun `isFirstTableRowOnPage is true until consumed`() { + controller.ensurePage() + + assertTrue(controller.isFirstTableRowOnPage) + controller.consumeFirstTableRowOnPage() + assertFalse(controller.isFirstTableRowOnPage) + } + + @Test + fun `isFirstTableRowOnPage resets to true on a new page`() { + controller.ensurePage() + controller.consumeFirstTableRowOnPage() + assertFalse(controller.isFirstTableRowOnPage) + + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertTrue(controller.isFirstTableRowOnPage) + } + private sealed interface PageEvent { data class Started(val pageNumber: Int) : PageEvent From 25ffb1f2be3c70261d2047b494c9103f3f41769d Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 11 Jun 2026 18:24:34 +0200 Subject: [PATCH 20/20] add unit tests to assure PdfWriter only draws one internal border between rows --- .../feature/pdf/render/FakePdfCanvas.kt | 5 +++- .../feature/pdf/render/PdfWriterTest.kt | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) 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 93db97243e..34060fb339 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 @@ -24,6 +24,7 @@ internal class FakePdfCanvas : PdfCanvas { var finishedPages = 0 val drawnText = mutableListOf() val drawnImages = mutableListOf() + val drawnLines = mutableListOf() override fun startPage(pageNumber: Int) { startedPages += pageNumber @@ -41,5 +42,7 @@ internal class FakePdfCanvas : PdfCanvas { drawnImages += image } - override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) = Unit + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) { + drawnLines += PdfLine(x1, y1, x2, y2) + } } 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 d148f05520..172fb6858d 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 @@ -157,6 +157,28 @@ class PdfWriterTest { assertFalse(canvas.drawnText.any { it.contains("/") }) } + @Test + fun `draws a top border on only the first table row of a page`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + // SINGLE_PAGE_DOCUMENT has 2 rows on one page: the first gets a top border, the second doesn't. + assertEquals(2, canvas.drawnLines.count { it.startX == it.endX }) + assertEquals(1, canvas.topBorderCount()) + } + + @Test + fun `draws a fresh top border on the first row of every page`() { + val canvas = FakePdfCanvas() + val pdfWriter = + newPdfWriter(TEST_PDF_DOCUMENT, PdfImageSet(emptyMap()), canvas, totalPages = null) + + 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()) + } + @Test fun `skips the table when there are no rows`() { val tableless = @@ -194,6 +216,13 @@ class PdfWriterTest { footer = document.footer, ) + private fun FakePdfCanvas.topBorderCount(): Int { + // This counts the rows as each row draws exactly 1 vertical divider + val rowCount = drawnLines.count { it.startX == it.endX } + val horizontalLines = drawnLines.count { it.startY == it.endY } + return horizontalLines - rowCount + } + private fun pdfImage(): PdfImage = PdfImage(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) private fun pdfImageSet(