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..773a65a214 --- /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. + * + * Unless noted otherwise, all measurements are in PDF points (1/72 inch). + */ +internal object PdfConfig { + /** Page width in points (A4 portrait, 210mm). */ + const val PAGE_WIDTH = 595 + + /** Page height in points (A4 portrait, 297mm). */ + const val PAGE_HEIGHT = 842 + + /** Page margin applied to all four edges. */ + const val MARGIN = 40 + + /** Font size for title text. */ + const val TITLE_SIZE = 11f + + /** Font size body and table-cell text. */ + const val BODY_SIZE = 11f + + /** Font size for captions and metadata (header/footer) text. */ + const val CAPTION_SIZE = 9f + + /** Vertical spacing added between lines of text. */ + const val LINE_SPACING = 4f + + /** Content width between the left and right margins. */ + const val USABLE_WIDTH = PAGE_WIDTH - 2 * MARGIN + + /** Resolution in dots-per-inch used when rasterizing images for the PDF. */ + const val IMAGE_RENDER_DPI = 300f +} 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..3b55da2103 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfGeometry.kt @@ -0,0 +1,42 @@ +/* + * 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) + +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 + get() = x + width + + val bottom: Float + get() = y + height +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/PageFooterLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/PageFooterLayout.kt new file mode 100644 index 0000000000..ff67c28bcc --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/PageFooterLayout.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.layout + +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_HEIGHT +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 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 pageNumberOffset: PdfOffset, + val pageNumberMaxWidth: Int, +) { + companion object { + /** Vertical gap above the footer separating it from body content. */ + const val TOP_GAP = 28f + + /** Width reserved on the footer's right side for the page-number label. */ + const val PAGE_NUMBER_BAND_WIDTH = 60 + + /** Minimum horizontal gap between the footer text and the page number. */ + const val PAGE_NUMBER_GAP = 8 + + /** Maximum width for footer text (usable width minus the page-number band and its gap). */ + const val TEXT_MAX_WIDTH = USABLE_WIDTH - PAGE_NUMBER_BAND_WIDTH - PAGE_NUMBER_GAP + + /** Maximum number of lines rendered for the footer text. */ + const val MAX_LINES = 1 + + /** + * Vertical space the footer occupies, including the [TOP_GAP] separating it from page content. + */ + fun reserve(footerHeight: Float): Float = footerHeight + TOP_GAP + + 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), + pageNumberOffset = PdfOffset(pageNumberLeft, top), + pageNumberMaxWidth = PAGE_NUMBER_BAND_WIDTH, + ) + } + } +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/PageHeaderLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/PageHeaderLayout.kt new file mode 100644 index 0000000000..9e061827a7 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/PageHeaderLayout.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.layout + +import 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 { + /** Horizontal gap between the two columns of the page header. */ + const val COLUMN_GAP = 16 + /** Vertical gap below the header before body content begins. */ + const val BOTTOM_GAP = 28f + + /** Maximum number of lines rendered for each header value. */ + const val MAX_LINES = 1 + const val COLUMN_WIDTH: Int = (USABLE_WIDTH - 2 * COLUMN_GAP) / 3 + const val LEFT_X: Float = MARGIN.toFloat() + const val CENTER_X: Float = LEFT_X + COLUMN_WIDTH + COLUMN_GAP + const val RIGHT_X: Float = LEFT_X + 2 * (COLUMN_WIDTH + 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 + 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/layout/QrBlockLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/QrBlockLayout.kt new file mode 100644 index 0000000000..edf77eb30f --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/QrBlockLayout.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.layout + +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_WIDTH +import org.groundplatform.feature.pdf.render.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 nextCursorY Cursor Y position after this block. + */ +internal data class QrBlockLayout( + val qrFrame: PdfRect, + val captionOffset: PdfOffset, + val nextCursorY: Float, +) { + companion object { + /** Target size of the QR code block. */ + const val QR_SIZE = 200f + + fun compute(top: Float, captionHeight: Float): QrBlockLayout { + val x = PAGE_WIDTH - MARGIN - QR_SIZE + val captionTop = top + QR_SIZE + LINE_SPACING + return QrBlockLayout( + qrFrame = PdfRect(x, top, QR_SIZE, QR_SIZE), + captionOffset = PdfOffset(x, captionTop), + nextCursorY = captionTop + captionHeight + LINE_SPACING * 2, + ) + } + } +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/TableLayout.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/TableLayout.kt new file mode 100644 index 0000000000..8d044b3e34 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/layout/TableLayout.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.layout + +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.MARGIN +import org.groundplatform.feature.pdf.render.PdfConfig.PAGE_HEIGHT +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 { + /** Inner padding between a table cell's border and its text. */ + const val CELL_PADDING = 6 + + /** Stroke width used for table and cell borders. */ + const val BORDER_WIDTH = 0.5f + + /** Fraction of [USABLE_WIDTH] allotted to the task-label column of a table row. */ + private const val TASK_LABEL_RATIO = 0.35f + + /** Width of the task-label column. */ + const val TASK_COLUMN_WIDTH = (USABLE_WIDTH * TASK_LABEL_RATIO).toInt() + + /** Width for task-label text (the task column minus its padding). */ + const val TASK_TEXT_WIDTH = TASK_COLUMN_WIDTH - 2 * CELL_PADDING + + /** Width for answer text (the remaining table width minus its padding). */ + const val ANSWER_TEXT_WIDTH = USABLE_WIDTH - TASK_COLUMN_WIDTH - 2 * CELL_PADDING + + /** Fraction of the usable page height a single photo may occupy before being scaled down. */ + private const val PHOTO_MAX_HEIGHT_RATIO = 0.35f + + /** Maximum table photo height. */ + const val PHOTO_MAX_HEIGHT = ((PAGE_HEIGHT - 2 * MARGIN) * PHOTO_MAX_HEIGHT_RATIO).toInt() + + /** + * 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. + * @property content Content-specific layout data. + * @property borders All the borders to draw around the row. + */ + data class Row(val totalHeight: Float, val content: Content, val borders: Borders) { + /** + * @param top Horizontal line along the row's top edge, or null if there is already a row + * directly above it on the same page. + * @param bottom Horizontal line along the row's bottom edge. + * @param divider Vertical line between the two columns, spanning the row's full height. + */ + data class Borders(val top: PdfLine?, val divider: PdfLine, val bottom: PdfLine) { + val drawableLines: List + get() = listOfNotNull(top, divider, bottom) + } + + /** + * @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. + */ + data class Content( + val leftTextOffset: PdfOffset, + val rightTextOffset: PdfOffset?, + val rightImageFrame: PdfRect?, + ) + } + + 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?, + includeTopBorder: Boolean = true, + ): Row { + val totalHeight = getRowHeight(leftTextHeight, rightTextHeight, rightImageSize) + + val left = MARGIN.toFloat() + val right = left + USABLE_WIDTH + val midX = left + 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, it.width, it.height) + } + + return Row( + totalHeight = totalHeight, + content = + Row.Content( + leftTextOffset = PdfOffset(left + CELL_PADDING, contentTop), + rightTextOffset = rightTextOffset, + rightImageFrame = rightImageFrame, + ), + borders = + Row.Borders( + top = + if (includeTopBorder) + PdfLine(startX = left, startY = rowTop, endX = right, endY = rowTop) + else null, + divider = PdfLine(startX = midX, startY = rowTop, endX = midX, endY = rowBottom), + bottom = PdfLine(startX = left, startY = rowBottom, endX = right, endY = rowBottom), + ), + ) + } +} 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) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/PageFooterLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/PageFooterLayoutTest.kt new file mode 100644 index 0000000000..2624d774dd --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/PageFooterLayoutTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.layout + +import kotlin.test.Test +import kotlin.test.assertEquals +import 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 = PageFooterLayout.PAGE_NUMBER_BAND_WIDTH + private val footerTextMaxWidth = PageFooterLayout.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 `page number slot width matches its configuration constant`() { + val layout = PageFooterLayout.compute(footerHeight = 12f) + + 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 + footerTextMaxWidth + 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) + } + + @Test + fun `reserve adds the top gap to the footer height`() { + val footerHeight = 12f + + assertEquals(footerHeight + PageFooterLayout.TOP_GAP, PageFooterLayout.reserve(footerHeight)) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/PageHeaderLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/PageHeaderLayoutTest.kt new file mode 100644 index 0000000000..0d8f290a69 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/PageHeaderLayoutTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.layout + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.render.PdfConfig +import org.groundplatform.feature.pdf.render.PdfOffset +import org.groundplatform.feature.pdf.render.layout.PageHeaderLayout.Companion.BOTTOM_GAP +import org.groundplatform.feature.pdf.render.layout.PageHeaderLayout.Companion.COLUMN_GAP + +class PageHeaderLayoutTest { + + private val lineSpacing = PdfConfig.LINE_SPACING + private val headerBottomGap = BOTTOM_GAP + private val headerColumnGap = 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 <= usableWidth) + } + + @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/layout/QrBlockLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/QrBlockLayoutTest.kt new file mode 100644 index 0000000000..eabb71ddc0 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/QrBlockLayoutTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.layout + +import kotlin.test.Test +import kotlin.test.assertEquals +import org.groundplatform.feature.pdf.render.PdfConfig +import org.groundplatform.feature.pdf.render.layout.QrBlockLayout.Companion.QR_SIZE + +class QrBlockLayoutTest { + + private val margin = PdfConfig.MARGIN + private val pageWidth = PdfConfig.PAGE_WIDTH + private val qrSize = QR_SIZE + private val lineSpacing = PdfConfig.LINE_SPACING + + private val expectedX = pageWidth - margin - qrSize + + @Test + fun `QR frame is a square anchored at the right margin`() { + val layout = QrBlockLayout.compute(top = 0f, captionHeight = 10f) + + assertEquals(expectedX, layout.qrFrame.x) + assertEquals(0f, layout.qrFrame.y) + assertEquals(qrSize, layout.qrFrame.width) + assertEquals(qrSize, layout.qrFrame.height) + assertEquals((pageWidth - margin).toFloat(), layout.qrFrame.right) + } + + @Test + fun `caption sits directly below the QR with line spacing between them`() { + val top = 100f + val layout = QrBlockLayout.compute(top = top, captionHeight = 10f) + + assertEquals(expectedX, layout.captionOffset.x) + assertEquals(top + qrSize + lineSpacing, layout.captionOffset.y) + } + + @Test + fun `caption shares its X with the QR frame`() { + val layout = QrBlockLayout.compute(top = 0f, captionHeight = 10f) + + assertEquals(layout.qrFrame.x, layout.captionOffset.x) + } + + @Test + fun `nextCursorY accounts for QR, caption, and trailing spacing`() { + val top = 50f + val captionHeight = 14f + val layout = QrBlockLayout.compute(top = top, captionHeight = captionHeight) + + val expectedCaptionTop = top + qrSize + lineSpacing + assertEquals(expectedCaptionTop + captionHeight + lineSpacing * 2, layout.nextCursorY) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/TableLayoutTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/TableLayoutTest.kt new file mode 100644 index 0000000000..822bb6d537 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/layout/TableLayoutTest.kt @@ -0,0 +1,264 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.layout + +import kotlin.test.Test +import kotlin.test.assertEquals +import 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.PdfLine +import org.groundplatform.feature.pdf.render.PdfOffset +import org.groundplatform.feature.pdf.render.layout.TableLayout.CELL_PADDING +import org.groundplatform.feature.pdf.render.layout.TableLayout.TASK_COLUMN_WIDTH + +class TableLayoutTest { + + private val cellPadding = CELL_PADDING.toFloat() + private val lineSpacing = PdfConfig.LINE_SPACING + private val margin = PdfConfig.MARGIN.toFloat() + private val usableWidth = PdfConfig.USABLE_WIDTH + private val taskColumnWidth = TASK_COLUMN_WIDTH + + @Test + 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 = + TableLayout.getRowHeight(leftTextHeight = 30f, rightTextHeight = 0f, rightImageSize = null) + + assertEquals(30f + 2 * cellPadding, height) + } + + @Test + fun `rowHeight picks the taller content height`() { + val tallerLeft = + TableLayout.getRowHeight(leftTextHeight = 50f, rightTextHeight = 20f, rightImageSize = null) + val tallerRight = + TableLayout.getRowHeight(leftTextHeight = 10f, rightTextHeight = 60f, rightImageSize = null) + val tallerImageRight = + TableLayout.getRowHeight( + 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 `rowHeight with both right text and image stacks them with line spacing`() { + val height = + TableLayout.getRowHeight( + leftTextHeight = 10f, + rightTextHeight = 20f, + rightImageSize = PdfItemSize(width = 100f, height = 80f), + ) + + assertEquals(20f + lineSpacing + 80f + 2 * cellPadding, height) + } + + @Test + fun `row always places left text at the row's top-left content area`() { + val layout = + TableLayout.getRow( + rowTop = 100f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = null, + ) + + assertEquals(PdfOffset(margin + cellPadding, 100f + cellPadding), layout.content.leftTextOffset) + } + + @Test + fun `row returns null right offsets when right cell has no content`() { + val layout = + TableLayout.getRow( + rowTop = 0f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = null, + ) + + assertNull(layout.content.rightTextOffset) + assertNull(layout.content.rightImageFrame) + } + + @Test + fun `row places right text at the right cell's top`() { + val layout = + TableLayout.getRow( + rowTop = 50f, + leftTextHeight = 20f, + rightTextHeight = 20f, + rightImageSize = null, + ) + + val rightCellX = margin + taskColumnWidth + cellPadding + assertEquals(PdfOffset(rightCellX, 50f + cellPadding), layout.content.rightTextOffset) + assertNull(layout.content.rightImageFrame) + } + + @Test + fun `row places image at the right cell's top`() { + val imageSize = PdfItemSize(width = 80f, height = 60f) + val layout = + TableLayout.getRow( + rowTop = 50f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = imageSize, + ) + + val rightCellX = margin + taskColumnWidth + cellPadding + val frame = assertNotNull(layout.content.rightImageFrame) + assertNull(layout.content.rightTextOffset) + with(frame) { + assertEquals(rightCellX, x) + assertEquals(50f + cellPadding, y) + assertEquals(imageSize.width, width) + assertEquals(imageSize.height, height) + } + } + + @Test + fun `row sets bounds and divider from page geometry`() { + val layout = + TableLayout.getRow( + rowTop = 0f, + leftTextHeight = 20f, + rightTextHeight = 0f, + rightImageSize = null, + ) + + val leftX = layout.borders.bottom.startX + val rightX = layout.borders.bottom.endX + val dividerX = layout.borders.divider.startX + assertEquals(margin, leftX) + assertEquals(margin + usableWidth, rightX) + assertEquals(margin + taskColumnWidth, dividerX) + assertTrue(leftX < dividerX) + assertTrue(dividerX < rightX) + } + + @Test + 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 + val borders = layout.borders + assertEquals(PdfLine(margin, rowTop, right, rowTop), borders.top) + assertEquals(PdfLine(margin, rowBottom, right, rowBottom), borders.bottom) + assertEquals(PdfLine(midX, rowTop, midX, rowBottom), borders.divider) + assertEquals(listOf(borders.top, borders.divider, borders.bottom), borders.drawableLines) + } + + @Test + fun `row omits its top border when drawTopBorder is false`() { + 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, + includeTopBorder = false, + ) + + assertNull(second.borders.top) + assertEquals(first.borders.bottom.startY, second.borders.divider.startY) + assertEquals(first.borders.divider.endX, second.borders.divider.startX) + assertEquals(first.borders.divider.endY, second.borders.divider.startY) + } + + @Test + fun `consecutive rows share 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, + includeTopBorder = false, + ) + + assertNull(second.borders.top) + assertEquals(first.borders.bottom.startY, second.borders.divider.startY) + assertEquals(first.borders.divider.endX, second.borders.divider.startX) + assertEquals(first.borders.divider.endY, second.borders.divider.startY) + } + + @Test + fun `row totalHeight matches the rowHeight helper`() { + val left = 30f + val right = 20f + val image = PdfItemSize(width = 80f, height = 60f) + val layout = TableLayout.getRow(rowTop = 0f, left, right, image) + + assertEquals(TableLayout.getRowHeight(left, right, image), layout.totalHeight) + } +}