pdfox 0.1.0

A pure-Rust PDF library — create, parse, and render PDF documents with zero C dependencies
Documentation
/// Watermark and header/footer template support.
///
/// Watermarks are applied as a shared Form XObject drawn behind (or over)
/// every page's content stream.  Headers and footers are drawn at fixed
/// y-positions with optional page-number macros.

use crate::color::Color;
use crate::content::ContentStream;
use crate::font::BuiltinFont;
use crate::object::{PdfDict, PdfObject};

// ── Watermark ─────────────────────────────────────────────────────────────────

/// How the watermark blends with the page content
#[derive(Debug, Clone, Copy)]
pub enum WatermarkLayer {
    /// Drawn before page content (behind text/images)
    Behind,
    /// Drawn after page content (over everything)
    Over,
}

/// A diagonal or straight text/image stamp applied to every page
#[derive(Debug, Clone)]
pub struct Watermark {
    pub text: String,
    pub font: BuiltinFont,
    pub font_size: f64,
    pub color: Color,
    /// Opacity 0.0 (transparent) – 1.0 (opaque)
    pub opacity: f64,
    /// Rotation in degrees (45 = diagonal bottom-left to top-right)
    pub rotation_deg: f64,
    pub layer: WatermarkLayer,
}

impl Watermark {
    /// Create a classic diagonal grey "CONFIDENTIAL"-style watermark
    pub fn diagonal(text: impl Into<String>) -> Self {
        Self {
            text: text.into(),
            font: BuiltinFont::HelveticaBold,
            font_size: 60.0,
            color: Color::rgb_u8(180, 180, 180),
            opacity: 0.35,
            rotation_deg: 45.0,
            layer: WatermarkLayer::Over,
        }
    }

    pub fn font(mut self, f: BuiltinFont) -> Self { self.font = f; self }
    pub fn font_size(mut self, s: f64) -> Self { self.font_size = s; self }
    pub fn color(mut self, c: Color) -> Self { self.color = c; self }
    pub fn opacity(mut self, o: f64) -> Self { self.opacity = o.clamp(0.0, 1.0); self }
    pub fn rotation(mut self, deg: f64) -> Self { self.rotation_deg = deg; self }
    pub fn layer(mut self, l: WatermarkLayer) -> Self { self.layer = l; self }

    /// Build the content-stream bytes that draw this watermark centred on a
    /// page of `page_w × page_h` points.  The caller wraps it in a Form XObject
    /// or inlines it directly.
    pub fn render_to_stream(&self, page_w: f64, page_h: f64) -> Vec<u8> {
        let mut cs = ContentStream::new();
        let font_key = "WmF1Reg";          // resource key used in watermark resources
        let text_w   = self.font.string_width(&self.text, self.font_size);

        // Opacity via graphics-state parameter (set separately in ExtGState)
        cs.save();
        // Apply opacity name defined in page/form ExtGState resources
        cs.push_raw("q");
        cs.push_raw(format!("/WmGS gs"));   // graphics state with ca/CA = opacity

        // Translate to page centre, rotate, then offset left by half text width
        let cx = page_w / 2.0;
        let cy = page_h / 2.0;
        let angle = self.rotation_deg.to_radians();
        let cos   = angle.cos();
        let sin   = angle.sin();
        // combined transform:  rotate around (cx,cy)
        // matrix: [cos sin -sin cos cx cy] then shift back by half text width
        let tx = cx - cos * text_w / 2.0 + sin * self.font_size / 4.0;
        let ty = cy - sin * text_w / 2.0 - cos * self.font_size / 4.0;

        cs.push_raw(format!(
            "{:.4} {:.4} {:.4} {:.4} {:.4} {:.4} cm",
            cos, sin, -sin, cos, tx, ty
        ));
        cs.push_raw(self.color.fill_op());
        cs.push_raw("BT");
        cs.push_raw(format!("/{} {:.2} Tf", font_key, self.font_size));
        cs.push_raw("0 0 Td");
        let escaped = crate::content::escape_for_stream(&self.text);
        cs.push_raw(format!("({}) Tj", escaped));
        cs.push_raw("ET");
        cs.push_raw("Q");
        cs.restore();

        cs.to_bytes()
    }

    /// Build the ExtGState dictionary entry for this watermark's opacity.
    /// Returns (gs_name, PdfDict) where gs_name = "WmGS".
    pub fn ext_gstate(&self) -> PdfDict {
        let mut gs = PdfDict::new();
        let op = PdfObject::Real(self.opacity);
        gs.set("ca", op.clone());   // fill opacity
        gs.set("CA", op);           // stroke opacity
        gs.set("Type", PdfObject::name("ExtGState"));
        gs
    }

    /// Build the Font resource entry for the watermark font.
    pub fn font_resource(&self) -> PdfDict {
        let mut f = PdfDict::new();
        f.set("Type",     PdfObject::name("Font"));
        f.set("Subtype",  PdfObject::name("Type1"));
        f.set("BaseFont", PdfObject::name(self.font.pdf_name()));
        f.set("Encoding", PdfObject::name("WinAnsiEncoding"));
        f
    }
}

// ── Header / Footer ──────────────────────────────────────────────────────────

/// Alignment within the header/footer band
#[derive(Debug, Clone, Copy)]
pub enum HFAlign {
    Left,
    Center,
    Right,
}

