rust 127 lines · 3 tabs

Streaming Paginated PDF Report Sections From a Domain Model in Rust

Shared by codesnips Sep 2026
3 tabs
use crate::paginator::Paginator;

#[derive(Clone)]
pub struct Row {
    pub cells: Vec<String>,
}

pub struct Column {
    pub header: String,
    pub width_mm: f32,
}

pub struct Layout {
    pub usable_height_mm: f32,
    pub header_height_mm: f32,
    pub footer_height_mm: f32,
    pub row_height_mm: f32,
}

impl Layout {
    pub fn rows_per_page(&self) -> usize {
        let content = self.usable_height_mm - self.header_height_mm - self.footer_height_mm;
        let fit = (content / self.row_height_mm).floor();
        (fit as usize).max(1)
    }
}

pub struct Report {
    pub title: String,
    pub columns: Vec<Column>,
    pub rows: Vec<Row>,
    pub layout: Layout,
}

impl Report {
    pub fn paginate(&self) -> Paginator<'_> {
        let per_page = self.layout.rows_per_page();
        let total = (self.rows.len() + per_page - 1) / per_page;
        Paginator {
            rows: &self.rows,
            per_page,
            total: total.max(1),
            cursor: 0,
            number: 0,
        }
    }
}
3 files · rust Explain with highlit

This snippet shows how a tabular report is turned into a paginated, PDF-style document without ever holding the whole rendered document in memory at once. The core idea is to separate the domain model (rows of data plus column definitions) from the layout engine (how many rows fit on a page) and the renderer (how a page is drawn), which keeps each concern testable in isolation.

In report.rs, the Report struct holds a title, columns, and a flat vector of rows. The interesting method is paginate, which returns a Paginator iterator rather than a Vec<Page>. This is a deliberate trade-off: pages are computed lazily on demand, so a report with a million rows costs only one page's worth of memory as it streams to disk. Layout captures the geometry — usable height, row height, and header height — and rows_per_page derives how many data rows survive after the header and footer are reserved. Using max(1) guards against a pathological configuration where the header alone would consume the page, which would otherwise produce an infinite loop of empty pages.

Paginator in paginator.rs implements Iterator<Item = Page>. Each call to next slices the shared rows between cursor and cursor + per_page, wrapping them in a Page that also carries its number and total count for footer text like "Page 2 of 9". Returning None when the cursor passes the end is what makes it composable with for, take, and enumerate. The slice borrows from the report, so Page holds &'a [Row] and avoids cloning row data per page.

render.rs is the imperative side that talks to printpdf. render_report walks the paginator, and for each Page it adds a physical page, draws the header row via draw_header, then draws each data row descending down the y-axis. The y cursor starts at the top margin and decrements by layout.row_height per row, which mirrors PDF's bottom-left origin coordinate system — a common pitfall for people expecting top-left. Because rendering consumes the iterator, backpressure is natural: the document is written page by page. This pattern is worth reaching for whenever report size is unbounded or user-controlled, since it bounds memory regardless of input and cleanly decouples pagination math from drawing code.


Related snips

Share this code

Here's the card — post it anywhere.

Streaming Paginated PDF Report Sections From a Domain Model in Rust — share card
Link copied