com.hp.autonomy.frontend.reports.powerpoint.PowerPointServiceImpl.java Source code

Java tutorial

Introduction

Here is the source code for com.hp.autonomy.frontend.reports.powerpoint.PowerPointServiceImpl.java

Source

/*
 * Copyright 2017-2018 Micro Focus International plc.
 * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License.
 */

package com.hp.autonomy.frontend.reports.powerpoint;

import com.hp.autonomy.frontend.reports.powerpoint.dto.Anchor;
import com.hp.autonomy.frontend.reports.powerpoint.dto.ComposableElement;
import com.hp.autonomy.frontend.reports.powerpoint.dto.DategraphData;
import com.hp.autonomy.frontend.reports.powerpoint.dto.ListData;
import com.hp.autonomy.frontend.reports.powerpoint.dto.MapData;
import com.hp.autonomy.frontend.reports.powerpoint.dto.ReportData;
import com.hp.autonomy.frontend.reports.powerpoint.dto.SunburstData;
import com.hp.autonomy.frontend.reports.powerpoint.dto.TableData;
import com.hp.autonomy.frontend.reports.powerpoint.dto.TextData;
import com.hp.autonomy.frontend.reports.powerpoint.dto.TopicMapData;
import java.awt.*;
import java.awt.geom.Arc2D;
import java.awt.geom.Path2D;
import java.awt.geom.Rectangle2D;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.xml.namespace.QName;
import org.apache.commons.lang.ArrayUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.poi.POIXMLDocumentPart;
import org.apache.poi.POIXMLException;
import org.apache.poi.hssf.util.CellReference;
import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
import org.apache.poi.openxml4j.exceptions.NotOfficeXmlFileException;
import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.openxml4j.opc.PackagePart;
import org.apache.poi.openxml4j.opc.PackagePartName;
import org.apache.poi.openxml4j.opc.PackageRelationship;
import org.apache.poi.openxml4j.opc.PackagingURIHelper;
import org.apache.poi.openxml4j.opc.TargetMode;
import org.apache.poi.sl.usermodel.ShapeType;
import org.apache.poi.sl.usermodel.TableCell;
import org.apache.poi.sl.usermodel.TextParagraph;
import org.apache.poi.sl.usermodel.TextShape;
import org.apache.poi.sl.usermodel.VerticalAlignment;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.util.AreaReference;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.util.IOUtils;
import org.apache.poi.xslf.usermodel.XMLSlideShow;
import org.apache.poi.xslf.usermodel.XSLFAutoShape;
import org.apache.poi.xslf.usermodel.XSLFChart;
import org.apache.poi.xslf.usermodel.XSLFFreeformShape;
import org.apache.poi.xslf.usermodel.XSLFGroupShape;
import org.apache.poi.xslf.usermodel.XSLFPictureData;
import org.apache.poi.xslf.usermodel.XSLFPictureShape;
import org.apache.poi.xslf.usermodel.XSLFRelation;
import org.apache.poi.xslf.usermodel.XSLFShape;
import org.apache.poi.xslf.usermodel.XSLFSlide;
import org.apache.poi.xslf.usermodel.XSLFTable;
import org.apache.poi.xslf.usermodel.XSLFTableCell;
import org.apache.poi.xslf.usermodel.XSLFTextBox;
import org.apache.poi.xslf.usermodel.XSLFTextParagraph;
import org.apache.poi.xslf.usermodel.XSLFTextRun;
import org.apache.poi.xssf.usermodel.XSSFCell;
import org.apache.poi.xssf.usermodel.XSSFRow;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.apache.xmlbeans.XmlOptions;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTChart;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTChartSpace;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTDPt;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTDoughnutChart;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTLegend;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTLegendEntry;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTMarker;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTNumData;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTNumRef;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTNumVal;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTPieSer;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTPlotArea;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTScatterChart;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTScatterSer;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTStrData;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTStrRef;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTStrVal;
import org.openxmlformats.schemas.drawingml.x2006.chart.CTUnsignedInt;
import org.openxmlformats.schemas.drawingml.x2006.main.CTGradientFillProperties;
import org.openxmlformats.schemas.drawingml.x2006.main.CTGradientStop;
import org.openxmlformats.schemas.drawingml.x2006.main.CTGradientStopList;
import org.openxmlformats.schemas.drawingml.x2006.main.CTHyperlink;
import org.openxmlformats.schemas.drawingml.x2006.main.CTLineProperties;
import org.openxmlformats.schemas.drawingml.x2006.main.CTSRgbColor;
import org.openxmlformats.schemas.drawingml.x2006.main.CTShapeProperties;
import org.openxmlformats.schemas.drawingml.x2006.main.CTSolidColorFillProperties;
import org.openxmlformats.schemas.drawingml.x2006.main.CTTextNormalAutofit;
import org.openxmlformats.schemas.presentationml.x2006.main.CTBuildList;
import org.openxmlformats.schemas.presentationml.x2006.main.CTShape;
import org.openxmlformats.schemas.presentationml.x2006.main.CTSlide;
import org.openxmlformats.schemas.presentationml.x2006.main.CTSlideTiming;
import org.openxmlformats.schemas.presentationml.x2006.main.CTTLAnimateEffectBehavior;
import org.openxmlformats.schemas.presentationml.x2006.main.CTTLBuildParagraph;
import org.openxmlformats.schemas.presentationml.x2006.main.CTTLCommonBehaviorData;
import org.openxmlformats.schemas.presentationml.x2006.main.CTTLCommonTimeNodeData;
import org.openxmlformats.schemas.presentationml.x2006.main.CTTLSetBehavior;
import org.openxmlformats.schemas.presentationml.x2006.main.CTTLTimeCondition;
import org.openxmlformats.schemas.presentationml.x2006.main.CTTLTimeConditionList;
import org.openxmlformats.schemas.presentationml.x2006.main.CTTLTimeNodeSequence;
import org.openxmlformats.schemas.presentationml.x2006.main.CTTimeNodeList;
import org.openxmlformats.schemas.presentationml.x2006.main.STTLAnimateEffectTransition;
import org.openxmlformats.schemas.presentationml.x2006.main.STTLNextActionType;
import org.openxmlformats.schemas.presentationml.x2006.main.STTLTimeNodeFillType;
import org.openxmlformats.schemas.presentationml.x2006.main.STTLTimeNodePresetClassType;
import org.openxmlformats.schemas.presentationml.x2006.main.STTLTimeNodeRestartType;
import org.openxmlformats.schemas.presentationml.x2006.main.STTLTimeNodeType;
import org.openxmlformats.schemas.presentationml.x2006.main.STTLTriggerEvent;
import org.openxmlformats.schemas.presentationml.x2006.main.STTLTriggerRuntimeNode;
import org.openxmlformats.schemas.presentationml.x2006.main.impl.STTLTimeNodeRestartTypeImpl;

import static com.hp.autonomy.frontend.reports.powerpoint.dto.ListData.Document;
import static com.hp.autonomy.frontend.reports.powerpoint.dto.MapData.Marker;
import static org.apache.poi.POIXMLTypeLoader.DEFAULT_XML_OPTIONS;

/**
 * Default implementation of PowerPointService.
 * @see <a href="https://github.com/hpe-idol/java-powerpoint-report/" target="_blank">README.md</a> for examples and usage instructions.
 */
public class PowerPointServiceImpl implements PowerPointService {

    /** The source for the template file. */
    private final TemplateSource pptxTemplate;

    /** The source for template settings, like anchor points etc. */
    private final TemplateSettingsSource pptxSettings;

    /** The image source for converting image identifiers to image data. */
    private final ImageSource imageSource;

    /**
     * Constructor for the PowerPointServiceImpl, allowing you to provide your own template and settings.
     * @param pptxTemplate what template .pptx file to use.
     * @param pptxSettings what template settings to use.
     * @param imageSource what image source to use for converting image identifiers to image data.
     */
    public PowerPointServiceImpl(final TemplateSource pptxTemplate, final TemplateSettingsSource pptxSettings,
            final ImageSource imageSource) {
        this.pptxTemplate = pptxTemplate;
        this.pptxSettings = pptxSettings;
        this.imageSource = imageSource;
    }

    /**
     * Constructor for the PowerPointServiceImpl, allowing you to provide your own template and settings.
     * This uses the default ImageSource implementation which only allows base64-encoded data URIs as images.
     * @param pptxTemplate what template .pptx file to use.
     * @param pptxSettings what template settings to use.
     */
    public PowerPointServiceImpl(final TemplateSource pptxTemplate, final TemplateSettingsSource pptxSettings) {
        this(pptxTemplate, pptxSettings, ImageSource.DEFAULT);
    }

    /**
     * Constructor which uses the default template and default settings, which don't have any logos or margins reserved.
     * This uses the default ImageSource implementation which only allows base64-encoded data URIs as images.
     */
    public PowerPointServiceImpl() {
        this(TemplateSource.DEFAULT, TemplateSettingsSource.DEFAULT, ImageSource.DEFAULT);
    }

    @Override
    public void validateTemplate() throws TemplateLoadException {
        loadTemplate();
    }

