mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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  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:
parent
9ed9ee6ca7
commit
b4ebab65c7
@ -291,6 +291,25 @@
|
||||
<version>5.4.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Apache Batik (SVG rasterization for docx image embedding) ===== -->
|
||||
<!--
|
||||
Used by MarkdownDocxRenderer to convert  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
|
||||
|
||||
@ -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 () — 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 () 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  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.
|
||||
|
||||
@ -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 }. 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 } 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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user