package jnpf.fileStorage.dms; import jnpf.service.DmsApprovalPageTagService; import org.docx4j.openpackaging.packages.WordprocessingMLPackage; import org.docx4j.wml.BooleanDefaultTrue; import org.docx4j.wml.CTSdtContentRun; import org.docx4j.wml.ObjectFactory; import org.docx4j.wml.P; import org.docx4j.wml.R; import org.docx4j.wml.RPr; import org.docx4j.wml.SdtPr; import org.docx4j.wml.SdtRun; import org.docx4j.wml.Tag; import org.docx4j.wml.Text; import org.docx4j.XmlUtils; import org.junit.Test; import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayOutputStream; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class DmsQianpiyeDocxWriterTest { private static final ObjectFactory WML = new ObjectFactory(); private final DmsQianpiyeDocxWriter writer = new DmsQianpiyeDocxWriter(); @Test public void fillsEveryDuplicateTagAndKeepsRunFormatting() throws Exception { MockMultipartFile source = docx( "dms.qianpiye.flow.approve-1.approver", "{{审核人}}", "dms.qianpiye.flow.approve-1.approver", "{{审核人}}" ); Map values = Collections.singletonMap( "dms.qianpiye.flow.approve-1.approver", "张三\n李四"); String xml = documentXml(writer.render(source, values)); assertEquals(2, occurrences(xml, "张三")); assertEquals(2, occurrences(xml, "李四")); assertEquals(2, occurrences(xml, "w:br")); assertTrue(xml.contains("w:b")); assertFalse(xml.contains("{{审核人}}")); } @Test public void writesBlankForAValidMissingValueButLeavesInvalidRoleTagUntouched() throws Exception { MockMultipartFile source = docx( "dms.qianpiye.flow.approve-2.approver", "MISSING", "dms.qianpiye.flow.approve-1.applicant", "KEEP" ); String xml = documentXml(writer.render(source, Collections.emptyMap())); assertFalse(xml.contains("MISSING")); assertTrue(xml.contains("KEEP")); } @Test public void allowsFieldControlsInsideTheWholeApprovalPageMarker() throws Exception { DmsApprovalPageTagService tags = new DmsApprovalPageTagService(); MultipartFile tagged = tags.tagApprovalPage(docx( "dms.qianpiye.form.wenjian_bianhao", "{{文件编号}}")); String xml = documentXml(writer.render(tagged, Collections.singletonMap( "dms.qianpiye.form.wenjian_bianhao", "DOC-001"))); assertTrue(xml.contains(DmsApprovalPageTagService.TAG_VALUE)); assertTrue(xml.contains("DOC-001")); } @Test public void untaggedApprovalPageKeepsItsVisibleContentAndDoesNotBlockArchiving() throws Exception { MockMultipartFile source = plainDocx("审核人"); String xml = documentXml(writer.render(source, Collections.emptyMap())); assertTrue(xml.contains("审核人")); } @Test public void resolvesTagsWhenTheTemplateContainsTheCurrentWorkflowVersion() throws Exception { MockMultipartFile source = withFlowVersionProperties(docx( "dms.qianpiye.flow.approve-1.approver", "{{审核人}}"), "new-flow", "revision-flow"); String xml = documentXml(writer.render(source, Collections.singletonMap( "dms.qianpiye.flow.approve-1.approver", "张三"), "revision-flow")); assertTrue(xml.contains("张三")); } @Test public void fillsOlderTemplatesFromActualNewAndRevisionWorkflowValues() throws Exception { MockMultipartFile source = withFlowVersionProperties(docx( "dms.qianpiye.flow.approve-1.approver", "旧审核人", "dms.qianpiye.form.department", "旧部门", "dms.qianpiye.form.deleted_field", "已删除字段旧值", "dms.qianpiye.flow.approve-9.approver", "已删除步骤旧值"), "old-new-flow", "old-revision-flow"); for (String flow : new String[]{"actual-new-flow", "actual-revision-flow"}) { String xml = documentXml(writer.render(source, Map.of( "dms.qianpiye.flow.approve-1.approver", flow + "审核人", "dms.qianpiye.form.department", flow + "部门"), flow)); assertTrue(xml.contains(flow + "审核人")); assertTrue(xml.contains(flow + "部门")); assertFalse(xml.contains("旧审核人")); assertFalse(xml.contains("旧部门")); assertFalse(xml.contains("已删除字段旧值")); assertFalse(xml.contains("已删除步骤旧值")); } } @Test public void incompleteTemplateVersionMetadataDoesNotBlockActualWorkflowRendering() throws Exception { MockMultipartFile source = withFlowVersionProperties(docx( "dms.qianpiye.form.department", "旧部门"), "old-new-flow", ""); String xml = documentXml(writer.render(source, Map.of("dms.qianpiye.form.department", "当前部门"), "actual-flow")); assertTrue(xml.contains("当前部门")); assertFalse(xml.contains("旧部门")); } @Test public void clearsMissingFieldsAndTablesIncludingPreviouslyRenderedRows() throws Exception { String xml = documentXml(writer.render(docx( "dms.qianpiye.form.deleted_field", "旧主表值", "dms.qianpiye.form.deleted_table.column", "旧子表值", "dms.qianpiye.rows.common.deleted_table", "旧表数据没有占位符"), Collections.emptyMap(), "actual-flow")); assertFalse(xml.contains("旧主表值")); assertFalse(xml.contains("旧子表值")); assertFalse(xml.contains("旧表数据没有占位符")); assertTrue(xml.contains("w:b")); } @Test public void rendersRepeatedRowsAsPairedValuesRatherThanIndependentColumns() throws Exception { String tag = "dms.qianpiye.rows.common.distribution"; String xml = documentXml(writer.render(docx(tag, "{{department}}({{copies}})"), Collections.singletonMap(tag, "[{\"department\":\"质量部\",\"copies\":\"10\"}," + "{\"department\":\"生产部\",\"copies\":\"20\"}]"))); assertTrue(xml.contains("质量部(10)")); assertTrue(xml.contains("生产部(20)")); assertEquals(1, occurrences(xml, "w:br")); assertTrue(xml.contains("w:b")); } @Test public void rejectsDeprecatedSourceBoundFieldTags() throws Exception { String tag = "dms.qianpiye.v2.form-1.new_department"; org.junit.Assert.assertThrows(jnpf.exception.DataException.class, () -> writer.render(docx(tag, "{{新部门}}"), Collections.singletonMap(tag, "质量部"))); } @Test public void refillsPreviouslyRenderedRowControlsUsingTheirSavedFormat() throws Exception { String tag = "dms.qianpiye.rows.common.distribution"; MultipartFile source = docx(tag, "{{department}}({{copies}})"); MultipartFile first = writer.render(source, Collections.singletonMap(tag, "[{\"department\":\"旧部门\",\"copies\":\"10\"}]")); String xml = documentXml(writer.render(first, Collections.singletonMap(tag, "[{\"department\":\"新部门\",\"copies\":\"20\"}," + "{\"department\":\"另一部门\",\"copies\":\"30\"}]"))); assertTrue(xml.contains("新部门(20)")); assertTrue(xml.contains("另一部门(30)")); assertFalse(xml.contains("旧部门")); assertTrue(xml.contains("w:b")); assertEquals(1, occurrences(xml, "w:br")); } @Test public void emptyChildTableRendersEmptyAndMissingColumnsRenderBlank() throws Exception { String tag = "dms.qianpiye.rows.common.distribution"; assertFalse(documentXml(writer.render(docx(tag, "{{department}}"), Collections.singletonMap(tag, "[]"))).contains("{{department}}")); String xml = documentXml(writer.render(docx(tag, "{{department}}({{deleted}})"), Collections.singletonMap(tag, "[{\"department\":\"质量部\"}]"))); assertTrue(xml.contains("质量部()")); assertFalse(xml.contains("{{deleted}}")); } @Test public void rendersChineseRowPlaceholdersUsingExplicitAliasBindings() throws Exception { String tag = "dms.qianpiye.rows.common.distribution"; WordprocessingMLPackage word = WordprocessingMLPackage.load(docx(tag, "{{部门}}({{份数}})").getInputStream()); P paragraph = (P) word.getMainDocumentPart().getContent().get(0); SdtRun control = (SdtRun) XmlUtils.unwrap(paragraph.getContent().get(0)); SdtPr.Alias alias = new SdtPr.Alias(); alias.setVal("{\"fields\":{\"部门\":\"department\",\"份数\":\"copies\"}}"); control.getSdtPr().getRPrOrAliasOrLock().add(alias); ByteArrayOutputStream bytes = new ByteArrayOutputStream(); word.save(bytes); MockMultipartFile source = new MockMultipartFile("file", "approval.docx", "application/octet-stream", bytes.toByteArray()); String xml = documentXml(writer.render(source, Collections.singletonMap(tag, "[{\"department\":\"质量部\",\"copies\":\"10\"},{\"department\":\"生产部\",\"copies\":\"20\"}]"))); assertTrue(xml.contains("质量部(10)")); assertTrue(xml.contains("生产部(20)")); } @Test public void pairsLegacyControlsWithPunctuationAndEmptyCopies() throws Exception { String department = "dms.qianpiye.form.distribution.department"; String copies = "dms.qianpiye.form.distribution.copies"; WordprocessingMLPackage word = WordprocessingMLPackage.load(docx(department, "DEPT", copies, "COPIES").getInputStream()); P first = (P) word.getMainDocumentPart().getContent().get(0); P second = (P) word.getMainDocumentPart().getContent().remove(1); first.getContent().add(textRun("(")); first.getContent().addAll(second.getContent()); first.getContent().add(textRun(")")); ByteArrayOutputStream bytes = new ByteArrayOutputStream(); word.save(bytes); Map values = new LinkedHashMap<>(); values.put(department, "运营管理部\n信息管理部"); values.put(copies, "\n20"); values.put("dms.qianpiye.rows.common.distribution", "[{\"department\":\"运营管理部\",\"copies\":\"\"},{\"department\":\"信息管理部\",\"copies\":\"20\"}]"); String xml = documentXml(writer.render(new MockMultipartFile("file", "approval.docx", "application/octet-stream", bytes.toByteArray()), values)); javax.xml.parsers.DocumentBuilderFactory factory = javax.xml.parsers.DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); org.w3c.dom.Document document = factory.newDocumentBuilder().parse(new java.io.ByteArrayInputStream(xml.getBytes(java.nio.charset.StandardCharsets.UTF_8))); org.w3c.dom.NodeList paragraphs = document.getElementsByTagNameNS("http://schemas.openxmlformats.org/wordprocessingml/2006/main", "p"); assertEquals(2, paragraphs.getLength()); assertEquals("运营管理部()", paragraphText((org.w3c.dom.Element) paragraphs.item(0))); assertEquals("信息管理部(20)", paragraphText((org.w3c.dom.Element) paragraphs.item(1))); assertEquals(0, occurrences(xml, "w:br")); assertTrue(xml.contains("w:b")); } @Test public void pairsLegacyRowsWhenAColumnWasRemovedFromTheActualWorkflow() throws Exception { String department = "dms.qianpiye.form.distribution.department"; String deleted = "dms.qianpiye.form.distribution.deleted"; WordprocessingMLPackage word = WordprocessingMLPackage.load(docx(department, "旧部门", deleted, "旧字段").getInputStream()); P first = (P) word.getMainDocumentPart().getContent().get(0); P second = (P) word.getMainDocumentPart().getContent().remove(1); first.getContent().add(textRun("(")); first.getContent().addAll(second.getContent()); first.getContent().add(textRun(")")); ByteArrayOutputStream bytes = new ByteArrayOutputStream(); word.save(bytes); Map values = Map.of( department, "质量部\n生产部", "dms.qianpiye.rows.common.distribution", "[{\"department\":\"质量部\"},{\"department\":\"生产部\"}]"); String xml = documentXml(writer.render(new MockMultipartFile("file", "approval.docx", "application/octet-stream", bytes.toByteArray()), values, "actual-flow")); assertTrue(xml.contains("质量部")); assertTrue(xml.contains("生产部")); assertEquals(2, occurrences(xml, "(")); assertEquals(2, occurrences(xml, ")")); assertFalse(xml.contains("旧字段")); assertFalse(xml.contains("旧部门")); assertEquals(0, occurrences(xml, "w:br")); } @Test public void malformedChildRowsStillFailRendering() throws Exception { String tag = "dms.qianpiye.rows.common.distribution"; org.junit.Assert.assertThrows(jnpf.exception.DataException.class, () -> writer.render(docx(tag, "{{department}}"), Map.of(tag, "[1]"))); org.junit.Assert.assertThrows(jnpf.exception.DataException.class, () -> writer.render(docx(tag, "{{department}}"), Map.of(tag, "[{\"department\":{}}]"))); } @Test public void preservesBookmarkRangesAndGivesRepeatedDrawingsUniqueIds() throws Exception { String xml = """ D ( C ) """; java.lang.reflect.Method render = DmsQianpiyeDocxWriter.class.getDeclaredMethod("writePart", byte[].class, Map.class, String.class); render.setAccessible(true); byte[] output = (byte[]) render.invoke(writer, xml.getBytes(java.nio.charset.StandardCharsets.UTF_8), Map.of( "dms.qianpiye.form.items.department", "质量部\n生产部", "dms.qianpiye.form.items.copies", "10\n20", "dms.qianpiye.rows.common.items", "[{\"department\":\"质量部\",\"copies\":\"10\"},{\"department\":\"生产部\",\"copies\":\"20\"}]"), "word/document.xml"); javax.xml.parsers.DocumentBuilderFactory factory = javax.xml.parsers.DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); org.w3c.dom.Document doc = factory.newDocumentBuilder().parse(new java.io.ByteArrayInputStream(output)); String w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; org.w3c.dom.NodeList paragraphs = doc.getElementsByTagNameNS(w, "p"); assertEquals("质量部(10)", paragraphText((org.w3c.dom.Element) paragraphs.item(1))); assertEquals("生产部(20)", paragraphText((org.w3c.dom.Element) paragraphs.item(2))); for (String name : new String[]{"bookmarkStart", "bookmarkEnd"}) { org.w3c.dom.NodeList bookmarks = doc.getElementsByTagNameNS(w, name); assertEquals(3, bookmarks.getLength()); java.util.Set ids = new java.util.HashSet<>(); for (int i = 0; i < bookmarks.getLength(); i++) ids.add(((org.w3c.dom.Element) bookmarks.item(i)).getAttributeNS(w, "id")); assertEquals(java.util.Set.of("8", "9", "10"), ids); } assertEquals(2, ((org.w3c.dom.Element) paragraphs.item(1)).getElementsByTagNameNS(w, "bookmarkStart").getLength()); assertEquals(0, ((org.w3c.dom.Element) paragraphs.item(2)).getElementsByTagNameNS(w, "bookmarkStart").getLength()); org.w3c.dom.NodeList images = doc.getElementsByTagNameNS("http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", "docPr"); assertEquals(2, images.getLength()); assertFalse(((org.w3c.dom.Element) images.item(0)).getAttribute("id").equals(((org.w3c.dom.Element) images.item(1)).getAttribute("id"))); } private R textRun(String value) { R run = WML.createR(); Text text = WML.createText(); text.setValue(value); run.getContent().add(WML.createRT(text)); return run; } private String paragraphText(org.w3c.dom.Element paragraph) { org.w3c.dom.NodeList texts = paragraph.getElementsByTagNameNS("http://schemas.openxmlformats.org/wordprocessingml/2006/main", "t"); StringBuilder result = new StringBuilder(); for (int i = 0; i < texts.getLength(); i++) result.append(texts.item(i).getTextContent()); return result.toString(); } private MockMultipartFile docx(String... tagsAndText) throws Exception { WordprocessingMLPackage word = WordprocessingMLPackage.createPackage(); word.getMainDocumentPart().getContent().clear(); for (int index = 0; index < tagsAndText.length; index += 2) { Tag tag = WML.createTag(); tag.setVal(tagsAndText[index]); SdtPr properties = WML.createSdtPr(); properties.setTag(tag); Text text = WML.createText(); text.setValue(tagsAndText[index + 1]); RPr runProperties = WML.createRPr(); runProperties.setB(new BooleanDefaultTrue()); R run = WML.createR(); run.setRPr(runProperties); run.getContent().add(WML.createRT(text)); CTSdtContentRun content = WML.createCTSdtContentRun(); content.getContent().add(run); SdtRun control = WML.createSdtRun(); control.setSdtPr(properties); control.setSdtContent(content); P paragraph = WML.createP(); paragraph.getContent().add(WML.createPSdt(control)); word.getMainDocumentPart().getContent().add(paragraph); } ByteArrayOutputStream output = new ByteArrayOutputStream(); word.save(output); return new MockMultipartFile("file", "approval.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", output.toByteArray()); } private MockMultipartFile plainDocx(String text) throws Exception { WordprocessingMLPackage word = WordprocessingMLPackage.createPackage(); word.getMainDocumentPart().getContent().clear(); word.getMainDocumentPart().addParagraphOfText(text); ByteArrayOutputStream output = new ByteArrayOutputStream(); word.save(output); return new MockMultipartFile("file", "approval.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", output.toByteArray()); } private MockMultipartFile withFlowVersionProperties(MultipartFile source, String newFlow, String revisionFlow) throws Exception { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); try (ZipInputStream input = new ZipInputStream(source.getInputStream()); ZipOutputStream output = new ZipOutputStream(bytes)) { ZipEntry entry; byte[] buffer = new byte[8192]; while ((entry = input.getNextEntry()) != null) { output.putNextEntry(new ZipEntry(entry.getName())); int read; while ((read = input.read(buffer)) != -1) output.write(buffer, 0, read); output.closeEntry(); input.closeEntry(); } output.putNextEntry(new ZipEntry("docProps/custom.xml")); output.write((""" %s %s """.formatted(newFlow, revisionFlow)).getBytes(java.nio.charset.StandardCharsets.UTF_8)); output.closeEntry(); } return new MockMultipartFile("file", "approval.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", bytes.toByteArray()); } private String documentXml(MultipartFile file) throws Exception { WordprocessingMLPackage word = WordprocessingMLPackage.load(file.getInputStream()); return XmlUtils.marshaltoString(word.getMainDocumentPart().getJaxbElement(), true, true); } private int occurrences(String source, String value) { int count = 0; for (int index = 0; (index = source.indexOf(value, index)) >= 0; index += value.length()) count++; return count; } }