刘光辉
117 分钟以前 06f314545ac3514d7ce28939920866ae8188d31c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
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));
    }
}