` URL
+ the user can download from chat.**
+
+ Use for FINAL deliverables — reports, white-papers, contracts, briefings —
+ where the recipient should not edit the document.
+
+ Markdown convention:
+ - Standard subset: headings (# ## ###), bold, italic, lists, tables,
+ blockquotes, code blocks, links.
+ - Optional YAML frontmatter at the top of the markdown drives cover
+ page and page header / footer:
+
+ ---
+ title: 季度业务回顾
+ subtitle: Q1 2026
+ header: 内部资料 - 仅限分发
+ footer: Mate Inc. © 2026
+ ---
+
+ # 第一章
+ ...
+
+ - Without frontmatter, the first `# H1` heading is used as the cover
+ title and pages are numbered automatically with no header / footer.
+
+ For markdown bodies larger than ~5 KB, prefer renderPdfFromFile.
+
+ Returns a markdown link the user can click to download the file.
+ The link is valid for 10 minutes.
+ """)
+ public String renderPdf(
+ @ToolParam(description = "Document content in Markdown format (optional YAML frontmatter for cover / header / footer)")
+ String markdown,
+ @ToolParam(description = "Output filename without extension, e.g. 'q1-review'")
+ String filename,
+ @ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false)
+ String pageSize,
+ @ToolParam(description = "Engine: 'auto' (default), 'html' (force in-process), or 'libreoffice' (force soffice)", required = false)
+ String engine) {
+
+ if (markdown == null || markdown.isBlank()) {
+ return "错误:markdown 参数为空,无法生成 PDF。";
+ }
+
+ String displayName = FilenameSanitizer.sanitize(filename, "document", ".pdf") + ".pdf";
+ String size = resolveSize(pageSize);
+ PdfProperties.Engine eng = resolveEngine(engine);
+
+ try {
+ MarkdownPdfRenderer.Result result = renderer.render(markdown, size, eng);
+ log.info("[PdfRender] generated {} ({} bytes via {})",
+ displayName, result.bytes().length, result.backend());
+ return GeneratedFileLink.resultZh(result.bytes(), displayName, PDF_MIME, cache, "PDF");
+ } catch (Exception e) {
+ log.error("[PdfRender] render failed for {}: {}", displayName, e.getMessage(), e);
+ return "渲染失败:" + e.getMessage();
+ }
+ }
+
+ @Tool(description = """
+ Render a .pdf from a markdown FILE on disk and return a one-time download URL.
+
+ **MUST use this tool (NOT renderDocxFromFile) whenever the user asks for a
+ PDF / .pdf / 导出 PDF / 生成 pdf and the markdown body is already on disk.**
+ Do not silently substitute docx when the user explicitly requested PDF.
+
+ Use this instead of `renderPdf` when the markdown body is large (>5 KB) — the
+ LLM does not need to repeat its own previous output as a tool argument.
+
+ Typical workflow:
+ 1. write_file(path="report.md", content="---\\ntitle: ...\\n---\\n# ...")
+ 2. renderPdfFromFile(filePath="report.md", filename="q1-review")
+ 3. return the download link to the user
+
+ The markdown file is read with UTF-8. Path resolution honors the workspace
+ boundary (same rules as read_file / write_file).
+
+ Same supported markdown subset and frontmatter convention as renderPdf.
+ """)
+ public String renderPdfFromFile(
+ @ToolParam(description = "Absolute or workspace-relative path to a markdown file")
+ String filePath,
+ @ToolParam(description = "Output filename without extension, e.g. 'q1-review'")
+ String filename,
+ @ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false)
+ String pageSize,
+ @ToolParam(description = "Engine: 'auto' (default), 'html', or 'libreoffice'", required = false)
+ String engine) {
+
+ Resolved input;
+ try {
+ input = MarkdownInputResolver.readSingle(filePath);
+ } catch (ResolveException e) {
+ return "Error: " + e.getMessage();
+ }
+
+ String displayName = FilenameSanitizer.sanitize(filename, "document", ".pdf") + ".pdf";
+ String size = resolveSize(pageSize);
+ PdfProperties.Engine eng = resolveEngine(engine);
+
+ try {
+ MarkdownPdfRenderer.Result result = renderer.render(input.markdown(), size, eng);
+ log.info("[PdfRender] generated {} ({} bytes via {} from {} bytes md)",
+ displayName, result.bytes().length, result.backend(), input.totalBytes());
+ return GeneratedFileLink.resultEn(result.bytes(), displayName, PDF_MIME, cache, "Document", 1);
+ } catch (Exception e) {
+ log.error("[PdfRender] render failed for {} (source: {}): {}",
+ displayName, input.sources().get(0), e.getMessage(), e);
+ return "Render failed: " + e.getMessage();
+ }
+ }
+
+ private static String resolveSize(String pageSize) {
+ return (pageSize == null || pageSize.isBlank()) ? "A4" : pageSize.trim();
+ }
+
+ private static PdfProperties.Engine resolveEngine(String engine) {
+ if (engine == null || engine.isBlank()) return PdfProperties.Engine.AUTO;
+ try {
+ return PdfProperties.Engine.valueOf(engine.trim().toUpperCase(Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ return PdfProperties.Engine.AUTO;
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/PptxRenderTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/PptxRenderTool.java
new file mode 100644
index 00000000..013c90f1
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/PptxRenderTool.java
@@ -0,0 +1,149 @@
+package vip.mate.tool.builtin;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.ai.tool.annotation.ToolParam;
+import org.springframework.stereotype.Component;
+import vip.mate.tool.document.FilenameSanitizer;
+import vip.mate.tool.document.GeneratedFileCache;
+import vip.mate.tool.document.GeneratedFileLink;
+import vip.mate.tool.document.MarkdownInputResolver;
+import vip.mate.tool.document.MarkdownInputResolver.Resolved;
+import vip.mate.tool.document.MarkdownInputResolver.ResolveException;
+import vip.mate.tool.document.MarkdownPptxRenderer;
+
+/**
+ * Render a brand-new .pptx deck from Markdown, in-process via Apache POI.
+ * The LLM produces a Marp-style markdown body where {@code ---} separates
+ * slides, {@code # / ## / ###} is the slide title, and {@code - item} are
+ * bullets.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PptxRenderTool {
+
+ private static final String PPTX_MIME =
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation";
+
+ private final MarkdownPptxRenderer renderer;
+ private final GeneratedFileCache cache;
+
+ @Tool(description = """
+ Render a NEW .pptx slide deck from Markdown and return a one-time download URL.
+ Use for creating presentations: pitch decks, project plans, talks, briefings.
+
+ Markdown convention (Marp-style):
+ - `---` on its own line separates slides.
+ - The first `# / ## / ###` of a slide becomes its title.
+ - Lines starting with `-` or `*` become bullet points.
+ - Other non-blank lines become plain paragraphs.
+ - `` HTML comments become speaker notes.
+
+ Example:
+ # My Presentation
+
+ By Author Name
+
+ ---
+
+ ## Topic 1
+
+ - Point one
+ - Point two
+ - Point three
+
+
+
+ ---
+
+ ## Conclusion
+
+ Thanks!
+
+ For markdown bodies larger than ~5 KB, prefer renderPptxFromFile (read
+ from disk) — passing huge markdown as a tool argument burns LLM tokens.
+
+ Returns a markdown link the user can click to download the file.
+ The link is valid for 10 minutes.
+ """)
+ public String renderPptx(
+ @ToolParam(description = "Slide content in Marp-style Markdown ('---' between slides)")
+ String markdown,
+ @ToolParam(description = "Output filename without extension, e.g. 'pitch-deck'")
+ String filename,
+ @ToolParam(description = "Aspect ratio: '16:9' (default, widescreen) or '4:3' (legacy)", required = false)
+ String aspectRatio) {
+
+ if (markdown == null || markdown.isBlank()) {
+ return "错误:markdown 参数为空,无法生成演示文稿。";
+ }
+
+ String displayName = FilenameSanitizer.sanitize(filename, "presentation", ".pptx") + ".pptx";
+ String ratio = resolveRatio(aspectRatio);
+
+ try {
+ long t0 = System.currentTimeMillis();
+ byte[] bytes = renderer.render(markdown, ratio);
+ log.info("[PptxRender] generated {} ({} bytes, {}ms)",
+ displayName, bytes.length, System.currentTimeMillis() - t0);
+ return GeneratedFileLink.resultZh(bytes, displayName, PPTX_MIME, cache, "演示文稿");
+ } catch (Exception e) {
+ log.error("[PptxRender] render failed for {}: {}", displayName, e.getMessage(), e);
+ return "渲染失败:" + e.getMessage();
+ }
+ }
+
+ @Tool(description = """
+ Render a .pptx deck from a markdown FILE on disk and return a one-time download URL.
+ Use this instead of `renderPptx` when the markdown body is large (>5 KB) — the
+ LLM does not need to repeat its own previous output as a tool argument.
+
+ Typical workflow:
+ 1. write_file(path="deck.md", content="# Title\\n\\n---\\n\\n## Topic\\n\\n- ...")
+ 2. renderPptxFromFile(filePath="deck.md", filename="pitch-deck")
+ 3. return the download link to the user
+
+ The markdown file is read with UTF-8. Path resolution honors the workspace
+ boundary (same rules as read_file / write_file).
+
+ Same supported Marp-style markdown subset as renderPptx (`---` slide breaks,
+ `# / ##` titles, `-` / `*` bullets, `` speaker notes).
+ """)
+ public String renderPptxFromFile(
+ @ToolParam(description = "Absolute or workspace-relative path to a markdown file")
+ String filePath,
+ @ToolParam(description = "Output filename without extension, e.g. 'pitch-deck'")
+ String filename,
+ @ToolParam(description = "Aspect ratio: '16:9' (default) or '4:3'", required = false)
+ String aspectRatio) {
+
+ Resolved input;
+ try {
+ input = MarkdownInputResolver.readSingle(filePath);
+ } catch (ResolveException e) {
+ return "Error: " + e.getMessage();
+ }
+
+ String displayName = FilenameSanitizer.sanitize(filename, "presentation", ".pptx") + ".pptx";
+ String ratio = resolveRatio(aspectRatio);
+
+ try {
+ long t0 = System.currentTimeMillis();
+ byte[] bytes = renderer.render(input.markdown(), ratio);
+ log.info("[PptxRender] generated {} ({} bytes from {} bytes md, {}ms)",
+ displayName, bytes.length, input.totalBytes(),
+ System.currentTimeMillis() - t0);
+ return GeneratedFileLink.resultEn(bytes, displayName, PPTX_MIME, cache, "Presentation", 1);
+ } catch (Exception e) {
+ log.error("[PptxRender] render failed for {} (source: {}): {}",
+ displayName, input.sources().get(0), e.getMessage(), e);
+ return "Render failed: " + e.getMessage();
+ }
+ }
+
+ private static String resolveRatio(String aspectRatio) {
+ return (aspectRatio == null || aspectRatio.isBlank()) ? "16:9" : aspectRatio.trim();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/XlsxRenderTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/XlsxRenderTool.java
new file mode 100644
index 00000000..c1f728e1
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/XlsxRenderTool.java
@@ -0,0 +1,133 @@
+package vip.mate.tool.builtin;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.ai.tool.annotation.ToolParam;
+import org.springframework.stereotype.Component;
+import vip.mate.tool.document.FilenameSanitizer;
+import vip.mate.tool.document.GeneratedFileCache;
+import vip.mate.tool.document.GeneratedFileLink;
+import vip.mate.tool.document.MarkdownInputResolver;
+import vip.mate.tool.document.MarkdownInputResolver.Resolved;
+import vip.mate.tool.document.MarkdownInputResolver.ResolveException;
+import vip.mate.tool.document.MarkdownXlsxRenderer;
+
+/**
+ * Render a brand-new .xlsx workbook from a Markdown body, in-process via
+ * Apache POI. Mirrors {@link DocxRenderTool}'s shape: the LLM produces a
+ * Markdown body where each {@code # Heading} starts a sheet and the pipe-style
+ * table beneath it becomes the sheet content.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class XlsxRenderTool {
+
+ private static final String XLSX_MIME =
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+
+ private final MarkdownXlsxRenderer renderer;
+ private final GeneratedFileCache cache;
+
+ @Tool(description = """
+ Render a NEW .xlsx workbook from Markdown and return a one-time download URL.
+ Use for creating spreadsheets: financial reports, data tables, comparison
+ matrices, plans, schedules.
+
+ Markdown convention:
+ - Each `# Sheet Name` starts a new sheet.
+ - The pipe-style table under the heading becomes the sheet body.
+ - The first table row is rendered as the header (bold, light-grey fill,
+ frozen). Numeric cells are auto-detected and stored as numbers so
+ Excel can sort / sum them; non-numeric cells stay as strings.
+ - Sub-headings (## / ###) and free-form prose are ignored — xlsx is
+ tabular and there is nowhere sensible to put them.
+
+ Example:
+ # Q1 Sales
+ | Region | Revenue | Growth |
+ | --- | --- | --- |
+ | North | 12000 | 0.15 |
+ | South | 8500 | 0.08 |
+
+ # Q2 Sales
+ | Region | Revenue |
+ | --- | --- |
+ | North | 14000 |
+
+ For markdown bodies larger than ~5 KB, prefer renderXlsxFromFile (read
+ from disk) — passing huge markdown as a tool argument burns LLM tokens.
+
+ Returns a markdown link the user can click to download the file.
+ The link is valid for 10 minutes.
+ """)
+ public String renderXlsx(
+ @ToolParam(description = "Workbook content in Markdown format (sheets as `# Heading`, tables as `| ... |`)")
+ String markdown,
+ @ToolParam(description = "Output filename without extension, e.g. 'q1-sales'")
+ String filename) {
+
+ if (markdown == null || markdown.isBlank()) {
+ return "错误:markdown 参数为空,无法生成工作簿。";
+ }
+
+ String displayName = FilenameSanitizer.sanitize(filename, "workbook", ".xlsx") + ".xlsx";
+
+ try {
+ long t0 = System.currentTimeMillis();
+ byte[] bytes = renderer.render(markdown);
+ log.info("[XlsxRender] generated {} ({} bytes, {}ms)",
+ displayName, bytes.length, System.currentTimeMillis() - t0);
+ return GeneratedFileLink.resultZh(bytes, displayName, XLSX_MIME, cache, "工作簿");
+ } catch (Exception e) {
+ log.error("[XlsxRender] render failed for {}: {}", displayName, e.getMessage(), e);
+ return "渲染失败:" + e.getMessage();
+ }
+ }
+
+ @Tool(description = """
+ Render a .xlsx workbook from a markdown FILE on disk and return a one-time download URL.
+ Use this instead of `renderXlsx` when the markdown body is large (>5 KB) — the
+ LLM does not need to repeat its own previous output as a tool argument.
+
+ Typical workflow:
+ 1. write_file(path="report.md", content="# Q1\\n| ... |\\n...")
+ 2. renderXlsxFromFile(filePath="report.md", filename="quarterly-report")
+ 3. return the download link to the user
+
+ The markdown file is read with UTF-8. Path resolution honors the workspace
+ boundary (same rules as read_file / write_file).
+
+ Same supported markdown subset as renderXlsx (`# Heading` per sheet,
+ pipe-style tables; numeric cells auto-detected).
+ """)
+ public String renderXlsxFromFile(
+ @ToolParam(description = "Absolute or workspace-relative path to a markdown file")
+ String filePath,
+ @ToolParam(description = "Output filename without extension, e.g. 'quarterly-report'")
+ String filename) {
+
+ Resolved input;
+ try {
+ input = MarkdownInputResolver.readSingle(filePath);
+ } catch (ResolveException e) {
+ return "Error: " + e.getMessage();
+ }
+
+ String displayName = FilenameSanitizer.sanitize(filename, "workbook", ".xlsx") + ".xlsx";
+
+ try {
+ long t0 = System.currentTimeMillis();
+ byte[] bytes = renderer.render(input.markdown());
+ log.info("[XlsxRender] generated {} ({} bytes from {} bytes md, {}ms)",
+ displayName, bytes.length, input.totalBytes(),
+ System.currentTimeMillis() - t0);
+ return GeneratedFileLink.resultEn(bytes, displayName, XLSX_MIME, cache, "Workbook", 1);
+ } catch (Exception e) {
+ log.error("[XlsxRender] render failed for {} (source: {}): {}",
+ displayName, input.sources().get(0), e.getMessage(), e);
+ return "Render failed: " + e.getMessage();
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/FilenameSanitizer.java b/mateclaw-server/src/main/java/vip/mate/tool/document/FilenameSanitizer.java
new file mode 100644
index 00000000..d7248d4f
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/FilenameSanitizer.java
@@ -0,0 +1,42 @@
+package vip.mate.tool.document;
+
+import java.util.Locale;
+
+/**
+ * Strip path separators and other characters that are illegal in download
+ * filenames from an LLM-supplied name. The LLM is allowed to suffix the
+ * extension itself (e.g. "report.docx") — {@link #sanitize} drops a known
+ * extension before sanitizing so callers can re-append it consistently.
+ */
+public final class FilenameSanitizer {
+
+ private FilenameSanitizer() {}
+
+ /**
+ * @param name candidate name from the LLM (may be null / blank / contain ext)
+ * @param fallback name to use when {@code name} is null, blank, or sanitizes to empty
+ * @param dropExt optional trailing extension to strip case-insensitively
+ * before sanitizing (e.g. {@code ".docx"}); pass {@code null}
+ * to skip
+ * @return a non-blank base name with no path separators or shell metacharacters
+ */
+ public static String sanitize(String name, String fallback, String dropExt) {
+ if (name == null) return fallback;
+ String trimmed = name.trim();
+ if (dropExt != null && !dropExt.isEmpty()
+ && trimmed.toLowerCase(Locale.ROOT).endsWith(dropExt.toLowerCase(Locale.ROOT))) {
+ trimmed = trimmed.substring(0, trimmed.length() - dropExt.length());
+ }
+ StringBuilder sb = new StringBuilder(trimmed.length());
+ for (char c : trimmed.toCharArray()) {
+ if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?'
+ || c == '"' || c == '<' || c == '>' || c == '|' || c < 0x20) {
+ sb.append('_');
+ } else {
+ sb.append(c);
+ }
+ }
+ String cleaned = sb.toString().strip();
+ return cleaned.isEmpty() ? fallback : cleaned;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java
new file mode 100644
index 00000000..06540941
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java
@@ -0,0 +1,58 @@
+package vip.mate.tool.document;
+
+/**
+ * Stash freshly-rendered bytes into the {@link GeneratedFileCache} and format
+ * the markdown link the tool returns to the LLM.
+ *
+ * Two locales are exposed because mateclaw's existing convention has the
+ * inline render tools speak Chinese and the file-driven render tools speak
+ * English. Each variant carries the "do NOT prepend a host" instruction
+ * because some models hallucinate a placeholder domain in front of the
+ * relative URL when echoing it back.
+ */
+public final class GeneratedFileLink {
+
+ private GeneratedFileLink() {}
+
+ /**
+ * Chinese-language tool result for inline render entry points
+ * ({@code renderDocx} / {@code renderXlsx} / {@code renderPptx}).
+ *
+ * @param typeLabel "文档" / "工作簿" / "演示文稿"
+ */
+ public static String resultZh(byte[] bytes, String displayName, String mimeType,
+ GeneratedFileCache cache, String typeLabel) {
+ String url = stash(bytes, displayName, mimeType, cache);
+ return typeLabel + "已生成:[" + displayName + "](" + url + ")(链接 10 分钟内有效)。\n"
+ + "重要:回答用户时**必须**使用上述相对路径 `" + url + "`,"
+ + "**不要**添加任何 https://、http:// 域名前缀,前端会自动拼接当前主机。";
+ }
+
+ /**
+ * English-language tool result for file-driven render entry points
+ * ({@code renderDocxFromFile} / {@code renderDocxFromFiles} / etc.).
+ *
+ * @param typeLabel "Document" / "Workbook" / "Presentation"
+ * @param sourceFileCount number of source markdown files combined into the
+ * artifact; values {@code > 1} produce a "from N files"
+ * prefix, {@code 1} produces the plain "generated" prefix
+ */
+ public static String resultEn(byte[] bytes, String displayName, String mimeType,
+ GeneratedFileCache cache, String typeLabel,
+ int sourceFileCount) {
+ String url = stash(bytes, displayName, mimeType, cache);
+ String prefix = sourceFileCount > 1
+ ? typeLabel + " generated from " + sourceFileCount + " files"
+ : typeLabel + " generated";
+ return prefix + ": [" + displayName + "](" + url + ") (link valid for 10 minutes).\n"
+ + "IMPORTANT: when replying to the user you **must** use the relative path `"
+ + url + "` verbatim. Do **not** prepend any https://, http:// or domain — "
+ + "the frontend will resolve the current host automatically.";
+ }
+
+ private static String stash(byte[] bytes, String displayName, String mimeType,
+ GeneratedFileCache cache) {
+ String id = cache.put(bytes, displayName, mimeType);
+ return "/api/v1/files/generated/" + id;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/MarkdownInputResolver.java b/mateclaw-server/src/main/java/vip/mate/tool/document/MarkdownInputResolver.java
new file mode 100644
index 00000000..a7a1caad
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/MarkdownInputResolver.java
@@ -0,0 +1,116 @@
+package vip.mate.tool.document;
+
+import vip.mate.tool.guard.WorkspacePathGuard;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Read one or more markdown files from the workspace, returning a single
+ * resolved record that the document-render tools can hand straight to a
+ * markdown-to-bytes renderer.
+ *
+ *
All path validation goes through {@link WorkspacePathGuard} so the LLM
+ * cannot escape the workspace boundary by passing {@code ../}-prefixed paths.
+ * Errors are signalled via {@link ResolveException} carrying a short message
+ * the tool layer surfaces verbatim to the model.
+ */
+public final class MarkdownInputResolver {
+
+ private MarkdownInputResolver() {}
+
+ public record Resolved(String markdown, List sources, long totalBytes) {
+ public int fileCount() {
+ return sources.size();
+ }
+ }
+
+ public static class ResolveException extends Exception {
+ public ResolveException(String message) { super(message); }
+ }
+
+ /** Read a single markdown file. */
+ public static Resolved readSingle(String filePath) throws ResolveException {
+ if (filePath == null || filePath.isBlank()) {
+ throw new ResolveException("filePath parameter is empty.");
+ }
+ Path resolved = validate(filePath, -1);
+ long size;
+ String content;
+ try {
+ size = Files.size(resolved);
+ content = Files.readString(resolved, StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ throw new ResolveException("failed to read markdown — " + e.getMessage());
+ }
+ if (content.isBlank()) {
+ throw new ResolveException("markdown file is empty " + resolved);
+ }
+ return new Resolved(content, List.of(resolved), size);
+ }
+
+ /**
+ * Read multiple markdown files in order and join them with one blank line
+ * between each. Used by the multi-chapter docx renderer so a long report
+ * can live in {@code cover.md} / {@code ch1.md} / {@code ch2.md} and still
+ * compile to a single document.
+ */
+ public static Resolved readManyJoined(List filePaths) throws ResolveException {
+ if (filePaths == null || filePaths.isEmpty()) {
+ throw new ResolveException("filePaths is empty.");
+ }
+ StringBuilder combined = new StringBuilder();
+ long totalBytes = 0;
+ List resolvedPaths = new ArrayList<>(filePaths.size());
+ for (int idx = 0; idx < filePaths.size(); idx++) {
+ String raw = filePaths.get(idx);
+ if (raw == null || raw.isBlank()) {
+ throw new ResolveException("filePaths[" + idx + "] is empty.");
+ }
+ Path resolved = validate(raw, idx);
+ String content;
+ try {
+ totalBytes += Files.size(resolved);
+ content = Files.readString(resolved, StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ throw new ResolveException(
+ "filePaths[" + idx + "] read failed — " + e.getMessage());
+ }
+ if (content.isBlank()) {
+ throw new ResolveException("filePaths[" + idx + "] is blank " + resolved);
+ }
+ if (combined.length() > 0) combined.append("\n\n");
+ combined.append(content);
+ resolvedPaths.add(resolved);
+ }
+ return new Resolved(combined.toString(), List.copyOf(resolvedPaths), totalBytes);
+ }
+
+ /**
+ * Resolve and validate a single path. {@code idx >= 0} formats errors as
+ * {@code filePaths[idx]: ...} for the multi-file caller; {@code idx < 0}
+ * uses the bare message form for the single-file caller.
+ */
+ private static Path validate(String raw, int idx) throws ResolveException {
+ Path resolved;
+ try {
+ resolved = WorkspacePathGuard.validatePath(raw);
+ } catch (Exception e) {
+ throw new ResolveException(prefix(idx) + "path validation failed — " + e.getMessage());
+ }
+ if (!Files.exists(resolved)) {
+ throw new ResolveException(prefix(idx) + "file not found at " + resolved);
+ }
+ if (!Files.isRegularFile(resolved) || !Files.isReadable(resolved)) {
+ throw new ResolveException(prefix(idx) + "path is not a readable regular file " + resolved);
+ }
+ return resolved;
+ }
+
+ private static String prefix(int idx) {
+ return idx < 0 ? "" : "filePaths[" + idx + "] ";
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/MarkdownPptxRenderer.java b/mateclaw-server/src/main/java/vip/mate/tool/document/MarkdownPptxRenderer.java
new file mode 100644
index 00000000..4efc3a53
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/MarkdownPptxRenderer.java
@@ -0,0 +1,206 @@
+package vip.mate.tool.document;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.poi.xslf.usermodel.XMLSlideShow;
+import org.apache.poi.xslf.usermodel.XSLFSlide;
+import org.apache.poi.xslf.usermodel.XSLFTextBox;
+import org.apache.poi.xslf.usermodel.XSLFTextParagraph;
+import org.apache.poi.xslf.usermodel.XSLFTextRun;
+import org.springframework.stereotype.Component;
+
+import java.awt.Dimension;
+import java.awt.Rectangle;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+/**
+ * Render a Markdown string into a PowerPoint .pptx byte array using Apache POI.
+ *
+ * Convention (Marp-compatible subset):
+ *
+ * - {@code ---} on its own line separates slides.
+ * - The first {@code # / ## / ###} of a slide becomes the slide title.
+ * - Lines starting with {@code - } or {@code * } become bullets.
+ * - Other non-blank lines become plain paragraphs.
+ * - {@code } HTML comments become speaker notes.
+ *
+ *
+ * Page size: 16:9 widescreen by default (960pt x 540pt). Pass
+ * {@code "4:3"} or {@code "STANDARD"} to {@link #render(String, String)} for
+ * legacy 4:3 (720pt x 540pt).
+ */
+@Slf4j
+@Component
+public class MarkdownPptxRenderer {
+
+ /** {@code ---} alone on a line separates slides (Marp / commonmark thematic break). */
+ private static final Pattern SLIDE_BREAK = Pattern.compile("^-{3,}\\s*$");
+
+ /** {@code # / ## / ###} title at the start of a slide. */
+ private static final Pattern HEADING = Pattern.compile("^(#{1,3})\\s+(.+)$");
+
+ /** Bullet item: {@code - foo} or {@code * foo}. */
+ private static final Pattern BULLET = Pattern.compile("^\\s*[-*]\\s+(.*)$");
+
+ /** Speaker note marker: {@code }. */
+ private static final Pattern SPEAKER_NOTE = Pattern.compile("^\\s*$");
+
+ private static final double TITLE_FONT_SIZE = 32.0;
+ private static final double BULLET_FONT_SIZE = 20.0;
+ private static final double PARAGRAPH_FONT_SIZE = 18.0;
+
+ public byte[] render(String markdown, String aspectRatio) throws IOException {
+ if (markdown == null) markdown = "";
+
+ try (XMLSlideShow ppt = new XMLSlideShow();
+ ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+
+ ppt.setPageSize(resolvePageSize(aspectRatio));
+
+ List slides = parseSlides(markdown);
+ if (slides.isEmpty()) {
+ // Always produce at least one slide so the file is openable.
+ slides.add(new SlideSpec(null, List.of(), null));
+ }
+
+ int width = (int) ppt.getPageSize().getWidth();
+ int height = (int) ppt.getPageSize().getHeight();
+ for (SlideSpec spec : slides) {
+ writeSlide(ppt, spec, width, height);
+ }
+
+ ppt.write(baos);
+ return baos.toByteArray();
+ }
+ }
+
+ private record SlideSpec(String title, List body, String speakerNote) {}
+
+ private record BodyLine(boolean bullet, String text) {}
+
+ private List parseSlides(String markdown) {
+ List result = new ArrayList<>();
+ String[] lines = markdown.split("\\R", -1);
+
+ String currentTitle = null;
+ List currentBody = new ArrayList<>();
+ StringBuilder currentNote = new StringBuilder();
+
+ for (String rawLine : lines) {
+ String line = rawLine.strip();
+ if (SLIDE_BREAK.matcher(line).matches()) {
+ if (currentTitle != null || !currentBody.isEmpty() || currentNote.length() > 0) {
+ result.add(new SlideSpec(
+ currentTitle, currentBody,
+ currentNote.length() == 0 ? null : currentNote.toString().strip()));
+ }
+ currentTitle = null;
+ currentBody = new ArrayList<>();
+ currentNote = new StringBuilder();
+ continue;
+ }
+
+ var noteMatch = SPEAKER_NOTE.matcher(line);
+ if (noteMatch.matches()) {
+ if (currentNote.length() > 0) currentNote.append('\n');
+ currentNote.append(noteMatch.group(1));
+ continue;
+ }
+
+ if (line.isEmpty()) {
+ if (!currentBody.isEmpty()) {
+ currentBody.add(new BodyLine(false, ""));
+ }
+ continue;
+ }
+
+ var headingMatch = HEADING.matcher(line);
+ if (headingMatch.matches() && currentTitle == null && currentBody.isEmpty()) {
+ currentTitle = headingMatch.group(2).strip();
+ continue;
+ }
+
+ var bulletMatch = BULLET.matcher(line);
+ if (bulletMatch.matches()) {
+ currentBody.add(new BodyLine(true, bulletMatch.group(1).strip()));
+ continue;
+ }
+
+ currentBody.add(new BodyLine(false, line));
+ }
+
+ if (currentTitle != null || !currentBody.isEmpty() || currentNote.length() > 0) {
+ result.add(new SlideSpec(
+ currentTitle, currentBody,
+ currentNote.length() == 0 ? null : currentNote.toString().strip()));
+ }
+ return result;
+ }
+
+ private void writeSlide(XMLSlideShow ppt, SlideSpec spec, int slideW, int slideH) {
+ XSLFSlide slide = ppt.createSlide();
+
+ int margin = 48;
+ int titleY = 36;
+ int titleH = spec.title() != null ? 80 : 0;
+ int bodyY = titleY + (titleH > 0 ? titleH + 12 : 0);
+ int bodyH = slideH - bodyY - margin;
+
+ if (spec.title() != null) {
+ XSLFTextBox titleBox = slide.createTextBox();
+ titleBox.setAnchor(new Rectangle(margin, titleY, slideW - margin * 2, titleH));
+ // POI creates text boxes with one empty paragraph + run; reuse it for the title.
+ XSLFTextParagraph titleP = titleBox.getTextParagraphs().get(0);
+ XSLFTextRun titleR = titleP.getTextRuns().isEmpty()
+ ? titleP.addNewTextRun()
+ : titleP.getTextRuns().get(0);
+ titleR.setText(spec.title());
+ titleR.setFontSize(TITLE_FONT_SIZE);
+ titleR.setBold(true);
+ }
+
+ if (!spec.body().isEmpty()) {
+ XSLFTextBox bodyBox = slide.createTextBox();
+ bodyBox.setAnchor(new Rectangle(margin + 12, bodyY, slideW - margin * 2 - 12, bodyH));
+ // Drop the default empty paragraph so our first body line lines up at the top.
+ bodyBox.clearText();
+
+ for (BodyLine bl : spec.body()) {
+ XSLFTextParagraph p = bodyBox.addNewTextParagraph();
+ if (bl.bullet()) {
+ p.setBullet(true);
+ p.setIndentLevel(0);
+ }
+ XSLFTextRun r = p.addNewTextRun();
+ r.setText(bl.text());
+ r.setFontSize(bl.bullet() ? BULLET_FONT_SIZE : PARAGRAPH_FONT_SIZE);
+ }
+ }
+
+ if (spec.speakerNote() != null && !spec.speakerNote().isBlank()) {
+ try {
+ slide.getNotes().getPlaceholder(0).setText(spec.speakerNote());
+ } catch (Exception e) {
+ log.debug("Failed to attach speaker note: {}", e.getMessage());
+ }
+ }
+ }
+
+ /**
+ * Resolve a user-supplied aspect-ratio string to a POI {@link Dimension}
+ * in points. The default (and value for any unrecognized input) is 16:9.
+ */
+ private Dimension resolvePageSize(String aspectRatio) {
+ if (aspectRatio == null) return new Dimension(960, 540);
+ String normalized = aspectRatio.trim().toUpperCase(Locale.ROOT);
+ return switch (normalized) {
+ case "4:3", "STANDARD" -> new Dimension(720, 540);
+ case "16:9", "WIDE", "WIDESCREEN", "" -> new Dimension(960, 540);
+ default -> new Dimension(960, 540);
+ };
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/MarkdownXlsxRenderer.java b/mateclaw-server/src/main/java/vip/mate/tool/document/MarkdownXlsxRenderer.java
new file mode 100644
index 00000000..d103ce44
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/MarkdownXlsxRenderer.java
@@ -0,0 +1,234 @@
+package vip.mate.tool.document;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.poi.ss.usermodel.BorderStyle;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellStyle;
+import org.apache.poi.ss.usermodel.FillPatternType;
+import org.apache.poi.ss.usermodel.Font;
+import org.apache.poi.ss.usermodel.HorizontalAlignment;
+import org.apache.poi.ss.usermodel.IndexedColors;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.springframework.stereotype.Component;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * Render a Markdown string into an Excel .xlsx byte array using Apache POI.
+ *
+ * Convention: each ATX H1 ({@code # Sheet Name}) starts a new sheet. The
+ * pipe-style table that follows becomes the sheet body. The first table row
+ * is treated as the header (bold, light-grey fill, frozen). Numeric-looking
+ * cells are stored as numbers; everything else is stored as a string.
+ *
+ *
Markdown without an explicit {@code # heading} produces a single sheet
+ * named {@code Sheet1}. Markdown without any {@code | table |} rows produces
+ * an empty workbook with one blank sheet (rendering still succeeds).
+ */
+@Slf4j
+@Component
+public class MarkdownXlsxRenderer {
+
+ /** Detects the markdown table separator row, e.g. {@code | --- | :---: |}. */
+ private static final Pattern TABLE_SEPARATOR =
+ Pattern.compile("^\\s*\\|?\\s*:?-{3,}:?\\s*(\\|\\s*:?-{3,}:?\\s*)+\\|?\\s*$");
+
+ /** Detects a sheet boundary {@code # Sheet Name}. ## / ### are NOT boundaries. */
+ private static final Pattern SHEET_BOUNDARY = Pattern.compile("^#\\s+(.+)$");
+
+ /** Cells that look like numbers (optional sign, digits, optional decimal). */
+ private static final Pattern NUMERIC = Pattern.compile("^-?\\d+(\\.\\d+)?$");
+
+ public byte[] render(String markdown) throws IOException {
+ if (markdown == null) markdown = "";
+
+ try (XSSFWorkbook wb = new XSSFWorkbook();
+ ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+
+ CellStyle headerStyle = buildHeaderStyle(wb);
+
+ List sheets = parseSheets(markdown);
+ if (sheets.isEmpty()) {
+ // Always produce a non-empty workbook so the file is openable.
+ wb.createSheet("Sheet1");
+ } else {
+ // Track names lowercased — Excel sheet uniqueness is
+ // case-insensitive ("Sales" and "sales" collide).
+ Set usedLower = new HashSet<>(sheets.size());
+ int seq = 1;
+ for (SheetSpec spec : sheets) {
+ String safe = sanitizeSheetName(spec.name(), seq++);
+ String unique = uniqueSheetName(safe, usedLower);
+ Sheet sheet = wb.createSheet(unique);
+ writeSheetBody(sheet, spec.rows(), headerStyle);
+ }
+ }
+
+ wb.write(baos);
+ return baos.toByteArray();
+ }
+ }
+
+ private record SheetSpec(String name, List> rows) {}
+
+ private List parseSheets(String markdown) {
+ List sheets = new ArrayList<>();
+ String currentName = null;
+ List> currentRows = new ArrayList<>();
+
+ for (String rawLine : markdown.split("\\R", -1)) {
+ String line = rawLine.strip();
+ if (line.isEmpty()) continue;
+
+ var sheetMatch = SHEET_BOUNDARY.matcher(line);
+ if (sheetMatch.matches()) {
+ if (currentName != null || !currentRows.isEmpty()) {
+ sheets.add(new SheetSpec(currentName, currentRows));
+ }
+ currentName = sheetMatch.group(1).strip();
+ currentRows = new ArrayList<>();
+ continue;
+ }
+
+ if (TABLE_SEPARATOR.matcher(line).matches()) {
+ continue;
+ }
+
+ // Strict markdown-table detection: a row must be wrapped in pipes,
+ // otherwise prose lines like "A | B 是数据库主键" or file paths like
+ // "src/main/java/Foo|Bar" would be silently swallowed into the sheet.
+ // GFM technically allows pipe-less leading/trailing pipes for tables,
+ // but the rendered LLM output overwhelmingly uses the wrapped form,
+ // and being strict avoids false positives that pollute the workbook.
+ if (line.startsWith("|") && line.endsWith("|") && line.length() >= 2) {
+ List cells = splitTableRow(line);
+ if (!cells.isEmpty()) {
+ currentRows.add(cells);
+ }
+ }
+ // Other content (paragraphs, sub-headings) is intentionally ignored —
+ // xlsx is tabular and there is nowhere sensible to render free prose.
+ }
+
+ if (currentName != null || !currentRows.isEmpty()) {
+ sheets.add(new SheetSpec(currentName, currentRows));
+ }
+ return sheets;
+ }
+
+ private List splitTableRow(String line) {
+ String trimmed = line.strip();
+ if (trimmed.startsWith("|")) trimmed = trimmed.substring(1);
+ if (trimmed.endsWith("|")) trimmed = trimmed.substring(0, trimmed.length() - 1);
+ String[] parts = trimmed.split("\\|", -1);
+ List cells = new ArrayList<>(parts.length);
+ for (String p : parts) cells.add(p.strip());
+ return cells;
+ }
+
+ private void writeSheetBody(Sheet sheet, List> rows, CellStyle headerStyle) {
+ if (rows.isEmpty()) return;
+
+ int maxCols = 0;
+ for (int r = 0; r < rows.size(); r++) {
+ List rowData = rows.get(r);
+ Row row = sheet.createRow(r);
+ for (int c = 0; c < rowData.size(); c++) {
+ Cell cell = row.createCell(c);
+ String value = rowData.get(c);
+ if (NUMERIC.matcher(value).matches()) {
+ cell.setCellValue(Double.parseDouble(value));
+ } else {
+ cell.setCellValue(value);
+ }
+ if (r == 0) cell.setCellStyle(headerStyle);
+ }
+ if (rowData.size() > maxCols) maxCols = rowData.size();
+ }
+
+ // Freeze the header row and auto-size columns. autoSizeColumn is O(n*m)
+ // but agent-generated workbooks are small, so the cost is negligible.
+ sheet.createFreezePane(0, 1);
+ for (int c = 0; c < maxCols; c++) {
+ try {
+ sheet.autoSizeColumn(c);
+ } catch (Exception e) {
+ log.debug("autoSizeColumn({}) failed (likely missing fonts on a headless host): {}",
+ c, e.getMessage());
+ }
+ }
+ }
+
+ private CellStyle buildHeaderStyle(XSSFWorkbook wb) {
+ CellStyle style = wb.createCellStyle();
+ Font font = wb.createFont();
+ font.setBold(true);
+ style.setFont(font);
+ style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
+ style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+ style.setAlignment(HorizontalAlignment.LEFT);
+ style.setBorderBottom(BorderStyle.THIN);
+ return style;
+ }
+
+ /**
+ * Resolve duplicate sheet names by appending {@code (2)}, {@code (3)}…
+ * within the 31-char Excel limit. POI throws on collision, which would
+ * otherwise abort the entire render when an LLM emits two sheets with the
+ * same heading or two long headings whose first 31 chars happen to match.
+ *
+ * Excel sheet uniqueness is case-INsensitive, so {@code "Sales"} and
+ * {@code "sales"} collide. We track names lowercased while still passing
+ * the original casing into {@link Sheet#createSheet(String)} — so the
+ * displayed tab keeps the user's casing.
+ */
+ private String uniqueSheetName(String candidate, Set usedLower) {
+ if (usedLower.add(candidate.toLowerCase(Locale.ROOT))) return candidate;
+ for (int i = 2; i < 1000; i++) {
+ String suffix = " (" + i + ")";
+ int maxBase = 31 - suffix.length();
+ String base = candidate.length() > maxBase
+ ? candidate.substring(0, maxBase)
+ : candidate;
+ String trial = base + suffix;
+ if (usedLower.add(trial.toLowerCase(Locale.ROOT))) return trial;
+ }
+ // Pathological: 1000 collisions. Fall back to a guaranteed-unique tag
+ // built from nanoTime so the render still succeeds.
+ String fallback = ("Sheet_" + System.nanoTime());
+ if (fallback.length() > 31) fallback = fallback.substring(0, 31);
+ usedLower.add(fallback.toLowerCase(Locale.ROOT));
+ return fallback;
+ }
+
+ /**
+ * Excel sheet names are limited to 31 chars and cannot contain {@code : / \ ? * [ ]},
+ * cannot be blank, and must be unique. Uniqueness is enforced separately by
+ * {@link #uniqueSheetName(String, Set)} so this method stays single-shot.
+ */
+ private String sanitizeSheetName(String raw, int seq) {
+ if (raw == null || raw.isBlank()) return "Sheet" + seq;
+ StringBuilder sb = new StringBuilder(raw.length());
+ for (char ch : raw.toCharArray()) {
+ if (ch == ':' || ch == '/' || ch == '\\' || ch == '?'
+ || ch == '*' || ch == '[' || ch == ']') {
+ sb.append('_');
+ } else {
+ sb.append(ch);
+ }
+ }
+ String cleaned = sb.toString().strip();
+ if (cleaned.isEmpty()) cleaned = "Sheet" + seq;
+ if (cleaned.length() > 31) cleaned = cleaned.substring(0, 31);
+ return cleaned;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/CjkFontResolver.java b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/CjkFontResolver.java
new file mode 100644
index 00000000..335dbfc7
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/CjkFontResolver.java
@@ -0,0 +1,112 @@
+package vip.mate.tool.document.pdf;
+
+import lombok.extern.slf4j.Slf4j;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+
+/**
+ * Locate a font file capable of rendering CJK text for {@link OpenHtmlToPdfBackend}.
+ *
+ * OpenHTMLtoPDF renders any glyph the registered font does not cover as a
+ * blank {@code .notdef} box, so a CJK-capable font is mandatory whenever the
+ * markdown contains Chinese, Japanese, or Korean text. We try in this order:
+ *
+ * - An explicit {@code mateclaw.pdf.font-path} configuration value.
+ * - A short list of OS-default paths that ship with macOS / Windows / common
+ * Linux distributions. The first existing file wins.
+ * - {@link Optional#empty()} — the renderer falls back to PDFBox's built-in
+ * Latin-only fonts, which renders Chinese as boxes; logged as a warning.
+ *
+ */
+@Slf4j
+public final class CjkFontResolver {
+
+ // .ttf candidates are listed FIRST because OpenPDF 2.0.5 (used by the
+ // FlyingSaucer PDF backend) cannot reliably read Apple-style .ttc font
+ // collections — it loads them without throwing, but the resulting
+ // BaseFont has an empty cmap and reports `charExists` as false even for
+ // ASCII. The PDF then renders as a blank page. .ttf collections do not
+ // share that limitation, so we try them first and only fall through to
+ // .ttc when nothing else is available. The runtime charExists check in
+ // FlyingSaucerPdfBackend will reject any candidate that loads but
+ // cannot actually render glyphs.
+
+ private static final String USER_HOME = System.getProperty("user.home", "");
+
+ private static final List CANDIDATES_MACOS = List.of(
+ // Popular open-source CJK .ttf fonts that users commonly install
+ USER_HOME + "/Library/Fonts/HarmonyOS_SansSC_Regular.ttf",
+ "/Library/Fonts/HarmonyOS_SansSC_Regular.ttf",
+ USER_HOME + "/Library/Fonts/SourceHanSansSC-Regular.otf",
+ "/Library/Fonts/SourceHanSansSC-Regular.otf",
+ USER_HOME + "/Library/Fonts/NotoSansSC-Regular.ttf",
+ "/Library/Fonts/NotoSansSC-Regular.ttf",
+ USER_HOME + "/Library/Fonts/Arial Unicode.ttf",
+ "/Library/Fonts/Arial Unicode.ttf",
+ // .ttc fallbacks — known to be lossy under OpenPDF on macOS,
+ // but listed so the resolver can still warn about them.
+ "/System/Library/Fonts/PingFang.ttc",
+ "/System/Library/Fonts/STHeiti Light.ttc",
+ "/System/Library/Fonts/STHeiti Medium.ttc",
+ "/Library/Fonts/Songti.ttc");
+
+ private static final List CANDIDATES_WINDOWS = List.of(
+ // Plain .ttf first, .ttc / .otf later
+ "C:/Windows/Fonts/msyh.ttf",
+ "C:/Windows/Fonts/simhei.ttf", // 黑体
+ "C:/Windows/Fonts/simsun.ttf",
+ "C:/Windows/Fonts/HarmonyOS_SansSC_Regular.ttf",
+ "C:/Windows/Fonts/NotoSansSC-Regular.ttf",
+ // Collections last
+ "C:/Windows/Fonts/msyh.ttc", // 微软雅黑
+ "C:/Windows/Fonts/simsun.ttc"); // 宋体
+
+ private static final List CANDIDATES_LINUX = List.of(
+ // Plain .ttf / .otf first
+ "/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
+ "/usr/share/fonts/truetype/noto/NotoSansSC-Regular.ttf",
+ "/usr/share/fonts/truetype/harmonyos-sans/HarmonyOS_SansSC_Regular.ttf",
+ "/usr/share/fonts/truetype/source-han-sans/SourceHanSansSC-Regular.otf",
+ // Collections last
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
+ "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
+ "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
+ "/usr/share/fonts/truetype/arphic/uming.ttc",
+ "/usr/share/fonts/truetype/arphic/ukai.ttc");
+
+ private CjkFontResolver() {}
+
+ public static Optional resolve(String configuredPath) {
+ if (configuredPath != null && !configuredPath.isBlank()) {
+ Path explicit = Paths.get(configuredPath.trim());
+ if (Files.isRegularFile(explicit)) {
+ log.debug("[CjkFont] using configured font: {}", explicit);
+ return Optional.of(explicit);
+ }
+ log.warn("[CjkFont] configured font path does not exist: {}", explicit);
+ }
+
+ for (String candidate : candidatesForCurrentOs()) {
+ Path p = Paths.get(candidate);
+ if (Files.isRegularFile(p)) {
+ log.debug("[CjkFont] auto-detected system font: {}", p);
+ return Optional.of(p);
+ }
+ }
+ log.warn("[CjkFont] no CJK font found on this host; PDF Chinese characters "
+ + "will render as blank boxes. Set mateclaw.pdf.font-path to override.");
+ return Optional.empty();
+ }
+
+ private static List candidatesForCurrentOs() {
+ String osName = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
+ if (osName.contains("mac")) return CANDIDATES_MACOS;
+ if (osName.contains("win")) return CANDIDATES_WINDOWS;
+ return CANDIDATES_LINUX;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/FlyingSaucerPdfBackend.java b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/FlyingSaucerPdfBackend.java
new file mode 100644
index 00000000..7f9cbae0
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/FlyingSaucerPdfBackend.java
@@ -0,0 +1,377 @@
+package vip.mate.tool.document.pdf;
+
+import com.lowagie.text.pdf.BaseFont;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.commonmark.ext.autolink.AutolinkExtension;
+import org.commonmark.ext.front.matter.YamlFrontMatterExtension;
+import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension;
+import org.commonmark.ext.gfm.tables.TablesExtension;
+import org.commonmark.node.Node;
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.html.HtmlRenderer;
+import org.springframework.stereotype.Component;
+import org.xhtmlrenderer.pdf.ITextFontResolver;
+import org.xhtmlrenderer.pdf.ITextRenderer;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+
+/**
+ * In-process PDF rendering: markdown → flexmark XHTML → Flying Saucer (XHTMLRenderer)
+ * → OpenPDF.
+ *
+ * This backend is always available and is the only one that supports cover
+ * pages, page headers, and page footers (driven by YAML frontmatter; see
+ * {@link PdfFrontmatter}). It uses CSS3 paged-media features that Flying Saucer
+ * implements: {@code @page}, {@code counter(page)}, {@code counter(pages)},
+ * {@code @top-center}, {@code @bottom-center}, and {@code page-break-before}.
+ *
+ *
Flying Saucer requires strict XHTML, so flexmark's HTML output is wrapped
+ * in an XHTML envelope. Self-closing void elements ({@code
}, {@code
},
+ * {@code
}) are normalised by flexmark when generating the body, so we do
+ * not need a post-processor.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class FlyingSaucerPdfBackend implements PdfBackend {
+
+ private final PdfProperties properties;
+
+ @Override
+ public String name() { return "flying-saucer"; }
+
+ @Override
+ public byte[] render(PdfRenderRequest request) throws Exception {
+ String bodyHtml = renderMarkdownToHtml(request.markdown());
+
+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+ ITextRenderer renderer = new ITextRenderer();
+ // Register the CJK font BEFORE building the HTML, because the CSS we
+ // emit references the font's actual family name (read from the font
+ // file). Aliases via ITextFontResolver's 5-arg overload proved
+ // unreliable on .ttc collections: the API accepts the override but
+ // the lookup map silently misses it, leaving the body to fall back
+ // to Times-Roman and Chinese to render as .notdef boxes.
+ String cjkFamily = registerCjkFont(renderer.getFontResolver());
+ String fullHtml = wrapHtml(bodyHtml, request, cjkFamily);
+ log.debug("[FlyingSaucerPdf] HTML length={}, body length={}, cjkFamily={}",
+ fullHtml.length(), bodyHtml.length(), cjkFamily);
+ try {
+ renderer.setDocumentFromString(fullHtml);
+ renderer.layout();
+ renderer.createPDF(baos);
+ } catch (Throwable t) {
+ log.error("[FlyingSaucerPdf] ITextRenderer failed: {}: {}",
+ t.getClass().getName(), t.getMessage(), t);
+ throw t;
+ }
+ return baos.toByteArray();
+ }
+ }
+
+ private String renderMarkdownToHtml(String markdown) {
+ List extensions = List.of(
+ TablesExtension.create(),
+ StrikethroughExtension.create(),
+ AutolinkExtension.create(),
+ YamlFrontMatterExtension.create());
+ Parser parser = Parser.builder().extensions(extensions).build();
+ // Flying Saucer requires strict XHTML, so void elements (
,
,
+ //
) must be self-closed. The xhtml renderer flavour does this.
+ HtmlRenderer renderer = HtmlRenderer.builder()
+ .extensions(extensions)
+ .build();
+ Node document = parser.parse(markdown);
+ return renderer.render(document);
+ }
+
+ /**
+ * Register the resolved CJK font with Flying Saucer and return the
+ * font's actual {@code font-family} name so the inline stylesheet can
+ * reference it. Returns {@code null} if no font was found or the
+ * registration failed — callers must tolerate Chinese rendering as
+ * blank boxes in that case.
+ *
+ * Why we read the real family name instead of using the 5-arg
+ * {@code addFont(... fontFamilyNameOverride ...)} overload: that override
+ * succeeds in the call but does not get added to the renderer's
+ * {@code _fontFamilies} lookup map for {@code .ttc} collections, so the
+ * CSS declaration {@code font-family: "CJK"} still misses and the body
+ * falls back to Times-Roman. Reading the font's intrinsic family name
+ * via OpenPDF's {@link BaseFont#getFamilyFontName()} sidesteps that
+ * map entirely.
+ */
+ private String registerCjkFont(ITextFontResolver fonts) {
+ Optional fontPath = CjkFontResolver.resolve(properties.fontPath());
+ if (fontPath.isEmpty()) {
+ log.error("[FlyingSaucerPdf] No CJK font registered. Chinese characters "
+ + "in this PDF will render as blank boxes. Set mateclaw.pdf.font-path "
+ + "to the absolute path of a CJK-capable .ttf / .ttc / .otf file.");
+ return null;
+ }
+ // BaseFont.IDENTITY_H + EMBEDDED is what makes CJK actually appear in
+ // the output PDF — without IDENTITY_H glyph indexing, Chinese characters
+ // render as blanks even when the font file is found.
+ //
+ // OpenPDF 2.0.5 has a known weakness with Apple-style .ttc font
+ // collections (PingFang.ttc, STHeiti.ttc, Songti.ttc on macOS): the
+ // load succeeds but the cmap is empty, charExists returns false even
+ // for ASCII, and the rendered PDF is a blank page. We probe the font
+ // with charExists below; if it cannot render the characters we need,
+ // we DO NOT register it and return null so the document keeps
+ // falling back to the next family in the CSS chain.
+ String fontKey = fontFileWithSubfontIndex(fontPath.get());
+ BaseFont probe;
+ try {
+ probe = BaseFont.createFont(fontKey, BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
+ } catch (Throwable t) {
+ log.error("[FlyingSaucerPdf] BaseFont.createFont failed for {} — Chinese "
+ + "will render as blank boxes. {}: {}",
+ fontKey, t.getClass().getSimpleName(), t.getMessage());
+ return null;
+ }
+ if (!probe.charExists('你') || !probe.charExists('A')) {
+ log.error("[FlyingSaucerPdf] Font {} loaded but cmap is empty "
+ + "(charExists '你'={} 'A'={}). This is the known OpenPDF Apple-.ttc "
+ + "limitation — install a .ttf CJK font (e.g. HarmonyOS Sans SC, "
+ + "Noto Sans SC) and either drop it under ~/Library/Fonts/ or set "
+ + "mateclaw.pdf.font-path to its absolute path.",
+ fontKey, probe.charExists('你'), probe.charExists('A'));
+ return null;
+ }
+ String realFamily = readFamilyName(probe, fontKey);
+ try {
+ fonts.addFont(fontKey, BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
+ log.info("[FlyingSaucerPdf] registered CJK font: {} (family=\"{}\", cmap OK)",
+ fontKey, realFamily);
+ return realFamily;
+ } catch (Exception e) {
+ log.error("[FlyingSaucerPdf] failed to register CJK font {} — Chinese "
+ + "characters in this PDF will render as blank boxes. {}: {}",
+ fontKey, e.getClass().getSimpleName(), e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Pull a usable family name out of the loaded font. Some fonts
+ * (HarmonyOS Sans SC) leave {@code getFamilyFontName} empty and
+ * carry the name only in {@code getPostscriptFontName}, so we fall
+ * back to that.
+ */
+ private static String readFamilyName(BaseFont probe, String fontKey) {
+ try {
+ String[][] familyNames = probe.getFamilyFontName();
+ if (familyNames != null && familyNames.length > 0) {
+ String fallback = null;
+ for (String[] row : familyNames) {
+ if (row == null || row.length < 4 || row[3] == null || row[3].isBlank()) continue;
+ if (fallback == null) fallback = row[3];
+ if ("3".equals(row[0]) && "1033".equals(row[2])) {
+ return row[3];
+ }
+ }
+ if (fallback != null) return fallback;
+ }
+ String psName = probe.getPostscriptFontName();
+ if (psName != null && !psName.isBlank()) return psName;
+ } catch (Throwable t) {
+ log.warn("[FlyingSaucerPdf] could not read family name from {}: {}",
+ fontKey, t.getMessage());
+ }
+ return "Helvetica"; // benign fallback
+ }
+
+ private static String fontFileWithSubfontIndex(Path path) {
+ String name = path.getFileName().toString().toLowerCase(Locale.ROOT);
+ if (name.endsWith(".ttc") || name.endsWith(".otc")) {
+ return path.toString() + ",0";
+ }
+ return path.toString();
+ }
+
+ /**
+ * Wrap the rendered markdown body in an XHTML envelope plus a CSS @page
+ * stylesheet that drives cover / header / footer / page numbers.
+ *
+ * @param cjkFamily the actual family name of the registered CJK font as
+ * reported by OpenPDF, or {@code null} if no font was
+ * registered. Injected verbatim into the body
+ * {@code font-family} declaration; when absent we fall
+ * through directly to Helvetica.
+ */
+ private String wrapHtml(String bodyHtml, PdfRenderRequest request, String cjkFamily) {
+ PdfFrontmatter fm = request.frontmatter();
+ String pageSize = request.pageSize();
+ String cjkFamilyDecl = cjkFamily == null
+ ? ""
+ : "\"" + cssEscape(cjkFamily) + "\", ";
+
+ // Only render a real cover page when the user explicitly asked for one
+ // via YAML frontmatter. A synthesised cover (H1 promoted into title)
+ // would otherwise duplicate the heading: once on the cover and again
+ // as the first body H1.
+ String coverHtml = fm.hasExplicitCover()
+ ? ""
+ + "
" + escape(fm.title()) + "
"
+ + (fm.subtitleOpt().isPresent()
+ ? "
" + escape(fm.subtitle()) + "
"
+ : "")
+ + "
"
+ : "";
+
+ // Page margin boxes do NOT inherit `font-family` from body — Flying
+ // Saucer treats them as detached generated content boxes. If we don't
+ // give them a CJK-capable font here, header/footer Chinese characters
+ // silently drop ("Tech Daily · 每日科技精选" → "Tech Daily ·") because
+ // the default Helvetica has no CJK glyphs. We thread the same family
+ // we registered for body text through here so the rendering is
+ // consistent across the document.
+ String marginBoxFontDecl = "font-family: " + cjkFamilyDecl
+ + "\"Helvetica\", sans-serif; font-size: 9pt; color: #888;";
+ String headerCss = fm.hasHeader()
+ ? "@top-center { content: \"" + cssEscape(fm.header()) + "\"; "
+ + marginBoxFontDecl + " }"
+ : "";
+ String footerCss = "@bottom-center { content: " + footerContent(fm)
+ + "; " + marginBoxFontDecl + " }";
+
+ // No : Flying Saucer's default EntityResolver tries to fetch
+ // the W3C XHTML DTD over the network during setDocumentFromString().
+ // On any host with no internet (or with W3C throttling) the document
+ // load silently fails and we emit a 1.3 KB blank PDF. Plain XHTML
+ // without a DOCTYPE renders just fine.
+ return """
+
+
+
+ document
+
+
+
+ %s
+ %s
+
+
+ """.formatted(pageSize, headerCss, footerCss, cjkFamilyDecl, coverHtml, bodyHtml);
+ }
+
+ private String footerContent(PdfFrontmatter fm) {
+ // Always show page numbers; concatenate user footer ahead if provided.
+ String pageCounter = "\"" + cssEscape("第 ") + "\" counter(page) "
+ + "\" / \" counter(pages) \"" + cssEscape(" 页") + "\"";
+ if (fm.hasFooter()) {
+ return "\"" + cssEscape(fm.footer()) + " \" " + pageCounter;
+ }
+ return pageCounter;
+ }
+
+ /** Escape user text for placement inside an HTML element. */
+ private String escape(String s) {
+ if (s == null) return "";
+ return s.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """);
+ }
+
+ /** Escape user text for placement inside a CSS string literal. */
+ private String cssEscape(String s) {
+ if (s == null) return "";
+ return s.replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\n", " ");
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/LibreOfficePdfBackend.java b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/LibreOfficePdfBackend.java
new file mode 100644
index 00000000..58424340
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/LibreOfficePdfBackend.java
@@ -0,0 +1,146 @@
+package vip.mate.tool.document.pdf;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import vip.mate.tool.document.MarkdownDocxRenderer;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Render PDF by routing markdown through {@link MarkdownDocxRenderer} and
+ * then handing the docx to a {@code soffice --convert-to pdf} subprocess.
+ * LibreOffice's typesetter beats anything we can write in-process for plain
+ * narrative text, especially with mixed CJK + Latin scripts, so this is the
+ * preferred path when the local install has it.
+ *
+ * Limitations the orchestrator must respect:
+ *
+ * - The intermediate docx has no first-class cover page, page header,
+ * or page footer the way {@link OpenHtmlToPdfBackend} does. Calls that
+ * want those features go to the HTML path instead — see
+ * {@link #supports(PdfRenderRequest)}.
+ * - Page numbers themselves come for free: LibreOffice adds them by
+ * default during PDF export.
+ *
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class LibreOfficePdfBackend implements PdfBackend {
+
+ private static final long CONVERT_TIMEOUT_SECONDS = 90;
+
+ private final MarkdownDocxRenderer docxRenderer;
+ private final PdfProperties properties;
+
+ @Override
+ public String name() { return "libreoffice"; }
+
+ @Override
+ public boolean isAvailable() {
+ if (!properties.libreoffice().enabled()) return false;
+ try {
+ ProcessBuilder pb = new ProcessBuilder(properties.libreoffice().binary(), "--version");
+ pb.redirectErrorStream(true);
+ Process p = pb.start();
+ // Drain stdout so the child can exit even on systems whose pipe buffers
+ // are tiny; the version string is short, this won't block.
+ p.getInputStream().readAllBytes();
+ boolean finished = p.waitFor(5, TimeUnit.SECONDS);
+ if (!finished) {
+ p.destroyForcibly();
+ return false;
+ }
+ return p.exitValue() == 0;
+ } catch (Exception e) {
+ log.debug("[LibreOfficePdf] soffice probe failed: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * The docx intermediate cannot carry page headers / footers / an explicit
+ * cover page, so we decline requests that need those. The orchestrator
+ * routes such requests to {@link FlyingSaucerPdfBackend} instead.
+ *
+ * Synthetic covers (an H1 that {@code parseOrSynthesise} promoted into a
+ * cover title) are NOT rejected — those would otherwise force AUTO mode to
+ * pick the in-process backend for almost every markdown body, since LLM
+ * output overwhelmingly starts with a {@code # H1}. The H1 will simply
+ * render as the document's first heading, which is what users expect when
+ * they didn't ask for a cover explicitly.
+ */
+ @Override
+ public boolean supports(PdfRenderRequest request) {
+ PdfFrontmatter fm = request.frontmatter();
+ return !fm.hasExplicitCover() && !fm.hasHeader() && !fm.hasFooter();
+ }
+
+ @Override
+ public byte[] render(PdfRenderRequest request) throws Exception {
+ // Use the same A4/LETTER page-size argument shape MarkdownDocxRenderer expects.
+ byte[] docxBytes = docxRenderer.render(request.markdown(), request.pageSize());
+
+ Path tempDir = Files.createTempDirectory("mc_pdf_");
+ try {
+ Path docxFile = tempDir.resolve("input.docx");
+ Files.write(docxFile, docxBytes);
+
+ ProcessBuilder pb = new ProcessBuilder(
+ properties.libreoffice().binary(),
+ "--headless",
+ "--convert-to", "pdf",
+ "--outdir", tempDir.toString(),
+ docxFile.toString());
+ pb.redirectErrorStream(true);
+ Process p = pb.start();
+ byte[] stderr = p.getInputStream().readAllBytes();
+ boolean finished = p.waitFor(CONVERT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ if (!finished) {
+ p.destroyForcibly();
+ throw new IOException("soffice conversion timed out after " + CONVERT_TIMEOUT_SECONDS + "s");
+ }
+ if (p.exitValue() != 0) {
+ throw new IOException("soffice exit " + p.exitValue() + ": "
+ + new String(stderr).strip());
+ }
+
+ Path pdfFile = tempDir.resolve("input.pdf");
+ if (!Files.isRegularFile(pdfFile)) {
+ throw new IOException("soffice produced no PDF (stderr: "
+ + new String(stderr).strip() + ")");
+ }
+ return Files.readAllBytes(pdfFile);
+ } finally {
+ cleanup(tempDir);
+ }
+ }
+
+ private void cleanup(Path tempDir) {
+ try (var stream = Files.walk(tempDir)) {
+ List entries = stream.sorted(Comparator.reverseOrder()).toList();
+ for (Path entry : entries) {
+ try {
+ Files.deleteIfExists(entry);
+ } catch (IOException ignored) {
+ // Best-effort cleanup; the temp dir lives inside java.io.tmpdir
+ // and will be reclaimed by the OS on next reboot if we lose the race.
+ }
+ }
+ } catch (IOException ignored) {
+ // ditto
+ }
+ // Suppress IDE warning about unused parameter when File.delete fails silently.
+ File f = tempDir.toFile();
+ if (f.exists() && !f.delete()) {
+ log.debug("[LibreOfficePdf] could not delete temp dir {}", tempDir);
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/MarkdownPdfRenderer.java b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/MarkdownPdfRenderer.java
new file mode 100644
index 00000000..b169bb5b
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/MarkdownPdfRenderer.java
@@ -0,0 +1,74 @@
+package vip.mate.tool.document.pdf;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * Orchestrate PDF rendering. Picks a {@link PdfBackend} based on the caller's
+ * engine preference, the backend's {@link PdfBackend#isAvailable()} probe, and
+ * its {@link PdfBackend#supports(PdfRenderRequest)} declaration. Both backends
+ * receive a normalised {@link PdfRenderRequest} so they don't have to redo
+ * frontmatter parsing or page-size defaulting.
+ *
+ * Dispatch table:
+ *
+ * engine=AUTO + libreoffice ok + supports request → libreoffice
+ * engine=AUTO + libreoffice missing OR can't do header/footer → openhtmltopdf
+ * engine=LIBREOFFICE + supports → libreoffice (else throw)
+ * engine=HTML → openhtmltopdf
+ *
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+@EnableConfigurationProperties(PdfProperties.class)
+public class MarkdownPdfRenderer {
+
+ private final LibreOfficePdfBackend libreOffice;
+ private final FlyingSaucerPdfBackend html;
+ private final PdfProperties properties;
+
+ public record Result(byte[] bytes, String backend) {}
+
+ public Result render(String markdown, String pageSize, PdfProperties.Engine engine) throws Exception {
+ if (engine == null) engine = properties.defaultEngine();
+
+ PdfFrontmatter fm = PdfFrontmatter.parseOrSynthesise(markdown);
+ String body = PdfFrontmatter.stripFrontmatter(markdown);
+ PdfRenderRequest request = new PdfRenderRequest(body, fm, pageSize, engine);
+
+ PdfBackend chosen = pick(request);
+ long t0 = System.currentTimeMillis();
+ byte[] bytes = chosen.render(request);
+ log.info("[Pdf] rendered via {} ({} bytes, {}ms, frontmatter cover={} header={} footer={})",
+ chosen.name(), bytes.length, System.currentTimeMillis() - t0,
+ fm.hasCover(), fm.hasHeader(), fm.hasFooter());
+ return new Result(bytes, chosen.name());
+ }
+
+ private PdfBackend pick(PdfRenderRequest request) {
+ return switch (request.engine()) {
+ case LIBREOFFICE -> {
+ if (!libreOffice.isAvailable()) {
+ throw new IllegalStateException(
+ "engine=libreoffice but soffice is not available on PATH");
+ }
+ if (!libreOffice.supports(request)) {
+ throw new IllegalStateException(
+ "engine=libreoffice but the request needs cover/header/footer; "
+ + "use engine=html or remove those frontmatter fields");
+ }
+ yield libreOffice;
+ }
+ case HTML -> html;
+ case AUTO -> {
+ if (libreOffice.isAvailable() && libreOffice.supports(request)) {
+ yield libreOffice;
+ }
+ yield html;
+ }
+ };
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/PdfBackend.java b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/PdfBackend.java
new file mode 100644
index 00000000..d5feb404
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/PdfBackend.java
@@ -0,0 +1,29 @@
+package vip.mate.tool.document.pdf;
+
+/**
+ * One way to turn markdown bytes into PDF bytes. {@link MarkdownPdfRenderer}
+ * picks an implementation at request time based on availability and the
+ * caller's {@link PdfRenderRequest#engine()} preference.
+ */
+public interface PdfBackend {
+
+ /** Stable identifier surfaced in the tool result and in logs. */
+ String name();
+
+ /**
+ * Whether this backend can run at all on the current host. The default
+ * implementation says yes; the LibreOffice backend overrides this to
+ * probe for {@code soffice}.
+ */
+ default boolean isAvailable() { return true; }
+
+ /**
+ * Whether this backend can faithfully render the request. The HTML
+ * backend always returns {@code true}; the LibreOffice backend declines
+ * requests that need cover / header / footer because those features
+ * cannot be expressed through the docx intermediate.
+ */
+ default boolean supports(PdfRenderRequest request) { return true; }
+
+ byte[] render(PdfRenderRequest request) throws Exception;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/PdfFrontmatter.java b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/PdfFrontmatter.java
new file mode 100644
index 00000000..fb752038
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/PdfFrontmatter.java
@@ -0,0 +1,165 @@
+package vip.mate.tool.document.pdf;
+
+import org.commonmark.ext.front.matter.YamlFrontMatterExtension;
+import org.commonmark.ext.front.matter.YamlFrontMatterVisitor;
+import org.commonmark.node.Node;
+import org.commonmark.parser.Parser;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Extract the YAML frontmatter block at the top of a markdown body so the PDF
+ * pipeline can drive cover / page header / page footer text from it. The
+ * frontmatter block — when present — has the form:
+ *
+ * ---
+ * title: 季度报告
+ * subtitle: Q1 2026
+ * header: 内部资料
+ * footer: Mate Inc. © 2026
+ * ---
+ *
+ *
+ * Markdown without frontmatter parses to {@link #empty()}; the renderer
+ * then synthesises a cover from the first {@code # H1} heading and uses
+ * default header / footer text.
+ */
+public record PdfFrontmatter(
+ String title,
+ String subtitle,
+ String header,
+ String footer,
+ boolean explicitCover) {
+
+ /**
+ * Backwards-compat constructor for callers that only know about the four
+ * text slots; the cover-source flag defaults to {@code false} (synthetic).
+ */
+ public PdfFrontmatter(String title, String subtitle, String header, String footer) {
+ this(title, subtitle, header, footer, false);
+ }
+
+ public boolean hasCover() {
+ return notBlank(title) || notBlank(subtitle);
+ }
+
+ /**
+ * Whether the cover came from a YAML frontmatter block (true) or was
+ * synthesised by promoting a leading {@code # H1} into a cover title (false).
+ * Synthetic covers are not real layout requirements — the LibreOffice
+ * backend can ignore them and render the H1 inline as part of the document.
+ */
+ public boolean hasExplicitCover() {
+ return explicitCover && hasCover();
+ }
+
+ public boolean hasHeader() {
+ return notBlank(header);
+ }
+
+ public boolean hasFooter() {
+ return notBlank(footer);
+ }
+
+ /** Whether ANY of the frontmatter slots is populated. */
+ public boolean isPresent() {
+ return hasCover() || hasHeader() || hasFooter();
+ }
+
+ public static PdfFrontmatter empty() {
+ return new PdfFrontmatter(null, null, null, null, false);
+ }
+
+ public static PdfFrontmatter parse(String markdown) {
+ if (markdown == null || markdown.isBlank()) return empty();
+
+ Parser parser = Parser.builder()
+ .extensions(List.of(YamlFrontMatterExtension.create()))
+ .build();
+ Node document = parser.parse(markdown);
+
+ YamlFrontMatterVisitor visitor = new YamlFrontMatterVisitor();
+ document.accept(visitor);
+ Map> data = visitor.getData();
+ if (data == null || data.isEmpty()) return empty();
+
+ return new PdfFrontmatter(
+ first(data, "title"),
+ first(data, "subtitle"),
+ first(data, "header"),
+ first(data, "footer"),
+ /* explicitCover = */ true);
+ }
+
+ private static String first(Map> data, String key) {
+ List values = data.get(key);
+ if (values == null || values.isEmpty()) return null;
+ String v = values.get(0);
+ if (v == null) return null;
+ // YAML scalar values come back with surrounding quotes preserved when the
+ // user wrote `title: "..."`. Strip a single matching pair so the rendered
+ // cover doesn't show literal quote characters.
+ v = v.trim();
+ if ((v.startsWith("\"") && v.endsWith("\"") && v.length() >= 2)
+ || (v.startsWith("'") && v.endsWith("'") && v.length() >= 2)) {
+ v = v.substring(1, v.length() - 1);
+ }
+ return v;
+ }
+
+ private static boolean notBlank(String s) {
+ return s != null && !s.isBlank();
+ }
+
+ /**
+ * Convenience: read frontmatter, if missing look for a leading {@code # H1}
+ * to use as the cover title. The synthesised result is flagged with
+ * {@code explicitCover=false} so backends that cannot render an actual
+ * cover page (LibreOffice via the docx intermediate) can safely ignore it
+ * — the H1 will still render as the first heading inline.
+ */
+ public static PdfFrontmatter parseOrSynthesise(String markdown) {
+ PdfFrontmatter fm = parse(markdown);
+ if (fm.hasCover()) return fm;
+
+ String firstHeading = firstHeading(markdown);
+ if (firstHeading != null) {
+ return new PdfFrontmatter(firstHeading, fm.subtitle(), fm.header(), fm.footer(),
+ /* explicitCover = */ false);
+ }
+ return fm;
+ }
+
+ private static String firstHeading(String markdown) {
+ for (String rawLine : markdown.split("\\R", -1)) {
+ String line = rawLine.strip();
+ if (line.startsWith("# ") && line.length() > 2) {
+ return line.substring(2).strip();
+ }
+ }
+ return null;
+ }
+
+ /** Strip a leading YAML frontmatter block from a markdown body. */
+ public static String stripFrontmatter(String markdown) {
+ if (markdown == null) return "";
+ String trimmed = markdown.stripLeading();
+ if (!trimmed.startsWith("---")) return markdown;
+ int firstBreak = trimmed.indexOf('\n');
+ if (firstBreak < 0) return markdown;
+ int closing = trimmed.indexOf("\n---", firstBreak);
+ if (closing < 0) return markdown;
+ int after = trimmed.indexOf('\n', closing + 4);
+ return after < 0 ? "" : trimmed.substring(after + 1);
+ }
+
+ /** Try to find {@link Optional} variant for callers preferring null-safe accessors. */
+ public Optional titleOpt() { return Optional.ofNullable(title).filter(PdfFrontmatter::nb); }
+ public Optional subtitleOpt() { return Optional.ofNullable(subtitle).filter(PdfFrontmatter::nb); }
+ public Optional headerOpt() { return Optional.ofNullable(header).filter(PdfFrontmatter::nb); }
+ public Optional footerOpt() { return Optional.ofNullable(footer).filter(PdfFrontmatter::nb); }
+
+ private static boolean nb(String s) { return !s.isBlank(); }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/PdfProperties.java b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/PdfProperties.java
new file mode 100644
index 00000000..0962c266
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/PdfProperties.java
@@ -0,0 +1,44 @@
+package vip.mate.tool.document.pdf;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Configuration for the markdown-to-PDF rendering pipeline.
+ *
+ * Example {@code application.yml}:
+ *
+ * mateclaw:
+ * pdf:
+ * fontPath: /Library/Fonts/Songti.ttc
+ * defaultEngine: AUTO
+ * libreoffice:
+ * enabled: true
+ * binary: soffice
+ *
+ */
+@ConfigurationProperties(prefix = "mateclaw.pdf")
+public record PdfProperties(
+ String fontPath,
+ Engine defaultEngine,
+ Libreoffice libreoffice) {
+
+ public PdfProperties {
+ if (defaultEngine == null) defaultEngine = Engine.AUTO;
+ if (libreoffice == null) libreoffice = new Libreoffice(true, "soffice");
+ }
+
+ public enum Engine {
+ /** Try LibreOffice first, fall back to OpenHTMLtoPDF. */
+ AUTO,
+ /** Force the LibreOffice subprocess path. Fails if soffice is missing. */
+ LIBREOFFICE,
+ /** Force the in-process OpenHTMLtoPDF path. */
+ HTML
+ }
+
+ public record Libreoffice(boolean enabled, String binary) {
+ public Libreoffice {
+ if (binary == null || binary.isBlank()) binary = "soffice";
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/PdfRenderRequest.java b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/PdfRenderRequest.java
new file mode 100644
index 00000000..8f8920bf
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/PdfRenderRequest.java
@@ -0,0 +1,28 @@
+package vip.mate.tool.document.pdf;
+
+/**
+ * Request payload handed to a {@link PdfBackend}. The orchestrator builds
+ * this once per call after parsing frontmatter and resolving page size; the
+ * backends are read-only consumers.
+ *
+ * @param markdown markdown body with the YAML frontmatter block already stripped
+ * @param frontmatter parsed (or synthesised from a leading {@code # H1}) frontmatter
+ * @param pageSize "A4" or "LETTER"
+ * @param engine the engine preference the caller gave; the orchestrator
+ * uses this to decide which backend to ask, but each backend
+ * only sees the request after the choice has been made and
+ * may largely ignore the field
+ */
+public record PdfRenderRequest(
+ String markdown,
+ PdfFrontmatter frontmatter,
+ String pageSize,
+ PdfProperties.Engine engine) {
+
+ public PdfRenderRequest {
+ if (markdown == null) markdown = "";
+ if (frontmatter == null) frontmatter = PdfFrontmatter.empty();
+ if (pageSize == null || pageSize.isBlank()) pageSize = "A4";
+ if (engine == null) engine = PdfProperties.Engine.AUTO;
+ }
+}
diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql
index 320e533f..5dc5311a 100644
--- a/mateclaw-server/src/main/resources/db/data-en.sql
+++ b/mateclaw-server/src/main/resources/db/data-en.sql
@@ -482,6 +482,21 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name,
KEY (id)
VALUES (1000000019, 'DocxRenderTool', 'DOCX Render', 'Render Markdown directly into a .docx and return a one-time download link. In-process Apache POI implementation, no Node.js subprocess; supports headings, bold, lists, tables. Preferred tool for creating new documents.', 'builtin', 'docxRenderTool', '📝', TRUE, TRUE, NOW(), NOW(), 0);
+-- Built-in tool: XLSX Render (in-process Apache POI; markdown tables -> multi-sheet workbook)
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000020, 'XlsxRenderTool', 'XLSX Render', 'Render Markdown directly into a .xlsx workbook and return a one-time download link. In-process Apache POI; each # heading becomes a sheet, pipe tables become rows, numeric cells auto-detected.', 'builtin', 'xlsxRenderTool', '📊', TRUE, TRUE, NOW(), NOW(), 0);
+
+-- Built-in tool: PPTX Render (in-process Apache POI; Marp-style markdown -> .pptx deck)
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000021, 'PptxRenderTool', 'PPTX Render', 'Render Marp-style Markdown directly into a .pptx deck and return a one-time download link. In-process Apache POI; --- separates slides, # / ## titles, - bullets, .', 'builtin', 'pptxRenderTool', '🎞️', TRUE, TRUE, NOW(), NOW(), 0);
+
+-- Built-in tool: PDF Render (dual backend: LibreOffice subprocess preferred, OpenPDF + Flying Saucer fallback)
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000022, 'PdfRenderTool', 'PDF Render', 'Render Markdown into a final-form .pdf and return a one-time download link. Two backends (LibreOffice subprocess preferred, OpenPDF + Flying Saucer fallback); supports YAML frontmatter for cover / page header / page footer.', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0);
+
-- Example MCP Server: Filesystem (see MateClaw docs mcpServers.filesystem)
MERGE INTO mate_mcp_server (
id, name, description, transport, url, headers_json, command, args_json, env_json, cwd,
diff --git a/mateclaw-server/src/main/resources/db/data-mysql-en.sql b/mateclaw-server/src/main/resources/db/data-mysql-en.sql
index aeef963f..9e7280a0 100644
--- a/mateclaw-server/src/main/resources/db/data-mysql-en.sql
+++ b/mateclaw-server/src/main/resources/db/data-mysql-en.sql
@@ -533,6 +533,21 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
VALUES (1000000019, 'DocxRenderTool', 'DOCX Render', 'Render Markdown directly into a .docx and return a one-time download link. In-process Apache POI implementation, no Node.js subprocess; supports headings, bold, lists, tables. Preferred tool for creating new documents.', 'builtin', 'docxRenderTool', '📝', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+-- Built-in tool: XLSX Render (in-process Apache POI; markdown tables -> multi-sheet workbook)
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000020, 'XlsxRenderTool', 'XLSX Render', 'Render Markdown directly into a .xlsx workbook and return a one-time download link. In-process Apache POI; each # heading becomes a sheet, pipe tables become rows, numeric cells auto-detected.', 'builtin', 'xlsxRenderTool', '📊', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
+-- Built-in tool: PPTX Render (in-process Apache POI; Marp-style markdown -> .pptx deck)
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000021, 'PptxRenderTool', 'PPTX Render', 'Render Marp-style Markdown directly into a .pptx deck and return a one-time download link. In-process Apache POI; --- separates slides, # / ## titles, - bullets, .', 'builtin', 'pptxRenderTool', '🎞️', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
+-- Built-in tool: PDF Render (dual backend: LibreOffice subprocess preferred, OpenPDF + Flying Saucer fallback)
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000022, 'PdfRenderTool', 'PDF Render', 'Render Markdown into a final-form .pdf and return a one-time download link. Two backends (LibreOffice subprocess preferred, OpenPDF + Flying Saucer fallback); supports YAML frontmatter for cover / page header / page footer.', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
-- Example MCP Server: Filesystem (see MateClaw docs mcpServers.filesystem)
INSERT INTO mate_mcp_server (id, name, description, transport, url, headers_json, command, args_json, env_json, cwd,
enabled, connect_timeout_seconds, read_timeout_seconds, last_status, last_error,
diff --git a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql
index bc326250..63d48a93 100644
--- a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql
+++ b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql
@@ -531,6 +531,21 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
VALUES (1000000019, 'DocxRenderTool', 'DOCX 渲染', '将 Markdown 直接渲染为 .docx 并返回一次性下载链接。进程内 Apache POI 实现,无需 Node.js 子进程;支持标题、加粗、列表、表格。新建文档场景的首选工具。', 'builtin', 'docxRenderTool', '📝', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+-- 内置工具:XLSX 渲染(进程内 Apache POI,从 Markdown 表格生成多 sheet 工作簿)
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000020, 'XlsxRenderTool', 'XLSX 渲染', '将 Markdown 直接渲染为 .xlsx 工作簿并返回一次性下载链接。进程内 Apache POI 实现;每个 # 一级标题生成一个 sheet,竖线表格成为行内容,数字单元格自动识别。', 'builtin', 'xlsxRenderTool', '📊', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
+-- 内置工具:PPTX 渲染(进程内 Apache POI,Marp 风格 Markdown 生成 .pptx)
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000021, 'PptxRenderTool', 'PPTX 渲染', '将 Marp 风格的 Markdown 直接渲染为 .pptx 演示文稿并返回一次性下载链接。进程内 Apache POI 实现;--- 分页、# / ## 作幻灯片标题、- 作要点、 作演讲者备注。', 'builtin', 'pptxRenderTool', '🎞️', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
+-- 内置工具:PDF 渲染(双 backend:LibreOffice 子进程优先,进程内 OpenPDF + Flying Saucer 兜底)
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000022, 'PdfRenderTool', 'PDF 渲染', '将 Markdown 渲染为最终交付形态的 .pdf 并返回一次性下载链接。双 backend 自动切换(优先 LibreOffice,不可用时回落到进程内 OpenPDF + Flying Saucer);通过 YAML frontmatter 控制封面、页眉、页脚。', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
-- 示例 MCP Server:Filesystem(参考 MateClaw 文档中的 mcpServers.filesystem)
INSERT INTO mate_mcp_server (
id, name, description, transport, url, headers_json, command, args_json, env_json, cwd,
diff --git a/mateclaw-server/src/main/resources/db/data-zh.sql b/mateclaw-server/src/main/resources/db/data-zh.sql
index 9b1b9641..588d56da 100644
--- a/mateclaw-server/src/main/resources/db/data-zh.sql
+++ b/mateclaw-server/src/main/resources/db/data-zh.sql
@@ -485,6 +485,21 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name,
KEY (id)
VALUES (1000000019, 'DocxRenderTool', 'DOCX 渲染', '将 Markdown 直接渲染为 .docx 并返回一次性下载链接。进程内 Apache POI 实现,无需 Node.js 子进程;支持标题、加粗、列表、表格。新建文档场景的首选工具。', 'builtin', 'docxRenderTool', '📝', TRUE, TRUE, NOW(), NOW(), 0);
+-- 内置工具:XLSX 渲染(进程内 Apache POI,从 Markdown 表格生成多 sheet 工作簿)
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000020, 'XlsxRenderTool', 'XLSX 渲染', '将 Markdown 直接渲染为 .xlsx 工作簿并返回一次性下载链接。进程内 Apache POI 实现;每个 # 一级标题生成一个 sheet,竖线表格成为行内容,数字单元格自动识别。', 'builtin', 'xlsxRenderTool', '📊', TRUE, TRUE, NOW(), NOW(), 0);
+
+-- 内置工具:PPTX 渲染(进程内 Apache POI,Marp 风格 Markdown 生成 .pptx)
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000021, 'PptxRenderTool', 'PPTX 渲染', '将 Marp 风格的 Markdown 直接渲染为 .pptx 演示文稿并返回一次性下载链接。进程内 Apache POI 实现;--- 分页、# / ## 作幻灯片标题、- 作要点、 作演讲者备注。', 'builtin', 'pptxRenderTool', '🎞️', TRUE, TRUE, NOW(), NOW(), 0);
+
+-- 内置工具:PDF 渲染(双 backend:LibreOffice 子进程优先,进程内 OpenPDF + Flying Saucer 兜底)
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000022, 'PdfRenderTool', 'PDF 渲染', '将 Markdown 渲染为最终交付形态的 .pdf 并返回一次性下载链接。双 backend 自动切换(优先 LibreOffice,不可用时回落到进程内 OpenPDF + Flying Saucer);通过 YAML frontmatter 控制封面、页眉、页脚。', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0);
+
-- 示例 MCP Server:Filesystem(参考 MateClaw 文档中的 mcpServers.filesystem)
MERGE INTO mate_mcp_server (
id, name, description, transport, url, headers_json, command, args_json, env_json, cwd,
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V94__register_office_render_tools.sql b/mateclaw-server/src/main/resources/db/migration/h2/V94__register_office_render_tools.sql
new file mode 100644
index 00000000..ede5b3d6
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V94__register_office_render_tools.sql
@@ -0,0 +1,16 @@
+-- V94: Register XlsxRenderTool / PptxRenderTool / PdfRenderTool as built-in tools.
+-- These mirror DocxRenderTool (V31) so agents can bind them through the tool picker
+-- and so the AvailableToolService surfaces them in the UI.
+-- Idempotent: MERGE INTO updates existing rows when id matches.
+
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000020, 'XlsxRenderTool', 'XLSX Render', 'Render Markdown directly into a .xlsx workbook and return a one-time download link. In-process Apache POI; each # heading becomes a sheet, pipe tables become rows, numeric cells auto-detected.', 'builtin', 'xlsxRenderTool', '📊', TRUE, TRUE, NOW(), NOW(), 0);
+
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000021, 'PptxRenderTool', 'PPTX Render', 'Render Marp-style Markdown directly into a .pptx deck and return a one-time download link. In-process Apache POI; --- separates slides, # / ## titles, - bullets, .', 'builtin', 'pptxRenderTool', '🎞️', TRUE, TRUE, NOW(), NOW(), 0);
+
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000022, 'PdfRenderTool', 'PDF Render', 'Render Markdown into a final-form .pdf and return a one-time download link. Two backends (LibreOffice subprocess preferred, OpenPDF + Flying Saucer fallback); supports YAML frontmatter for cover / page header / page footer.', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0);
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V94__register_office_render_tools.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V94__register_office_render_tools.sql
new file mode 100644
index 00000000..80740109
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V94__register_office_render_tools.sql
@@ -0,0 +1,16 @@
+-- V94: Register XlsxRenderTool / PptxRenderTool / PdfRenderTool as built-in tools.
+-- These mirror DocxRenderTool (V31) so agents can bind them through the tool picker
+-- and so the AvailableToolService surfaces them in the UI.
+-- Idempotent: ON DUPLICATE KEY UPDATE keeps rows in sync if they already exist.
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000020, 'XlsxRenderTool', 'XLSX Render', 'Render Markdown directly into a .xlsx workbook and return a one-time download link. In-process Apache POI; each # heading becomes a sheet, pipe tables become rows, numeric cells auto-detected.', 'builtin', 'xlsxRenderTool', '📊', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), bean_name=VALUES(bean_name), icon=VALUES(icon), update_time=VALUES(update_time);
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000021, 'PptxRenderTool', 'PPTX Render', 'Render Marp-style Markdown directly into a .pptx deck and return a one-time download link. In-process Apache POI; --- separates slides, # / ## titles, - bullets, .', 'builtin', 'pptxRenderTool', '🎞️', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), bean_name=VALUES(bean_name), icon=VALUES(icon), update_time=VALUES(update_time);
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000022, 'PdfRenderTool', 'PDF Render', 'Render Markdown into a final-form .pdf and return a one-time download link. Two backends (LibreOffice subprocess preferred, OpenPDF + Flying Saucer fallback); supports YAML frontmatter for cover / page header / page footer.', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), bean_name=VALUES(bean_name), icon=VALUES(icon), update_time=VALUES(update_time);