    /**
     * Utility function to load and parse the template file.
     * @return the internal parsed template and chart information.
     * @throws TemplateLoadException if any errors occurred.
     */
    private SlideShowTemplate loadTemplate() throws TemplateLoadException {
        try (InputStream inputStream = pptxTemplate.getInputStream()) {
            return new SlideShowTemplate(inputStream);
        } catch (IOException e) {
            throw new TemplateLoadException("Error while loading template", e);
        } catch (NotOfficeXmlFileException | POIXMLException e) {
            throw new TemplateLoadException("File is not a valid Office PowerPoint file", e);
        }
    }

    /**
     * Creates a bounding rectangle in PowerPoint coordinates to draw on for a given PowerPoint slideshow, using the
     *   anchor points from the settings.
     * @param ppt the PowerPoint presentation to use.
     * @return bounding rectangle in PowerPoint coordinates.
     */
    private Rectangle2D.Double createPageAnchor(final XMLSlideShow ppt) {
        return createPageAnchor(ppt, this.pptxSettings.getSettings().getAnchor());
    }

    /**
     * Convert provided anchor points from fractional units (0-1 range) to PowerPoint coordinates.
     * @param ppt the PowerPoint presentation to use.
     * @param anchor the anchor in fractional (0-1) units.
     * @return bounding rectangle in PowerPoint coordinates.
     */
    private static Rectangle2D.Double createPageAnchor(final XMLSlideShow ppt, final Anchor anchor) {
        final Dimension pageSize = ppt.getPageSize();
        final double availW = pageSize.getWidth();
        final double availH = pageSize.getHeight();

        return new Rectangle2D.Double(availW * anchor.getX(), availH * anchor.getY(), availW * anchor.getWidth(),
                availH * anchor.getHeight());
    }

    @Override
    public XMLSlideShow topicmap(final TopicMapData topicmap) throws TemplateLoadException {
        final XMLSlideShow ppt = loadTemplate().getSlideShow();
        final XSLFSlide slide = ppt.createSlide();

        addTopicMap(slide, createPageAnchor(ppt), topicmap);

        return ppt;
    }

    /**
     * Internal implementation to add a topic map to a slide.
     * @param slide the slide to add to.
     * @param anchor bounding rectangle to draw onto, in PowerPoint coordinates.
     * @param data the topic map data.
     */
    private static void addTopicMap(final XSLFSlide slide, final Rectangle2D.Double anchor,
            final TopicMapData data) {
        for (final TopicMapData.Path reqPath : data.getPaths()) {
            final XSLFFreeformShape shape = slide.createFreeform();
            final Path2D.Double path = new Path2D.Double();

            boolean first = true;

            for (double[] point : reqPath.getPoints()) {
                final double x = point[0] * anchor.getWidth() + anchor.getMinX();
                final double y = point[1] * anchor.getHeight() + anchor.getMinY();
                if (first) {
                    path.moveTo(x, y);
                    first = false;
                } else {
                    path.lineTo(x, y);
                }
            }
            path.closePath();

            shape.setPath(path);
            shape.setStrokeStyle(2);
            shape.setLineColor(Color.GRAY);
            shape.setHorizontalCentered(true);
            shape.setVerticalAlignment(VerticalAlignment.MIDDLE);
            shape.setTextAutofit(TextShape.TextAutofit.NORMAL);

            final XSLFTextParagraph text = shape.addNewTextParagraph();
            final XSLFTextRun textRun = text.addNewTextRun();
            textRun.setText(reqPath.name);
            textRun.setFontColor(Color.WHITE);
            textRun.setBold(true);

            final CTShape cs = (CTShape) shape.getXmlObject();

            double max = 100, min = 1, scale = 100;
            final CTTextNormalAutofit autoFit = cs.getTxBody().getBodyPr().getNormAutofit();
            final double availHeight = path.getBounds2D().getHeight();
            final int RESIZE_ATTEMPTS = 7;

            for (int attempts = 0; attempts < RESIZE_ATTEMPTS; ++attempts) {
                // PowerPoint doesn't resize the text till you edit it once, which means the text initially looks too
                //   large when you first view the slide; so we binary-chop to get a sensible initial approximation.
                // OpenOffice does the text resize on load so it doesn't have this problem.
                autoFit.setFontScale(Math.max(1, (int) (scale * 1000)));

                final double textHeight = shape.getTextHeight();
                if (textHeight < availHeight) {
                    min = scale;
                    scale = 0.5 * (min + max);
                } else if (textHeight > availHeight) {
                    max = scale;
                    scale = 0.5 * (min + max);
                } else {
                    break;
                }
            }

            final int opacity = (int) (100000 * reqPath.getOpacity());
            final Color c1 = Color.decode(reqPath.getColor());
            final Color c2 = Color.decode(reqPath.getColor2());

            final CTGradientFillProperties gFill = cs.getSpPr().addNewGradFill();
            gFill.addNewLin().setAng(3300000);
            final CTGradientStopList list = gFill.addNewGsLst();

            final CTGradientStop stop1 = list.addNewGs();
            stop1.setPos(0);
            final CTSRgbColor color1 = stop1.addNewSrgbClr();
            color1.setVal(new byte[] { (byte) c1.getRed(), (byte) c1.getGreen(), (byte) c1.getBlue() });
            color1.addNewAlpha().setVal(opacity);

            final CTGradientStop stop2 = list.addNewGs();
            stop2.setPos(100000);
            final CTSRgbColor color2 = stop2.addNewSrgbClr();
            color2.setVal(new byte[] { (byte) c2.getRed(), (byte) c2.getGreen(), (byte) c2.getBlue() });
            color2.addNewAlpha().setVal(opacity);

            if (reqPath.level > 0) {
                // The nodes which aren't leaf nodes can be clicked on to hide them so you can see the nodes underneath.
                // This only works in PowerPoint; OpenOffice doesn't seem to support it. OpenOffice has its own syntax
                //   to do something similar, but we don't use it since PowerPoint treats it as corrupt.
                final String shapeId = Integer.toString(shape.getShapeId());
                final CTSlide slXML = slide.getXmlObject();

                final CTTimeNodeList childTnLst;
                final CTBuildList bldLst;
                if (!slXML.isSetTiming()) {
                    final CTSlideTiming timing = slXML.addNewTiming();
                    final CTTLCommonTimeNodeData ctn = timing.addNewTnLst().addNewPar().addNewCTn();
                    ctn.setDur("indefinite");
                    ctn.setRestart(STTLTimeNodeRestartTypeImpl.NEVER);
                    ctn.setNodeType(STTLTimeNodeType.TM_ROOT);
                    childTnLst = ctn.addNewChildTnLst();
                    bldLst = timing.addNewBldLst();
                } else {
                    final CTSlideTiming timing = slXML.getTiming();
                    childTnLst = timing.getTnLst().getParArray(0).getCTn().getChildTnLst();
                    bldLst = timing.getBldLst();
                }

                final CTTLTimeNodeSequence seq = childTnLst.addNewSeq();
                seq.setConcurrent(true);
                seq.setNextAc(STTLNextActionType.SEEK);
                final CTTLCommonTimeNodeData common = seq.addNewCTn();

                common.setRestart(STTLTimeNodeRestartType.WHEN_NOT_ACTIVE);
                common.setFill(STTLTimeNodeFillType.HOLD);
                common.setEvtFilter("cancelBubble");
                common.setNodeType(STTLTimeNodeType.INTERACTIVE_SEQ);

                final CTTLTimeConditionList condList = common.addNewStCondLst();
                final CTTLTimeCondition cond = condList.addNewCond();
                cond.setEvt(STTLTriggerEvent.ON_CLICK);
                cond.setDelay(0);
                cond.addNewTgtEl().addNewSpTgt().setSpid(shapeId);

                final CTTLTimeCondition endSync = common.addNewEndSync();
                endSync.setEvt(STTLTriggerEvent.END);
                endSync.setDelay(0);
                endSync.addNewRtn().setVal(STTLTriggerRuntimeNode.ALL);

                // These holdCtn* 'hold' transitions with zero delay might seem redundant; but they're exported in the
                //   PowerPoint XML, and the online PowerPoint Office365 viewer won't support the click animations
                //   unless they're present (e.g. from the 'Start Slide Show' button in the browser).
                final CTTLCommonTimeNodeData holdCtn1 = common.addNewChildTnLst().addNewPar().addNewCTn();

                holdCtn1.setFill(STTLTimeNodeFillType.HOLD);
                holdCtn1.addNewStCondLst().addNewCond().setDelay(0);

                final CTTLCommonTimeNodeData holdCtn2 = holdCtn1.addNewChildTnLst().addNewPar().addNewCTn();

                holdCtn2.setFill(STTLTimeNodeFillType.HOLD);
                holdCtn2.addNewStCondLst().addNewCond().setDelay(0);

                final CTTLCommonTimeNodeData clickCtn = holdCtn2.addNewChildTnLst().addNewPar().addNewCTn();

                clickCtn.setPresetID(10);
                clickCtn.setPresetClass(STTLTimeNodePresetClassType.EXIT);
                clickCtn.setPresetSubtype(0);
                clickCtn.setFill(STTLTimeNodeFillType.HOLD);
                clickCtn.setGrpId(0);
                clickCtn.setNodeType(STTLTimeNodeType.CLICK_EFFECT);

                clickCtn.addNewStCondLst().addNewCond().setDelay(0);

                final CTTimeNodeList clickChildTnList = clickCtn.addNewChildTnLst();

                final CTTLAnimateEffectBehavior animEffect = clickChildTnList.addNewAnimEffect();
                animEffect.setTransition(STTLAnimateEffectTransition.OUT);
                animEffect.setFilter("fade");
                final CTTLCommonBehaviorData cBhvr = animEffect.addNewCBhvr();
                final CTTLCommonTimeNodeData bhvrCtn = cBhvr.addNewCTn();

                bhvrCtn.setDur(500);
                cBhvr.addNewTgtEl().addNewSpTgt().setSpid(shapeId);

                final CTTLSetBehavior clickSet = clickChildTnList.addNewSet();
                final CTTLCommonBehaviorData clickSetBhvr = clickSet.addNewCBhvr();
                final CTTLCommonTimeNodeData hideCtn = clickSetBhvr.addNewCTn();

                hideCtn.setDur(1);
                hideCtn.setFill(STTLTimeNodeFillType.HOLD);
                hideCtn.addNewStCondLst().addNewCond().setDelay(499);

                clickSetBhvr.addNewTgtEl().addNewSpTgt().setSpid(shapeId);
                clickSetBhvr.addNewAttrNameLst().addAttrName("style.visibility");
                clickSet.addNewTo().addNewStrVal().setVal("hidden");

                final CTTLBuildParagraph bldP = bldLst.addNewBldP();
                bldP.setSpid(shapeId);
                bldP.setGrpId(0);
                bldP.setAnimBg(true);
            }
        }
    }

