Files
songbook/.plans/issue-17-page-overflow.md
shahondin1624 f35813560b fix: add bounds checking and content splitting for multi-page songs (Closes #17)
The PDF renderer now tracks y-position against the bottom margin and
splits song sections across pages when content exceeds available space.
Footer elements (notes, bottom metadata, reference footer) render on the
last page with proper space reservation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 14:17:46 +01:00

110 lines
6.1 KiB
Markdown

# Issue #17: Fix page overflow — bounds checking and content splitting
## Summary
The PDF renderer (`PdfBookRenderer.renderSongPage()`) currently renders all song sections on page 0 and leaves page 1 blank for 2-page songs. There is no bounds checking — the `y` coordinate can go below the bottom margin, causing content to render outside the visible page area. This plan adds proper `y`-position tracking against a minimum `yMin` boundary, splits content across pages at section boundaries when a song exceeds one page, and reserves space for bottom metadata/references on the last page.
## AC Verification Checklist
1. The renderer tracks `y` against the bottom margin during song page rendering
2. For 2-page songs, content splits across pages when it exceeds page 0's available space — remaining sections continue on page 1
3. Content that would be rendered below the bottom margin (minus reserved footer space) is moved to the next page
4. If metadata is "bottom" position, sufficient space is reserved at the bottom of the last page
5. No text or images are rendered outside the printable page area
6. Existing tests continue to pass
7. New tests verify content splitting for songs exceeding one page
## Implementation Steps
### Step 1: Add a section height calculation helper in PdfBookRenderer
Add a private method `calculateSectionHeight()` that computes how many PDF points a given `SongSection` will consume when rendered. This mirrors the measurement engine logic but uses the actual PDF `BaseFont` widths (not stubs). This is needed to decide whether a section fits on the current page.
The method signature:
```kotlin
private fun calculateSectionHeight(
section: SongSection,
fontMetrics: PdfFontMetrics,
config: BookConfig,
contentWidth: Float
): Float
```
### Step 2: Add footer space reservation calculation
Add a private method `calculateFooterReservation()` that computes how much vertical space must be reserved at the bottom of the **last** page of a song for:
- Bottom-position metadata (if `metadataPosition == "bottom"`)
- Notes
- Reference book footer
```kotlin
private fun calculateFooterReservation(
song: Song,
fontMetrics: PdfFontMetrics,
config: BookConfig,
contentWidth: Float
): Float
```
### Step 3: Refactor renderSongPage() to split content across pages
The key change: Instead of `val sections = if (pageIndex == 0) song.sections else emptyList()`, determine which sections belong on each page by:
1. Calculate `yMin` = bottom margin in points (plus footer reservation for the last page)
2. For `pageIndex == 0`: Render sections in order. Before rendering each section, check if the section's height fits above `yMin`. If not, stop — remaining sections go to page 1.
3. For `pageIndex == 1`: Render the sections that didn't fit on page 0. The split point is stored via a `splitIndex` that is computed during page 0 rendering.
**Approach:** Since `renderSongPage()` is called separately for page 0 and page 1, we need a way to know the split point on both calls. The cleanest approach is to compute the split index as a function:
```kotlin
private fun computeSplitIndex(
song: Song,
fontMetrics: PdfFontMetrics,
config: BookConfig,
contentWidth: Float,
availableHeight: Float // total space on page 0 (contentTop - yMin)
): Int // index of first section that goes to page 1
```
This method calculates the cumulative height of header + sections. When the cumulative height exceeds `availableHeight`, it returns the section index. If all sections fit, it returns `song.sections.size`.
### Step 4: Update renderSongPage() to use bounds checking during rendering
Even after determining the split, the actual rendering loop should still check `y >= yMin` as a safety net. If a section that was estimated to fit actually overflows (due to measurement inaccuracy), clamp rendering — do not render below `yMin`.
### Step 5: Update footer rendering for multi-page songs
Currently `isLastPage` is hardcoded to `pageIndex == 0`. Change it to correctly identify the last page:
- For 1-page songs: `pageIndex == 0` is the last page
- For 2-page songs: `pageIndex == 1` is the last page
The song's `pageCount` isn't directly available in the renderer, but we can determine it: if `pageIndex == 1`, it's always the last page. If `pageIndex == 0`, it's the last page only if the song fits on one page (i.e., `computeSplitIndex == song.sections.size`).
A simpler approach: pass the total page count as a parameter, or compute whether the song needs 2 pages inside `renderSongPage()`.
**Decision:** Add a `totalPages: Int` parameter to `renderSongPage()`. The caller already knows this from the `PageContent.SongPage` list (consecutive song pages with pageIndex 0 and 1 for the same song).
Actually, the simplest approach: The renderer sees `PageContent.SongPage(song, 0)` and `PageContent.SongPage(song, 1)` in the page list. We can pre-scan the pages list to know if a song has 2 pages. But even simpler: we can compute `computeSplitIndex` to know whether the song needs a second page. If `splitIndex < song.sections.size`, the song has 2 pages.
### Step 6: Move notes and bottom-metadata to the last page
Currently notes and bottom metadata only render on `pageIndex == 0`. Change this to render on the last page (which might be page 1 for 2-page songs). The logic:
- Compute `isLastPage` based on split index
- Render notes, bottom metadata, and reference footer only on the last page
### Step 7: Write tests
Add tests in `PdfBookRendererTest`:
1. `render handles two-page song with content split across pages` — Create a song with many sections that exceed one page, render with pageIndex 0 and 1, verify PDF is valid.
2. `render does not overflow below bottom margin` — Create a very long song, verify rendering completes without error.
3. `render places metadata at bottom of last page for two-page songs` — Use `metadataPosition = "bottom"`, create a 2-page song, verify PDF is valid.
4. `render handles notes on last page of two-page song` — Song with notes that spans 2 pages, verify rendering.
### Step 8: Verify existing tests pass
Run `gradle :renderer-pdf:test` and `gradle :app:test` to ensure no regressions.