Skip to main content

devup_editor_pdf/
lib.rs

1//! PDF export for the devup editor.
2//!
3//! Pure-Rust, dependency-free, wasm32-compatible PDF 1.4 writer.
4//! Renders blocks as plain text using built-in Helvetica with `WinAnsi` encoding.
5//! Non-Latin characters are replaced with `?`.
6
7mod writer;
8
9use devup_editor_core::{Block, Document, DocumentExport};
10use serde_json::Value;
11use thiserror::Error;
12
13use writer::{Fragment, PAGE_DEFAULT_FONT_SIZE, PAGE_MARGIN_X, PdfWriter};
14
15/// Error produced by PDF export.
16///
17/// The current implementation is infallible for all engine-produced
18/// `Document`s — we paginate and emit bytes without checked fallibility.
19/// The `Generation` variant is reserved for future paths where font
20/// embedding, image decoding, or external resource fetches could fail.
21#[derive(Debug, Error)]
22pub enum PdfError {
23    #[error("PDF generation error: {0}")]
24    Generation(String),
25}
26
27/// PDF exporter (no importer — PDF is a one-way format).
28pub struct Pdf;
29
30const INDENT_STEP_PT: f32 = 18.0;
31
32impl DocumentExport for Pdf {
33    type Output = Vec<u8>;
34    type Error = PdfError;
35
36    fn export(doc: &Document) -> Result<Vec<u8>, PdfError> {
37        let mut w = PdfWriter::new();
38
39        for id in doc.root_block_ids() {
40            let Some(block) = doc.get_block(id) else {
41                continue;
42            };
43            render_block(&mut w, block);
44        }
45
46        Ok(w.finish())
47    }
48}
49
50fn render_block(w: &mut PdfWriter, block: &Block) {
51    let indent = i16::try_from(block.indent_level()).map_or(0.0, f32::from);
52    let x = PAGE_MARGIN_X + indent * INDENT_STEP_PT;
53
54    let (prefix, font_size, bold) = match block.ty.as_str() {
55        "heading" => {
56            let level = block
57                .props
58                .get("level")
59                .and_then(Value::as_u64)
60                .unwrap_or(1)
61                .clamp(1, 3);
62            let size = match level {
63                1 => 22.0,
64                2 => 18.0,
65                _ => 15.0,
66            };
67            (String::new(), size, true)
68        }
69        "todo" => {
70            let checked = block
71                .props
72                .get("checked")
73                .and_then(Value::as_bool)
74                .unwrap_or(false);
75            let mark = if checked { "[x] " } else { "[ ] " };
76            (mark.to_string(), PAGE_DEFAULT_FONT_SIZE, false)
77        }
78        "list" => {
79            let style = block
80                .props
81                .get("style")
82                .and_then(Value::as_str)
83                .unwrap_or("unordered");
84            let marker = if style.starts_with("ordered") {
85                "1. "
86            } else {
87                "- "
88            };
89            (marker.to_string(), PAGE_DEFAULT_FONT_SIZE, false)
90        }
91        "quote" => ("| ".to_string(), PAGE_DEFAULT_FONT_SIZE, false),
92        "toggle" => ("> ".to_string(), PAGE_DEFAULT_FONT_SIZE, true),
93        _ => (String::new(), PAGE_DEFAULT_FONT_SIZE, false),
94    };
95
96    let lines = render_inline_fragments(&block.content, bold);
97
98    if lines.is_empty() && prefix.is_empty() {
99        w.skip(font_size * 0.5);
100        return;
101    }
102
103    for line in lines {
104        let mut fragments = Vec::new();
105        if !prefix.is_empty() {
106            fragments.push(Fragment {
107                text: prefix.clone(),
108                bold,
109                color: None,
110                highlight: None,
111            });
112        }
113        fragments.extend(line);
114        if !fragments.is_empty() {
115            w.push_fragments(fragments, x, font_size);
116        }
117    }
118}
119
120fn render_inline_fragments(
121    spans: &[devup_editor_core::TextSpan],
122    default_bold: bool,
123) -> Vec<Vec<Fragment>> {
124    let mut lines: Vec<Vec<Fragment>> = vec![Vec::new()];
125
126    for span in spans {
127        let parts: Vec<&str> = span.text.split('\n').collect();
128        for (i, part) in parts.iter().enumerate() {
129            if !part.is_empty() {
130                lines.last_mut().expect("line exists").push(Fragment {
131                    text: (*part).to_string(),
132                    bold: default_bold || span.marks.iter().any(|m| m.ty == "bold"),
133                    color: mark_rgb(&span.marks, "color", "color"),
134                    highlight: mark_rgb(&span.marks, "highlight", "backgroundColor"),
135                });
136            }
137            if i + 1 < parts.len() {
138                lines.push(Vec::new());
139            }
140        }
141    }
142
143    if lines.is_empty() {
144        vec![Vec::new()]
145    } else {
146        lines
147    }
148}
149
150fn mark_rgb(
151    marks: &[devup_editor_core::Mark],
152    mark_type: &str,
153    style_key: &str,
154) -> Option<(f32, f32, f32)> {
155    let hex = marks
156        .iter()
157        .find(|mark| mark.ty == mark_type)
158        .and_then(|mark| mark.style())
159        .and_then(|style| style.get(style_key))
160        .and_then(Value::as_str)?;
161    parse_hex_rgb(hex)
162}
163
164fn parse_hex_rgb(value: &str) -> Option<(f32, f32, f32)> {
165    let hex = value.strip_prefix('#')?;
166    if hex.len() != 6 {
167        return None;
168    }
169    let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
170    let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
171    let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
172    Some((
173        f32::from(r) / 255.0,
174        f32::from(g) / 255.0,
175        f32::from(b) / 255.0,
176    ))
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use devup_editor_core::{Block, BlockId, Mark, TextSpan};
183    use serde_json::{Map, Value};
184
185    fn doc_with(blocks: Vec<Block>) -> Document {
186        let mut doc = Document::new();
187        for b in blocks {
188            doc.push_root_block(b);
189        }
190        doc
191    }
192
193    #[test]
194    fn export_paragraph_returns_pdf_bytes() {
195        let mut block = Block::new_paragraph(BlockId::new("b1"));
196        block.content = vec![TextSpan::plain("Hello, PDF!")];
197        let doc = doc_with(vec![block]);
198
199        let bytes = Pdf::export(&doc).unwrap();
200        assert!(bytes.starts_with(b"%PDF-1.4"));
201        assert!(bytes.ends_with(b"%%EOF\n"));
202    }
203
204    #[test]
205    fn export_multiple_blocks() {
206        let mut p1 = Block::new_paragraph(BlockId::new("b1"));
207        p1.content = vec![TextSpan::plain("first")];
208        let mut p2 = Block::new_paragraph(BlockId::new("b2"));
209        p2.content = vec![TextSpan::plain("second")];
210        let doc = doc_with(vec![p1, p2]);
211
212        let bytes = Pdf::export(&doc).unwrap();
213        assert!(bytes.starts_with(b"%PDF-1.4"));
214    }
215
216    #[test]
217    fn export_empty_document_returns_valid_pdf() {
218        let doc = Document::new();
219        let bytes = Pdf::export(&doc).unwrap();
220        assert!(bytes.starts_with(b"%PDF-1.4"));
221        assert!(bytes.ends_with(b"%%EOF\n"));
222    }
223
224    #[test]
225    fn export_color_and_highlight_writes_pdf_color_ops() {
226        let mut color_style = Map::new();
227        color_style.insert("color".into(), Value::String("#ff0000".into()));
228        let mut color_attrs = Map::new();
229        color_attrs.insert("style".into(), Value::Object(color_style));
230
231        let mut highlight_style = Map::new();
232        highlight_style.insert("backgroundColor".into(), Value::String("#fff000".into()));
233        let mut highlight_attrs = Map::new();
234        highlight_attrs.insert("style".into(), Value::Object(highlight_style));
235
236        let mut block = Block::new_paragraph(BlockId::new("b1"));
237        block.content = vec![TextSpan::with_marks(
238            "hello",
239            vec![
240                Mark::with_attrs("color", color_attrs),
241                Mark::with_attrs("highlight", highlight_attrs),
242            ],
243        )];
244        let bytes = Pdf::export(&doc_with(vec![block])).unwrap();
245        let text = String::from_utf8_lossy(&bytes);
246        assert!(text.contains("1.000 0.000 0.000 rg"));
247        assert!(text.contains("re f Q"));
248    }
249}