lightweight-pdf 0.2.0

Lightweight, dependency-minimal Rust PDF generation for recurring business documents
Documentation
//! Translates `lightweight-pdf-layout`'s `RenderNode` tree into `lightweight-pdf-writer`
//! content-stream operations — the facade is where layout output meets the
//! PDF writer (`plan/00a-contracts-and-artifacts.md` point 3: `lightweight-pdf-writer`
//! never sees `RenderNode` itself). Also converts the internal top-down
//! layout coordinate system to PDF's bottom-left origin, and (Phase 4)
//! drives font subsetting: layout first (needs advances for whatever
//! Unicode text the document contains), then walk the finished render tree
//! to learn exactly which characters were used, then subset each font
//! down to just those glyphs before writing the PDF.
//!
//! Split across three files (round-3 `cargo judge` maintainability-index
//! cleanup: splitting `render_node` into per-variant functions lowered each
//! function's own complexity but raised the *whole-file* score, since MI
//! sums LOC/cyclomatic across every function in one file regardless of
//! their individual size — the fix is smaller files, not smaller
//! functions). This file owns page/document-level orchestration and the
//! state (`RenderCtx`) both submodules share; `tree` walks the `RenderNode`
//! tree (rects/lines/images), `text` owns font subsetting/embedding and
//! text-line rendering.
//!
//! `render()`/`render_with_diagnostics()` need the bundled default fonts
//! (`default-fonts` feature) and are `#[cfg]`-gated on it accordingly;
//! `render_with_fonts()`/`render_with_fonts_and_diagnostics()` take an
//! already-built `FontRegistry` (e.g. `FontRegistry::with_fonts(...)`, see
//! `fonts.rs`) and work regardless of that feature — so without
//! `default-fonts`, this module's private render pipeline is still
//! reachable through those two, not dead code.

mod text;
mod tree;

use crate::fonts::FontRegistry;
use crate::images::ImageEmbedError;
use lightweight_pdf_core::{Color, Document, FontKey, Watermark};
use lightweight_pdf_layout::{paginate, LayoutCtx, LayoutWarning, PageRender, Rect};
use lightweight_pdf_writer::{ContentBuilder, PdfDocument, PdfPage, Rgb};
use std::collections::{BTreeSet, HashMap};
use text::EmbeddedFont;

#[derive(Debug)]
pub enum RenderError {
    Font(lightweight_pdf_fonts::FontError),
    Image(ImageEmbedError),
}

impl core::fmt::Display for RenderError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            RenderError::Font(e) => write!(f, "font error: {e}"),
            RenderError::Image(e) => write!(f, "image error: {e}"),
        }
    }
}

impl From<lightweight_pdf_fonts::FontError> for RenderError {
    fn from(e: lightweight_pdf_fonts::FontError) -> Self {
        RenderError::Font(e)
    }
}

impl From<ImageEmbedError> for RenderError {
    fn from(e: ImageEmbedError) -> Self {
        RenderError::Image(e)
    }
}

fn to_rgb(c: Color) -> Rgb {
    Rgb(c.0, c.1, c.2)
}

/// `page_height - top_left_y - height` — converts a layout-space box's
/// top-left/height into the bottom-left `y` PDF rectangles expect.
fn pdf_rect_y(page_height: f32, y_top: f32, height: f32) -> f32 {
    page_height - y_top - height
}

/// Shared state every `render_*` helper in `tree`/`text` needs: the
/// page-space-to-PDF-space conversion input, the fonts embedded for this
/// page, and the two sinks (`pdf` for images/font resources, `cb` for
/// content-stream ops) content actually gets written to. Bundled into one
/// `&mut` so per-variant helpers stay under clippy's argument-count limit
/// without losing any of them.
struct RenderCtx<'a> {
    page_height: f32,
    embedded: &'a HashMap<FontKey, EmbeddedFont>,
    pdf: &'a mut PdfDocument,
    cb: &'a mut ContentBuilder,
}

