pdfox 0.1.0

A pure-Rust PDF library — create, parse, and render PDF documents with zero C dependencies
Documentation
/// Page builder — constructs individual PDF pages with content, fonts, and images.

use crate::color::Color;
use crate::content::{ContentStream, TextAlign};
use crate::font::BuiltinFont;
use crate::image::PdfImage;
use crate::object::{PdfDict, PdfObject};
use crate::table::Table;

/// Standard page sizes in points (1pt = 1/72 inch)
#[allow(non_upper_case_globals)]
#[allow(non_snake_case)]
pub mod PageSize {
    pub const A4: (f64, f64) = (595.276, 841.890);
    pub const A3: (f64, f64) = (841.890, 1190.551);
    pub const A5: (f64, f64) = (419.528, 595.276);
    pub const LETTER: (f64, f64) = (612.0, 792.0);
    pub const LEGAL: (f64, f64) = (612.0, 1008.0);
    pub const TABLOID: (f64, f64) = (792.0, 1224.0);
}

/// A font registered on a page
struct RegisteredFont {
    key: String,       // Resource name, e.g. "F1"
    bold_key: String,  // Bold variant resource name, e.g. "F1Bold"
    font: BuiltinFont,
    bold_font: BuiltinFont,
}

/// A page being built
pub struct PageBuilder {
    pub width: f64,
    pub height: f64,
    content: ContentStream,
    fonts: Vec<RegisteredFont>,
    font_counter: usize,
    /// Image XObject references: resource key -> (ref, image)
    images: Vec<(String, PdfImage)>,
    image_counter: usize,
    /// Default margins
    pub margin: f64,
}

impl PageBuilder {
    pub fn new(width: f64, height: f64) -> Self {
        Self {
            width,
            height,
            content: ContentStream::new(),
            fonts: Vec::new(),
            font_counter: 0,
            images: Vec::new(),
            image_counter: 0,
            margin: 40.0,
        }
    }

    pub fn a4() -> Self { Self::new(PageSize::A4.0, PageSize::A4.1) }
    pub fn letter() -> Self { Self::new(PageSize::LETTER.0, PageSize::LETTER.1) }
    pub fn a3() -> Self { Self::new(PageSize::A3.0, PageSize::A3.1) }

    /// Set default margin
    pub fn margin(mut self, m: f64) -> Self {
        self.margin = m;
        self
    }

    /// Register a font pair (regular + bold) on this page.
    /// Returns the font key prefix (e.g. "F1")
    pub fn register_font(&mut self, font: BuiltinFont, bold: BuiltinFont) -> String {
        self.font_counter += 1;
        let key = format!("F{}", self.font_counter);
        self.fonts.push(RegisteredFont {
            key: format!("{}Reg", key),
            bold_key: format!("{}Bold", key),
            font,
            bold_font: bold,
        });
        key
    }

    /// Register a standard Helvetica font pair
    pub fn use_helvetica(&mut self) -> String {
        self.register_font(BuiltinFont::Helvetica, BuiltinFont::HelveticaBold)
    }

    /// Register a Times Roman font pair
    pub fn use_times(&mut self) -> String {
        self.register_font(BuiltinFont::TimesRoman, BuiltinFont::TimesBold)
    }

    /// Register a Courier font pair
    pub fn use_courier(&mut self) -> String {
        self.register_font(BuiltinFont::Courier, BuiltinFont::CourierBold)
    }

    /// Get the regular font key for a prefix
    pub fn reg_key(&self, prefix: &str) -> String {
        format!("{}Reg", prefix)
    }

    /// Get the bold font key for a prefix
    pub fn bold_key(&self, prefix: &str) -> String {
        format!("{}Bold", prefix)
    }

    /// Get the BuiltinFont for a registered key (for width calculation)
    pub fn font_for_key(&self, key: &str) -> BuiltinFont {
        for f in &self.fonts {
            if f.key == key { return f.font; }
            if f.bold_key == key { return f.bold_font; }
        }
        BuiltinFont::Helvetica
    }

    // ── Content helpers (chainable) ───────────────────────────────────────────

    pub fn text(
        &mut self,
        text: &str,
        x: f64, y: f64,
        font_key: &str,
        size: f64,
        color: Color,
    ) -> &mut Self {
        let font = self.font_for_key(font_key);
        self.content.draw_text(text, x, y, font_key, font, size, color, TextAlign::Left);
        self
    }

    pub fn text_centered(
        &mut self,
        text: &str,
        cx: f64, y: f64,
        font_key: &str,
        size: f64,
        color: Color,
    ) -> &mut Self {
        let font = self.font_for_key(font_key);
        self.content.draw_text(text, cx, y, font_key, font, size, color, TextAlign::Center);
        self
    }

