package com.qcomp.qcj.letters.view; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.xml.bind.JAXBElement; import javax.xml.bind.JAXBException; import org.apache.commons.lang.StringUtils; import org.apache.log4j.Logger; import org.docx4j.Docx4J; import org.docx4j.Docx4jProperties; import org.docx4j.XmlUtils; import org.docx4j.convert.in.xhtml.XHTMLImporter; import org.docx4j.convert.in.xhtml.XHTMLImporterImpl; import org.docx4j.convert.out.HTMLSettings; import org.docx4j.dml.wordprocessingDrawing.Inline; import org.docx4j.model.fields.merge.DataFieldName; import org.docx4j.model.fields.merge.MailMerger.OutputField; import org.docx4j.openpackaging.exceptions.Docx4JException; import org.docx4j.openpackaging.exceptions.InvalidFormatException; import org.docx4j.openpackaging.io.SaveToZipFile; import org.docx4j.openpackaging.packages.WordprocessingMLPackage; import org.docx4j.openpackaging.parts.WordprocessingML.BinaryPartAbstractImage; import org.docx4j.openpackaging.parts.WordprocessingML.NumberingDefinitionsPart; import org.docx4j.wml.ContentAccessor; import org.docx4j.wml.Drawing; import org.docx4j.wml.ObjectFactory; import org.docx4j.wml.P; import org.docx4j.wml.R; import org.docx4j.wml.Tbl; import org.docx4j.wml.Tc; import org.docx4j.wml.Text; import org.docx4j.wml.Tr; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Entities.EscapeMode; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.stereotype.Component; import com.qcomp.qcj.common.domain.Artifact; import com.qcomp.qcj.common.service.UserService; import com.qcomp.qcj.letters.domain.MergeLetter; import com.qcomp.qcj.letters.service.LetterService; import com.qcomp.qcj.security.service.SecurityUtils; import com.qcomp.qcj.trim.service.TrimDocumentServiceHelper; import com.qcomp.qcj.trim.service.impl.TrimDocumentDownloadException; @Component public class WordOutput { private static final Logger LOGGER = Logger.getLogger(WordOutput.class); @Autowired private LetterService letterService; @Autowired private TrimDocumentServiceHelper trimDocumentServiceHelper; @Autowired private UserService userService; private static final String SIGNATURE_TEXT = "~SIGNATURE~"; private static final List HTML_PLACEHOLDERS = Arrays.asList("ACT_PROVISION_TEXT", "PRELIMINARY_APPOINTMENTS", "GENERAL_APPOINTMENTS", "TRIBUNAL_APPOINTMENTS"); /** * * Generate a docx file and return as an #InputStream. * * @param pMergeFields * @param pMergeLetter * @param pEmbedLogo * @return * @throws Exception */ public InputStream generate(final Map pMergeFields, final MergeLetter pMergeLetter, final boolean pEmbedLogo) throws Exception { InputStream is = null; try { OutputStream os = new ByteArrayOutputStream(); boolean success = new SaveToZipFile(getDocument(pMergeFields, pMergeLetter, pEmbedLogo)).save(os); if (!success) { LOGGER.error("Unable to save as zip file"); throw new Exception("Unable to save as zip file"); } /** * Bit of to'ing and fro'ing here. Docx are zip files so need to write to an output stream, which * then pipe to an input stream so Primefaces can stream it to the output response. */ is = new ByteArrayInputStream(((ByteArrayOutputStream) os).toByteArray()); } catch (final Exception ex) { LOGGER.fatal("Unable to create Word doc", ex); throw new Exception(ex); } return is; } public String generateAsHtml(final Map pMergeFields, final MergeLetter pMergeLetter, final boolean pEmbedLogo) { WordprocessingMLPackage wordDoc = getDocument(pMergeFields, pMergeLetter, pEmbedLogo); // Strip the signature block table as not used in HTML try { removeSignatureBlock(wordDoc); } catch (Exception e1) { LOGGER.error("Error trying to remove signature block", e1); return StringUtils.EMPTY; } HTMLSettings htmlSettings = Docx4J.createHTMLSettings(); htmlSettings.setWmlPackage(wordDoc); OutputStream os = new ByteArrayOutputStream(); try { Docx4jProperties.setProperty("docx4j.Convert.Out.HTML.OutputMethodXML", true); Docx4J.toHTML(htmlSettings, os, Docx4J.FLAG_EXPORT_PREFER_NONXSL); } catch (Docx4JException e) { LOGGER.error("Failed to convert Word doc to HTML", e); } return os.toString(); } /** * Package-private for testing. * * @param pMergeFields * @param pMergeLetter * @param pEmbedLogo * @return */ protected WordprocessingMLPackage getDocument(final Map pMergeFields, final MergeLetter pMergeLetter, final boolean pEmbedLogo) { if (pEmbedLogo) { WordprocessingMLPackage templateDoc = getDocumentTemplate(pMergeLetter.getReportTemplate()); templateDoc.getMainDocumentPart().getContent().clear(); WordprocessingMLPackage mergedContent = createMergeDocument(pMergeFields, pMergeLetter); templateDoc.getMainDocumentPart().getContent().addAll(mergedContent.getMainDocumentPart().getContent()); mergeHeaderInfo(templateDoc, pMergeLetter.getDescription()); try { mergeSignature(templateDoc); } catch (Exception e) { LOGGER.error("Unable to insert signature image"); } LOGGER.debug(XmlUtils.marshaltoString(templateDoc.getMainDocumentPart().getJaxbElement(), true, true)); return templateDoc; } else { // Attach HTML to email. NB Signature not required for email. return createMergeDocument(pMergeFields, pMergeLetter); } } /** * * @return * @throws Exception */ private WordprocessingMLPackage getDocumentTemplate(final boolean pIsReport) { Resource docxTemplate = new ClassPathResource(pIsReport ? "/templates/docx/djag-report.docx" : "/templates/docx/djag-letter.docx"); WordprocessingMLPackage wordMLPackage = null; try { wordMLPackage = Docx4J.load(docxTemplate.getFile()); // Make sure a cloned template is returned rather then updating the original. WordprocessingMLPackage clonedDoc = cloneDoc(wordMLPackage); return clonedDoc; } catch (Docx4JException e1) { LOGGER.error("Error trying to load docx", e1); } catch (IOException e1) { LOGGER.error("Error trying to clone docx object", e1); } return null; } /** * Rather then using performMerge below could have gone with MailMerger.getConsolidatedResultCrude. * Unfortunately this completely stuffs up the images. Hence the reason for probably crude in the * method name :{ The docx4j author recommends below approach even if a bit more long-winded. * See eg at https://github.com/plutext/docx4j/blob/master/src/samples/docx4j/org/docx4j/samples/FieldsMailMerge.java * * @param pDoc * @param pReportHeader * @return */ private WordprocessingMLPackage mergeHeaderInfo(final WordprocessingMLPackage pDoc, final String pReportHeader) { final int idx = pReportHeader.lastIndexOf("-"); String headerDesc = null; if (idx != -1) { if (pReportHeader.split("-").length == 2) { headerDesc = pReportHeader.substring(idx + 1).trim(); } else { headerDesc = StringUtils.substringBetween(pReportHeader, "-").trim(); } } else { headerDesc = pReportHeader; } try { List> data = new ArrayList>(); Map map = new HashMap(); map.put(new DataFieldName("REPORT_HEADER"), headerDesc); data.add(map); org.docx4j.model.fields.merge.MailMerger.setMERGEFIELDInOutput(OutputField.REMOVED); for (Map thismap : data) { org.docx4j.model.fields.merge.MailMerger.performMerge(pDoc, thismap, true); } return pDoc; } catch (Docx4JException e1) { LOGGER.error("Error trying to merge header info", e1); } return null; } /* * Create Word document including the merged fields. Only want the body from this document * as the paragraphs will be merged with the docx template. */ @SuppressWarnings("unchecked") protected WordprocessingMLPackage createMergeDocument(final Map pMergeFields, final MergeLetter pMergeLetter) { LOGGER.debug("Create merge document"); WordprocessingMLPackage wordMLPackage = null; Map htmlReplaceHolders = new HashMap(); try { wordMLPackage = Docx4J.load(trimDocumentServiceHelper.downloadDocument(pMergeLetter.getTrimReference())); /* NumberingDefinitionsPart ndp = new NumberingDefinitionsPart(); wordMLPackage.getMainDocumentPart().addTargetPart(ndp); ndp.unmarshalDefaultNumbering(); */ List> data = new ArrayList>(); Map map = new HashMap(); for (Map.Entry mergeField : pMergeFields.entrySet()) { String val = null; String mergeKey = mergeField.getKey().replaceAll("\"", ""); if (HTML_PLACEHOLDERS.contains(mergeKey)) { String placeHolderKey = HTML_PLACEHOLDERS.get(HTML_PLACEHOLDERS.indexOf(mergeKey)); htmlReplaceHolders.put(placeHolderKey, mergeField.getValue()); val = placeHolderKey; } else { val = mergeField.getValue(); } map.put(new DataFieldName(mergeField.getKey()), val); } data.add(map); org.docx4j.model.fields.merge.MailMerger.setMERGEFIELDInOutput(OutputField.REMOVED); for (Map thismap : data) { org.docx4j.model.fields.merge.MailMerger.performMerge(wordMLPackage, thismap, true); } // Find and replace embedded html within view field eg ACT_PROVISION_TEXT for (Map.Entry htmlReplaceHolder : htmlReplaceHolders.entrySet()) { WordprocessingMLPackage htmlAsWordMLPackage = html2docx(convertHtmlToXhtml("
" + htmlReplaceHolder.getValue() + "
")); replaceParagraph(htmlReplaceHolder.getKey(), getAllElementFromObject(htmlAsWordMLPackage.getMainDocumentPart(), P.class), wordMLPackage, wordMLPackage.getMainDocumentPart()); } } catch (Docx4JException e) { LOGGER.error("Unable to load document. Check trim reference on merge letter or that document is in TRIM", e); } catch (TrimDocumentDownloadException e) { LOGGER.error("Unable to download document from TRIM", e); } catch (Exception e) { LOGGER.error("Error trying to insert signature", e); } return wordMLPackage; } /** * Modified version of example given at @see http://java.dzone.com/articles/create-complex-word-docx * * @param pParaPlaceholder * @param pParasToAdd * @param pTemplate * @param pAddTo */ @SuppressWarnings("unchecked") private void replaceParagraph(final String pParaPlaceholder, final List pParasToAdd, final WordprocessingMLPackage pTemplate , final ContentAccessor pAddTo) { // Get the paragraph List paragraphs = getAllElementFromObject(pTemplate.getMainDocumentPart(), P.class); P toReplace = null; for (Object p : paragraphs) { List texts = getAllElementFromObject(p, Text.class); for (Object t : texts) { Text content = (Text) t; if (content.getValue().equals(pParaPlaceholder)) { toReplace = (P) p; break; } } } if (toReplace == null) { // No matching placeholder. Get out. return; } // Add the paragraphs to the document pAddTo.getContent().addAll(paragraphs.indexOf(toReplace), pParasToAdd); // Remove the original paragraph pAddTo.getContent().remove(toReplace); } /** * Scammed from @see {@link org.docx4j.model.fields.merge.MailMerger#getConsolidatedResultCrude(WordprocessingMLPackage, List, boolean)} * * @param pInput * @return * @throws Docx4JException */ private WordprocessingMLPackage cloneDoc(final WordprocessingMLPackage pInput) throws Docx4JException { LOGGER.info("Start cloneDoc"); ByteArrayOutputStream baos = new ByteArrayOutputStream(); SaveToZipFile saver = new SaveToZipFile(pInput); saver.save(baos); byte[] template = baos.toByteArray(); WordprocessingMLPackage target = WordprocessingMLPackage.load(new ByteArrayInputStream(template)); return target; } private WordprocessingMLPackage html2docx(final String pHtml) throws Exception { WordprocessingMLPackage wordMLPackage = null; try { wordMLPackage = WordprocessingMLPackage.createPackage(); XHTMLImporter xhtmlImporter = new XHTMLImporterImpl(wordMLPackage); NumberingDefinitionsPart ndp = new NumberingDefinitionsPart(); wordMLPackage.getMainDocumentPart().addTargetPart(ndp); ndp.unmarshalDefaultNumbering(); wordMLPackage.getMainDocumentPart().getContent().addAll(xhtmlImporter.convert(pHtml, null)); LOGGER.debug(XmlUtils.marshaltoString(wordMLPackage.getMainDocumentPart().getJaxbElement(), true, true)); } catch (InvalidFormatException e) { LOGGER.error("Invalid format when setting up numbering definitions on main document", e); } catch (JAXBException | Docx4JException e) { LOGGER.error("Unable to convert XHTML to DOCX", e); } LOGGER.info("Done converting"); return wordMLPackage; } private String convertHtmlToXhtml(final String pHtml) { Document doc = Jsoup.parse(pHtml); doc.outputSettings().escapeMode(EscapeMode.xhtml); // Document clean = new Cleaner(Whitelist.none()).clean(doc); return doc.body().html(); } /** * Image stuff scammed from @see http://vixmemon.blogspot.com.au/2013/04/docx4j-replace-text-placeholders-with.html *

* Need the plain ~SIGNATURE~ text in document to be within a Table. This method then scans document looking for * all tables. If it contains ~SIGNATURE~ then is is replaced with user signature image. * * @param pDoc * @throws Exception */ @SuppressWarnings("rawtypes") private void mergeSignature(final WordprocessingMLPackage pDoc) throws Exception { LOGGER.info("Insert signature for [" + SecurityUtils.getCurrentUsername() + "]"); final Artifact signature = userService.findById(SecurityUtils.getCurrentUsername()).getArtifact(); List elements = getAllElementFromObject(pDoc.getMainDocumentPart(), Tbl.class); for (Object obj : elements) { if (obj instanceof Tbl) { Tbl table = (Tbl) obj; List rows = getAllElementFromObject(table, Tr.class); for (Object trObj : rows) { Tr tr = (Tr) trObj; List cols = getAllElementFromObject(tr, Tc.class); for (Object tcObj : cols) { Tc tc = (Tc) tcObj; List texts = getAllElementFromObject(tc, Text.class); for (Object textObj : texts) { Text text = (Text) textObj; if (text.getValue().equalsIgnoreCase(SIGNATURE_TEXT)) { P paragraphWithImage = addInlineImageToParagraph(createInlineImage(pDoc, signature.getFileContent())); tc.getContent().remove(0); tc.getContent().add(paragraphWithImage); } } } } } } } /** * TODO (RR) Appease Checkstyle and make pObj final. This could be tricky as docx4j needs to work on * current object. * * @param pObj * @param pToSearch * @return */ @SuppressWarnings({ "rawtypes", "unchecked" }) //CHECKSTYLE:OFF private List getAllElementFromObject(Object pObj, final Class pToSearch) { //CHECKSTYLE:ON List result = new ArrayList(); if (pObj instanceof JAXBElement) { pObj = ((JAXBElement) pObj).getValue(); } if (pObj.getClass().equals(pToSearch)) { result.add(pObj); } else if (pObj instanceof ContentAccessor) { List children = ((ContentAccessor) pObj).getContent(); for (Object child : children) { result.addAll(getAllElementFromObject(child, pToSearch)); } } return result; } private P addInlineImageToParagraph(final Inline pInline) { // Now add the in-line image to a paragraph ObjectFactory factory = new ObjectFactory(); P paragraph = factory.createP(); R run = factory.createR(); paragraph.getContent().add(run); Drawing drawing = factory.createDrawing(); run.getContent().add(drawing); drawing.getAnchorOrInline().add(pInline); return paragraph; } private Inline createInlineImage(final WordprocessingMLPackage pDoc, final byte[] pImage) throws Exception { BinaryPartAbstractImage imagePart = BinaryPartAbstractImage.createImagePart(pDoc, pImage); int docPrId = 1, cNvPrId = 2; // Image measurement in EMUs (English Metric Unit). // @see http://startbigthinksmall.wordpress.com/2010/01/04/points-inches-and-emus-measuring-units-in-office-open-xml/ // Signature size is 5cm width x 1.5cm height return imagePart.createImageInline("signature", "Signature", docPrId, cNvPrId, 1800000 , 540000, false); } @SuppressWarnings("rawtypes") private void removeSignatureBlock(final WordprocessingMLPackage pDoc) throws Exception { LOGGER.info("Remove signature block"); List elements = getAllElementFromObject(pDoc.getMainDocumentPart(), Tbl.class); for (Object obj : elements) { if (obj instanceof Tbl) { Tbl table = (Tbl) obj; List rows = getAllElementFromObject(table, Tr.class); for (Object trObj : rows) { Tr tr = (Tr) trObj; List cols = getAllElementFromObject(tr, Tc.class); for (Object tcObj : cols) { Tc tc = (Tc) tcObj; List texts = getAllElementFromObject(tc, Text.class); for (Object textObj : texts) { Text text = (Text) textObj; if (text.getValue().equalsIgnoreCase(SIGNATURE_TEXT)) { remove(pDoc.getMainDocumentPart().getContent(), table); return; } } } } } } } /** * A #Table gets wrapped in a JAXBElement. This means you cannot just do *

* pDoc.getMainDocumentPart().getContent().remove(table) *

*

* Instead have to loop through the list of Body elements (or whatever is passed in), * unwrap it, check if it equals the passed in Object (Table) and if so remove it. * Slightly annoying but it works. *

* * @see http://stackoverflow.com/questions/14738446/how-to-remove-all-comments-from-docx-file-with-docx4j * * @param pList * @param pElement * @return */ private boolean remove(final List pList, final Object pElement) { for (Object obj : pList) { if (XmlUtils.unwrap(obj).equals(pElement)) { return pList.remove(obj); } } return false; } }