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