6.1 KiB
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
- The renderer tracks
yagainst the bottom margin during song page rendering - For 2-page songs, content splits across pages when it exceeds page 0's available space — remaining sections continue on page 1
- Content that would be rendered below the bottom margin (minus reserved footer space) is moved to the next page
- If metadata is "bottom" position, sufficient space is reserved at the bottom of the last page
- No text or images are rendered outside the printable page area
- Existing tests continue to pass
- 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:
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
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:
- Calculate
yMin= bottom margin in points (plus footer reservation for the last page) - For
pageIndex == 0: Render sections in order. Before rendering each section, check if the section's height fits aboveyMin. If not, stop — remaining sections go to page 1. - For
pageIndex == 1: Render the sections that didn't fit on page 0. The split point is stored via asplitIndexthat 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:
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 == 0is the last page - For 2-page songs:
pageIndex == 1is 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
isLastPagebased on split index - Render notes, bottom metadata, and reference footer only on the last page
Step 7: Write tests
Add tests in PdfBookRendererTest:
-
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. -
render does not overflow below bottom margin— Create a very long song, verify rendering completes without error. -
render places metadata at bottom of last page for two-page songs— UsemetadataPosition = "bottom", create a 2-page song, verify PDF is valid. -
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.