diff --git a/easyflow-modules/easyflow-module-ai/pom.xml b/easyflow-modules/easyflow-module-ai/pom.xml index aef63fc..daa868a 100644 --- a/easyflow-modules/easyflow-module-ai/pom.xml +++ b/easyflow-modules/easyflow-module-ai/pom.xml @@ -80,6 +80,14 @@ org.jsoup jsoup + + org.commonmark + commonmark-ext-gfm-tables + + + com.openhtmltopdf + openhtmltopdf-pdfbox + tech.easyflow easyflow-module-system diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java index 0c14e2c..3a678ad 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java @@ -11,6 +11,9 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage; import tech.easyflow.ai.entity.PluginItem; import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.node.filegeneration.FileGenerationRules; +import tech.easyflow.ai.node.filegeneration.SourceFormat; +import tech.easyflow.ai.node.filegeneration.TargetFormat; import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService; import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; import tech.easyflow.ai.service.PluginItemService; @@ -45,6 +48,7 @@ public class WorkflowCheckService { private static final String TYPE_LOOP = "loopNode"; private static final String TYPE_WORKFLOW = "workflow-node"; private static final String TYPE_PLUGIN = "plugin-node"; + private static final String TYPE_MAKE_FILE = "make-file"; private static final String SYSTEM_START_PARAM_NAME = "user_input"; @Resource @@ -222,6 +226,10 @@ public class WorkflowCheckService { if (node == null) { continue; } + if (TYPE_MAKE_FILE.equals(node.type)) { + checkMakeFileNode(node, issues, issueKeys); + continue; + } if (workflowDatacenterContentService.isSearchDatasetNode(node.type)) { checkSearchDatasetNode(node, issues, issueKeys); continue; @@ -236,6 +244,26 @@ public class WorkflowCheckService { } } + private void checkMakeFileNode(NodeView node, + List issues, + Set issueKeys) { + try { + String targetFormatValue = node.data == null ? null : trimToNull(node.data.getString("targetFormat")); + if (!StringUtils.hasText(targetFormatValue)) { + targetFormatValue = TargetFormat.DOCX.getValue(); + } + String sourceFormatValue = node.data == null ? null : trimToNull(node.data.getString("sourceFormat")); + if (!StringUtils.hasText(sourceFormatValue)) { + sourceFormatValue = SourceFormat.MARKDOWN.getValue(); + } + TargetFormat targetFormat = TargetFormat.fromValue(targetFormatValue); + SourceFormat sourceFormat = SourceFormat.fromValue(sourceFormatValue); + FileGenerationRules.requireSupportedCombination(sourceFormat, targetFormat); + } catch (BusinessException e) { + addIssue(issues, issueKeys, "MAKE_FILE_INVALID", e.getMessage(), node.id, null, node.name); + } + } + private void checkSearchDatasetNode(NodeView node, List issues, Set issueKeys) { diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/CustomFile.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/CustomFile.java index 54fb3db..cf6671d 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/CustomFile.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/CustomFile.java @@ -13,10 +13,16 @@ public class CustomFile implements MultipartFile { private final String fileName; private final byte[] bytes; + private final String contentType; public CustomFile(String fileName, byte[] bytes) { + this(fileName, bytes, null); + } + + public CustomFile(String fileName, byte[] bytes, String contentType) { this.fileName = fileName; this.bytes = bytes; + this.contentType = contentType; } @Override @@ -31,6 +37,9 @@ public class CustomFile implements MultipartFile { @Override public String getContentType() { + if (contentType != null && !contentType.isBlank()) { + return contentType; + } Tika tika = new Tika(); InputStream inputStream = new ByteArrayInputStream(bytes); String contentType = ""; diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/MakeFileNode.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/MakeFileNode.java index aaf083d..aaecb32 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/MakeFileNode.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/MakeFileNode.java @@ -1,101 +1,100 @@ package tech.easyflow.ai.node; -import cn.hutool.core.util.IdUtil; -import cn.hutool.core.util.StrUtil; import com.easyagents.flow.core.chain.Chain; import com.easyagents.flow.core.node.BaseNode; -import org.apache.poi.xwpf.usermodel.XWPFDocument; -import org.apache.poi.xwpf.usermodel.XWPFParagraph; -import org.apache.poi.xwpf.usermodel.XWPFRun; -import org.apache.poi.xwpf.usermodel.XWPFStyle; -import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTInd; -import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPrGeneral; -import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyle; +import tech.easyflow.ai.node.filegeneration.FileGenerationRequest; +import tech.easyflow.ai.node.filegeneration.FileGenerationResult; +import tech.easyflow.ai.node.filegeneration.FileGenerationService; import tech.easyflow.common.filestorage.FileStorageManager; import tech.easyflow.common.util.SpringContextUtil; +import tech.easyflow.common.web.exceptions.BusinessException; -import java.io.ByteArrayOutputStream; -import java.io.IOException; import java.util.HashMap; -import java.util.List; import java.util.Map; +/** + * 工作流文件导出节点。 + * + *

节点只负责解析运行时参数、委托导出服务生成文件内容,并通过现有文件存储返回下载地址。

+ * + * @author Codex + * @since 2026-04-18 + */ public class MakeFileNode extends BaseNode { - private String suffix; + private String targetFormat; + private String sourceFormat; + private String fileName; + private String templateStyle; public MakeFileNode() { } - public MakeFileNode(String suffix) { - this.suffix = suffix; + public MakeFileNode(String targetFormat, String sourceFormat, String fileName, String templateStyle) { + this.targetFormat = targetFormat; + this.sourceFormat = sourceFormat; + this.fileName = fileName; + this.templateStyle = templateStyle; } + /** + * 执行文件导出。 + * + * @param chain 当前流程链 + * @return 仅包含下载地址的输出对象 + */ @Override public Map execute(Chain chain) { Map map = chain.getState().resolveParameters(this); - - Map res = new HashMap<>(); - - String content = map.get("content").toString(); - ByteArrayOutputStream os = new ByteArrayOutputStream(); - createFile(suffix, content, os); - - String fileName = IdUtil.fastSimpleUUID() + "." + suffix; - CustomFile file = new CustomFile(fileName, os.toByteArray()); - + Object rawContent = map.get("content"); + if (rawContent == null) { + throw new BusinessException("文件生成节点缺少 content 参数"); + } + FileGenerationRequest request = new FileGenerationRequest( + rawContent.toString(), + sourceFormat, + targetFormat, + fileName, + templateStyle + ); + FileGenerationService generationService = SpringContextUtil.getBean(FileGenerationService.class); + FileGenerationResult result = generationService.generate(request); FileStorageManager manager = SpringContextUtil.getBean(FileStorageManager.class); - - String url = manager.save(file); + Map res = new HashMap<>(); + String url = manager.save(new CustomFile(result.getFileName(), result.getBytes(), result.getContentType())); res.put("url", url); return res; } - private void createFile(String suffix, String content, ByteArrayOutputStream os) { - if ("docx".equals(suffix)) { - docx(content, os); - } + public String getTargetFormat() { + return targetFormat; } - private void docx(String content, ByteArrayOutputStream os) { - String separator = "\n"; - List split = StrUtil.split(content, separator); - // 创建一个新的Word文档 - XWPFDocument doc = new XWPFDocument(); - // 创建样式 -// CTStyle ctStyle = CTStyle.Factory.newInstance(); -// ctStyle.setStyleId("IndentStyle"); -// CTPPrGeneral pPr = ctStyle.addNewPPr(); -// CTInd ind = pPr.addNewInd(); -// ind.setFirstLine(400); -// doc.createStyles().addStyle(new XWPFStyle(ctStyle)); - - for (String str : split) { - // 创建段落 - XWPFParagraph paragraph = doc.createParagraph(); - paragraph.setStyle("IndentStyle"); - XWPFRun run = paragraph.createRun(); - run.setText(str); - } - try { - doc.write(os); - } catch (IOException e) { - throw new RuntimeException(e); - } finally { - try { - os.close(); - doc.close(); - } catch (IOException e) { - System.out.println("关闭流异常" + e.getMessage()); - } - } + public void setTargetFormat(String targetFormat) { + this.targetFormat = targetFormat; } - public String getSuffix() { - return suffix; + public String getSourceFormat() { + return sourceFormat; } - public void setSuffix(String suffix) { - this.suffix = suffix; + public void setSourceFormat(String sourceFormat) { + this.sourceFormat = sourceFormat; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getTemplateStyle() { + return templateStyle; + } + + public void setTemplateStyle(String templateStyle) { + this.templateStyle = templateStyle; } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/MakeFileNodeParser.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/MakeFileNodeParser.java index a2de647..7993ed4 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/MakeFileNodeParser.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/MakeFileNodeParser.java @@ -4,16 +4,28 @@ import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSONObject; import com.easyagents.flow.core.node.BaseNode; import com.easyagents.flow.core.parser.BaseNodeParser; +import tech.easyflow.ai.node.filegeneration.FileGenerationRules; +import tech.easyflow.ai.node.filegeneration.SourceFormat; +import tech.easyflow.ai.node.filegeneration.TargetFormat; public class MakeFileNodeParser extends BaseNodeParser { @Override public BaseNode doParse(JSONObject root, JSONObject data, JSONObject tinyflow) { - String suffix = data.getString("suffix"); - if (StrUtil.isEmpty(suffix)) { - suffix = "docx"; + String targetFormatValue = data == null ? null : data.getString("targetFormat"); + if (StrUtil.isBlank(targetFormatValue)) { + targetFormatValue = TargetFormat.DOCX.getValue(); } - return new MakeFileNode(suffix); + String sourceFormatValue = data == null ? null : data.getString("sourceFormat"); + if (StrUtil.isBlank(sourceFormatValue)) { + sourceFormatValue = SourceFormat.MARKDOWN.getValue(); + } + String fileName = data == null ? null : StrUtil.trimToNull(data.getString("fileName")); + String templateStyle = FileGenerationRules.normalizeTemplateStyle(data == null ? null : data.getString("templateStyle")); + TargetFormat targetFormat = TargetFormat.fromValue(targetFormatValue); + SourceFormat sourceFormat = SourceFormat.fromValue(sourceFormatValue); + FileGenerationRules.requireSupportedCombination(sourceFormat, targetFormat); + return new MakeFileNode(targetFormat.getValue(), sourceFormat.getValue(), fileName, templateStyle); } public String getNodeName() { diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/DocumentRenderer.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/DocumentRenderer.java new file mode 100644 index 0000000..22944eb --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/DocumentRenderer.java @@ -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); +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/DocxFileRenderer.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/DocxFileRenderer.java new file mode 100644 index 0000000..f68bbbc --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/DocxFileRenderer.java @@ -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 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 collectTableRows(TableBlock tableBlock) { + List 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 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 cells; + + private TableRowContent(boolean header, List 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); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileGenerationRequest.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileGenerationRequest.java new file mode 100644 index 0000000..00fd28c --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileGenerationRequest.java @@ -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; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileGenerationResult.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileGenerationResult.java new file mode 100644 index 0000000..7d3988d --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileGenerationResult.java @@ -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; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileGenerationRules.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileGenerationRules.java new file mode 100644 index 0000000..994da61 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileGenerationRules.java @@ -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; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileGenerationService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileGenerationService.java new file mode 100644 index 0000000..086859e --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileGenerationService.java @@ -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 rendererMap = new EnumMap<>(TargetFormat.class); + + public FileGenerationService(List 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; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileNameSanitizer.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileNameSanitizer.java new file mode 100644 index 0000000..525026a --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileNameSanitizer.java @@ -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); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlDocumentBuilder.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlDocumentBuilder.java new file mode 100644 index 0000000..5659fd6 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlDocumentBuilder.java @@ -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 """ + + + + + + __TITLE__ + + + +
+
__BODY__
+
+ + + """ + .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("

") + .append(escaped.replace("\n", "
")) + .append("

"); + } + if (builder.length() == 0) { + builder.append("

"); + } + return builder.toString(); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlFileRenderer.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlFileRenderer.java new file mode 100644 index 0000000..df39ecf --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlFileRenderer.java @@ -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 + ); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlSanitizer.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlSanitizer.java new file mode 100644 index 0000000..b67dcac --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlSanitizer.java @@ -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); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/MarkdownFileRenderer.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/MarkdownFileRenderer.java new file mode 100644 index 0000000..3a445d8 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/MarkdownFileRenderer.java @@ -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 + ); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/MarkdownSupport.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/MarkdownSupport.java new file mode 100644 index 0000000..abfdb68 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/MarkdownSupport.java @@ -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 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)); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/PdfFileRenderer.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/PdfFileRenderer.java new file mode 100644 index 0000000..0dd03db --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/PdfFileRenderer.java @@ -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); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/SourceFormat.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/SourceFormat.java new file mode 100644 index 0000000..29e6bc8 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/SourceFormat.java @@ -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)); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/TargetFormat.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/TargetFormat.java new file mode 100644 index 0000000..87c04cf --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/TargetFormat.java @@ -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)); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/resources/fonts/SourceHanSansSC-VF.ttf b/easyflow-modules/easyflow-module-ai/src/main/resources/fonts/SourceHanSansSC-VF.ttf new file mode 100644 index 0000000..c8496a2 Binary files /dev/null and b/easyflow-modules/easyflow-module-ai/src/main/resources/fonts/SourceHanSansSC-VF.ttf differ diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java index bfa57bf..898a8e4 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java @@ -8,6 +8,7 @@ import org.junit.Test; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage; import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.node.MakeFileNodeParser; import tech.easyflow.ai.node.SearchDatasetNodeParser; import tech.easyflow.ai.node.WorkflowNodeParser; import tech.easyflow.ai.service.WorkflowService; @@ -328,6 +329,37 @@ public class WorkflowCheckServiceTest { assertHasCode(result, "START_FORM_OPTIONS_EMPTY"); } + @Test + public void testSaveShouldBlockInvalidMakeFileCombination() throws Exception { + WorkflowCheckService service = newService(new HashMap<>()); + JSONObject data = data("文件生成"); + data.put("sourceFormat", "html"); + data.put("targetFormat", "docx"); + String content = workflowJson( + array(node("mf1", "make-file", null, data)), + new JSONArray() + ); + + WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.SAVE, null); + Assert.assertFalse(result.isPassed()); + assertHasCode(result, "MAKE_FILE_INVALID"); + } + + @Test + public void testSaveShouldPassForValidMakeFileCombination() throws Exception { + WorkflowCheckService service = newService(new HashMap<>()); + JSONObject data = data("文件生成"); + data.put("sourceFormat", "markdown"); + data.put("targetFormat", "pdf"); + String content = workflowJson( + array(node("mf1", "make-file", null, data)), + new JSONArray() + ); + + WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.SAVE, null); + Assert.assertTrue(result.isPassed()); + } + private static WorkflowCheckService newService(Map workflowStore) throws Exception { WorkflowCheckService service = new WorkflowCheckService(); ChainParser parser = ChainParser.builder() @@ -335,6 +367,7 @@ public class WorkflowCheckServiceTest { .build(); parser.addNodeParser("workflow-node", new WorkflowNodeParser()); parser.addNodeParser("search-dataset-node", new SearchDatasetNodeParser()); + parser.addNodeParser("make-file", new MakeFileNodeParser()); setField(service, "chainParser", parser); setField(service, "workflowService", mockWorkflowService(workflowStore)); setField(service, "workflowDatacenterContentService", new WorkflowDatacenterContentService()); diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/MakeFileNodeParserTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/MakeFileNodeParserTest.java new file mode 100644 index 0000000..c5b4b08 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/MakeFileNodeParserTest.java @@ -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")); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/MakeFileNodeTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/MakeFileNodeTest.java new file mode 100644 index 0000000..aeba4c5 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/MakeFileNodeTest.java @@ -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 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 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 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"; + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/filegeneration/FileGenerationServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/filegeneration/FileGenerationServiceTest.java new file mode 100644 index 0000000..11f73c9 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/filegeneration/FileGenerationServiceTest.java @@ -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")); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/filegeneration/FileRendererTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/filegeneration/FileRendererTest.java new file mode 100644 index 0000000..53c6ce4 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/filegeneration/FileRendererTest.java @@ -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( + "
ok
", + "html", + "html", + "demo.html", + "default" + )); + String html = new String(result.getBytes()); + + Assert.assertTrue(html.contains("")); + Assert.assertFalse(html.contains("