Files
songbook/.plans/issue-17-page-overflow.md

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

  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:

private fun calculateSectionHeight(
    section: SongSection,
    fontMetrics: PdfFontMetrics,
    config: BookConfig,
    contentWidth: Float
): Float

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:

  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:

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.

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.