Skip to content

Latest commit

 

History

History
407 lines (312 loc) · 16.8 KB

File metadata and controls

407 lines (312 loc) · 16.8 KB

Render examples

A cookbook for driving the render pipeline from Rust. Each block is self-contained and pasteable; together they cover loading a report, choosing where its rows come from, picking an output backend, feeding a report from your own data, building for WebAssembly, and handling errors. For the pipeline's design — the Page IR, the coordinate model, format resolution — see Rendering; for the CLI, see Usage.

The facade and free functions live in the rpt-render crate. Examples that build a custom data source also use rpt-data (the record pipeline), rpt-model (the semantic model types), and crystal-formula (the Value type). Add whichever you use to your Cargo.toml.

Load and render, zero-config

ReportDocument is the SDK-shaped facade: one object that loads a report, holds its decoded model, and exports it. The zero-config exporters (to_pdf / to_html / export_svg_pages) render from the report's saved data (or, with none, just the static header/footer bands) and never fail.

use rpt_render::ReportDocument;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let doc = ReportDocument::load("report.rpt")?;

    // Inspect the decoded model.
    let report = doc.report();
    println!("{} areas", report.report_definition.areas.len());

    // Zero-config export — these paths are infallible.
    let pdf: Vec<u8> = doc.to_pdf();
    let html: String = doc.to_html();
    let svg_pages: Vec<String> = doc.export_svg_pages();

    std::fs::write("report.pdf", pdf)?;
    std::fs::write("report.html", html)?;
    let _ = svg_pages;

    // Or write straight to disk (SDK-style `ExportToDisk`).
    doc.export_pdf_to_disk("out.pdf")?;
    doc.export_html_to_disk("out.html")?;
    Ok(())
}

To decode without the facade, rpt::Rpt::open("report.rpt")?.report() hands back the same typed Report; the facade is sugar over it.

Choosing where rows come from: RenderOptions

Where the rows come from is RenderOptions::datasource, a RenderSource:

  • RenderSource::Saved (the default) — the report's own saved data if present, else zero rows.
  • RenderSource::Rows(&dyn RowSource) — a live or custom row feed (see below).
  • RenderSource::Dataset(&Dataset) — a pipeline result you built yourself.

render_with is the fallible, options-driven path — it also carries report parameters, the render locale, and an optional subreport scope. Because RenderOptions derives Default, set only the fields you need and spread the rest; RenderOptions::default() is exactly the zero-config render.

use rpt_render::{Locale, RenderOptions, RenderSource, ReportDocument};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let doc = ReportDocument::load("report.rpt")?;

    // Default: the report's saved data.
    let from_saved = doc.render_with(RenderOptions::default())?;

    // A custom RowSource, with a non-default locale. `EmptySource` stands in for a real feed.
    let source = rpt_data::EmptySource;
    let from_rows = doc.render_with(RenderOptions {
        datasource: RenderSource::Rows(&source),
        locale: Locale::from_tag("de-DE"),
        ..RenderOptions::default()
    })?;

    println!("{} / {} pages", from_saved.pages.len(), from_rows.pages.len());
    Ok(())
}

Parameters and locale

RenderOptions::params supplies report parameter current-values so formulas referencing {?Name} resolve; it is a Parameters map (ignored when the datasource is a pre-built Dataset, which carries its own). RenderOptions::locale is the render Locale — separators, month/day names, AM/PM, default decimals — merged with each field's stored format leaf. Locale::from_tag("en-US" | "de-DE" | …) falls back to en-US for an unknown tag.

Parameters is a HashMap<String, Value> keyed by the normalized parameter name — normalize_param_name drops any surrounding {} and a leading ? and lowercases — so a formula's {?Region} resolves to the value you set under "region":

use crystal_formula::eval::Value;
use rpt_data::{normalize_param_name, Parameters};
use rpt_render::{RenderOptions, RenderSource, ReportDocument};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let doc = ReportDocument::load("report.rpt")?;

    let mut params = Parameters::new();
    params.insert(normalize_param_name("Region"), Value::Str("North".into()));

    let source = rpt_data::EmptySource;
    let pages = doc.render_with(RenderOptions {
        datasource: RenderSource::Rows(&source),
        params,
        ..RenderOptions::default()
    })?;
    println!("{} pages", pages.pages.len());
    Ok(())
}

Picking a backend via the Page IR

Every exporter funnels through one seam: render produces a PagedDocument — the backend-agnostic Page IR of positioned draw-ops in twips — and each backend consumes it. render_backend lets a caller pick a backend as a value (e.g. from a CLI flag) rather than calling a format-specific function:

use rpt_render::{
    render, render_backend, HtmlBackend, HtmlOptions, PdfBackend, PdfOptions, RasterBackend,
    RasterOptions, SvgBackend,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let rpt = rpt::Rpt::open("report.rpt")?;
    let pages = render(rpt.report()); // PagedDocument

    let pdf: Vec<u8> = render_backend(&pages, &PdfBackend, &PdfOptions::default());
    let html: String = render_backend(&pages, &HtmlBackend, &HtmlOptions);
    let svgs: Vec<String> = render_backend(&pages, &SvgBackend, &());
    let pngs: Vec<Vec<u8>> = render_backend(&pages, &RasterBackend, &RasterOptions::default());

    std::fs::write("report.pdf", pdf)?;
    let _ = (html, svgs, pngs);
    Ok(())
}

