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<String> START_FIELDS = new HashSet<>(Arrays.asList(
|
"applicant", "applicantDepartment", "applicantTime", "stepName"));
|
private static final Set<String> APPROVE_FIELDS = new HashSet<>(Arrays.asList(
|
"approver", "approverDepartment", "approveTime", "stepName"));
|
private static final Set<String> 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<String, String> 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<String, String> 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<String, String> valuesByTag) {
|
return write(source, valuesByTag, null);
|
}
|
|
byte[] write(byte[] source, Map<String, String> valuesByTag, String flowVersionId) {
|
if (source == null || source.length == 0 || source.length > MAX_TOTAL_BYTES) {
|
throw new DataException("签批页 DOCX 大小无效");
|
}
|
Map<String, String> values = checkedValues(valuesByTag);
|
Set<String> 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<String, String> 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<Element> 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<Element> pairLegacyChildFields(Document document, Map<String, String> values) throws Exception {
|
Set<Element> handled = new HashSet<>();
|
java.util.List<Element> 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<String> 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<String> 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<String> 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<String, String> checkedValues(Map<String, String> source) {
|
Map<String, String> result = new LinkedHashMap<>();
|
for (Map.Entry<String, String> 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); }
|
}
|
}
|