    @Override
    public XMLSlideShow sunburst(final SunburstData sunburst) throws TemplateLoadException {
        if (!sunburst.validateInput()) {
            throw new IllegalArgumentException(
                    "Number of values should match the number of categories, and color / stroke color must either be null or nonempty");
        }

        final SlideShowTemplate template = loadTemplate();
        final XMLSlideShow ppt = template.getSlideShow();
        final XSLFSlide slide = ppt.createSlide();

        final int shapeId = 1;

        addSunburst(template, slide, null, sunburst, shapeId, "relId" + shapeId);

        return ppt;
    }

    /**
     * Internal implementation to add a sunburst chart (actually a doughnut chart) to a slide, based on a template.
     * @param template the parsed template information.
     * @param slide the slide to add to.
     * @param anchor optional bounding rectangle to draw onto, in PowerPoint coordinates.
     *               If null, we'll use the bounds from the original template chart.
     * @param data the sunburst data.
     * @param shapeId the slide shape ID, should be unique within the slide.
     * @param relId the relation ID to the chart data.
     * @throws TemplateLoadException if we can't create the sunburst; most likely due to an invalid template.
     */
    private static void addSunburst(final SlideShowTemplate template, final XSLFSlide slide,
            final Rectangle2D.Double anchor, final SunburstData data, final int shapeId, final String relId)
            throws TemplateLoadException {
        final String[] categories = data.getCategories();
        final double[] values = data.getValues();
        final String title = data.getTitle();

        slide.getXmlObject().getCSld().getSpTree().addNewGraphicFrame()
                .set(template.getDoughnutChartShapeXML(relId, shapeId, "chart" + shapeId, anchor));

        final XSSFWorkbook workbook = new XSSFWorkbook();
        final XSSFSheet sheet = workbook.createSheet();

        final XSLFChart baseChart = template.getDoughnutChart();

        final CTChartSpace chartSpace = (CTChartSpace) baseChart.getCTChartSpace().copy();
        final CTChart ctChart = chartSpace.getChart();
        final CTPlotArea plotArea = ctChart.getPlotArea();

        if (StringUtils.isEmpty(title)) {
            if (ctChart.getAutoTitleDeleted() != null) {
                ctChart.getAutoTitleDeleted().setVal(true);
            }

            ctChart.unsetTitle();
        }

        final CTDoughnutChart donutChart = plotArea.getDoughnutChartArray(0);

        final CTPieSer series = donutChart.getSerArray(0);

        final CTStrRef strRef = series.getTx().getStrRef();
        strRef.getStrCache().getPtArray(0).setV(title);
        sheet.createRow(0).createCell(1).setCellValue(title);
        strRef.setF(new CellReference(sheet.getSheetName(), 0, 1, true, true).formatAsString());

        final CTStrRef categoryRef = series.getCat().getStrRef();
        final CTStrData categoryData = categoryRef.getStrCache();
        final CTNumRef numRef = series.getVal().getNumRef();
        final CTNumData numericData = numRef.getNumCache();

        final String[] fillColors = data.getColors();
        final String[] strokeColors = data.getStrokeColors();
        final boolean overrideFill = ArrayUtils.isNotEmpty(fillColors);
        final boolean overrideStroke = ArrayUtils.isNotEmpty(strokeColors);
        final boolean overrideColors = overrideFill || overrideStroke;
        final List<CTDPt> dPtList = series.getDPtList();
        final CTDPt templatePt = (CTDPt) dPtList.get(0).copy();
        if (overrideColors) {
            dPtList.clear();

            final CTShapeProperties spPr = templatePt.getSpPr();
            final CTLineProperties ln = spPr.getLn();

            // We need to unset any styles on the existing template
            if (overrideFill) {
                unsetSpPrFills(spPr);
            }

            if (overrideStroke) {
                unsetLineFills(ln);
            }
        }

        categoryData.setPtArray(null);
        numericData.setPtArray(null);

        CTLegend legend = null;
        final int[] showInLegend = data.getShowInLegend();
        int nextLegendToShow = 0, nextLegendToShowIdx = -1;
        if (showInLegend != null) {
            // We need to write legendEntry elements to hide the legend for chart series we don't want.
            // Note this only works in PowerPoint, and not OpenOffice.
            legend = ctChart.isSetLegend() ? ctChart.getLegend() : ctChart.addNewLegend();
            Arrays.sort(showInLegend);
            nextLegendToShow = showInLegend[++nextLegendToShowIdx];
        }

        for (int idx = 0; idx < values.length; ++idx) {
            final CTStrVal categoryPoint = categoryData.addNewPt();
            categoryPoint.setIdx(idx);
            categoryPoint.setV(categories[idx]);

            final CTNumVal numericPoint = numericData.addNewPt();
            numericPoint.setIdx(idx);
            numericPoint.setV(Double.toString(values[idx]));

            if (overrideColors) {
                final CTDPt copiedPt = (CTDPt) templatePt.copy();
                copiedPt.getIdx().setVal(idx);

                if (overrideFill) {
                    final Color color = Color.decode(fillColors[idx % fillColors.length]);
                    final CTSolidColorFillProperties fillClr = copiedPt.getSpPr().addNewSolidFill();
                    fillClr.addNewSrgbClr().setVal(
                            new byte[] { (byte) color.getRed(), (byte) color.getGreen(), (byte) color.getBlue() });
                }

                if (overrideStroke) {
                    final Color strokeColor = Color.decode(strokeColors[idx % strokeColors.length]);
                    final CTSolidColorFillProperties strokeClr = copiedPt.getSpPr().getLn().addNewSolidFill();
                    strokeClr.addNewSrgbClr().setVal(new byte[] { (byte) strokeColor.getRed(),
                            (byte) strokeColor.getGreen(), (byte) strokeColor.getBlue() });
                }

                dPtList.add(copiedPt);
            }

            if (legend != null) {
                // We're hiding some legend elements. Should we show this index?
                if (nextLegendToShow == idx) {
                    // We show this index, find the next one to show.
                    ++nextLegendToShowIdx;
                    if (nextLegendToShowIdx < showInLegend.length) {
                        nextLegendToShow = showInLegend[nextLegendToShowIdx];
                    }
                } else {
                    // We hide this index. If there's already a matching legend entry in the XML, update it,
                    //   otherwise we create a new legend entry.
                    boolean found = false;
                    for (int ii = 0, max = legend.sizeOfLegendEntryArray(); ii < max; ++ii) {
                        final CTLegendEntry legendEntry = legend.getLegendEntryArray(ii);
                        final CTUnsignedInt idxLegend = legendEntry.getIdx();
                        if (idxLegend != null && idxLegend.getVal() == idx) {
                            found = true;
                            if (legendEntry.isSetDelete()) {
                                legendEntry.getDelete().setVal(true);
                            } else {
                                legendEntry.addNewDelete().setVal(true);
                            }
                        }
                    }

                    if (!found) {
                        final CTLegendEntry idxLegend = legend.addNewLegendEntry();
                        idxLegend.addNewIdx().setVal(idx);
                        idxLegend.addNewDelete().setVal(true);
                    }
                }
            }

            XSSFRow row = sheet.createRow(idx + 1);
            row.createCell(0).setCellValue(categories[idx]);
            row.createCell(1).setCellValue(values[idx]);
        }
        categoryData.getPtCount().setVal(categories.length);
        numericData.getPtCount().setVal(values.length);

        categoryRef.setF(new CellRangeAddress(1, values.length, 0, 0).formatAsString(sheet.getSheetName(), true));
        numRef.setF(new CellRangeAddress(1, values.length, 1, 1).formatAsString(sheet.getSheetName(), true));

        try {
            writeChart(template.getSlideShow(), slide, baseChart, chartSpace, workbook, relId);
        } catch (IOException | InvalidFormatException e) {
            throw new TemplateLoadException("Error writing chart in loaded template", e);
        }
    }

    private static void unsetLineFills(final CTLineProperties ln) {
        if (ln.isSetSolidFill()) {
            ln.unsetSolidFill();
        }
        if (ln.isSetPattFill()) {
            ln.unsetPattFill();
        }
        if (ln.isSetGradFill()) {
            ln.unsetGradFill();
        }
        if (ln.isSetNoFill()) {
            ln.unsetNoFill();
        }
    }

