feat: 支持工作流文件节点多格式文档导出

- 补齐 md/html/pdf/docx 导出与统一渲染服务

- 收口文件生成节点配置与格式校验

- 修复 PDF 中文字体与 Markdown 渲染链路
This commit is contained in:
2026-04-19 15:23:23 +08:00
parent 51198ff492
commit a5aab86de2
33 changed files with 2144 additions and 102 deletions

View File

@@ -8,6 +8,7 @@ import org.junit.Test;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.node.MakeFileNodeParser;
import tech.easyflow.ai.node.SearchDatasetNodeParser;
import tech.easyflow.ai.node.WorkflowNodeParser;
import tech.easyflow.ai.service.WorkflowService;
@@ -328,6 +329,37 @@ public class WorkflowCheckServiceTest {
assertHasCode(result, "START_FORM_OPTIONS_EMPTY");
}
@Test
public void testSaveShouldBlockInvalidMakeFileCombination() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject data = data("文件生成");
data.put("sourceFormat", "html");
data.put("targetFormat", "docx");
String content = workflowJson(
array(node("mf1", "make-file", null, data)),
new JSONArray()
);
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "MAKE_FILE_INVALID");
}
@Test
public void testSaveShouldPassForValidMakeFileCombination() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject data = data("文件生成");
data.put("sourceFormat", "markdown");
data.put("targetFormat", "pdf");
String content = workflowJson(
array(node("mf1", "make-file", null, data)),
new JSONArray()
);
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.SAVE, null);
Assert.assertTrue(result.isPassed());
}
private static WorkflowCheckService newService(Map<String, String> workflowStore) throws Exception {
WorkflowCheckService service = new WorkflowCheckService();
ChainParser parser = ChainParser.builder()
@@ -335,6 +367,7 @@ public class WorkflowCheckServiceTest {
.build();
parser.addNodeParser("workflow-node", new WorkflowNodeParser());
parser.addNodeParser("search-dataset-node", new SearchDatasetNodeParser());
parser.addNodeParser("make-file", new MakeFileNodeParser());
setField(service, "chainParser", parser);
setField(service, "workflowService", mockWorkflowService(workflowStore));
setField(service, "workflowDatacenterContentService", new WorkflowDatacenterContentService());

View File

@@ -0,0 +1,58 @@
package tech.easyflow.ai.node;
import com.alibaba.fastjson.JSONObject;
import com.easyagents.flow.core.node.BaseNode;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.common.web.exceptions.BusinessException;
/**
* {@link MakeFileNodeParser} 单元测试。
*
* @author Codex
* @since 2026-04-18
*/
public class MakeFileNodeParserTest {
@Test
public void testShouldUseDefaults() {
MakeFileNodeParser parser = new MakeFileNodeParser();
MakeFileNode node = (MakeFileNode) parser.doParse(new JSONObject(), new JSONObject(), new JSONObject());
Assert.assertEquals("docx", node.getTargetFormat());
Assert.assertEquals("markdown", node.getSourceFormat());
Assert.assertEquals("default", node.getTemplateStyle());
Assert.assertNull(node.getFileName());
}
@Test
public void testShouldPreferTargetFormatAndTrimFileName() {
MakeFileNodeParser parser = new MakeFileNodeParser();
JSONObject data = new JSONObject();
data.put("targetFormat", "pdf");
data.put("sourceFormat", "markdown");
data.put("fileName", " 导出报告 ");
data.put("templateStyle", "custom");
BaseNode parsed = parser.doParse(new JSONObject(), data, new JSONObject());
MakeFileNode node = (MakeFileNode) parsed;
Assert.assertEquals("pdf", node.getTargetFormat());
Assert.assertEquals("markdown", node.getSourceFormat());
Assert.assertEquals("导出报告", node.getFileName());
Assert.assertEquals("default", node.getTemplateStyle());
}
@Test
public void testShouldRejectIllegalCombination() {
MakeFileNodeParser parser = new MakeFileNodeParser();
JSONObject data = new JSONObject();
data.put("sourceFormat", "html");
data.put("targetFormat", "md");
BusinessException exception = Assert.assertThrows(
BusinessException.class,
() -> parser.doParse(new JSONObject(), data, new JSONObject())
);
Assert.assertTrue(exception.getMessage().contains("html -> md"));
}
}

View File

@@ -0,0 +1,118 @@
package tech.easyflow.ai.node;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.node.filegeneration.FileGenerationService;
import tech.easyflow.ai.node.filegeneration.MarkdownFileRenderer;
import tech.easyflow.common.filestorage.FileStorageManager;
import tech.easyflow.common.util.SpringContextUtil;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* {@link MakeFileNode} 单元测试。
*
* @author Codex
* @since 2026-04-18
*/
public class MakeFileNodeTest {
@Test
public void testExecuteShouldReturnUrl() throws Exception {
RecordingFileStorageManager storageManager = new RecordingFileStorageManager();
FileGenerationService generationService = new FileGenerationService(List.of(new MarkdownFileRenderer()));
ApplicationContext previousContext = getStaticField("applicationContext");
Object previousBeanFactory = getStaticField("beanFactory");
try {
setStaticField("beanFactory", null);
setStaticField("applicationContext", mockApplicationContext(generationService, storageManager));
MakeFileNode node = new MakeFileNode("md", "plain_text", "测试导出", "default");
node.setParameters(Collections.singletonList(new Parameter("content")));
Chain chain = createChain(Map.of("content", "hello"));
Map<String, Object> result = node.execute(chain);
Assert.assertEquals("https://example.com/generated.md", result.get("url"));
Assert.assertNotNull(storageManager.lastFile);
Assert.assertEquals("测试导出.md", storageManager.lastFile.getOriginalFilename());
} finally {
setStaticField("applicationContext", previousContext);
setStaticField("beanFactory", previousBeanFactory);
}
}
private static Chain createChain(Map<String, Object> memory) {
Chain chain = new Chain(new ChainDefinition(), UUID.randomUUID().toString());
chain.setChainStateRepository(new InMemoryChainStateRepository());
chain.getState().getMemory().putAll(memory);
return chain;
}
private static ApplicationContext mockApplicationContext(FileGenerationService service,
FileStorageManager storageManager) {
return (ApplicationContext) Proxy.newProxyInstance(
ApplicationContext.class.getClassLoader(),
new Class[]{ApplicationContext.class},
(proxy, method, args) -> {
if ("getBean".equals(method.getName()) && args != null && args.length == 1 && args[0] instanceof Class<?> clazz) {
if (clazz == FileGenerationService.class) {
return service;
}
if (clazz == FileStorageManager.class) {
return storageManager;
}
}
if ("equals".equals(method.getName())) {
return proxy == args[0];
}
if ("hashCode".equals(method.getName())) {
return System.identityHashCode(proxy);
}
if (method.getReturnType() == boolean.class) {
return false;
}
if (method.getReturnType() == int.class) {
return 0;
}
if (method.getReturnType() == long.class) {
return 0L;
}
return null;
}
);
}
@SuppressWarnings("unchecked")
private static <T> T getStaticField(String fieldName) throws Exception {
Field field = SpringContextUtil.class.getDeclaredField(fieldName);
field.setAccessible(true);
return (T) field.get(null);
}
private static void setStaticField(String fieldName, Object value) throws Exception {
Field field = SpringContextUtil.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(null, value);
}
private static class RecordingFileStorageManager extends FileStorageManager {
private MultipartFile lastFile;
@Override
public String save(MultipartFile file) {
this.lastFile = file;
return "https://example.com/generated.md";
}
}
}

View File

@@ -0,0 +1,60 @@
package tech.easyflow.ai.node.filegeneration;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.List;
/**
* {@link FileGenerationService} 单元测试。
*
* @author Codex
* @since 2026-04-18
*/
public class FileGenerationServiceTest {
@Test
public void testShouldSanitizeFileNameAndOverrideExtension() {
FileGenerationService service = new FileGenerationService(List.of(new MarkdownFileRenderer()));
FileGenerationResult result = service.generate(new FileGenerationRequest(
"demo",
"plain_text",
"md",
" a/b.docx ",
"custom"
));
Assert.assertEquals("ab.md", result.getFileName());
Assert.assertEquals("md", result.getTargetFormat());
Assert.assertEquals("text/markdown", result.getContentType());
}
@Test
public void testShouldFallbackToGeneratedFileName() {
FileGenerationService service = new FileGenerationService(List.of(new MarkdownFileRenderer()));
FileGenerationResult result = service.generate(new FileGenerationRequest(
"",
"markdown",
"md",
" / ",
"default"
));
Assert.assertTrue(result.getFileName().startsWith("generated-file-"));
Assert.assertTrue(result.getFileName().endsWith(".md"));
Assert.assertEquals(0, result.getSize());
}
@Test
public void testShouldRejectHtmlToDocx() {
FileGenerationService service = new FileGenerationService(List.of(new DocxFileRenderer(new MarkdownSupport())));
BusinessException exception = Assert.assertThrows(
BusinessException.class,
() -> service.generate(new FileGenerationRequest("html", "html", "docx", "demo", "default"))
);
Assert.assertTrue(exception.getMessage().contains("html -> docx"));
}
}

View File

@@ -0,0 +1,189 @@
package tech.easyflow.ai.node.filegeneration;
import com.openhtmltopdf.outputdevice.helper.ExternalResourceType;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.junit.Assert;
import org.junit.Test;
import java.io.ByteArrayInputStream;
/**
* 文档渲染器单元测试。
*
* @author Codex
* @since 2026-04-18
*/
public class FileRendererTest {
@Test
public void testMarkdownRendererShouldSupportPlainTextAndMarkdown() {
MarkdownFileRenderer renderer = new MarkdownFileRenderer();
FileGenerationResult plain = renderer.render(new FileGenerationRequest(
"hello",
"plain_text",
"md",
"demo.md",
"default"
));
FileGenerationResult markdown = renderer.render(new FileGenerationRequest(
"# title",
"markdown",
"md",
"demo.md",
"default"
));
Assert.assertEquals("hello", new String(plain.getBytes()));
Assert.assertEquals("# title", new String(markdown.getBytes()));
}
@Test
public void testHtmlRendererShouldSanitizeDangerousHtml() {
HtmlFileRenderer renderer = new HtmlFileRenderer(new HtmlDocumentBuilder(new MarkdownSupport(), new HtmlSanitizer()));
FileGenerationResult result = renderer.render(new FileGenerationRequest(
"<script>alert(1)</script><table><tr><td>ok</td></tr></table>",
"html",
"html",
"demo.html",
"default"
));
String html = new String(result.getBytes());
Assert.assertTrue(html.contains("<table>"));
Assert.assertFalse(html.contains("<script>"));
Assert.assertTrue(html.contains("document-paper"));
}
@Test
public void testHtmlRendererShouldSanitizeMarkdownGeneratedResources() {
HtmlFileRenderer renderer = new HtmlFileRenderer(new HtmlDocumentBuilder(new MarkdownSupport(), new HtmlSanitizer()));
FileGenerationResult result = renderer.render(new FileGenerationRequest(
"![remote](https://example.com/demo.png)\n\n# title",
"markdown",
"html",
"demo.html",
"default"
));
String html = new String(result.getBytes());
Assert.assertFalse(html.contains("<img"));
Assert.assertTrue(html.contains("<h1>title</h1>"));
}
@Test
public void testPdfRendererShouldGeneratePdfWithChineseContent() throws Exception {
PdfFileRenderer renderer = new PdfFileRenderer(new HtmlDocumentBuilder(new MarkdownSupport(), new HtmlSanitizer()));
FileGenerationResult result = renderer.render(new FileGenerationRequest(
"# 中文标题\n\n- 列表项\n\n| 列1 | 列2 |\n| --- | --- |\n| A | B |",
"markdown",
"pdf",
"demo.pdf",
"default"
));
byte[] bytes = result.getBytes();
Assert.assertTrue(bytes.length > 1024);
Assert.assertEquals("%PDF", new String(bytes, 0, 4));
try (PDDocument document = PDDocument.load(bytes)) {
String text = new PDFTextStripper().getText(document);
Assert.assertTrue(text.contains("中文标题"));
Assert.assertTrue(text.contains("列表项"));
Assert.assertTrue(text.contains("列1"));
}
}
@Test
public void testPdfRendererShouldRenderMarkdownInsteadOfKeepingRawSyntax() throws Exception {
PdfFileRenderer renderer = new PdfFileRenderer(new HtmlDocumentBuilder(new MarkdownSupport(), new HtmlSanitizer()));
FileGenerationResult result = renderer.render(new FileGenerationRequest(
"# Java 入门手册\n\n## Hello World\n\n```java\nSystem.out.println(\"你好\");\n```",
"markdown",
"pdf",
"manual.pdf",
"default"
));
try (PDDocument document = PDDocument.load(result.getBytes())) {
String text = new PDFTextStripper().getText(document)
.replace('\u00A0', ' ')
.replace("\r", "");
Assert.assertTrue(text.contains("Java 入门手册"));
Assert.assertTrue(text.contains("Hello World"));
Assert.assertTrue(text.contains("System.out.println"));
Assert.assertTrue(text.contains("你好"));
Assert.assertFalse(text.contains("# Java 入门手册"));
Assert.assertFalse(text.contains("## Hello World"));
Assert.assertFalse(text.contains("```java"));
}
}
@Test
public void testPdfRendererShouldBlockExternalResources() {
PdfFileRenderer renderer = new PdfFileRenderer(new HtmlDocumentBuilder(new MarkdownSupport(), new HtmlSanitizer()) {
@Override
public String buildDocument(FileGenerationRequest request) {
return """
<!DOCTYPE html>
<html>
<body>
<p>blocked resource</p>
<img src="https://example.com/demo.png" alt="remote" />
</body>
</html>
""";
}
});
FileGenerationResult result = renderer.render(new FileGenerationRequest(
"ignored",
"html",
"pdf",
"demo.pdf",
"default"
));
Assert.assertTrue(result.getBytes().length > 512);
Assert.assertEquals("%PDF", new String(result.getBytes(), 0, 4));
Assert.assertNull(PdfFileRenderer.resolveBlockedUri("https://base.example", "https://example.com/demo.png"));
Assert.assertFalse(PdfFileRenderer.denyExternalResource("https://example.com/demo.png", ExternalResourceType.IMAGE_RASTER));
}
@Test
public void testPdfRendererShouldFailFastWhenFontMissing() {
IllegalStateException exception = Assert.assertThrows(
IllegalStateException.class,
() -> new PdfFileRenderer(
new HtmlDocumentBuilder(new MarkdownSupport(), new HtmlSanitizer()),
"fonts/missing-font.otf"
)
);
Assert.assertTrue(exception.getMessage().contains("字体资源缺失"));
}
@Test
public void testDocxRendererShouldRenderMarkdownBlocks() throws Exception {
DocxFileRenderer renderer = new DocxFileRenderer(new MarkdownSupport());
FileGenerationResult result = renderer.render(new FileGenerationRequest(
"# 标题\n\n正文 **加粗** 与 [链接](https://example.com)\n\n- 列表项\n\n> 引用内容\n\n| 列1 | 列2 |\n| --- | --- |\n| A | B |",
"markdown",
"docx",
"demo.docx",
"default"
));
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(result.getBytes()))) {
Assert.assertTrue(document.getParagraphs().stream().anyMatch(item -> item.getText().contains("标题")));
Assert.assertTrue(document.getParagraphs().stream().anyMatch(item -> item.getText().contains("列表项")));
Assert.assertTrue(document.getParagraphs().stream().anyMatch(item -> item.getText().contains("引用内容")));
Assert.assertTrue(document.getParagraphs().stream().anyMatch(item -> item.getText().contains("链接")));
Assert.assertTrue(document.getHyperlinks().length > 0);
Assert.assertEquals("https://example.com", document.getHyperlinks()[0].getURL());
Assert.assertEquals(1, document.getTables().size());
Assert.assertEquals("列1", document.getTables().get(0).getRow(0).getCell(0).getText());
Assert.assertEquals("A", document.getTables().get(0).getRow(1).getCell(0).getText());
}
}
}