pub mod ast;
pub mod error;
pub mod generator;
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;
use generator::{
Document, markdown_to_document, markdown_to_document_with_base_dir,
markdown_to_document_with_css_and_page_config,
};
#[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
}
}
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 mut pdf_gen = PdfDocumentGenerator::new("output".to_string());
for page in &document.pages {
pdf_gen.render_page(page)?;
}
pdf_gen.finalize()
}
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![
"Noto Serif SC".to_string(),
"Source Han Serif SC".to_string(),
"SimSun".to_string(),
"SimSun-ExtB".to_string(),
];
let chinese_sans_fonts = vec![
"Noto Sans SC".to_string(),
"Source Han 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) -> crate::error::Result<Vec<u8>> {
render_pdf(&markdown_to_document(markdown))
}
pub fn markdown_to_svg(markdown: &str) -> crate::error::Result<Vec<String>> {
Ok(render_svg(&markdown_to_document(markdown)))
}
pub fn markdown_to_png(markdown: &str) -> crate::error::Result<Vec<Vec<u8>>> {
render_png(&markdown_to_document(markdown))
}
pub fn markdown_file_to_pdf(path: &Path) -> crate::error::Result<Vec<u8>> {
let (markdown, base_dir) = read_markdown_file(path)?;
render_pdf(&markdown_to_document_with_base_dir(&markdown, base_dir))
}
pub fn markdown_file_to_svg(path: &Path) -> crate::error::Result<Vec<String>> {
let (markdown, base_dir) = read_markdown_file(path)?;
Ok(render_svg(&markdown_to_document_with_base_dir(
&markdown, base_dir,
)))
}
pub fn markdown_file_to_png(path: &Path) -> crate::error::Result<Vec<Vec<u8>>> {
let (markdown, base_dir) = read_markdown_file(path)?;
render_png(&markdown_to_document_with_base_dir(&markdown, base_dir))
}
pub fn markdown_to_pdf_with_options(
markdown: &str,
options: &ConvertOptions,
) -> crate::error::Result<Vec<u8>> {
let user_css = resolve_user_css(options, Some(markdown))?;
let doc = (if options.strict {
markdown_to_document_with_css_and_page_config(
markdown,
&user_css,
options.page_config.clone(),
None,
true,
)
} else {
markdown_to_document_with_css_and_page_config(
markdown,
&user_css,
options.page_config.clone(),
None,
false,
)
})
.map_err(crate::error::Error::CssParseError)?;
render_pdf(&doc)
}
pub fn markdown_to_svg_with_options(
markdown: &str,
options: &ConvertOptions,
) -> crate::error::Result<Vec<String>> {
let user_css = resolve_user_css(options, Some(markdown))?;
let doc = (if options.strict {
markdown_to_document_with_css_and_page_config(
markdown,
&user_css,
options.page_config.clone(),
None,
true,
)
} else {
markdown_to_document_with_css_and_page_config(
markdown,
&user_css,
options.page_config.clone(),
None,
false,
)
})
.map_err(crate::error::Error::CssParseError)?;
Ok(render_svg(&doc))
}
pub fn markdown_to_png_with_options(
markdown: &str,
options: &ConvertOptions,
) -> crate::error::Result<Vec<Vec<u8>>> {
let user_css = resolve_user_css(options, Some(markdown))?;
let doc = (if options.strict {
markdown_to_document_with_css_and_page_config(
markdown,
&user_css,
options.page_config.clone(),
None,
true,
)
} else {
markdown_to_document_with_css_and_page_config(
markdown,
&user_css,
options.page_config.clone(),
None,
false,
)
})
.map_err(crate::error::Error::CssParseError)?;
render_png(&doc)
}
pub fn markdown_file_to_pdf_with_options(
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 doc = (if options.strict {
markdown_to_document_with_css_and_page_config(
&markdown,
&user_css,
options.page_config.clone(),
base_dir,
true,
)
} else {
markdown_to_document_with_css_and_page_config(
&markdown,
&user_css,
options.page_config.clone(),
base_dir,
false,
)
})
.map_err(crate::error::Error::CssParseError)?;
render_pdf(&doc)
}
pub fn markdown_file_to_svg_with_options(
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 doc = (if options.strict {
markdown_to_document_with_css_and_page_config(
&markdown,
&user_css,
options.page_config.clone(),
base_dir,
true,
)
} else {
markdown_to_document_with_css_and_page_config(
&markdown,
&user_css,
options.page_config.clone(),
base_dir,
false,
)
})
.map_err(crate::error::Error::CssParseError)?;
Ok(render_svg(&doc))
}
pub fn markdown_file_to_png_with_options(
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 doc = (if options.strict {
markdown_to_document_with_css_and_page_config(
&markdown,
&user_css,
options.page_config.clone(),
base_dir,
true,
)
} else {
markdown_to_document_with_css_and_page_config(
&markdown,
&user_css,
options.page_config.clone(),
base_dir,
false,
)
})
.map_err(crate::error::Error::CssParseError)?;
render_png(&doc)
}