pub mod config;
mod debug;
pub mod fonts;
pub mod markdown;
pub mod pdf;
pub mod styling;
pub mod validation;
use markdown::*;
use pdf::Pdf;
use std::error::Error;
use std::fmt;
#[derive(Debug)]
pub enum MdpError {
ParseError {
message: String,
position: Option<usize>,
suggestion: Option<String>,
},
PdfError {
message: String,
path: Option<String>,
suggestion: Option<String>,
},
FontError {
font_name: String,
message: String,
suggestion: String,
},
ConfigError { message: String, suggestion: String },
IoError {
message: String,
path: String,
suggestion: String,
},
}
impl Error for MdpError {}
impl fmt::Display for MdpError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
MdpError::ParseError {
message,
position,
suggestion,
} => {
write!(f, "❌ Markdown Parsing Error: {}", message)?;
if let Some(pos) = position {
write!(f, " (at position {})", pos)?;
}
if let Some(hint) = suggestion {
write!(f, "\n💡 Suggestion: {}", hint)?;
}
Ok(())
}
MdpError::PdfError {
message,
path,
suggestion,
} => {
write!(f, "❌ PDF Generation Error: {}", message)?;
if let Some(p) = path {
write!(f, "\n📁 Path: {}", p)?;
}
if let Some(hint) = suggestion {
write!(f, "\n💡 Suggestion: {}", hint)?;
}
Ok(())
}
MdpError::FontError {
font_name,
message,
suggestion,
} => {
write!(f, "❌ Font Error: Failed to load font '{}'", font_name)?;
write!(f, "\n Reason: {}", message)?;
write!(f, "\n💡 Suggestion: {}", suggestion)?;
Ok(())
}
MdpError::ConfigError {
message,
suggestion,
} => {
write!(f, "❌ Configuration Error: {}", message)?;
write!(f, "\n💡 Suggestion: {}", suggestion)?;
Ok(())
}
MdpError::IoError {
message,
path,
suggestion,
} => {
write!(f, "❌ File Error: {}", message)?;
write!(f, "\n📁 Path: {}", path)?;
write!(f, "\n💡 Suggestion: {}", suggestion)?;
Ok(())
}
}
}
}
impl MdpError {
pub fn parse_error(message: impl Into<String>) -> Self {
MdpError::ParseError {
message: message.into(),
position: None,
suggestion: Some(
"Check your Markdown syntax for unclosed brackets, quotes, or code blocks"
.to_string(),
),
}
}
pub fn pdf_error(message: impl Into<String>) -> Self {
MdpError::PdfError {
message: message.into(),
path: None,
suggestion: Some(
"Check that the output directory exists and you have write permissions".to_string(),
),
}
}
}
pub fn parse_into_file(
markdown: String,
path: &str,
config: config::ConfigSource,
font_config: Option<&fonts::FontConfig>,
) -> Result<(), MdpError> {
if let Some(parent) = std::path::Path::new(path).parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
return Err(MdpError::IoError {
message: format!("Output directory does not exist"),
path: parent.display().to_string(),
suggestion: format!("Create the directory first: mkdir -p {}", parent.display()),
});
}
}
let mut lexer = Lexer::new(markdown);
let tokens = lexer.parse().map_err(|e| {
let msg = format!("{:?}", e);
MdpError::ParseError {
message: msg.clone(),
position: None,
suggestion: Some(if msg.contains("UnexpectedEndOfInput") {
"Check for unclosed code blocks (```), links, or image tags".to_string()
} else {
"Verify your Markdown syntax is valid. Try testing with a simpler document first."
.to_string()
}),
}
})?;
let style = config::load_config_from_source(config);
let pdf = Pdf::new(tokens, style, font_config)?;
let document = pdf.render_into_document();
if let Some(err) = Pdf::render(document, path) {
return Err(MdpError::PdfError {
message: err.clone(),
path: Some(path.to_string()),
suggestion: Some(if err.contains("Permission") || err.contains("denied") {
"Check that you have write permissions for this location".to_string()
} else if err.contains("No such file") {
"Make sure the output directory exists".to_string()
} else {
"Try a different output path or check available disk space".to_string()
}),
});
}
Ok(())
}
pub fn parse_into_bytes(
markdown: String,
config: config::ConfigSource,
font_config: Option<&fonts::FontConfig>,
) -> Result<Vec<u8>, MdpError> {
let mut lexer = Lexer::new(markdown);
let tokens = lexer.parse().map_err(|e| {
let msg = format!("{:?}", e);
MdpError::ParseError {
message: msg.clone(),
position: None,
suggestion: Some(if msg.contains("UnexpectedEndOfInput") {
"Check for unclosed code blocks (```), links, or image tags".to_string()
} else {
"Verify your Markdown syntax is valid. Try testing with a simpler document first."
.to_string()
}),
}
})?;
let style = config::load_config_from_source(config);
let pdf = Pdf::new(tokens, style, font_config)?;
let document = pdf.render_into_document();
Pdf::render_to_bytes(document).map_err(|err| MdpError::PdfError {
message: err,
path: None,
suggestion: Some("Check available memory and try with a smaller document".to_string()),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_basic_markdown_conversion() {
let markdown = "# Test\nHello world".to_string();
let result = parse_into_file(
markdown,
"test_output.pdf",
config::ConfigSource::Default,
None,
);
assert!(result.is_ok());
fs::remove_file("test_output.pdf").unwrap();
}
#[test]
fn test_invalid_markdown() {
let markdown = "![Invalid".to_string();
let result = parse_into_file(
markdown,
"error_output.pdf",
config::ConfigSource::Default,
None,
);
assert!(matches!(result, Err(MdpError::ParseError { .. })));
}
#[test]
fn test_invalid_output_path() {
let markdown = "# Test".to_string();
let result = parse_into_file(
markdown,
"/nonexistent/directory/output.pdf",
config::ConfigSource::Default,
None,
);
assert!(matches!(
result,
Err(MdpError::IoError { .. }) | Err(MdpError::PdfError { .. })
));
}
#[test]
fn test_basic_markdown_to_bytes() {
let markdown = "# Test\nHello world".to_string();
let result = parse_into_bytes(markdown, config::ConfigSource::Default, None);
assert!(result.is_ok());
let pdf_bytes = result.unwrap();
assert!(!pdf_bytes.is_empty());
assert!(pdf_bytes.starts_with(b"%PDF-"));
}
#[test]
fn test_embedded_config_file_output() {
const EMBEDDED_CONFIG: &str = r#"
[margin]
top = 20.0
right = 20.0
bottom = 20.0
left = 20.0
[heading.1]
size = 20
bold = true
alignment = "center"
"#;
let markdown = "# Test Heading\nThis is test content.".to_string();
let result = parse_into_file(
markdown,
"test_embedded_output.pdf",
config::ConfigSource::Embedded(EMBEDDED_CONFIG),
None,
);
assert!(result.is_ok());
assert!(std::path::Path::new("test_embedded_output.pdf").exists());
fs::remove_file("test_embedded_output.pdf").unwrap();
}
#[test]
fn test_embedded_config_bytes_output() {
const EMBEDDED_CONFIG: &str = r#"
[text]
size = 14
alignment = "justify"
fontfamily = "helvetica"
[heading.1]
size = 18
textcolor = { r = 100, g = 100, b = 100 }
"#;
let markdown =
"# Hello World\nThis is a test document with embedded configuration.".to_string();
let result = parse_into_bytes(
markdown,
config::ConfigSource::Embedded(EMBEDDED_CONFIG),
None,
);
assert!(result.is_ok());
let pdf_bytes = result.unwrap();
assert!(!pdf_bytes.is_empty());
assert!(pdf_bytes.starts_with(b"%PDF-"));
}
#[test]
fn test_embedded_config_invalid_toml() {
const INVALID_CONFIG: &str = "this is not valid toml {{{";
let markdown = "# Test\nContent".to_string();
let result = parse_into_bytes(
markdown,
config::ConfigSource::Embedded(INVALID_CONFIG),
None,
);
assert!(result.is_ok());
let pdf_bytes = result.unwrap();
assert!(!pdf_bytes.is_empty());
}
#[test]
fn test_embedded_config_empty() {
const EMPTY_CONFIG: &str = "";
let markdown = "# Test\nContent".to_string();
let result = parse_into_bytes(markdown, config::ConfigSource::Embedded(EMPTY_CONFIG), None);
assert!(result.is_ok());
let pdf_bytes = result.unwrap();
assert!(!pdf_bytes.is_empty());
}
#[test]
fn test_config_source_variants() {
let markdown = "# Test\nContent".to_string();
let result = parse_into_bytes(markdown.clone(), config::ConfigSource::Default, None);
assert!(result.is_ok());
const EMBEDDED: &str = r#"
[heading.1]
size = 16
bold = true
"#;
let result = parse_into_bytes(
markdown.clone(),
config::ConfigSource::Embedded(EMBEDDED),
None,
);
assert!(result.is_ok());
let result = parse_into_bytes(
markdown,
config::ConfigSource::File("nonexistent.toml"),
None,
);
assert!(result.is_ok());
}
#[test]
fn test_complex_markdown_to_bytes() {
let markdown = r#"
# Document Title
This is a paragraph with **bold** and *italic* text.
## Subheading
- List item 1
- List item 2
- Nested item
1. Ordered item 1
2. Ordered item 2
```rust
fn hello() {
println!("Hello, world!");
}
```
[Link example](https://example.com)
---
Final paragraph.
"#
.to_string();
let result = parse_into_bytes(markdown, config::ConfigSource::Default, None);
assert!(result.is_ok());
let pdf_bytes = result.unwrap();
assert!(!pdf_bytes.is_empty());
assert!(pdf_bytes.starts_with(b"%PDF-"));
}
#[test]
fn test_empty_markdown_to_bytes() {
let markdown = "".to_string();
let result = parse_into_bytes(markdown, config::ConfigSource::Default, None);
assert!(result.is_ok());
let pdf_bytes = result.unwrap();
assert!(!pdf_bytes.is_empty());
assert!(pdf_bytes.starts_with(b"%PDF-"));
}
#[test]
fn test_invalid_markdown_to_bytes() {
let markdown = "![Invalid".to_string();
let result = parse_into_bytes(markdown, config::ConfigSource::Default, None);
assert!(matches!(result, Err(MdpError::ParseError { .. })));
}
#[test]
fn test_link_styling_with_underline() {
const LINK_STYLE_CONFIG: &str = r#"
[link]
size = 10
textcolor = { r = 0, g = 0, b = 200 }
bold = true
italic = false
underline = true
strikethrough = false
"#;
let markdown = r#"
# Links Test
- [Styled link](https://example.com)
- [Another styled link](https://example.org)
"#
.to_string();
let result = parse_into_bytes(
markdown,
config::ConfigSource::Embedded(LINK_STYLE_CONFIG),
None,
);
assert!(result.is_ok());
let pdf_bytes = result.unwrap();
assert!(!pdf_bytes.is_empty());
assert!(pdf_bytes.starts_with(b"%PDF-"));
}
#[test]
fn test_link_styling_with_strikethrough() {
const LINK_STYLE_CONFIG: &str = r#"
[link]
size = 10
textcolor = { r = 200, g = 0, b = 0 }
bold = false
italic = true
underline = false
strikethrough = true
"#;
let markdown = "[Strikethrough link](https://example.com)".to_string();
let result = parse_into_bytes(
markdown,
config::ConfigSource::Embedded(LINK_STYLE_CONFIG),
None,
);
assert!(result.is_ok());
let pdf_bytes = result.unwrap();
assert!(!pdf_bytes.is_empty());
assert!(pdf_bytes.starts_with(b"%PDF-"));
}
}