From 12dab87d2d1db87bcb26f9c85cdb7dd4bd9c27f4 Mon Sep 17 00:00:00 2001 From: hejunjie Date: Mon, 10 Nov 2025 19:29:27 +0800 Subject: [PATCH 1/3] =?UTF-8?q?freemarker=E6=B7=BB=E5=8A=A0=E5=A4=84?= =?UTF-8?q?=E7=90=86=E6=A0=87=E7=AD=BE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- freemarker-tool/README.md | 1 + freemarker-tool/pom.xml | 2 +- .../freemarker/dto/CreateDocxRequest.java | 5 + .../freemarker/util/FreeMarkerUtil.java | 337 +++++++- .../freemarker/util/RichTextImageHandler.java | 734 ++++++++++++++++++ 5 files changed, 1061 insertions(+), 18 deletions(-) create mode 100644 freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/RichTextImageHandler.java diff --git a/freemarker-tool/README.md b/freemarker-tool/README.md index e44c27026..6ebb0dbd1 100644 --- a/freemarker-tool/README.md +++ b/freemarker-tool/README.md @@ -32,6 +32,7 @@ freemarker工具 | jsonData | String | 模板数据 | | imageMap | Map | k=图片名称,v=图片信息 | | base64 | Boolean | 图片是否编码
| +| processImgTag | Boolean | 是否将富文本中的`<img>`标签转换为 Word 图片,默认 false | # 如何制作docx模板 diff --git a/freemarker-tool/pom.xml b/freemarker-tool/pom.xml index 7ea0a93e0..c7cab3b59 100644 --- a/freemarker-tool/pom.xml +++ b/freemarker-tool/pom.xml @@ -7,7 +7,7 @@ com.netease.lowcode freemarker-tool - 1.2.5 + 1.3.3 8 diff --git a/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/dto/CreateDocxRequest.java b/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/dto/CreateDocxRequest.java index 341d784fb..ec3a92918 100644 --- a/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/dto/CreateDocxRequest.java +++ b/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/dto/CreateDocxRequest.java @@ -34,4 +34,9 @@ public class CreateDocxRequest { * 如果为true,表示图片传入的是base64编码 */ public Boolean base64 = false; + + /** + * 是否将富文本中的img标签转换为图片 + */ + public Boolean processImgTag = false; } diff --git a/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/FreeMarkerUtil.java b/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/FreeMarkerUtil.java index c2cf4a62d..12eab7eda 100644 --- a/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/FreeMarkerUtil.java +++ b/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/FreeMarkerUtil.java @@ -10,27 +10,49 @@ import com.spire.xls.FileFormat; import com.spire.xls.Workbook; import freemarker.cache.URLTemplateLoader; +import freemarker.core.HTMLOutputFormat; import freemarker.template.Configuration; import freemarker.template.Template; import freemarker.template.TemplateException; import org.apache.poi.ss.usermodel.WorkbookFactory; -import sun.misc.BASE64Decoder; -import sun.misc.BASE64Encoder; import java.io.*; import java.net.MalformedURLException; import java.net.URL; import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; +import org.apache.commons.lang3.StringUtils; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; public class FreeMarkerUtil { + private static final Pattern IMG_OPEN_TAG_PATTERN = Pattern.compile("(?i)]*?>"); + private static final Pattern IMG_CLOSE_TAG_PATTERN = Pattern.compile("(?i)"); + private static final String CONTENT_TYPES_FILE = "[Content_Types].xml"; + private static final String CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"; + private static final Pattern AMPERSAND_PATTERN = + Pattern.compile("&(?!(?:[A-Za-z]+|#[0-9]+|#x[0-9A-Fa-f]+);)"); + /** * 根据模板和数据创建指定后缀文件,并下载 * @@ -119,10 +141,13 @@ public static DownloadResponseDTO createNewDocxFile(CreateDocxRequest request) { try { CreateDocxRequestValidator.validate(request); + // 预处理 json,仅对 img 标签的尖括号转义,避免 Freemarker 把它视作真实节点 + String sanitizedJsonData = escapeImgTagBrackets(request.jsonData, request.processImgTag); + // 图片转Base64 Map picMap = new HashMap<>(); if (Objects.nonNull(request.imageMap) && !request.base64) { - BASE64Encoder encoder = new BASE64Encoder(); + Base64.Encoder encoder = Base64.getEncoder(); for (Map.Entry entry : request.imageMap.entrySet()) { InputStream inputStream = FileUtil.getFileInputStream(entry.getValue()); @@ -133,7 +158,7 @@ public static DownloadResponseDTO createNewDocxFile(CreateDocxRequest request) { bos.write(buffer, 0, read); } - picMap.put(entry.getKey(), encoder.encodeBuffer(bos.toByteArray())); + picMap.put(entry.getKey(), encoder.encodeToString(bos.toByteArray())); inputStream.close(); bos.close(); } @@ -151,10 +176,13 @@ public static DownloadResponseDTO createNewDocxFile(CreateDocxRequest request) { } } - ByteArrayOutputStream outputStream = createDocx(request.jsonData, + // 是否开启富文本 img 转图片的增强逻辑,由调用方显式控制 + boolean enableRichTextImage = Boolean.TRUE.equals(request.processImgTag); + ByteArrayOutputStream outputStream = createDocx(sanitizedJsonData, picMap, docxInputStream, - templateFileMap); + templateFileMap, + enableRichTextImage); // 将内容写入到文件 // try (FileOutputStream fileOutputStream = new FileOutputStream("/data/aa.docx")) { // outputStream.writeTo(fileOutputStream); @@ -178,21 +206,44 @@ public static DownloadResponseDTO createNewDocxFile(CreateDocxRequest request) { public static ByteArrayOutputStream createDocx(String jsonData, Map picMap, InputStream docxInputSteam, - Map templateFileMap) throws Exception { + Map templateFileMap, + boolean enableRichTextImage) throws Exception { ZipOutputStream zipOut = null; ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); try { - Map templateFileStreamMap = new HashMap<>(); - for (Map.Entry entry : templateFileMap.entrySet()) { - templateFileStreamMap.put(entry.getKey(),getFreemarkerContentInputStreamV2(jsonData,entry.getValue())); + byte[] templateDocBytes = toByteArray(docxInputSteam); + if (docxInputSteam != null) { + docxInputSteam.close(); + } + // 根据开关确定模板渲染方式:开启时在渲染结果中查找 img 标签并做额外处理,关闭时保持原逻辑 + Map originalEntries = enableRichTextImage + ? extractZipEntries(templateDocBytes) + : Collections.emptyMap(); + Map processedTemplateBytes = enableRichTextImage + ? prepareTemplateFiles(jsonData, templateFileMap, picMap, originalEntries) + : Collections.emptyMap(); + Map templateFileStreamMap = enableRichTextImage + ? buildTemplateStreamMap(processedTemplateBytes) + : buildOriginalTemplateStreamMap(jsonData, templateFileMap); + + if (enableRichTextImage) { + Set imageExtensions = collectImageExtensions(picMap); + if (!imageExtensions.isEmpty()) { + byte[] baseContentTypes = processedTemplateBytes.getOrDefault(CONTENT_TYPES_FILE, + originalEntries.get(CONTENT_TYPES_FILE)); + byte[] updatedContentTypes = ensureImageContentTypes(baseContentTypes, imageExtensions); + if (updatedContentTypes != null) { + templateFileStreamMap.put(CONTENT_TYPES_FILE, new ByteArrayInputStream(updatedContentTypes)); + } + } } //最初设计的模板 zipOut = new ZipOutputStream(outputStream); //开始覆盖文档, 不要删除原图片,存在无需替换的情况,因此全部保留 - writeZipFileV2(docxInputSteam,zipOut,templateFileStreamMap); + writeZipFileV2(new ByteArrayInputStream(templateDocBytes),zipOut,templateFileStreamMap); //写入图片 ,可能存在用户命名与保留文件重复,客户图片名称前加特定标识,由用户控制 writePicture(picMap, zipOut); @@ -259,12 +310,12 @@ private static void writeZipFileV2(InputStream zipInputStream, private static void writePicture(Map picMap, ZipOutputStream zipout) throws IOException { int len; byte[] buffer = new byte[1024]; - BASE64Decoder decoder = new BASE64Decoder(); + Base64.Decoder decoder = Base64.getDecoder(); for (Map.Entry entry : picMap.entrySet()) { // 用户输入的图片名称可能会与原文件重复,这里交由使用方控制,图片名称前加个标识区分,防止覆盖 ZipEntry next = new ZipEntry("word/media/"+entry.getKey()); zipout.putNextEntry(new ZipEntry(next.toString())); - byte[] bytes = decoder.decodeBuffer(entry.getValue()); + byte[] bytes = decoder.decode(entry.getValue()); InputStream in = new ByteArrayInputStream(bytes); while ((len = in.read(buffer)) != -1) { zipout.write(buffer, 0, len); @@ -272,4 +323,256 @@ private static void writePicture(Map picMap, ZipOutputStream zip in.close(); } } + + private static Map buildTemplateStreamMap(Map templateBytes) { + Map result = new HashMap<>(); + for (Map.Entry entry : templateBytes.entrySet()) { + result.put(entry.getKey(), new ByteArrayInputStream(entry.getValue())); + } + return result; + } + + /** + * 当未开启富文本增强时,仅负责把模板从远端下载并渲染为输入流即可。 + */ + private static Map buildOriginalTemplateStreamMap(String jsonData, + Map templateFileMap) throws IOException, TemplateException { + Map result = new HashMap<>(); + if (templateFileMap == null || templateFileMap.isEmpty()) { + return result; + } + for (Map.Entry entry : templateFileMap.entrySet()) { + result.put(entry.getKey(), getFreemarkerContentInputStreamV2(jsonData, entry.getValue())); + } + return result; + } + + /** + * 渲染模板并扫描富文本 img,补齐 document.xml 与 rels 的引用关系。 + */ + private static Map prepareTemplateFiles(String jsonData, + Map templateFileMap, + Map picMap, + Map originalEntries) throws Exception { + Map templateBytesMap = new HashMap<>(); + if (templateFileMap == null || templateFileMap.isEmpty()) { + return templateBytesMap; + } + AtomicInteger imageSequence = new AtomicInteger(1); + AtomicInteger docPrSequence = new AtomicInteger(1000); + Map> relationshipsByPart = new HashMap<>(); + + for (Map.Entry entry : templateFileMap.entrySet()) { + ByteArrayInputStream templateInputStream = getFreemarkerContentInputStreamV2(jsonData, entry.getValue()); + byte[] templateBytes = toByteArray(templateInputStream); + templateInputStream.close(); + + if (RichTextImageHandler.shouldProcess(entry.getKey())) { + RichTextImageHandler.Result result = RichTextImageHandler.processPart(entry.getKey(), + new String(templateBytes, StandardCharsets.UTF_8), + picMap, + imageSequence, + docPrSequence); + templateBytes = result.getXml().getBytes(StandardCharsets.UTF_8); + if (!result.getRelationships().isEmpty()) { + relationshipsByPart.put(entry.getKey(), result.getRelationships()); + } + } + + templateBytesMap.put(entry.getKey(), templateBytes); + } + + for (Map.Entry> entry : relationshipsByPart.entrySet()) { + String relationshipPath = RichTextImageHandler.toRelationshipPath(entry.getKey()); + byte[] relBytes = templateBytesMap.containsKey(relationshipPath) + ? templateBytesMap.get(relationshipPath) + : originalEntries.get(relationshipPath); + String updated = RichTextImageHandler.appendRelationships(relBytes, entry.getValue()); + templateBytesMap.put(relationshipPath, updated.getBytes(StandardCharsets.UTF_8)); + } + return templateBytesMap; + } + + private static byte[] toByteArray(InputStream inputStream) throws IOException { + if (inputStream == null) { + return new byte[0]; + } + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int len; + while ((len = inputStream.read(buffer)) != -1) { + bos.write(buffer, 0, len); + } + return bos.toByteArray(); + } + + private static Map extractZipEntries(byte[] zipBytes) throws IOException { + Map entries = new HashMap<>(); + if (zipBytes == null || zipBytes.length == 0) { + return entries; + } + byte[] buffer = new byte[1024]; + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + int len; + while ((len = zis.read(buffer)) != -1) { + bos.write(buffer, 0, len); + } + entries.put(entry.getName(), bos.toByteArray()); + } + } + return entries; + } + + private static String escapeImgTagBrackets(String jsonData, Boolean processImgTag) { + if (!Boolean.TRUE.equals(processImgTag) || StringUtils.isBlank(jsonData)) { + return jsonData; + } + String ampersandEscaped = escapeBareAmpersands(jsonData); + String interim = escapeByPattern(ampersandEscaped, IMG_OPEN_TAG_PATTERN); + return escapeByPattern(interim, IMG_CLOSE_TAG_PATTERN); + } + + private static String escapeByPattern(String source, Pattern pattern) { + Matcher matcher = pattern.matcher(source); + StringBuffer buffer = new StringBuffer(); + while (matcher.find()) { + String escaped = matcher.group().replace("<", "<").replace(">", ">"); + matcher.appendReplacement(buffer, Matcher.quoteReplacement(escaped)); + } + matcher.appendTail(buffer); + return buffer.toString(); + } + + private static String escapeBareAmpersands(String source) { + if (StringUtils.isBlank(source)) { + return source; + } + Matcher matcher = AMPERSAND_PATTERN.matcher(source); + StringBuffer buffer = new StringBuffer(); + while (matcher.find()) { + matcher.appendReplacement(buffer, "&"); + } + matcher.appendTail(buffer); + return buffer.toString(); + } + + private static Set collectImageExtensions(Map picMap) { + Set extensions = new HashSet<>(); + if (picMap == null || picMap.isEmpty()) { + return extensions; + } + for (String name : picMap.keySet()) { + if (StringUtils.isBlank(name)) { + continue; + } + int idx = name.lastIndexOf('.'); + if (idx == -1 || idx == name.length() - 1) { + continue; + } + String ext = name.substring(idx + 1).toLowerCase(Locale.ROOT); + if (ext.matches("[a-z0-9]+")) { + extensions.add(ext); + } + } + return extensions; + } + + private static byte[] ensureImageContentTypes(byte[] existingContentTypes, + Set imageExtensions) throws Exception { + if (imageExtensions == null || imageExtensions.isEmpty()) { + return null; + } + + Document document = parseContentTypesDocument(existingContentTypes); + Element root = document.getDocumentElement(); + if (root == null || !"Types".equals(root.getLocalName())) { + document = createEmptyContentTypesDocument(); + root = document.getDocumentElement(); + } + + Set existingExtensions = new HashSet<>(); + NodeList defaults = root.getElementsByTagNameNS(CONTENT_TYPES_NS, "Default"); + for (int i = 0; i < defaults.getLength(); i++) { + Element element = (Element) defaults.item(i); + existingExtensions.add(element.getAttribute("Extension").toLowerCase(Locale.ROOT)); + } + + boolean modified = false; + for (String extension : imageExtensions) { + if (existingExtensions.contains(extension)) { + continue; + } + Element defaultElement = document.createElementNS(CONTENT_TYPES_NS, "Default"); + defaultElement.setAttribute("Extension", extension); + defaultElement.setAttribute("ContentType", guessImageContentType(extension)); + root.appendChild(defaultElement); + existingExtensions.add(extension); + modified = true; + } + + return modified ? transformDocumentToBytes(document) : null; + } + + private static Document parseContentTypesDocument(byte[] content) throws Exception { + if (content == null || content.length == 0) { + return createEmptyContentTypesDocument(); + } + DocumentBuilder builder = buildSecureDocumentBuilder(); + try (InputStream inputStream = new ByteArrayInputStream(content)) { + return builder.parse(inputStream); + } + } + + private static Document createEmptyContentTypesDocument() throws ParserConfigurationException { + DocumentBuilder builder = buildSecureDocumentBuilder(); + Document document = builder.newDocument(); + Element root = document.createElementNS(CONTENT_TYPES_NS, "Types"); + document.appendChild(root); + return document; + } + + private static DocumentBuilder buildSecureDocumentBuilder() throws ParserConfigurationException { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory.newDocumentBuilder(); + } + + private static byte[] transformDocumentToBytes(Document document) throws TransformerException { + TransformerFactory transformerFactory = TransformerFactory.newInstance(); + Transformer transformer = transformerFactory.newTransformer(); + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + transformer.setOutputProperty(OutputKeys.INDENT, "no"); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + transformer.transform(new DOMSource(document), new StreamResult(outputStream)); + return outputStream.toByteArray(); + } + + private static String guessImageContentType(String extension) { + switch (extension) { + case "jpg": + case "jpeg": + return "image/jpeg"; + case "png": + return "image/png"; + case "gif": + return "image/gif"; + case "bmp": + return "image/bmp"; + case "tif": + case "tiff": + return "image/tiff"; + default: + return "image/" + extension; + } + } } diff --git a/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/RichTextImageHandler.java b/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/RichTextImageHandler.java new file mode 100644 index 000000000..131d3683f --- /dev/null +++ b/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/RichTextImageHandler.java @@ -0,0 +1,734 @@ +package com.netease.lowcode.freemarker.util; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +import javax.imageio.ImageIO; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 将富文本中的 <img> 标签转换为 Word 文档中的图片节点,并维护关系文件。 + */ +public class RichTextImageHandler { + + private static final Pattern IMG_PATTERN = Pattern.compile("(?i)]*>"); + private static final Pattern ATTRIBUTE_PATTERN = Pattern.compile("(?i)([A-Za-z_:][\\w:.-]*)\\s*=\\s*(\"([^\"]*)\"|'([^']*)')"); + private static final double PT_TO_PX = 96D / 72D; + private static final long EMUS_PER_PIXEL = 9525L; + + private static final String WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + private static final String WP_NS = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"; + private static final String PIC_NS = "http://schemas.openxmlformats.org/drawingml/2006/picture"; + private static final String A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"; + private static final String R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + private static final String REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"; + private static final String REL_IMAGE_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; + private static final String XML_NS = "http://www.w3.org/XML/1998/namespace"; + private static final long DEFAULT_MAX_IMAGE_WIDTH_EMU = 5_943_600L; // ≈6.5in usable page width + + private RichTextImageHandler() { + } + + public static boolean shouldProcess(String entryName) { + if (StringUtils.isBlank(entryName)) { + return false; + } + return entryName.startsWith("word/") + && entryName.endsWith(".xml") + && !entryName.contains("/_rels/"); + } + + public static String toRelationshipPath(String partName) { + if (StringUtils.isBlank(partName)) { + return ""; + } + int idx = partName.lastIndexOf('/'); + String dir = idx == -1 ? "" : partName.substring(0, idx); + String file = idx == -1 ? partName : partName.substring(idx + 1); + String relDir = dir.isEmpty() ? "_rels" : dir + "/_rels"; + return relDir + "/" + file + ".rels"; + } + + public static Result processPart(String partName, + String xmlContent, + Map picMap, + AtomicInteger imageSequence, + AtomicInteger docPrSequence) throws Exception { + + Objects.requireNonNull(picMap, "picMap must not be null"); + + if (StringUtils.isBlank(xmlContent) + || (!xmlContent.contains("(), false); + } + + Document document = parseXml(xmlContent); + List textNodes = collectTextNodes(document); + List relationships = new ArrayList<>(); + boolean modified = false; + + for (Element textElement : textNodes) { + String text = textElement.getTextContent(); + if (StringUtils.isBlank(text) || !containsImage(text)) { + continue; + } + + Element runElement = (Element) textElement.getParentNode(); + if (runElement == null) { + continue; + } + Node container = runElement.getParentNode(); + if (container == null) { + continue; + } + + // 将同一个文本节点拆成文字片段和图片片段,后续逐个构造 w:r + List segments = splitSegments(text, picMap, imageSequence, docPrSequence, relationships); + if (segments.isEmpty()) { + continue; + } + + for (Node newRun : buildRuns(document, runElement, segments)) { + container.insertBefore(newRun, runElement); + } + container.removeChild(runElement); + modified = true; + } + + if (!modified) { + return new Result(xmlContent, new ArrayList<>(), false); + } + + return new Result(transformToString(document), relationships, true); + } + + public static String appendRelationships(byte[] relContent, + List relationships) throws Exception { + if (relationships == null || relationships.isEmpty()) { + return relContent == null ? "" : new String(relContent, StandardCharsets.UTF_8); + } + + Document document; + if (relContent == null || relContent.length == 0) { + document = createEmptyRelationshipsDocument(); + } else { + document = parseXml(new String(relContent, StandardCharsets.UTF_8)); + } + + Element root = document.getDocumentElement(); + if (root == null || !"Relationships".equals(root.getLocalName())) { + document = createEmptyRelationshipsDocument(); + root = document.getDocumentElement(); + } + + Set existingIds = collectRelationshipIds(root); + for (RelationshipInfo info : relationships) { + if (existingIds.contains(info.getId())) { + // Relationship id already exists, skip to avoid inconsistencies. + continue; + } + Element relationship = document.createElementNS(REL_NS, "Relationship"); + relationship.setAttribute("Id", info.getId()); + relationship.setAttribute("Type", info.getType()); + relationship.setAttribute("Target", info.getTarget()); + root.appendChild(relationship); + } + return transformToString(document); + } + + private static List collectTextNodes(Document document) { + List result = new ArrayList<>(); + NodeList nodeList = document.getElementsByTagNameNS(WORD_NS, "t"); + for (int i = 0; i < nodeList.getLength(); i++) { + Node node = nodeList.item(i); + if (node instanceof Element) { + result.add((Element) node); + } + } + return result; + } + + private static boolean containsImage(String text) { + return IMG_PATTERN.matcher(text).find(); + } + + private static List splitSegments(String text, + Map picMap, + AtomicInteger imageSequence, + AtomicInteger docPrSequence, + List relationships) { + List segments = new ArrayList<>(); + Matcher matcher = IMG_PATTERN.matcher(text); + int lastEnd = 0; + while (matcher.find()) { + if (matcher.start() > lastEnd) { + segments.add(Segment.text(text.substring(lastEnd, matcher.start()))); + } + String tag = matcher.group(); + ImageInfo imageInfo = buildImageInfo(tag, picMap, imageSequence, docPrSequence); + if (imageInfo == null) { + segments.add(Segment.text(tag)); + } else { + segments.add(Segment.image(imageInfo)); + relationships.add(new RelationshipInfo(imageInfo.relationshipId, REL_IMAGE_TYPE, "media/" + imageInfo.fileName)); + } + lastEnd = matcher.end(); + } + if (lastEnd < text.length()) { + segments.add(Segment.text(text.substring(lastEnd))); + } + return segments; + } + + private static List buildRuns(Document document, Element templateRun, List segments) { + List runs = new ArrayList<>(); + for (Segment segment : segments) { + + if (segment.type == SegmentType.TEXT) { + if (StringUtils.isEmpty(segment.text)) { + continue; + } + runs.add(createTextRun(document, templateRun, segment.text)); + continue; + } + + if (segment.imageInfo != null) { + Node imageRun = createImageRun(document, templateRun, segment.imageInfo); + if (imageRun != null) { + runs.add(imageRun); + } + } + } + return runs; + } + + private static Element createTextRun(Document document, Element templateRun, String value) { + Element run = (Element) templateRun.cloneNode(false); + copyRunProperties(document, templateRun, run); + Element textElement = document.createElementNS(WORD_NS, "w:t"); + if (needsPreserve(value)) { + textElement.setAttributeNS(XML_NS, "xml:space", "preserve"); + } + textElement.setTextContent(value); + run.appendChild(textElement); + return run; + } + + private static boolean needsPreserve(String value) { + if (StringUtils.isEmpty(value)) { + return false; + } + return Character.isWhitespace(value.charAt(0)) || Character.isWhitespace(value.charAt(value.length() - 1)); + } + + private static Node createImageRun(Document document, Element templateRun, ImageInfo imageInfo) { + Element run = (Element) templateRun.cloneNode(false); + copyRunProperties(document, templateRun, run); + + Element drawing = document.createElementNS(WORD_NS, "w:drawing"); + Element inline = document.createElementNS(WP_NS, "wp:inline"); + inline.setAttribute("distT", "0"); + inline.setAttribute("distB", "0"); + inline.setAttribute("distL", "0"); + inline.setAttribute("distR", "0"); + + Element extent = document.createElementNS(WP_NS, "wp:extent"); + extent.setAttribute("cx", String.valueOf(imageInfo.cx)); + extent.setAttribute("cy", String.valueOf(imageInfo.cy)); + inline.appendChild(extent); + + Element effectExtent = document.createElementNS(WP_NS, "wp:effectExtent"); + effectExtent.setAttribute("l", "0"); + effectExtent.setAttribute("t", "0"); + effectExtent.setAttribute("r", "0"); + effectExtent.setAttribute("b", "0"); + inline.appendChild(effectExtent); + + Element docPr = document.createElementNS(WP_NS, "wp:docPr"); + docPr.setAttribute("id", String.valueOf(imageInfo.docPrId)); + docPr.setAttribute("name", "Picture " + imageInfo.docPrId); + if (StringUtils.isNotBlank(imageInfo.alt)) { + docPr.setAttribute("descr", imageInfo.alt); + } + inline.appendChild(docPr); + + Element cNvGraphicFramePr = document.createElementNS(WP_NS, "wp:cNvGraphicFramePr"); + Element graphicFrameLocks = document.createElementNS(A_NS, "a:graphicFrameLocks"); + graphicFrameLocks.setAttribute("noChangeAspect", "1"); + cNvGraphicFramePr.appendChild(graphicFrameLocks); + inline.appendChild(cNvGraphicFramePr); + + Element graphic = document.createElementNS(A_NS, "a:graphic"); + Element graphicData = document.createElementNS(A_NS, "a:graphicData"); + graphicData.setAttribute("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture"); + + Element pic = document.createElementNS(PIC_NS, "pic:pic"); + Element nvPicPr = document.createElementNS(PIC_NS, "pic:nvPicPr"); + Element cNvPr = document.createElementNS(PIC_NS, "pic:cNvPr"); + cNvPr.setAttribute("id", String.valueOf(imageInfo.docPrId)); + cNvPr.setAttribute("name", imageInfo.fileName); + nvPicPr.appendChild(cNvPr); + nvPicPr.appendChild(document.createElementNS(PIC_NS, "pic:cNvPicPr")); + pic.appendChild(nvPicPr); + + Element blipFill = document.createElementNS(PIC_NS, "pic:blipFill"); + Element blip = document.createElementNS(A_NS, "a:blip"); + blip.setAttributeNS(R_NS, "r:embed", imageInfo.relationshipId); + blipFill.appendChild(blip); + Element stretch = document.createElementNS(A_NS, "a:stretch"); + stretch.appendChild(document.createElementNS(A_NS, "a:fillRect")); + blipFill.appendChild(stretch); + pic.appendChild(blipFill); + + Element spPr = document.createElementNS(PIC_NS, "pic:spPr"); + Element xfrm = document.createElementNS(A_NS, "a:xfrm"); + Element off = document.createElementNS(A_NS, "a:off"); + off.setAttribute("x", "0"); + off.setAttribute("y", "0"); + xfrm.appendChild(off); + Element ext = document.createElementNS(A_NS, "a:ext"); + ext.setAttribute("cx", String.valueOf(imageInfo.cx)); + ext.setAttribute("cy", String.valueOf(imageInfo.cy)); + xfrm.appendChild(ext); + spPr.appendChild(xfrm); + Element prstGeom = document.createElementNS(A_NS, "a:prstGeom"); + prstGeom.setAttribute("prst", "rect"); + prstGeom.appendChild(document.createElementNS(A_NS, "a:avLst")); + spPr.appendChild(prstGeom); + pic.appendChild(spPr); + + graphicData.appendChild(pic); + graphic.appendChild(graphicData); + inline.appendChild(graphic); + drawing.appendChild(inline); + run.appendChild(drawing); + return run; + } + + private static void copyRunProperties(Document document, Element source, Element target) { + NodeList childNodes = source.getChildNodes(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node child = childNodes.item(i); + if (child instanceof Element) { + Element childElement = (Element) child; + if ("rPr".equals(childElement.getLocalName())) { + target.appendChild(childElement.cloneNode(true)); + } + } + } + } + + private static ImageInfo buildImageInfo(String tag, + Map picMap, + AtomicInteger imageSequence, + AtomicInteger docPrSequence) { + + Map attributes = parseAttributes(tag); + String src = attributes.get("src"); + if (StringUtils.isBlank(src)) { + return null; + } + + ImageBinary imageBinary = readImage(src.trim()); + if (imageBinary == null || imageBinary.bytes == null || imageBinary.bytes.length == 0) { + return null; + } + + Dimension dimension = resolveDimension(attributes, imageBinary); + if (dimension == null) { + return null; + } + + String extension = determineExtension(imageBinary, src); + String fileName = generateUniqueFileName(picMap, extension, imageSequence); + // 将下载/解析后的图片重新塞回 picMap,这样主流程会把它写入 word/media + picMap.put(fileName, Base64.getEncoder().encodeToString(imageBinary.bytes)); + + ImageInfo imageInfo = new ImageInfo(); + imageInfo.fileName = fileName; + imageInfo.relationshipId = "rIdRichText" + UUID.randomUUID().toString().replace("-", ""); + imageInfo.cx = dimension.widthEmu; + imageInfo.cy = dimension.heightEmu; + imageInfo.docPrId = docPrSequence.getAndIncrement(); + imageInfo.alt = attributes.getOrDefault("alt", ""); + return imageInfo; + } + + private static String generateUniqueFileName(Map picMap, + String extension, + AtomicInteger imageSequence) { + String ext = StringUtils.isBlank(extension) ? "png" : extension; + String fileName; + do { + fileName = "rich_text_inline_" + imageSequence.getAndIncrement() + "_" + + UUID.randomUUID().toString().replace("-", "") + "." + ext; + } while (picMap.containsKey(fileName)); + return fileName; + } + + private static Dimension resolveDimension(Map attributes, ImageBinary imageBinary) { + Integer width = extractDimension(attributes, "width"); + Integer height = extractDimension(attributes, "height"); + + if (imageBinary.widthPx > 0 && imageBinary.heightPx > 0) { + if (width != null && height == null) { + height = Math.max(1, (int) Math.round((double) width / imageBinary.widthPx * imageBinary.heightPx)); + } else if (height != null && width == null) { + width = Math.max(1, (int) Math.round((double) height / imageBinary.heightPx * imageBinary.widthPx)); + } else if (width == null) { + width = imageBinary.widthPx; + height = imageBinary.heightPx; + } + } + + if (width == null || height == null) { + return null; + } + + Dimension dimension = new Dimension(); + dimension.widthEmu = pixelsToEmu(width); + dimension.heightEmu = pixelsToEmu(height); + enforceMaxWidth(dimension); + return dimension; + } + + private static Integer extractDimension(Map attributes, String key) { + String direct = attributes.get(key); + Integer value = parsePixels(direct); + if (value != null) { + return value; + } + String style = attributes.get("style"); + if (StringUtils.isBlank(style)) { + return null; + } + for (String token : style.split(";")) { + String[] pair = token.split(":"); + if (pair.length != 2) { + continue; + } + String styleKey = pair[0].trim(); + String styleValue = pair[1].trim(); + if (key.equalsIgnoreCase(styleKey)) { + Integer parsed = parsePixels(styleValue); + if (parsed != null) { + return parsed; + } + } + } + return null; + } + + private static Integer parsePixels(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + String value = raw.trim().toLowerCase(Locale.ROOT); + try { + if (value.endsWith("px")) { + value = value.substring(0, value.length() - 2); + return (int) Math.round(Double.parseDouble(value)); + } + if (value.endsWith("pt")) { + double pt = Double.parseDouble(value.substring(0, value.length() - 2)); + return (int) Math.round(pt * PT_TO_PX); + } + if (!Character.isDigit(value.charAt(value.length() - 1))) { + return null; + } + return (int) Math.round(Double.parseDouble(value)); + } catch (NumberFormatException ex) { + return null; + } + } + + private static ImageBinary readImage(String src) { + try { + if (src.startsWith("data:") && src.contains("base64,")) { + String base64 = src.substring(src.indexOf("base64,") + 7); + byte[] bytes = Base64.getDecoder().decode(base64); + return buildImageBinary(bytes, extractMimeFromDataUrl(src)); + } + try (InputStream inputStream = FileUtil.getFileInputStream(src)) { + byte[] bytes = readAllBytes(inputStream); + return buildImageBinary(bytes, null); + } + } catch (IOException ex) { +// LOGGER.warn("Failed to load image for rich text: {}", src, ex); + return null; + } + } + + private static ImageBinary buildImageBinary(byte[] bytes, String mime) throws IOException { + ImageBinary binary = new ImageBinary(); + binary.bytes = bytes; + binary.mimeType = mime; + try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes)) { + BufferedImage image = ImageIO.read(bais); + if (image != null) { + binary.widthPx = image.getWidth(); + binary.heightPx = image.getHeight(); + } + } + return binary; + } + + private static String extractMimeFromDataUrl(String src) { + int start = src.indexOf(':'); + int end = src.indexOf(';'); + if (start >= 0 && end > start) { + return src.substring(start + 1, end); + } + return null; + } + + private static byte[] readAllBytes(InputStream inputStream) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int len; + while ((len = inputStream.read(buffer)) != -1) { + bos.write(buffer, 0, len); + } + return bos.toByteArray(); + } + + private static long pixelsToEmu(int pixels) { + return Math.max(1, Math.round(pixels * EMUS_PER_PIXEL)); + } + + private static String determineExtension(ImageBinary imageBinary, String src) { + if (imageBinary != null && StringUtils.isNotBlank(imageBinary.mimeType)) { + String mime = imageBinary.mimeType.toLowerCase(Locale.ROOT); + if (mime.contains("png")) { + return "png"; + } + if (mime.contains("jpeg") || mime.contains("jpg")) { + return "jpg"; + } + if (mime.contains("gif")) { + return "gif"; + } + } + if (StringUtils.isNotBlank(src)) { + String sanitized = src.split("\\?")[0]; + int idx = sanitized.lastIndexOf('.'); + if (idx != -1 && idx < sanitized.length() - 1) { + String ext = sanitized.substring(idx + 1).toLowerCase(Locale.ROOT); + if (ext.matches("[a-z]{2,4}")) { + return ext; + } + } + } + return "png"; + } + + private static void enforceMaxWidth(Dimension dimension) { + if (dimension == null || dimension.widthEmu <= DEFAULT_MAX_IMAGE_WIDTH_EMU) { + return; + } + double scale = (double) DEFAULT_MAX_IMAGE_WIDTH_EMU / (double) dimension.widthEmu; + dimension.widthEmu = DEFAULT_MAX_IMAGE_WIDTH_EMU; + dimension.heightEmu = Math.max(1L, Math.round(dimension.heightEmu * scale)); + } + + private static Map parseAttributes(String tag) { + Map attributes = new HashMap<>(); + Matcher matcher = ATTRIBUTE_PATTERN.matcher(tag); + while (matcher.find()) { + String name = matcher.group(1).toLowerCase(Locale.ROOT); + String value = matcher.group(3); + if (value == null) { + value = matcher.group(4); + } + if (value != null) { + attributes.put(name, value); + } + } + return attributes; + } + + private static Document parseXml(String xml) throws Exception { + DocumentBuilder builder = newDocumentBuilder(); + try (StringReader reader = new StringReader(xml)) { + InputSource source = new InputSource(reader); + return builder.parse(source); + } + } + + private static DocumentBuilder newDocumentBuilder() throws ParserConfigurationException { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + disableExternalEntities(factory); + return factory.newDocumentBuilder(); + } + + private static void disableExternalEntities(DocumentBuilderFactory factory) throws ParserConfigurationException { + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + } + + private static Document createEmptyRelationshipsDocument() throws ParserConfigurationException { + DocumentBuilder builder = newDocumentBuilder(); + Document document = builder.newDocument(); + Element root = document.createElementNS(REL_NS, "Relationships"); + document.appendChild(root); + return document; + } + + private static Set collectRelationshipIds(Element root) { + Set ids = new HashSet<>(); + NodeList nodes = root.getElementsByTagNameNS(REL_NS, "Relationship"); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node instanceof Element) { + Element element = (Element) node; + ids.add(element.getAttribute("Id")); + } + } + return ids; + } + + private static String transformToString(Document document) throws TransformerException { + TransformerFactory transformerFactory = TransformerFactory.newInstance(); + Transformer transformer = transformerFactory.newTransformer(); + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + transformer.setOutputProperty(OutputKeys.INDENT, "no"); + StringWriter writer = new StringWriter(); + transformer.transform(new DOMSource(document), new StreamResult(writer)); + return writer.toString(); + } + + public static class RelationshipInfo { + private final String id; + private final String type; + private final String target; + + public RelationshipInfo(String id, String type, String target) { + this.id = id; + this.type = type; + this.target = target; + } + + public String getId() { + return id; + } + + public String getType() { + return type; + } + + public String getTarget() { + return target; + } + } + + public static class Result { + private final String xml; + private final List relationships; + private final boolean modified; + + public Result(String xml, List relationships, boolean modified) { + this.xml = xml; + this.relationships = relationships; + this.modified = modified; + } + + public String getXml() { + return xml; + } + + public List getRelationships() { + return relationships; + } + + public boolean isModified() { + return modified; + } + } + + private enum SegmentType { + TEXT, IMAGE + } + + private static class Segment { + SegmentType type; + String text; + ImageInfo imageInfo; + + static Segment text(String value) { + Segment segment = new Segment(); + segment.type = SegmentType.TEXT; + segment.text = value; + return segment; + } + + static Segment image(ImageInfo info) { + Segment segment = new Segment(); + segment.type = SegmentType.IMAGE; + segment.imageInfo = info; + return segment; + } + } + + private static class ImageInfo { + String relationshipId; + String fileName; + long cx; + long cy; + int docPrId; + String alt; + } + + private static class ImageBinary { + byte[] bytes; + String mimeType; + int widthPx; + int heightPx; + } + + private static class Dimension { + long widthEmu; + long heightEmu; + } +} From b3d1e9798ec69cb3c32ff3fc9646e8e4e4642b0a Mon Sep 17 00:00:00 2001 From: hejunjie Date: Mon, 17 Nov 2025 19:02:06 +0800 Subject: [PATCH 2/3] support webp image --- freemarker-tool/pom.xml | 9 +- .../freemarker/util/FreeMarkerUtil.java | 2 + .../freemarker/util/RichTextImageHandler.java | 97 ++++++++++++++++--- 3 files changed, 92 insertions(+), 16 deletions(-) diff --git a/freemarker-tool/pom.xml b/freemarker-tool/pom.xml index c7cab3b59..47830f65f 100644 --- a/freemarker-tool/pom.xml +++ b/freemarker-tool/pom.xml @@ -7,7 +7,7 @@ com.netease.lowcode freemarker-tool - 1.3.3 + 1.3.4 8 @@ -99,6 +99,11 @@ commons-lang3 3.13.0 + + com.twelvemonkeys.imageio + imageio-webp + 3.10.1 + org.springframework.boot spring-boot-starter-test @@ -152,4 +157,4 @@ - \ No newline at end of file + diff --git a/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/FreeMarkerUtil.java b/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/FreeMarkerUtil.java index 12eab7eda..1eba34c5a 100644 --- a/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/FreeMarkerUtil.java +++ b/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/FreeMarkerUtil.java @@ -571,6 +571,8 @@ private static String guessImageContentType(String extension) { case "tif": case "tiff": return "image/tiff"; + case "webp": + return "image/webp"; default: return "image/" + extension; } diff --git a/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/RichTextImageHandler.java b/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/RichTextImageHandler.java index 131d3683f..ee2f9d2a5 100644 --- a/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/RichTextImageHandler.java +++ b/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/RichTextImageHandler.java @@ -10,6 +10,8 @@ import org.xml.sax.InputSource; import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; @@ -499,12 +501,19 @@ private static ImageBinary buildImageBinary(byte[] bytes, String mime) throws IO ImageBinary binary = new ImageBinary(); binary.bytes = bytes; binary.mimeType = mime; + binary.format = detectFormat(bytes); + + BufferedImage image; try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes)) { - BufferedImage image = ImageIO.read(bais); - if (image != null) { - binary.widthPx = image.getWidth(); - binary.heightPx = image.getHeight(); - } + image = ImageIO.read(bais); + } + if (image != null) { + binary.widthPx = image.getWidth(); + binary.heightPx = image.getHeight(); + } + + if ("webp".equalsIgnoreCase(binary.format)) { + convertWebpToPng(binary, image); } return binary; } @@ -533,16 +542,33 @@ private static long pixelsToEmu(int pixels) { } private static String determineExtension(ImageBinary imageBinary, String src) { - if (imageBinary != null && StringUtils.isNotBlank(imageBinary.mimeType)) { - String mime = imageBinary.mimeType.toLowerCase(Locale.ROOT); - if (mime.contains("png")) { - return "png"; - } - if (mime.contains("jpeg") || mime.contains("jpg")) { - return "jpg"; + if (imageBinary != null) { + if (StringUtils.isNotBlank(imageBinary.mimeType)) { + String mime = imageBinary.mimeType.toLowerCase(Locale.ROOT); + if (mime.contains("png")) { + return "png"; + } + if (mime.contains("jpeg") || mime.contains("jpg")) { + return "jpg"; + } + if (mime.contains("gif")) { + return "gif"; + } + if (mime.contains("bmp")) { + return "bmp"; + } + if (mime.contains("tif") || mime.contains("tiff")) { + return "tiff"; + } + if (mime.contains("webp")) { + return "png"; + } } - if (mime.contains("gif")) { - return "gif"; + if (StringUtils.isNotBlank(imageBinary.format)) { + if ("webp".equalsIgnoreCase(imageBinary.format)) { + return "png"; + } + return imageBinary.format.toLowerCase(Locale.ROOT); } } if (StringUtils.isNotBlank(src)) { @@ -551,6 +577,9 @@ private static String determineExtension(ImageBinary imageBinary, String src) { if (idx != -1 && idx < sanitized.length() - 1) { String ext = sanitized.substring(idx + 1).toLowerCase(Locale.ROOT); if (ext.matches("[a-z]{2,4}")) { + if ("webp".equals(ext)) { + return "png"; + } return ext; } } @@ -725,10 +754,50 @@ private static class ImageBinary { String mimeType; int widthPx; int heightPx; + String format; } private static class Dimension { long widthEmu; long heightEmu; } + + private static String detectFormat(byte[] bytes) { + try (ImageInputStream stream = ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) { + if (stream == null) { + return null; + } + java.util.Iterator readers = ImageIO.getImageReaders(stream); + if (readers.hasNext()) { + ImageReader reader = readers.next(); + try { + return reader.getFormatName(); + } finally { + reader.dispose(); + } + } + } catch (IOException ex) { + return null; + } + return null; + } + + private static void convertWebpToPng(ImageBinary binary, BufferedImage sourceImage) throws IOException { + BufferedImage image = sourceImage; + if (image == null) { + try (ByteArrayInputStream bais = new ByteArrayInputStream(binary.bytes)) { + image = ImageIO.read(bais); + } + } + if (image == null) { + return; + } + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ImageIO.write(image, "png", bos); + binary.bytes = bos.toByteArray(); + binary.mimeType = "image/png"; + binary.format = "png"; + binary.widthPx = image.getWidth(); + binary.heightPx = image.getHeight(); + } } From 33a13fc1ef37205168a586a1b13fd668edf8c5e8 Mon Sep 17 00:00:00 2001 From: hejunjie Date: Thu, 20 Nov 2025 16:48:55 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=B8=8B=E8=BD=BDclient=EF=BC=8C=E6=94=AF=E6=8C=81=E4=BB=8Ehtt?= =?UTF-8?q?p=20302=20=E5=88=B0https?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- freemarker-tool/pom.xml | 2 +- .../lowcode/freemarker/util/FileUtil.java | 39 ++++++++++++++++--- .../freemarker/util/FreeMarkerUtil.java | 7 +++- .../freemarker/util/RichTextImageHandler.java | 5 ++- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/freemarker-tool/pom.xml b/freemarker-tool/pom.xml index 47830f65f..3f3398f5d 100644 --- a/freemarker-tool/pom.xml +++ b/freemarker-tool/pom.xml @@ -7,7 +7,7 @@ com.netease.lowcode freemarker-tool - 1.3.4 + 1.3.7 8 diff --git a/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/FileUtil.java b/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/FileUtil.java index 788c3c207..3f789f85a 100644 --- a/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/FileUtil.java +++ b/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/FileUtil.java @@ -18,17 +18,46 @@ import java.net.URL; import java.util.Arrays; import java.util.Objects; +import java.util.concurrent.TimeUnit; public class FileUtil { private static final Logger logger = LoggerFactory.getLogger("LCAP_CUSTOMIZE_LOGGER"); + private static final OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .build(); + public static InputStream getFileInputStream(String urlStr) throws IOException { - URL url = new URL(getTrueUrl(urlStr)); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - connection.setConnectTimeout(3 * 1000); - connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.82 Safari/537.36"); - return url.openStream(); + logger.info("urlStr using OkHttp: {}", urlStr); + + // 还是保留你原本的 URL 处理逻辑 + String finalUrl = getTrueUrl(urlStr); + + Request request = new Request.Builder() + .url(finalUrl) + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.82 Safari/537.36") + .build(); + + // 执行请求 + Response response = client.newCall(request).execute(); + + if (!response.isSuccessful()) { + // 如果失败(比如 404 或 500),必须关闭响应体并抛出异常 + response.close(); + throw new IOException("Unexpected code " + response); + } + + ResponseBody body = response.body(); + if (body == null) { + response.close(); + throw new IOException("Response body is null"); + } + + return body.byteStream(); } // 新增方法:检测 URL 是否已经编码 diff --git a/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/FreeMarkerUtil.java b/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/FreeMarkerUtil.java index 1eba34c5a..da3c58140 100644 --- a/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/FreeMarkerUtil.java +++ b/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/FreeMarkerUtil.java @@ -21,6 +21,8 @@ import java.net.URL; import java.nio.charset.StandardCharsets; import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NodeList; @@ -45,6 +47,7 @@ import java.util.zip.ZipOutputStream; public class FreeMarkerUtil { + private static final Logger log = LoggerFactory.getLogger("LCAP_EXTENSION_LOGGER"); private static final Pattern IMG_OPEN_TAG_PATTERN = Pattern.compile("(?i)]*?>"); private static final Pattern IMG_CLOSE_TAG_PATTERN = Pattern.compile("(?i)"); @@ -136,6 +139,7 @@ public static DownloadResponseDTO createNewXlsx(CreateRequest request) { */ @NaslLogic public static DownloadResponseDTO createNewDocxFile(CreateDocxRequest request) { + log.info("createDocxFile request:{}", request); Map templateFileMap = new HashMap<>(); try { @@ -208,7 +212,7 @@ public static ByteArrayOutputStream createDocx(String jsonData, InputStream docxInputSteam, Map templateFileMap, boolean enableRichTextImage) throws Exception { - + log.info("createDocx jsonData:{}, picMap:{}, docxInputSteam:{}, templateFileMap:{}, enableRichTextImage:{}", jsonData, picMap, docxInputSteam, templateFileMap, enableRichTextImage); ZipOutputStream zipOut = null; ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); try { @@ -354,6 +358,7 @@ private static Map prepareTemplateFiles(String jsonData, Map templateFileMap, Map picMap, Map originalEntries) throws Exception { + log.info("prepareTemplateFiles jsonData:{}, templateFileMap:{}, picMap:{}, originalEntries:{}", jsonData, templateFileMap, picMap, originalEntries); Map templateBytesMap = new HashMap<>(); if (templateFileMap == null || templateFileMap.isEmpty()) { return templateBytesMap; diff --git a/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/RichTextImageHandler.java b/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/RichTextImageHandler.java index ee2f9d2a5..57d90fc44 100644 --- a/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/RichTextImageHandler.java +++ b/freemarker-tool/src/main/java/com/netease/lowcode/freemarker/util/RichTextImageHandler.java @@ -49,6 +49,8 @@ */ public class RichTextImageHandler { + private static final Logger log = LoggerFactory.getLogger("LCAP_EXTENSION_LOGGER"); + private static final Pattern IMG_PATTERN = Pattern.compile("(?i)]*>"); private static final Pattern ATTRIBUTE_PATTERN = Pattern.compile("(?i)([A-Za-z_:][\\w:.-]*)\\s*=\\s*(\"([^\"]*)\"|'([^']*)')"); private static final double PT_TO_PX = 96D / 72D; @@ -481,6 +483,7 @@ private static Integer parsePixels(String raw) { } private static ImageBinary readImage(String src) { + log.info("Loading image for rich text: {}", src); try { if (src.startsWith("data:") && src.contains("base64,")) { String base64 = src.substring(src.indexOf("base64,") + 7); @@ -492,7 +495,7 @@ private static ImageBinary readImage(String src) { return buildImageBinary(bytes, null); } } catch (IOException ex) { -// LOGGER.warn("Failed to load image for rich text: {}", src, ex); + log.warn("Failed to load image for rich text: {}", src, ex); return null; } }