diff --git a/pom.xml b/pom.xml index 97f5f00..f57fe8c 100644 --- a/pom.xml +++ b/pom.xml @@ -54,6 +54,12 @@ arbitrary XML, JSON, etc. 4.13.2 test + + org.mockito + mockito-core + 4.11.0 + test + org.apache.poi poi diff --git a/src/main/java/org/wordinator/xml2docx/generator/DocxConstants.java b/src/main/java/org/wordinator/xml2docx/generator/DocxConstants.java index ef3f9e6..3764597 100644 --- a/src/main/java/org/wordinator/xml2docx/generator/DocxConstants.java +++ b/src/main/java/org/wordinator/xml2docx/generator/DocxConstants.java @@ -114,6 +114,8 @@ public final class DocxConstants { public static final QName QNAME_VERTICAL_ALIGNMENT_ATT = new QName("", "vertical-alignment"); public static final QName QNAME_WIDTH_ATT = new QName("", "width"); public static final QName QNAME_XSLT_FORMAT_ATT = new QName("", "xslt-format"); + public static final QName QNAME_NUMID_ATT = new QName("", "numId"); + // Elements: public static final QName QNAME_COLS_ELEM = new QName(SIMPLE_WP_NS, "cols"); diff --git a/src/main/java/org/wordinator/xml2docx/generator/DocxGenerator.java b/src/main/java/org/wordinator/xml2docx/generator/DocxGenerator.java index ba2e990..5c1e038 100644 --- a/src/main/java/org/wordinator/xml2docx/generator/DocxGenerator.java +++ b/src/main/java/org/wordinator/xml2docx/generator/DocxGenerator.java @@ -18,9 +18,12 @@ import java.net.URL; import java.net.URLConnection; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import javax.imageio.ImageIO; import javax.xml.namespace.QName; @@ -35,6 +38,7 @@ import org.apache.poi.wp.usermodel.HeaderFooterType; import org.apache.poi.xwpf.usermodel.BreakType; import org.apache.poi.xwpf.usermodel.ParagraphAlignment; +import org.apache.poi.xwpf.usermodel.TableWidthType; import org.apache.poi.xwpf.usermodel.UnderlinePatterns; import org.apache.poi.xwpf.usermodel.XWPFAbstractFootnoteEndnote; import org.apache.poi.xwpf.usermodel.XWPFAbstractNum; @@ -46,6 +50,7 @@ import org.apache.poi.xwpf.usermodel.XWPFNum; import org.apache.poi.xwpf.usermodel.XWPFNumbering; import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRelation; import org.apache.poi.xwpf.usermodel.XWPFRun; import org.apache.poi.xwpf.usermodel.XWPFStyle; import org.apache.poi.xwpf.usermodel.XWPFStyles; @@ -71,6 +76,7 @@ import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTHdrFtrRef; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTHyperlink; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTMarkupRange; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTNumPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTOnOff; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar; @@ -84,6 +90,8 @@ import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSimpleField; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyle; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTbl; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblGrid; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblGridCol; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblLayoutType; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblWidth; @@ -377,6 +385,7 @@ public boolean hasBorders() { } private static final Logger log = LogManager.getLogger(DocxGenerator.class); + private static final String DEFAULT_TABLE_WIDTH = "100%"; private File outFile; private int dotsPerInch = 72; /* DPI */ @@ -390,6 +399,8 @@ public boolean hasBorders() { private boolean isFirstParagraphStyleWarning = true; private boolean isFirstCharacterStyleWarning = true; private boolean isFirstTableStyleWarning = true; + private int maxNum; + private Map> numParas = new LinkedHashMap<>(); /** * @@ -416,8 +427,8 @@ public void generate(XmlObject xml) throws DocxGenerationException, XmlException setupNumbering(doc, this.templateDoc); setupStyles(doc, this.templateDoc); constructDoc(doc, xml); - - FileOutputStream out = new FileOutputStream(outFile); + setNumbering(doc); + FileOutputStream out = new FileOutputStream(this.outFile); doc.write(out); doc.close(); } @@ -1230,6 +1241,7 @@ private XWPFParagraph makeParagraph( cursor.push(); String styleName = cursor.getAttributeText(DocxConstants.QNAME_STYLE_ATT); String styleId = cursor.getAttributeText(DocxConstants.QNAME_STYLEID_ATT); + String numId = cursor.getAttributeText(DocxConstants.QNAME_NUMID_ATT); if (null != styleName && null == styleId) { // Look up the style by name: @@ -1328,6 +1340,13 @@ private XWPFParagraph makeParagraph( } while(cursor.toNextSibling()); } cursor.pop(); + + if (numId != null && !numId.isEmpty()) { + // Store Numbering paragraph + List paras = this.numParas.computeIfAbsent(numId, k -> new ArrayList<>()); + paras.add(para); + } + return para; } @@ -2154,23 +2173,39 @@ private void makeHyperlink(XWPFParagraph para, XmlCursor cursor) throws DocxGene // Set the appropriate target: + XWPFHyperlinkRun hyperlinkRun; + if (href.startsWith("#")) { // Just a fragment ID, must be to a bookmark String bookmarkName = href.substring(1); hyperlink.setAnchor(bookmarkName); + cursor.push(); + hyperlinkRun = makeHyperlinkRun(hyperlink, cursor, para); + cursor.pop(); } else { - // Create a relationship that targets the href and use the - // relationship's ID on the hyperlink - // It's not yet clear from the POI API how to create a new relationship for - // use by an external hyperlink. - // throw new NotImplementedException("Links to external resources not yet implemented."); - } + // Add the link as External relationship + String id = para.getDocument().getPackagePart().addExternalRelationship(href, XWPFRelation.HYPERLINK.getRelation()).getId(); - cursor.push(); - XWPFHyperlinkRun hyperlinkRun = makeHyperlinkRun(hyperlink, cursor, para); - cursor.pop(); - para.addRun(hyperlinkRun); + // Append the link and bind it to the relationship + hyperlink.setId(id); + + // Create the linked text + String linkedText = cursor.getTextValue(); + CTText ctText = CTText.Factory.newInstance(); + ctText.setStringValue(linkedText); + CTR ctr = CTR.Factory.newInstance(); + ctr.setTArray(new CTText[]{ctText}); + + // Create the formatting + CTRPr rpr = ctr.addNewRPr(); + rpr.addNewRStyle().setVal("Hyperlink"); + // Insert the linked text into the link + hyperlink.setRArray(new CTR[]{ctr}); + + hyperlinkRun = new XWPFHyperlinkRun(hyperlink, CTR.Factory.newInstance(), para); + } + para.addRun(hyperlinkRun); } /** @@ -2323,6 +2358,9 @@ private void makeTable(XWPFTable table, XmlObject xml) throws DocxGenerationExce } while (cursor.toNextSibling()); } + setDefaultTableWidthIfNeeded(table); + addTableGridWithColumnsIfNeeded(table, colDefs); + // populate the rows and cells. cursor = xml.newCursor(); @@ -2992,6 +3030,26 @@ private void setupStyles(XWPFDocument doc, XWPFDocument templateDoc) throws Docx } + private void setNumbering(XWPFDocument doc) { + int i = 1; + + for (List paras : this.numParas.values()) { + + int newNum = this.maxNum + i; + BigInteger bgiNumId = BigInteger.valueOf(newNum); + for (XWPFParagraph numPara : paras) { + CTPPr ctpPr = numPara.getCTPPr(); + CTNumPr ctNumPr = ctpPr.addNewNumPr(); + CTDecimalNumber decNumId = CTDecimalNumber.Factory.newInstance(); + decNumId.setVal(bgiNumId); + ctNumPr.setNumId(decNumId); + } + doc.getNumbering().addNum(bgiNumId); + + i++; + } + } + private void setupNumbering(XWPFDocument doc, XWPFDocument templateDoc) throws DocxGenerationException { // Load the template's numbering definitions to the new document @@ -3028,6 +3086,10 @@ private void setupNumbering(XWPFDocument doc, XWPFDocument templateDoc) throws D } while (num != null); + // Calculate max numbering number + Optional maxXwpfNum = numbering.getNums().stream().max(Comparator.comparing(o -> o.getCTNum().getNumId())); + this.maxNum = maxXwpfNum.map(xwpfNum -> xwpfNum.getCTNum().getNumId().intValue()).orElse(0); + } catch (Exception e) { new DocxGenerationException(e.getClass().getSimpleName() + " Copying numbering definitions from template doc: " + e.getMessage(), e); } @@ -3114,5 +3176,52 @@ else if ("jpeg".equals(imgExtension) || return format; } + /** + *

+ * Add table grid (w:tblGrid) and grid columns (w:gridCol) based on table column definitions. + *

+ * + * @param table XWPF table + * @param colDefs table column definitions + */ + public static void addTableGridWithColumnsIfNeeded(XWPFTable table, TableColumnDefinitions colDefs) { + CTTblGrid tblGrid = table.getCTTbl().getTblGrid(); + if (tblGrid == null) { + tblGrid = table.getCTTbl().addNewTblGrid(); + for (TableColumnDefinition colDef : colDefs.getColumnDefinitions()) { + String specifiedWidth = colDef.getSpecifiedWidth(); + CTTblGridCol gridCol = tblGrid.addNewGridCol(); + BigInteger gridColWidth; + // logic below has been copied from XWPFTable.setWidthValue + if (specifiedWidth.matches(XWPFTable.REGEX_PERCENTAGE)) { + String numberPart = specifiedWidth.substring(0, specifiedWidth.length() - 1); + double percentage = Double.parseDouble(numberPart) * 50; + long intValue = Math.round(percentage); + gridColWidth = BigInteger.valueOf(intValue); + } else if (specifiedWidth.matches("auto")) { + gridColWidth = BigInteger.ZERO; + } else { + gridColWidth = new BigInteger(specifiedWidth); + } + gridCol.setW(gridColWidth); + } + } + } + + /** + *

+ * Set table width to 100% and change width type if needed for correct displaying in the both MS Word and LibreOffice + *

+ * + * @param table XWPF table + */ + public static void setDefaultTableWidthIfNeeded(XWPFTable table) { + if (table.getWidthType() == TableWidthType.AUTO && table.getWidth() == 0) { + table.setWidth(DEFAULT_TABLE_WIDTH); + } else if (table.getWidthType() == TableWidthType.NIL) { + table.setWidthType(TableWidthType.PCT); + table.setWidth(DEFAULT_TABLE_WIDTH); + } + } } diff --git a/src/main/xsl/html2docx/baseProcessing.xsl b/src/main/xsl/html2docx/baseProcessing.xsl index 22de534..c1ba5e7 100644 --- a/src/main/xsl/html2docx/baseProcessing.xsl +++ b/src/main/xsl/html2docx/baseProcessing.xsl @@ -141,10 +141,8 @@ - - - - + + diff --git a/src/main/xsl/html2docx/get-style-name.xsl b/src/main/xsl/html2docx/get-style-name.xsl index 54be602..2fa7f1b 100644 --- a/src/main/xsl/html2docx/get-style-name.xsl +++ b/src/main/xsl/html2docx/get-style-name.xsl @@ -87,7 +87,14 @@ - + + + + + + + + diff --git a/src/test/java/org/wordinator/xml2docx/TestDocxGenerator.java b/src/test/java/org/wordinator/xml2docx/TestDocxGenerator.java index c21a579..7cecbb5 100644 --- a/src/test/java/org/wordinator/xml2docx/TestDocxGenerator.java +++ b/src/test/java/org/wordinator/xml2docx/TestDocxGenerator.java @@ -10,6 +10,7 @@ import org.apache.poi.xwpf.usermodel.BodyElementType; import org.apache.poi.xwpf.usermodel.IBodyElement; import org.apache.poi.xwpf.usermodel.IRunElement; +import org.apache.poi.xwpf.usermodel.TableWidthType; import org.apache.poi.xwpf.usermodel.XWPFAbstractNum; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFFooter; @@ -26,16 +27,22 @@ import org.apache.xmlbeans.XmlCursor; import org.apache.xmlbeans.XmlObject; import org.junit.Test; +import org.mockito.Mockito; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBody; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTDocument1; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTFldChar; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTbl; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblGrid; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblGridCol; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTText; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STFldCharType; import org.wordinator.xml2docx.generator.DocxConstants; import org.wordinator.xml2docx.generator.DocxGenerator; +import org.wordinator.xml2docx.generator.MeasurementException; +import org.wordinator.xml2docx.generator.TableColumnDefinitions; import junit.framework.TestCase; @@ -515,6 +522,98 @@ public void testNestedTableWidth() throws Exception { assertEquals(BodyElementType.TABLE, elem.getElementType()); } + @Test + public void testAddTableGridWithColumnsIfNeeded__should_create_table_grid_width_in_percentages() throws MeasurementException { + // GIVEN + XWPFTable table = Mockito.mock(XWPFTable.class); + CTTbl ctTbl = Mockito.mock(CTTbl.class); + CTTblGrid ctTblGrid = Mockito.mock(CTTblGrid.class); + CTTblGridCol col1 = Mockito.mock(CTTblGridCol.class); + CTTblGridCol col2 = Mockito.mock(CTTblGridCol.class); + Mockito.when(table.getCTTbl()).thenReturn(ctTbl); + Mockito.when(ctTbl.addNewTblGrid()).thenReturn(ctTblGrid); + Mockito.when(ctTblGrid.addNewGridCol()).thenReturn(col1, col2); + + TableColumnDefinitions colDefs = new TableColumnDefinitions(); + final int dotsPerInch = 72; + colDefs.newColumnDef().setWidth("30%", dotsPerInch); + colDefs.newColumnDef().setWidth("70%", dotsPerInch); + + // WHEN + DocxGenerator.addTableGridWithColumnsIfNeeded(table, colDefs); + + // THEN + Mockito.verify(col1).setW(BigInteger.valueOf(1500)); + Mockito.verify(col2).setW(BigInteger.valueOf(3500)); + } + + @Test + public void testAddTableGridWithColumnsIfNeeded__should_create_table_grid_auto_width() { + // GIVEN + XWPFTable table = Mockito.mock(XWPFTable.class); + CTTbl ctTbl = Mockito.mock(CTTbl.class); + CTTblGrid ctTblGrid = Mockito.mock(CTTblGrid.class); + CTTblGridCol col1 = Mockito.mock(CTTblGridCol.class); + CTTblGridCol col2 = Mockito.mock(CTTblGridCol.class); + Mockito.when(table.getCTTbl()).thenReturn(ctTbl); + Mockito.when(ctTbl.addNewTblGrid()).thenReturn(ctTblGrid); + Mockito.when(ctTblGrid.addNewGridCol()).thenReturn(col1, col2); + + TableColumnDefinitions colDefs = new TableColumnDefinitions(); + colDefs.newColumnDef().setWidthAuto(); + colDefs.newColumnDef().setWidthAuto(); + + // WHEN + DocxGenerator.addTableGridWithColumnsIfNeeded(table, colDefs); + + // THEN + Mockito.verify(col1).setW(BigInteger.ZERO); + Mockito.verify(col2).setW(BigInteger.ZERO); + } + + @Test + public void testAddTableGridWithColumnsIfNeeded__should_create_table_grid_width_in_ints() throws MeasurementException { + // GIVEN + XWPFTable table = Mockito.mock(XWPFTable.class); + CTTbl ctTbl = Mockito.mock(CTTbl.class); + CTTblGrid ctTblGrid = Mockito.mock(CTTblGrid.class); + CTTblGridCol col1 = Mockito.mock(CTTblGridCol.class); + CTTblGridCol col2 = Mockito.mock(CTTblGridCol.class); + Mockito.when(table.getCTTbl()).thenReturn(ctTbl); + Mockito.when(ctTbl.addNewTblGrid()).thenReturn(ctTblGrid); + Mockito.when(ctTblGrid.addNewGridCol()).thenReturn(col1, col2); + + TableColumnDefinitions colDefs = new TableColumnDefinitions(); + final int dotsPerInch = 72; + colDefs.newColumnDef().setWidth("30", dotsPerInch); + colDefs.newColumnDef().setWidth("70", dotsPerInch); + + // WHEN + DocxGenerator.addTableGridWithColumnsIfNeeded(table, colDefs); + + // THEN + Mockito.verify(col1).setW(BigInteger.valueOf(30)); + Mockito.verify(col2).setW(BigInteger.valueOf(70)); + } + + @Test + public void testSetDefaultTableWidthIfNeeded__should_set_100_percentages_width_for_auto_width_type() { + XWPFTable table = Mockito.mock(XWPFTable.class); + Mockito.when(table.getWidthType()).thenReturn(TableWidthType.AUTO); + Mockito.when(table.getWidth()).thenReturn(0); + DocxGenerator.setDefaultTableWidthIfNeeded(table); + Mockito.verify(table).setWidth("100%"); + } + + @Test + public void testSetDefaultTableWidthIfNeeded__should_set_100_percentages_width_and_pct_type_for_nil_width_type() { + XWPFTable table = Mockito.mock(XWPFTable.class); + Mockito.when(table.getWidthType()).thenReturn(TableWidthType.NIL); + DocxGenerator.setDefaultTableWidthIfNeeded(table); + Mockito.verify(table).setWidthType(TableWidthType.PCT); + Mockito.verify(table).setWidth("100%"); + } + // ===== INTERNAL UTILITIES private XWPFDocument convert(String infile, String outfile) throws Exception {