/// A single slot in a header or footer (left / centre / right)
#[derive(Debug, Clone)]
pub struct HFSlot {
    pub text: String,        // supports {page} and {total} macros
    pub align: HFAlign,
    pub font: BuiltinFont,
    pub font_size: f64,
    pub color: Color,
}

impl HFSlot {
    pub fn new(text: impl Into<String>, align: HFAlign) -> Self {
        Self {
            text: text.into(),
            align,
            font: BuiltinFont::Helvetica,
            font_size: 9.0,
            color: Color::rgb_u8(100, 100, 100),
        }
    }
    pub fn font(mut self, f: BuiltinFont) -> Self { self.font = f; self }
    pub fn font_size(mut self, s: f64) -> Self { self.font_size = s; self }
    pub fn color(mut self, c: Color) -> Self { self.color = c; self }
}

/// Header or footer template applied to all pages automatically
#[derive(Debug, Clone)]
pub struct HeaderFooter {
    /// Slots: (left, center, right) — any can be None
    pub left:   Option<HFSlot>,
    pub center: Option<HFSlot>,
    pub right:  Option<HFSlot>,
    /// Height of the band in points (default 20)
    pub height: f64,
    /// Draw a thin separator line between band and page body
    pub separator_line: bool,
    pub separator_color: Color,
}

impl Default for HeaderFooter {
    fn default() -> Self {
        Self {
            left:   None,
            center: None,
            right:  None,
            height: 20.0,
            separator_line: true,
            separator_color: Color::rgb_u8(200, 200, 200),
        }
    }
}

impl HeaderFooter {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn left(mut self, slot: HFSlot) -> Self   { self.left   = Some(slot); self }
    pub fn center(mut self, slot: HFSlot) -> Self { self.center = Some(slot); self }
    pub fn right(mut self, slot: HFSlot) -> Self  { self.right  = Some(slot); self }
    pub fn height(mut self, h: f64) -> Self       { self.height = h; self }
    pub fn no_line(mut self) -> Self              { self.separator_line = false; self }

    /// Render to content-stream bytes.
    /// `y_baseline` = the y position of the text baseline within the band.
    /// `page_w`     = page width in points.
    /// `page_num`   = current page (1-based).
    /// `page_total` = total pages in document.
    /// `is_header`  = true → band at top, false → band at bottom.
    pub fn render(
        &self,
        page_w: f64,
        page_h: f64,
        page_num: usize,
        page_total: usize,
        is_header: bool,
    ) -> Vec<u8> {
        let mut cs = ContentStream::new();
        let margin  = 40.0;
        let usable  = page_w - 2.0 * margin;

        let band_y = if is_header { page_h - self.height } else { 0.0 };
        let text_y = band_y + 6.0;   // baseline inside the band

        // Background band (very light)
        let bg = Color::rgb_u8(245, 246, 248);
        cs.push_raw(bg.fill_op());
        cs.push_raw(format!("{:.4} {:.4} {:.4} {:.4} re f", 0.0, band_y, page_w, self.height));

        // Separator line
        if self.separator_line {
            let line_y = if is_header { band_y } else { self.height };
            cs.push_raw(self.separator_color.stroke_op());
            cs.push_raw("0.5 w");
            cs.push_raw(format!("{:.4} {:.4} m {:.4} {:.4} l S", 0.0, line_y, page_w, line_y));
        }

        // Draw each slot
        for slot in [&self.left, &self.center, &self.right].iter().filter_map(|s| s.as_ref()) {
            let resolved = slot.text
                .replace("{page}",  &page_num.to_string())
                .replace("{total}", &page_total.to_string());

            let text_w = slot.font.string_width(&resolved, slot.font_size);
            let x = match slot.align {
                HFAlign::Left   => margin,
                HFAlign::Center => margin + usable / 2.0 - text_w / 2.0,
                HFAlign::Right  => margin + usable - text_w,
            };

            let fk = match slot.align {
                HFAlign::Left   => "HFL",
                HFAlign::Center => "HFC",
                HFAlign::Right  => "HFR",
            };

            cs.push_raw(slot.color.fill_op());
            cs.push_raw("BT");
            cs.push_raw(format!("/{} {:.2} Tf", fk, slot.font_size));
            cs.push_raw(format!("{:.4} {:.4} Td", x, text_y));
            let esc = crate::content::escape_for_stream(&resolved);
            cs.push_raw(format!("({}) Tj", esc));
            cs.push_raw("ET");
        }

        cs.to_bytes()
    }

    /// Font resource entries for the three slots.
    pub fn font_resources(&self) -> Vec<(&'static str, PdfDict)> {
        let slots = [
            ("HFL", &self.left),
            ("HFC", &self.center),
            ("HFR", &self.right),
        ];
        let mut out = Vec::new();
        for (key, slot_opt) in &slots {
            if let Some(slot) = slot_opt {
                let mut f = PdfDict::new();
                f.set("Type",     PdfObject::name("Font"));
                f.set("Subtype",  PdfObject::name("Type1"));
                f.set("BaseFont", PdfObject::name(slot.font.pdf_name()));
                f.set("Encoding", PdfObject::name("WinAnsiEncoding"));
                out.push((*key, f));
            }
        }
        out
    }
}