devup-editor-pdf 1.0.0

PDF export (paginated, font-embedded) for devup-editor documents
Documentation
//! PDF export for the devup editor.
//!
//! Pure-Rust, dependency-free, wasm32-compatible PDF 1.4 writer.
//! Renders blocks as plain text using built-in Helvetica with `WinAnsi` encoding.
//! Non-Latin characters are replaced with `?`.

mod writer;

use devup_editor_core::{Block, Document, DocumentExport};
use serde_json::Value;
use thiserror::Error;

use writer::{Fragment, PAGE_DEFAULT_FONT_SIZE, PAGE_MARGIN_X, PdfWriter};

/// Error produced by PDF export.
///
/// The current implementation is infallible for all engine-produced
/// `Document`s — we paginate and emit bytes without checked fallibility.
/// The `Generation` variant is reserved for future paths where font
/// embedding, image decoding, or external resource fetches could fail.
#[derive(Debug, Error)]
pub enum PdfError {
    #[error("PDF generation error: {0}")]
    Generation(String),
}

/// PDF exporter (no importer — PDF is a one-way format).
pub struct Pdf;

const INDENT_STEP_PT: f32 = 18.0;

impl DocumentExport for Pdf {
    type Output = Vec<u8>;
    type Error = PdfError;

    fn export(doc: &Document) -> Result<Vec<u8>, PdfError> {
        let mut w = PdfWriter::new();

        for id in doc.root_block_ids() {
            let Some(block) = doc.get_block(id) else {
                continue;
            };
            render_block(&mut w, block);
        }

        Ok(w.finish())
    }
}

fn render_block(w: &mut PdfWriter, block: &Block) {
    let indent = i16::try_from(block.indent_level()).map_or(0.0, f32::from);
    let x = PAGE_MARGIN_X + indent * INDENT_STEP_PT;

    let (prefix, font_size, bold) = match block.ty.as_str() {
        "heading" => {
            let level = block
                .props
                .get("level")
                .and_then(Value::as_u64)
                .unwrap_or(1)
                .clamp(1, 3);
            let size = match level {
                1 => 22.0,
                2 => 18.0,
                _ => 15.0,
            };
            (String::new(), size, true)
        }
        "todo" => {
            let checked = block
                .props
                .get("checked")
                .and_then(Value::as_bool)
                .unwrap_or(false);
            let mark = if checked { "[x] " } else { "[ ] " };
            (mark.to_string(), PAGE_DEFAULT_FONT_SIZE, false)
        }
        "list" => {
            let style = block
                .props
                .get("style")
                .and_then(Value::as_str)
                .unwrap_or("unordered");
            let marker = if style.starts_with("ordered") {
                "1. "
            } else {
                "- "
            };
            (marker.to_string(), PAGE_DEFAULT_FONT_SIZE, false)
        }
        "quote" => ("| ".to_string(), PAGE_DEFAULT_FONT_SIZE, false),
        "toggle" => ("> ".to_string(), PAGE_DEFAULT_FONT_SIZE, true),
        _ => (String::new(), PAGE_DEFAULT_FONT_SIZE, false),
    };

    let lines = render_inline_fragments(&block.content, bold);

    if lines.is_empty() && prefix.is_empty() {
        w.skip(font_size * 0.5);
        return;
    }

    for line in lines {
        let mut fragments = Vec::new();
        if !prefix.is_empty() {
            fragments.push(Fragment {
                text: prefix.clone(),
                bold,
                color: None,
                highlight: None,
            });
        }
        fragments.extend(line);
        if !fragments.is_empty() {
            w.push_fragments(fragments, x, font_size);
        }
    }
}

