A PDF creation library written in Rust, designed for SaaS and web applications. Low memory and CPU consumption — even for documents with hundreds of pages.
Tables allow structured data — reports, invoices, bills of material — to be rendered as a grid of rows and columns within a PDF. Tables use the same fit-flow algorithm as fit_textflow, enabling large datasets to be streamed row-by-row from database cursors or iterators with minimal memory overhead.
The table API uses two types:
Table — config only: column widths, border settings, and a default style template. No row storage, no internal cursor.TableCursor — tracks the current Y position on the current page. Created by the caller; reset when starting a new page.The caller drives the loop, placing one row at a time via fit_row on PdfDocument:
FitResult |
Meaning |
|---|---|
Stop |
Row was placed. Advance to the next row. |
BoxFull |
Page is full. End page, begin new page, reset cursor, retry the same row. |
BoxEmpty |
Rect is intrinsically too small (first_row is still true). Skip. |
use pdf_core::{
BuiltinFont, Cell, CellStyle, Color, FitResult, FontRef,
PdfDocument, Rect, Row, Table, TableCursor,
};
let table = Table::new(vec![120.0, 200.0, 100.0]);
let rect = Rect { x: 72.0, y: 720.0, width: 468.0, height: 648.0 };
doc.begin_page(612.0, 792.0);
let mut cursor = TableCursor::new(&rect);
for row in database_results.iter() {
loop {
// Insert a header at the top of each page.
if cursor.is_first_row() {
doc.fit_row(&table, &header_row, &mut cursor)?;
}
match doc.fit_row(&table, row, &mut cursor)? {
FitResult::Stop => break,
FitResult::BoxFull => {
doc.end_page()?;
doc.begin_page(612.0, 792.0);
cursor.reset(&rect);
}
FitResult::BoxEmpty => break,
}
}
}
doc.end_page()?;
cursor.is_first_row() returns true after construction and after reset(), making it natural to insert a repeated header at the top of each page.
Rect uses the same convention as fit_textflow:
(x, y) is the top-left corner in PDF absolute coordinates (from page bottom)y is measured from the bottom of the pageExample: for a US Letter page (612×792 pt) with 1-inch margins:
Rect { x: 72.0, y: 720.0, width: 468.0, height: 648.0 }
pub struct TableCursor { ... }
impl TableCursor {
pub fn new(rect: &Rect) -> Self // current_y = rect.y, is_first_row = true
pub fn reset(&mut self, rect: &Rect) // call when starting a new page
pub fn is_first_row(&self) -> bool // true if no rows placed on this page yet
pub fn current_y(&self) -> f64 // Y below the last row placed (table bottom)
}
The cursor is owned by the caller. This means the caller can inspect is_first_row() before each fit_row call to decide whether to insert a header. After all rows are placed, current_y() returns the exact Y coordinate at the bottom of the last row — use this to position content that follows the table (e.g., a totals section) without hardcoding a coordinate.
Row height is determined in two ways:
count_lines × line_height + 2 × paddingrow.height = Some(pts) to override. Required for Clip and Shrink overflow.Each cell has an overflow: CellOverflow field:
| Mode | Behavior | Requires fixed row.height? |
|---|---|---|
Wrap (default) |
Row grows to fit all wrapped text | No |
Clip |
Text is word-wrapped but clipped to the row’s fixed height | Yes |
Shrink |
Font size reduced until text fits within the fixed height | Yes |
Shrink reduces font size by 0.5pt steps down to a minimum of 4pt.
Borders are enabled by default (0.5pt black lines). Configure on Table:
table.border_color = Color::rgb(0.5, 0.5, 0.5);
table.border_width = 0.75;
// Disable:
table.border_width = 0.0;
Per row, borders draw:
Horizontal dividers at row boundaries are produced by adjacent rows’ top/bottom lines.
Two levels of background fill:
row.background_color) — fills the entire rowcell.style.background_color) — overrides the row background for that celllet mut row = Row::new(cells);
row.background_color = Some(Color::gray(0.9)); // light gray row
let mut header_style = CellStyle::default();
header_style.background_color = Some(Color::rgb(0.2, 0.3, 0.5)); // dark blue cell
Each cell has a text_align: TextAlign field that controls horizontal alignment:
| Variant | Behavior |
|---|---|
TextAlign::Left |
Left-aligned (default) |
TextAlign::Center |
Centered within the cell |
TextAlign::Right |
Right-aligned — primary use case: currency values |
use pdf_core::{Cell, CellStyle, TextAlign};
// Right-align a currency column
let amount_style = CellStyle {
text_align: TextAlign::Right,
..CellStyle::default()
};
Cell::styled("$1,234.56", amount_style)
For tables with consistent column alignment (e.g., all amounts right-aligned), create one style per column type and clone it for each cell:
let desc_style = CellStyle::default(); // Left (default)
let num_style = CellStyle { text_align: TextAlign::Right, ..CellStyle::default() };
let row = Row::new(vec![
Cell::styled("Web Development", desc_style),
Cell::styled("40", num_style.clone()),
Cell::styled("$150.00", num_style.clone()),
Cell::styled("$6,000.00", num_style),
]);
In PHP:
$style = new CellStyle();
$style->textAlign = 'right'; // 'left', 'center', or 'right'
Each wrapped line within a cell is individually aligned — a multi-line right-aligned cell will have each line flush to the right edge of the cell.
CellStyle controls per-cell appearance:
| Field | Type | Default | Notes |
|---|---|---|---|
font |
FontRef |
Helvetica | Builtin or TrueType |
font_size |
f64 |
10.0 pt | |
padding |
f64 |
4.0 pt | All four sides |
overflow |
CellOverflow |
Wrap |
|
word_break |
WordBreak |
BreakAll |
See Word Break |
text_align |
TextAlign |
Left |
Left, Center, or Right |
background_color |
Option<Color> |
None | |
text_color |
Option<Color> |
None (black) |
The Cell struct also has a col_span: usize field (default 1) — see the Column Span section.
The Table.default_style field is a reference style — it is not applied automatically. Clone it when constructing cells to reuse a consistent style:
let style = table.default_style.clone();
Cell::styled("text", style)
use pdf_core::{
BuiltinFont, Cell, CellStyle, Color, FitResult, FontRef,
PdfDocument, Rect, Row, Table, TableCursor,
};
let table = Table::new(vec![120.0, 200.0, 100.0]);
let header_style = CellStyle {
font: FontRef::Builtin(BuiltinFont::HelveticaBold),
font_size: 10.0,
background_color: Some(Color::rgb(0.2, 0.3, 0.5)),
text_color: Some(Color::rgb(1.0, 1.0, 1.0)),
..CellStyle::default()
};
let header_row = Row::new(vec![
Cell::styled("Name", header_style.clone()),
Cell::styled("Description", header_style.clone()),
Cell::styled("Amount", header_style),
]);
let rect = Rect { x: 72.0, y: 720.0, width: 468.0, height: 648.0 };
let mut rows = database_results.iter().peekable();
doc.begin_page(612.0, 792.0);
let mut cursor = TableCursor::new(&rect);
while rows.peek().is_some() {
if cursor.is_first_row() {
doc.fit_row(&table, &header_row, &mut cursor)?;
}
let row = rows.peek().unwrap();
match doc.fit_row(&table, row, &mut cursor)? {
FitResult::Stop => { rows.next(); }
FitResult::BoxFull => {
doc.end_page()?;
doc.begin_page(612.0, 792.0);
cursor.reset(&rect);
}
FitResult::BoxEmpty => break,
}
}
doc.end_page()?;
A cell can span multiple consecutive columns by setting col_span:
use pdf_core::{Cell, CellStyle, TextAlign};
let mut group = Cell::styled("Employee Details", CellStyle {
text_align: TextAlign::Center,
..CellStyle::default()
});
group.col_span = 3; // spans columns 1, 2, and 3
// Row: | (blank) | Employee Details (span 3) | Amount |
// col 0 cols 1-3 col 4
let header = Row::new(vec![
Cell::new(""), // col 0
group, // cols 1, 2, 3
Cell::new("Amount"), // col 4
]);
Rules:
col_span defaults to 1 — no API change needed for existing code.col_span values in a row must equal table.columns.len(). fit_row returns Err(InvalidInput) if not.In PHP:
$cell = Cell::styled("Group Label", $style);
$cell->colSpan = 3; // camelCase — ext-php-rs converts col_span → colSpan
The original fit_table design required the entire dataset to be loaded into Vec<Row> before rendering began. For reports with thousands of rows from a database cursor, this wastes memory. The fit_row + TableCursor design lets the caller fetch one row at a time and pass it directly, enabling true streaming with O(1) memory per row.
The tradeoff is a slightly more complex calling pattern, but the caller gains full control: they can inspect cursor.is_first_row() to insert headers, peek at data to adjust row styles, or interleave table rows with other page content.
Caller ownership of TableCursor enables is_first_row() to be checked before each fit_row call. If the cursor were internal to Table, the caller would have no way to inspect page state without additional API surface. The cursor is cheap (three fields) and its lifecycle exactly matches a single page rect.
Borders are drawn per row to naturally support multi-page flow. Each row draws its own outer rectangle and column dividers. The top line of each row overlaps with the bottom line of the previous row, which is visually correct and avoids state carried between rows.
In Rust, non-optional struct fields always have a value, making it impossible to distinguish “user explicitly set this” from “this is the default”. Rather than adding a parallel Option<T> field for every CellStyle attribute, the table’s default_style acts as a template — users clone it when building cells. This keeps the API surface small and avoids hidden behavior.
Each cell is wrapped in a PDF graphics state save/restore (q/Q). This isolates each cell’s text color, clip path (Clip mode), and any other state changes, preventing style from leaking between adjacent cells. The overhead is minimal (2 bytes per cell).
fit_table.fit_table + internal row storage with fit_row + caller-owned TableCursor. Enables streaming from database cursors. is_first_row() added to support automatic header repetition across pages.word_break: WordBreak to CellStyle (default BreakAll). Long words are now broken at character boundaries by default instead of overflowing. See Word Break for details.text_align: TextAlign to CellStyle (default Left). Each cell can be independently left-, center-, or right-aligned. Multi-line cells align each wrapped line independently. Invoice examples updated to right-align all currency columns.text_align → textAlign, font_name → fontName). Stubs and all PHP examples updated to use the correct camelCase names. The clone() docblock and wordBreak (TextFlow) stub were also corrected.col_span: usize to Cell (default 1). A spanning cell’s width equals the sum of its column widths; internal vertical dividers within the span are suppressed. fit_row validates that the sum of all col_span values equals table.columns.len(), returning Err(InvalidInput) otherwise. PHP binding exposes colSpan property on Cell. Table examples updated to demonstrate a group header row.