    private static void unsetSpPrFills(final CTShapeProperties spPr) {
        if (spPr.isSetBlipFill()) {
            spPr.unsetBlipFill();
        }
        if (spPr.isSetGradFill()) {
            spPr.unsetGradFill();
        }
        if (spPr.isSetGrpFill()) {
            spPr.unsetGrpFill();
        }
        if (spPr.isSetNoFill()) {
            spPr.unsetNoFill();
        }
        if (spPr.isSetSolidFill()) {
            spPr.unsetSolidFill();
        }
        if (spPr.isSetPattFill()) {
            spPr.unsetPattFill();
        }
    }

    @Override
    public XMLSlideShow table(final TableData tableData, final String title) throws TemplateLoadException {
        final int rows = tableData.getRows(), cols = tableData.getCols();
        final String[] data = tableData.getCells();

        final XMLSlideShow ppt = loadTemplate().getSlideShow();
        final XSLFSlide sl = ppt.createSlide();

        final Rectangle2D.Double pageAnchor = createPageAnchor(ppt);

        final double textHeight;

        if (StringUtils.isNotBlank(title)) {
            textHeight = 0.1 * pageAnchor.getHeight();
            final XSLFTextBox textBox = sl.createTextBox();
            textBox.setText(title);
            textBox.setHorizontalCentered(true);
            textBox.setTextAutofit(TextShape.TextAutofit.SHAPE);
            textBox.setAnchor(startingSpace(pageAnchor, textHeight));
        } else {
            textHeight = 0;
        }

        addTable(sl, remainingSpace(pageAnchor, textHeight), rows, cols, data, false);

        return ppt;
    }

    /**
     * Utility function to compute bounds for a shape in PowerPoint coordinates, given the bounds of the original
     *   free space available and a measure of the shape's height.
     * @param pageAnchor the available bounds in PowerPoint coordinates.
     * @param space the height of the shape.
     * @return the bounds for the shape.
     */
    private static Rectangle2D.Double startingSpace(final Rectangle2D.Double pageAnchor, final double space) {
        return new Rectangle2D.Double(pageAnchor.getMinX(), pageAnchor.getMinY(), pageAnchor.getWidth(), space);
    }

    /**
     * Utility function to compute the bounds for the remaining space, given the bounds of the original
     *   free space available and the amount of vertical height already taken.
     * @param pageAnchor the available bounds in PowerPoint coordinates.
     * @param usedSpace how much height is already taken.
     * @return the bounds for the remaining space under the used space.
     */
    private static Rectangle2D.Double remainingSpace(final Rectangle2D.Double pageAnchor, final double usedSpace) {
        return new Rectangle2D.Double(pageAnchor.getMinX(), pageAnchor.getMinY() + usedSpace, pageAnchor.getWidth(),
                Math.max(1, pageAnchor.getHeight() - usedSpace));
    }

    /**
     * Internal implementation to add a table to a slide.
     * @param slide the slide to add to.
     * @param anchor bounding rectangle to draw onto, in PowerPoint coordinates.
     * @param rows number of rows.
     * @param cols number of columns.
     * @param data the data for each cell, laid out row-by-row.
     * @param crop whether we should try and crop the table to the bounding rectangle by removing extra rows.
     *               This doesn't guarantee an exact match, since the font metrics may not exactly match.
     */
    private static void addTable(final XSLFSlide slide, final Rectangle2D.Double anchor, final int rows,
            final int cols, final String[] data, final boolean crop) {
        final XSLFTable table = slide.createTable(rows, cols);

        int idx = 0;

        double availWidth = anchor.getWidth();
        double tableW = 0;

        if (cols == 2) {
            // In the most common situation, there's a count column which should be relatively smaller.
            // Make it take 10%, or 70 pixels, whichever is bigger, unless that's more than 50% of the overall space.
            final double minCountWidth = 70;
            final double countColWidth = Math.min(0.5 * availWidth, Math.max(minCountWidth, availWidth * 0.1));
            table.setColumnWidth(0, availWidth - countColWidth);
            table.setColumnWidth(1, countColWidth);
            tableW += table.getColumnWidth(0);
            tableW += table.getColumnWidth(1);
        } else {
            for (int col = 0; col < cols; ++col) {
                table.setColumnWidth(col, availWidth / cols);
                tableW += table.getColumnWidth(col);
            }
        }

        // PowerPoint won't auto-shrink the table for you; and the POI API can't calculate the heights, so we just
        //   have to assume the total row heights add up to be the table height.
        double tableH = 0;

        for (int row = 0; row < rows; ++row) {
            for (int col = 0; col < cols; ++col) {
                final XSLFTableCell cell = table.getCell(row, col);
                cell.setText(data[idx++]);

                for (final TableCell.BorderEdge edge : TableCell.BorderEdge.values()) {
                    cell.setBorderColor(edge, Color.BLACK);
                }
            }

            final double nextH = tableH + table.getRowHeight(row);

            if (crop && nextH > anchor.getHeight() && row < rows - 1) {
                // If it doesn't fit, merge all the final row cells together and label them with an ellipsis.
                table.mergeCells(row, row, 0, cols - 1);
                table.getCell(row, 0).setText("\u2026");
                break;
            } else {
                tableH = nextH;
            }
        }

        final double width = Math.min(tableW, availWidth);

        table.setAnchor(new Rectangle2D.Double(anchor.getMinX() + 0.5 * (availWidth - width), anchor.getMinY(),
                width, Math.min(tableH, anchor.getHeight())));
    }

    @Override
    public XMLSlideShow map(final MapData map, final String title) throws TemplateLoadException {
        final String image = map.getImage();

        final XMLSlideShow ppt = loadTemplate().getSlideShow();
        final XSLFSlide sl = ppt.createSlide();

        final Rectangle2D.Double pageAnchor = createPageAnchor(ppt);
        final double textHeight;

        if (StringUtils.isNotBlank(title)) {
            textHeight = 0.1 * pageAnchor.getHeight();

            final XSLFTextBox textBox = sl.createTextBox();
            textBox.clearText();
            final XSLFTextParagraph paragraph = textBox.addNewTextParagraph();
            paragraph.setTextAlign(TextParagraph.TextAlign.CENTER);
            paragraph.addNewTextRun().setText(title);
            textBox.setHorizontalCentered(true);
            textBox.setTextAutofit(TextShape.TextAutofit.SHAPE);
            textBox.setAnchor(startingSpace(pageAnchor, textHeight));
        } else {
            textHeight = 0;
        }

        final XSLFPictureData picture = addPictureData(imageSource, ppt, image);
        addMap(sl, remainingSpace(pageAnchor, textHeight), picture, map.getMarkers(), map.getPolygons());

        return ppt;
    }

    /**
     * Utility function to add image data to a PowerPoint presentation.
     * @param imageSource the image source.
     * @param ppt the presentation to add to.
     * @param imageId the image identifier, typically a URI.
     * @return the picture data.
     */
    private static XSLFPictureData addPictureData(final ImageSource imageSource, final XMLSlideShow ppt,
            final String imageId) {
        final ImageData imageData = imageSource.getImageData(imageId);
        return ppt.addPicture(imageData.getData(), imageData.getType());
    }

