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<DocxControlSnapshot> 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<DocxControlSnapshot> read(InputStream input) {
|
if (input == null) {
|
throw new DataException("DOCX 文件不能为空");
|
}
|
List<DocxControlSnapshot> 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<DocxControlSnapshot> 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<ControlBuilder> stack = new ArrayDeque<>();
|
List<ControlBuilder> completedControls = new ArrayList<>();
|
Deque<TableContext> tables = new ArrayDeque<>();
|
Deque<CellContext> 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<String, Object> 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<Integer> 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;
|
}
|
}
|
}
|