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};
#[derive(Debug, Error)]
pub enum PdfError {
#[error("PDF generation error: {0}")]
Generation(String),
}
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"));
}
}