    /**
     * Internal implementation to add an image (a world map, though other image data is also fine) to a slide.
     *   Preserves the original image's aspect ratio, leaving blank space below and to the sides of the image.
     * @param slide the slide to add to.
     * @param anchor bounding rectangle to draw onto, in PowerPoint coordinates.
     * @param picture the picture data.
     * @param markers an array of markers to draw over the map.
     * @param polygons
     * @return the picture shape object added to the slide.
     */
    private static XSLFPictureShape addMap(final XSLFSlide slide, final Rectangle2D.Double anchor,
            final XSLFPictureData picture, final Marker[] markers, final MapData.Polygon[] polygons) {
        double tgtW = anchor.getWidth(), tgtH = anchor.getHeight();

        final Dimension size = picture.getImageDimension();
        final double ratio = size.getWidth() / size.getHeight();

        if (ratio > tgtW / tgtH) {
            // source image is wider than target, clip fixed width variable height
            tgtH = tgtW / ratio;
        } else {
            tgtW = tgtH * ratio;
        }

        final XSLFPictureShape canvas = slide.createPicture(picture);
        // Vertically align top, horizontally-align center
        final double offsetX = anchor.getMinX() + 0.5 * (anchor.getWidth() - tgtW), offsetY = anchor.getMinY();
        canvas.setAnchor(new Rectangle2D.Double(offsetX, offsetY, tgtW, tgtH));

        if (polygons != null) {
            for (MapData.Polygon polygon : polygons) {
                final Color color = Color.decode(polygon.getColor());
                final double[][] shapes = polygon.getPoints();
                // The ESRI spec version 1.2.1 from http://www.opengeospatial.org/standards/sfa has section 6.1.11.1,
                //    which defines polygons as follows:
                /// A Polygon is a planar Surface defined by 1 exterior boundary and 0 or more interior boundaries.
                //    Each interior boundary defines a hole in the Polygon. A Triangle is a polygon with 3 distinct,
                //    non-collinear vertices and no interior boundary.
                /// The exterior boundary LinearRing defines the top? of the surface which is the side of the surface
                //    from which the exterior boundary appears to traverse the boundary in a counter clockwise direction.
                //    The interior LinearRings will have the opposite orientation, and appear as clockwise
                //    when viewed from the top?
                // so it's even-odd winding (whereas the Path2D default is non-zero-winding).
                final Path2D.Double path = new Path2D.Double(Path2D.WIND_EVEN_ODD);

                for (final double[] points : shapes) {
                    for (int ii = 0; ii < points.length; ii += 2) {
                        final double x1 = offsetX + points[ii] * tgtW;
                        final double y1 = offsetY + points[ii + 1] * tgtH;
                        if (ii == 0) {
                            path.moveTo(x1, y1);
                        } else {
                            path.lineTo(x1, y1);
                        }
                    }
                    path.closePath();
                }

                final XSLFFreeformShape freeform = slide.createFreeform();
                freeform.setPath(path);
                freeform.setStrokeStyle(0.5);
                // There's a 0.5 alpha transparency on the stroke, and a 0.2 alpha transparency on the polygon fill.
                freeform.setLineColor(transparentColor(color, 128));
                freeform.setFillColor(transparentColor(color, 51));

                if (StringUtils.isNotEmpty(polygon.getText())) {
                    final PackageRelationship rel = freeform.getSheet().getPackagePart().addRelationship(
                            slide.getPackagePart().getPartName(), TargetMode.INTERNAL,
                            XSLFRelation.SLIDE.getRelation());
                    // We create a hyperlink which links back to this slide; so we get hover-over-detail-text on the polygon
                    final CTHyperlink link = ((CTShape) freeform.getXmlObject()).getNvSpPr().getCNvPr()
                            .addNewHlinkClick();
                    link.setTooltip(polygon.getText());
                    link.setId(rel.getId());
                    link.setAction("ppaction://hlinksldjump");
                }
            }
        }

        for (Marker marker : markers) {
            final Color color = Color.decode(marker.getColor());
            final double centerX = offsetX + marker.getX() * tgtW;
            final double centerY = offsetY + marker.getY() * tgtH;

            if (marker.isCluster()) {
                final XSLFGroupShape group = slide.createGroup();
                double halfMark = 10;
                double mark = halfMark * 2;
                double innerHalfMark = 7;
                double innerMark = innerHalfMark * 2;
                // align these so the middle is the latlng position
                final Rectangle2D.Double groupAnchor = new Rectangle2D.Double(centerX - halfMark,
                        centerY - halfMark, mark, mark);

                group.setAnchor(groupAnchor);
                group.setInteriorAnchor(groupAnchor);

                final XSLFAutoShape shape = group.createAutoShape();
                shape.setShapeType(ShapeType.ELLIPSE);
                final boolean fade = marker.isFade();
                // There's a 0.3 alpha transparency (255 * 0.3 is 76) when a marker is faded out
                final int FADE_ALPHA = 76;
                shape.setFillColor(transparentColor(color, fade ? 47 : 154));
                shape.setAnchor(groupAnchor);

                final XSLFAutoShape inner = group.createAutoShape();
                inner.setFillColor(fade ? transparentColor(color, FADE_ALPHA) : color);
                inner.setLineWidth(0.1);
                inner.setLineColor(new Color((int) (color.getRed() * 0.9), (int) (color.getGreen() * 0.9),
                        (int) (color.getBlue() * 0.9), fade ? FADE_ALPHA : 255));
                inner.setShapeType(ShapeType.ELLIPSE);
                inner.setHorizontalCentered(true);
                inner.setWordWrap(false);
                inner.setVerticalAlignment(VerticalAlignment.MIDDLE);
                inner.clearText();
                final XSLFTextParagraph para = inner.addNewTextParagraph();
                para.setTextAlign(TextParagraph.TextAlign.CENTER);
                final XSLFTextRun text = para.addNewTextRun();
                text.setFontSize(6.0);
                final Color fontColor = Color.decode(StringUtils.defaultString(marker.getFontColor(), "#000000"));
                text.setFontColor(fade ? transparentColor(fontColor, FADE_ALPHA) : fontColor);
                text.setText(marker.getText());
                inner.setAnchor(new Rectangle2D.Double(centerX - innerHalfMark, centerY - innerHalfMark, innerMark,
                        innerMark));
            } else {
                final XSLFGroupShape group = slide.createGroup();

                final XSLFFreeformShape shape = group.createFreeform();
                shape.setHorizontalCentered(true);
                shape.setWordWrap(false);

                shape.setVerticalAlignment(VerticalAlignment.BOTTOM);
                shape.setLineWidth(0.5);
                shape.setLineColor(color.darker());
                shape.setFillColor(transparentColor(color, 210));

                final double halfMark = 8, mark = halfMark * 2, extension = 0.85,
                        markerHeight = (0.5 + extension) * mark, angle = Math.asin(0.5 / extension) * 180 / Math.PI;

                // Set group position
                group.setAnchor(
                        new Rectangle2D.Double(centerX - halfMark, centerY - markerHeight, mark, markerHeight));
                group.setInteriorAnchor(new Rectangle2D.Double(0, 0, mark, markerHeight));

                // Draw a semicircle and a triangle to represent the marker, pointing at the precise x,y location
                final Path2D.Double path = new Path2D.Double();
                path.moveTo(halfMark, markerHeight);
                path.append(new Arc2D.Double(0, 0, mark, mark, -angle, 180 + angle + angle, Arc2D.OPEN), true);
                path.lineTo(halfMark, markerHeight);
                shape.setPath(path);
                shape.setAnchor(new Rectangle2D.Double(0, 0, mark, markerHeight));

                final XSLFAutoShape disc = group.createAutoShape();
                disc.setShapeType(ShapeType.DONUT);
                final double discRadius = 0.25 * mark;
                final double discDiameter = 2 * discRadius;
                disc.setAnchor(new Rectangle2D.Double(halfMark - discRadius, halfMark - discRadius, discDiameter,
                        discDiameter));
                disc.setFillColor(Color.WHITE);
                disc.setLineColor(Color.WHITE);

                if (StringUtils.isNotEmpty(marker.getText())) {
                    final PackageRelationship rel = shape.getSheet().getPackagePart().addRelationship(
                            slide.getPackagePart().getPartName(), TargetMode.INTERNAL,
                            XSLFRelation.SLIDE.getRelation());
                    // We create a hyperlink which links back to this slide; so we get hover-over-detail-text on the marker
                    // Annoyingly, you can't put a link on the group, just on the individual shapes.
                    for (XSLFShape clickable : group.getShapes()) {
                        final CTHyperlink link = ((CTShape) clickable.getXmlObject()).getNvSpPr().getCNvPr()
                                .addNewHlinkClick();
                        link.setTooltip(marker.getText());
                        link.setId(rel.getId());
                        link.setAction("ppaction://hlinksldjump");
                    }
                }
            }
        }

        return canvas;
    }

    /**
     * Utility function to create a semi-transparent variant of a given colour.
     * @param color the original colour.
     * @param a alpha, as a value from 0 (transparent) to 255 (opaque).
     * @return colour with transparency set.
     */
    private static Color transparentColor(final Color color, final int a) {
        return new Color(color.getRed(), color.getGreen(), color.getBlue(), a);
    }

    @Override
    public XMLSlideShow list(final ListData documentList, final String results, final String sortBy)
            throws TemplateLoadException {
        final XMLSlideShow ppt = loadTemplate().getSlideShow();

        addList(imageSource, ppt, null, createPageAnchor(ppt), true, documentList, results, sortBy);

        return ppt;
    }

