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
27const LATIN_FONT_CANDIDATES: &[&str] = &[
31 "/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 "/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 "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
42 "/usr/share/fonts/liberation-sans/LiberationSans-Regular.ttf",
43];
44
45const 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
56pub 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 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 §ions,
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
249fn 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
290fn 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
323fn 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(®ular_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(®ular_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#[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
403fn 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 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
470fn 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 Block::ListItem(spans) => format!("- {}\n", emit_spans(spans)),
483 Block::Code(text) => format!("#raw(block: true, {})\n\n", typst_string(text)),
484 }
485}
486
487fn 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
504fn 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 '\n' => out.push(' '),
518 _ => out.push(c),
519 }
520 }
521 out
522}
523
524fn 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
539struct 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
593struct Span {
599 text: String,
600 bold: bool,
601 italic: bool,
602}
603
604enum 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 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 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
738async 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 #[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 §ions,
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 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 §ions,
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 #[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 #[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 §ions,
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}