1pub mod ast;
2pub mod css;
3pub mod error;
4pub mod generator;
5pub mod html;
6pub mod render;
7pub mod text;
8pub mod visual;
9
10use std::fs;
11use std::path::Path;
12use std::path::PathBuf;
13
14pub use render::{
15 PageRenderer, PdfDocumentGenerator, PdfRenderer, PixmapDocumentGenerator, PixmapRenderer,
16 SvgDocumentGenerator, SvgRenderer,
17};
18
19pub use ast::PageConfig;
20
21pub use html::{markdown_to_html, markdown_to_html_document};
22
23use generator::Document;
24
25#[derive(Debug, Clone)]
38pub struct ConvertOptions {
39 pub font_family: Vec<String>,
45 pub user_css: String,
47 pub css_file: Option<PathBuf>,
50 pub strict: bool,
52 pub auto_font: bool,
58 pub page_config: Option<PageConfig>,
63}
64
65impl ConvertOptions {
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 pub fn with_font_family(mut self, families: &[&str]) -> Self {
85 self.font_family = families.iter().map(|f| f.to_string()).collect();
86 self
87 }
88
89 pub fn with_css(mut self, css: &str) -> Self {
91 self.user_css = css.to_string();
92 self
93 }
94
95 pub fn with_css_file(mut self, path: PathBuf) -> Self {
97 self.css_file = Some(path);
98 self
99 }
100
101 pub fn with_strict(mut self, strict: bool) -> Self {
103 self.strict = strict;
104 self
105 }
106
107 pub fn with_auto_font(mut self, auto_font: bool) -> Self {
109 self.auto_font = auto_font;
110 self
111 }
112
113 pub fn with_page_config(mut self, config: PageConfig) -> Self {
129 self.page_config = Some(config);
130 self
131 }
132
133 pub fn with_header(mut self, header: &str) -> Self {
148 let config = self.page_config.get_or_insert_with(PageConfig::default);
149 config.header = Some(header.to_string());
150 self
151 }
152
153 pub fn with_footer(mut self, footer: &str) -> Self {
168 let config = self.page_config.get_or_insert_with(PageConfig::default);
169 config.footer = Some(footer.to_string());
170 self
171 }
172
173 pub fn with_header_font_size(mut self, size: f32) -> Self {
177 let config = self.page_config.get_or_insert_with(PageConfig::default);
178 config.header_font_size = Some(size);
179 self
180 }
181
182 pub fn with_footer_font_size(mut self, size: f32) -> Self {
186 let config = self.page_config.get_or_insert_with(PageConfig::default);
187 config.footer_font_size = Some(size);
188 self
189 }
190
191 pub fn with_height_unlimited(mut self, unlimited: bool) -> Self {
205 let config = self.page_config.get_or_insert_with(PageConfig::default);
206 config.height_unlimited = Some(unlimited);
207 self
208 }
209}
210
211impl Default for ConvertOptions {
212 fn default() -> Self {
213 Self {
214 font_family: Vec::new(),
215 user_css: String::new(),
216 css_file: None,
217 strict: false,
218 auto_font: true,
219 page_config: None,
220 }
221 }
222}
223
224fn render_pdf(document: &Document) -> crate::error::Result<Vec<u8>> {
227 let generator = PdfDocumentGenerator::new(document);
228 generator.generate()
229}
230
231fn render_svg(document: &Document) -> Vec<String> {
232 let mut svgs = Vec::new();
233 for page in &document.pages {
234 let mut renderer = SvgRenderer::new(page.width, page.height);
235 renderer.render_elements(&page.elements);
236 svgs.push(renderer.finalize());
237 }
238 svgs
239}
240
241fn render_png(document: &Document) -> crate::error::Result<Vec<Vec<u8>>> {
242 let mut pngs = Vec::new();
243 for page in &document.pages {
244 let mut renderer = PixmapRenderer::new_default_dpi(page.width, page.height);
245 renderer.render_elements(&page.elements);
246 pngs.push(renderer.render_to_png()?);
247 }
248 Ok(pngs)
249}
250
251fn read_markdown_file(path: &Path) -> crate::error::Result<(String, Option<PathBuf>)> {
254 let markdown = fs::read_to_string(path)?;
255 let base_dir = path.parent().map(|p| p.to_path_buf());
256 Ok((markdown, base_dir))
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
263enum ScriptRange {
264 Han,
265 Japanese,
266 Korean,
267 Latin,
268 Other,
269}
270
271impl ScriptRange {
272 fn from_char(c: char) -> Self {
273 let code = c as u32;
274 match code {
275 0x3040..=0x309F | 0x30A0..=0x30FF | 0x31F0..=0x31FF => ScriptRange::Japanese,
277 0x3400..=0x4DBF
288 | 0x4E00..=0x9FFF
289 | 0xF900..=0xFAFF
290 | 0x20000..=0x2A6DF
291 | 0x2A700..=0x2B73F
292 | 0x2B740..=0x2B81F
293 | 0x2B820..=0x2CEAF
294 | 0x2CEB0..=0x2EBE0
295 | 0x2F800..=0x2FA1F => ScriptRange::Han,
296 0xAC00..=0xD7AF => ScriptRange::Korean,
298 0x0000..=0x00FF | 0x2000..=0x206F => ScriptRange::Latin,
300 _ if c.is_alphabetic() => ScriptRange::Latin,
302 _ => ScriptRange::Other,
303 }
304 }
305}
306
307fn infer_font_family(markdown: &str) -> Vec<String> {
309 let mut counts = std::collections::HashMap::new();
310 let mut in_code = false;
311 let mut in_link = false;
312
313 for line in markdown.lines() {
314 if line.trim().starts_with("```") {
316 in_code = !in_code;
317 continue;
318 }
319 if in_code {
320 continue;
321 }
322
323 let content = line.trim_start().trim_start_matches('#').trim_start();
325
326 for c in content.chars() {
327 if c == '[' {
329 in_link = true;
330 continue;
331 }
332 if in_link && c == ']' {
333 in_link = false;
334 continue;
335 }
336 if in_link {
337 continue;
338 }
339 if c == '`' {
340 continue;
341 }
342
343 let range = ScriptRange::from_char(c);
344 if range != ScriptRange::Other {
345 *counts.entry(range).or_insert(0) += 1;
346 }
347 }
348 }
349
350 let total: usize = counts.values().sum();
351 if total == 0 {
352 return vec!["serif".to_string()];
353 }
354
355 let dominant = counts
357 .iter()
358 .max_by_key(|&(_, count)| *count)
359 .map(|(k, _)| *k)
360 .unwrap_or(ScriptRange::Other);
361
362 let chinese_serif_fonts = vec![
368 "FangSong".to_string(),
369 "FangSong_GB2312".to_string(),
370 "Noto Serif CJK SC".to_string(),
371 "Source Han Serif SC".to_string(),
372 "Noto Serif SC".to_string(),
373 "SimSun".to_string(),
374 "SimSun-ExtB".to_string(),
375 ];
376 let chinese_sans_fonts = vec![
377 "Noto Sans CJK SC".to_string(),
378 "Source Han Sans SC".to_string(),
379 "Noto Sans SC".to_string(),
380 "Microsoft YaHei".to_string(),
381 "WenQuanYi Micro Hei".to_string(),
382 ];
383
384 match dominant {
385 ScriptRange::Han => {
386 let mut fonts = chinese_serif_fonts;
387 fonts.extend(chinese_sans_fonts);
388 fonts.push("serif".to_string());
389 fonts.push("sans-serif".to_string());
390 fonts
391 }
392 ScriptRange::Japanese => vec![
393 "Noto Serif CJK JP".to_string(),
394 "Noto Serif JP".to_string(),
395 "Noto Sans CJK JP".to_string(),
396 "Noto Sans JP".to_string(),
397 "serif".to_string(),
398 "sans-serif".to_string(),
399 ],
400 ScriptRange::Korean => vec![
401 "Noto Serif CJK KR".to_string(),
402 "Noto Serif KR".to_string(),
403 "Noto Sans CJK KR".to_string(),
404 "Noto Sans KR".to_string(),
405 "serif".to_string(),
406 "sans-serif".to_string(),
407 ],
408 ScriptRange::Latin => {
409 let mut fonts = vec![
411 "Noto Serif".to_string(),
412 "Georgia".to_string(),
413 "Times New Roman".to_string(),
414 ];
415 fonts.extend(chinese_serif_fonts);
416 fonts.extend(chinese_sans_fonts);
417 fonts.push("serif".to_string());
418 fonts.push("sans-serif".to_string());
419 fonts
420 }
421 ScriptRange::Other => {
422 let mut fonts = chinese_serif_fonts;
423 fonts.extend(chinese_sans_fonts);
424 fonts.push("serif".to_string());
425 fonts.push("sans-serif".to_string());
426 fonts
427 }
428 }
429}
430
431fn resolve_user_css(
434 options: &ConvertOptions,
435 markdown: Option<&str>,
436) -> crate::error::Result<String> {
437 let file_css = match &options.css_file {
438 Some(path) => fs::read_to_string(path)?,
439 None => String::new(),
440 };
441
442 let user_has_font_css =
446 file_css.contains("font-family") || options.user_css.contains("font-family");
447
448 let font_css = if user_has_font_css || !options.font_family.is_empty() {
450 if !options.font_family.is_empty() {
451 let families: Vec<String> = options
452 .font_family
453 .iter()
454 .map(|f| {
455 if f.contains(' ') {
456 format!("\"{}\"", f)
457 } else {
458 f.clone()
459 }
460 })
461 .collect();
462 format!("body {{ font-family: {}; }}\n", families.join(", "))
463 } else {
464 String::new()
465 }
466 } else if options.auto_font {
467 if let Some(md) = markdown {
468 let families = infer_font_family(md);
469 format!(
470 "body {{ font-family: {}; }}\n",
471 families
472 .iter()
473 .map(|f| {
474 if f.contains(' ') {
475 format!("\"{}\"", f)
476 } else {
477 f.clone()
478 }
479 })
480 .collect::<Vec<_>>()
481 .join(", ")
482 )
483 } else {
484 String::new()
485 }
486 } else {
487 String::new()
488 };
489
490 let parts: Vec<&str> = [
491 font_css.as_str(),
492 options.user_css.as_str(),
493 file_css.as_str(),
494 ]
495 .into_iter()
496 .filter(|s| !s.is_empty())
497 .collect();
498
499 if parts.is_empty() {
500 Ok(String::new())
501 } else {
502 Ok(parts.join("\n"))
503 }
504}
505
506pub fn markdown_to_pdf(markdown: &str, options: &ConvertOptions) -> crate::error::Result<Vec<u8>> {
514 let user_css = resolve_user_css(options, Some(markdown))?;
515 let html_str = html::markdown_to_html(markdown);
516 let document = html_to_document(
517 &html_str,
518 &user_css,
519 options.strict,
520 options.page_config.clone(),
521 )?;
522 render_pdf(&document)
523}
524
525pub fn markdown_file_to_pdf(
527 path: &Path,
528 options: &ConvertOptions,
529) -> crate::error::Result<Vec<u8>> {
530 let (markdown, base_dir) = read_markdown_file(path)?;
531 let user_css = resolve_user_css(options, Some(&markdown))?;
532 let html_str = html::markdown_to_html(&markdown);
533 let html_str = html::embed_local_images(&html_str, base_dir.as_deref());
534 let document = html_to_document(
535 &html_str,
536 &user_css,
537 options.strict,
538 options.page_config.clone(),
539 )?;
540 render_pdf(&document)
541}
542
543pub fn markdown_to_svg(
545 markdown: &str,
546 options: &ConvertOptions,
547) -> crate::error::Result<Vec<String>> {
548 let user_css = resolve_user_css(options, Some(markdown))?;
549 let html_str = html::markdown_to_html(markdown);
550 let document = html_to_document(
551 &html_str,
552 &user_css,
553 options.strict,
554 options.page_config.clone(),
555 )?;
556 Ok(render_svg(&document))
557}
558
559pub fn markdown_to_png(
561 markdown: &str,
562 options: &ConvertOptions,
563) -> crate::error::Result<Vec<Vec<u8>>> {
564 let user_css = resolve_user_css(options, Some(markdown))?;
565 let html_str = html::markdown_to_html(markdown);
566 let document = html_to_document(
567 &html_str,
568 &user_css,
569 options.strict,
570 options.page_config.clone(),
571 )?;
572 render_png(&document)
573}
574
575pub fn markdown_file_to_svg(
577 path: &Path,
578 options: &ConvertOptions,
579) -> crate::error::Result<Vec<String>> {
580 let (markdown, base_dir) = read_markdown_file(path)?;
581 let user_css = resolve_user_css(options, Some(&markdown))?;
582 let html_str = html::markdown_to_html(&markdown);
583 let html_str = html::embed_local_images(&html_str, base_dir.as_deref());
584 let document = html_to_document(
585 &html_str,
586 &user_css,
587 options.strict,
588 options.page_config.clone(),
589 )?;
590 Ok(render_svg(&document))
591}
592
593pub fn markdown_file_to_png(
595 path: &Path,
596 options: &ConvertOptions,
597) -> crate::error::Result<Vec<Vec<u8>>> {
598 let (markdown, base_dir) = read_markdown_file(path)?;
599 let user_css = resolve_user_css(options, Some(&markdown))?;
600 let html_str = html::markdown_to_html(&markdown);
601 let html_str = html::embed_local_images(&html_str, base_dir.as_deref());
602 let document = html_to_document(
603 &html_str,
604 &user_css,
605 options.strict,
606 options.page_config.clone(),
607 )?;
608 render_png(&document)
609}
610
611pub fn html_to_pdf(html: &str, options: &ConvertOptions) -> crate::error::Result<Vec<u8>> {
618 let user_css = resolve_user_css(options, None)?;
619 let document = html_to_document(html, &user_css, options.strict, options.page_config.clone())?;
620 render_pdf(&document)
621}
622
623pub fn html_file_to_pdf(path: &Path, options: &ConvertOptions) -> crate::error::Result<Vec<u8>> {
625 let html = std::fs::read_to_string(path).map_err(crate::error::Error::IoError)?;
626 let base_dir = path.parent();
627 let html = html::embed_local_images(&html, base_dir);
628 let user_css = resolve_user_css(options, None)?;
629 let document = html_to_document(
630 &html,
631 &user_css,
632 options.strict,
633 options.page_config.clone(),
634 )?;
635 render_pdf(&document)
636}
637
638pub fn html_to_svg(html: &str, options: &ConvertOptions) -> crate::error::Result<Vec<String>> {
640 let user_css = resolve_user_css(options, None)?;
641 let document = html_to_document(html, &user_css, options.strict, options.page_config.clone())?;
642 Ok(render_svg(&document))
643}
644
645pub fn html_file_to_svg(
647 path: &Path,
648 options: &ConvertOptions,
649) -> crate::error::Result<Vec<String>> {
650 let html = std::fs::read_to_string(path).map_err(crate::error::Error::IoError)?;
651 let base_dir = path.parent();
652 let html = html::embed_local_images(&html, base_dir);
653 let user_css = resolve_user_css(options, None)?;
654 let document = html_to_document(
655 &html,
656 &user_css,
657 options.strict,
658 options.page_config.clone(),
659 )?;
660 Ok(render_svg(&document))
661}
662
663pub fn html_to_png(html: &str, options: &ConvertOptions) -> crate::error::Result<Vec<Vec<u8>>> {
665 let user_css = resolve_user_css(options, None)?;
666 let document = html_to_document(html, &user_css, options.strict, options.page_config.clone())?;
667 render_png(&document)
668}
669
670pub fn html_file_to_png(
672 path: &Path,
673 options: &ConvertOptions,
674) -> crate::error::Result<Vec<Vec<u8>>> {
675 let html = std::fs::read_to_string(path).map_err(crate::error::Error::IoError)?;
676 let base_dir = path.parent();
677 let html = html::embed_local_images(&html, base_dir);
678 let user_css = resolve_user_css(options, None)?;
679 let document = html_to_document(
680 &html,
681 &user_css,
682 options.strict,
683 options.page_config.clone(),
684 )?;
685 render_png(&document)
686}
687
688fn html_to_document(
694 html: &str,
695 user_css: &str,
696 strict: bool,
697 page_config: Option<PageConfig>,
698) -> crate::error::Result<generator::Document> {
699 let doc = html::parse_html(html);
701
702 let builtin_css = ast::presets::DEFAULT_CSS;
704 let mut engine =
705 css::engine::CssEngine::new(builtin_css).map_err(crate::error::Error::CssParseError)?;
706
707 for sheet in &doc.style_sheets {
709 engine = engine
710 .with_user_css(sheet)
711 .map_err(crate::error::Error::CssParseError)?;
712 }
713
714 if !user_css.is_empty() {
716 engine = engine
717 .with_user_css(user_css)
718 .map_err(crate::error::Error::CssParseError)?;
719 }
720
721 if strict {
722 engine = engine.with_strict_mode(true);
723 }
724
725 let default_style = ast::Style::default();
728 let root_style = engine.resolve_style("html", &[], None, &[], &default_style);
729 engine.set_root_font_size(root_style.font_size_pt);
730
731 let styled_node = html::html_to_styled_nodes(&doc, &engine);
733
734 let page_config = page_config.unwrap_or_else(|| engine.page_config().clone());
736 let mut generator = generator::DocumentGenerator::with_settings(page_config.into());
737
738 if let ast::NodeKind::Document { children } = &styled_node.kind {
739 for child in children {
740 generator.layout_node(child);
741 }
742 } else {
743 generator.layout_node(&styled_node);
744 }
745
746 Ok(generator.finish().into())
747}
748
749#[cfg(test)]
750mod pipeline_tests {
751 use super::*;
752
753 fn sample_markdown() -> &'static str {
754 "# 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 |"
755 }
756
757 #[test]
758 fn test_pdf_generation() {
759 let opts = ConvertOptions::default();
760 let result = markdown_to_pdf(sample_markdown(), &opts);
761 assert!(result.is_ok(), "PDF generation should succeed");
762 let pdf = result.unwrap();
763 assert!(!pdf.is_empty(), "PDF bytes should not be empty");
764 assert!(pdf.starts_with(b"%PDF"), "Should be valid PDF");
765 }
766
767 #[test]
768 fn test_svg_generation() {
769 let opts = ConvertOptions::default();
770 let result = markdown_to_svg(sample_markdown(), &opts);
771 assert!(result.is_ok(), "SVG generation should succeed");
772 let svgs = result.unwrap();
773 assert!(!svgs.is_empty(), "Should generate at least one SVG page");
774 assert!(svgs[0].contains("<svg"), "Should contain SVG tag");
775 }
776}