    /**
     * Internal implementation to add a list of documents to a presentation; either as a single slide or a series of slides.
     * @param imageSource the image source to convert images to data.
     * @param ppt the presentation to add to.
     * @param sl the slide to add to (can be null if pagination is enabled).
     * @param anchor bounding rectangle to draw onto, in PowerPoint coordinates.
     * @param paginate whether to render results as multiple slides if they don't fit on one slide.
     * @param data the documents to render.
     * @param results optional string to render into the top-left corner of the available space.
     *                  Will appear on each page if pagination is enabled.
     * @param sortBy optional string to render into the top-right corner of the available space.
     *                  Will appear on each page if pagination is enabled.
     */
    private static void addList(final ImageSource imageSource, final XMLSlideShow ppt, XSLFSlide sl,
            final Rectangle2D.Double anchor, final boolean paginate, final ListData data, final String results,
            final String sortBy) {
        final double
        // How much space to leave at the left and right edge of the slide
        xMargin = 20,
                // How much space to leave at the top
                yMargin = 5,
                // Size of the icon
                iconWidth = 20, iconHeight = 24,
                // Find's thumbnail height is 97px by 55px, hardcoded in the CSS in .document-thumbnail
                thumbScale = 0.8, thumbW = 97 * thumbScale, thumbH = 55 * thumbScale,
                // Margin around the thumbnail
                thumbMargin = 4.,
                // Space between list items
                listItemMargin = 5.;

        final Pattern highlightPattern = Pattern
                .compile("<HavenSearch-QueryText-Placeholder>(.*?)</HavenSearch-QueryText-Placeholder>");

        double yCursor = yMargin + anchor.getMinY(), xCursor = xMargin + anchor.getMinX();

        int docsOnPage = 0;

        final Document[] docs = data.getDocs();
        for (int docIdx = 0; docIdx < docs.length; ++docIdx) {
            final Document doc = docs[docIdx];

            if (sl == null) {
                sl = ppt.createSlide();
                yCursor = yMargin + anchor.getMinY();
                xCursor = xMargin + anchor.getMinX();
                docsOnPage = 0;

                double yStep = 0;

                if (StringUtils.isNotBlank(results)) {
                    final XSLFTextBox textBox = sl.createTextBox();
                    textBox.clearText();
                    final Rectangle2D.Double textBounds = new Rectangle2D.Double(xCursor, yCursor,
                            Math.max(0, anchor.getMaxX() - xCursor - xMargin), 20);
                    textBox.setAnchor(textBounds);

                    addTextRun(textBox.addNewTextParagraph(), results, 12., Color.LIGHT_GRAY);

                    yStep = textBox.getTextHeight();
                }

                if (StringUtils.isNotBlank(sortBy)) {
                    final XSLFTextBox sortByEl = sl.createTextBox();
                    sortByEl.clearText();
                    final XSLFTextParagraph sortByText = sortByEl.addNewTextParagraph();
                    sortByText.setTextAlign(TextParagraph.TextAlign.RIGHT);

                    addTextRun(sortByText, sortBy, 12., Color.LIGHT_GRAY);

                    sortByEl.setAnchor(new Rectangle2D.Double(xCursor, yCursor,
                            Math.max(0, anchor.getMaxX() - xCursor - xMargin), 20));

                    yStep = Math.max(sortByEl.getTextHeight(), yStep);
                }

                if (yStep > 0) {
                    yCursor += listItemMargin + yStep;
                }
            }

            XSLFAutoShape icon = null;
            if (data.isDrawIcons()) {
                icon = sl.createAutoShape();
                icon.setShapeType(ShapeType.SNIP_1_RECT);
                icon.setAnchor(new Rectangle2D.Double(xCursor, yCursor + listItemMargin, iconWidth, iconHeight));
                icon.setLineColor(Color.decode("#888888"));
                icon.setLineWidth(2.0);

                xCursor += iconWidth;
            }

            final XSLFTextBox listEl = sl.createTextBox();
            listEl.clearText();
            listEl.setAnchor(new Rectangle2D.Double(xCursor, yCursor,
                    Math.max(0, anchor.getMaxX() - xCursor - xMargin), Math.max(0, anchor.getMaxY() - yCursor)));

            final XSLFTextParagraph titlePara = listEl.addNewTextParagraph();
            addTextRun(titlePara, doc.getTitle(), data.getTitleFontSize(), Color.BLACK).setBold(true);

            if (StringUtils.isNotBlank(doc.getDate())) {
                final XSLFTextParagraph datePara = listEl.addNewTextParagraph();
                datePara.setLeftMargin(5.);
                addTextRun(datePara, doc.getDate(), data.getDateFontSize(), Color.GRAY).setItalic(true);
            }

            if (StringUtils.isNotBlank(doc.getRef())) {
                addTextRun(listEl.addNewTextParagraph(), doc.getRef(), data.getRefFontSize(), Color.GRAY);
            }

            final double thumbnailOffset = listEl.getTextHeight();

            final XSLFTextParagraph contentPara = listEl.addNewTextParagraph();

            Rectangle2D.Double pictureAnchor = null;
            XSLFPictureData pictureData = null;

            if (StringUtils.isNotBlank(doc.getThumbnail())) {
                try {
                    // Picture reuse is automatic
                    pictureData = addPictureData(imageSource, ppt, doc.getThumbnail());
                    // We reserve space for the picture, but we don't actually add it yet.
                    // The reason is we may have to remove it later if it doesn't fit; but due to a quirk of OpenOffice,
                    //   deleting the picture shape removes the pictureData as well; which is a problem since the
                    //   pictureData can be shared between multiple pictures.
                    pictureAnchor = new Rectangle2D.Double(xCursor, yCursor + thumbnailOffset + thumbMargin, thumbW,
                            thumbH);

                    // If there is enough horizontal space, put the text summary to the right of the thumbnail image,
                    //    otherwise put it under the thumbnail,
                    if (listEl.getAnchor().getWidth() > 2.5 * thumbW) {
                        contentPara.setLeftMargin(thumbW);
                    } else {
                        contentPara.addLineBreak().setFontSize(thumbH);
                    }

                } catch (RuntimeException e) {
                    // if there's any errors, we'll just ignore the image
                }
            }

            final String rawSummary = doc.getSummary();
            if (StringUtils.isNotBlank(rawSummary)) {
                // HTML treats newlines and multiple whitespace as a single whitespace.
                final String summary = rawSummary.replaceAll("\\s+", " ");
                final Matcher matcher = highlightPattern.matcher(summary);
                int idx = 0;

                while (matcher.find()) {
                    final int start = matcher.start();

                    if (idx < start) {
                        addTextRun(contentPara, summary.substring(idx, start), data.getSummaryFontSize(),
                                Color.DARK_GRAY);
                    }

                    addTextRun(contentPara, matcher.group(1), data.getSummaryFontSize(), Color.DARK_GRAY)
                            .setBold(true);
                    idx = matcher.end();
                }

                if (idx < summary.length()) {
                    addTextRun(contentPara, summary.substring(idx), data.getSummaryFontSize(), Color.DARK_GRAY);
                }
            }

            double elHeight = Math.max(listEl.getTextHeight(), iconHeight);
            if (pictureAnchor != null) {
                elHeight = Math.max(elHeight, pictureAnchor.getMaxY() - yCursor);
            }

            yCursor += elHeight;
            xCursor = xMargin + anchor.getMinX();

            docsOnPage++;

            if (yCursor > anchor.getMaxY()) {
                if (docsOnPage > 1) {
                    // If we drew more than one list element on this page; and we exceeded the available space,
                    //   delete the last element's shapes and redraw it on the next page.
                    // We don't have to remove the picture since we never added it.
                    sl.removeShape(listEl);
                    if (icon != null) {
                        sl.removeShape(icon);
                    }

                    --docIdx;
                } else if (pictureAnchor != null) {
                    // We've confirmed we need the picture, add it.
                    sl.createPicture(pictureData).setAnchor(pictureAnchor);
                }

                sl = null;

                if (!paginate) {
                    break;
                }
            } else {
                yCursor += listItemMargin;

                if (pictureAnchor != null) {
                    // We've confirmed we need the picture, add it.
                    sl.createPicture(pictureData).setAnchor(pictureAnchor);
                }
            }
        }
    }

    /**
     * Utility function to create a text run with specified formatting.
     * @param paragraph the paragraph to add to.
     * @param text the text string to add, can contain e.g. '\n' for newlines.
     * @param fontSize font size.
     * @param color font colour.
     * @return the new text run which was added to the paragraph.
     */
    private static XSLFTextRun addTextRun(final XSLFTextParagraph paragraph, final String text,
            final double fontSize, final Color color) {
        final XSLFTextRun summary = paragraph.addNewTextRun();
        summary.setFontColor(color);
        summary.setText(text);
        summary.setFontSize(fontSize);
        return summary;
    }

    @Override
    public XMLSlideShow graph(final DategraphData data) throws TemplateLoadException {
        final SlideShowTemplate template = loadTemplate();
        final XMLSlideShow ppt = template.getSlideShow();
        final int shapeId = 1;
        final String relId = "relId" + shapeId;

        addDategraph(template, ppt.createSlide(), null, data, shapeId, relId);

        return ppt;
    }

    /**
     * Internal implementation to add a date graph (aka xy scatterplot chart with time-series x-axis) to a slide, based on a template.
     * @param template the parsed template information.
     * @param slide the slide to add to.
     * @param anchor optional bounding rectangle to draw onto, in PowerPoint coordinates.
     *               If null, we'll use the bounds from the original template chart.
     * @param data the date graph data.
     * @param shapeId the slide shape ID, should be unique within the slide.
     * @param relId the relation ID to the chart data.
     * @throws TemplateLoadException if we can't create the date graph; most likely due to an invalid template.
     */
    private static void addDategraph(final SlideShowTemplate template, final XSLFSlide slide,
            final Rectangle2D.Double anchor, final DategraphData data, final int shapeId, final String relId)
            throws TemplateLoadException {
        if (!data.validateInput()) {
            throw new IllegalArgumentException("Invalid data provided");
        }

        final List<DategraphData.Row> rows = data.getRows();
        boolean useSecondaryAxis = rows.stream().anyMatch(DategraphData.Row::isSecondaryAxis);

        if (rows.stream().allMatch(DategraphData.Row::isSecondaryAxis)) {
            // If everything is on the secondary axis; just use the primary axis
            rows.forEach(row -> row.setSecondaryAxis(false));
            useSecondaryAxis = false;
        }

        final XSSFWorkbook wb = writeChart(data);

        final XMLSlideShow ppt = template.getSlideShow();

        slide.getXmlObject().getCSld().getSpTree().addNewGraphicFrame()
                .set(template.getGraphChartShapeXML(relId, shapeId, "chart" + shapeId, anchor));

        XSLFChart baseChart = template.getGraphChart();
        final CTChartSpace chartSpace = (CTChartSpace) baseChart.getCTChartSpace().copy();

        final CTChart ctChart = chartSpace.getChart();
        final CTPlotArea plotArea = ctChart.getPlotArea();
        final XSSFSheet sheet = wb.getSheetAt(0);

        // In the template, we have two <c:scatterChart> objects, one for the primary axis, one for the secondary.
        if (!useSecondaryAxis) {
            // Discard the extra chart and its two axes.
            // OpenOffice is happy enough if you remove the scatterplot chart, but PowerPoint will complain it's a corrupt
            //   file and unhelpfully delete the entire chart when you choose 'repair' if any orphan axes remain.
            plotArea.removeScatterChart(1);
            plotArea.removeValAx(3);
            plotArea.removeValAx(2);
        }

        for (CTScatterChart ctScatterChart : plotArea.getScatterChartArray()) {
            for (final CTScatterSer ser : ctScatterChart.getSerArray()) {
                ser.getDPtList().clear();
            }
        }

        int primarySeriesCount = 0;
        int secondarySeriesCount = 0;

        for (int seriesIdx = 0; seriesIdx < rows.size(); ++seriesIdx) {
            final DategraphData.Row row = rows.get(seriesIdx);

            final CTScatterChart tgtChart = plotArea.getScatterChartArray(row.isSecondaryAxis() ? 1 : 0);

            final CTScatterSer[] serArray = tgtChart.getSerArray();
            final int createdSeriesIdx = row.isSecondaryAxis() ? secondarySeriesCount++ : primarySeriesCount++;

            final CTScatterSer curSeries;

            if (createdSeriesIdx < serArray.length) {
                curSeries = serArray[createdSeriesIdx];
            } else {
                curSeries = tgtChart.addNewSer();
                curSeries.set(serArray[0].copy());
            }

            updateCTScatterSer(data, sheet, seriesIdx, curSeries);
        }

        try {
            writeChart(ppt, slide, baseChart, chartSpace, wb, relId);
        } catch (IOException | InvalidFormatException e) {
            throw new TemplateLoadException("Unexpected error writing files from loaded template", e);
        }
    }

