Skip to main content

liepress/
lib.rs

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/// Markdown 转换配置
26///
27/// 控制样式、字体、严格模式等选项。通过 builder 风格方法快速构造:
28///
29/// ```
30/// use liepress::ConvertOptions;
31///
32/// let opts = ConvertOptions::new()
33///     .with_font_family(&["Noto Sans CJK SC", "sans-serif"])
34///     .with_css("h1 { color: red; }")
35///     .with_strict(true);
36/// ```
37#[derive(Debug, Clone)]
38pub struct ConvertOptions {
39    /// 全局默认字体家族列表(优先级从高到低)
40    ///
41    /// 设置后会自动生成 `body { font-family: ... }` 样式,
42    /// 通过 CSS 继承机制应用到所有元素。如果同时提供了 `user_css`
43    /// 或 `css_file`,其中的 `body { font-family }` 会覆盖此设置。
44    pub font_family: Vec<String>,
45    /// 用户提供的 CSS 样式字符串(叠加在默认样式之上)
46    pub user_css: String,
47    /// 用户提供的 CSS 样式文件路径(叠加在默认样式之上)
48    /// 如果与 `user_css` 同时设置,两者会合并
49    pub css_file: Option<PathBuf>,
50    /// 严格模式:CSS 解析失败时返回错误(默认 false)
51    pub strict: bool,
52    /// 自动字体:根据文档内容自动选择合适的字体(默认 true)
53    ///
54    /// 启用后,如果没有显式设置 `font_family`,会根据文档中的字符分布
55    /// 自动推荐字体列表(如中文优先仿宋 FangSong,日文优先 Noto Serif CJK JP)。
56    /// 用户提供的 CSS(包括 `<style>` 中的 `body { font-family }`)始终最高优先级。
57    pub auto_font: bool,
58    /// 页面配置(页面尺寸、边距等)
59    ///
60    /// 可通过 `@page` CSS 规则或此字段设置。此字段优先级高于 CSS 中的 `@page` 规则。
61    /// 如果为 `None`(默认),则完全由 CSS `@page` 或内置默认值决定。
62    pub page_config: Option<PageConfig>,
63}
64
65impl ConvertOptions {
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    /// 设置全局默认字体家族
71    ///
72    /// 列表按优先级从高到低排列,支持 CSS 通用家族关键字
73    /// (`serif`、`sans-serif`、`monospace`)和具体字体名称。
74    ///
75    /// ```
76    /// use liepress::ConvertOptions;
77    ///
78    /// // 单个字体 + 回退
79    /// let opts = ConvertOptions::new().with_font_family(&["Noto Sans CJK SC", "sans-serif"]);
80    ///
81    /// // 使用通用字体
82    /// let opts = ConvertOptions::new().with_font_family(&["serif"]);
83    /// ```
84    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    /// 设置用户 CSS 样式字符串
90    pub fn with_css(mut self, css: &str) -> Self {
91        self.user_css = css.to_string();
92        self
93    }
94
95    /// 设置用户 CSS 样式文件路径
96    pub fn with_css_file(mut self, path: PathBuf) -> Self {
97        self.css_file = Some(path);
98        self
99    }
100
101    /// 设置严格模式
102    pub fn with_strict(mut self, strict: bool) -> Self {
103        self.strict = strict;
104        self
105    }
106
107    /// 设置自动字体模式
108    pub fn with_auto_font(mut self, auto_font: bool) -> Self {
109        self.auto_font = auto_font;
110        self
111    }
112
113    /// 设置页面配置(页面尺寸、边距等)
114    ///
115    /// 优先级高于 CSS 中的 `@page` 规则。
116    /// 可通过 `PageConfig::default()` 创建后按需设置字段。
117    ///
118    /// ```
119    /// use liepress::{ConvertOptions, ast::PageConfig};
120    ///
121    /// let page_cfg = PageConfig {
122    ///     width: Some(841.890),  // A4 landscape
123    ///     height: Some(595.276),
124    ///     ..PageConfig::default()
125    /// };
126    /// let opts = ConvertOptions::new().with_page_config(page_cfg);
127    /// ```
128    pub fn with_page_config(mut self, config: PageConfig) -> Self {
129        self.page_config = Some(config);
130        self
131    }
132
133    /// 设置页眉文本(支持 {page} 和 {total} 模板变量)
134    ///
135    /// 页眉会显示在每页的顶部边距区域,居中对齐。
136    /// 使用 `{page}` 表示当前页码,`{total}` 表示总页数。
137    ///
138    /// ```
139    /// use liepress::ConvertOptions;
140    ///
141    /// let opts = ConvertOptions::new()
142    ///     .with_header("我的文档");
143    ///
144    /// let opts = ConvertOptions::new()
145    ///     .with_header("第 {page} 页 / 共 {total} 页");
146    /// ```
147    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    /// 设置页脚文本(支持 {page} 和 {total} 模板变量)
154    ///
155    /// 页脚会显示在每页的底部边距区域,居中对齐。
156    /// 使用 `{page}` 表示当前页码,`{total}` 表示总页数。
157    ///
158    /// ```
159    /// use liepress::ConvertOptions;
160    ///
161    /// let opts = ConvertOptions::new()
162    ///     .with_footer("- {page} -");
163    ///
164    /// let opts = ConvertOptions::new()
165    ///     .with_footer("第 {page} 页 / 共 {total} 页");
166    /// ```
167    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    /// 设置页眉字体大小(pt)
174    ///
175    /// 默认 9pt。仅在设置了页眉时生效。
176    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    /// 设置页脚字体大小(pt)
183    ///
184    /// 默认 9pt。仅在设置了页脚时生效。
185    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    /// 启用无限高度模式(仅限定宽度,高度自适应内容)
192    ///
193    /// 启用后:
194    /// - 内容不分页,所有元素连续排列在一个页面上
195    /// - 页面高度根据实际内容自动扩展
196    /// - 页眉页脚仍会显示,但 `{total}` 始终为 1
197    ///
198    /// ```
199    /// use liepress::ConvertOptions;
200    ///
201    /// let opts = ConvertOptions::new()
202    ///     .with_height_unlimited(true);
203    /// ```
204    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
224// ─── 内部渲染辅助函数 ─────────────────────────────────────
225
226fn 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
251// ─── 内部文件读取辅助函数 ─────────────────────────────────
252
253fn 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// ─── 自动字体推断 ────────────────────────────────────────
260
261/// 运行脚本范围(避免误判 URL、代码、标签中的字符)
262#[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            // 日文:平假名、片假名
276            0x3040..=0x309F | 0x30A0..=0x30FF | 0x31F0..=0x31FF => ScriptRange::Japanese,
277            // 中文/汉字:
278            //   CJK 统一表意文字 (4E00-9FFF)
279            //   CJK 扩展 A (3400-4DBF)
280            //   CJK 扩展 B (20000-2A6DF)
281            //   CJK 扩展 C (2A700-2B73F)
282            //   CJK 扩展 D (2B740-2B81F)
283            //   CJK 扩展 E (2B820-2CEAF)
284            //   CJK 扩展 F (2CEB0-2EBE0)
285            //   CJK 兼容表意文字 (F900-FAFF)
286            //   CJK 兼容表意文字补充 (2F800-2FA1F)
287            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            // 韩文
297            0xAC00..=0xD7AF => ScriptRange::Korean,
298            // 拉丁文基础、补充标点
299            0x0000..=0x00FF | 0x2000..=0x206F => ScriptRange::Latin,
300            // 其他有字母属性的字符归为 Latin
301            _ if c.is_alphabetic() => ScriptRange::Latin,
302            _ => ScriptRange::Other,
303        }
304    }
305}
306
307/// 从 Markdown 文本中推断主要语言,返回推荐字体列表
308fn 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        // 简单状态机:代码块用 ``` 包围
315        if line.trim().starts_with("```") {
316            in_code = !in_code;
317            continue;
318        }
319        if in_code {
320            continue;
321        }
322
323        // 跳过标题标记和列表标记
324        let content = line.trim_start().trim_start_matches('#').trim_start();
325
326        for c in content.chars() {
327            // 跳过链接内容 [text](url)
328            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    // 找出占比最高的脚本
356    let dominant = counts
357        .iter()
358        .max_by_key(|&(_, count)| *count)
359        .map(|(k, _)| *k)
360        .unwrap_or(ScriptRange::Other);
361
362    // 基础中文字体列表(作为所有语言场景的回退)
363    // 包含衬线体(Serif)和无衬线体(Sans-serif)。
364    // 中文 serif 优先使用仿宋(FangSong,公文/报告排版惯例)。
365    // 说明:代码/CLI 场景优先使用 Noto CJK 统一系列(与 JP/KR 同族、字形规范统一),
366    // 再回退到独立 Noto SC / 思源宋体(Source Han)及系统宋体。
367    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            // 英文为主时,优先使用拉丁字体,但保留中文字体作为回退
410            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
431// ─── CSS 解析 ───────────────────────────────────────────────
432
433fn 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    // 判断用户是否已经显式设置了 font-family(通过 CSS 字符串)
443    // 注意:这里做简单启发式判断。更严谨的做法是在 CSS 解析阶段
444    // 检查是否有 body { font-family: ... } 规则。当前先以显式 font_family 为首要考虑。
445    let user_has_font_css =
446        file_css.contains("font-family") || options.user_css.contains("font-family");
447
448    // 优先级:用户 CSS > auto-font > font_family
449    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
506// ─── Markdown 管线入口 ──────────────────────────────────────
507
508/// 核心转换逻辑:Markdown → PDF
509///
510/// 管线:Markdown → HTML → HtmlDocument → Styled Node → Document → PDF
511///
512/// 本地图片需在调用前已嵌入为 data URI(由 `markdown_file_to_pdf` 自动处理)。
513pub 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
525/// Markdown 文件 → PDF(自动将本地图片嵌入为 base64)
526pub 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
543/// 核心转换逻辑:Markdown → SVG
544pub 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
559/// 核心转换逻辑:Markdown → PNG
560pub 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
575/// Markdown 文件 → SVG(自动将本地图片嵌入为 base64)
576pub 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
593/// Markdown 文件 → PNG(自动将本地图片嵌入为 base64)
594pub 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
611// ─── HTML → PDF/SVG/PNG ─────────────────────────────────────
612
613/// HTML → PDF 转换
614///
615/// 直接将 HTML 内容转换为 PDF,不经过 Markdown 解析。
616/// 适用于已有 HTML 文件的场景。
617pub 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
623/// HTML 文件 → PDF(自动将本地图片嵌入为 base64)
624pub 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
638/// HTML → SVG 转换
639pub 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
645/// HTML 文件 → SVG(自动将本地图片嵌入为 base64)
646pub 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
663/// HTML → PNG 转换
664pub 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
670/// HTML 文件 → PNG(自动将本地图片嵌入为 base64)
671pub 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
688// ─── 内部:HTML → Document 公共逻辑 ────────────────────────
689
690/// HTML → Document 的核心转换逻辑
691///
692/// 被所有 markdown_to_* 入口共享。
693fn 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    // 1. HTML → HtmlDocument
700    let doc = html::parse_html(html);
701
702    // 2. 合并 CSS:内置样式 + <style> 标签 + 用户 CSS
703    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    // 应用 <style> 标签中的 CSS
708    for sheet in &doc.style_sheets {
709        engine = engine
710            .with_user_css(sheet)
711            .map_err(crate::error::Error::CssParseError)?;
712    }
713
714    // 应用用户提供的 CSS
715    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    // 检测根元素字号:解析 <html> 元素,将计算后的 font-size 设为根字号
726    // 这让 rem 单位能正确参考根元素的实际字号
727    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    // 3. HtmlDocument → Styled Node Tree
732    let styled_node = html::html_to_styled_nodes(&doc, &engine);
733
734    // 4. Styled Node → Document(布局)
735    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}