devup-editor-pdf 1.0.9

PDF export (paginated, font-embedded) for devup-editor documents
Documentation
//! Minimal pure-Rust PDF 1.4 writer.
//!
//! Generates plain-text PDFs using the built-in Helvetica font with `WinAnsi`
//! encoding (Latin-1). Non-Latin characters (e.g. Korean) are replaced with
//! `?` since the standard 14 fonts cannot render them.
//!
//! No external dependencies — works in `wasm32-unknown-unknown` and any other
//! target supported by Rust.

use std::fmt::Write as _;

const PAGE_W: f32 = 595.0; // A4 width in points (72dpi)
const PAGE_H: f32 = 842.0;
const MARGIN_X: f32 = 50.0;
const MARGIN_TOP: f32 = 60.0;
const MARGIN_BOTTOM: f32 = 60.0;
const DEFAULT_FONT_SIZE: f32 = 11.0;
const DEFAULT_LINE_HEIGHT: f32 = 14.0;

/// A single line of text with positioning info, queued for rendering.
#[derive(Clone)]
pub struct Fragment {
    pub text: String,
    pub bold: bool,
    pub color: Option<(f32, f32, f32)>,
    pub highlight: Option<(f32, f32, f32)>,
}

pub struct Line {
    pub fragments: Vec<Fragment>,
    pub x: f32,
    pub font_size: f32,
}

pub struct PdfWriter {
    pages: Vec<Vec<Line>>,
    current: Vec<Line>,
    y: f32,
}

impl Default for PdfWriter {
    fn default() -> Self {
        Self::new()
    }
}

impl PdfWriter {
    #[must_use]
    pub fn new() -> Self {
        Self {
            pages: Vec::new(),
            current: Vec::new(),
            y: PAGE_H - MARGIN_TOP,
        }
    }

    pub fn push_fragments(&mut self, fragments: Vec<Fragment>, x: f32, font_size: f32) {
        let line_h = (font_size * 1.3).max(DEFAULT_LINE_HEIGHT);
        if self.y < MARGIN_BOTTOM + line_h {
            self.flush_page();
        }
        self.current.push(Line {
            fragments,
            x,
            font_size,
        });
        self.y -= line_h;
    }

    /// Add vertical spacing without writing anything.
    pub fn skip(&mut self, amount: f32) {
        self.y -= amount;
        if self.y < MARGIN_BOTTOM {
            self.flush_page();
        }
    }

    fn flush_page(&mut self) {
        if !self.current.is_empty() {
            self.pages.push(std::mem::take(&mut self.current));
        }
        self.y = PAGE_H - MARGIN_TOP;
    }

