1mod 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#[derive(Debug, Error)]
22pub enum PdfError {
23 #[error("PDF generation error: {0}")]
24 Generation(String),
25}
26
27pub 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}