feat: 支持工作流文件节点多格式文档导出
- 补齐 md/html/pdf/docx 导出与统一渲染服务 - 收口文件生成节点配置与格式校验 - 修复 PDF 中文字体与 Markdown 渲染链路
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
"\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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user