    /**
     * Utility function to update a scatterplot line's data series.
     * @param data the datagraph data.
     * @param sheet the Excel sheet which contains corresponding data from the scatterplot data series.
     * @param seriesIdx the index of the data in the dategraph data.
     * @param series the XML object representing the series in the chart.
     */
    private static void updateCTScatterSer(final DategraphData data, final XSSFSheet sheet, final int seriesIdx,
            final CTScatterSer series) {
        final String sheetName = sheet.getSheetName();

        // the series idx starts from 0
        final DategraphData.Row row = data.getRows().get(seriesIdx);
        final String title = row.getLabel();
        final Color color = Color.decode(row.getColor());

        series.getOrder().setVal(seriesIdx);
        series.getIdx().setVal(seriesIdx);

        final CTSolidColorFillProperties fill = series.getSpPr().getLn().getSolidFill();

        // We have to set any possible colour type, PowerPoint throws an error if there's multiple fills, and we don't
        //   know what colour type the user may have used in their template slide.
        if (fill.getSchemeClr() != null) {
            fill.unsetSchemeClr();
        }
        if (fill.getSrgbClr() != null) {
            fill.unsetSrgbClr();
        }
        if (fill.getHslClr() != null) {
            fill.unsetHslClr();
        }
        if (fill.getPrstClr() != null) {
            fill.unsetPrstClr();
        }
        if (fill.getScrgbClr() != null) {
            fill.unsetScrgbClr();
        }
        if (fill.getSysClr() != null) {
            fill.unsetSysClr();
        }

        final CTSRgbColor fillClr = fill.addNewSrgbClr();
        final byte[] colorBytes = { (byte) color.getRed(), (byte) color.getGreen(), (byte) color.getBlue() };
        fillClr.setVal(colorBytes);

        final CTMarker marker = series.getMarker();

        if (marker != null) {
            final CTShapeProperties markerSpPr = marker.getSpPr();
            unsetSpPrFills(markerSpPr);
            markerSpPr.addNewSolidFill().addNewSrgbClr().setVal(colorBytes);

            final CTLineProperties markerLn = markerSpPr.getLn();
            if (markerLn != null) {
                unsetLineFills(markerLn);
                markerLn.addNewSolidFill().addNewSrgbClr().setVal(colorBytes);
            }
        }

        final CTStrRef strRef = series.getTx().getStrRef();
        strRef.getStrCache().getPtArray()[0].setV(title);

        strRef.setF(new CellReference(sheetName, 0, seriesIdx + 1, true, true).formatAsString());

        final long[] timestamps = data.getTimestamps();
        {
            final CTNumRef timestampCatNumRef = series.getXVal().getNumRef();
            timestampCatNumRef.setF(new AreaReference(new CellReference(sheetName, 1, 0, true, true),
                    new CellReference(sheetName, 1 + timestamps.length, 0, true, true)).formatAsString());

            final CTNumData timeStampCatNumCache = timestampCatNumRef.getNumCache();
            timeStampCatNumCache.getPtCount().setVal(timestamps.length);
            timeStampCatNumCache.setPtArray(null);

            for (int ii = 0; ii < timestamps.length; ++ii) {
                final CTNumVal pt = timeStampCatNumCache.addNewPt();
                pt.setIdx(ii);
                pt.setV(sheet.getRow(1 + ii).getCell(0).getRawValue());
            }
        }

        {
            final double[] seriesData = row.getValues();

            final CTNumRef valuesNumRef = series.getYVal().getNumRef();
            valuesNumRef.setF(new AreaReference(new CellReference(sheetName, 1, seriesIdx + 1, true, true),
                    new CellReference(sheetName, 1 + timestamps.length, seriesIdx + 1, true, true))
                            .formatAsString());

            final CTNumData valuesNumCache = valuesNumRef.getNumCache();
            valuesNumCache.getPtCount().setVal(timestamps.length);
            valuesNumCache.setPtArray(null);

            for (int ii = 0; ii < timestamps.length; ++ii) {
                final CTNumVal pt = valuesNumCache.addNewPt();
                pt.setIdx(ii);
                pt.setV(Double.toString(seriesData[ii]));
            }
        }
    }

    /**
     * Utility function to write the date graph data as a Excel workbook; required since PowerPoint charts actually
     *   embed an Excel file with corresponding data. If invalid, it'll open in OpenOffice fine, but PowerPoint will
     *   complain that the presentation is corrupted.
     * @param data the date graph data.
     * @return a new Excel workbook with specified data on a new sheet.
     */
    private static XSSFWorkbook writeChart(final DategraphData data) {
        final XSSFWorkbook wb = new XSSFWorkbook();
        final XSSFSheet sheet = wb.createSheet("Sheet1");

        final CellStyle cellStyle = wb.createCellStyle();
        cellStyle.setDataFormat((short) 14);

        final List<DategraphData.Row> rows = data.getRows();
        final long[] timestamps = data.getTimestamps();

        final XSSFRow header = sheet.createRow(0);
        header.createCell(0).setCellValue("Timestamp");
        for (int ii = 0; ii < rows.size(); ++ii) {
            header.createCell(ii + 1).setCellValue(rows.get(ii).getLabel());
        }

        for (int rowIdx = 0; rowIdx < timestamps.length; ++rowIdx) {
            final XSSFRow row = sheet.createRow(rowIdx + 1);

            final XSSFCell cell = row.createCell(0);
            cell.setCellStyle(cellStyle);
            cell.setCellValue(new Date(timestamps[rowIdx] * 1000));

            for (int ii = 0; ii < rows.size(); ++ii) {
                row.createCell(ii + 1).setCellValue(rows.get(ii).getValues()[rowIdx]);
            }
        }

        return wb;
    }

