Skip to content
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
shobhitagarwal1612 marked this conversation as resolved.
/** 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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
}
Loading
Loading