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,
}
}
}
use crate::report::Row;
pub struct Page<'a> {
pub number: usize,
pub total: usize,
pub rows: &'a [Row],
}
pub struct Paginator<'a> {
pub rows: &'a [Row],
pub per_page: usize,
pub total: usize,
pub cursor: usize,
pub number: usize,
}
impl<'a> Iterator for Paginator<'a> {
type Item = Page<'a>;
fn next(&mut self) -> Option<Self::Item> {
if self.cursor >= self.rows.len() && self.number > 0 {
return None;
}
let end = (self.cursor + self.per_page).min(self.rows.len());
let slice = &self.rows[self.cursor..end];
self.cursor = end;
self.number += 1;
Some(Page {
number: self.number,
total: self.total,
rows: slice,
})
}
}
use printpdf::{Mm, PdfDocument, IndirectFontRef, PdfLayerReference};
use std::io::BufWriter;
use std::fs::File;
use crate::report::Report;
use crate::paginator::Page;
const PAGE_W: f32 = 210.0;
const PAGE_H: f32 = 297.0;
const TOP_MARGIN: f32 = 280.0;
const LEFT_MARGIN: f32 = 15.0;
pub fn render_report(report: &Report, path: &str) -> std::io::Result<()> {
let (doc, page1, layer1) =
PdfDocument::new(&report.title, Mm(PAGE_W), Mm(PAGE_H), "layer");
let font = doc.add_builtin_font(printpdf::BuiltinFont::Helvetica).unwrap();
let mut current = doc.get_page(page1).get_layer(layer1);
for (idx, page) in report.paginate().enumerate() {
if idx > 0 {
let (p, l) = doc.add_page(Mm(PAGE_W), Mm(PAGE_H), "layer");
current = doc.get_page(p).get_layer(l);
}
draw_page(¤t, &font, report, &page);
}
let file = File::create(path)?;
doc.save(&mut BufWriter::new(file)).unwrap();
Ok(())
}
fn draw_page(layer: &PdfLayerReference, font: &IndirectFontRef, report: &Report, page: &Page) {
let mut y = TOP_MARGIN;
let headers: Vec<&str> = report.columns.iter().map(|c| c.header.as_str()).collect();
layer.use_text(headers.join(" | "), 11.0, Mm(LEFT_MARGIN), Mm(y), font);
y -= report.layout.header_height_mm;
for row in page.rows {
layer.use_text(row.cells.join(" "), 9.0, Mm(LEFT_MARGIN), Mm(y), font);
y -= report.layout.row_height_mm;
}
let footer = format!("Page {} of {}", page.number, page.total);
layer.use_text(footer, 8.0, Mm(LEFT_MARGIN), Mm(10.0), font);
}
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
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
struct Config<'a> {
name: &'a str,
value: &'a str,
}
fn parse_config(line: &str) -> Config {
Lifetime annotations for flexible borrowing in structs
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
use tracing::{info, instrument};
#[instrument]
fn process_request(user_id: u64) {
info!(user_id, "Processing request");
// Work happens here
tracing for structured logging and distributed tracing
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
use clap::Parser;
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
#[arg(short, long)]
clap for CLI argument parsing with derive macros
Share this code
Here's the card — post it anywhere.