pub mod ast;
pub mod css;
pub mod error;
pub mod generator;
pub mod html;
pub mod render;
pub mod text;
pub mod visual;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
pub use render::{
PageRenderer, PdfDocumentGenerator, PdfRenderer, PixmapDocumentGenerator, PixmapRenderer,
SvgDocumentGenerator, SvgRenderer,
};
pub use ast::PageConfig;
pub use html::{markdown_to_html, markdown_to_html_document};
use generator::Document;
#[derive(Debug, Clone)]
pub struct ConvertOptions {
pub font_family: Vec<String>,
pub user_css: String,
pub css_file: Option<PathBuf>,
pub strict: bool,
pub auto_font: bool,
pub page_config: Option<PageConfig>,
}
impl ConvertOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_font_family(mut self, families: &[&str]) -> Self {
self.font_family = families.iter().map(|f| f.to_string()).collect();
self
}
pub fn with_css(mut self, css: &str) -> Self {
self.user_css = css.to_string();
self
}
pub fn with_css_file(mut self, path: PathBuf) -> Self {
self.css_file = Some(path);
self
}
pub fn with_strict(mut self, strict: bool) -> Self {
self.strict = strict;
self
}
pub fn with_auto_font(mut self, auto_font: bool) -> Self {
self.auto_font = auto_font;
self
}
pub fn with_page_config(mut self, config: PageConfig) -> Self {
self.page_config = Some(config);
self
}
pub fn with_header(mut self, header: &str) -> Self {
let config = self.page_config.get_or_insert_with(PageConfig::default);
config.header = Some(header.to_string());
self
}
pub fn with_footer(mut self, footer: &str) -> Self {
let config = self.page_config.get_or_insert_with(PageConfig::default);
config.footer = Some(footer.to_string());
self
}
pub fn with_header_font_size(mut self, size: f32) -> Self {
let config = self.page_config.get_or_insert_with(PageConfig::default);
config.header_font_size = Some(size);
self
}
pub fn with_footer_font_size(mut self, size: f32) -> Self {
let config = self.page_config.get_or_insert_with(PageConfig::default);
config.footer_font_size = Some(size);
self
}
pub fn with_height_unlimited(mut self, unlimited: bool) -> Self {
let config = self.page_config.get_or_insert_with(PageConfig::default);
config.height_unlimited = Some(unlimited);
self
}
}
impl Default for ConvertOptions {
fn default() -> Self {
Self {
font_family: Vec::new(),
user_css: String::new(),
css_file: None,
strict: false,
auto_font: true,
page_config: None,
}
}
}
fn render_pdf(document: &Document) -> crate::error::Result<Vec<u8>> {
let generator = PdfDocumentGenerator::new(document);
generator.generate()
}
fn render_svg(document: &Document) -> Vec<String> {
let mut svgs = Vec::new();
for page in &document.pages {
let mut renderer = SvgRenderer::new(page.width, page.height);
renderer.render_elements(&page.elements);
svgs.push(renderer.finalize());
}
svgs
}
fn render_png(document: &Document) -> crate::error::Result<Vec<Vec<u8>>> {
let mut pngs = Vec::new();
for page in &document.pages {
let mut renderer = PixmapRenderer::new_default_dpi(page.width, page.height);
renderer.render_elements(&page.elements);
pngs.push(renderer.render_to_png()?);
}
Ok(pngs)
}
fn read_markdown_file(path: &Path) -> crate::error::Result<(String, Option<PathBuf>)> {
let markdown = fs::read_to_string(path)?;
let base_dir = path.parent().map(|p| p.to_path_buf());
Ok((markdown, base_dir))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum ScriptRange {
Han,
Japanese,
Korean,
Latin,
Other,
}
impl ScriptRange {
fn from_char(c: char) -> Self {
let code = c as u32;
match code {
0x3040..=0x309F | 0x30A0..=0x30FF | 0x31F0..=0x31FF => ScriptRange::Japanese,
0x3400..=0x4DBF
| 0x4E00..=0x9FFF
| 0xF900..=0xFAFF
| 0x20000..=0x2A6DF
| 0x2A700..=0x2B73F
| 0x2B740..=0x2B81F
| 0x2B820..=0x2CEAF
| 0x2CEB0..=0x2EBE0
| 0x2F800..=0x2FA1F => ScriptRange::Han,
0xAC00..=0xD7AF => ScriptRange::Korean,
0x0000..=0x00FF | 0x2000..=0x206F => ScriptRange::Latin,
_ if c.is_alphabetic() => ScriptRange::Latin,
_ => ScriptRange::Other,
}
}
}
fn infer_font_family(markdown: &str) -> Vec<String> {
let mut counts = std::collections::HashMap::new();
let mut in_code = false;
let mut in_link = false;
for line in markdown.lines() {
if line.trim().starts_with("```") {
in_code = !in_code;
continue;
}
if in_code {
continue;
}
let content = line.trim_start().trim_start_matches('#').trim_start();
for c in content.chars() {
if c == '[' {
in_link = true;
continue;
}
if in_link && c == ']' {
in_link = false;
continue;
}
if in_link {
continue;
}
if c == '`' {
continue;
}
let range = ScriptRange::from_char(c);
if range != ScriptRange::Other {
*counts.entry(range).or_insert(0) += 1;
}
}
}
let total: usize = counts.values().sum();
if total == 0 {
return vec!["serif".to_string()];
}
let dominant = counts
.iter()
.max_by_key(|&(_, count)| *count)
.map(|(k, _)| *k)
.unwrap_or(ScriptRange::Other);
let chinese_serif_fonts = vec![
"FangSong".to_string(),
"FangSong_GB2312".to_string(),
"Noto Serif CJK SC".to_string(),
"Source Han Serif SC".to_string(),
"Noto Serif SC".to_string(),
"SimSun".to_string(),
"SimSun-ExtB".to_string(),
];
let chinese_sans_fonts = vec![
"Noto Sans CJK SC".to_string(),
"Source Han Sans SC".to_string(),
"Noto Sans SC".to_string(),
"Microsoft YaHei".to_string(),
"WenQuanYi Micro Hei".to_string(),
];
match dominant {
ScriptRange::Han => {
let mut fonts = chinese_serif_fonts;
fonts.extend(chinese_sans_fonts);
fonts.push("serif".to_string());
fonts.push("sans-serif".to_string());
fonts
}
ScriptRange::Japanese => vec![
"Noto Serif CJK JP".to_string(),
"Noto Serif JP".to_string(),
"Noto Sans CJK JP".to_string(),
"Noto Sans JP".to_string(),
"serif".to_string(),
"sans-serif".to_string(),
],
ScriptRange::Korean => vec![
"Noto Serif CJK KR".to_string(),
"Noto Serif KR".to_string(),
"Noto Sans CJK KR".to_string(),
"Noto Sans KR".to_string(),
"serif".to_string(),
"sans-serif".to_string(),
],
ScriptRange::Latin => {
let mut fonts = vec![
"Noto Serif".to_string(),
"Georgia".to_string(),
"Times New Roman".to_string(),
];
fonts.extend(chinese_serif_fonts);
fonts.extend(chinese_sans_fonts);
fonts.push("serif".to_string());
fonts.push("sans-serif".to_string());
fonts
}
ScriptRange::Other => {
let mut fonts = chinese_serif_fonts;
fonts.extend(chinese_sans_fonts);
fonts.push("serif".to_string());
fonts.push("sans-serif".to_string());
fonts
}
}
}
fn resolve_user_css(
options: &ConvertOptions,
markdown: Option<&str>,
) -> crate::error::Result<String> {
let file_css = match &options.css_file {
Some(path) => fs::read_to_string(path)?,
None => String::new(),
};
let user_has_font_css =
file_css.contains("font-family") || options.user_css.contains("font-family");
let font_css = if user_has_font_css || !options.font_family.is_empty() {
if !options.font_family.is_empty() {
let families: Vec<String> = options
.font_family
.iter()
.map(|f| {
if f.contains(' ') {
format!("\"{}\"", f)
} else {
f.clone()
}
})
.collect();
format!("body {{ font-family: {}; }}\n", families.join(", "))
} else {
String::new()
}
} else if options.auto_font {
if let Some(md) = markdown {
let families = infer_font_family(md);
format!(
"body {{ font-family: {}; }}\n",
families
.iter()
.map(|f| {
if f.contains(' ') {
format!("\"{}\"", f)
} else {
f.clone()
}
})
.collect::<Vec<_>>()
.join(", ")
)
} else {
String::new()
}
} else {
String::new()
};
let parts: Vec<&str> = [
font_css.as_str(),
options.user_css.as_str(),
file_css.as_str(),
]
.into_iter()
.filter(|s| !s.is_empty())
.collect();
if parts.is_empty() {
Ok(String::new())
} else {
Ok(parts.join("\n"))
}
}
pub fn markdown_to_pdf(markdown: &str, options: &ConvertOptions) -> crate::error::Result<Vec<u8>> {
let user_css = resolve_user_css(options, Some(markdown))?;
let html_str = html::markdown_to_html(markdown);
let document = html_to_document(
&html_str,
&user_css,
options.strict,
options.page_config.clone(),
)?;
render_pdf(&document)
}
pub fn markdown_file_to_pdf(
path: &Path,
options: &ConvertOptions,
) -> crate::error::Result<Vec<u8>> {
let (markdown, base_dir) = read_markdown_file(path)?;
let user_css = resolve_user_css(options, Some(&markdown))?;
let html_str = html::markdown_to_html(&markdown);
let html_str = html::embed_local_images(&html_str, base_dir.as_deref());
let document = html_to_document(
&html_str,
&user_css,
options.strict,
options.page_config.clone(),
)?;
render_pdf(&document)
}
pub fn markdown_to_svg(
markdown: &str,
options: &ConvertOptions,
) -> crate::error::Result<Vec<String>> {
let user_css = resolve_user_css(options, Some(markdown))?;
let html_str = html::markdown_to_html(markdown);
let document = html_to_document(
&html_str,
&user_css,
options.strict,
options.page_config.clone(),
)?;
Ok(render_svg(&document))
}
pub fn markdown_to_png(
markdown: &str,
options: &ConvertOptions,
) -> crate::error::Result<Vec<Vec<u8>>> {
let user_css = resolve_user_css(options, Some(markdown))?;
let html_str = html::markdown_to_html(markdown);
let document = html_to_document(
&html_str,
&user_css,
options.strict,
options.page_config.clone(),
)?;
render_png(&document)
}
pub fn markdown_file_to_svg(
path: &Path,
options: &ConvertOptions,
) -> crate::error::Result<Vec<String>> {
let (markdown, base_dir) = read_markdown_file(path)?;
let user_css = resolve_user_css(options, Some(&markdown))?;
let html_str = html::markdown_to_html(&markdown);
let html_str = html::embed_local_images(&html_str, base_dir.as_deref());
let document = html_to_document(
&html_str,
&user_css,
options.strict,
options.page_config.clone(),
)?;
Ok(render_svg(&document))
}
pub fn markdown_file_to_png(
path: &Path,
options: &ConvertOptions,
) -> crate::error::Result<Vec<Vec<u8>>> {
let (markdown, base_dir) = read_markdown_file(path)?;
let user_css = resolve_user_css(options, Some(&markdown))?;
let html_str = html::markdown_to_html(&markdown);
let html_str = html::embed_local_images(&html_str, base_dir.as_deref());
let document = html_to_document(
&html_str,
&user_css,
options.strict,
options.page_config.clone(),
)?;
render_png(&document)
}
pub fn html_to_pdf(html: &str, options: &ConvertOptions) -> crate::error::Result<Vec<u8>> {
let user_css = resolve_user_css(options, None)?;
let document = html_to_document(html, &user_css, options.strict, options.page_config.clone())?;
render_pdf(&document)
}
pub fn html_file_to_pdf(path: &Path, options: &ConvertOptions) -> crate::error::Result<Vec<u8>> {
let html = std::fs::read_to_string(path).map_err(crate::error::Error::IoError)?;
let base_dir = path.parent();
let html = html::embed_local_images(&html, base_dir);
let user_css = resolve_user_css(options, None)?;
let document = html_to_document(
&html,
&user_css,
options.strict,
options.page_config.clone(),
)?;
render_pdf(&document)
}
pub fn html_to_svg(html: &str, options: &ConvertOptions) -> crate::error::Result<Vec<String>> {
let user_css = resolve_user_css(options, None)?;
let document = html_to_document(html, &user_css, options.strict, options.page_config.clone())?;
Ok(render_svg(&document))
}
pub fn html_file_to_svg(
path: &Path,
options: &ConvertOptions,
) -> crate::error::Result<Vec<String>> {
let html = std::fs::read_to_string(path).map_err(crate::error::Error::IoError)?;
let base_dir = path.parent();
let html = html::embed_local_images(&html, base_dir);
let user_css = resolve_user_css(options, None)?;
let document = html_to_document(
&html,
&user_css,
options.strict,
options.page_config.clone(),
)?;
Ok(render_svg(&document))
}
pub fn html_to_png(html: &str, options: &ConvertOptions) -> crate::error::Result<Vec<Vec<u8>>> {
let user_css = resolve_user_css(options, None)?;
let document = html_to_document(html, &user_css, options.strict, options.page_config.clone())?;
render_png(&document)
}
pub fn html_file_to_png(
path: &Path,
options: &ConvertOptions,
) -> crate::error::Result<Vec<Vec<u8>>> {
let html = std::fs::read_to_string(path).map_err(crate::error::Error::IoError)?;
let base_dir = path.parent();
let html = html::embed_local_images(&html, base_dir);
let user_css = resolve_user_css(options, None)?;
let document = html_to_document(
&html,
&user_css,
options.strict,
options.page_config.clone(),
)?;
render_png(&document)
}
fn html_to_document(
html: &str,
user_css: &str,
strict: bool,
page_config: Option<PageConfig>,
) -> crate::error::Result<generator::Document> {
let doc = html::parse_html(html);
let builtin_css = ast::presets::DEFAULT_CSS;
let mut engine =
css::engine::CssEngine::new(builtin_css).map_err(crate::error::Error::CssParseError)?;
for sheet in &doc.style_sheets {
engine = engine
.with_user_css(sheet)
.map_err(crate::error::Error::CssParseError)?;
}
if !user_css.is_empty() {
engine = engine
.with_user_css(user_css)
.map_err(crate::error::Error::CssParseError)?;
}
if strict {
engine = engine.with_strict_mode(true);
}
let default_style = ast::Style::default();
let root_style = engine.resolve_style("html", &[], None, &[], &default_style);
engine.set_root_font_size(root_style.font_size_pt);
let styled_node = html::html_to_styled_nodes(&doc, &engine);
let page_config = page_config.unwrap_or_else(|| engine.page_config().clone());
let mut generator = generator::DocumentGenerator::with_settings(page_config.into());
if let ast::NodeKind::Document { children } = &styled_node.kind {
for child in children {
generator.layout_node(child);
}
} else {
generator.layout_node(&styled_node);
}
Ok(generator.finish().into())
}
#[cfg(test)]
mod pipeline_tests {
use super::*;
fn sample_markdown() -> &'static str {
"# Hello World\n\nThis is a **test** paragraph with *italic* text.\n\n- item 1\n- item 2\n- [ ] unchecked task\n- [x] checked task\n\n> A blockquote\n\n| A | B |\n|---|---|\n| 1 | 2 |"
}
#[test]
fn test_pdf_generation() {
let opts = ConvertOptions::default();
let result = markdown_to_pdf(sample_markdown(), &opts);
assert!(result.is_ok(), "PDF generation should succeed");
let pdf = result.unwrap();
assert!(!pdf.is_empty(), "PDF bytes should not be empty");
assert!(pdf.starts_with(b"%PDF"), "Should be valid PDF");
}
#[test]
fn test_svg_generation() {
let opts = ConvertOptions::default();
let result = markdown_to_svg(sample_markdown(), &opts);
assert!(result.is_ok(), "SVG generation should succeed");
let svgs = result.unwrap();
assert!(!svgs.is_empty(), "Should generate at least one SVG page");
assert!(svgs[0].contains("<svg"), "Should contain SVG tag");
}
}