The four backends re-exported from rpt-render and their outputs:

Backend Options Output
HtmlBackend HtmlOptions String (one self-contained document)
SvgBackend () Vec<String> (one SVG per page)
PdfBackend PdfOptions Vec<u8> (one multi-page PDF)
RasterBackend RasterOptions Vec<Vec<u8>> (one PNG per page)

Feeding a report from your own data

A report doesn't have to render from its saved data. rpt_data::RowSource is the extension point — a schema and the rows:

pub trait RowSource {
    fn columns(&self) -> &[Column];
    fn rows(&self) -> Vec<Row>;
}

A Column is a name plus a FieldValueType; a Row holds field values keyed by column name. Names resolve case-insensitively and by both their full table.field and bare field forms, so a formula referencing either finds the value. Build a row with Row::insert, which stores a value under both its full and short names.

A complete in-memory source, fed into a render:

use crystal_formula::eval::Value;
use rpt_data::{Column, Row, RowSource};
use rpt_model::FieldValueType;
use rpt_render::{RenderOptions, RenderSource, ReportDocument};

struct InMemorySource {
    columns: Vec<Column>,
    rows: Vec<Row>,
}

impl RowSource for InMemorySource {
    fn columns(&self) -> &[Column] {
        &self.columns
    }
    fn rows(&self) -> Vec<Row> {
        self.rows.clone()
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let columns = vec![
        Column { name: "customers.name".into(), value_type: FieldValueType::String },
        Column { name: "customers.balance".into(), value_type: FieldValueType::Currency },
    ];

    let mut row = Row::default();
    row.insert("customers.name", Value::Str("Acme".into()));
    row.insert("customers.balance", Value::Currency(1250.0));

    let source = InMemorySource { columns, rows: vec![row] };

    // The short name resolves too — formulas may reference either form.
    assert_eq!(source.rows()[0].get("name"), Some(&Value::Str("Acme".into())));

    let doc = ReportDocument::load("report.rpt")?;
    let pages = doc.render_with(RenderOptions {
        datasource: RenderSource::Rows(&source),
        ..RenderOptions::default()
    })?;
    println!("{} pages", pages.pages.len());
    Ok(())
}

The saved-data date-typing footgun

SavedDataSource is the built-in source over a report's stored rows, and its two constructors differ for dates:

  • SavedDataSource::new(saved) types each column from the saved batch's own schema. But a saved batch stores Date/DateTime fields as integer day serials typed as integers, so a date column surfaces as a bare number and never groups, sorts, or formats as a date.
  • SavedDataSource::from_report(saved, report) reconciles the batch's physical types against the report's declared field types, re-typing those serial columns back to Date/DateTime. Prefer this for offline renders — it makes the saved-data path type dates exactly like the live-DB path.

The zero-config path already uses from_report internally; reach for the constructors directly only when building a Dataset by hand:

use rpt_data::{build_dataset, SavedDataSource};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let rpt = rpt::Rpt::open("report.rpt")?;
    let report = rpt.report();

    if let Some(saved) = &report.saved_data {
        // Date columns are re-typed from the report's declared field types.
        let source = SavedDataSource::from_report(saved, report);
        let dataset = build_dataset(&source, &report.data_definition);
        let _ = dataset;
    }
    Ok(())
}

Subreports: ScopeData

Subreports are nested reports with their own tables. To render them from live data instead of their own saved data, supply a ScopeData provider — one method returning a boxed RowSource for a given (sub)report scope, or None to fall back to that scope's saved data:

use rpt_data::{RowSource, ScopeData};
use rpt_model::Report;
use rpt_render::RenderOptions;

struct MyScopes;

impl ScopeData for MyScopes {
    fn rows_for(&self, report: &Report) -> Option<Box<dyn RowSource>> {
        // Inspect `report` (its tables/connection) and return a live source, or None to keep
        // that scope's saved data.
        let _ = report;
        None
    }
}

fn main() {
    let scopes = MyScopes;
    let opts = RenderOptions { scope: Some(&scopes), ..Default::default() };
    let _ = opts;
}

The live-database path

When a report has no saved data, its rows come from a live database. The built-in drivers rpt-db-postgres and rpt-db-sqlite implement RowSource over a real database, executing the joined SELECT that rpt-query builds from the report's table/link graph. They are native-only and isolated behind the trait, so the portable render core never links them (a WASM build simply omits them — see below).

CLI: rpt-render --db

The CLI wires the drivers up from the environment rather than a flag, so a connection URL (and any embedded password) never lands in argv. Connections are keyed by server: set RPT_DB_URL_<SERVER> per distinct server, or — for a single-server report — the generic RPT_DB_URL / DATABASE_URL. The URL's scheme selects the backend (postgres://…, sqlite://…).

