package jnpf.fileStorage.dms; import jnpf.exception.DataException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; 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.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.util.HashSet; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; 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; /** Writes DMS approval values into tagged Word content controls. */ @Service @Slf4j public class DmsQianpiyeDocxWriter { private static final String WORD_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; private static final String PREFIX = "dms.qianpiye."; private static final Pattern FLOW_TAG = Pattern.compile( "^dms\\.qianpiye\\.flow\\.(start(?:-[2-9][0-9]*)?|approve-[1-9][0-9]*)\\.([A-Za-z][A-Za-z0-9]*)$"); private static final Pattern FORM_TAG = Pattern.compile( "^dms\\.qianpiye\\.form\\.([A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)?)$"); private static final Pattern ROWS_TAG = Pattern.compile( "^dms\\.qianpiye\\.rows\\.common\\.([A-Za-z_][A-Za-z0-9_]*)$"); private static final Pattern ROW_FIELD = Pattern.compile("\\{\\{([^{}]+)\\}\\}"); private static final Pattern LEGACY_TAG = Pattern.compile( "^dms\\.qianpiye\\.([A-Za-z0-9_-]+)\\.([A-Za-z0-9_-]+)\\.([A-Za-z][A-Za-z0-9]*)$"); private static final Set START_FIELDS = new HashSet<>(Arrays.asList( "applicant", "applicantDepartment", "applicantTime", "stepName")); private static final Set APPROVE_FIELDS = new HashSet<>(Arrays.asList( "approver", "approverDepartment", "approveTime", "stepName")); private static final Set LEGACY_FIELDS = new HashSet<>(Arrays.asList( "applicant", "applicantDepartment", "applicantTime", "approver", "approverDepartment", "approveTime", "stepName")); 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 MultipartFile render(MultipartFile source, Map valuesByTag) { return render(source, valuesByTag, null); } /** * Values are frozen from the approval task's actual workflow version. Template version * properties describe its annotation catalog and do not restrict runtime rendering. */ public MultipartFile render(MultipartFile source, Map valuesByTag, String flowVersionId) { if (source == null || source.isEmpty()) throw new DataException("签批页 DOCX 为空"); String name = source.getOriginalFilename(); if (name == null || !name.toLowerCase(java.util.Locale.ROOT).endsWith(".docx")) { throw new DataException("动态签批页必须是 DOCX 格式"); } try { byte[] rendered = write(source.getBytes(), valuesByTag == null ? Collections.emptyMap() : valuesByTag, flowVersionId); return new GeneratedMultipartFile(source.getName(), name, rendered); } catch (DataException e) { throw e; } catch (Exception e) { throw new DataException("填写签批页失败: " + message(e)); } } byte[] write(byte[] source, Map valuesByTag) { return write(source, valuesByTag, null); } byte[] write(byte[] source, Map valuesByTag, String flowVersionId) { if (source == null || source.length == 0 || source.length > MAX_TOTAL_BYTES) { throw new DataException("签批页 DOCX 大小无效"); } Map values = checkedValues(valuesByTag); 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 条目过多"); String name = entry.getName(); requireSafeEntryName(name); if (!entryNames.add(name)) throw new DataException("签批页 DOCX 存在重复条目"); 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 (isTargetPart(name) && entryBytes > MAX_PART_BYTES) { throw new DataException("签批页 Word 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, values, name); 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 缺少正文"); 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("填写签批页失败: " + message(e)); } } private byte[] writePart(byte[] source, Map values, String partName) throws Exception { DocumentBuilderFactory factory = secureDocumentBuilderFactory(); 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))); Set pairedControls = pairLegacyChildFields(document, values); NodeList controls = document.getElementsByTagNameNS(WORD_NAMESPACE, "sdt"); boolean changed = !pairedControls.isEmpty(); for (int index = 0; index < controls.getLength(); index++) { Element control = (Element) controls.item(index); if (pairedControls.contains(control)) continue; Element properties = directChild(control, "sdtPr"); String tag = tag(properties); if (tag == null || !tag.startsWith(PREFIX)) continue; if (!validTag(tag)) { log.warn("签批页包含无法识别的标签,保持原内容: part={}, tag={}", partName, tag); continue; } if (hasApprovalFieldAncestor(control)) { throw new DataException("签批页字段控件不能互相嵌套: " + tag); } Element content = directChild(control, "sdtContent"); if (content == null) throw new DataException("签批页标签缺少内容区: " + tag); String value = values.getOrDefault(tag, ""); if (ROWS_TAG.matcher(tag).matches() && values.containsKey(tag)) value = renderRows(properties, content, value, tag); replaceText(document, control, content, value); changed = true; } if (!changed) return source; TransformerFactory transformerFactory = TransformerFactory.newInstance(); transformerFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); try { transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); } catch (IllegalArgumentException ignored) { } try { transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ""); } catch (IllegalArgumentException ignored) { } 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 DocumentBuilderFactory secureDocumentBuilderFactory() 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); return factory; } /** Old templates place separate child-column controls in one paragraph. Repeat that * paragraph from the frozen rows so punctuation and each department/copy pair stay together. */ private Set pairLegacyChildFields(Document document, Map values) throws Exception { Set handled = new HashSet<>(); java.util.List paragraphs = new java.util.ArrayList<>(); NodeList nodes = document.getElementsByTagNameNS(WORD_NAMESPACE, "p"); for (int i = 0; i < nodes.getLength(); i++) paragraphs.add((Element) nodes.item(i)); for (Element paragraph : paragraphs) { NodeList controls = paragraph.getElementsByTagNameNS(WORD_NAMESPACE, "sdt"); if (controls.getLength() < 2) continue; String rowsTag = null; Set columns = new HashSet<>(); boolean compatible = true; for (int i = 0; i < controls.getLength(); i++) { Element control = (Element) controls.item(i); String tag = tag(directChild(control, "sdtPr")); String[] binding = childBinding(tag); if (binding == null || control.getParentNode() != paragraph || hasApprovalFieldAncestor(control)) { compatible = false; break; } if (rowsTag != null && !rowsTag.equals(binding[0])) { compatible = false; break; } rowsTag = binding[0]; columns.add(binding[1]); } if (!compatible || columns.size() < 2 || !values.containsKey(rowsTag)) continue; com.fasterxml.jackson.databind.JsonNode rows = new com.fasterxml.jackson.databind.ObjectMapper().readTree(values.get(rowsTag)); if (rows == null || !rows.isArray()) throw new DataException("子表回填值必须为数组: " + rowsTag); for (int rowIndex = 0; rowIndex < Math.max(1, rows.size()); rowIndex++) { Element copy = (Element) paragraph.cloneNode(true); // Preserve each bookmark range once, spanning the repeated content. This also // handles ranges whose other endpoint lies outside this paragraph. if (rowIndex > 0) removeDescendants(copy, WORD_NAMESPACE, "bookmarkStart"); if (rowIndex < Math.max(1, rows.size()) - 1) removeDescendants(copy, WORD_NAMESPACE, "bookmarkEnd"); renewDrawingIds(document, copy); NodeList copiedControls = copy.getElementsByTagNameNS(WORD_NAMESPACE, "sdt"); for (int i = 0; i < copiedControls.getLength(); i++) { Element control = (Element) copiedControls.item(i); Element properties = directChild(control, "sdtPr"); String column = childBinding(tag(properties))[1]; String text = ""; if (!rows.isEmpty()) { com.fasterxml.jackson.databind.JsonNode row = rows.get(rowIndex); if (!row.isObject() || row.path(column).isContainerNode()) throw new DataException("子表模板字段未转换为文本: " + column); text = row.path(column).asText(""); } Element content = directChild(control, "sdtContent"); if (content == null) throw new DataException("签批页标签缺少内容区"); replaceText(document, control, content, text); Element id = directChild(properties, "id"); if (id != null) properties.removeChild(id); handled.add(control); } copy.removeAttributeNS("http://schemas.microsoft.com/office/word/2010/wordml", "paraId"); copy.removeAttributeNS("http://schemas.microsoft.com/office/word/2010/wordml", "textId"); paragraph.getParentNode().insertBefore(copy, paragraph); } paragraph.getParentNode().removeChild(paragraph); } return handled; } private void removeDescendants(Element parent, String namespace, String name) { NodeList nodes = parent.getElementsByTagNameNS(namespace, name); while (nodes.getLength() > 0) { Node node = nodes.item(0); node.getParentNode().removeChild(node); } } private void renewDrawingIds(Document document, Element copy) { for (String[] kind : new String[][] { {"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", "docPr"}, {"http://schemas.openxmlformats.org/drawingml/2006/picture", "cNvPr"}}) { Set used = new HashSet<>(); NodeList existing = document.getElementsByTagNameNS(kind[0], kind[1]); for (int i = 0; i < existing.getLength(); i++) used.add(((Element) existing.item(i)).getAttribute("id")); NodeList drawings = copy.getElementsByTagNameNS(kind[0], kind[1]); long next = 1; for (int i = 0; i < drawings.getLength(); i++) { while (used.contains(Long.toString(next))) next++; String id = Long.toString(next++); ((Element) drawings.item(i)).setAttribute("id", id); used.add(id); } } } private String[] childBinding(String tag) { if (tag == null || !validTag(tag)) return null; Matcher common = FORM_TAG.matcher(tag); if (!common.matches()) return null; String path = common.group(1); int dot = path.indexOf('.'); if (dot < 0) return null; return new String[] {PREFIX + "rows.common." + path.substring(0, dot), path.substring(dot + 1)}; } /** A single content control holds an editable row pattern, e.g. {{department}}({{copies}}). */ private String renderRows(Element properties, Element content, String json, String tag) { StringBuilder visiblePattern = new StringBuilder(); NodeList texts = content.getElementsByTagNameNS(WORD_NAMESPACE, "t"); for (int i = 0; i < texts.getLength(); i++) visiblePattern.append(texts.item(i).getTextContent()); try { com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); com.fasterxml.jackson.databind.JsonNode rows = mapper.readTree(json); if (rows == null || !rows.isArray()) throw new DataException("子表回填值必须为数组: " + tag); Element alias = directChild(properties, "alias"); com.fasterxml.jackson.databind.JsonNode bindings = null; com.fasterxml.jackson.databind.node.ObjectNode metadata = mapper.createObjectNode(); if (alias != null) { String value = alias.getAttributeNS(WORD_NAMESPACE, "val"); if (value.startsWith("{")) { metadata = (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(value); bindings = metadata.path("fields"); } else metadata.put("title", value); } // Keep the original row format in metadata so a later revision can refill this // same control after its visible placeholders have already been replaced. String pattern = metadata.has("patternBase64") ? new String(java.util.Base64.getDecoder().decode(metadata.path("patternBase64").asText()), java.nio.charset.StandardCharsets.UTF_8) : visiblePattern.toString(); if (!ROW_FIELD.matcher(pattern).find()) throw new DataException("子表模板没有字段占位符: " + tag); java.util.List lines = new java.util.ArrayList<>(); for (com.fasterxml.jackson.databind.JsonNode row : rows) { if (!row.isObject()) throw new DataException("子表回填行必须为对象: " + tag); Matcher matcher = ROW_FIELD.matcher(pattern); StringBuffer line = new StringBuffer(); while (matcher.find()) { String label = matcher.group(1); String column = bindings == null ? label : bindings.path(label).asText(""); if (row.path(column).isContainerNode()) throw new DataException("子表模板字段未转换为文本: " + column); String text = "biz_sign".equals(column) ? "" : row.path(column).asText(""); matcher.appendReplacement(line, Matcher.quoteReplacement(text)); } matcher.appendTail(line); lines.add(line.toString()); } if (bindings == null) { com.fasterxml.jackson.databind.node.ObjectNode fields = mapper.createObjectNode(); Matcher matcher = ROW_FIELD.matcher(pattern); while (matcher.find()) fields.put(matcher.group(1), matcher.group(1)); metadata.set("fields", fields); } metadata.put("patternBase64", java.util.Base64.getEncoder().encodeToString( pattern.getBytes(java.nio.charset.StandardCharsets.UTF_8))); if (alias == null) { alias = properties.getOwnerDocument().createElementNS(WORD_NAMESPACE, "w:alias"); properties.appendChild(alias); } alias.setAttributeNS(WORD_NAMESPACE, "w:val", mapper.writeValueAsString(metadata)); return String.join("\n", lines); } catch (DataException e) { throw e; } catch (Exception e) { throw new DataException("子表回填数据格式错误: " + tag); } } private boolean validTag(String tag) { Matcher flow = FLOW_TAG.matcher(tag); if (flow.matches()) { return flow.group(1).startsWith("start") ? START_FIELDS.contains(flow.group(2)) : APPROVE_FIELDS.contains(flow.group(2)); } Matcher form = FORM_TAG.matcher(tag); if (form.matches()) return !"biz_sign".equals(lastSegment(form.group(1))); if (ROWS_TAG.matcher(tag).matches()) return true; Matcher legacy = LEGACY_TAG.matcher(tag); return legacy.matches() && LEGACY_FIELDS.contains(legacy.group(3)); } private Map checkedValues(Map source) { Map result = new LinkedHashMap<>(); for (Map.Entry entry : source.entrySet()) { String tag = entry.getKey() == null ? "" : entry.getKey().trim(); if (!validTag(tag)) throw new DataException("签批页回填数据包含非法标签: " + tag); result.put(tag, entry.getValue() == null ? "" : entry.getValue()); } return result; } private 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"); Element run = run(document, oldRun, value); if (inline) { content.appendChild(run); return; } Element paragraph = document.createElementNS(WORD_NAMESPACE, "w:p"); copyAttributes(oldParagraph, paragraph); Element properties = oldParagraph == null ? null : directChild(oldParagraph, "pPr"); if (properties != null) paragraph.appendChild(properties.cloneNode(true)); paragraph.appendChild(run); content.appendChild(paragraph); } private Element run(Document document, Element oldRun, String value) { Element run = document.createElementNS(WORD_NAMESPACE, "w:r"); copyAttributes(oldRun, run); Element properties = oldRun == null ? null : directChild(oldRun, "rPr"); if (properties != null) run.appendChild(properties.cloneNode(true)); String[] lines = value.split("\\n", -1); for (int index = 0; index < lines.length; index++) { if (index > 0) run.appendChild(document.createElementNS(WORD_NAMESPACE, "w:br")); Element text = document.createElementNS(WORD_NAMESPACE, "w:t"); text.setAttributeNS(XMLConstants.XML_NS_URI, "xml:space", "preserve"); text.setTextContent(lines[index]); run.appendChild(text); } return run; } private boolean hasApprovalFieldAncestor(Element element) { for (Node parent = element.getParentNode(); parent != null; parent = parent.getParentNode()) { if (parent.getNodeType() != Node.ELEMENT_NODE || !WORD_NAMESPACE.equals(parent.getNamespaceURI()) || !"sdt".equals(parent.getLocalName())) continue; String parentTag = tag(directChild((Element) parent, "sdtPr")); if (parentTag != null && parentTag.startsWith(PREFIX)) return true; } return false; } private 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 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 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 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 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 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 boolean isNumberedPart(String name, String prefix) { return name.startsWith(prefix) && name.endsWith(".xml") && name.indexOf('/', prefix.length()) < 0; } private void requireSafeEntryName(String name) { if (name == null || name.isEmpty() || name.startsWith("/") || name.startsWith("\\") || name.indexOf('\\') >= 0) throw new DataException("签批页 DOCX 条目路径无效"); for (String segment : name.split("/", -1)) { if ("..".equals(segment) || ".".equals(segment)) { throw new DataException("签批页 DOCX 条目不允许跳出目录"); } } } private void rejectExcessiveCompression(ZipEntry entry, long uncompressedBytes) { long compressedBytes = entry.getCompressedSize(); if (uncompressedBytes > 0 && compressedBytes == 0) { throw new DataException("签批页 DOCX 条目压缩比无效"); } if (compressedBytes > 0 && uncompressedBytes > compressedBytes * MAX_COMPRESSION_RATIO) { throw new DataException("签批页 DOCX 条目压缩比超过 100:1"); } } private String lastSegment(String value) { int separator = value.lastIndexOf('.'); return separator < 0 ? value : value.substring(separator + 1); } private String message(Exception exception) { return exception.getMessage() == null || exception.getMessage().isBlank() ? exception.getClass().getSimpleName() : exception.getMessage(); } private static final class GeneratedMultipartFile implements MultipartFile { private final String name; private final String originalFilename; private final byte[] content; private GeneratedMultipartFile(String name, String originalFilename, byte[] content) { this.name = name; this.originalFilename = originalFilename; this.content = content; } @Override public String getName() { return name; } @Override public String getOriginalFilename() { return originalFilename; } @Override public String getContentType() { return MediaType.parseMediaType( "application/vnd.openxmlformats-officedocument.wordprocessingml.document").toString(); } @Override public boolean isEmpty() { return content.length == 0; } @Override public long getSize() { return content.length; } @Override public byte[] getBytes() { return content.clone(); } @Override public InputStream getInputStream() { return new ByteArrayInputStream(content); } @Override public void transferTo(File destination) throws IOException { Files.write(destination.toPath(), content); } } }