feat(tool): docx image embedding + multi-file render

Two follow-up improvements on top of renderDocxFromFile so the docx
pipeline can handle real long-form deliverables instead of just
prose-only memos.

Image embedding (P1).
MarkdownDocxRenderer now recognizes single-line ![alt](path) markdown
and embeds the referenced file via POI's XWPFRun.addPicture():
- PNG / JPG / GIF / BMP read straight from disk
- SVG rasterized via Apache Batik (PNGTranscoder, target width 1400px)
  before embedding — OOXML stores raster images, so any vector source
  needs conversion. Batik runs in-JVM, no rsvg-convert / cairo on host.
- Pictures are pinned to roughly the printable page width (≈ 5.77 in
  for A4 minus default 1800-twip margins) and given a 4:3 height
  fallback. Mixing images inline with other paragraph text is not
  supported by design — the markdown subset assumes one image per
  block paragraph. Inline images would require splitting paragraphs
  across runs with explicit positioning, well beyond what this
  renderer covers.
- Failure modes (missing file, unsupported format, Batik blowing up)
  emit an italicised "[image: alt — reason]" placeholder so the rest
  of the document still renders; the agent can read its own log to
  see why the picture didn't make it.
- Adds two transitive deps via pom: batik-transcoder + batik-codec at
  1.18, ~10 MB combined. Worth it given the alternative is shelling
  out to system tooling.

Multi-file render (P2-lite).
New tool renderDocxFromFiles(List<String> filePaths, filename, pageSize)
reads several markdown files in order and renders one combined docx.
Lets the agent split a 30-page proposal into cover.md / ch1.md /
ch2.md / appendix.md and produce a single deliverable in one tool
call. Each path goes through WorkspacePathGuard.validatePath; any
empty or unreadable file aborts with a typed error so the agent
fixes its file list before retrying. Files are joined with a blank
line — no separator markup is injected, headings carry over cleanly.

I deliberately did NOT build the heavier mutable-docx state
("appendDocxChapter / finalizeDocx") flavor of P2: the multi-file
form covers the same workflow with no per-conversation state to
clean up, and the agent can iterate by rewriting the chapter file
and re-running the tool. Stateful append can come later if a
streaming use case actually shows up.

renderDocx and renderDocxFromFile @Tool descriptions updated to point
the agent at renderDocxFromFile for >5 KB markdown and to advertise
the new image-embedding capability.
This commit is contained in:
matevip 2026-04-27 08:42:03 +08:00
parent 9ed9ee6ca7
commit b4ebab65c7
3 changed files with 270 additions and 5 deletions

View File

@ -291,6 +291,25 @@
<version>5.4.1</version>
</dependency>
<!-- ===== Apache Batik (SVG rasterization for docx image embedding) ===== -->
<!--
Used by MarkdownDocxRenderer to convert ![alt](*.svg) image references
into PNG bytes that POI can embed via XWPFRun.addPicture(). Without this,
agents that produce architecture diagrams as inline SVG cannot get them
into the final .docx. Rasterization runs in-JVM (no rsvg-convert / cairo
dependency on the host).
-->
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-transcoder</artifactId>
<version>1.18</version>
</dependency>
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-codec</artifactId>
<version>1.18</version>
</dependency>
<!-- ===== jsoup (HTML cleanup for Wiki ingest, RFC-051 PR-1c) ===== -->
<!--
Used by WikiContentNormalizer to strip nav/footer/script/style/aside

View File

