facett-form/src/pdf.rs
//! **Render a [`FormSnapshot`] to a PDF document** — the form's current state as a
//! printable page, in pure Rust via [`pdf_writer`] (no shell-out, no native libs, so
//! it compiles for `wasm32-unknown-unknown` like every other facett panel path).
//!
//! The layout is deliberately plain: a title, a one-line validity note, then every
//! captured field laid out `Label: value`, grouped under its section heading — a
//! single-column A4 page in the standard `Helvetica` Type-1 font (one of the 14
//! PDF base fonts, so nothing has to be embedded). Text is flattened to printable
//! ASCII (the base-font WinAnsi range) so any model renders without a font subset.
use pdf_writer::{Content, Name, Pdf, Rect, Ref, Str};
use crate::FormSnapshot;
/// A4 in PDF points (72 dpi): 210 × 297 mm.
const PAGE_W: f32 = 595.0;
const PAGE_H: f32 = 842.0;
/// Left margin + the y of the first baseline.
const LEFT: f32 = 56.0;
const TOP: f32 = PAGE_H - 64.0;
/// Flatten a string to the printable-ASCII bytes the base font can always draw
/// (`0x20..=0x7e`); anything else (accents, emoji) becomes `?`. `pdf_writer`'s
/// [`Str`] escapes the PDF-syntactic bytes (`(`, `)`, `\`) on its own.
fn ascii(s: &str) -> Vec<u8> {
s.chars().map(|c| if (0x20..=0x7e).contains(&(c as u32)) { c as u8 } else { b'?' }).collect()
}
/// Render `snap` to PDF bytes. Never fails: an empty form still yields a valid
/// one-page document (title + validity line).
#[must_use]
pub fn render(snap: &FormSnapshot) -> Vec<u8> {
// Object ids.
let catalog = Ref::new(1);
let tree = Ref::new(2);
let page = Ref::new(3);
let font = Ref::new(4);
let content_id = Ref::new(5);
let font_name = Name(b"F1");
let mut pdf = Pdf::new();
pdf.catalog(catalog).pages(tree);
pdf.pages(tree).kids([page]).count(1);
{
let mut p = pdf.page(page);
p.parent(tree);
p.media_box(Rect::new(0.0, 0.0, PAGE_W, PAGE_H));
p.contents(content_id);
p.resources().fonts().pair(font_name, font);
}
pdf.type1_font(font).base_font(Name(b"Helvetica"));
// The page content stream: one text object, moving the baseline down per line.
let mut c = Content::new();
c.begin_text();
// Title.
c.set_font(font_name, 18.0);
c.next_line(LEFT, TOP);
c.show(Str(&ascii(&snap.title)));
// Validity note.
c.set_font(font_name, 10.0);
c.next_line(0.0, -22.0);
c.show(Str(&ascii(&format!("Validation: {}", if snap.valid { "clean" } else { "has errors" }))));
// Fields, grouped by their section heading.
let mut cur: Option<&str> = None;
for f in &snap.fields {
if f.section.as_deref() != cur {
cur = f.section.as_deref();
c.set_font(font_name, 13.0);
c.next_line(0.0, -26.0);
c.show(Str(&ascii(cur.unwrap_or("Fields"))));
}
c.set_font(font_name, 11.0);
c.next_line(0.0, -16.0);
c.show(Str(&ascii(&format!("{}: {}", f.label, f.value))));
}
c.end_text();
pdf.stream(content_id, &c.finish());
pdf.finish()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CapturedField;
fn sample() -> FormSnapshot {
FormSnapshot {
title: "New Case".into(),
valid: true,
fields: vec![
CapturedField { id: "title".into(), label: "Title".into(), value: "Investigate vessel".into(), section: Some("Case".into()) },
CapturedField { id: "priority".into(), label: "Priority".into(), value: "2".into(), section: Some("Case".into()) },
CapturedField { id: "notes".into(), label: "Notes".into(), value: "cross-ref AIS".into(), section: Some("Notes".into()) },
],
}
}
#[test]
fn render_emits_a_pdf_document() {
let bytes = render(&sample());
assert!(!bytes.is_empty(), "a form renders to non-empty PDF bytes");
assert!(bytes.starts_with(b"%PDF-"), "carries the PDF header");
// The trailer marks a complete document.
assert!(bytes.windows(5).any(|w| w == b"%%EOF"), "ends with the PDF EOF marker");
}
#[test]
fn empty_form_still_renders_a_valid_page() {
let bytes = render(&FormSnapshot { title: "Empty".into(), valid: false, fields: vec![] });
assert!(bytes.starts_with(b"%PDF-"));
}
#[test]
fn ascii_flattens_non_printable_and_keeps_text() {
assert_eq!(ascii("Ångström ✓"), b"?ngstr?m ?");
assert_eq!(ascii("plain"), b"plain");
}
}