package jnpf.elnService.template.docx; import jnpf.exception.DataException; import jnpf.file.FileUploadApi; import jnpf.model.upload.UploadFileModel; import jnpf.elnService.template.ElnTemplateDocumentEvidence; import feign.Response; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import javax.xml.stream.XMLInputFactory; import javax.xml.stream.XMLStreamConstants; import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @Service public class DocxContentControlReader implements ElnTemplateDocumentEvidence { 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; private final FileUploadApi fileUploadApi; public DocxContentControlReader() { this.fileUploadApi = null; } @Autowired public DocxContentControlReader(FileUploadApi fileUploadApi) { this.fileUploadApi = fileUploadApi; } @Override public List readControls(String tenantId, String fileId) { if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(fileId) || fileUploadApi == null) { throw new DataException("DOCX 控件证据服务不可用"); } try (Response response = fileUploadApi.downFile( new UploadFileModel("annex", fileId.trim()))) { if (response == null || response.status() < 200 || response.status() >= 300 || response.body() == null) { throw new DataException("模板文件下载失败"); } Integer declaredLength = response.body().length(); if (declaredLength != null && (declaredLength == 0 || declaredLength > MAX_TOTAL_BYTES)) { throw new DataException("模板文件大小无效"); } try (InputStream input = response.body().asInputStream(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { byte[] buffer = new byte[8192]; int total = 0; int count; while ((count = input.read(buffer)) != -1) { total += count; if (total > MAX_TOTAL_BYTES) { throw new DataException("模板文件超过 50MB 限制"); } output.write(buffer, 0, count); } if (total == 0) { throw new DataException("模板文件不能为空"); } return read(new ByteArrayInputStream(output.toByteArray())); } } catch (DataException e) { throw e; } catch (IOException | RuntimeException e) { throw new DataException("模板文件下载失败"); } } public List read(InputStream input) { if (input == null) { throw new DataException("DOCX 文件不能为空"); } List controls = new ArrayList<>(); boolean documentFound = false; int entries = 0; long totalBytes = 0; try (ZipInputStream zip = new ZipInputStream(input)) { ZipEntry entry; byte[] buffer = new byte[8192]; while ((entry = zip.getNextEntry()) != null) { entries++; if (entries > MAX_ENTRIES) { throw new DataException("DOCX ZIP 条目超过 10000 个限制"); } String name = entry.getName(); requireSafeEntryName(name); boolean targetPart = isTargetPart(name); if ("word/document.xml".equals(name)) { documentFound = true; } ByteArrayOutputStream part = targetPart ? new ByteArrayOutputStream() : null; long entryBytes = 0; int read; while ((read = zip.read(buffer)) != -1) { entryBytes += read; totalBytes += read; if (totalBytes > MAX_TOTAL_BYTES) { throw new DataException("DOCX 解压后超过 50MB 限制"); } if (targetPart && entryBytes > MAX_PART_BYTES) { throw new DataException("DOCX XML 部件超过 10MB 限制"); } if (targetPart) { part.write(buffer, 0, read); } } zip.closeEntry(); rejectExcessiveCompression(entry, entryBytes); if (targetPart) { parsePart(name, part.toByteArray(), controls); } } } catch (DataException e) { throw e; } catch (IOException | XMLStreamException | RuntimeException e) { throw new DataException("DOCX 内容控件解析失败"); } if (!documentFound) { throw new DataException("DOCX 缺少 word/document.xml"); } return controls; } private static void parsePart(String partName, byte[] bytes, List controls) throws XMLStreamException { XMLInputFactory factory = XMLInputFactory.newFactory(); factory.setProperty(XMLInputFactory.IS_NAMESPACE_AWARE, true); factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); factory.setProperty("javax.xml.stream.isSupportingExternalEntities", false); XMLStreamReader xml = factory.createXMLStreamReader(new ByteArrayInputStream(bytes)); Deque stack = new ArrayDeque<>(); List completedControls = new ArrayList<>(); Deque tables = new ArrayDeque<>(); Deque cells = new ArrayDeque<>(); int depth = 0; int paragraphDepth = 0; int tableSequence = 0; try { while (xml.hasNext()) { int event = xml.next(); if (event == XMLStreamConstants.DTD) { throw new XMLStreamException("DOCX XML 不允许 DTD"); } if (event == XMLStreamConstants.START_ELEMENT) { depth++; String local = xml.getLocalName(); if (isWordElement(xml, "p")) { paragraphDepth++; } if (isWordElement(xml, "tbl")) { tables.push(new TableContext(depth, tableSequence++)); } else if (isWordElement(xml, "tr") && !tables.isEmpty()) { tables.peek().startRow(); } else if (isWordElement(xml, "tc")) { cells.push(new CellContext(depth)); } else if (isWordElement(xml, "vMerge") && !cells.isEmpty()) { cells.peek().verticalMerge = true; if (!tables.isEmpty()) { tables.peek().markVerticalMerge(); } } if (isWordElement(xml, "sdt")) { TableContext table = tables.peek(); CellContext cell = cells.peek(); boolean nestedControl = !stack.isEmpty(); if (nestedControl) stack.peek().nestedControl = true; stack.push(new ControlBuilder(partName, depth, paragraphDepth > 0, table == null ? null : table.tableIndex, table == null ? null : table.rowIndex, tables.size() > 1, cell != null && cell.verticalMerge, nestedControl, table)); continue; } if (stack.isEmpty()) { continue; } ControlBuilder current = stack.peek(); if (isWordElement(xml, "sdtPr")) { current.propertyDepth = depth; } else if (isWordElement(xml, "sdtContent")) { current.contentDepth = depth; } else if (current.contentDepth == 0 && current.propertyDepth > 0 && depth > current.propertyDepth) { readProperty(xml, local, current); } else if (current.contentDepth > 0) { if (isWordElement(xml, "t")) { current.textDepth = depth; } readContentValue(xml, local, current); } } else if (event == XMLStreamConstants.CHARACTERS && !stack.isEmpty()) { ControlBuilder current = stack.peek(); if (current.contentDepth > 0 && current.textDepth > 0) { current.text.append(xml.getText()); } } else if (event == XMLStreamConstants.END_ELEMENT) { if (!stack.isEmpty() && isWordElement(xml, "t") && stack.peek().textDepth == depth) { stack.peek().textDepth = 0; } if (!stack.isEmpty() && isWordElement(xml, "p") && stack.peek().contentDepth > 0 && stack.peek().text.length() > 0 && stack.peek().text.charAt(stack.peek().text.length() - 1) != '\n') { stack.peek().text.append('\n'); } if (!stack.isEmpty() && isWordElement(xml, "sdt") && stack.peek().sdtDepth == depth) { ControlBuilder completed = stack.pop(); completedControls.add(completed); } if (isWordElement(xml, "p")) { paragraphDepth--; } if (!cells.isEmpty() && isWordElement(xml, "tc") && cells.peek().depth == depth) { cells.pop(); } if (!tables.isEmpty() && isWordElement(xml, "tbl") && tables.peek().depth == depth) { tables.pop(); } depth--; } } for (ControlBuilder completed : completedControls) { DocxControlSnapshot snapshot = completed.build(); if (snapshot != null) { controls.add(snapshot); } } } finally { xml.close(); } } private static void readContentValue(XMLStreamReader xml, String local, ControlBuilder current) { if ("checked".equals(local)) { current.checked = isTrue(value(xml, "val")); } } private static void readProperty(XMLStreamReader xml, String local, ControlBuilder current) { if ("tag".equals(local)) { current.tag = value(xml, "val"); } else if ("alias".equals(local)) { current.alias = value(xml, "val"); } else if ("lock".equals(local)) { current.lock = value(xml, "val"); } else if ("showingPlcHdr".equals(local)) { current.showingPlaceholder = isOnOffEnabled(value(xml, "val")); } else if ("text".equals(local)) { String multiline = value(xml, "multiLine"); current.controlType = isTrue(multiline) ? "MULTILINE" : "TEXT"; } else if ("dropDownList".equals(local) || "comboBox".equals(local)) { current.controlType = "DROPDOWN"; } else if ("listItem".equals(local) && "DROPDOWN".equals(current.controlType)) { String itemValue = value(xml, "value"); if (itemValue != null) { current.config.put(itemValue, value(xml, "displayText")); } } else if ("checkbox".equalsIgnoreCase(local)) { current.controlType = "CHECKBOX"; } else if ("checked".equals(local) && "CHECKBOX".equals(current.controlType)) { // Word stores checkbox state in w14:sdtPr/w14:checked, not in sdtContent. current.checked = isTrue(value(xml, "val")); } else if ("checkedState".equals(local) || "uncheckedState".equals(local)) { String state = value(xml, "val"); if (state != null) { current.config.put(local, state); } } else if ("date".equals(local)) { current.controlType = "DATE"; String fullDate = value(xml, "fullDate"); if (fullDate != null) { current.dateValue = fullDate; } } else if ("dateFormat".equals(local)) { String format = value(xml, "val"); if (format != null) { current.config.put("dateFormat", format); } } else if (isUnsupportedControlType(xml, local)) { current.unsupportedType = true; } if ("date".equals(local)) { String format = value(xml, "dateFormat"); if (format != null) { current.config.put("dateFormat", format); } } } private static boolean isWordElement(XMLStreamReader xml, String local) { return local.equals(xml.getLocalName()) && WORD_NAMESPACE.equals(xml.getNamespaceURI()); } private static String value(XMLStreamReader xml, String local) { String result = xml.getAttributeValue(WORD_NAMESPACE, local); if (result == null) { result = xml.getAttributeValue(null, local); } if (result == null) { for (int i = 0; i < xml.getAttributeCount(); i++) { if (local.equals(xml.getAttributeLocalName(i))) { return xml.getAttributeValue(i); } } } return result; } private static boolean isTrue(String value) { return "1".equals(value) || "true".equalsIgnoreCase(value) || "on".equalsIgnoreCase(value); } private static boolean isOnOffEnabled(String value) { return value == null || !("0".equals(value) || "false".equalsIgnoreCase(value) || "off".equalsIgnoreCase(value)); } private static boolean isUnsupportedControlType(XMLStreamReader xml, String local) { String namespace = xml.getNamespaceURI(); boolean wordNamespace = WORD_NAMESPACE.equals(namespace) || namespace != null && namespace.startsWith("http://schemas.microsoft.com/office/word/") && namespace.endsWith("/wordml"); return wordNamespace && ("bibliography".equals(local) || "citation".equals(local) || "docPartList".equals(local) || "docPartObj".equals(local) || "entityPicker".equals(local) || "equation".equals(local) || "group".equals(local) || "picture".equals(local) || "repeatingSection".equals(local) || "repeatingSectionItem".equals(local)); } 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 限制"); } } private static final class ControlBuilder { private final String partName; private final int sdtDepth; private final boolean inline; private final Integer tableIndex; private final Integer tableRowIndex; private final boolean nestedTable; private final boolean verticalMerge; private boolean nestedControl; private final TableContext table; private final Map config = new LinkedHashMap<>(); private int propertyDepth; private int contentDepth; private String tag; private String alias; private String lock; private String controlType; private boolean unsupportedType; private boolean showingPlaceholder; private Boolean checked; private String dateValue; private int textDepth; private final StringBuilder text = new StringBuilder(); private ControlBuilder(String partName, int sdtDepth, boolean inline, Integer tableIndex, Integer tableRowIndex, boolean nestedTable, boolean verticalMerge, boolean nestedControl, TableContext table) { this.partName = partName; this.sdtDepth = sdtDepth; this.inline = inline; this.tableIndex = tableIndex; this.tableRowIndex = tableRowIndex; this.nestedTable = nestedTable; this.verticalMerge = verticalMerge; this.nestedControl = nestedControl; this.table = table; } private DocxControlSnapshot build() { if (tag == null || (!tag.startsWith("eln.field.") && !tag.startsWith("eln.ref.") && !tag.startsWith("eln.repeat."))) { return null; } boolean repeat = tag.startsWith("eln.repeat."); String role = tag.startsWith("eln.ref.") ? "REFERENCE" : repeat ? "REPEAT" : "PRIMARY"; return new DocxControlSnapshot(tag, alias, lock, unsupportedType ? "UNKNOWN" : controlType == null ? (inline ? "TEXT" : "MULTILINE") : controlType, role, partName, "OK", config, currentValue(), tableIndex, tableRowIndex, nestedTable, verticalMerge || table != null && table.hasVerticalMerge(tableRowIndex), nestedControl); } private String currentValue() { if (showingPlaceholder) { return null; } if ("CHECKBOX".equals(controlType) && checked != null) { return String.valueOf(checked); } if ("DATE".equals(controlType) && dateValue != null) { return dateValue; } if (text.length() > 0 && text.charAt(text.length() - 1) == '\n') { text.setLength(text.length() - 1); } return text.length() == 0 ? null : text.toString(); } } private static final class TableContext { private final int depth; private final int tableIndex; private final Set verticalMergeRows = new HashSet<>(); private int rowIndex = -1; private TableContext(int depth, int tableIndex) { this.depth = depth; this.tableIndex = tableIndex; } private void startRow() { rowIndex++; } private void markVerticalMerge() { if (rowIndex >= 0) { verticalMergeRows.add(rowIndex); } } private boolean hasVerticalMerge(Integer targetRowIndex) { return targetRowIndex != null && verticalMergeRows.contains(targetRowIndex); } } private static final class CellContext { private final int depth; private boolean verticalMerge; private CellContext(int depth) { this.depth = depth; } } }