110 lines
6.1 KiB
Markdown
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.
|