feat: 支持工作流文件节点多格式文档导出
- 补齐 md/html/pdf/docx 导出与统一渲染服务 - 收口文件生成节点配置与格式校验 - 修复 PDF 中文字体与 Markdown 渲染链路
This commit is contained in:
@@ -80,6 +80,14 @@
|
|||||||
<groupId>org.jsoup</groupId>
|
<groupId>org.jsoup</groupId>
|
||||||
<artifactId>jsoup</artifactId>
|
<artifactId>jsoup</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.commonmark</groupId>
|
||||||
|
<artifactId>commonmark-ext-gfm-tables</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.openhtmltopdf</groupId>
|
||||||
|
<artifactId>openhtmltopdf-pdfbox</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-module-system</artifactId>
|
<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.easyagentsflow.entity.WorkflowCheckStage;
|
||||||
import tech.easyflow.ai.entity.PluginItem;
|
import tech.easyflow.ai.entity.PluginItem;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
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.dependency.WorkflowPluginDependencyService;
|
||||||
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
|
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
|
||||||
import tech.easyflow.ai.service.PluginItemService;
|
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_LOOP = "loopNode";
|
||||||
private static final String TYPE_WORKFLOW = "workflow-node";
|
private static final String TYPE_WORKFLOW = "workflow-node";
|
||||||
private static final String TYPE_PLUGIN = "plugin-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";
|
private static final String SYSTEM_START_PARAM_NAME = "user_input";
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
@@ -222,6 +226,10 @@ public class WorkflowCheckService {
|
|||||||
if (node == null) {
|
if (node == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (TYPE_MAKE_FILE.equals(node.type)) {
|
||||||
|
checkMakeFileNode(node, issues, issueKeys);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (workflowDatacenterContentService.isSearchDatasetNode(node.type)) {
|
if (workflowDatacenterContentService.isSearchDatasetNode(node.type)) {
|
||||||
checkSearchDatasetNode(node, issues, issueKeys);
|
checkSearchDatasetNode(node, issues, issueKeys);
|
||||||
continue;
|
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,
|
private void checkSearchDatasetNode(NodeView node,
|
||||||
List<WorkflowCheckIssue> issues,
|
List<WorkflowCheckIssue> issues,
|
||||||
Set<String> issueKeys) {
|
Set<String> issueKeys) {
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ public class CustomFile implements MultipartFile {
|
|||||||
|
|
||||||
private final String fileName;
|
private final String fileName;
|
||||||
private final byte[] bytes;
|
private final byte[] bytes;
|
||||||
|
private final String contentType;
|
||||||
|
|
||||||
public CustomFile(String fileName, byte[] bytes) {
|
public CustomFile(String fileName, byte[] bytes) {
|
||||||
|
this(fileName, bytes, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CustomFile(String fileName, byte[] bytes, String contentType) {
|
||||||
this.fileName = fileName;
|
this.fileName = fileName;
|
||||||
this.bytes = bytes;
|
this.bytes = bytes;
|
||||||
|
this.contentType = contentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -31,6 +37,9 @@ public class CustomFile implements MultipartFile {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getContentType() {
|
public String getContentType() {
|
||||||
|
if (contentType != null && !contentType.isBlank()) {
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
Tika tika = new Tika();
|
Tika tika = new Tika();
|
||||||
InputStream inputStream = new ByteArrayInputStream(bytes);
|
InputStream inputStream = new ByteArrayInputStream(bytes);
|
||||||
String contentType = "";
|
String contentType = "";
|
||||||
|
|||||||
@@ -1,101 +1,100 @@
|
|||||||
package tech.easyflow.ai.node;
|
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.chain.Chain;
|
||||||
import com.easyagents.flow.core.node.BaseNode;
|
import com.easyagents.flow.core.node.BaseNode;
|
||||||
import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
import tech.easyflow.ai.node.filegeneration.FileGenerationRequest;
|
||||||
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
|
import tech.easyflow.ai.node.filegeneration.FileGenerationResult;
|
||||||
import org.apache.poi.xwpf.usermodel.XWPFRun;
|
import tech.easyflow.ai.node.filegeneration.FileGenerationService;
|
||||||
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.common.filestorage.FileStorageManager;
|
import tech.easyflow.common.filestorage.FileStorageManager;
|
||||||
import tech.easyflow.common.util.SpringContextUtil;
|
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.HashMap;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流文件导出节点。
|
||||||
|
*
|
||||||
|
* <p>节点只负责解析运行时参数、委托导出服务生成文件内容,并通过现有文件存储返回下载地址。</p>
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-18
|
||||||
|
*/
|
||||||
public class MakeFileNode extends BaseNode {
|
public class MakeFileNode extends BaseNode {
|
||||||
|
|
||||||
private String suffix;
|
private String targetFormat;
|
||||||
|
private String sourceFormat;
|
||||||
|
private String fileName;
|
||||||
|
private String templateStyle;
|
||||||
|
|
||||||
public MakeFileNode() {
|
public MakeFileNode() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public MakeFileNode(String suffix) {
|
public MakeFileNode(String targetFormat, String sourceFormat, String fileName, String templateStyle) {
|
||||||
this.suffix = suffix;
|
this.targetFormat = targetFormat;
|
||||||
|
this.sourceFormat = sourceFormat;
|
||||||
|
this.fileName = fileName;
|
||||||
|
this.templateStyle = templateStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行文件导出。
|
||||||
|
*
|
||||||
|
* @param chain 当前流程链
|
||||||
|
* @return 仅包含下载地址的输出对象
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> execute(Chain chain) {
|
public Map<String, Object> execute(Chain chain) {
|
||||||
Map<String, Object> map = chain.getState().resolveParameters(this);
|
Map<String, Object> map = chain.getState().resolveParameters(this);
|
||||||
|
Object rawContent = map.get("content");
|
||||||
Map<String, Object> res = new HashMap<>();
|
if (rawContent == null) {
|
||||||
|
throw new BusinessException("文件生成节点缺少 content 参数");
|
||||||
String content = map.get("content").toString();
|
}
|
||||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
FileGenerationRequest request = new FileGenerationRequest(
|
||||||
createFile(suffix, content, os);
|
rawContent.toString(),
|
||||||
|
sourceFormat,
|
||||||
String fileName = IdUtil.fastSimpleUUID() + "." + suffix;
|
targetFormat,
|
||||||
CustomFile file = new CustomFile(fileName, os.toByteArray());
|
fileName,
|
||||||
|
templateStyle
|
||||||
|
);
|
||||||
|
FileGenerationService generationService = SpringContextUtil.getBean(FileGenerationService.class);
|
||||||
|
FileGenerationResult result = generationService.generate(request);
|
||||||
FileStorageManager manager = SpringContextUtil.getBean(FileStorageManager.class);
|
FileStorageManager manager = SpringContextUtil.getBean(FileStorageManager.class);
|
||||||
|
Map<String, Object> res = new HashMap<>();
|
||||||
String url = manager.save(file);
|
String url = manager.save(new CustomFile(result.getFileName(), result.getBytes(), result.getContentType()));
|
||||||
res.put("url", url);
|
res.put("url", url);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createFile(String suffix, String content, ByteArrayOutputStream os) {
|
public String getTargetFormat() {
|
||||||
if ("docx".equals(suffix)) {
|
return targetFormat;
|
||||||
docx(content, os);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void docx(String content, ByteArrayOutputStream os) {
|
public void setTargetFormat(String targetFormat) {
|
||||||
String separator = "\n";
|
this.targetFormat = targetFormat;
|
||||||
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 String getSuffix() {
|
public String getSourceFormat() {
|
||||||
return suffix;
|
return sourceFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setSuffix(String suffix) {
|
public void setSourceFormat(String sourceFormat) {
|
||||||
this.suffix = suffix;
|
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.alibaba.fastjson.JSONObject;
|
||||||
import com.easyagents.flow.core.node.BaseNode;
|
import com.easyagents.flow.core.node.BaseNode;
|
||||||
import com.easyagents.flow.core.parser.BaseNodeParser;
|
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 {
|
public class MakeFileNodeParser extends BaseNodeParser {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public BaseNode doParse(JSONObject root, JSONObject data, JSONObject tinyflow) {
|
public BaseNode doParse(JSONObject root, JSONObject data, JSONObject tinyflow) {
|
||||||
String suffix = data.getString("suffix");
|
String targetFormatValue = data == null ? null : data.getString("targetFormat");
|
||||||
if (StrUtil.isEmpty(suffix)) {
|
if (StrUtil.isBlank(targetFormatValue)) {
|
||||||
suffix = "docx";
|
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() {
|
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.WorkflowCheckResult;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.node.MakeFileNodeParser;
|
||||||
import tech.easyflow.ai.node.SearchDatasetNodeParser;
|
import tech.easyflow.ai.node.SearchDatasetNodeParser;
|
||||||
import tech.easyflow.ai.node.WorkflowNodeParser;
|
import tech.easyflow.ai.node.WorkflowNodeParser;
|
||||||
import tech.easyflow.ai.service.WorkflowService;
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
@@ -328,6 +329,37 @@ public class WorkflowCheckServiceTest {
|
|||||||
assertHasCode(result, "START_FORM_OPTIONS_EMPTY");
|
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 {
|
private static WorkflowCheckService newService(Map<String, String> workflowStore) throws Exception {
|
||||||
WorkflowCheckService service = new WorkflowCheckService();
|
WorkflowCheckService service = new WorkflowCheckService();
|
||||||
ChainParser parser = ChainParser.builder()
|
ChainParser parser = ChainParser.builder()
|
||||||
@@ -335,6 +367,7 @@ public class WorkflowCheckServiceTest {
|
|||||||
.build();
|
.build();
|
||||||
parser.addNodeParser("workflow-node", new WorkflowNodeParser());
|
parser.addNodeParser("workflow-node", new WorkflowNodeParser());
|
||||||
parser.addNodeParser("search-dataset-node", new SearchDatasetNodeParser());
|
parser.addNodeParser("search-dataset-node", new SearchDatasetNodeParser());
|
||||||
|
parser.addNodeParser("make-file", new MakeFileNodeParser());
|
||||||
setField(service, "chainParser", parser);
|
setField(service, "chainParser", parser);
|
||||||
setField(service, "workflowService", mockWorkflowService(workflowStore));
|
setField(service, "workflowService", mockWorkflowService(workflowStore));
|
||||||
setField(service, "workflowDatacenterContentService", new WorkflowDatacenterContentService());
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,16 @@
|
|||||||
"fileGeneration": "FileGeneration",
|
"fileGeneration": "FileGeneration",
|
||||||
"fileSettings": "FileSettings",
|
"fileSettings": "FileSettings",
|
||||||
"fileDownloadURL": "FileDownloadURL",
|
"fileDownloadURL": "FileDownloadURL",
|
||||||
|
"targetFormat": "TargetFormat",
|
||||||
|
"fileName": "FileName",
|
||||||
|
"documents": "Documents",
|
||||||
|
"count": "Count",
|
||||||
|
"fileNamePlaceholder": "For example: meeting-notes",
|
||||||
|
"templateStyle": "TemplateStyle",
|
||||||
|
"defaultTemplateStyle": "Default",
|
||||||
|
"plain_text": "Plain Text",
|
||||||
|
"markdown": "Markdown",
|
||||||
|
"html": "HTML",
|
||||||
"pluginSelect": "PluginSelect",
|
"pluginSelect": "PluginSelect",
|
||||||
"saveData": "SaveData",
|
"saveData": "SaveData",
|
||||||
"saveDataset": "Write Data",
|
"saveDataset": "Write Data",
|
||||||
@@ -105,6 +115,9 @@
|
|||||||
"fileGeneration": "Generate Word, PDF, HTML, etc. files for users to download",
|
"fileGeneration": "Generate Word, PDF, HTML, etc. files for users to download",
|
||||||
"fileType": "Please select the type of file to generate",
|
"fileType": "Please select the type of file to generate",
|
||||||
"fileDownloadURL": "Generated file URL",
|
"fileDownloadURL": "Generated file URL",
|
||||||
|
"targetFormat": "Choose the final document format to export",
|
||||||
|
"fileName": "Optional. The system will sanitize invalid characters and enforce the target extension",
|
||||||
|
"templateStyle": "Only the default style is available in the first version. Unknown values fall back automatically",
|
||||||
"plugin": "Select a predefined plugin",
|
"plugin": "Select a predefined plugin",
|
||||||
"saveData": "Save data to data hub",
|
"saveData": "Save data to data hub",
|
||||||
"saveDataset": "Write data into a managed table",
|
"saveDataset": "Write data into a managed table",
|
||||||
|
|||||||
@@ -42,6 +42,16 @@
|
|||||||
"fileGeneration": "文件生成",
|
"fileGeneration": "文件生成",
|
||||||
"fileSettings": "文件设置",
|
"fileSettings": "文件设置",
|
||||||
"fileDownloadURL": "文件下载地址",
|
"fileDownloadURL": "文件下载地址",
|
||||||
|
"targetFormat": "输出文档类型",
|
||||||
|
"fileName": "文件名",
|
||||||
|
"documents": "文档列表",
|
||||||
|
"count": "数量",
|
||||||
|
"fileNamePlaceholder": "例如:会议纪要",
|
||||||
|
"templateStyle": "模板样式",
|
||||||
|
"defaultTemplateStyle": "默认样式",
|
||||||
|
"plain_text": "纯文本",
|
||||||
|
"markdown": "Markdown",
|
||||||
|
"html": "HTML",
|
||||||
"pluginSelect": "插件选择",
|
"pluginSelect": "插件选择",
|
||||||
"saveData": "保存数据",
|
"saveData": "保存数据",
|
||||||
"saveDataset": "写入数据",
|
"saveDataset": "写入数据",
|
||||||
@@ -105,6 +115,9 @@
|
|||||||
"fileGeneration": "生成 Word、PDF、HTML 等文件供用户下载",
|
"fileGeneration": "生成 Word、PDF、HTML 等文件供用户下载",
|
||||||
"fileType": "请选择生成的文件类型",
|
"fileType": "请选择生成的文件类型",
|
||||||
"fileDownloadURL": "生成后的文件地址",
|
"fileDownloadURL": "生成后的文件地址",
|
||||||
|
"targetFormat": "选择最终要导出的文档格式",
|
||||||
|
"fileName": "可选,系统会自动补齐目标扩展名并清洗非法字符",
|
||||||
|
"templateStyle": "首版仅提供默认样式,未知值会自动回退",
|
||||||
"plugin": "选择定义好的插件",
|
"plugin": "选择定义好的插件",
|
||||||
"saveData": "保存数据到数据中枢",
|
"saveData": "保存数据到数据中枢",
|
||||||
"saveDataset": "将数据写入已接入表",
|
"saveDataset": "将数据写入已接入表",
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getMakeFileNodeFormPatch,
|
||||||
|
getMakeFileTargetFormatOptions,
|
||||||
|
isMakeFileFormatCombinationAllowed,
|
||||||
|
resolveMakeFileNodeTargetFormat,
|
||||||
|
resolveMakeFileNodeTemplateStyle,
|
||||||
|
sanitizeMakeFileNodeFileName,
|
||||||
|
} from '../makeFileNode';
|
||||||
|
|
||||||
|
describe('makeFileNode helpers', () => {
|
||||||
|
it('清洗文件名中的非法字符与首尾空白', () => {
|
||||||
|
expect(sanitizeMakeFileNodeFileName(' a/b\\\\c:*?<>| ')).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('识别受支持与不受支持的格式组合', () => {
|
||||||
|
expect(isMakeFileFormatCombinationAllowed('markdown', 'pdf')).toBe(true);
|
||||||
|
expect(isMakeFileFormatCombinationAllowed('html', 'html')).toBe(true);
|
||||||
|
expect(isMakeFileFormatCombinationAllowed('html', 'md')).toBe(false);
|
||||||
|
expect(isMakeFileFormatCombinationAllowed('html', 'docx')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('按当前 targetFormat 回显目标格式', () => {
|
||||||
|
expect(resolveMakeFileNodeTargetFormat({ targetFormat: 'pdf' })).toBe('pdf');
|
||||||
|
expect(resolveMakeFileNodeTargetFormat({ targetFormat: 'html' })).toBe('html');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('对未知 templateStyle 做默认回退', () => {
|
||||||
|
expect(resolveMakeFileNodeTemplateStyle({ templateStyle: 'fancy' })).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('在 html 源格式下仅暴露 html 与 pdf 目标格式', () => {
|
||||||
|
expect(getMakeFileTargetFormatOptions('html').map((item) => item.value)).toEqual([
|
||||||
|
'html',
|
||||||
|
'pdf',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('在 sourceFormat 切换为 html 时自动修正非法目标格式', () => {
|
||||||
|
expect(
|
||||||
|
getMakeFileNodeFormPatch('sourceFormat', 'html', {
|
||||||
|
sourceFormat: 'markdown',
|
||||||
|
targetFormat: 'docx',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
sourceFormat: 'html',
|
||||||
|
targetFormat: 'html',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,117 @@ import { $t } from '#/locales';
|
|||||||
|
|
||||||
import nodeNames from './nodeNames';
|
import nodeNames from './nodeNames';
|
||||||
|
|
||||||
|
const INVALID_FILE_NAME_PATTERN = /[\\/:*?"<>|\u0000-\u001f\u007f]/g;
|
||||||
|
|
||||||
|
export const makeFileTargetFormatValues = ['md', 'html', 'pdf', 'docx'] as const;
|
||||||
|
export const makeFileSourceFormatValues = ['plain_text', 'markdown', 'html'] as const;
|
||||||
|
const DEFAULT_TARGET_FORMAT = 'docx';
|
||||||
|
const DEFAULT_SOURCE_FORMAT = 'markdown';
|
||||||
|
const DEFAULT_TEMPLATE_STYLE = 'default';
|
||||||
|
|
||||||
|
type MakeFileTargetFormat = (typeof makeFileTargetFormatValues)[number];
|
||||||
|
type MakeFileSourceFormat = (typeof makeFileSourceFormatValues)[number];
|
||||||
|
|
||||||
|
function isMakeFileTargetFormat(value: unknown): value is MakeFileTargetFormat {
|
||||||
|
return typeof value === 'string' && makeFileTargetFormatValues.includes(value as MakeFileTargetFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMakeFileSourceFormat(value: unknown): value is MakeFileSourceFormat {
|
||||||
|
return typeof value === 'string' && makeFileSourceFormatValues.includes(value as MakeFileSourceFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredTargetFormat(data?: Record<string, any>) {
|
||||||
|
const candidate = data?.targetFormat;
|
||||||
|
return isMakeFileTargetFormat(candidate) ? candidate : DEFAULT_TARGET_FORMAT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeMakeFileNodeFileName(value?: string) {
|
||||||
|
return (value ?? '').replace(INVALID_FILE_NAME_PATTERN, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMakeFileFormatCombinationAllowed(
|
||||||
|
sourceFormat = DEFAULT_SOURCE_FORMAT,
|
||||||
|
targetFormat = 'docx',
|
||||||
|
) {
|
||||||
|
return !(
|
||||||
|
sourceFormat === 'html' &&
|
||||||
|
(targetFormat === 'md' || targetFormat === 'docx')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMakeFileNodeSourceFormat(data?: Record<string, any>): MakeFileSourceFormat {
|
||||||
|
return isMakeFileSourceFormat(data?.sourceFormat) ? data.sourceFormat : DEFAULT_SOURCE_FORMAT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMakeFileNodeTargetFormat(data?: Record<string, any>): MakeFileTargetFormat {
|
||||||
|
const sourceFormat = resolveMakeFileNodeSourceFormat(data);
|
||||||
|
const targetFormat = getConfiguredTargetFormat(data);
|
||||||
|
if (isMakeFileFormatCombinationAllowed(sourceFormat, targetFormat)) {
|
||||||
|
return targetFormat;
|
||||||
|
}
|
||||||
|
return sourceFormat === 'html' ? 'html' : DEFAULT_TARGET_FORMAT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMakeFileNodeTemplateStyle(data?: Record<string, any>) {
|
||||||
|
return data?.templateStyle === DEFAULT_TEMPLATE_STYLE ? DEFAULT_TEMPLATE_STYLE : DEFAULT_TEMPLATE_STYLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMakeFileTargetFormatOptions(sourceFormat = DEFAULT_SOURCE_FORMAT) {
|
||||||
|
return makeFileTargetFormatValues
|
||||||
|
.filter((targetFormat) => isMakeFileFormatCombinationAllowed(sourceFormat, targetFormat))
|
||||||
|
.map((value) => ({
|
||||||
|
label: value,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMakeFileNodeFormPatch(
|
||||||
|
name: string,
|
||||||
|
nextValue: string | number | boolean | undefined,
|
||||||
|
data?: Record<string, any>,
|
||||||
|
) {
|
||||||
|
if (name === 'sourceFormat') {
|
||||||
|
const sourceFormat = isMakeFileSourceFormat(nextValue) ? nextValue : DEFAULT_SOURCE_FORMAT;
|
||||||
|
const currentTargetFormat = getConfiguredTargetFormat(data);
|
||||||
|
if (isMakeFileFormatCombinationAllowed(sourceFormat, currentTargetFormat)) {
|
||||||
|
return { sourceFormat };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sourceFormat,
|
||||||
|
targetFormat: sourceFormat === 'html' ? 'html' : DEFAULT_TARGET_FORMAT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'targetFormat') {
|
||||||
|
const sourceFormat = resolveMakeFileNodeSourceFormat(data);
|
||||||
|
const targetFormat = isMakeFileTargetFormat(nextValue) ? nextValue : DEFAULT_TARGET_FORMAT;
|
||||||
|
return {
|
||||||
|
targetFormat: isMakeFileFormatCombinationAllowed(sourceFormat, targetFormat)
|
||||||
|
? targetFormat
|
||||||
|
: sourceFormat === 'html'
|
||||||
|
? 'html'
|
||||||
|
: DEFAULT_TARGET_FORMAT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'templateStyle') {
|
||||||
|
return { templateStyle: resolveMakeFileNodeTemplateStyle({ templateStyle: nextValue }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { [name]: nextValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileNameInput(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement | null;
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sanitized = sanitizeMakeFileNodeFileName(target.value);
|
||||||
|
if (target.value !== sanitized) {
|
||||||
|
target.value = sanitized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
[nodeNames.makeFileNode]: {
|
[nodeNames.makeFileNode]: {
|
||||||
title: $t('aiWorkflow.fileGeneration'),
|
title: $t('aiWorkflow.fileGeneration'),
|
||||||
@@ -9,8 +120,8 @@ export default {
|
|||||||
description: $t('aiWorkflow.descriptions.fileGeneration'),
|
description: $t('aiWorkflow.descriptions.fileGeneration'),
|
||||||
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M14 13.5V8C14 5.79086 12.2091 4 10 4C7.79086 4 6 5.79086 6 8V13.5C6 17.0899 8.91015 20 12.5 20C16.0899 20 19 17.0899 19 13.5V4H21V13.5C21 18.1944 17.1944 22 12.5 22C7.80558 22 4 18.1944 4 13.5V8C4 4.68629 6.68629 2 10 2C13.3137 2 16 4.68629 16 8V13.5C16 15.433 14.433 17 12.5 17C10.567 17 9 15.433 9 13.5V8H11V13.5C11 14.3284 11.6716 15 12.5 15C13.3284 15 14 14.3284 14 13.5Z"></path></svg>',
|
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M14 13.5V8C14 5.79086 12.2091 4 10 4C7.79086 4 6 5.79086 6 8V13.5C6 17.0899 8.91015 20 12.5 20C16.0899 20 19 17.0899 19 13.5V4H21V13.5C21 18.1944 17.1944 22 12.5 22C7.80558 22 4 18.1944 4 13.5V8C4 4.68629 6.68629 2 10 2C13.3137 2 16 4.68629 16 8V13.5C16 15.433 14.433 17 12.5 17C10.567 17 9 15.433 9 13.5V8H11V13.5C11 14.3284 11.6716 15 12.5 15C13.3284 15 14 14.3284 14 13.5Z"></path></svg>',
|
||||||
sortNo: 802,
|
sortNo: 802,
|
||||||
parametersAddEnable: true,
|
parametersAddEnable: false,
|
||||||
outputDefsAddEnable: true,
|
outputDefsAddEnable: false,
|
||||||
forms: [
|
forms: [
|
||||||
{
|
{
|
||||||
type: 'heading',
|
type: 'heading',
|
||||||
@@ -18,16 +129,42 @@ export default {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'select',
|
type: 'select',
|
||||||
label: $t('documentCollection.splitterDoc.fileType'),
|
label: $t('aiWorkflow.targetFormat'),
|
||||||
description: $t('aiWorkflow.descriptions.fileType'),
|
description: $t('aiWorkflow.descriptions.targetFormat'),
|
||||||
name: 'suffix',
|
name: 'targetFormat',
|
||||||
defaultValue: 'docx',
|
defaultValue: DEFAULT_TARGET_FORMAT,
|
||||||
|
resolveValue: resolveMakeFileNodeTargetFormat,
|
||||||
|
resolveOptions: (data: Record<string, any>) =>
|
||||||
|
getMakeFileTargetFormatOptions(resolveMakeFileNodeSourceFormat(data)),
|
||||||
|
onValueChange: (value: string | number | boolean | undefined, data: Record<string, any>) =>
|
||||||
|
getMakeFileNodeFormPatch('targetFormat', value, data),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
label: $t('aiWorkflow.fileName'),
|
||||||
|
description: $t('aiWorkflow.descriptions.fileName'),
|
||||||
|
name: 'fileName',
|
||||||
|
placeholder: $t('aiWorkflow.fileNamePlaceholder'),
|
||||||
|
attrs: {
|
||||||
|
maxlength: 128,
|
||||||
|
oninput: handleFileNameInput,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
label: $t('aiWorkflow.templateStyle'),
|
||||||
|
description: $t('aiWorkflow.descriptions.templateStyle'),
|
||||||
|
name: 'templateStyle',
|
||||||
|
defaultValue: DEFAULT_TEMPLATE_STYLE,
|
||||||
|
resolveValue: resolveMakeFileNodeTemplateStyle,
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
label: 'docx',
|
label: $t('aiWorkflow.defaultTemplateStyle'),
|
||||||
value: 'docx',
|
value: DEFAULT_TEMPLATE_STYLE,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
onValueChange: (value: string | number | boolean | undefined, data: Record<string, any>) =>
|
||||||
|
getMakeFileNodeFormPatch('templateStyle', value, data),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
parameters: [
|
parameters: [
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
import {Button, Chosen, Heading, Input, Select, Textarea} from '../base';
|
import {Button, Chosen, Heading, Input, Select, Textarea} from '../base';
|
||||||
import RefParameterList from '../core/RefParameterList.svelte';
|
import RefParameterList from '../core/RefParameterList.svelte';
|
||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
import {fillParameterId, useAddParameter} from '../utils/useAddParameter.svelte';
|
||||||
import {getOptions} from '../utils/NodeUtils';
|
import {getOptions} from '../utils/NodeUtils';
|
||||||
import OutputDefList from '../core/OutputDefList.svelte';
|
import OutputDefList from '../core/OutputDefList.svelte';
|
||||||
import {fillParameterId} from '../utils/useAddParameter.svelte.js';
|
|
||||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||||
|
|
||||||
const { data, ...rest }: {
|
const { data, ...rest }: {
|
||||||
@@ -23,15 +22,31 @@
|
|||||||
const editorParameters = $derived.by(() => {
|
const editorParameters = $derived.by(() => {
|
||||||
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
||||||
});
|
});
|
||||||
|
const currentNodeData = $derived.by(() => {
|
||||||
|
return (currentNode?.current?.data as Record<string, any>) || (data as Record<string, any>) || {};
|
||||||
|
});
|
||||||
|
|
||||||
const updateNodeData = (data: Record<string, any>) => {
|
const updateNodeData = (data: Record<string, any>) => {
|
||||||
updateNodeDataInner(currentNodeId, data);
|
updateNodeDataInner(currentNodeId, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateNodeDataByEvent = (name: string, event: Event) => {
|
const resolveFormValue = (form: any) => {
|
||||||
updateNodeData({
|
return form.resolveValue?.(currentNodeData) ?? currentNodeData?.[form.name] ?? form.defaultValue;
|
||||||
[name]: (event.target as any)?.value
|
};
|
||||||
});
|
|
||||||
|
const resolveFormOptions = (form: any) => {
|
||||||
|
return form.resolveOptions?.(currentNodeData) ?? form.options ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFormValue = (form: any, nextValue: any) => {
|
||||||
|
const patch = form.onValueChange?.(nextValue, currentNodeData) ?? {
|
||||||
|
[form.name]: nextValue
|
||||||
|
};
|
||||||
|
updateNodeData(patch);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateNodeDataByEvent = (form: any, event: Event) => {
|
||||||
|
updateFormValue(form, (event.target as any)?.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const node = {
|
const node = {
|
||||||
@@ -118,21 +133,21 @@
|
|||||||
mode="input"
|
mode="input"
|
||||||
placeholder={form.placeholder}
|
placeholder={form.placeholder}
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
value={data[form.name] || form.defaultValue}
|
value={resolveFormValue(form)}
|
||||||
parameters={editorParameters}
|
parameters={editorParameters}
|
||||||
{...form.attrs}
|
{...form.attrs}
|
||||||
oninput={(e)=>{
|
oninput={(e:any)=>{
|
||||||
updateNodeDataByEvent(form.name,e)
|
updateNodeDataByEvent(form,e)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<Input
|
<Input
|
||||||
placeholder={form.placeholder}
|
placeholder={form.placeholder}
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
value={data[form.name] || form.defaultValue}
|
value={resolveFormValue(form)}
|
||||||
{...form.attrs}
|
{...form.attrs}
|
||||||
onchange={(e)=>{
|
onchange={(e)=>{
|
||||||
updateNodeDataByEvent(form.name,e)
|
updateNodeDataByEvent(form,e)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -146,11 +161,11 @@
|
|||||||
rows={3}
|
rows={3}
|
||||||
placeholder={form.placeholder}
|
placeholder={form.placeholder}
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
value={data[form.name] || form.defaultValue}
|
value={resolveFormValue(form)}
|
||||||
parameters={editorParameters}
|
parameters={editorParameters}
|
||||||
{...form.attrs}
|
{...form.attrs}
|
||||||
oninput={(e)=>{
|
oninput={(e:any)=>{
|
||||||
updateNodeDataByEvent(form.name,e)
|
updateNodeDataByEvent(form,e)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -158,10 +173,10 @@
|
|||||||
rows={3}
|
rows={3}
|
||||||
placeholder={form.placeholder}
|
placeholder={form.placeholder}
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
value={data[form.name] || form.defaultValue}
|
value={resolveFormValue(form)}
|
||||||
{...form.attrs}
|
{...form.attrs}
|
||||||
onchange={(e)=>{
|
onchange={(e)=>{
|
||||||
updateNodeDataByEvent(form.name,e)
|
updateNodeDataByEvent(form,e)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -176,19 +191,16 @@
|
|||||||
type="range"
|
type="range"
|
||||||
{...form.attrs}
|
{...form.attrs}
|
||||||
value={data[form.name] ?? form.defaultValue}
|
value={data[form.name] ?? form.defaultValue}
|
||||||
oninput={(e) => updateNodeData({ [form.name]: parseFloat(e.target.value) })}
|
oninput={(e:any) => updateNodeData({ [form.name]: parseFloat(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if form.type === 'select'}
|
{:else if form.type === 'select'}
|
||||||
<div class="setting-title">{form.label}</div>
|
<div class="setting-title">{form.label}</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<Select items={form.options||[]} style="width: 100%" placeholder={form.placeholder} onSelect={(item)=>{
|
<Select items={resolveFormOptions(form)} style="width: 100%" placeholder={form.placeholder} onSelect={(item)=>{
|
||||||
const newValue = item.value;
|
updateFormValue(form, item.value)
|
||||||
updateNodeData({
|
}} value={[resolveFormValue(form)]} />
|
||||||
[form.name]: newValue
|
|
||||||
})
|
|
||||||
}} value={data[form.name] ? [data[form.name]] : [form.defaultValue]} />
|
|
||||||
</div>
|
</div>
|
||||||
{:else if form.type === 'chosen'}
|
{:else if form.type === 'chosen'}
|
||||||
<div class="setting-title">{form.label}</div>
|
<div class="setting-title">{form.label}</div>
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ export type CustomNodeForm = {
|
|||||||
defaultValue?: string | number | boolean;
|
defaultValue?: string | number | boolean;
|
||||||
attrs?: Record<string, any>;
|
attrs?: Record<string, any>;
|
||||||
options?: SelectItem[];
|
options?: SelectItem[];
|
||||||
|
resolveValue?: (data: Record<string, any>) => string | number | boolean | undefined;
|
||||||
|
resolveOptions?: (data: Record<string, any>) => SelectItem[];
|
||||||
|
onValueChange?: (
|
||||||
|
value: string | number | boolean | undefined,
|
||||||
|
data: Record<string, any>,
|
||||||
|
) => Record<string, any> | void;
|
||||||
templateSupport?: boolean;
|
templateSupport?: boolean;
|
||||||
chosen?: {
|
chosen?: {
|
||||||
labelDataKey: string;
|
labelDataKey: string;
|
||||||
@@ -66,6 +72,7 @@ export type CustomNode = {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
sortNo?: number;
|
sortNo?: number;
|
||||||
group?: 'base' | 'tools';
|
group?: 'base' | 'tools';
|
||||||
|
renderFirst?: boolean;
|
||||||
rootClass?: string;
|
rootClass?: string;
|
||||||
rootStyle?: string;
|
rootStyle?: string;
|
||||||
parameters?: Parameter[];
|
parameters?: Parameter[];
|
||||||
|
|||||||
13
pom.xml
13
pom.xml
@@ -35,6 +35,7 @@
|
|||||||
<sa-token.version>1.40.0</sa-token.version>
|
<sa-token.version>1.40.0</sa-token.version>
|
||||||
<commonmark.version>0.18.0</commonmark.version>
|
<commonmark.version>0.18.0</commonmark.version>
|
||||||
<jsoup.version>1.16.1</jsoup.version>
|
<jsoup.version>1.16.1</jsoup.version>
|
||||||
|
<openhtmltopdf.version>1.0.10</openhtmltopdf.version>
|
||||||
<commons-io.version>2.18.0</commons-io.version>
|
<commons-io.version>2.18.0</commons-io.version>
|
||||||
<commons-compress.version>1.28.0</commons-compress.version>
|
<commons-compress.version>1.28.0</commons-compress.version>
|
||||||
<fastexcel.version>1.2.0</fastexcel.version>
|
<fastexcel.version>1.2.0</fastexcel.version>
|
||||||
@@ -298,12 +299,24 @@
|
|||||||
<version>${commonmark.version}</version>
|
<version>${commonmark.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.commonmark</groupId>
|
||||||
|
<artifactId>commonmark-ext-gfm-tables</artifactId>
|
||||||
|
<version>${commonmark.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.jsoup</groupId>
|
<groupId>org.jsoup</groupId>
|
||||||
<artifactId>jsoup</artifactId>
|
<artifactId>jsoup</artifactId>
|
||||||
<version>${jsoup.version}</version>
|
<version>${jsoup.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.openhtmltopdf</groupId>
|
||||||
|
<artifactId>openhtmltopdf-pdfbox</artifactId>
|
||||||
|
<version>${openhtmltopdf.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>commons-io</groupId>
|
<groupId>commons-io</groupId>
|
||||||
<artifactId>commons-io</artifactId>
|
<artifactId>commons-io</artifactId>
|
||||||
|
|||||||
Reference in New Issue
Block a user