    pub fn text_right(
        &mut self,
        text: &str,
        x: f64, y: f64,
        font_key: &str,
        size: f64,
        color: Color,
    ) -> &mut Self {
        let font = self.font_for_key(font_key);
        self.content.draw_text(text, x, y, font_key, font, size, color, TextAlign::Right);
        self
    }

    /// Multi-line wrapped text box. Returns the y position below the last line.
    pub fn text_box(
        &mut self,
        text: &str,
        x: f64, y: f64, width: f64,
        font_key: &str,
        size: f64,
        color: Color,
        line_height: f64,
    ) -> f64 {
        let font = self.font_for_key(font_key);
        self.content.draw_text_box(text, x, y, width, font_key, font, size, color, line_height)
    }

    pub fn filled_rect(&mut self, x: f64, y: f64, w: f64, h: f64, color: Color) -> &mut Self {
        self.content.filled_rect(x, y, w, h, color);
        self
    }

    pub fn stroked_rect(&mut self, x: f64, y: f64, w: f64, h: f64, color: Color, lw: f64) -> &mut Self {
        self.content.stroked_rect(x, y, w, h, color, lw);
        self
    }

    pub fn line(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, color: Color, width: f64) -> &mut Self {
        self.content.line(x1, y1, x2, y2, color, width);
        self
    }

    pub fn circle_fill(&mut self, cx: f64, cy: f64, r: f64, color: Color) -> &mut Self {
        self.content.save();
        self.content.fill_color(color);
        self.content.circle(cx, cy, r);
        self.content.fill();
        self.content.restore();
        self
    }

    /// Register and draw an image
    pub fn image(&mut self, img: PdfImage, x: f64, y: f64, w: f64, h: f64) -> &mut Self {
        self.image_counter += 1;
        let key = format!("Im{}", self.image_counter);
        self.content.draw_image(&key, x, y, w, h);
        self.images.push((key, img));
        self
    }

    /// Draw an image with optional crop, border, and drop-shadow decorations.
    ///
    /// # Crop
    /// A `CropRect` clips the visible region of the image using a PDF clipping path.
    /// The image is first scaled to fill `(w, h)`, then the clip is applied so that
    /// only the crop region (expressed as fractions of the image) is visible.
    ///
    /// # Border
    /// Drawn as a stroked rectangle over the image area.
    ///
    /// # Drop Shadow
    /// Approximated as two offset semi-transparent filled rectangles (dark + spread).
    pub fn image_ex(
        &mut self,
        img: PdfImage,
        x: f64, y: f64, w: f64, h: f64,
        opts: &crate::image::ImageOptions,
    ) -> &mut Self {
        use crate::color::Color;

        // ── Drop shadow (drawn before the image) ─────────────────────────────
        if let Some(ref shadow) = opts.shadow {
            let alpha = shadow.opacity;
            let spread = shadow.blur_spread;
            // Extract RGB components from the shadow color for blending
            let (sr, sg, sb) = match shadow.color {
                Color::Rgb(r, g, b) => (r, g, b),
                Color::Gray(v)      => (v, v, v),
                Color::Cmyk(c, m, y, _k) => (1.0 - c, 1.0 - m, 1.0 - y),
            };
            for i in 0..3u8 {
                let s = spread * (1.0 - i as f64 * 0.3);
                let a = alpha * (1.0 - i as f64 * 0.25);
                // blend shadow colour toward white to approximate blur spread
                let blend = |ch: f64| -> f64 { (ch * a + 1.0 * (1.0 - a)).min(1.0) };
                let c = Color::Rgb(blend(sr), blend(sg), blend(sb));
                self.content.save();
                self.content.fill_color(c);
                let sx = x + shadow.offset_x - s / 2.0;
                let sy = y - shadow.offset_y - s / 2.0;
                self.content.push_raw(format!(
                    "{:.4} {:.4} {:.4} {:.4} re f",
                    sx, sy, w + s, h + s
                ));
                self.content.restore();
            }
        }

        // ── Clipping path for crop ────────────────────────────────────────────
        let has_crop = opts.crop.is_some();
        if has_crop {
            self.content.save();
            if let Some(ref crop) = opts.crop {
                // compute clip rect in page space from normalised crop fractions
                let img_w = img.width as f64;
                let img_h = img.height as f64;
                let cx = x + (crop.x as f64 / img_w) * w;
                let cy = y + (crop.y as f64 / img_h) * h;
                let cw = (crop.width  as f64 / img_w) * w;
                let ch = (crop.height as f64 / img_h) * h;
                self.content.push_raw(format!(
                    "{:.4} {:.4} {:.4} {:.4} re W n",
                    cx, cy, cw, ch
                ));
            }
        }

        // ── Draw image ────────────────────────────────────────────────────────
        self.image_counter += 1;
        let key = format!("Im{}", self.image_counter);
        self.content.draw_image(&key, x, y, w, h);
        self.images.push((key, img));

        if has_crop {
            self.content.restore();
        }

        // ── Border (drawn over the image) ─────────────────────────────────────
        if let Some(ref border) = opts.border {
            self.content.save();
            self.content.stroke_color(border.color);
            self.content.push_raw(format!("{:.4} w", border.width));
            if border.radius > 0.0 {
                // Rounded rectangle using Bézier curves
                let r = border.radius;
                let k = 0.5523 * r; // Bézier control point factor
                let (lx, ly, rx, ry) = (x, y, x + w, y + h);
                self.content.push_raw(format!("{:.4} {:.4} m", lx + r, ly));
                self.content.push_raw(format!("{:.4} {:.4} l", rx - r, ly));
                self.content.push_raw(format!("{:.4} {:.4} {:.4} {:.4} {:.4} {:.4} c", rx-r+k,ly, rx,ly+r-k, rx,ly+r));
                self.content.push_raw(format!("{:.4} {:.4} l", rx, ry - r));
                self.content.push_raw(format!("{:.4} {:.4} {:.4} {:.4} {:.4} {:.4} c", rx,ry-r+k, rx-r+k,ry, rx-r,ry));
                self.content.push_raw(format!("{:.4} {:.4} l", lx + r, ry));
                self.content.push_raw(format!("{:.4} {:.4} {:.4} {:.4} {:.4} {:.4} c", lx+r-k,ry, lx,ry-r+k, lx,ry-r));
                self.content.push_raw(format!("{:.4} {:.4} l", lx, ly + r));
                self.content.push_raw(format!("{:.4} {:.4} {:.4} {:.4} {:.4} {:.4} c", lx,ly+r-k, lx+r-k,ly, lx+r,ly));
                self.content.push_raw("h S");
            } else {
                self.content.push_raw(format!(
                    "{:.4} {:.4} {:.4} {:.4} re S",
                    x, y, w, h
                ));
            }
            self.content.restore();
        }

        self
    }

