Skip to main content

liepress/
lib.rs

1pub mod ast;
2pub mod error;
3pub mod generator;
4pub mod render;
5pub mod text;
6pub mod visual;
7
8use std::fs;
9use std::path::Path;
10use std::path::PathBuf;
11
12pub use render::{
13    PageRenderer, PdfDocumentGenerator, PdfRenderer, PixmapDocumentGenerator, PixmapRenderer,
14    SvgDocumentGenerator, SvgRenderer,
15};
16
17pub use ast::PageConfig;
18
19use generator::{
20    Document, markdown_to_document, markdown_to_document_with_base_dir,
21    markdown_to_document_with_css_and_page_config,
22};
23
24/// Markdown 转换配置
25///
26/// 控制样式、字体、严格模式等选项。通过 builder 风格方法快速构造:
27///
28/// ```
29/// use liepress::ConvertOptions;
30///
31/// let opts = ConvertOptions::new()
32///     .with_font_family(&["Noto Sans SC", "sans-serif"])
33///     .with_css("h1 { color: red; }")
34///     .with_strict(true);
35/// ```
36#[derive(Debug, Clone)]
37pub struct ConvertOptions {
38    /// 全局默认字体家族列表(优先级从高到低)
39    ///
40    /// 设置后会自动生成 `body { font-family: ... }` 样式,
41    /// 通过 CSS 继承机制应用到所有元素。如果同时提供了 `user_css`
42    /// 或 `css_file`,其中的 `body { font-family }` 会覆盖此设置。
43    pub font_family: Vec<String>,
44    /// 用户提供的 CSS 样式字符串(叠加在默认样式之上)
45    pub user_css: String,
46    /// 用户提供的 CSS 样式文件路径(叠加在默认样式之上)
47    /// 如果与 `user_css` 同时设置,两者会合并
48    pub css_file: Option<PathBuf>,
49    /// 严格模式:CSS 解析失败时返回错误(默认 false)
50    pub strict: bool,
51    /// 自动字体:根据文档内容自动选择合适的字体(默认 true)
52    ///
53    /// 启用后,如果没有显式设置 `font_family`,会根据文档中的字符分布
54    /// 自动推荐字体列表(如中文优先 Noto Serif SC + SimSun,日文优先 Noto Serif CJK JP)。
55    /// 用户提供的 CSS(包括 `<style>` 中的 `body { font-family }`)始终最高优先级。
56    pub auto_font: bool,
57    /// 页面配置(页面尺寸、边距等)
58    ///
59    /// 可通过 `@page` CSS 规则或此字段设置。此字段优先级高于 CSS 中的 `@page` 规则。
60    /// 如果为 `None`(默认),则完全由 CSS `@page` 或内置默认值决定。
61    pub page_config: Option<PageConfig>,
62}
63
64impl ConvertOptions {
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// 设置全局默认字体家族
70    ///
71    /// 列表按优先级从高到低排列,支持 CSS 通用家族关键字
72    /// (`serif`、`sans-serif`、`monospace`)和具体字体名称。
73    ///
74    /// ```
75    /// use liepress::ConvertOptions;
76    ///
77    /// // 单个字体 + 回退
78    /// let opts = ConvertOptions::new().with_font_family(&["Noto Sans SC", "sans-serif"]);
79    ///
80    /// // 使用通用字体
81    /// let opts = ConvertOptions::new().with_font_family(&["serif"]);
82    /// ```
83    pub fn with_font_family(mut self, families: &[&str]) -> Self {
84        self.font_family = families.iter().map(|f| f.to_string()).collect();
85        self
86    }
87
88    /// 设置用户 CSS 样式字符串
89    pub fn with_css(mut self, css: &str) -> Self {
90        self.user_css = css.to_string();
91        self
92    }
93
94    /// 设置用户 CSS 样式文件路径
95    pub fn with_css_file(mut self, path: PathBuf) -> Self {
96        self.css_file = Some(path);
97        self
98    }
99
100    /// 设置严格模式
101    pub fn with_strict(mut self, strict: bool) -> Self {
102        self.strict = strict;
103        self
104    }
105
106    /// 设置自动字体模式
107    pub fn with_auto_font(mut self, auto_font: bool) -> Self {
108        self.auto_font = auto_font;
109        self
110    }
111
112    /// 设置页面配置(页面尺寸、边距等)
113    ///
114    /// 优先级高于 CSS 中的 `@page` 规则。
115    /// 可通过 `PageConfig::default()` 创建后按需设置字段。
116    ///
117    /// ```
118    /// use liepress::{ConvertOptions, ast::PageConfig};
119    ///
120    /// let page_cfg = PageConfig {
121    ///     width: Some(841.890),  // A4 landscape
122    ///     height: Some(595.276),
123    ///     ..PageConfig::default()
124    /// };
125    /// let opts = ConvertOptions::new().with_page_config(page_cfg);
126    /// ```
127    pub fn with_page_config(mut self, config: PageConfig) -> Self {
128        self.page_config = Some(config);
129        self
130    }
131
132    /// 设置页眉文本(支持 {page} 和 {total} 模板变量)
133    ///
134    /// 页眉会显示在每页的顶部边距区域,居中对齐。
135    /// 使用 `{page}` 表示当前页码,`{total}` 表示总页数。
136    ///
137    /// ```
138    /// use liepress::ConvertOptions;
139    ///
140    /// let opts = ConvertOptions::new()
141    ///     .with_header("我的文档");
142    ///
143    /// let opts = ConvertOptions::new()
144    ///     .with_header("第 {page} 页 / 共 {total} 页");
145    /// ```
146    pub fn with_header(mut self, header: &str) -> Self {
147        let config = self.page_config.get_or_insert_with(PageConfig::default);
148        config.header = Some(header.to_string());
149        self
150    }
151
152    /// 设置页脚文本(支持 {page} 和 {total} 模板变量)
153    ///
154    /// 页脚会显示在每页的底部边距区域,居中对齐。
155    /// 使用 `{page}` 表示当前页码,`{total}` 表示总页数。
156    ///
157    /// ```
158    /// use liepress::ConvertOptions;
159    ///
160    /// let opts = ConvertOptions::new()
161    ///     .with_footer("- {page} -");
162    ///
163    /// let opts = ConvertOptions::new()
164    ///     .with_footer("第 {page} 页 / 共 {total} 页");
165    /// ```
166    pub fn with_footer(mut self, footer: &str) -> Self {
167        let config = self.page_config.get_or_insert_with(PageConfig::default);
168        config.footer = Some(footer.to_string());
169        self
170    }
171
172    /// 设置页眉字体大小(pt)
173    ///
174    /// 默认 9pt。仅在设置了页眉时生效。
175    pub fn with_header_font_size(mut self, size: f32) -> Self {
176        let config = self.page_config.get_or_insert_with(PageConfig::default);
177        config.header_font_size = Some(size);
178        self
179    }
180
181    /// 设置页脚字体大小(pt)
182    ///
183    /// 默认 9pt。仅在设置了页脚时生效。
184    pub fn with_footer_font_size(mut self, size: f32) -> Self {
185        let config = self.page_config.get_or_insert_with(PageConfig::default);
186        config.footer_font_size = Some(size);
187        self
188    }
189}
190
191impl Default for ConvertOptions {
192    fn default() -> Self {
193        Self {
194            font_family: Vec::new(),
195            user_css: String::new(),
196            css_file: None,
197            strict: false,
198            auto_font: true,
199            page_config: None,
200        }
201    }
202}
203
204// ─── 内部渲染辅助函数 ─────────────────────────────────────
205
206fn render_pdf(document: &Document) -> crate::error::Result<Vec<u8>> {
207    let mut pdf_gen = PdfDocumentGenerator::new("output".to_string());
208    for page in &document.pages {
209        pdf_gen.render_page(page)?;
210    }
211    pdf_gen.finalize()
212}
213
214fn render_svg(document: &Document) -> Vec<String> {
215    let mut svgs = Vec::new();
216    for page in &document.pages {
217        let mut renderer = SvgRenderer::new(page.width, page.height);
218        renderer.render_elements(&page.elements);
219        svgs.push(renderer.finalize());
220    }
221    svgs
222}
223
224fn render_png(document: &Document) -> crate::error::Result<Vec<Vec<u8>>> {
225    let mut pngs = Vec::new();
226    for page in &document.pages {
227        let mut renderer = PixmapRenderer::new_default_dpi(page.width, page.height);
228        renderer.render_elements(&page.elements);
229        pngs.push(renderer.render_to_png()?);
230    }
231    Ok(pngs)
232}
233
234// ─── 内部文件读取辅助函数 ─────────────────────────────────
235
236fn read_markdown_file(path: &Path) -> crate::error::Result<(String, Option<PathBuf>)> {
237    let markdown = fs::read_to_string(path)?;
238    let base_dir = path.parent().map(|p| p.to_path_buf());
239    Ok((markdown, base_dir))
240}
241
242// ─── 自动字体推断 ────────────────────────────────────────
243
244/// 运行脚本范围(避免误判 URL、代码、标签中的字符)
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
246enum ScriptRange {
247    Han,
248    Japanese,
249    Korean,
250    Latin,
251    Other,
252}
253
254impl ScriptRange {
255    fn from_char(c: char) -> Self {
256        let code = c as u32;
257        match code {
258            // 日文:平假名、片假名
259            0x3040..=0x309F | 0x30A0..=0x30FF | 0x31F0..=0x31FF => ScriptRange::Japanese,
260            // 中文/汉字:
261            //   CJK 统一表意文字 (4E00-9FFF)
262            //   CJK 扩展 A (3400-4DBF)
263            //   CJK 扩展 B (20000-2A6DF)
264            //   CJK 扩展 C (2A700-2B73F)
265            //   CJK 扩展 D (2B740-2B81F)
266            //   CJK 扩展 E (2B820-2CEAF)
267            //   CJK 扩展 F (2CEB0-2EBE0)
268            //   CJK 兼容表意文字 (F900-FAFF)
269            //   CJK 兼容表意文字补充 (2F800-2FA1F)
270            0x3400..=0x4DBF
271            | 0x4E00..=0x9FFF
272            | 0xF900..=0xFAFF
273            | 0x20000..=0x2A6DF
274            | 0x2A700..=0x2B73F
275            | 0x2B740..=0x2B81F
276            | 0x2B820..=0x2CEAF
277            | 0x2CEB0..=0x2EBE0
278            | 0x2F800..=0x2FA1F => ScriptRange::Han,
279            // 韩文
280            0xAC00..=0xD7AF => ScriptRange::Korean,
281            // 拉丁文基础、补充标点
282            0x0000..=0x00FF | 0x2000..=0x206F => ScriptRange::Latin,
283            // 其他有字母属性的字符归为 Latin
284            _ if c.is_alphabetic() => ScriptRange::Latin,
285            _ => ScriptRange::Other,
286        }
287    }
288}
289
290/// 从 Markdown 文本中推断主要语言,返回推荐字体列表
291fn infer_font_family(markdown: &str) -> Vec<String> {
292    let mut counts = std::collections::HashMap::new();
293    let mut in_code = false;
294    let mut in_link = false;
295
296    for line in markdown.lines() {
297        // 简单状态机:代码块用 ``` 包围
298        if line.trim().starts_with("```") {
299            in_code = !in_code;
300            continue;
301        }
302        if in_code {
303            continue;
304        }
305
306        // 跳过标题标记和列表标记
307        let content = line.trim_start().trim_start_matches('#').trim_start();
308
309        for c in content.chars() {
310            // 跳过链接内容 [text](url)
311            if c == '[' {
312                in_link = true;
313                continue;
314            }
315            if in_link && c == ']' {
316                in_link = false;
317                continue;
318            }
319            if in_link {
320                continue;
321            }
322            if c == '`' {
323                continue;
324            }
325
326            let range = ScriptRange::from_char(c);
327            if range != ScriptRange::Other {
328                *counts.entry(range).or_insert(0) += 1;
329            }
330        }
331    }
332
333    let total: usize = counts.values().sum();
334    if total == 0 {
335        return vec!["serif".to_string()];
336    }
337
338    // 找出占比最高的脚本
339    let dominant = counts
340        .iter()
341        .max_by_key(|&(_, count)| *count)
342        .map(|(k, _)| *k)
343        .unwrap_or(ScriptRange::Other);
344
345    // 基础中文字体列表(作为所有语言场景的回退)
346    // 包含衬线体(Serif)和无衬线体(Sans-serif)
347    let chinese_serif_fonts = vec![
348        "Noto Serif SC".to_string(),
349        "Source Han Serif SC".to_string(),
350        "SimSun".to_string(),
351        "SimSun-ExtB".to_string(),
352    ];
353    let chinese_sans_fonts = vec![
354        "Noto Sans SC".to_string(),
355        "Source Han Sans SC".to_string(),
356        "Microsoft YaHei".to_string(),
357        "WenQuanYi Micro Hei".to_string(),
358    ];
359
360    match dominant {
361        ScriptRange::Han => {
362            let mut fonts = chinese_serif_fonts;
363            fonts.extend(chinese_sans_fonts);
364            fonts.push("serif".to_string());
365            fonts.push("sans-serif".to_string());
366            fonts
367        }
368        ScriptRange::Japanese => vec![
369            "Noto Serif CJK JP".to_string(),
370            "Noto Serif JP".to_string(),
371            "Noto Sans CJK JP".to_string(),
372            "Noto Sans JP".to_string(),
373            "serif".to_string(),
374            "sans-serif".to_string(),
375        ],
376        ScriptRange::Korean => vec![
377            "Noto Serif CJK KR".to_string(),
378            "Noto Serif KR".to_string(),
379            "Noto Sans CJK KR".to_string(),
380            "Noto Sans KR".to_string(),
381            "serif".to_string(),
382            "sans-serif".to_string(),
383        ],
384        ScriptRange::Latin => {
385            // 英文为主时,优先使用拉丁字体,但保留中文字体作为回退
386            let mut fonts = vec![
387                "Noto Serif".to_string(),
388                "Georgia".to_string(),
389                "Times New Roman".to_string(),
390            ];
391            fonts.extend(chinese_serif_fonts);
392            fonts.extend(chinese_sans_fonts);
393            fonts.push("serif".to_string());
394            fonts.push("sans-serif".to_string());
395            fonts
396        }
397        ScriptRange::Other => {
398            let mut fonts = chinese_serif_fonts;
399            fonts.extend(chinese_sans_fonts);
400            fonts.push("serif".to_string());
401            fonts.push("sans-serif".to_string());
402            fonts
403        }
404    }
405}
406
407// ─── CSS 解析 ───────────────────────────────────────────────
408
409fn resolve_user_css(
410    options: &ConvertOptions,
411    markdown: Option<&str>,
412) -> crate::error::Result<String> {
413    let file_css = match &options.css_file {
414        Some(path) => fs::read_to_string(path)?,
415        None => String::new(),
416    };
417
418    // 判断用户是否已经显式设置了 font-family(通过 CSS 字符串)
419    // 注意:这里做简单启发式判断。更严谨的做法是在 CSS 解析阶段
420    // 检查是否有 body { font-family: ... } 规则。当前先以显式 font_family 为首要考虑。
421    let user_has_font_css =
422        file_css.contains("font-family") || options.user_css.contains("font-family");
423
424    // 优先级:用户 CSS > auto-font > font_family
425    let font_css = if user_has_font_css || !options.font_family.is_empty() {
426        if !options.font_family.is_empty() {
427            let families: Vec<String> = options
428                .font_family
429                .iter()
430                .map(|f| {
431                    if f.contains(' ') {
432                        format!("\"{}\"", f)
433                    } else {
434                        f.clone()
435                    }
436                })
437                .collect();
438            format!("body {{ font-family: {}; }}\n", families.join(", "))
439        } else {
440            String::new()
441        }
442    } else if options.auto_font {
443        if let Some(md) = markdown {
444            let families = infer_font_family(md);
445            format!(
446                "body {{ font-family: {}; }}\n",
447                families
448                    .iter()
449                    .map(|f| {
450                        if f.contains(' ') {
451                            format!("\"{}\"", f)
452                        } else {
453                            f.clone()
454                        }
455                    })
456                    .collect::<Vec<_>>()
457                    .join(", ")
458            )
459        } else {
460            String::new()
461        }
462    } else {
463        String::new()
464    };
465
466    let parts: Vec<&str> = [
467        font_css.as_str(),
468        options.user_css.as_str(),
469        file_css.as_str(),
470    ]
471    .into_iter()
472    .filter(|s| !s.is_empty())
473    .collect();
474
475    if parts.is_empty() {
476        Ok(String::new())
477    } else {
478        Ok(parts.join("\n"))
479    }
480}
481
482// ─── 快速入口(无额外配置) ───────────────────────────────
483
484pub fn markdown_to_pdf(markdown: &str) -> crate::error::Result<Vec<u8>> {
485    render_pdf(&markdown_to_document(markdown))
486}
487
488pub fn markdown_to_svg(markdown: &str) -> crate::error::Result<Vec<String>> {
489    Ok(render_svg(&markdown_to_document(markdown)))
490}
491
492pub fn markdown_to_png(markdown: &str) -> crate::error::Result<Vec<Vec<u8>>> {
493    render_png(&markdown_to_document(markdown))
494}
495
496pub fn markdown_file_to_pdf(path: &Path) -> crate::error::Result<Vec<u8>> {
497    let (markdown, base_dir) = read_markdown_file(path)?;
498    render_pdf(&markdown_to_document_with_base_dir(&markdown, base_dir))
499}
500
501pub fn markdown_file_to_svg(path: &Path) -> crate::error::Result<Vec<String>> {
502    let (markdown, base_dir) = read_markdown_file(path)?;
503    Ok(render_svg(&markdown_to_document_with_base_dir(
504        &markdown, base_dir,
505    )))
506}
507
508pub fn markdown_file_to_png(path: &Path) -> crate::error::Result<Vec<Vec<u8>>> {
509    let (markdown, base_dir) = read_markdown_file(path)?;
510    render_png(&markdown_to_document_with_base_dir(&markdown, base_dir))
511}
512
513// ─── 带配置的入口 ────────────────────────────────────────
514
515pub fn markdown_to_pdf_with_options(
516    markdown: &str,
517    options: &ConvertOptions,
518) -> crate::error::Result<Vec<u8>> {
519    let user_css = resolve_user_css(options, Some(markdown))?;
520    let doc = (if options.strict {
521        markdown_to_document_with_css_and_page_config(
522            markdown,
523            &user_css,
524            options.page_config.clone(),
525            None,
526            true,
527        )
528    } else {
529        markdown_to_document_with_css_and_page_config(
530            markdown,
531            &user_css,
532            options.page_config.clone(),
533            None,
534            false,
535        )
536    })
537    .map_err(crate::error::Error::CssParseError)?;
538    render_pdf(&doc)
539}
540
541pub fn markdown_to_svg_with_options(
542    markdown: &str,
543    options: &ConvertOptions,
544) -> crate::error::Result<Vec<String>> {
545    let user_css = resolve_user_css(options, Some(markdown))?;
546    let doc = (if options.strict {
547        markdown_to_document_with_css_and_page_config(
548            markdown,
549            &user_css,
550            options.page_config.clone(),
551            None,
552            true,
553        )
554    } else {
555        markdown_to_document_with_css_and_page_config(
556            markdown,
557            &user_css,
558            options.page_config.clone(),
559            None,
560            false,
561        )
562    })
563    .map_err(crate::error::Error::CssParseError)?;
564    Ok(render_svg(&doc))
565}
566
567pub fn markdown_to_png_with_options(
568    markdown: &str,
569    options: &ConvertOptions,
570) -> crate::error::Result<Vec<Vec<u8>>> {
571    let user_css = resolve_user_css(options, Some(markdown))?;
572    let doc = (if options.strict {
573        markdown_to_document_with_css_and_page_config(
574            markdown,
575            &user_css,
576            options.page_config.clone(),
577            None,
578            true,
579        )
580    } else {
581        markdown_to_document_with_css_and_page_config(
582            markdown,
583            &user_css,
584            options.page_config.clone(),
585            None,
586            false,
587        )
588    })
589    .map_err(crate::error::Error::CssParseError)?;
590    render_png(&doc)
591}
592
593pub fn markdown_file_to_pdf_with_options(
594    path: &Path,
595    options: &ConvertOptions,
596) -> crate::error::Result<Vec<u8>> {
597    let (markdown, base_dir) = read_markdown_file(path)?;
598    let user_css = resolve_user_css(options, Some(&markdown))?;
599    let doc = (if options.strict {
600        markdown_to_document_with_css_and_page_config(
601            &markdown,
602            &user_css,
603            options.page_config.clone(),
604            base_dir,
605            true,
606        )
607    } else {
608        markdown_to_document_with_css_and_page_config(
609            &markdown,
610            &user_css,
611            options.page_config.clone(),
612            base_dir,
613            false,
614        )
615    })
616    .map_err(crate::error::Error::CssParseError)?;
617    render_pdf(&doc)
618}
619
620pub fn markdown_file_to_svg_with_options(
621    path: &Path,
622    options: &ConvertOptions,
623) -> crate::error::Result<Vec<String>> {
624    let (markdown, base_dir) = read_markdown_file(path)?;
625    let user_css = resolve_user_css(options, Some(&markdown))?;
626    let doc = (if options.strict {
627        markdown_to_document_with_css_and_page_config(
628            &markdown,
629            &user_css,
630            options.page_config.clone(),
631            base_dir,
632            true,
633        )
634    } else {
635        markdown_to_document_with_css_and_page_config(
636            &markdown,
637            &user_css,
638            options.page_config.clone(),
639            base_dir,
640            false,
641        )
642    })
643    .map_err(crate::error::Error::CssParseError)?;
644    Ok(render_svg(&doc))
645}
646
647pub fn markdown_file_to_png_with_options(
648    path: &Path,
649    options: &ConvertOptions,
650) -> crate::error::Result<Vec<Vec<u8>>> {
651    let (markdown, base_dir) = read_markdown_file(path)?;
652    let user_css = resolve_user_css(options, Some(&markdown))?;
653    let doc = (if options.strict {
654        markdown_to_document_with_css_and_page_config(
655            &markdown,
656            &user_css,
657            options.page_config.clone(),
658            base_dir,
659            true,
660        )
661    } else {
662        markdown_to_document_with_css_and_page_config(
663            &markdown,
664            &user_css,
665            options.page_config.clone(),
666            base_dir,
667            false,
668        )
669    })
670    .map_err(crate::error::Error::CssParseError)?;
671    render_png(&doc)
672}