@ -12,6 +12,8 @@ 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;
/**
* Render a brand-new .docx from Markdown without ever forking a process.
@ -40,7 +42,12 @@ public class DocxRenderTool {
Render a new .docx file from Markdown text and return a one-time download URL.
Use for creating NEW documents: reports, memos, contracts, letters, resumes.
Supports: headings (# ## ###), bold (**text**), bullet lists (- item),
numbered lists (1. item), tables (| col | col |), plain paragraphs.
numbered lists (1. item), tables (| col | col |), plain paragraphs,
images (![alt](path/to/file.png|jpg|gif|bmp|svg)) SVG is rasterized
to PNG; image lines must contain only the image syntax.
For markdown bodies larger than ~5 KB, prefer renderDocxFromFile (read from
disk) passing huge markdown as a tool argument burns LLM tokens needlessly.
Do NOT use for:
- Editing an existing .docx file (use run_skill_script with unpack/edit/pack)
@ -112,9 +119,10 @@ public class DocxRenderTool {
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 renderDocx (headings, bold, lists, tables).
Image references (![alt](path)) are NOT yet rendered into the docx they will
appear as raw markdown text. SVG inline embedding requires a follow-up tool.
Same supported markdown subset as renderDocx (headings, bold, lists, tables,
images). Image references ![alt](path) are rendered when path resolves to a
readable file in the workspace. SVG sources are rasterized to PNG via Batik;
PNG/JPG/GIF/BMP are embedded directly.
""")
public String renderDocxFromFile(
@ToolParam(description = "Absolute or workspace-relative path to a markdown file")
@ -178,6 +186,107 @@ public class DocxRenderTool {
}
}
/**
* Multi-file renderer read several markdown files in order and concatenate
* them into one docx. Lets the agent split a long report into chapters
* (cover.md, intro.md, ch1.md, ...) and render the whole thing in one call,
* so a 30-page deliverable does not need to live in a single source file.
* <p>
* Files are joined with a blank line so heading hierarchy and paragraph
* structure carry over cleanly; no extra separator markup is injected.
* Empty / missing files abort the render with a clear error so the agent
* can fix its file list before retrying.
*/
@Tool(description = """
Render a .docx by concatenating MULTIPLE markdown files in order and return a
download URL. Use when a report is split into chapters / sections, or when the
agent assembled the document piece by piece (cover, table of contents, body,
appendix) across several files.
Typical workflow:
1. write_file(path="cover.md", content="# Title\\n...")
2. write_file(path="ch1.md", content="## Chapter 1\\n...")
3. write_file(path="ch2.md", content="## Chapter 2\\n...")
4. renderDocxFromFiles(filePaths=["cover.md","ch1.md","ch2.md"],
filename="quarterly-report")
Files are read with UTF-8, joined with one blank line between them, and
rendered with the same markdown subset as renderDocx (headings, bold,
lists, tables). All paths must pass the workspace boundary check.
""")
public String renderDocxFromFiles(
@ToolParam(description = "List of markdown file paths in render order")
List<String> filePaths,
@ToolParam(description = "Output filename without extension, e.g. 'quarterly-report'")
String filename,
@ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false)
String pageSize) {
if (filePaths == null || filePaths.isEmpty()) {
return "Error: filePaths is empty.";
}
StringBuilder combined = new StringBuilder();
long totalBytes = 0;
List<String> resolvedPaths = new ArrayList<>();
for (int idx = 0; idx < filePaths.size(); idx++) {
String raw = filePaths.get(idx);
if (raw == null || raw.isBlank()) {
return "Error: filePaths[" + idx + "] is empty.";
}
Path resolved;
try {
resolved = WorkspacePathGuard.validatePath(raw);
} catch (Exception e) {
return "Error: filePaths[" + idx + "] validation failed — " + e.getMessage();
}
if (!Files.exists(resolved)) {
return "Error: filePaths[" + idx + "] not found at " + resolved;
}
if (!Files.isRegularFile(resolved) || !Files.isReadable(resolved)) {
return "Error: filePaths[" + idx + "] is not a readable regular file " + resolved;
}
String content;
try {
totalBytes += Files.size(resolved);
content = Files.readString(resolved, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("[DocxRender] read failed for {}: {}", resolved, e.getMessage(), e);
return "Error: read failed for " + resolved + "" + e.getMessage();
}
if (content.isBlank()) {
return "Error: filePaths[" + idx + "] is blank " + resolved;
}
if (combined.length() > 0) combined.append("\n\n");
combined.append(content);
resolvedPaths.add(resolved.toString());
}
String safeName = sanitizeFilename(filename);
String displayName = safeName + ".docx";
String size = (pageSize == null || pageSize.isBlank()) ? "A4" : pageSize.trim();
try {
long t0 = System.currentTimeMillis();
byte[] bytes = renderer.render(combined.toString(), size);
String id = cache.put(bytes, displayName, DOCX_MIME);
long elapsed = System.currentTimeMillis() - t0;
log.info("[DocxRender] generated {} ({} bytes from {} files / {} bytes md, {}ms, id={})",
displayName, bytes.length, resolvedPaths.size(), totalBytes, elapsed, id);
String url = "/api/v1/files/generated/" + id;
return "Document generated from " + resolvedPaths.size() + " files: ["
+ 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.";
} catch (Exception e) {
log.error("[DocxRender] render failed for {} (sources: {}): {}",
displayName, resolvedPaths, e.getMessage(), e);
return "Render failed: " + e.getMessage();
}
}
/**
* Strip path separators and other unsafe characters from a user-supplied
* filename. Falls back to a generic name when nothing usable remains.

View File

@ -28,13 +28,23 @@ import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STNumberFormat;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STShd;
import org.apache.batik.transcoder.TranscoderInput;
import org.apache.batik.transcoder.TranscoderOutput;
import org.apache.batik.transcoder.image.PNGTranscoder;
import org.apache.poi.util.Units;
import org.apache.poi.xwpf.usermodel.Document;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -62,6 +72,25 @@ public class MarkdownDocxRenderer {
private static final Pattern ORDERED_ITEM = Pattern.compile("^\\s*\\d+\\.\\s+(.*)$");
private static final Pattern TABLE_SEPARATOR = Pattern.compile("^\\s*\\|?\\s*:?-{3,}:?\\s*(\\|\\s*:?-{3,}:?\\s*)+\\|?\\s*$");
/**
* Image-only line, e.g. {@code ![alt text](path/to/file.png)}. Whitespace around
* the syntax is allowed but inline images mixed with other text in the same
* paragraph are intentionally NOT recognized they would require splitting
* a single paragraph into multiple POI runs with image positioning that the
* markdown subset doesn't otherwise support.
*/
private static final Pattern IMAGE_LINE = Pattern.compile(
"^\\s*!\\[([^\\]]*)\\]\\(([^)\\s]+)\\)\\s*$");
/**
* Page width in EMU after default A4 margins (page width 11906 twips - left
* 1800 - right 1800 = 8306 twips 5.77 inches). POI's image API works in EMU
* (1 inch = 914400 EMU); precomputing the maximum width keeps oversized images
* from spilling outside the printable area while still allowing small images
* to render at native size.
*/
private static final int MAX_IMAGE_WIDTH_EMU = Units.toEMU(5.77 * 72);
private static final String LATIN_FONT = "Arial";
private static final String CJK_BODY_FONT = "FangSong"; // 仿宋
private static final String CJK_HEADING_FONT = "SimHei"; // 黑体
@ -90,7 +119,10 @@ public class MarkdownDocxRenderer {
continue;
}
if (stripped.startsWith("### ")) {
Matcher imageMatch = IMAGE_LINE.matcher(line);
if (imageMatch.matches()) {
renderImage(doc, imageMatch.group(1), imageMatch.group(2));
} else if (stripped.startsWith("### ")) {
renderHeading(doc, stripped.substring(4), 3);
} else if (stripped.startsWith("## ")) {
renderHeading(doc, stripped.substring(3), 2);
@ -200,6 +232,111 @@ public class MarkdownDocxRenderer {
renderInline(p, text, false, 0);
}
// ==================== images ====================
/**
* Render an {@code ![alt](path)} line as an embedded image. Falls back to
* showing the alt text in italics on any failure (file missing, unsupported
* format, SVG conversion error) so the rest of the document still renders.
* <p>
* Path resolution: the markdown is treated as living in the workspace root,
* so a path like {@code assets/x.png} resolves relative to the JVM working
* directory. Absolute paths are accepted as-is. {@code .svg} files are
* rasterized to PNG via Apache Batik before embedding because OOXML images
* must be a raster format.
*/
private void renderImage(XWPFDocument doc, String alt, String rawPath) {
XWPFParagraph p = doc.createParagraph();
p.setAlignment(ParagraphAlignment.CENTER);
XWPFRun run = p.createRun();
Path path;
try {
path = Paths.get(rawPath);
if (!path.isAbsolute()) {
path = Paths.get(".").resolve(rawPath).normalize();
}
} catch (Exception e) {
renderImageFallback(run, alt, "invalid path: " + e.getMessage());
return;
}
if (!Files.exists(path) || !Files.isReadable(path)) {
renderImageFallback(run, alt, "file not found: " + path);
return;
}
String lower = path.getFileName().toString().toLowerCase(Locale.ROOT);
int format;
byte[] imageBytes;
try {
if (lower.endsWith(".svg")) {
imageBytes = svgToPng(Files.readAllBytes(path));
format = Document.PICTURE_TYPE_PNG;
} else if (lower.endsWith(".png")) {
imageBytes = Files.readAllBytes(path);
format = Document.PICTURE_TYPE_PNG;
} else if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
imageBytes = Files.readAllBytes(path);
format = Document.PICTURE_TYPE_JPEG;
} else if (lower.endsWith(".gif")) {
imageBytes = Files.readAllBytes(path);
format = Document.PICTURE_TYPE_GIF;
} else if (lower.endsWith(".bmp")) {
imageBytes = Files.readAllBytes(path);
format = Document.PICTURE_TYPE_BMP;
} else {
renderImageFallback(run, alt, "unsupported image format: " + lower);
return;
}
} catch (Exception e) {
log.warn("[MarkdownDocxRenderer] failed to read image {}: {}", path, e.getMessage());
renderImageFallback(run, alt, "read failed: " + e.getMessage());
return;
}
// Choose width: scale to MAX_IMAGE_WIDTH_EMU. POI's addPicture expects
// EMU; we don't know the source image's intrinsic size cheaply, so
// pin width and let height scale proportionally via height=0 POI
// does not infer height for us, so use a reasonable height ratio
// (4:3 default) to avoid stretching extremely wide diagrams.
int width = MAX_IMAGE_WIDTH_EMU;
int height = (int) (MAX_IMAGE_WIDTH_EMU * 0.6);
try (ByteArrayInputStream in = new ByteArrayInputStream(imageBytes)) {
run.addPicture(in, format, path.getFileName().toString(), width, height);
} catch (Exception e) {
log.warn("[MarkdownDocxRenderer] addPicture failed for {}: {}",
path, e.getMessage());
renderImageFallback(run, alt, "embed failed: " + e.getMessage());
}
}
/**
* Convert an SVG byte array to PNG using Batik's PNGTranscoder. Width is
* pinned so the rasterized output matches the docx page-width target;
* height scales proportionally per the SVG's own viewBox.
*/
private byte[] svgToPng(byte[] svgBytes) throws IOException {
PNGTranscoder t = new PNGTranscoder();
// Roughly 1400px wide renders crisply at our docx target width.
t.addTranscodingHint(PNGTranscoder.KEY_WIDTH, 1400f);
TranscoderInput input = new TranscoderInput(new ByteArrayInputStream(svgBytes));
ByteArrayOutputStream out = new ByteArrayOutputStream();
TranscoderOutput output = new TranscoderOutput(out);
try {
t.transcode(input, output);
} catch (Exception e) {
throw new IOException("SVG transcode failed: " + e.getMessage(), e);
}
return out.toByteArray();
}
private void renderImageFallback(XWPFRun run, String alt, String reason) {
run.setItalic(true);
run.setText("[image: " + (alt == null || alt.isBlank() ? "(no alt)" : alt)
+ "" + reason + "]");
}
// ==================== inline (bold) ====================
private void renderInline(XWPFParagraph p, String text, boolean heading, int headingLevel) {