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

@@ -80,6 +80,14 @@
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-tables</artifactId>
</dependency>
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-system</artifactId>

View File

@@ -11,6 +11,9 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.node.filegeneration.FileGenerationRules;
import tech.easyflow.ai.node.filegeneration.SourceFormat;
import tech.easyflow.ai.node.filegeneration.TargetFormat;
import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.PluginItemService;
@@ -45,6 +48,7 @@ public class WorkflowCheckService {
private static final String TYPE_LOOP = "loopNode";
private static final String TYPE_WORKFLOW = "workflow-node";
private static final String TYPE_PLUGIN = "plugin-node";
private static final String TYPE_MAKE_FILE = "make-file";
private static final String SYSTEM_START_PARAM_NAME = "user_input";
@Resource
@@ -222,6 +226,10 @@ public class WorkflowCheckService {
if (node == null) {
continue;
}
if (TYPE_MAKE_FILE.equals(node.type)) {
checkMakeFileNode(node, issues, issueKeys);
continue;
}
if (workflowDatacenterContentService.isSearchDatasetNode(node.type)) {
checkSearchDatasetNode(node, issues, issueKeys);
continue;
@@ -236,6 +244,26 @@ public class WorkflowCheckService {
}
}
private void checkMakeFileNode(NodeView node,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
try {
String targetFormatValue = node.data == null ? null : trimToNull(node.data.getString("targetFormat"));
if (!StringUtils.hasText(targetFormatValue)) {
targetFormatValue = TargetFormat.DOCX.getValue();
}
String sourceFormatValue = node.data == null ? null : trimToNull(node.data.getString("sourceFormat"));
if (!StringUtils.hasText(sourceFormatValue)) {
sourceFormatValue = SourceFormat.MARKDOWN.getValue();
}
TargetFormat targetFormat = TargetFormat.fromValue(targetFormatValue);
SourceFormat sourceFormat = SourceFormat.fromValue(sourceFormatValue);
FileGenerationRules.requireSupportedCombination(sourceFormat, targetFormat);
} catch (BusinessException e) {
addIssue(issues, issueKeys, "MAKE_FILE_INVALID", e.getMessage(), node.id, null, node.name);
}
}
private void checkSearchDatasetNode(NodeView node,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {

View File

@@ -13,10 +13,16 @@ public class CustomFile implements MultipartFile {
private final String fileName;
private final byte[] bytes;
private final String contentType;
public CustomFile(String fileName, byte[] bytes) {
this(fileName, bytes, null);
}
public CustomFile(String fileName, byte[] bytes, String contentType) {
this.fileName = fileName;
this.bytes = bytes;
this.contentType = contentType;
}
@Override
@@ -31,6 +37,9 @@ public class CustomFile implements MultipartFile {
@Override
public String getContentType() {
if (contentType != null && !contentType.isBlank()) {
return contentType;
}
Tika tika = new Tika();
InputStream inputStream = new ByteArrayInputStream(bytes);
String contentType = "";

View File

@@ -1,101 +1,100 @@
package tech.easyflow.ai.node;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.node.BaseNode;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.poi.xwpf.usermodel.XWPFStyle;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTInd;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPrGeneral;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyle;
import tech.easyflow.ai.node.filegeneration.FileGenerationRequest;
import tech.easyflow.ai.node.filegeneration.FileGenerationResult;
import tech.easyflow.ai.node.filegeneration.FileGenerationService;
import tech.easyflow.common.filestorage.FileStorageManager;
import tech.easyflow.common.util.SpringContextUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 工作流文件导出节点。
*
* <p>节点只负责解析运行时参数、委托导出服务生成文件内容,并通过现有文件存储返回下载地址。</p>
*
* @author Codex
* @since 2026-04-18
*/
public class MakeFileNode extends BaseNode {
private String suffix;
private String targetFormat;
private String sourceFormat;
private String fileName;
private String templateStyle;
public MakeFileNode() {
}
public MakeFileNode(String suffix) {
this.suffix = suffix;
public MakeFileNode(String targetFormat, String sourceFormat, String fileName, String templateStyle) {
this.targetFormat = targetFormat;
this.sourceFormat = sourceFormat;
this.fileName = fileName;
this.templateStyle = templateStyle;
}
/**
* 执行文件导出。
*
* @param chain 当前流程链
* @return 仅包含下载地址的输出对象
*/
@Override
public Map<String, Object> execute(Chain chain) {
Map<String, Object> map = chain.getState().resolveParameters(this);
Map<String, Object> res = new HashMap<>();
String content = map.get("content").toString();
ByteArrayOutputStream os = new ByteArrayOutputStream();
createFile(suffix, content, os);
String fileName = IdUtil.fastSimpleUUID() + "." + suffix;
CustomFile file = new CustomFile(fileName, os.toByteArray());
Object rawContent = map.get("content");
if (rawContent == null) {
throw new BusinessException("文件生成节点缺少 content 参数");
}
FileGenerationRequest request = new FileGenerationRequest(
rawContent.toString(),
sourceFormat,
targetFormat,
fileName,
templateStyle
);
FileGenerationService generationService = SpringContextUtil.getBean(FileGenerationService.class);
FileGenerationResult result = generationService.generate(request);
FileStorageManager manager = SpringContextUtil.getBean(FileStorageManager.class);
String url = manager.save(file);
Map<String, Object> res = new HashMap<>();
String url = manager.save(new CustomFile(result.getFileName(), result.getBytes(), result.getContentType()));
res.put("url", url);
return res;
}
private void createFile(String suffix, String content, ByteArrayOutputStream os) {
if ("docx".equals(suffix)) {
docx(content, os);
}
public String getTargetFormat() {
return targetFormat;
}
private void docx(String content, ByteArrayOutputStream os) {
String separator = "\n";
List<String> split = StrUtil.split(content, separator);
// 创建一个新的Word文档
XWPFDocument doc = new XWPFDocument();
// 创建样式
// CTStyle ctStyle = CTStyle.Factory.newInstance();
// ctStyle.setStyleId("IndentStyle");
// CTPPrGeneral pPr = ctStyle.addNewPPr();
// CTInd ind = pPr.addNewInd();
// ind.setFirstLine(400);
// doc.createStyles().addStyle(new XWPFStyle(ctStyle));
for (String str : split) {
// 创建段落
XWPFParagraph paragraph = doc.createParagraph();
paragraph.setStyle("IndentStyle");
XWPFRun run = paragraph.createRun();
run.setText(str);
}
try {
doc.write(os);
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
try {
os.close();
doc.close();
} catch (IOException e) {
System.out.println("关闭流异常" + e.getMessage());
}
}
public void setTargetFormat(String targetFormat) {
this.targetFormat = targetFormat;
}
public String getSuffix() {
return suffix;
public String getSourceFormat() {
return sourceFormat;
}
public void setSuffix(String suffix) {
this.suffix = suffix;
public void setSourceFormat(String sourceFormat) {
this.sourceFormat = sourceFormat;
}
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public String getTemplateStyle() {
return templateStyle;
}
public void setTemplateStyle(String templateStyle) {
this.templateStyle = templateStyle;
}
}

View File

@@ -4,16 +4,28 @@ import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject;
import com.easyagents.flow.core.node.BaseNode;
import com.easyagents.flow.core.parser.BaseNodeParser;
import tech.easyflow.ai.node.filegeneration.FileGenerationRules;
import tech.easyflow.ai.node.filegeneration.SourceFormat;
import tech.easyflow.ai.node.filegeneration.TargetFormat;
public class MakeFileNodeParser extends BaseNodeParser {
@Override
public BaseNode doParse(JSONObject root, JSONObject data, JSONObject tinyflow) {
String suffix = data.getString("suffix");
if (StrUtil.isEmpty(suffix)) {
suffix = "docx";
String targetFormatValue = data == null ? null : data.getString("targetFormat");
if (StrUtil.isBlank(targetFormatValue)) {
targetFormatValue = TargetFormat.DOCX.getValue();
}
return new MakeFileNode(suffix);
String sourceFormatValue = data == null ? null : data.getString("sourceFormat");
if (StrUtil.isBlank(sourceFormatValue)) {
sourceFormatValue = SourceFormat.MARKDOWN.getValue();
}
String fileName = data == null ? null : StrUtil.trimToNull(data.getString("fileName"));
String templateStyle = FileGenerationRules.normalizeTemplateStyle(data == null ? null : data.getString("templateStyle"));
TargetFormat targetFormat = TargetFormat.fromValue(targetFormatValue);
SourceFormat sourceFormat = SourceFormat.fromValue(sourceFormatValue);
FileGenerationRules.requireSupportedCombination(sourceFormat, targetFormat);
return new MakeFileNode(targetFormat.getValue(), sourceFormat.getValue(), fileName, templateStyle);
}
public String getNodeName() {

View File

@@ -0,0 +1,33 @@
package tech.easyflow.ai.node.filegeneration;
/**
* 文档格式渲染器。
*
* @author Codex
* @since 2026-04-18
*/
public interface DocumentRenderer {
/**
* 当前渲染器对应的目标格式。
*
* @return 目标格式
*/
TargetFormat getTargetFormat();
/**
* 判断当前渲染器是否支持给定输入格式。
*
* @param sourceFormat 输入格式
* @return 是否支持
*/
boolean supports(SourceFormat sourceFormat);
/**
* 执行文档渲染。
*
* @param request 已归一化的导出请求
* @return 导出结果
*/
FileGenerationResult render(FileGenerationRequest request);
}

View File

@@ -0,0 +1,446 @@
package tech.easyflow.ai.node.filegeneration;
import org.apache.poi.xwpf.usermodel.*;
import org.commonmark.ext.gfm.tables.TableBlock;
import org.commonmark.ext.gfm.tables.TableBody;
import org.commonmark.ext.gfm.tables.TableCell;
import org.commonmark.ext.gfm.tables.TableHead;
import org.commonmark.ext.gfm.tables.TableRow;
import org.commonmark.node.*;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* DOCX 文件渲染器。
*
* @author Codex
* @since 2026-04-18
*/
@Component
public class DocxFileRenderer implements DocumentRenderer {
private final MarkdownSupport markdownSupport;
public DocxFileRenderer(MarkdownSupport markdownSupport) {
this.markdownSupport = markdownSupport;
}
@Override
public TargetFormat getTargetFormat() {
return TargetFormat.DOCX;
}
@Override
public boolean supports(SourceFormat sourceFormat) {
return sourceFormat == SourceFormat.PLAIN_TEXT || sourceFormat == SourceFormat.MARKDOWN;
}
@Override
public FileGenerationResult render(FileGenerationRequest request) {
try (XWPFDocument document = new XWPFDocument();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
SourceFormat sourceFormat = SourceFormat.fromValue(request.getSourceFormat());
if (sourceFormat == SourceFormat.PLAIN_TEXT) {
renderPlainText(document, request.getContent());
} else {
renderMarkdown(document, request.getContent());
}
document.write(outputStream);
return new FileGenerationResult(
request.getFileName(),
TargetFormat.DOCX.getValue(),
TargetFormat.DOCX.getContentType(),
outputStream.toByteArray()
);
} catch (IOException e) {
throw new IllegalStateException("DOCX 导出失败: " + e.getMessage(), e);
}
}
private void renderPlainText(XWPFDocument document, String content) {
String normalized = content == null ? "" : content.replace("\r\n", "\n").replace('\r', '\n');
String[] lines = normalized.split("\\n", -1);
if (lines.length == 0) {
lines = new String[]{""};
}
for (String line : lines) {
XWPFParagraph paragraph = document.createParagraph();
paragraph.setSpacingAfter(180);
XWPFRun run = paragraph.createRun();
run.setFontSize(11);
run.setText(line);
}
}
private void renderMarkdown(XWPFDocument document, String markdown) {
Node root = markdownSupport.parse(markdown);
for (Node child = root.getFirstChild(); child != null; child = child.getNext()) {
renderBlock(child, document, 0, 0);
}
}
private void renderBlock(Node node, XWPFDocument document, int listLevel, int quoteLevel) {
if (node instanceof Heading heading) {
renderHeading(heading, document, listLevel, quoteLevel);
return;
}
if (node instanceof Paragraph paragraphNode) {
renderParagraph(paragraphNode, document, listLevel, quoteLevel, null);
return;
}
if (node instanceof BulletList bulletList) {
renderBulletList(bulletList, document, listLevel + 1, quoteLevel);
return;
}
if (node instanceof OrderedList orderedList) {
renderOrderedList(orderedList, document, listLevel + 1, quoteLevel);
return;
}
if (node instanceof BlockQuote blockQuote) {
for (Node child = blockQuote.getFirstChild(); child != null; child = child.getNext()) {
renderBlock(child, document, listLevel, quoteLevel + 1);
}
return;
}
if (node instanceof ThematicBreak) {
XWPFParagraph paragraph = createParagraph(document, listLevel, quoteLevel);
XWPFRun run = paragraph.createRun();
run.setColor("94A3B8");
run.setText("────────────────────────");
return;
}
if (node instanceof TableBlock tableBlock) {
renderTable(tableBlock, document);
return;
}
if (node instanceof FencedCodeBlock fencedCodeBlock) {
renderCodeParagraph(document, fencedCodeBlock.getLiteral(), listLevel, quoteLevel);
return;
}
if (node instanceof IndentedCodeBlock indentedCodeBlock) {
renderCodeParagraph(document, indentedCodeBlock.getLiteral(), listLevel, quoteLevel);
return;
}
for (Node child = node.getFirstChild(); child != null; child = child.getNext()) {
renderBlock(child, document, listLevel, quoteLevel);
}
}
private void renderHeading(Heading heading, XWPFDocument document, int listLevel, int quoteLevel) {
XWPFParagraph paragraph = createParagraph(document, listLevel, quoteLevel);
paragraph.setSpacingBefore(240);
paragraph.setSpacingAfter(180);
XWPFRun run = paragraph.createRun();
run.setBold(true);
run.setFontSize(switch (heading.getLevel()) {
case 1 -> 20;
case 2 -> 18;
case 3 -> 16;
case 4 -> 15;
case 5 -> 14;
default -> 13;
});
renderInlineChildren(heading, paragraph, InlineStyle.defaults().withBold(true).withFontSize(run.getFontSize()));
}
private void renderBulletList(BulletList list, XWPFDocument document, int listLevel, int quoteLevel) {
for (Node child = list.getFirstChild(); child != null; child = child.getNext()) {
if (child instanceof ListItem listItem) {
renderListItem(listItem, document, listLevel, quoteLevel, "- ");
}
}
}
private void renderOrderedList(OrderedList list, XWPFDocument document, int listLevel, int quoteLevel) {
int index = list.getStartNumber();
for (Node child = list.getFirstChild(); child != null; child = child.getNext()) {
if (child instanceof ListItem listItem) {
renderListItem(listItem, document, listLevel, quoteLevel, index + ". ");
index++;
}
}
}
private void renderListItem(ListItem listItem, XWPFDocument document, int listLevel, int quoteLevel, String marker) {
boolean markerRendered = false;
for (Node child = listItem.getFirstChild(); child != null; child = child.getNext()) {
if (child instanceof Paragraph paragraphNode) {
renderParagraph(paragraphNode, document, listLevel, quoteLevel, markerRendered ? null : marker);
markerRendered = true;
continue;
}
if (child instanceof BulletList bulletList) {
renderBulletList(bulletList, document, listLevel + 1, quoteLevel);
continue;
}
if (child instanceof OrderedList orderedList) {
renderOrderedList(orderedList, document, listLevel + 1, quoteLevel);
continue;
}
renderBlock(child, document, listLevel, quoteLevel);
}
if (!markerRendered) {
XWPFParagraph paragraph = createParagraph(document, listLevel, quoteLevel);
appendPrefix(paragraph, quoteLevel, marker);
}
}
private void renderParagraph(Node paragraphNode,
XWPFDocument document,
int listLevel,
int quoteLevel,
String prefix) {
XWPFParagraph paragraph = createParagraph(document, listLevel, quoteLevel);
appendPrefix(paragraph, quoteLevel, prefix);
renderInlineChildren(paragraphNode, paragraph, InlineStyle.defaults());
}
private void renderCodeParagraph(XWPFDocument document, String literal, int listLevel, int quoteLevel) {
XWPFParagraph paragraph = createParagraph(document, listLevel, quoteLevel);
XWPFRun run = paragraph.createRun();
run.setFontFamily("Consolas");
run.setFontSize(10);
run.setText(literal == null ? "" : literal);
}
private void renderInlineChildren(Node parent, XWPFParagraph paragraph, InlineStyle style) {
for (Node child = parent.getFirstChild(); child != null; child = child.getNext()) {
renderInlineNode(child, paragraph, style);
}
}
private void renderInlineNode(Node node, XWPFParagraph paragraph, InlineStyle style) {
if (node instanceof Text text) {
createStyledRun(paragraph, style, text.getLiteral());
return;
}
if (node instanceof SoftLineBreak || node instanceof HardLineBreak) {
XWPFRun run = paragraph.createRun();
applyStyle(run, style);
run.addBreak();
return;
}
if (node instanceof Emphasis emphasis) {
renderInlineChildren(emphasis, paragraph, style.withItalic(true));
return;
}
if (node instanceof StrongEmphasis strongEmphasis) {
renderInlineChildren(strongEmphasis, paragraph, style.withBold(true));
return;
}
if (node instanceof Link link) {
String label = collectInlineText(link);
if (label.isBlank()) {
label = link.getDestination();
}
createHyperlinkRun(paragraph, style, label, link.getDestination());
return;
}
if (node instanceof Code code) {
XWPFRun run = paragraph.createRun();
applyStyle(run, style);
run.setFontFamily("Consolas");
run.setText(code.getLiteral());
return;
}
if (node instanceof HtmlInline htmlInline) {
createStyledRun(paragraph, style, htmlInline.getLiteral());
return;
}
if (node instanceof Image image) {
String altText = collectInlineText(image);
createStyledRun(paragraph, style, altText.isBlank() ? "[图片]" : altText);
return;
}
renderInlineChildren(node, paragraph, style);
}
private void renderTable(TableBlock tableBlock, XWPFDocument document) {
List<TableRowContent> rows = collectTableRows(tableBlock);
if (rows.isEmpty()) {
return;
}
int columnCount = rows.stream().mapToInt(item -> item.cells.size()).max().orElse(1);
XWPFTable table = document.createTable(rows.size(), columnCount);
for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) {
TableRowContent rowContent = rows.get(rowIndex);
XWPFTableRow tableRow = table.getRow(rowIndex);
for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) {
XWPFTableCell tableCell = tableRow.getCell(columnIndex);
clearCell(tableCell);
if (columnIndex < rowContent.cells.size()) {
XWPFParagraph paragraph = tableCell.addParagraph();
paragraph.setSpacingAfter(80);
renderInlineChildren(
rowContent.cells.get(columnIndex),
paragraph,
rowContent.header ? InlineStyle.defaults().withBold(true) : InlineStyle.defaults()
);
}
}
}
}
private List<TableRowContent> collectTableRows(TableBlock tableBlock) {
List<TableRowContent> rows = new ArrayList<>();
for (Node section = tableBlock.getFirstChild(); section != null; section = section.getNext()) {
boolean header = section instanceof TableHead;
if (!(section instanceof TableHead) && !(section instanceof TableBody)) {
continue;
}
for (Node rowNode = section.getFirstChild(); rowNode != null; rowNode = rowNode.getNext()) {
if (!(rowNode instanceof TableRow)) {
continue;
}
List<TableCell> cells = new ArrayList<>();
for (Node cellNode = rowNode.getFirstChild(); cellNode != null; cellNode = cellNode.getNext()) {
if (cellNode instanceof TableCell tableCell) {
cells.add(tableCell);
}
}
rows.add(new TableRowContent(header, cells));
}
}
return rows;
}
private void clearCell(XWPFTableCell tableCell) {
while (tableCell.getParagraphs().size() > 0) {
tableCell.removeParagraph(0);
}
}
private XWPFParagraph createParagraph(XWPFDocument document, int listLevel, int quoteLevel) {
XWPFParagraph paragraph = document.createParagraph();
paragraph.setSpacingAfter(140);
paragraph.setIndentationLeft((listLevel + quoteLevel) * 320);
return paragraph;
}
private void appendPrefix(XWPFParagraph paragraph, int quoteLevel, String prefix) {
if (quoteLevel > 0) {
createStyledRun(paragraph, InlineStyle.defaults().withColor("64748B"),
"> ".repeat(Math.max(1, quoteLevel)));
}
if (prefix != null && !prefix.isBlank()) {
createStyledRun(paragraph, InlineStyle.defaults().withBold(true), prefix);
}
}
private void createStyledRun(XWPFParagraph paragraph, InlineStyle style, String text) {
XWPFRun run = paragraph.createRun();
applyStyle(run, style);
run.setText(text == null ? "" : text);
}
/**
* 创建外部超链接;内部锚点与空链接首版退化为普通文本。
*
* @param paragraph 目标段落
* @param style 当前内联样式
* @param label 链接文案
* @param destination 链接地址
*/
private void createHyperlinkRun(XWPFParagraph paragraph, InlineStyle style, String label, String destination) {
if (destination == null || destination.isBlank() || destination.startsWith("#")) {
createStyledRun(paragraph, style.withUnderline(true).withColor("2563EB"), label);
return;
}
XWPFHyperlinkRun hyperlinkRun = paragraph.createHyperlinkRun(destination);
applyStyle(hyperlinkRun, style.withUnderline(true).withColor("2563EB"));
hyperlinkRun.setText(label == null ? "" : label);
}
private void applyStyle(XWPFRun run, InlineStyle style) {
run.setBold(style.bold);
run.setItalic(style.italic);
if (style.underline) {
run.setUnderline(UnderlinePatterns.SINGLE);
}
if (style.color != null) {
run.setColor(style.color);
}
if (style.fontSize != null) {
run.setFontSize(style.fontSize);
} else {
run.setFontSize(11);
}
}
private String collectInlineText(Node node) {
StringBuilder builder = new StringBuilder();
appendInlineText(node, builder);
return builder.toString();
}
private void appendInlineText(Node node, StringBuilder builder) {
if (node instanceof Text text) {
builder.append(text.getLiteral());
return;
}
if (node instanceof Code code) {
builder.append(code.getLiteral());
return;
}
if (node instanceof SoftLineBreak || node instanceof HardLineBreak) {
builder.append('\n');
return;
}
for (Node child = node.getFirstChild(); child != null; child = child.getNext()) {
appendInlineText(child, builder);
}
}
private static class TableRowContent {
private final boolean header;
private final List<TableCell> cells;
private TableRowContent(boolean header, List<TableCell> cells) {
this.header = header;
this.cells = cells;
}
}
private static class InlineStyle {
private final boolean bold;
private final boolean italic;
private final boolean underline;
private final String color;
private final Integer fontSize;
private InlineStyle(boolean bold, boolean italic, boolean underline, String color, Integer fontSize) {
this.bold = bold;
this.italic = italic;
this.underline = underline;
this.color = color;
this.fontSize = fontSize;
}
private static InlineStyle defaults() {
return new InlineStyle(false, false, false, null, null);
}
private InlineStyle withBold(boolean value) {
return new InlineStyle(value, italic, underline, color, fontSize);
}
private InlineStyle withItalic(boolean value) {
return new InlineStyle(bold, value, underline, color, fontSize);
}
private InlineStyle withUnderline(boolean value) {
return new InlineStyle(bold, italic, value, color, fontSize);
}
private InlineStyle withColor(String value) {
return new InlineStyle(bold, italic, underline, value, fontSize);
}
private InlineStyle withFontSize(Integer value) {
return new InlineStyle(bold, italic, underline, color, value);
}
}
}

View File

@@ -0,0 +1,47 @@
package tech.easyflow.ai.node.filegeneration;
/**
* 文档导出请求。
*
* @author Codex
* @since 2026-04-18
*/
public class FileGenerationRequest {
private final String content;
private final String sourceFormat;
private final String targetFormat;
private final String fileName;
private final String templateStyle;
public FileGenerationRequest(String content,
String sourceFormat,
String targetFormat,
String fileName,
String templateStyle) {
this.content = content;
this.sourceFormat = sourceFormat;
this.targetFormat = targetFormat;
this.fileName = fileName;
this.templateStyle = templateStyle;
}
public String getContent() {
return content;
}
public String getSourceFormat() {
return sourceFormat;
}
public String getTargetFormat() {
return targetFormat;
}
public String getFileName() {
return fileName;
}
public String getTemplateStyle() {
return templateStyle;
}
}

View File

@@ -0,0 +1,43 @@
package tech.easyflow.ai.node.filegeneration;
/**
* 文档导出结果。
*
* @author Codex
* @since 2026-04-18
*/
public class FileGenerationResult {
private final String fileName;
private final String targetFormat;
private final String contentType;
private final byte[] bytes;
private final long size;
public FileGenerationResult(String fileName, String targetFormat, String contentType, byte[] bytes) {
this.fileName = fileName;
this.targetFormat = targetFormat;
this.contentType = contentType;
this.bytes = bytes;
this.size = bytes == null ? 0L : bytes.length;
}
public String getFileName() {
return fileName;
}
public String getTargetFormat() {
return targetFormat;
}
public String getContentType() {
return contentType;
}
public byte[] getBytes() {
return bytes;
}
public long getSize() {
return size;
}
}

View File

@@ -0,0 +1,60 @@
package tech.easyflow.ai.node.filegeneration;
import tech.easyflow.common.web.exceptions.BusinessException;
/**
* 文件导出节点的固定规则集合。
*
* @author Codex
* @since 2026-04-18
*/
public final class FileGenerationRules {
public static final String DEFAULT_TEMPLATE_STYLE = "default";
private FileGenerationRules() {
}
/**
* 归一模板样式。
*
* @param templateStyle 原始样式值
* @return 可用的模板样式
*/
public static String normalizeTemplateStyle(String templateStyle) {
if (templateStyle == null || templateStyle.isBlank()) {
return DEFAULT_TEMPLATE_STYLE;
}
return DEFAULT_TEMPLATE_STYLE.equalsIgnoreCase(templateStyle.trim())
? DEFAULT_TEMPLATE_STYLE
: DEFAULT_TEMPLATE_STYLE;
}
/**
* 校验输入/输出格式组合是否合法。
*
* @param sourceFormat 输入格式
* @param targetFormat 输出格式
*/
public static void requireSupportedCombination(SourceFormat sourceFormat, TargetFormat targetFormat) {
if (!isSupportedCombination(sourceFormat, targetFormat)) {
throw new BusinessException("文件生成节点暂不支持 " + sourceFormat.getValue() + " -> " + targetFormat.getValue() + " 组合");
}
}
/**
* 判断输入/输出格式组合是否受支持。
*
* @param sourceFormat 输入格式
* @param targetFormat 输出格式
* @return 是否受支持
*/
public static boolean isSupportedCombination(SourceFormat sourceFormat, TargetFormat targetFormat) {
if (sourceFormat == null || targetFormat == null) {
return false;
}
if (sourceFormat == SourceFormat.HTML) {
return targetFormat == TargetFormat.HTML || targetFormat == TargetFormat.PDF;
}
return true;
}
}

View File

@@ -0,0 +1,65 @@
package tech.easyflow.ai.node.filegeneration;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/**
* 文件导出服务。
*
* @author Codex
* @since 2026-04-18
*/
@Service
public class FileGenerationService {
private final Map<TargetFormat, DocumentRenderer> rendererMap = new EnumMap<>(TargetFormat.class);
public FileGenerationService(List<DocumentRenderer> renderers) {
for (DocumentRenderer renderer : renderers) {
rendererMap.put(renderer.getTargetFormat(), renderer);
}
}
/**
* 生成目标格式文件。
*
* @param request 原始导出请求
* @return 文件导出结果
*/
public FileGenerationResult generate(FileGenerationRequest request) {
if (request == null) {
throw new BusinessException("文件导出请求不能为空");
}
if (request.getContent() == null) {
throw new BusinessException("文件导出内容不能为空");
}
TargetFormat targetFormat = TargetFormat.fromValue(request.getTargetFormat());
SourceFormat sourceFormat = SourceFormat.fromValue(request.getSourceFormat());
FileGenerationRules.requireSupportedCombination(sourceFormat, targetFormat);
DocumentRenderer renderer = rendererMap.get(targetFormat);
if (renderer == null) {
throw new BusinessException("未找到 " + targetFormat.getValue() + " 对应的文件渲染器");
}
if (!renderer.supports(sourceFormat)) {
throw new BusinessException("文件生成节点暂不支持 " + sourceFormat.getValue() + " -> " + targetFormat.getValue() + " 组合");
}
String normalizedTemplateStyle = FileGenerationRules.normalizeTemplateStyle(request.getTemplateStyle());
String normalizedFileName = FileNameSanitizer.sanitize(request.getFileName(), targetFormat);
FileGenerationRequest normalizedRequest = new FileGenerationRequest(
request.getContent(),
sourceFormat.getValue(),
targetFormat.getValue(),
normalizedFileName,
normalizedTemplateStyle
);
FileGenerationResult result = renderer.render(normalizedRequest);
if (result == null || !StringUtils.hasText(result.getFileName()) || result.getBytes() == null) {
throw new IllegalStateException("文件导出失败:渲染结果不完整");
}
return result;
}
}

View File

@@ -0,0 +1,54 @@
package tech.easyflow.ai.node.filegeneration;
import cn.hutool.core.util.IdUtil;
import org.springframework.util.StringUtils;
/**
* 文件名清洗工具。
*
* @author Codex
* @since 2026-04-18
*/
public final class FileNameSanitizer {
private static final String INVALID_CHARS_PATTERN = "[\\\\/:*?\"<>|\\p{Cntrl}]";
private FileNameSanitizer() {
}
/**
* 清洗并补齐目标扩展名。
*
* @param rawFileName 原始文件名
* @param targetFormat 目标格式
* @return 清洗后的最终文件名
*/
public static String sanitize(String rawFileName, TargetFormat targetFormat) {
String baseName = sanitizeBaseName(rawFileName);
if (!StringUtils.hasText(baseName)) {
baseName = "generated-file-" + IdUtil.fastSimpleUUID();
}
return removeTrailingExtension(baseName) + "." + targetFormat.getValue();
}
/**
* 清洗不含扩展名的文件名主体。
*
* @param rawFileName 原始文件名
* @return 清洗后的文件名主体
*/
public static String sanitizeBaseName(String rawFileName) {
if (!StringUtils.hasText(rawFileName)) {
return null;
}
String sanitized = rawFileName.trim().replaceAll(INVALID_CHARS_PATTERN, "");
return sanitized.isBlank() ? null : sanitized;
}
private static String removeTrailingExtension(String fileName) {
int lastDot = fileName.lastIndexOf('.');
if (lastDot <= 0) {
return fileName;
}
return fileName.substring(0, lastDot);
}
}

View File

@@ -0,0 +1,153 @@
package tech.easyflow.ai.node.filegeneration;
import org.jsoup.nodes.Entities;
import org.springframework.stereotype.Component;
import java.util.regex.Pattern;
/**
* HTML 文档构建器。
*
* @author Codex
* @since 2026-04-18
*/
@Component
public class HtmlDocumentBuilder {
private static final Pattern BLANK_LINE_PATTERN = Pattern.compile("\\n\\s*\\n");
private final MarkdownSupport markdownSupport;
private final HtmlSanitizer htmlSanitizer;
public HtmlDocumentBuilder(MarkdownSupport markdownSupport, HtmlSanitizer htmlSanitizer) {
this.markdownSupport = markdownSupport;
this.htmlSanitizer = htmlSanitizer;
}
/**
* 构建完整 HTML 文档。
*
* @param request 已归一化请求
* @return 完整 HTML 文档字符串
*/
public String buildDocument(FileGenerationRequest request) {
String title = Entities.escape(request.getFileName());
String body = buildBody(request);
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>__TITLE__</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
background: #f5f7fb;
color: #1f2937;
font-family: "EasyFlowPdfCjk", "Noto Sans CJK SC", "Microsoft YaHei", "PingFang SC", sans-serif;
}
.document-shell {
max-width: 960px;
margin: 0 auto;
padding: 40px 32px 56px;
}
.document-paper {
background: #ffffff;
border: 1px solid #dbe3ef;
border-radius: 20px;
padding: 40px 44px;
}
h1, h2, h3, h4, h5, h6 {
color: #0f172a;
margin-top: 1.4em;
margin-bottom: 0.6em;
line-height: 1.3;
}
h1 { font-size: 30px; }
h2 { font-size: 24px; }
h3 { font-size: 20px; }
p, li, blockquote, td, th {
font-size: 15px;
line-height: 1.75;
}
p { margin: 0 0 1em; white-space: normal; }
ul, ol { padding-left: 1.5em; margin: 0.4em 0 1em; }
blockquote {
margin: 1em 0;
padding: 12px 16px;
border-left: 4px solid #93c5fd;
background: #eff6ff;
color: #334155;
}
hr {
border: none;
border-top: 1px solid #dbe3ef;
margin: 1.5em 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1em 0 1.4em;
}
th, td {
border: 1px solid #dbe3ef;
padding: 10px 12px;
vertical-align: top;
}
th {
background: #eff6ff;
color: #0f172a;
font-weight: 600;
}
a { color: #2563eb; text-decoration: underline; }
code, pre {
font-family: "SFMono-Regular", "Consolas", "EasyFlowPdfCjk", "Noto Sans CJK SC", "Microsoft YaHei", monospace;
}
pre {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 14px 16px;
}
</style>
</head>
<body>
<main class="document-shell">
<article class="document-paper">__BODY__</article>
</main>
</body>
</html>
"""
.replace("__TITLE__", title)
.replace("__BODY__", body);
}
private String buildBody(FileGenerationRequest request) {
SourceFormat sourceFormat = SourceFormat.fromValue(request.getSourceFormat());
return switch (sourceFormat) {
case PLAIN_TEXT -> plainTextToHtml(request.getContent());
case MARKDOWN -> htmlSanitizer.sanitize(markdownSupport.renderHtml(request.getContent()));
case HTML -> htmlSanitizer.sanitize(request.getContent());
};
}
private String plainTextToHtml(String content) {
String normalized = content == null ? "" : content.replace("\r\n", "\n").replace('\r', '\n');
String[] paragraphs = BLANK_LINE_PATTERN.split(normalized, -1);
StringBuilder builder = new StringBuilder();
for (String paragraph : paragraphs) {
if (builder.length() > 0) {
builder.append('\n');
}
String escaped = Entities.escape(paragraph);
builder.append("<p>")
.append(escaped.replace("\n", "<br />"))
.append("</p>");
}
if (builder.length() == 0) {
builder.append("<p></p>");
}
return builder.toString();
}
}

View File

@@ -0,0 +1,43 @@
package tech.easyflow.ai.node.filegeneration;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
/**
* HTML 文件渲染器。
*
* @author Codex
* @since 2026-04-18
*/
@Component
public class HtmlFileRenderer implements DocumentRenderer {
private final HtmlDocumentBuilder htmlDocumentBuilder;
public HtmlFileRenderer(HtmlDocumentBuilder htmlDocumentBuilder) {
this.htmlDocumentBuilder = htmlDocumentBuilder;
}
@Override
public TargetFormat getTargetFormat() {
return TargetFormat.HTML;
}
@Override
public boolean supports(SourceFormat sourceFormat) {
return sourceFormat == SourceFormat.PLAIN_TEXT
|| sourceFormat == SourceFormat.MARKDOWN
|| sourceFormat == SourceFormat.HTML;
}
@Override
public FileGenerationResult render(FileGenerationRequest request) {
byte[] bytes = htmlDocumentBuilder.buildDocument(request).getBytes(StandardCharsets.UTF_8);
return new FileGenerationResult(
request.getFileName(),
TargetFormat.HTML.getValue(),
TargetFormat.HTML.getContentType(),
bytes
);
}
}

View File

@@ -0,0 +1,42 @@
package tech.easyflow.ai.node.filegeneration;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.safety.Safelist;
import org.springframework.stereotype.Component;
/**
* HTML 清洗组件。
*
* @author Codex
* @since 2026-04-18
*/
@Component
public class HtmlSanitizer {
private final Safelist safelist;
public HtmlSanitizer() {
this.safelist = Safelist.relaxed()
.addTags("table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col")
.addAttributes("table", "border", "cellpadding", "cellspacing")
.addAttributes("th", "colspan", "rowspan", "scope", "align")
.addAttributes("td", "colspan", "rowspan", "align")
.addAttributes("col", "span", "width")
.addAttributes("colgroup", "span", "width")
.addProtocols("a", "href", "http", "https", "mailto");
this.safelist.removeTags("img", "style", "script", "iframe", "object", "embed", "form");
this.safelist.preserveRelativeLinks(true);
}
/**
* 清洗 HTML 片段。
*
* @param html 原始 HTML
* @return 清洗后的 HTML 片段
*/
public String sanitize(String html) {
Document.OutputSettings outputSettings = new Document.OutputSettings();
outputSettings.prettyPrint(false);
return Jsoup.clean(html == null ? "" : html, "", safelist, outputSettings);
}
}

View File

@@ -0,0 +1,36 @@
package tech.easyflow.ai.node.filegeneration;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
/**
* Markdown 文件渲染器。
*
* @author Codex
* @since 2026-04-18
*/
@Component
public class MarkdownFileRenderer implements DocumentRenderer {
@Override
public TargetFormat getTargetFormat() {
return TargetFormat.MD;
}
@Override
public boolean supports(SourceFormat sourceFormat) {
return sourceFormat == SourceFormat.PLAIN_TEXT || sourceFormat == SourceFormat.MARKDOWN;
}
@Override
public FileGenerationResult render(FileGenerationRequest request) {
byte[] bytes = (request.getContent() == null ? "" : request.getContent()).getBytes(StandardCharsets.UTF_8);
return new FileGenerationResult(
request.getFileName(),
TargetFormat.MD.getValue(),
TargetFormat.MD.getContentType(),
bytes
);
}
}

View File

@@ -0,0 +1,54 @@
package tech.easyflow.ai.node.filegeneration;
import org.commonmark.Extension;
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 java.util.List;
/**
* Markdown 解析与 HTML 渲染支持。
*
* @author Codex
* @since 2026-04-18
*/
@Component
public class MarkdownSupport {
private final Parser parser;
private final HtmlRenderer htmlRenderer;
public MarkdownSupport() {
List<Extension> extensions = List.of(TablesExtension.create());
this.parser = Parser.builder()
.extensions(extensions)
.build();
this.htmlRenderer = HtmlRenderer.builder()
.extensions(extensions)
.escapeHtml(true)
.sanitizeUrls(true)
.build();
}
/**
* 解析 Markdown 文本。
*
* @param markdown Markdown 内容
* @return AST 根节点
*/
public Node parse(String markdown) {
return parser.parse(markdown == null ? "" : markdown);
}
/**
* 将 Markdown 渲染为安全 HTML 片段。
*
* @param markdown Markdown 内容
* @return HTML 片段
*/
public String renderHtml(String markdown) {
return htmlRenderer.render(parse(markdown));
}
}

View File

@@ -0,0 +1,109 @@
package tech.easyflow.ai.node.filegeneration;
import com.openhtmltopdf.extend.FSUriResolver;
import com.openhtmltopdf.outputdevice.helper.ExternalResourceControlPriority;
import com.openhtmltopdf.outputdevice.helper.ExternalResourceType;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
/**
* PDF 文件渲染器。
*
* @author Codex
* @since 2026-04-18
*/
@Component
public class PdfFileRenderer implements DocumentRenderer {
static final String FONT_RESOURCE_PATH = "fonts/SourceHanSansSC-VF.ttf";
static final String FONT_FAMILY = "EasyFlowPdfCjk";
private static final FSUriResolver BLOCK_ALL_URI_RESOLVER = PdfFileRenderer::resolveBlockedUri;
private final HtmlDocumentBuilder htmlDocumentBuilder;
private final File fontFile;
@Autowired
public PdfFileRenderer(HtmlDocumentBuilder htmlDocumentBuilder) {
this(htmlDocumentBuilder, FONT_RESOURCE_PATH);
}
PdfFileRenderer(HtmlDocumentBuilder htmlDocumentBuilder, String fontResourcePath) {
this.htmlDocumentBuilder = htmlDocumentBuilder;
this.fontFile = loadFont(fontResourcePath);
}
@Override
public TargetFormat getTargetFormat() {
return TargetFormat.PDF;
}
@Override
public boolean supports(SourceFormat sourceFormat) {
return sourceFormat == SourceFormat.PLAIN_TEXT
|| sourceFormat == SourceFormat.MARKDOWN
|| sourceFormat == SourceFormat.HTML;
}
@Override
public FileGenerationResult render(FileGenerationRequest request) {
String html = htmlDocumentBuilder.buildDocument(request);
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
PdfRendererBuilder builder = new PdfRendererBuilder();
builder.useFastMode();
builder.useFont(fontFile, FONT_FAMILY);
// 首版 PDF 只允许文本与内联样式,不允许解析任何外部或内嵌资源。
builder.useUriResolver(BLOCK_ALL_URI_RESOLVER);
builder.useExternalResourceAccessControl(
PdfFileRenderer::denyExternalResource,
ExternalResourceControlPriority.RUN_BEFORE_RESOLVING_URI
);
builder.useExternalResourceAccessControl(
PdfFileRenderer::denyExternalResource,
ExternalResourceControlPriority.RUN_AFTER_RESOLVING_URI
);
builder.withHtmlContent(html, fontFile.getParentFile().toURI().toString());
builder.toStream(outputStream);
builder.run();
return new FileGenerationResult(
request.getFileName(),
TargetFormat.PDF.getValue(),
TargetFormat.PDF.getContentType(),
outputStream.toByteArray()
);
} catch (Exception e) {
throw new IllegalStateException("PDF 导出失败: " + e.getMessage(), e);
}
}
static String resolveBlockedUri(String baseUri, String uri) {
return null;
}
static boolean denyExternalResource(String uri, ExternalResourceType type) {
return false;
}
private File loadFont(String fontResourcePath) {
ClassPathResource resource = new ClassPathResource(fontResourcePath);
if (!resource.exists()) {
throw new IllegalStateException("PDF 中文字体资源缺失: " + fontResourcePath);
}
try (InputStream inputStream = resource.getInputStream()) {
Path tempFile = Files.createTempFile("easyflow-pdf-font-", ".otf");
Files.copy(inputStream, tempFile, StandardCopyOption.REPLACE_EXISTING);
tempFile.toFile().deleteOnExit();
return tempFile.toFile();
} catch (IOException e) {
throw new IllegalStateException("PDF 中文字体资源加载失败: " + fontResourcePath, e);
}
}
}

View File

@@ -0,0 +1,45 @@
package tech.easyflow.ai.node.filegeneration;
import org.springframework.util.StringUtils;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.Arrays;
/**
* 文件导出的输入内容格式。
*
* @author Codex
* @since 2026-04-18
*/
public enum SourceFormat {
PLAIN_TEXT("plain_text"),
MARKDOWN("markdown"),
HTML("html");
private final String value;
SourceFormat(String value) {
this.value = value;
}
public String getValue() {
return value;
}
/**
* 解析输入格式。
*
* @param value 原始值
* @return 输入格式枚举
*/
public static SourceFormat fromValue(String value) {
if (!StringUtils.hasText(value)) {
throw new BusinessException("文件导出输入格式不能为空");
}
String normalized = value.trim().toLowerCase();
return Arrays.stream(values())
.filter(item -> item.value.equals(normalized))
.findFirst()
.orElseThrow(() -> new BusinessException("暂不支持的文件导出输入格式: " + value));
}
}

View File

@@ -0,0 +1,52 @@
package tech.easyflow.ai.node.filegeneration;
import org.springframework.util.StringUtils;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.Arrays;
/**
* 文件导出的目标格式。
*
* @author Codex
* @since 2026-04-18
*/
public enum TargetFormat {
MD("md", "text/markdown"),
HTML("html", "text/html"),
PDF("pdf", "application/pdf"),
DOCX("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
private final String value;
private final String contentType;
TargetFormat(String value, String contentType) {
this.value = value;
this.contentType = contentType;
}
public String getValue() {
return value;
}
public String getContentType() {
return contentType;
}
/**
* 解析目标格式。
*
* @param value 原始值
* @return 目标格式枚举
*/
public static TargetFormat fromValue(String value) {
if (!StringUtils.hasText(value)) {
throw new BusinessException("文件导出目标格式不能为空");
}
String normalized = value.trim().toLowerCase();
return Arrays.stream(values())
.filter(item -> item.value.equals(normalized))
.findFirst()
.orElseThrow(() -> new BusinessException("暂不支持的文件导出目标格式: " + value));
}
}

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());
}
}
}