package jnpf.fileStorage.dms;
|
|
import jnpf.exception.DataException;
|
import org.junit.Test;
|
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
import java.io.*;
|
import java.nio.charset.StandardCharsets;
|
import java.util.*;
|
import java.util.zip.*;
|
import static org.junit.Assert.*;
|
|
public class DmsDocxCommentCleanerTest {
|
private final DmsDocxCommentCleaner cleaner = new DmsDocxCommentCleaner();
|
private static final String W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
private static final String R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
|
private static final String REL = "http://schemas.openxmlformats.org/package/2006/relationships";
|
private static byte[] bytes(String s) { return s.getBytes(StandardCharsets.UTF_8); }
|
private Map<String, byte[]> fixture() {
|
Map<String, byte[]> p = new LinkedHashMap<>();
|
p.put("[Content_Types].xml", bytes("<Types xmlns='http://schemas.openxmlformats.org/package/2006/content-types'>"
|
+ "<Override PartName='/word/document.xml' ContentType='application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml'/>"
|
+ "<Override PartName='/word/notes.xml' ContentType='application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml'/>"
|
+ "<Override PartName='/word/thread.xml' ContentType='application/vnd.ms-word.commentsExtended+xml'/></Types>"));
|
p.put("word/document.xml", bytes("<w:document xmlns:w='" + W + "'><w:body><w:p><w:commentRangeStart w:id='1'/>"
|
+ "<w:r><w:rPr><w:b/></w:rPr><w:t>正文与签批页</w:t></w:r><w:commentRangeEnd w:id='1'/>"
|
+ "<w:r><w:commentReference w:id='1'/></w:r><w:ins w:id='2'><w:r><w:t>修订保留</w:t></w:r></w:ins>"
|
+ "<w:del w:id='3'><w:r><w:delText>删除痕迹保留</w:delText></w:r></w:del></w:p></w:body></w:document>"));
|
p.put("word/header1.xml", bytes("<w:hdr xmlns:w='" + W + "'><w:p><w:commentRangeStart w:id='2'/><w:r><w:t>页眉</w:t></w:r></w:p></w:hdr>"));
|
p.put("word/notes.xml", bytes("<w:comments xmlns:w='" + W + "'><w:comment w:id='1' w:author='审核人'><w:p><w:r><w:t>秘密批注</w:t></w:r></w:p></w:comment></w:comments>"));
|
p.put("word/thread.xml", bytes("<w15:commentsEx xmlns:w15='http://schemas.microsoft.com/office/word/2012/wordml'><w15:commentEx w15:done='1'/></w15:commentsEx>"));
|
p.put("word/_rels/document.xml.rels", bytes("<Relationships xmlns='" + REL + "'>"
|
+ link("c", R + "comments", "notes.xml") + link("t", "http://schemas.microsoft.com/office/2011/relationships/commentsExtended", "thread.xml")
|
+ link("i", R + "image", "media/shared.png") + "</Relationships>"));
|
p.put("word/_rels/notes.xml.rels", bytes("<Relationships xmlns='" + REL + "'>" + link("i", R + "image", "media/comment.png")
|
+ link("s", R + "image", "media/shared.png") + "</Relationships>"));
|
p.put("word/media/shared.png", new byte[]{1, 2, 3});
|
p.put("word/media/comment.png", new byte[]{4, 5});
|
p.put("word/styles.xml", bytes("<w:styles xmlns:w='" + W + "'/>"));
|
return p;
|
}
|
private static String link(String id, String type, String target) {
|
return "<Relationship Id='" + id + "' Type='" + type + "' Target='" + target + "'/>";
|
}
|
private MultipartFile file(Map<String, byte[]> parts) throws Exception {
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
try (ZipOutputStream zip = new ZipOutputStream(out)) {
|
for (var part : parts.entrySet()) { zip.putNextEntry(new ZipEntry(part.getKey())); zip.write(part.getValue()); zip.closeEntry(); }
|
}
|
return new MockMultipartFile("file", "正式稿.DOCX", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", out.toByteArray());
|
}
|
private Map<String, byte[]> unzip(MultipartFile f) throws Exception {
|
Map<String, byte[]> out = new LinkedHashMap<>();
|
try (ZipInputStream zip = new ZipInputStream(f.getInputStream())) {
|
ZipEntry e; while ((e = zip.getNextEntry()) != null) out.put(e.getName(), zip.readAllBytes());
|
}
|
return out;
|
}
|
@Test public void removesCommentsAndRejectsRevisionsWithoutChangingSource() throws Exception {
|
Map<String, byte[]> original = fixture(); MultipartFile source = file(original); byte[] before = source.getBytes();
|
MultipartFile cleaned = cleaner.clean(source); Map<String, byte[]> result = unzip(cleaned);
|
assertArrayEquals(before, source.getBytes());
|
assertFalse(result.containsKey("word/notes.xml")); assertFalse(result.containsKey("word/thread.xml"));
|
assertFalse(result.containsKey("word/_rels/notes.xml.rels")); assertFalse(result.containsKey("word/media/comment.png"));
|
assertArrayEquals(original.get("word/media/shared.png"), result.get("word/media/shared.png"));
|
assertArrayEquals(original.get("word/styles.xml"), result.get("word/styles.xml"));
|
String body = new String(result.get("word/document.xml"), StandardCharsets.UTF_8);
|
assertTrue(body.contains("正文与签批页")); assertFalse(body.contains("w:ins")); assertFalse(body.contains("w:del")); assertFalse(body.contains("修订保留")); assertTrue(body.contains("删除痕迹保留")); assertTrue(body.contains("w:b"));
|
for (byte[] part : result.values()) { String xml = new String(part, StandardCharsets.UTF_8); assertFalse(xml.contains("commentRange")); assertFalse(xml.contains("commentReference")); assertFalse(xml.contains("notes.xml")); assertFalse(xml.contains("thread.xml")); }
|
assertSame(cleaned, cleaner.clean(cleaned));
|
}
|
@Test public void removesAllModernMetadataViaTypesAndStrictMarkers() throws Exception {
|
Map<String, byte[]> p = fixture();
|
p.put("word/document.xml", bytes(new String(p.get("word/document.xml"), StandardCharsets.UTF_8).replace(W, "http://purl.oclc.org/ooxml/wordprocessingml/main")));
|
for (String kind : List.of("commentsIds", "commentsExtensible", "people")) {
|
String types = new String(p.get("[Content_Types].xml"), StandardCharsets.UTF_8);
|
p.put("[Content_Types].xml", bytes(types.replace("</Types>", "<Override PartName='/word/" + kind + ".xml' ContentType='application/vnd.ms-word." + kind + "+xml'/></Types>")));
|
p.put("word/" + kind + ".xml", bytes("<metadata/>"));
|
}
|
Map<String, byte[]> result = unzip(cleaner.clean(file(p)));
|
for (String kind : List.of("commentsIds", "commentsExtensible", "people")) assertFalse(result.containsKey("word/" + kind + ".xml"));
|
assertFalse(new String(result.get("word/document.xml"), StandardCharsets.UTF_8).contains("commentRange"));
|
}
|
@Test public void failsClosedOnCorruptXmlUnsafePackageAndDigitalSignature() throws Exception {
|
Map<String, byte[]> p = fixture(); p.put("word/document.xml", bytes("<!DOCTYPE a [<!ENTITY x SYSTEM 'file:///etc/passwd'>]><a>&x;</a>"));
|
MultipartFile external = file(p); assertThrows(DataException.class, () -> cleaner.clean(external));
|
p = fixture(); p.put("../bad", bytes("bad")); MultipartFile unsafe = file(p); assertThrows(DataException.class, () -> cleaner.clean(unsafe));
|
p = fixture(); p.put("_xmlsignatures/sig1.xml", bytes("<signature/>")); MultipartFile signed = file(p); assertThrows(DataException.class, () -> cleaner.clean(signed));
|
assertThrows(DataException.class, () -> cleaner.clean(new MockMultipartFile("f", "bad.docx", null, bytes("invalid zip"))));
|
}
|
@Test public void actualMergedDocxReopensWithApprovalPageAndRevisionsRejected() throws Exception {
|
org.docx4j.openpackaging.packages.WordprocessingMLPackage document = org.docx4j.openpackaging.packages.WordprocessingMLPackage.createPackage();
|
ByteArrayOutputStream output = new ByteArrayOutputStream(); document.save(output);
|
Map<String, byte[]> parts = unzip(new MockMultipartFile("f", "main.docx", null, output.toByteArray()));
|
Map<String, byte[]> comments = fixture();
|
parts.put("word/document.xml", comments.get("word/document.xml"));
|
parts.put("word/notes.xml", comments.get("word/notes.xml"));
|
String types = new String(parts.get("[Content_Types].xml"), StandardCharsets.UTF_8);
|
parts.put("[Content_Types].xml", bytes(types.replace("</Types>", "<Override PartName='/word/notes.xml' ContentType='application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml'/></Types>")));
|
String links = new String(parts.get("word/_rels/document.xml.rels"), StandardCharsets.UTF_8);
|
parts.put("word/_rels/document.xml.rels", bytes(links.replace("</Relationships>", link("comment1", R + "comments", "notes.xml") + "</Relationships>")));
|
org.docx4j.openpackaging.packages.WordprocessingMLPackage approval = org.docx4j.openpackaging.packages.WordprocessingMLPackage.createPackage();
|
approval.getMainDocumentPart().addParagraphOfText("APPROVAL SIGNATURE PAGE");
|
ByteArrayOutputStream pageBytes = new ByteArrayOutputStream(); approval.save(pageBytes);
|
DmsApprovalPageMergeService merge = new DmsApprovalPageMergeService(new jnpf.service.DmsApprovalPageTagService());
|
MultipartFile merged = merge.replaceOrPrepend(file(parts), new MockMultipartFile("f", "approval.docx", null, pageBytes.toByteArray()));
|
MultipartFile result = cleaner.clean(merged);
|
assertTrue(merge.hasApprovalPage(result));
|
var reopened = org.docx4j.openpackaging.packages.WordprocessingMLPackage.load(result.getInputStream());
|
String body = org.docx4j.XmlUtils.marshaltoString(reopened.getMainDocumentPart().getJaxbElement(), true, true);
|
assertTrue(body.contains("APPROVAL SIGNATURE PAGE")); assertFalse(body.contains("修订保留"));
|
assertTrue(body.contains("删除痕迹保留")); assertFalse(body.contains("commentReference"));
|
assertFalse(unzip(result).containsKey("word/notes.xml"));
|
}
|
|
@Test public void restoresFormattingMovesDeletedRowsAndParagraphBreaks() throws Exception {
|
Map<String, byte[]> parts = fixture();
|
parts.put("word/document.xml", bytes("<w:document xmlns:w='" + W + "'><w:body>"
|
+ "<w:p><w:pPr><w:rPr><w:ins w:id='1'/></w:rPr></w:pPr><w:r><w:t>first</w:t></w:r></w:p>"
|
+ "<w:p><w:r><w:rPr><w:b/><w:rPrChange w:id='2'><w:rPr><w:i/></w:rPr></w:rPrChange></w:rPr><w:t>second</w:t></w:r>"
|
+ "<w:moveFrom w:id='3'><w:r><w:delText>original-place</w:delText></w:r></w:moveFrom>"
|
+ "<w:moveTo w:id='4'><w:r><w:t>new-place</w:t></w:r></w:moveTo></w:p>"
|
+ "<w:tbl><w:tr><w:trPr><w:ins/></w:trPr><w:tc><w:p><w:r><w:t>inserted-row</w:t></w:r></w:p></w:tc></w:tr>"
|
+ "<w:tr><w:trPr><w:del/></w:trPr><w:tc><w:p><w:del><w:r><w:delText>restored-row</w:delText></w:r></w:del></w:p></w:tc></w:tr></w:tbl>"
|
+ "</w:body></w:document>"));
|
String body = new String(unzip(cleaner.clean(file(parts))).get("word/document.xml"), StandardCharsets.UTF_8);
|
assertTrue(body.contains("first")); assertTrue(body.contains("second"));
|
assertTrue(body.contains("w:i")); assertFalse(body.contains("w:b/"));
|
assertTrue(body.contains("original-place")); assertFalse(body.contains("new-place"));
|
assertTrue(body.contains("restored-row")); assertFalse(body.contains("inserted-row"));
|
for (String marker : List.of("w:ins", "w:del", "w:moveFrom", "w:moveTo", "w:rPrChange")) assertFalse(body.contains(marker));
|
assertEquals(2, body.split("<w:p>", -1).length - 1);
|
}
|
@Test public void rejectsRevisionsInHeadersAndStrictDocuments() throws Exception {
|
Map<String, byte[]> parts = fixture();
|
parts.put("word/header1.xml", bytes("<w:hdr xmlns:w='http://purl.oclc.org/ooxml/wordprocessingml/main'><w:p>"
|
+ "<w:ins><w:r><w:t>new-header</w:t></w:r></w:ins><w:del><w:r><w:delText>old-header</w:delText></w:r></w:del>"
|
+ "</w:p></w:hdr>"));
|
String header = new String(unzip(cleaner.clean(file(parts))).get("word/header1.xml"), StandardCharsets.UTF_8);
|
assertFalse(header.contains("new-header")); assertTrue(header.contains("old-header")); assertFalse(header.contains("w:del"));
|
}
|
@Test public void unsupportedStructuralRevisionsFailBeforeStorage() throws Exception {
|
for (String marker : List.of("cellMerge", "numberingChange", "customXmlDelRangeStart")) {
|
Map<String, byte[]> parts = fixture();
|
parts.put("word/document.xml", bytes("<w:document xmlns:w='" + W + "'><w:body><w:p><w:" + marker + "/></w:p></w:body></w:document>"));
|
MultipartFile source = file(parts);
|
assertThrows(DataException.class, () -> cleaner.clean(source));
|
}
|
}
|
|
private org.w3c.dom.Document cleanBody(String body) throws Exception {
|
Map<String, byte[]> parts = fixture();
|
parts.put("word/document.xml", bytes("<w:document xmlns:w='" + W + "'><w:body>" + body + "</w:body></w:document>"));
|
MultipartFile cleaned = cleaner.clean(file(parts));
|
assertSame("Cleaning must be idempotent", cleaned, cleaner.clean(cleaned));
|
var factory = javax.xml.parsers.DocumentBuilderFactory.newDefaultInstance();
|
factory.setNamespaceAware(true);
|
return factory.newDocumentBuilder().parse(new ByteArrayInputStream(unzip(cleaned).get("word/document.xml")));
|
}
|
|
@Test public void paragraphMergeKeepsPropertiesFirstDespiteWhitespaceAndComments() throws Exception {
|
var doc = cleanBody("<w:p><w:pPr><w:rPr><w:ins/></w:rPr></w:pPr><w:r><w:t>A</w:t></w:r></w:p>"
|
+ "<w:p>\n <!--before properties--> <w:pPr><w:jc w:val='center'/></w:pPr>\n"
|
+ "<w:r><w:t>B</w:t></w:r></w:p>");
|
var paragraphs = doc.getElementsByTagNameNS(W, "p");
|
assertEquals(1, paragraphs.getLength());
|
var paragraph = paragraphs.item(0);
|
org.w3c.dom.Node first = paragraph.getFirstChild();
|
while (!(first instanceof org.w3c.dom.Element)) first = first.getNextSibling();
|
assertEquals("pPr", first.getLocalName());
|
assertEquals("center", ((org.w3c.dom.Element) doc.getElementsByTagNameNS(W, "jc").item(0)).getAttributeNS(W, "val"));
|
var text = doc.getElementsByTagNameNS(W, "t");
|
assertEquals(2, text.getLength());
|
assertEquals("A", text.item(0).getTextContent());
|
assertEquals("B", text.item(1).getTextContent());
|
}
|
|
@Test public void rejectsWholeAndConsecutiveParagraphMovesWithoutEmptyDestinationParagraphs() throws Exception {
|
for (int count : List.of(1, 2)) {
|
StringBuilder body = new StringBuilder();
|
for (int i = 0; i < count; i++) body.append("<w:p><w:pPr><w:rPr><w:moveFrom/></w:rPr></w:pPr>"
|
+ "<w:moveFrom><w:r><w:t>original" + i + "</w:t></w:r></w:moveFrom></w:p>");
|
body.append("<w:p><w:r><w:t>middle</w:t></w:r></w:p>");
|
for (int i = 0; i < count; i++) body.append("<w:p><w:pPr><w:rPr><w:moveTo/></w:rPr></w:pPr>"
|
+ "<w:moveTo><w:r><w:t>original" + i + "</w:t></w:r></w:moveTo></w:p>");
|
body.append("<w:p>\n<w:pPr><w:jc w:val='right'/></w:pPr><w:r><w:t>end</w:t></w:r></w:p>");
|
var doc = cleanBody(body.toString());
|
var paragraphs = doc.getElementsByTagNameNS(W, "p");
|
assertEquals(count + 2, paragraphs.getLength());
|
for (int i = 0; i < count; i++) assertEquals("original" + i, paragraphs.item(i).getTextContent());
|
assertEquals("middle", paragraphs.item(count).getTextContent());
|
assertEquals("end", paragraphs.item(count + 1).getTextContent().trim());
|
assertEquals("right", ((org.w3c.dom.Element) doc.getElementsByTagNameNS(W, "jc").item(0)).getAttributeNS(W, "val"));
|
assertEquals(0, doc.getElementsByTagNameNS(W, "moveFrom").getLength());
|
assertEquals(0, doc.getElementsByTagNameNS(W, "moveTo").getLength());
|
}
|
}
|
|
@Test public void paragraphMarkRejectionFailsClosedAtStructuralBoundaries() throws Exception {
|
for (String mark : List.of("ins", "moveTo")) {
|
for (String boundary : List.of("", "<w:tbl><w:tr><w:tc><w:p/></w:tc></w:tr></w:tbl>", "<w:sectPr/>")) {
|
assertThrows(DataException.class, () -> cleanBody("<w:p><w:pPr><w:rPr><w:" + mark
|
+ "/></w:rPr></w:pPr><w:r><w:t>text</w:t></w:r></w:p>" + boundary));
|
}
|
}
|
}
|
|
@Test public void nonDocxIsUntouched() {
|
MultipartFile source = new MockMultipartFile("f", "original.pdf", null, bytes("pdf")); assertSame(source, cleaner.clean(source));
|
}
|
}
|