Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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");
    }
}