package jnpf.elnService.template.docx; import jnpf.exception.DataException; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NamedNodeMap; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.InputSource; import org.xml.sax.SAXException; import org.xml.sax.SAXParseException; import org.xml.sax.helpers.DefaultHandler; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.transform.OutputKeys; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; /** 受限 DOCX writer:只替换按稳定 Tag 唯一定位的文本内容控件内容。 */ public class DocxContentControlWriter { private static final String WORD_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; private static final int MAX_PART_BYTES = 10 * 1024 * 1024; private static final int MAX_TOTAL_BYTES = 50 * 1024 * 1024; private static final int MAX_ENTRIES = 10000; private static final int MAX_COMPRESSION_RATIO = 100; public byte[] write(byte[] source, Map valuesByTag) { if (source == null || source.length == 0 || source.length > MAX_TOTAL_BYTES) { throw new DataException("DOCX 文件大小无效"); } Map requested = requiredValues(valuesByTag); Map matches = new LinkedHashMap<>(); for (String tag : requested.keySet()) matches.put(tag, 0); Set entryNames = new HashSet<>(); boolean documentFound = false; int entries = 0; long extracted = 0; try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(source)); ByteArrayOutputStream bytes = new ByteArrayOutputStream(source.length); ZipOutputStream output = new ZipOutputStream(bytes)) { ZipEntry entry; byte[] buffer = new byte[8192]; while ((entry = input.getNextEntry()) != null) { if (++entries > MAX_ENTRIES) { throw new DataException("DOCX ZIP 条目超过 10000 个限制"); } String name = entry.getName(); requireSafeEntryName(name); if (!entryNames.add(name)) throw new DataException("DOCX ZIP 条目名称重复"); if ("word/document.xml".equals(name)) documentFound = true; ByteArrayOutputStream part = new ByteArrayOutputStream(); long entryBytes = 0; int read; while ((read = input.read(buffer)) != -1) { entryBytes += read; extracted += read; if (entryBytes > MAX_PART_BYTES && isTargetPart(name)) { throw new DataException("DOCX XML 部件超过 10MB 限制"); } if (extracted > MAX_TOTAL_BYTES) { throw new DataException("DOCX 解压后超过 50MB 限制"); } part.write(buffer, 0, read); } rejectExcessiveCompression(entry, entryBytes); byte[] content = part.toByteArray(); if (isTargetPart(name) && content.length > 0) { content = writePart(content, requested, matches); } ZipEntry replacement = new ZipEntry(name); replacement.setTime(entry.getTime()); if (entry.getComment() != null) replacement.setComment(entry.getComment()); if (entry.getExtra() != null) replacement.setExtra(entry.getExtra()); output.putNextEntry(replacement); output.write(content); output.closeEntry(); input.closeEntry(); } output.finish(); if (!documentFound) throw new DataException("DOCX 缺少 word/document.xml"); for (Map.Entry match : matches.entrySet()) { if (match.getValue() == 0) { throw new DataException("DOCX 缺少目标字段控件: " + match.getKey()); } if (match.getValue() > 1) { throw new DataException("DOCX 目标字段 Tag 重复: " + match.getKey()); } } byte[] result = bytes.toByteArray(); if (result.length == 0 || result.length > MAX_TOTAL_BYTES) { throw new DataException("DOCX 写入后大小无效"); } return result; } catch (DataException e) { throw e; } catch (Exception e) { throw new DataException("DOCX 内容控件写入失败: " + e.getMessage()); } } private static byte[] writePart(byte[] source, Map requested, Map matches) throws Exception { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); factory.setXIncludeAware(false); factory.setExpandEntityReferences(false); 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); DocumentBuilder builder = factory.newDocumentBuilder(); builder.setErrorHandler(new DefaultHandler() { @Override public void error(SAXParseException exception) throws SAXException { throw exception; } @Override public void fatalError(SAXParseException exception) throws SAXException { throw exception; } }); Document document = builder.parse( new InputSource(new ByteArrayInputStream(source))); NodeList controls = document.getElementsByTagNameNS(WORD_NAMESPACE, "sdt"); boolean changed = false; for (int index = 0; index < controls.getLength(); index++) { Element control = (Element) controls.item(index); Element properties = directChild(control, "sdtPr"); Element content = directChild(control, "sdtContent"); String tag = tag(properties); if (tag == null || !requested.containsKey(tag)) continue; matches.put(tag, matches.get(tag) + 1); if (content == null) throw new DataException("DOCX 目标字段控件缺少内容区: " + tag); replaceText(document, control, content, requested.get(tag)); changed = true; } if (!changed) return source; TransformerFactory transformerFactory = TransformerFactory.newInstance(); transformerFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); Transformer transformer = transformerFactory.newTransformer(); transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no"); ByteArrayOutputStream output = new ByteArrayOutputStream(source.length); transformer.transform(new DOMSource(document), new StreamResult(output)); return output.toByteArray(); } private static void replaceText(Document document, Element control, Element content, String value) { Element oldParagraph = firstDescendant(content, "p"); Element oldRun = firstDescendant(content, "r"); while (content.hasChildNodes()) content.removeChild(content.getFirstChild()); boolean inline = hasAncestor(control, "p"); if (!inline) { Element paragraph = document.createElementNS(WORD_NAMESPACE, "w:p"); copyAttributes(oldParagraph, paragraph); Element paragraphProperties = oldParagraph == null ? null : directChild(oldParagraph, "pPr"); if (paragraphProperties != null) { paragraph.appendChild(paragraphProperties.cloneNode(true)); } paragraph.appendChild(run(document, oldRun, value)); content.appendChild(paragraph); } else { content.appendChild(run(document, oldRun, value)); } } private static Element run(Document document, Element oldRun, String value) { Element run = document.createElementNS(WORD_NAMESPACE, "w:r"); copyAttributes(oldRun, run); Element runProperties = oldRun == null ? null : directChild(oldRun, "rPr"); if (runProperties != null) run.appendChild(runProperties.cloneNode(true)); Element text = document.createElementNS(WORD_NAMESPACE, "w:t"); text.setAttributeNS(XMLConstants.XML_NS_URI, "xml:space", "preserve"); text.setTextContent(value); run.appendChild(text); return run; } private static String tag(Element properties) { if (properties == null) return null; NodeList tags = properties.getElementsByTagNameNS(WORD_NAMESPACE, "tag"); if (tags.getLength() == 0) return null; Element tag = (Element) tags.item(0); String value = tag.getAttributeNS(WORD_NAMESPACE, "val"); if (value.isEmpty()) value = tag.getAttribute("val"); return value.isEmpty() ? null : value; } private static Element directChild(Element parent, String localName) { if (parent == null) return null; for (Node child = parent.getFirstChild(); child != null; child = child.getNextSibling()) { if (child.getNodeType() == Node.ELEMENT_NODE && WORD_NAMESPACE.equals(child.getNamespaceURI()) && localName.equals(child.getLocalName())) return (Element) child; } return null; } private static Element firstDescendant(Element parent, String localName) { if (parent == null) return null; NodeList result = parent.getElementsByTagNameNS(WORD_NAMESPACE, localName); return result.getLength() == 0 ? null : (Element) result.item(0); } private static boolean hasAncestor(Element element, String localName) { for (Node parent = element.getParentNode(); parent != null; parent = parent.getParentNode()) { if (parent.getNodeType() == Node.ELEMENT_NODE && WORD_NAMESPACE.equals(parent.getNamespaceURI()) && localName.equals(parent.getLocalName())) return true; } return false; } private static void copyAttributes(Element source, Element target) { if (source == null) return; NamedNodeMap attributes = source.getAttributes(); for (int index = 0; index < attributes.getLength(); index++) { Node attribute = attributes.item(index); target.setAttributeNS(attribute.getNamespaceURI(), attribute.getNodeName(), attribute.getNodeValue()); } } private static Map requiredValues(Map valuesByTag) { if (valuesByTag == null || valuesByTag.isEmpty()) { throw new DataException("DOCX 目标字段控件不能为空"); } Map result = new LinkedHashMap<>(); for (Map.Entry entry : valuesByTag.entrySet()) { String tag = entry.getKey() == null ? null : entry.getKey().trim(); if (tag == null || (!tag.startsWith("eln.field.") && !tag.startsWith("eln.ref."))) { throw new DataException("DOCX 目标字段 Tag 无效"); } if (entry.getValue() == null) throw new DataException("DOCX 目标字段值不能为空"); result.put(tag, entry.getValue()); } return result; } private static boolean isTargetPart(String name) { return "word/document.xml".equals(name) || "word/footnotes.xml".equals(name) || "word/endnotes.xml".equals(name) || isNumberedPart(name, "word/header") || isNumberedPart(name, "word/footer"); } private static boolean isNumberedPart(String name, String prefix) { return name.startsWith(prefix) && name.endsWith(".xml") && name.indexOf('/', prefix.length()) < 0; } private static void requireSafeEntryName(String name) { if (name == null || name.isEmpty() || name.startsWith("/") || name.startsWith("\\") || name.indexOf('\\') >= 0) { throw new DataException("DOCX ZIP 条目路径无效"); } String[] segments = name.split("/", -1); for (String segment : segments) { if ("..".equals(segment) || ".".equals(segment)) { throw new DataException("DOCX ZIP 条目不允许路径跳转"); } } } private static void rejectExcessiveCompression(ZipEntry entry, long uncompressedBytes) { long compressedBytes = entry.getCompressedSize(); if (uncompressedBytes > 0 && compressedBytes == 0) { throw new DataException("DOCX ZIP 条目压缩比无效"); } if (compressedBytes > 0 && uncompressedBytes > compressedBytes * MAX_COMPRESSION_RATIO) { throw new DataException("DOCX ZIP 条目解压比超过 100:1 限制"); } } }