    /// Draw a table, returns the y coordinate below the table
    pub fn table(&mut self, tbl: &Table, x: f64, y_top: f64) -> f64 {
        // Use the first registered font prefix for the table
        let prefix = if !self.fonts.is_empty() {
            self.fonts[0].key.trim_end_matches("Reg").to_string()
        } else {
            "F1".to_string()
        };
        tbl.render(&mut self.content, x, y_top, &prefix)
    }

    /// Draw a hyperlink annotation area (visible as blue underlined text)
    pub fn hyperlink(
        &mut self,
        text: &str,
        url: &str,
        x: f64, y: f64,
        font_key: &str,
        size: f64,
    ) -> &mut Self {
        let font = self.font_for_key(font_key);
        let text_w = font.string_width(text, size);

        // Draw blue underlined text
        self.content.draw_text(text, x, y, font_key, font, size, Color::DARK_BLUE, TextAlign::Left);
        self.content.line(x, y - 1.0, x + text_w, y - 1.0, Color::DARK_BLUE, 0.5);

        // Store link annotation metadata (will be added as page annotation)
        // For now we embed the URL as a tagged draw call
        // Full annotation support is in the document layer
        let _ = url;

        self
    }

    /// Direct access to the content stream for advanced operations
    pub fn content_stream(&mut self) -> &mut ContentStream {
        &mut self.content
    }

    // ── Internal: build PDF objects ──────────────────────────────────────────    /// Consume the builder, return content bytes, image data, and pre-built font resources
    pub(crate) fn finish(self) -> (Vec<u8>, Vec<(String, PdfImage)>, PdfDict) {
        // Build font portion of resources now (before we lose self)
        let mut resources = PdfDict::new();
        if !self.fonts.is_empty() {
            let mut font_dict = PdfDict::new();
            for f in &self.fonts {
                let mut font_obj = PdfDict::new();
                font_obj.set("Type", PdfObject::name("Font"));
                font_obj.set("Subtype", PdfObject::name("Type1"));
                font_obj.set("BaseFont", PdfObject::name(f.font.pdf_name()));
                font_obj.set("Encoding", PdfObject::name("WinAnsiEncoding"));
                font_dict.set(f.key.clone(), PdfObject::Dictionary(font_obj));

                let mut bold_obj = PdfDict::new();
                bold_obj.set("Type", PdfObject::name("Font"));
                bold_obj.set("Subtype", PdfObject::name("Type1"));
                bold_obj.set("BaseFont", PdfObject::name(f.bold_font.pdf_name()));
                bold_obj.set("Encoding", PdfObject::name("WinAnsiEncoding"));
                font_dict.set(f.bold_key.clone(), PdfObject::Dictionary(bold_obj));
            }
            resources.set("Font", PdfObject::Dictionary(font_dict));
        }
        (self.content.to_bytes(), self.images, resources)
    }
}