# Discover exactly which variables this report needs.
rpt-render report.rpt --list-sources

# Render from a live database (URL from the environment).
RPT_DB_URL='postgres://user:pass@host:5432/dbname' rpt-render report.rpt --db -o out.pdf -v

See Usage for the full CLI contract.

Library: your own RowSource over a live connection

From Rust, you own the fetch: query your database however you like, wrap the result in a RowSource, and pass it via RenderSource::Rows. The in-memory source above is the whole pattern — swap its fixed rows for rows you fetched. This keeps the database dependency in your code, not the render pipeline, and is exactly how a WASM host supplies rows fetched in JavaScript.

To use a built-in driver directly, construct its RowSource (each rpt-db-* crate exposes one that runs the rpt-query SQL) and hand it to RenderSource::Rows the same way.

Building for WebAssembly

The whole decode → data → layout → Page IR → backend chain is portable and compiles to wasm32-unknown-unknown. What is not WASM-safe lives behind a seam:

  • The native database drivers (rpt-db-postgres / rpt-db-sqlite) — a WASM build omits them and supplies its own RowSource (fetch rows in JS, wrap them; see above).
  • rpt-text's system-font scan — cosmic-text can shape on WASM, but scanning OS fonts uses std::fs. Inject fonts explicitly instead (below).
  • Of the four backends, rpt-render-html and rpt-render-svg build for wasm32; rpt-render-raster and rpt-render-pdf's default backend are native-only.

rpt-render's default features (cosmic, db-postgres, db-sqlite) pull native-only code, so disable them for a WASM target:

# ApproxLayout default — dependency-free, no fonts, no DB crates:
cargo build -p rpt-render --target wasm32-unknown-unknown --no-default-features

# Font-accurate layout on WASM — cosmic-text without the system-font scan:
cargo build -p rpt-render --target wasm32-unknown-unknown --no-default-features --features cosmic

ApproxLayout and its pagination divergence

With --no-default-features, the default text layout is ApproxLayout: dependency-free, but only approximate — a fixed average advance per em and greedy space-based wrapping. It triggers wrapping and stacks lines, but it is not metric-accurate and not script-aware (it cannot wrap CJK, which has no spaces). Because wrap points and can-grow heights feed pagination, page counts from an approximate layout are not byte-identical with a real font stack — the paginator emits a one-shot diagnostic when an approximate layout is in use. Fine for a quick preview; use a real font stack when pagination must match.

Injecting a font-loaded layout

For metric-accurate layout on WASM, build a CosmicLayout that never touches the filesystem — a FontProvider with use_system_fonts: false and no local dirs loads only the bundled metric-compatible fallback faces. Add any host-supplied fonts with load_font_bytes, then hand the layout to render_dataset_with — the bring-your-own-layout entry point:

use rpt_data::{build_dataset, EmptySource};
use rpt_render::{render_dataset_with, CosmicLayout, FontProvider};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let rpt = rpt::Rpt::open("report.rpt")?;
    let report = rpt.report();

    // In a real WASM app, rows come from a JS-side fetch wrapped in a RowSource.
    let dataset = build_dataset(&EmptySource, &report.data_definition);

    // No filesystem: bundled fallback faces only.
    let provider = FontProvider { use_system_fonts: false, local_dirs: Vec::new() };
    let layout = CosmicLayout::new(provider);

    // Register fonts the host handed us (e.g. bytes fetched in JS).
    let font_bytes: Vec<u8> = Vec::new();
    layout.load_font_bytes(font_bytes);

    let pages = render_dataset_with(report, &dataset, Box::new(layout));
    println!("{} pages", pages.pages.len());
    Ok(())
}

The same render_dataset_with is how a native caller reuses one CosmicLayout across renders (avoiding a per-render font scan); on WASM it is how host-supplied fonts get in.

Error handling

The zero-config paths (render and the to_* / export_* facade methods) are infallible. render_with returns RenderError, a typed cause a caller can match on — a decode error, a datasource problem, a parameter-coercion failure, a database-driver error, or an output error — rather than a message string:

use rpt_render::{RenderError, RenderOptions, ReportDocument};

fn run() -> Result<(), RenderError> {
    // `load` returns rpt::Result; `?` converts via `RenderError: From<rpt::Error>`.
    let doc = ReportDocument::load("report.rpt")?;

    // Infallible.
    let _pdf: Vec<u8> = doc.to_pdf();

    // Fallible — a typed cause.
    match doc.render_with(RenderOptions::default()) {
        Ok(pages) => println!("{} pages", pages.pages.len()),
        Err(RenderError::Datasource(msg)) => eprintln!("datasource: {msg}"),
        Err(RenderError::Params(msg)) => eprintln!("parameter: {msg}"),
        Err(RenderError::Db(msg)) => eprintln!("database: {msg}"),
        Err(RenderError::Io(msg)) => eprintln!("output: {msg}"),
        Err(e) => eprintln!("{e}"),
    }
    Ok(())
}

RenderError is #[non_exhaustive], so match with a trailing _ arm as above.