    /// Finalize and serialize the PDF as bytes.
    #[must_use]
    pub fn finish(mut self) -> Vec<u8> {
        if !self.current.is_empty() {
            self.pages.push(std::mem::take(&mut self.current));
        }
        if self.pages.is_empty() {
            self.pages.push(Vec::new());
        }

        // Build PDF objects:
        //   1: Catalog
        //   2: Pages
        //   3: Helvetica font
        //   4: Helvetica-Bold font
        //   5..: alternating Page + Content for each page
        let mut objects: Vec<String> = Vec::new();

        // 1: Catalog
        objects.push("<< /Type /Catalog /Pages 2 0 R >>".into());

        // 2: Pages root (filled in below once page IDs are known)
        let pages_obj_idx = objects.len();
        objects.push(String::new()); // placeholder

        // 3: Helvetica
        objects.push(
            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"
                .into(),
        );

        // 4: Helvetica-Bold
        objects.push(
            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>"
                .into(),
        );

        // 5..: page + content pairs
        let mut page_obj_ids: Vec<usize> = Vec::new();
        let pages_taken = std::mem::take(&mut self.pages);
        for lines in &pages_taken {
            let content = build_content_stream(lines);
            let content_obj_id = objects.len() + 1;
            objects.push(format!(
                "<< /Length {} >>\nstream\n{content}\nendstream",
                content.len()
            ));

            let page_obj_id = objects.len() + 1;
            page_obj_ids.push(page_obj_id);
            objects.push(format!(
                "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {PAGE_W} {PAGE_H}] /Contents {content_obj_id} 0 R /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> >>"
            ));
        }

        // Now fill in the Pages object with the actual kids
        let kids = page_obj_ids
            .iter()
            .map(|id| format!("{id} 0 R"))
            .collect::<Vec<_>>()
            .join(" ");
        objects[pages_obj_idx] = format!(
            "<< /Type /Pages /Kids [{kids}] /Count {} >>",
            page_obj_ids.len()
        );

        // Serialize objects + xref. Build into a String for easy formatting,
        // then convert to bytes (PDF allows ASCII + Latin-1 in literals; our
        // text escape function only emits those).
        let mut s = String::with_capacity(2048);
        s.push_str("%PDF-1.4\n");

        let mut offsets: Vec<usize> = Vec::with_capacity(objects.len());
        for (i, body) in objects.iter().enumerate() {
            offsets.push(s.len());
            let id = i + 1;
            let _ = writeln!(s, "{id} 0 obj");
            s.push_str(body);
            s.push_str("\nendobj\n");
        }

        let xref_offset = s.len();
        let n = objects.len() + 1;
        let _ = writeln!(s, "xref\n0 {n}\n0000000000 65535 f ");
        for offset in &offsets {
            let _ = writeln!(s, "{offset:010} 00000 n ");
        }
        let _ = write!(
            s,
            "trailer\n<< /Size {n} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n"
        );

        s.into_bytes()
    }
}

fn build_content_stream(lines: &[Line]) -> String {
    if lines.is_empty() {
        return String::new();
    }
    let mut content = String::new();
    let mut current_y = PAGE_H - MARGIN_TOP;
    for line in lines {
        let line_h = (line.font_size * 1.3).max(DEFAULT_LINE_HEIGHT);
        let mut current_x = line.x;
        for fragment in &line.fragments {
            if fragment.text.is_empty() {
                continue;
            }

            if let Some((highlight_red, highlight_green, highlight_blue)) = fragment.highlight {
                let width = estimate_text_width(&fragment.text, line.font_size);
                let rect_y = current_y - line.font_size * 0.28;
                let rect_h = line.font_size * 1.05;
                let _ = writeln!(
                    content,
                    "q {highlight_red:.3} {highlight_green:.3} {highlight_blue:.3} rg {current_x:.3} {rect_y:.3} {width:.3} {rect_h:.3} re f Q"
                );
            }

            let (text_red, text_green, text_blue) = fragment.color.unwrap_or((0.0, 0.0, 0.0));
            let font = if fragment.bold { "/F2" } else { "/F1" };
            let _ = writeln!(
                content,
                "BT {font} {} Tf {text_red:.3} {text_green:.3} {text_blue:.3} rg 1 0 0 1 {current_x:.3} {current_y:.3} Tm ({}) Tj ET",
                line.font_size,
                escape_pdf_string(&fragment.text)
            );
            current_x += estimate_text_width(&fragment.text, line.font_size);
        }
        current_y -= line_h;
    }
    content
}

fn estimate_text_width(text: &str, font_size: f32) -> f32 {
    text.chars().fold(0.0, |count, _| count + 1.0) * font_size * 0.55
}

/// Escape characters for PDF literal strings and replace non-Latin1 chars with `?`.
fn escape_pdf_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '(' => out.push_str("\\("),
            ')' => out.push_str("\\)"),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            ch if (ch as u32) < 0x80 => out.push(ch),
            ch if (ch as u32) <= 0xFF => {
                // Latin-1 range — encode as raw byte
                out.push(ch);
            }
            _ => out.push('?'),
        }
    }
    out
}

pub const PAGE_MARGIN_X: f32 = MARGIN_X;
pub const PAGE_DEFAULT_FONT_SIZE: f32 = DEFAULT_FONT_SIZE;