    @Override
    public XMLSlideShow report(final ReportData report, final boolean slidePerVisualizer)
            throws TemplateLoadException {
        final SlideShowTemplate template = loadTemplate();
        final XMLSlideShow ppt = template.getSlideShow();

        final Rectangle2D.Double pageAnchor = createPageAnchor(ppt);
        double width = pageAnchor.getWidth();
        double height = pageAnchor.getHeight();

        if (!slidePerVisualizer) {
            // If drawing multiple visualizations on a single slide, we need to render the charts first since adding
            //   chart objects directly via XML after calling slide.getShapes() or createShape() etc. will break things.
            Arrays.sort(report.getChildren(), Comparator.comparingInt(PowerPointServiceImpl::prioritizeCharts));
        }

        // As above, we need to have a separate slide to place our sizing textbox for calculations.
        XSLFSlide sizingSlide = ppt.createSlide();
        // This is the slide to draw on.
        XSLFSlide slide = ppt.createSlide();

        int shapeId = 1;
        boolean first = true;

        for (final ReportData.Child child : report.getChildren()) {
            if (slidePerVisualizer && !first) {
                sizingSlide = ppt.createSlide();
                slide = ppt.createSlide();
            }

            first = false;

            final ComposableElement data = child.getData();
            final Rectangle2D.Double anchor = new Rectangle2D.Double(pageAnchor.getMinX() + width * child.getX(),
                    pageAnchor.getMinY() + height * child.getY(), width * child.getWidth(),
                    height * child.getHeight());

            if (child.getMargin() >= 0) {
                final double margin = child.getMargin();
                final double marginX2 = margin * 2;
                final double textMargin = child.getTextMargin();

                if (anchor.getWidth() > marginX2) {
                    double xCursor = anchor.getMinX() + margin, xWidthAvail = anchor.getWidth() - marginX2,
                            yCursor = anchor.getMinY() + margin, yHeightAvail = anchor.getHeight() - marginX2;
                    XSLFTextBox sizingBox = null;

                    final String title = child.getTitle();
                    if (StringUtils.isNotEmpty(title) && yHeightAvail > 0) {
                        sizingBox = sizingSlide.createTextBox();
                        final Rectangle2D.Double sizingAnchor = new Rectangle2D.Double(xCursor, yCursor,
                                xWidthAvail, yHeightAvail);
                        sizingBox.setAnchor(sizingAnchor);
                        sizingBox.clearText();
                        addTextRun(sizingBox.addNewTextParagraph(), title, child.getFontSize(), Color.BLACK)
                                .setFontFamily(child.getFontFamily());

                        final double textHeight = sizingBox.getTextHeight() + textMargin;
                        yCursor += textHeight;
                        yHeightAvail -= textHeight;
                    }

                    if (yHeightAvail > 0) {
                        anchor.setRect(xCursor, yCursor, xWidthAvail, yHeightAvail);
                    } else if (sizingBox != null) {
                        sizingSlide.removeShape(sizingBox);
                    }
                }
            }

            if (data instanceof DategraphData) {
                addDategraph(template, slide, anchor, (DategraphData) data, shapeId, "relId" + shapeId);
                shapeId++;
            } else if (data instanceof ListData) {
                final ListData listData = (ListData) data;
                addList(imageSource, ppt, slide, anchor, false, listData, null, null);
            } else if (data instanceof MapData) {
                final MapData mapData = (MapData) data;
                addMap(slide, anchor, addPictureData(imageSource, ppt, mapData.getImage()), mapData.getMarkers(),
                        mapData.getPolygons());
            } else if (data instanceof SunburstData) {
                addSunburst(template, slide, anchor, (SunburstData) data, shapeId, "relId" + shapeId);
                shapeId++;
            } else if (data instanceof TableData) {
                final TableData tableData = (TableData) data;
                addTable(slide, anchor, tableData.getRows(), tableData.getCols(), tableData.getCells(), true);
            } else if (data instanceof TopicMapData) {
                addTopicMap(slide, anchor, (TopicMapData) data);
            } else if (data instanceof TextData) {
                addTextData(slide, anchor, (TextData) data);
            }

            if (slidePerVisualizer) {
                transferSizedTextboxes(ppt, slide, sizingSlide);
            }
        }

        if (!slidePerVisualizer) {
            transferSizedTextboxes(ppt, slide, sizingSlide);
        }

        return ppt;
    }

    private static void transferSizedTextboxes(final XMLSlideShow ppt, final XSLFSlide slide,
            final XSLFSlide sizingSlide) {
        // Clone all text boxes to the original slide afterward, and remove the sizing slide
        for (XSLFShape shape : sizingSlide.getShapes()) {
            if (shape instanceof XSLFTextBox) {
                final XSLFTextBox src = (XSLFTextBox) shape;
                final XSLFTextBox textBox = slide.createTextBox();
                textBox.setAnchor(src.getAnchor());
                textBox.clearText();
                src.forEach(
                        srcPara -> textBox.addNewTextParagraph().getXmlObject().set(srcPara.getXmlObject().copy()));
            }
        }

        ppt.removeSlide(ppt.getSlides().indexOf(sizingSlide));
    }

    /**
     * Utility function to render a TextData object as multiple text runs on the screen in a single text paragraph.
     * Note that newlines are not added automatically; this is so we can support adjacent text with different formatting.
     * @param slide the slide to add to.
     * @param anchor bounding rectangle to draw onto, in PowerPoint coordinates.
     * @param data the text data to render.
     */
    private void addTextData(final XSLFSlide slide, final Rectangle2D.Double anchor, final TextData data) {
        final XSLFTextBox textBox = slide.createTextBox();
        textBox.setAnchor(anchor);
        textBox.clearText();

        final XSLFTextParagraph para = textBox.addNewTextParagraph();

        for (final TextData.Paragraph runData : data.getText()) {
            final XSLFTextRun run = para.addNewTextRun();
            run.setText(runData.getText());
            run.setFontSize(runData.getFontSize());
            run.setBold(runData.isBold());
            run.setItalic(runData.isItalic());
            run.setFontColor(Color.decode(runData.getColor()));

            if (textBox.getTextHeight() > anchor.getHeight()) {
                // Try removing words from the last box until we find something that fits, or we run out of words
                final String trimmedText = runData.getText().trim();
                run.setText(trimmedText);

                for (final StringBuilder text = new StringBuilder(trimmedText); textBox.getTextHeight() > anchor
                        .getHeight() && text.length() > 0;) {
                    final int lastSpaceIdx = Math.max(text.lastIndexOf(" "), text.lastIndexOf("\n"));

                    if (lastSpaceIdx < 0) {
                        break;
                    }

                    text.delete(lastSpaceIdx, text.length());
                    // Add a trailing ellipsis unless it's empty or already contained a trailing ellipsis or "..." at the final truncated position.
                    run.setText(
                            text.length() > 0 ? text.toString().replaceFirst("(\\s*(\\.{3}|\u2026))?$", "\u2026")
                                    : "");
                }

                // The font metrics aren't going to be perfect (due to unavailability of fonts etc.) so we force the truncated text to fit.
                textBox.setTextAutofit(TextShape.TextAutofit.NORMAL);

                break;
            }
        }
    }

    /**
     * Utility function to define a sort order which places date graph and sunburst to the front, since they have to
     *   be added to the XML before any other shapes are drawn on the slide.
     * @param child the child to test.
     * @return -1 for date graphs/sunbursts, 0 otherwise.
     */
    private static int prioritizeCharts(final ReportData.Child child) {
        final ComposableElement d = child.getData();
        return d instanceof DategraphData || d instanceof SunburstData ? -1 : 0;
    }

    /**
     * Utility function to generate a new unique package part name within a PowerPoint zip file, given a base name
     *   which has a number before the file extension.
     * @param opcPackage the PowerPoint zip package.
     * @param baseName the original name.
     * @return a new unique package part name with an incremented number if the old name had a number before the file extension,
     *         or the old name otherwise.
     * @throws InvalidFormatException if there was an exception while generating the new name.
     */
    private static PackagePartName generateNewName(final OPCPackage opcPackage, final String baseName)
            throws InvalidFormatException {
        final Pattern pattern = Pattern.compile("(.*?)(\\d+)(\\.\\w+)?$");

        final Matcher matcher = pattern.matcher(baseName);

        if (matcher.find()) {
            int num = Integer.parseInt(matcher.group(2));

            for (int ii = num + 1; ii < Integer.MAX_VALUE; ++ii) {
                final PackagePartName testName = PackagingURIHelper
                        .createPartName(matcher.group(1) + ii + matcher.group(3));

                if (opcPackage.getPart(testName) == null) {
                    return testName;
                }
            }
        }

        // If the document doesn't have a numeric extension, just return it
        return PackagingURIHelper.createPartName(baseName);
    }

    /**
     * Utility function to write a chart object to a slide based on a template.
     * Creates new copies of referred objects in the chart, e.g. colors1.xml and style1.xml, and writes the Excel
     *   workbook data to new files in the PowerPoint .zip structure.
     * @param pptx the presentation to add to.
     * @param slide the slide to add to.
     * @param templateChart the original template chart XML reference object from the template.
     * @param modifiedChart the new chart XML object.
     * @param workbook the Excel workbook data corresponding to the chart XML data.
     * @param relId the relation id for the new chart.
     * @throws IOException if there's IO errors working with the chart.
     * @throws InvalidFormatException if there's errors generating new package part names for the new copies of the data.
     */
    private static void writeChart(final XMLSlideShow pptx, final XSLFSlide slide, final XSLFChart templateChart,
            final CTChartSpace modifiedChart, final XSSFWorkbook workbook, final String relId)
            throws IOException, InvalidFormatException {
        final OPCPackage opcPackage = pptx.getPackage();
        final PackagePartName chartName = generateNewName(opcPackage,
                templateChart.getPackagePart().getPartName().getURI().getPath());

        final ByteArrayOutputStream baos = new ByteArrayOutputStream();
        XmlOptions xmlOptions = new XmlOptions(DEFAULT_XML_OPTIONS);
        xmlOptions.setSaveSyntheticDocumentElement(
                new QName(CTChartSpace.type.getName().getNamespaceURI(), "chartSpace", "c"));
        modifiedChart.save(baos, xmlOptions);

        final PackagePart chartPart = opcPackage.createPart(chartName, XSLFRelation.CHART.getContentType(), baos);

        slide.getPackagePart().addRelationship(chartName, TargetMode.INTERNAL, XSLFRelation.CHART.getRelation(),
                relId);

        for (final POIXMLDocumentPart.RelationPart part : templateChart.getRelationParts()) {
            final ByteArrayOutputStream partCopy = new ByteArrayOutputStream();
            final URI targetURI = part.getRelationship().getTargetURI();

            final PackagePartName name = generateNewName(opcPackage, targetURI.getPath());

            final String contentType = part.getDocumentPart().getPackagePart().getContentType();

            if ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".equals(contentType)) {
                workbook.write(partCopy);
            } else {
                IOUtils.copy(part.getDocumentPart().getPackagePart().getInputStream(), partCopy);
            }

            opcPackage.createPart(name, contentType, partCopy);
            chartPart.addRelationship(name, TargetMode.INTERNAL, part.getRelationship().getRelationshipType());
        }
    }
}