/// Renders one page's header/watermark/body/footer into a fresh content
/// stream and returns the finished `PdfPage`.
fn render_page(
    page: &PageRender,
    watermark: Option<&Watermark>,
    body_area: Rect,
    page_width: f32,
    page_height: f32,
    embedded: &HashMap<FontKey, EmbeddedFont>,
    pdf: &mut PdfDocument,
) -> Result<PdfPage, RenderError> {
    let mut cb = ContentBuilder::new();
    cb.save();
    cb.clip_rect(0.0, 0.0, page_width, page_height);
    // Watermark first (bottom layer, `05-overflow-and-robustness.md`):
    // normal content always draws on top of it afterwards, which is
    // what guarantees it never makes text unreadable.
    if let Some(watermark) = watermark {
        text::draw_watermark(watermark, body_area, page_height, embedded, &mut cb);
    }
    let mut ctx = RenderCtx {
        page_height,
        embedded,
        pdf,
        cb: &mut cb,
    };
    if let Some(header) = &page.header {
        tree::render_node(header, &mut ctx)?;
    }
    tree::render_node(&page.body, &mut ctx)?;
    if let Some(footer) = &page.footer {
        tree::render_node(footer, &mut ctx)?;
    }
    cb.restore();
    Ok(PdfPage {
        width: page_width,
        height: page_height,
        content: cb.into_bytes(),
    })
}

fn render_document(doc: &Document, fonts: &FontRegistry) -> Result<(Vec<u8>, Vec<LayoutWarning>), RenderError> {
    let ctx = LayoutCtx { resolver: fonts };
    let paginated = paginate(doc, &ctx);

    let mut used_chars: HashMap<FontKey, BTreeSet<char>> = HashMap::new();
    for page in &paginated.pages {
        text::collect_chars_in_page(page, &mut used_chars);
    }
    if let Some(watermark) = &doc.watermark {
        used_chars.entry(watermark.font).or_default().extend(watermark.text.chars());
    }

    let mut pdf = PdfDocument::new();
    let embedded = text::embed_fonts(&mut pdf, fonts, &used_chars)?;

    for page in &paginated.pages {
        let pdf_page = render_page(
            page,
            doc.watermark.as_ref(),
            paginated.body_area,
            paginated.page_width,
            paginated.page_height,
            &embedded,
            &mut pdf,
        )?;
        pdf.add_page(pdf_page);
    }

    Ok((pdf.write(), paginated.warnings))
}

/// Extension trait adding `render()`/`render_with_diagnostics()` (bundled
/// default fonts) and `render_with_fonts()`/`render_with_fonts_and_diagnostics()`
/// (caller-supplied fonts, see `fonts.rs::FontRegistry::with_fonts()`) to
/// `lightweight_pdf_core::Document`. Lives here (not in `lightweight-pdf-core`)
/// because rendering needs layout, fonts and the PDF writer —
/// `lightweight-pdf-core` must not depend on any of them (ADR-002). This is
/// the point at which `render()` becomes public (ADR-002).
pub trait DocumentExt {
    // Without `default-fonts` there is no bundled font source, so these two
    // are simply not part of the trait rather than failing at runtime.
    #[cfg(feature = "default-fonts")]
    fn render(&self) -> Result<Vec<u8>, RenderError>;
    #[cfg(feature = "default-fonts")]
    fn render_with_diagnostics(&self) -> Result<(Vec<u8>, Vec<LayoutWarning>), RenderError>;

    fn render_with_fonts(&self, fonts: &FontRegistry) -> Result<Vec<u8>, RenderError>;
    fn render_with_fonts_and_diagnostics(&self, fonts: &FontRegistry) -> Result<(Vec<u8>, Vec<LayoutWarning>), RenderError>;
}

impl DocumentExt for Document {
    #[cfg(feature = "default-fonts")]
    fn render(&self) -> Result<Vec<u8>, RenderError> {
        let (bytes, _warnings) = self.render_with_diagnostics()?;
        Ok(bytes)
    }

    #[cfg(feature = "default-fonts")]
    fn render_with_diagnostics(&self) -> Result<(Vec<u8>, Vec<LayoutWarning>), RenderError> {
        let fonts = FontRegistry::with_defaults()?;
        render_document(self, &fonts)
    }

    fn render_with_fonts(&self, fonts: &FontRegistry) -> Result<Vec<u8>, RenderError> {
        let (bytes, _warnings) = self.render_with_fonts_and_diagnostics(fonts)?;
        Ok(bytes)
    }

    fn render_with_fonts_and_diagnostics(&self, fonts: &FontRegistry) -> Result<(Vec<u8>, Vec<LayoutWarning>), RenderError> {
        render_document(self, fonts)
    }
}