Skip to main content

microclaw_engine/tools/
render_pdf.rs

1use async_trait::async_trait;
2use serde_json::{json, Value};
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
7
8use typst::diag::{FileError, FileResult, SourceDiagnostic, Warned};
9use typst::foundations::{Bytes, Datetime, Duration};
10use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
11use typst::text::{Font, FontBook};
12use typst::utils::LazyHash;
13use typst::{Library, LibraryExt, World};
14use typst_layout::PagedDocument;
15use typst_pdf::PdfOptions;
16
17use crate::internal::channels::channel::deliver_and_store_bot_message;
18use crate::internal::channels::channel_adapter::ChannelRegistry;
19use crate::internal::storage::db::Database;
20use crate::internal::tool_runtime::media_client::persist_output;
21use crate::internal::tool_runtime::runtime::auth_context_from_input;
22use microclaw_core::llm_types::ToolDefinition;
23
24use super::{schema_object, Tool, ToolResult};
25use crate::config::{BookConfig, Config};
26
27/// Fonts probed (in order) when `media.book.font_path` is unset. Typst subsets
28/// whatever font it uses, so output stays small either way; we still prefer a
29/// compact Latin face for Latin-only docs and a CJK-capable face for CJK docs.
30const LATIN_FONT_CANDIDATES: &[&str] = &[
31    // macOS.
32    "/System/Library/Fonts/Supplemental/Arial.ttf",
33    "/System/Library/Fonts/Supplemental/Georgia.ttf",
34    "/System/Library/Fonts/Supplemental/Verdana.ttf",
35    "/Library/Fonts/Arial.ttf",
36    // Debian/Ubuntu.
37    "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
38    "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
39    "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
40    // Fedora/RHEL.
41    "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
42    "/usr/share/fonts/liberation-sans/LiberationSans-Regular.ttf",
43];
44
45/// CJK-capable fonts (used when the document contains CJK characters). Typst
46/// reads `.ttc` collections and OTF/CFF too, but single-face `.ttf` is simplest.
47const CJK_FONT_CANDIDATES: &[&str] = &[
48    "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
49    "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttf",
50    "/usr/share/fonts/truetype/arphic/uming.ttf",
51];
52
53const BASE_FONT_SIZE: u8 = 11;
54const MAX_SECTIONS: usize = 64;
55
56/// "Generate a book" tool: renders structured content to a self-contained PDF
57/// (cover, optional table of contents, headed sections with Markdown bodies,
58/// page numbers) via the pure-Rust Typst backend — no external binaries, and
59/// fonts are subsetted so CJK PDFs stay small.
60pub struct RenderPdfTool {
61    data_dir: PathBuf,
62    channels: Arc<ChannelRegistry>,
63    db: Arc<Database>,
64    cfg: BookConfig,
65}
66
67impl RenderPdfTool {
68    pub fn new(config: &Config, channels: Arc<ChannelRegistry>, db: Arc<Database>) -> Self {
69        Self {
70            data_dir: PathBuf::from(&config.data_dir),
71            channels,
72            db,
73            cfg: config.media.book.clone(),
74        }
75    }
76}
77
78struct Section {
79    heading: String,
80    level: u8,
81    body: String,
82}
83
84#[async_trait]
85impl Tool for RenderPdfTool {
86    fn name(&self) -> &str {
87        "render_pdf"
88    }
89
90    fn definition(&self) -> ToolDefinition {
91        ToolDefinition {
92            name: self.name().into(),
93            description: "Render a structured document (a \"book\" / report) to a PDF. \
94                Provide a `title` and an ordered list of `sections`, each with a \
95                `heading` and a Markdown `body`. Produces a cover page, an optional \
96                table of contents, and headed sections with page numbers. The PDF is \
97                saved under the bot's data directory and — when the active channel \
98                supports attachments — sent back inline. Self-contained (pure-Rust, no \
99                external tools); supports CJK and inline bold/italic. Disabled by \
100                default; requires operator opt-in via `media.book.enabled`."
101                .into(),
102            input_schema: schema_object(
103                json!({
104                    "title": {"type": "string", "description": "Document title (shown on the cover)."},
105                    "subtitle": {"type": "string", "description": "Optional subtitle (cover)."},
106                    "author": {"type": "string", "description": "Optional author/byline (cover)."},
107                    "cover": {"type": "boolean", "description": "Render a cover page (default true)."},
108                    "toc": {"type": "boolean", "description": "Render a table of contents (default true)."},
109                    "sections": {
110                        "type": "array",
111                        "description": "Ordered sections of the document.",
112                        "items": {
113                            "type": "object",
114                            "properties": {
115                                "heading": {"type": "string", "description": "Section heading."},
116                                "level": {"type": "integer", "description": "Heading level 1-3 (default 1)."},
117                                "body_markdown": {"type": "string", "description": "Section body as Markdown (headings, paragraphs, lists, code)."}
118                            },
119                            "required": ["heading", "body_markdown"]
120                        }
121                    },
122                    "deliver": {"type": "boolean", "description": "Attempt channel delivery (default true)."}
123                }),
124                &["title", "sections"],
125            ),
126        }
127    }
128
129    async fn execute(&self, input: Value) -> ToolResult {
130        if !self.cfg.enabled {
131            return ToolResult::error(
132                "render_pdf is disabled. Set media.book.enabled=true to enable.".into(),
133            );
134        }
135        let title = match input.get("title").and_then(|v| v.as_str()) {
136            Some(t) if !t.trim().is_empty() => t.trim().to_string(),
137            _ => return ToolResult::error("Missing parameter: title".into()),
138        };
139        let subtitle = input
140            .get("subtitle")
141            .and_then(|v| v.as_str())
142            .map(|s| s.trim().to_string())
143            .filter(|s| !s.is_empty());
144        let author = input
145            .get("author")
146            .and_then(|v| v.as_str())
147            .map(|s| s.trim().to_string())
148            .filter(|s| !s.is_empty());
149        let want_cover = input.get("cover").and_then(|v| v.as_bool()).unwrap_or(true);
150        let want_toc = input.get("toc").and_then(|v| v.as_bool()).unwrap_or(true);
151        let deliver = input
152            .get("deliver")
153            .and_then(|v| v.as_bool())
154            .unwrap_or(true);
155
156        let sections = match parse_sections(&input) {
157            Ok(s) => s,
158            Err(e) => return ToolResult::error(e),
159        };
160
161        // Typesetting is CPU-bound and synchronous; keep it off the async
162        // executor. Move owned inputs into a blocking task.
163        let cfg = self.cfg.clone();
164        let data_dir = self.data_dir.clone();
165        let title_for_doc = title.clone();
166        let render_res = tokio::task::spawn_blocking(move || {
167            render_document(
168                &cfg,
169                &data_dir,
170                &title_for_doc,
171                subtitle.as_deref(),
172                author.as_deref(),
173                want_cover,
174                want_toc,
175                &sections,
176            )
177        })
178        .await;
179
180        let saved = match render_res {
181            Ok(Ok(p)) => p,
182            Ok(Err(e)) => return ToolResult::error(e),
183            Err(e) => return ToolResult::error(format!("render task panicked: {e}")),
184        };
185
186        let mut summary = format!("rendered PDF '{}' -> {}", title, saved.display());
187        if deliver {
188            if let Some(auth) = auth_context_from_input(&input) {
189                match deliver_attachment(
190                    &self.channels,
191                    self.db.clone(),
192                    auth.caller_chat_id,
193                    &saved,
194                    &title,
195                )
196                .await
197                {
198                    Ok(msg) => summary.push_str(&format!("; {msg}")),
199                    Err(e) => summary.push_str(&format!("; delivery skipped: {e}")),
200                }
201            }
202        }
203        ToolResult::success(summary).with_metadata(json!({
204            "path": saved.to_string_lossy(),
205            "title": title,
206        }))
207    }
208}
209
210fn parse_sections(input: &Value) -> Result<Vec<Section>, String> {
211    let arr = input
212        .get("sections")
213        .and_then(|v| v.as_array())
214        .ok_or_else(|| "Missing or invalid parameter: sections (expected an array)".to_string())?;
215    if arr.is_empty() {
216        return Err("sections must contain at least one entry".into());
217    }
218    if arr.len() > MAX_SECTIONS {
219        return Err(format!(
220            "too many sections ({}); max is {MAX_SECTIONS}",
221            arr.len()
222        ));
223    }
224    let mut out = Vec::with_capacity(arr.len());
225    for (i, s) in arr.iter().enumerate() {
226        let heading = match s.get("heading").and_then(|v| v.as_str()) {
227            Some(h) if !h.trim().is_empty() => h.trim().to_string(),
228            _ => return Err(format!("section {i}: missing or empty 'heading'")),
229        };
230        let body = s
231            .get("body_markdown")
232            .and_then(|v| v.as_str())
233            .unwrap_or("")
234            .to_string();
235        let level = s
236            .get("level")
237            .and_then(|v| v.as_u64())
238            .map(|v| v.clamp(1, 3) as u8)
239            .unwrap_or(1);
240        out.push(Section {
241            heading,
242            level,
243            body,
244        });
245    }
246    Ok(out)
247}
248
249// ---------------------------------------------------------------------------
250// Font selection
251// ---------------------------------------------------------------------------
252
253fn resolve_font_path(cfg: &BookConfig, needs_cjk: bool) -> Result<PathBuf, String> {
254    if let Some(p) = cfg.font_path.as_deref().filter(|s| !s.trim().is_empty()) {
255        let pb = PathBuf::from(p);
256        if !pb.exists() {
257            return Err(format!("media.book.font_path '{p}' does not exist"));
258        }
259        return Ok(pb);
260    }
261    let order: [&[&str]; 2] = if needs_cjk {
262        [CJK_FONT_CANDIDATES, LATIN_FONT_CANDIDATES]
263    } else {
264        [LATIN_FONT_CANDIDATES, CJK_FONT_CANDIDATES]
265    };
266    for group in order {
267        for cand in group {
268            if Path::new(cand).exists() {
269                return Ok(PathBuf::from(cand));
270            }
271        }
272    }
273    let hint = if needs_cjk {
274        " The document contains CJK text, so a CJK-capable font is required."
275    } else {
276        ""
277    };
278    Err(format!(
279        "no usable font found. Set media.book.font_path to a TrueType/OpenType font.{hint}"
280    ))
281}
282
283#[derive(Clone, Copy)]
284enum FaceKind {
285    Bold,
286    Italic,
287    BoldItalic,
288}
289
290/// Find the sibling variant file for a regular font, trying common naming
291/// conventions (`Arial Bold.ttf`, `DejaVuSans-Bold.ttf`, …).
292fn variant_path(regular: &Path, kind: FaceKind) -> Option<PathBuf> {
293    let dir = regular.parent()?;
294    let stem = regular.file_stem()?.to_str()?;
295    let ext = regular
296        .extension()
297        .and_then(|e| e.to_str())
298        .unwrap_or("ttf");
299    let (spaced, hyphen, oblique) = match kind {
300        FaceKind::Bold => (" Bold", "-Bold", "-Bold"),
301        FaceKind::Italic => (" Italic", "-Italic", "-Oblique"),
302        FaceKind::BoldItalic => (" Bold Italic", "-BoldItalic", "-BoldOblique"),
303    };
304    let base = stem
305        .trim_end_matches("-Regular")
306        .trim_end_matches("Regular")
307        .trim_end_matches(['-', ' ']);
308    let base = if base.is_empty() { stem } else { base };
309    for n in [
310        format!("{stem}{spaced}.{ext}"),
311        format!("{base}{hyphen}.{ext}"),
312        format!("{base}{oblique}.{ext}"),
313        format!("{base}{spaced}.{ext}"),
314    ] {
315        let p = dir.join(n);
316        if p.exists() {
317            return Some(p);
318        }
319    }
320    None
321}
322
323/// Load the regular face (plus any Bold/Italic siblings) as Typst fonts and
324/// return them with the regular face's family name.
325fn load_fonts(cfg: &BookConfig, needs_cjk: bool) -> Result<(Vec<Font>, String), String> {
326    let regular_path = resolve_font_path(cfg, needs_cjk)?;
327    let regular_bytes = std::fs::read(&regular_path)
328        .map_err(|e| format!("failed to read font '{}': {e}", regular_path.display()))?;
329    let regular = Font::new(Bytes::new(regular_bytes), 0)
330        .ok_or_else(|| format!("could not parse font '{}'", regular_path.display()))?;
331    let family = regular.info().family.clone();
332
333    let mut fonts = vec![regular];
334    for kind in [FaceKind::Bold, FaceKind::Italic, FaceKind::BoldItalic] {
335        if let Some(p) = variant_path(&regular_path, kind) {
336            if let Ok(b) = std::fs::read(&p) {
337                if let Some(f) = Font::new(Bytes::new(b), 0) {
338                    fonts.push(f);
339                }
340            }
341        }
342    }
343    Ok((fonts, family))
344}
345
346fn contains_cjk(sections: &[Section], title: &str) -> bool {
347    let is_cjk = |c: char| {
348        matches!(c as u32,
349            0x3000..=0x303F
350            | 0x3040..=0x30FF
351            | 0x3400..=0x4DBF
352            | 0x4E00..=0x9FFF
353            | 0xF900..=0xFAFF
354            | 0xAC00..=0xD7AF
355            | 0xFF00..=0xFFEF
356        )
357    };
358    title.chars().any(is_cjk)
359        || sections
360            .iter()
361            .any(|s| s.heading.chars().any(is_cjk) || s.body.chars().any(is_cjk))
362}
363
364// ---------------------------------------------------------------------------
365// Rendering (Typst)
366// ---------------------------------------------------------------------------
367
368#[allow(clippy::too_many_arguments)]
369fn render_document(
370    cfg: &BookConfig,
371    data_dir: &Path,
372    title: &str,
373    subtitle: Option<&str>,
374    author: Option<&str>,
375    want_cover: bool,
376    want_toc: bool,
377    sections: &[Section],
378) -> Result<PathBuf, String> {
379    let needs_cjk = contains_cjk(sections, title);
380    let (fonts, family) = load_fonts(cfg, needs_cjk)?;
381    let markup = build_markup(
382        title, subtitle, author, want_cover, want_toc, sections, &family,
383    );
384
385    let world = TypstWorld::new(fonts, markup);
386    let Warned { output, .. } = typst::compile::<PagedDocument>(&world);
387    let document =
388        output.map_err(|d| format!("typst compile failed: {}", format_diagnostics(&d)))?;
389    let pdf = typst_pdf::pdf(&document, &PdfOptions::default())
390        .map_err(|d| format!("typst PDF export failed: {}", format_diagnostics(&d)))?;
391
392    persist_output(data_dir, "docs", "pdf", &pdf).map_err(|e| format!("failed to save PDF: {e}"))
393}
394
395fn format_diagnostics(diags: &[SourceDiagnostic]) -> String {
396    diags
397        .iter()
398        .map(|d| d.message.as_str())
399        .collect::<Vec<_>>()
400        .join("; ")
401}
402
403/// Build the Typst markup for the whole document.
404fn build_markup(
405    title: &str,
406    subtitle: Option<&str>,
407    author: Option<&str>,
408    want_cover: bool,
409    want_toc: bool,
410    sections: &[Section],
411    family: &str,
412) -> String {
413    let mut m = String::new();
414    m.push_str(&format!("#set document(title: {})\n", typst_string(title)));
415    m.push_str(&format!(
416        "#set text(font: {}, size: {}pt)\n",
417        typst_string(family),
418        BASE_FONT_SIZE
419    ));
420    m.push_str("#set par(justify: false, leading: 0.7em)\n");
421    m.push_str("#set heading(numbering: none)\n");
422    m.push_str("#show heading.where(level: 1): set text(size: 18pt)\n");
423    m.push_str("#show heading.where(level: 2): set text(size: 14pt)\n");
424    m.push_str("#show heading.where(level: 3): set text(size: 12pt)\n");
425    m.push_str("#set page(paper: \"a4\", margin: 2cm, numbering: none)\n\n");
426
427    if want_cover {
428        m.push_str("#align(center)[\n  #v(5cm)\n");
429        m.push_str(&format!(
430            "  #text(size: 28pt, weight: \"bold\")[{}]\n",
431            escape_typst(title)
432        ));
433        if let Some(sub) = subtitle {
434            m.push_str(&format!(
435                "  #v(0.6cm)\n  #text(size: 16pt)[{}]\n",
436                escape_typst(sub)
437            ));
438        }
439        if let Some(a) = author {
440            m.push_str(&format!(
441                "  #v(1.2cm)\n  #text(size: 12pt)[{}]\n",
442                escape_typst(a)
443            ));
444        }
445        m.push_str("]\n#pagebreak()\n\n");
446    }
447
448    // Number pages from 1 starting after the cover.
449    m.push_str("#set page(numbering: \"1\")\n#counter(page).update(1)\n\n");
450
451    if want_toc && sections.len() > 1 {
452        m.push_str("#outline(title: [Contents], depth: 1)\n#pagebreak()\n\n");
453    }
454
455    for s in sections {
456        let level = s.level.clamp(1, 3) as usize;
457        m.push_str(&format!(
458            "{} {}\n\n",
459            "=".repeat(level),
460            escape_typst(&s.heading)
461        ));
462        for block in markdown_blocks(&s.body) {
463            m.push_str(&emit_block(&block));
464        }
465        m.push('\n');
466    }
467    m
468}
469
470/// Render one Markdown block to Typst markup.
471fn emit_block(block: &Block) -> String {
472    match block {
473        Block::Heading(level, text) => {
474            format!(
475                "\n{} {}\n\n",
476                "=".repeat(*level as usize),
477                escape_typst(text)
478            )
479        }
480        Block::Paragraph(spans) => format!("{}\n\n", emit_spans(spans)),
481        // No blank line after a list item, so consecutive items form one list.
482        Block::ListItem(spans) => format!("- {}\n", emit_spans(spans)),
483        Block::Code(text) => format!("#raw(block: true, {})\n\n", typst_string(text)),
484    }
485}
486
487/// Render inline spans to Typst markup, applying bold/italic via `#strong` /
488/// `#emph` (function form, so escaping is unambiguous).
489fn emit_spans(spans: &[Span]) -> String {
490    let mut out = String::new();
491    for span in spans {
492        let esc = escape_typst(&span.text);
493        let rendered = match (span.bold, span.italic) {
494            (true, true) => format!("#strong[#emph[{esc}]]"),
495            (true, false) => format!("#strong[{esc}]"),
496            (false, true) => format!("#emph[{esc}]"),
497            (false, false) => esc,
498        };
499        out.push_str(&rendered);
500    }
501    out
502}
503
504/// Escape text for use in Typst markup context. Over-escapes a few characters
505/// that are only special at line start (`-`, `+`, `=`) — harmless, they render
506/// literally — to keep arbitrary user text from triggering markup.
507fn escape_typst(s: &str) -> String {
508    let mut out = String::with_capacity(s.len() + 8);
509    for c in s.chars() {
510        match c {
511            '\\' | '#' | '$' | '*' | '_' | '`' | '<' | '>' | '@' | '[' | ']' | '~' | '=' | '+'
512            | '-' | '/' | '"' => {
513                out.push('\\');
514                out.push(c);
515            }
516            // A bare newline would end the paragraph; keep runs on one line.
517            '\n' => out.push(' '),
518            _ => out.push(c),
519        }
520    }
521    out
522}
523
524/// Quote a Rust string as a Typst string literal (`"..."`).
525fn typst_string(s: &str) -> String {
526    let mut out = String::with_capacity(s.len() + 2);
527    out.push('"');
528    for c in s.chars() {
529        match c {
530            '\\' => out.push_str("\\\\"),
531            '"' => out.push_str("\\\""),
532            _ => out.push(c),
533        }
534    }
535    out.push('"');
536    out
537}
538
539/// A minimal in-memory Typst [`World`] backed by a single source string and a
540/// fixed set of fonts. No filesystem or package access.
541struct TypstWorld {
542    library: LazyHash<Library>,
543    book: LazyHash<FontBook>,
544    fonts: Vec<Font>,
545    main: FileId,
546    source: Source,
547}
548
549impl TypstWorld {
550    fn new(fonts: Vec<Font>, markup: String) -> Self {
551        let book = FontBook::from_fonts(fonts.iter());
552        let vpath = VirtualPath::new("main.typ").expect("valid virtual path");
553        let main = FileId::new(RootedPath::new(VirtualRoot::Project, vpath));
554        let source = Source::new(main, markup);
555        Self {
556            library: LazyHash::new(Library::builder().build()),
557            book: LazyHash::new(book),
558            fonts,
559            main,
560            source,
561        }
562    }
563}
564
565impl World for TypstWorld {
566    fn library(&self) -> &LazyHash<Library> {
567        &self.library
568    }
569    fn book(&self) -> &LazyHash<FontBook> {
570        &self.book
571    }
572    fn main(&self) -> FileId {
573        self.main
574    }
575    fn source(&self, id: FileId) -> FileResult<Source> {
576        if id == self.main {
577            Ok(self.source.clone())
578        } else {
579            Err(FileError::NotFound(PathBuf::new()))
580        }
581    }
582    fn file(&self, _id: FileId) -> FileResult<Bytes> {
583        Err(FileError::NotFound(PathBuf::new()))
584    }
585    fn font(&self, index: usize) -> Option<Font> {
586        self.fonts.get(index).cloned()
587    }
588    fn today(&self, _offset: Option<Duration>) -> Option<Datetime> {
589        None
590    }
591}
592
593// ---------------------------------------------------------------------------
594// Markdown parsing (block + inline spans)
595// ---------------------------------------------------------------------------
596
597/// An inline run of text sharing one style (bold/italic).
598struct Span {
599    text: String,
600    bold: bool,
601    italic: bool,
602}
603
604/// A parsed Markdown block. Paragraph/list bodies keep their inline spans so
605/// bold/italic can be rendered; headings and code blocks are single-style.
606enum Block {
607    Heading(u8, String),
608    Paragraph(Vec<Span>),
609    ListItem(Vec<Span>),
610    Code(String),
611}
612
613fn flatten_spans(spans: &[Span]) -> String {
614    spans.iter().map(|s| s.text.as_str()).collect()
615}
616
617fn trim_spans(mut spans: Vec<Span>) -> Vec<Span> {
618    while spans.first().is_some_and(|s| s.text.trim().is_empty()) {
619        spans.remove(0);
620    }
621    while spans.last().is_some_and(|s| s.text.trim().is_empty()) {
622        spans.pop();
623    }
624    if let Some(first) = spans.first_mut() {
625        first.text = first.text.trim_start().to_string();
626    }
627    if let Some(last) = spans.last_mut() {
628        last.text = last.text.trim_end().to_string();
629    }
630    spans
631}
632
633fn push_span(spans: &mut Vec<Span>, text: &str, bold: bool, italic: bool) {
634    if text.is_empty() {
635        return;
636    }
637    if let Some(last) = spans.last_mut() {
638        if last.bold == bold && last.italic == italic {
639            last.text.push_str(text);
640            return;
641        }
642    }
643    spans.push(Span {
644        text: text.to_string(),
645        bold,
646        italic,
647    });
648}
649
650fn markdown_blocks(md: &str) -> Vec<Block> {
651    let mut blocks = Vec::new();
652    let mut spans: Vec<Span> = Vec::new();
653    // Active block kind: 0 none, 1 paragraph, 2 item, 3 code, 4 heading.
654    let mut mode: u8 = 0;
655    let mut heading_level: u8 = 1;
656    let mut bold = 0u32;
657    let mut italic = 0u32;
658
659    let flush = |blocks: &mut Vec<Block>, spans: &mut Vec<Span>, mode: u8, hl: u8| {
660        let taken = std::mem::take(spans);
661        match mode {
662            1 => {
663                let s = trim_spans(taken);
664                if !s.is_empty() {
665                    blocks.push(Block::Paragraph(s));
666                }
667            }
668            2 => {
669                let s = trim_spans(taken);
670                if !s.is_empty() {
671                    blocks.push(Block::ListItem(s));
672                }
673            }
674            3 => {
675                let t = flatten_spans(&taken).trim().to_string();
676                if !t.is_empty() {
677                    blocks.push(Block::Code(t));
678                }
679            }
680            4 => {
681                let t = flatten_spans(&taken).trim().to_string();
682                if !t.is_empty() {
683                    blocks.push(Block::Heading(hl, t));
684                }
685            }
686            _ => {}
687        }
688    };
689
690    for ev in Parser::new(md) {
691        match ev {
692            Event::Start(Tag::Paragraph) => {
693                flush(&mut blocks, &mut spans, mode, heading_level);
694                mode = 1;
695            }
696            Event::Start(Tag::Item) => {
697                flush(&mut blocks, &mut spans, mode, heading_level);
698                mode = 2;
699            }
700            Event::Start(Tag::CodeBlock(_)) => {
701                flush(&mut blocks, &mut spans, mode, heading_level);
702                mode = 3;
703            }
704            Event::Start(Tag::Heading { level, .. }) => {
705                flush(&mut blocks, &mut spans, mode, heading_level);
706                mode = 4;
707                heading_level = heading_md_level(level);
708            }
709            Event::Start(Tag::Strong) => bold += 1,
710            Event::Start(Tag::Emphasis) => italic += 1,
711            Event::End(TagEnd::Strong) => bold = bold.saturating_sub(1),
712            Event::End(TagEnd::Emphasis) => italic = italic.saturating_sub(1),
713            Event::End(
714                TagEnd::Paragraph | TagEnd::Item | TagEnd::CodeBlock | TagEnd::Heading(_),
715            ) => {
716                flush(&mut blocks, &mut spans, mode, heading_level);
717                mode = 0;
718            }
719            // Inline code is rendered as regular text.
720            Event::Text(t) | Event::Code(t) => push_span(&mut spans, &t, bold > 0, italic > 0),
721            Event::SoftBreak => push_span(&mut spans, " ", bold > 0, italic > 0),
722            Event::HardBreak => push_span(&mut spans, " ", bold > 0, italic > 0),
723            _ => {}
724        }
725    }
726    flush(&mut blocks, &mut spans, mode, heading_level);
727    blocks
728}
729
730fn heading_md_level(level: HeadingLevel) -> u8 {
731    match level {
732        HeadingLevel::H1 => 1,
733        HeadingLevel::H2 => 2,
734        _ => 3,
735    }
736}
737
738// ---------------------------------------------------------------------------
739// Delivery
740// ---------------------------------------------------------------------------
741
742async fn deliver_attachment(
743    channels: &ChannelRegistry,
744    db: Arc<Database>,
745    chat_id: i64,
746    file: &Path,
747    caption: &str,
748) -> Result<String, String> {
749    let routing = crate::internal::channels::channel::get_required_chat_routing(
750        channels,
751        db.clone(),
752        chat_id,
753    )
754    .await?;
755    let Some(adapter) = channels.get(&routing.channel_name) else {
756        return Err(format!("no adapter for channel '{}'", routing.channel_name));
757    };
758    if adapter.is_local_only() {
759        return Ok(format!(
760            "channel '{}' is local-only, path retained at: {}",
761            routing.channel_name,
762            file.display()
763        ));
764    }
765    let external_chat_id = crate::internal::storage::db::call_blocking(db.clone(), move |d| {
766        d.get_chat_external_id(chat_id)
767    })
768    .await
769    .map_err(|e| e.to_string())?
770    .unwrap_or_else(|| chat_id.to_string());
771    let caption_short = caption.chars().take(120).collect::<String>();
772    match adapter
773        .send_attachment(&external_chat_id, file, Some(&caption_short))
774        .await
775    {
776        Ok(_) => {
777            let _ = deliver_and_store_bot_message(
778                channels,
779                db,
780                "bot",
781                chat_id,
782                &format!("[document attached: {}]", file.display()),
783            )
784            .await;
785            Ok(format!("delivered via channel '{}'", routing.channel_name))
786        }
787        Err(e) => Err(e),
788    }
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794
795    #[test]
796    fn rejects_empty_sections() {
797        let input = json!({"title": "t", "sections": []});
798        assert!(parse_sections(&input).is_err());
799    }
800
801    #[test]
802    fn parses_sections_with_defaults() {
803        let input =
804            json!({"title": "t", "sections": [{"heading": "Intro", "body_markdown": "hi"}]});
805        let s = parse_sections(&input).unwrap();
806        assert_eq!(s.len(), 1);
807        assert_eq!(s[0].level, 1);
808        assert_eq!(s[0].heading, "Intro");
809    }
810
811    #[test]
812    fn escape_typst_neutralizes_markup() {
813        assert_eq!(escape_typst("a*b_c#d"), "a\\*b\\_c\\#d");
814        assert_eq!(typst_string("a\"b\\c"), "\"a\\\"b\\\\c\"");
815    }
816
817    // Real render smoke test — needs a system font, so it's #[ignore] by
818    // default. Run with: cargo test --lib render_smoke -- --ignored --nocapture
819    #[test]
820    #[ignore]
821    fn render_smoke() {
822        let cfg = BookConfig {
823            enabled: true,
824            ..Default::default()
825        };
826        let dir = std::env::temp_dir().join("microclaw_render_smoke");
827        let _ = std::fs::create_dir_all(&dir);
828        let sections = vec![
829            Section {
830                heading: "Introduction".into(),
831                level: 1,
832                body: "This is a **first** paragraph with enough words to require \
833                       wrapping across multiple lines so we exercise the layout \
834                       engine thoroughly.\n\n- bullet one\n- bullet two"
835                    .into(),
836            },
837            Section {
838                heading: "中文章节".into(),
839                level: 1,
840                body: "这是一段没有空格的中文文本,用来验证按字符断行的逻辑是否\
841                       正确工作。我们需要足够长的内容,使其超过一行的宽度,从而\
842                       触发自动换行,避免文字溢出页面边界。"
843                    .into(),
844            },
845        ];
846        let out = render_document(
847            &cfg,
848            &dir,
849            "Smoke Test 烟雾测试",
850            Some("A self-contained PDF"),
851            Some("MicroClaw"),
852            true,
853            true,
854            &sections,
855        )
856        .expect("render should succeed");
857        let bytes = std::fs::read(&out).unwrap();
858        assert!(bytes.starts_with(b"%PDF"), "output is not a PDF");
859        // Typst subsets the CJK font, so even a Chinese PDF should be small.
860        assert!(
861            bytes.len() > 1500,
862            "PDF suspiciously small: {}",
863            bytes.len()
864        );
865        assert!(
866            bytes.len() < 5_000_000,
867            "CJK PDF should be subset-small, got {} bytes",
868            bytes.len()
869        );
870        eprintln!("rendered {} bytes -> {}", bytes.len(), out.display());
871    }
872
873    #[test]
874    #[ignore]
875    fn render_smoke_english() {
876        let cfg = BookConfig {
877            enabled: true,
878            ..Default::default()
879        };
880        let dir = std::env::temp_dir().join("microclaw_render_smoke_en");
881        let _ = std::fs::create_dir_all(&dir);
882        let sections = vec![Section {
883            heading: "Introduction".into(),
884            level: 1,
885            body: "A short English-only document. It should render small.\n\n- one\n- two".into(),
886        }];
887        let out = render_document(
888            &cfg,
889            &dir,
890            "English Report",
891            None,
892            None,
893            true,
894            true,
895            &sections,
896        )
897        .expect("render should succeed");
898        let len = std::fs::read(&out).unwrap().len();
899        eprintln!("english PDF: {} bytes -> {}", len, out.display());
900        assert!(
901            len < 5_000_000,
902            "English PDF unexpectedly large: {len} bytes"
903        );
904    }
905
906    // Live end-to-end "write a book" test: a real LLM drafts the chapters, then
907    // the real render_pdf tool renders them. #[ignore] — needs an API key (LLM
908    // only; rendering itself is keyless) and a system font; costs ~a cent.
909    // Run with: OPENAI_API_KEY=... cargo test --lib live_render_book -- --ignored --nocapture
910    #[tokio::test]
911    #[ignore]
912    async fn live_render_book() {
913        let key = std::env::var("OPENAI_API_KEY")
914            .or_else(|_| std::env::var("MICROCLAW_OPENAI_API_KEY"))
915            .expect("set OPENAI_API_KEY (or MICROCLAW_OPENAI_API_KEY) to run this live test");
916
917        let prompt = "Write a very short book on \"the history of the quartz \
918            watch\". Respond with ONLY a JSON object of the form {\"title\": \
919            string, \"subtitle\": string, \"sections\": [{\"heading\": string, \
920            \"body_markdown\": string}]} with exactly 3 sections. Each \
921            body_markdown is ~120 words of real prose plus a short markdown \
922            bullet list.";
923        let req = json!({
924            "model": "gpt-4o-mini",
925            "response_format": {"type": "json_object"},
926            "messages": [{"role": "user", "content": prompt}]
927        });
928        let resp = reqwest::Client::new()
929            .post("https://api.openai.com/v1/chat/completions")
930            .bearer_auth(&key)
931            .json(&req)
932            .send()
933            .await
934            .expect("chat request failed");
935        assert!(resp.status().is_success(), "chat HTTP {}", resp.status());
936        let v: Value = resp.json().await.expect("bad chat json");
937        let content = v["choices"][0]["message"]["content"]
938            .as_str()
939            .expect("no message content");
940        let mut book: Value = serde_json::from_str(content).expect("LLM did not return JSON");
941        let n = book["sections"].as_array().map(|a| a.len()).unwrap_or(0);
942        assert!(n > 0, "LLM returned no sections");
943
944        let work =
945            std::env::temp_dir().join(format!("microclaw_book_live_{}", uuid::Uuid::new_v4()));
946        std::fs::create_dir_all(&work).unwrap();
947        let mut cfg = crate::config::Config::test_defaults();
948        cfg.data_dir = work.to_string_lossy().into_owned();
949        cfg.media.book.enabled = true;
950        let db_dir = work.join("db");
951        std::fs::create_dir_all(&db_dir).unwrap();
952        let db = Arc::new(Database::new(db_dir.to_str().unwrap()).unwrap());
953        let tool = RenderPdfTool::new(&cfg, Arc::new(ChannelRegistry::new()), db);
954
955        book["deliver"] = json!(false);
956        let res = tool.execute(book.clone()).await;
957        assert!(!res.is_error, "render_pdf errored: {}", res.content);
958
959        let path = res
960            .metadata
961            .as_ref()
962            .and_then(|m| m.get("path"))
963            .and_then(|x| x.as_str())
964            .expect("metadata.path missing");
965        let bytes = std::fs::read(path).unwrap();
966        assert!(bytes.starts_with(b"%PDF"), "output is not a PDF");
967        assert!(
968            bytes.len() > 1500,
969            "PDF suspiciously small: {} bytes",
970            bytes.len()
971        );
972        eprintln!(
973            "OK: book \"{}\" ({} sections) -> {} ({} bytes)",
974            book["title"].as_str().unwrap_or("?"),
975            n,
976            path,
977            bytes.len()
978        );
979    }
980
981    // Renders a Latin doc with bold/italic and checks it stays a valid PDF.
982    // #[ignore] — needs a font.
983    #[test]
984    #[ignore]
985    fn render_emphasis_smoke() {
986        let cfg = BookConfig {
987            enabled: true,
988            ..Default::default()
989        };
990        let dir = std::env::temp_dir().join("microclaw_render_emphasis");
991        let _ = std::fs::create_dir_all(&dir);
992        let sections = vec![Section {
993            heading: "Styles".into(),
994            level: 1,
995            body: "This paragraph has **bold words**, *italic words*, and \
996                   ***both at once***, plus inline `code`. It should wrap across \
997                   a couple of lines with the emphasis preserved.\n\n\
998                   - a **bold** bullet\n- an *italic* bullet"
999                .into(),
1000        }];
1001        let out = render_document(
1002            &cfg,
1003            &dir,
1004            "Emphasis Test",
1005            None,
1006            None,
1007            true,
1008            true,
1009            &sections,
1010        )
1011        .expect("render should succeed");
1012        let bytes = std::fs::read(&out).unwrap();
1013        assert!(bytes.starts_with(b"%PDF"), "not a PDF");
1014        assert!(
1015            (1500..8_000_000).contains(&bytes.len()),
1016            "unexpected size: {} bytes",
1017            bytes.len()
1018        );
1019        eprintln!("emphasis PDF: {} bytes -> {}", bytes.len(), out.display());
1020    }
1021
1022    #[test]
1023    fn markdown_emphasis_produces_styled_spans() {
1024        let blocks = markdown_blocks("Plain **bold** and *italic* words.");
1025        assert_eq!(blocks.len(), 1);
1026        let Block::Paragraph(spans) = &blocks[0] else {
1027            panic!("expected a paragraph");
1028        };
1029        assert!(spans
1030            .iter()
1031            .any(|s| s.bold && !s.italic && s.text.contains("bold")));
1032        assert!(spans
1033            .iter()
1034            .any(|s| s.italic && !s.bold && s.text.contains("italic")));
1035        assert!(spans
1036            .iter()
1037            .any(|s| !s.bold && !s.italic && s.text.contains("Plain")));
1038    }
1039
1040    #[test]
1041    fn markdown_blocks_splits_paragraph_and_list() {
1042        let blocks = markdown_blocks("Hello world.\n\n- one\n- two");
1043        assert_eq!(blocks.len(), 3);
1044        assert!(matches!(blocks[0], Block::Paragraph(_)));
1045        assert!(matches!(blocks[1], Block::ListItem(_)));
1046        assert!(matches!(blocks[2], Block::ListItem(_)));
1047    }
1048}