fn render_inline_fragments(
    spans: &[devup_editor_core::TextSpan],
    default_bold: bool,
) -> Vec<Vec<Fragment>> {
    let mut lines: Vec<Vec<Fragment>> = vec![Vec::new()];

    for span in spans {
        let parts: Vec<&str> = span.text.split('\n').collect();
        for (i, part) in parts.iter().enumerate() {
            if !part.is_empty() {
                lines.last_mut().expect("line exists").push(Fragment {
                    text: (*part).to_string(),
                    bold: default_bold || span.marks.iter().any(|m| m.ty == "bold"),
                    color: mark_rgb(&span.marks, "color", "color"),
                    highlight: mark_rgb(&span.marks, "highlight", "backgroundColor"),
                });
            }
            if i + 1 < parts.len() {
                lines.push(Vec::new());
            }
        }
    }

    if lines.is_empty() {
        vec![Vec::new()]
    } else {
        lines
    }
}

fn mark_rgb(
    marks: &[devup_editor_core::Mark],
    mark_type: &str,
    style_key: &str,
) -> Option<(f32, f32, f32)> {
    let hex = marks
        .iter()
        .find(|mark| mark.ty == mark_type)
        .and_then(|mark| mark.style())
        .and_then(|style| style.get(style_key))
        .and_then(Value::as_str)?;
    parse_hex_rgb(hex)
}

fn parse_hex_rgb(value: &str) -> Option<(f32, f32, f32)> {
    let hex = value.strip_prefix('#')?;
    if hex.len() != 6 {
        return None;
    }
    let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
    let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
    let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
    Some((
        f32::from(r) / 255.0,
        f32::from(g) / 255.0,
        f32::from(b) / 255.0,
    ))
}

#[cfg(test)]
mod tests {
    use super::*;
    use devup_editor_core::{Block, BlockId, Mark, TextSpan};
    use serde_json::{Map, Value};

    fn doc_with(blocks: Vec<Block>) -> Document {
        let mut doc = Document::new();
        for b in blocks {
            doc.push_root_block(b);
        }
        doc
    }

    #[test]
    fn export_paragraph_returns_pdf_bytes() {
        let mut block = Block::new_paragraph(BlockId::new("b1"));
        block.content = vec![TextSpan::plain("Hello, PDF!")];
        let doc = doc_with(vec![block]);

        let bytes = Pdf::export(&doc).unwrap();
        assert!(bytes.starts_with(b"%PDF-1.4"));
        assert!(bytes.ends_with(b"%%EOF\n"));
    }

    #[test]
    fn export_multiple_blocks() {
        let mut p1 = Block::new_paragraph(BlockId::new("b1"));
        p1.content = vec![TextSpan::plain("first")];
        let mut p2 = Block::new_paragraph(BlockId::new("b2"));
        p2.content = vec![TextSpan::plain("second")];
        let doc = doc_with(vec![p1, p2]);

        let bytes = Pdf::export(&doc).unwrap();
        assert!(bytes.starts_with(b"%PDF-1.4"));
    }

    #[test]
    fn export_empty_document_returns_valid_pdf() {
        let doc = Document::new();
        let bytes = Pdf::export(&doc).unwrap();
        assert!(bytes.starts_with(b"%PDF-1.4"));
        assert!(bytes.ends_with(b"%%EOF\n"));
    }

    #[test]
    fn export_color_and_highlight_writes_pdf_color_ops() {
        let mut color_style = Map::new();
        color_style.insert("color".into(), Value::String("#ff0000".into()));
        let mut color_attrs = Map::new();
        color_attrs.insert("style".into(), Value::Object(color_style));

        let mut highlight_style = Map::new();
        highlight_style.insert("backgroundColor".into(), Value::String("#fff000".into()));
        let mut highlight_attrs = Map::new();
        highlight_attrs.insert("style".into(), Value::Object(highlight_style));

        let mut block = Block::new_paragraph(BlockId::new("b1"));
        block.content = vec![TextSpan::with_marks(
            "hello",
            vec![
                Mark::with_attrs("color", color_attrs),
                Mark::with_attrs("highlight", highlight_attrs),
            ],
        )];
        let bytes = Pdf::export(&doc_with(vec![block])).unwrap();
        let text = String::from_utf8_lossy(&bytes);
        assert!(text.contains("1.000 0.000 0.000 rg"));
        assert!(text.contains("re f Q"));
    }
}