pub mod config;
mod debug;
pub mod fonts;
pub mod frontmatter;
pub mod markdown;
pub mod render;
pub mod styling;
pub mod validation;
use markdown::*;
use std::error::Error;
use std::fmt;
#[derive(Debug)]
pub enum MdpError {
ParseError {
message: String,
line: Option<usize>,
column: 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,
line,
column,
suggestion,
} => {
write!(f, "❌ Markdown Parsing Error: {}", message)?;
if let (Some(l), Some(c)) = (line, column) {
write!(f, " (at line {}, column {})", l, c)?;
} else if let Some(l) = line {
write!(f, " (at line {})", l)?;
}
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(),
line: None,
column: 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_with_style(
markdown: String,
path: impl AsRef<std::path::Path>,
style: styling::ResolvedStyle,
font_config: Option<&fonts::FontConfig>,
) -> Result<(), MdpError> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
return Err(MdpError::IoError {
message: "Output directory does not exist".to_string(),
path: parent.display().to_string(),
suggestion: format!("Create the directory first: mkdir -p {}", parent.display()),
});
}
}
let (body, fm) = split_frontmatter(markdown);
let tokens = parse_markdown(body)?;
let mut style = style;
if let Some(fm) = fm {
fm.apply(&mut style.metadata);
}
render::render_to_file(tokens, style, font_config, path)
}
pub fn parse_into_file(
markdown: String,
path: impl AsRef<std::path::Path>,
config: config::ConfigSource,
font_config: Option<&fonts::FontConfig>,
) -> Result<(), MdpError> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
return Err(MdpError::IoError {
message: "Output directory does not exist".to_string(),
path: parent.display().to_string(),
suggestion: format!("Create the directory first: mkdir -p {}", parent.display()),
});
}
}
let (body, fm) = split_frontmatter(markdown);
let tokens = parse_markdown(body)?;
let mut style = config::load_config_from_source(config);
if let Some(fm) = fm {
fm.apply(&mut style.metadata);
}
render::render_to_file(tokens, style, font_config, path)
}
fn split_frontmatter(markdown: String) -> (String, Option<frontmatter::Frontmatter>) {
if let Some((fm, body_start)) = frontmatter::extract(&markdown) {
let body = markdown[body_start..].to_string();
(body, Some(fm))
} else {
(markdown, None)
}
}
fn parse_markdown(markdown: String) -> Result<Vec<markdown::Token>, MdpError> {
let mut lexer = Lexer::new(markdown);
lexer.parse().map_err(|e| {
let (line, column) = e.position();
let (message, suggestion) = match &e {
markdown::LexerError::UnexpectedEndOfInput { .. } => (
"Unexpected end of input".to_string(),
"Check for unclosed code blocks (```), links, or image tags".to_string(),
),
markdown::LexerError::UnknownToken { message, .. } => (
message.clone(),
"Verify your Markdown syntax is valid. Try testing with a simpler document first."
.to_string(),
),
};
MdpError::ParseError {
message,
line: Some(line),
column: Some(column),
suggestion: Some(suggestion),
}
})
}
pub fn parse_into_bytes(
markdown: String,
config: config::ConfigSource,
font_config: Option<&fonts::FontConfig>,
) -> Result<Vec<u8>, MdpError> {
let (body, fm) = split_frontmatter(markdown);
let tokens = parse_markdown(body)?;
let mut style = config::load_config_from_source(config);
if let Some(fm) = fm {
fm.apply(&mut style.metadata);
}
render::render_to_bytes(tokens, style, font_config)
}
pub fn parse_into_bytes_with_style(
markdown: String,
style: styling::ResolvedStyle,
font_config: Option<&fonts::FontConfig>,
) -> Result<Vec<u8>, MdpError> {
let (body, fm) = split_frontmatter(markdown);
let tokens = parse_markdown(body)?;
let mut style = style;
if let Some(fm) = fm {
fm.apply(&mut style.metadata);
}
render::render_to_bytes(tokens, style, font_config)
}
#[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_output_path_does_not_swallow_real_errors() {
let markdown = "<!--never closes".to_string();
let result = parse_into_file(
markdown,
"error_output.pdf",
config::ConfigSource::Default,
None,
);
if let Err(MdpError::ParseError { .. }) = result {
panic!("lexer should treat unclosed comment as literal text, not a parse error");
}
let _ = std::fs::remove_file("error_output.pdf");
}
#[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 parse_into_bytes_with_style_renders() {
let markdown = "# Test\nBody".to_string();
let style = styling::ResolvedStyle::default();
let bytes = parse_into_bytes_with_style(markdown, style, None).expect("render");
assert!(bytes.starts_with(b"%PDF-"));
}
#[test]
fn parse_error_display_includes_line_and_column_when_present() {
let err = MdpError::ParseError {
message: "Bad token".to_string(),
line: Some(7),
column: Some(3),
suggestion: None,
};
let s = format!("{}", err);
assert!(
s.contains("line 7") && s.contains("column 3"),
"expected line/column in display, got: {}",
s
);
}
#[test]
fn parse_error_display_omits_position_when_absent() {
let err = MdpError::ParseError {
message: "Bad token".to_string(),
line: None,
column: None,
suggestion: None,
};
let s = format!("{}", err);
assert!(!s.contains("line"), "unexpected position in display: {}", s);
}
#[test]
fn parse_into_file_accepts_pathbuf_and_str() {
let markdown = "# Hi".to_string();
let pathbuf = std::env::temp_dir().join("m2p_asref_test.pdf");
parse_into_file(
markdown.clone(),
&pathbuf,
config::ConfigSource::Default,
None,
)
.expect("PathBuf path works");
assert!(pathbuf.exists());
let _ = fs::remove_file(&pathbuf);
let path_str = pathbuf.to_str().unwrap();
parse_into_file(markdown, path_str, config::ConfigSource::Default, None)
.expect("&str path works");
let _ = fs::remove_file(&pathbuf);
}
#[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_partial_html_comment_is_not_a_parse_error() {
let markdown = "<!--never closes".to_string();
let result = parse_into_bytes(markdown, config::ConfigSource::Default, None);
if let Err(MdpError::ParseError { .. }) = result {
panic!("lexer should treat unclosed comment as literal text, not a parse error");
}
}
#[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-"));
}
}