blob: 27f053dc853ee18484433ebdb979f9e1b2ad67cf [file] [log] [blame]
/*
* ====================================================================
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ====================================================================
*/
package org.apache.poi.xslf.util;
import java.awt.AlphaComposite;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.Dimension2D;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.poi.common.usermodel.GenericRecord;
import org.apache.poi.poifs.filesystem.FileMagic;
import org.apache.poi.sl.draw.Drawable;
import org.apache.poi.sl.draw.EmbeddedExtractor.EmbeddedPart;
import org.apache.poi.util.Dimension2DDouble;
import org.apache.poi.util.GenericRecordJsonWriter;
import org.apache.poi.util.LocaleUtil;
/**
* An utility to convert slides of a .pptx slide show to a PNG image
*/
public final class PPTX2PNG {
private static final Logger LOG = LogManager.getLogger(PPTX2PNG.class);
private static final String INPUT_PAT_REGEX =
"(?<slideno>[^|]+)\\|(?<format>[^|]+)\\|(?<basename>.+)\\.(?<ext>[^.]++)";
private static final Pattern INPUT_PATTERN = Pattern.compile(INPUT_PAT_REGEX);
private static final String OUTPUT_PAT_REGEX = "${basename}-${slideno}.${format}";
private static void usage(String error){
String msg =
"Usage: PPTX2PNG [options] <.ppt/.pptx/.emf/.wmf file or 'stdin'>\n" +
(error == null ? "" : ("Error: "+error+"\n")) +
"Options:\n" +
" -scale <float> scale factor\n" +
" -fixSide <side> specify side (long,short,width,height) to fix - use <scale> as amount of pixels\n" +
" -slide <integer> 1-based index of a slide to render\n" +
" -format <type> png,gif,jpg,svg,pdf (,log,null for testing)\n" +
" -outdir <dir> output directory, defaults to origin of the ppt/pptx file\n" +
" -outfile <file> output filename, defaults to '"+OUTPUT_PAT_REGEX+"'\n" +
" -outpat <pattern> output filename pattern, defaults to '"+OUTPUT_PAT_REGEX+"'\n" +
" patterns: basename, slideno, format, ext\n" +
" -dump <file> dump the annotated records to a file\n" +
" -quiet do not write to console (for normal processing)\n" +
" -ignoreParse ignore parsing error and continue with the records read until the error\n" +
" -extractEmbedded extract embedded parts\n" +
" -inputType <type> default input file type (OLE2,WMF,EMF), default is OLE2 = Powerpoint\n" +
" some files (usually wmf) don't have a header, i.e. an identifiable file magic\n" +
" -textAsShapes text elements are saved as shapes in SVG, necessary for variable spacing\n" +
" often found in math formulas\n" +
" -charset <cs> sets the default charset to be used, defaults to Windows-1252\n" +
" -emfHeaderBounds force the usage of the emf header bounds to calculate the bounding box\n" +
" -fontdir <dir> (PDF only) font directories separated by \";\" - use $HOME for current users home dir\n" +
" defaults to the usual plattform directories\n" +
" -fontTtf <regex> (PDF only) regex to match the .ttf filenames\n" +
" -fontMap <map> \";\"-separated list of font mappings <typeface from>:<typeface to>";
System.out.println(msg);
// no System.exit here, as we also run in junit tests!
}
public static void main(String[] args) throws Exception {
PPTX2PNG p2p = new PPTX2PNG();
if (p2p.parseCommandLine(args)) {
p2p.processFile();
}
}
private String slidenumStr = "-1";
private float scale = 1;
private File file = null;
private String format = "png";
private File outdir = null;
private String outfile = null;
private boolean quiet = false;
private String outPattern = OUTPUT_PAT_REGEX;
private File dumpfile = null;
private String fixSide = "scale";
private boolean ignoreParse = false;
private boolean extractEmbedded = false;
private FileMagic defaultFileType = FileMagic.OLE2;
private boolean textAsShapes = false;
private Charset charset = LocaleUtil.CHARSET_1252;
private boolean emfHeaderBounds = false;
private String fontDir = null;
private String fontTtf = null;
private String fontMap = null;
private PPTX2PNG() {
}
private boolean parseCommandLine(String[] args) {
if (args.length == 0) {
usage(null);
return false;
}
for (int i = 0; i < args.length; i++) {
String opt = (i+1 < args.length) ? args[i+1] : null;
switch (args[i].toLowerCase(Locale.ROOT)) {
case "-scale":
if (opt != null) {
scale = Float.parseFloat(opt);
i++;
}
break;
case "-slide":
slidenumStr = opt;
i++;
break;
case "-format":
format = opt;
i++;
break;
case "-outdir":
if (opt != null) {
outdir = new File(opt);
i++;
}
break;
case "-outfile":
outfile = opt;
i++;
break;
case "-outpat":
outPattern = opt;
i++;
break;
case "-quiet":
quiet = true;
break;
case "-dump":
if (opt != null) {
dumpfile = new File(opt);
i++;
} else {
dumpfile = new File("pptx2png.dump");
}
break;
case "-fixside":
if (opt != null) {
fixSide = opt.toLowerCase(Locale.ROOT);
i++;
} else {
fixSide = "long";
}
break;
case "-inputtype":
if (opt != null) {
defaultFileType = FileMagic.valueOf(opt);
i++;
} else {
defaultFileType = FileMagic.OLE2;
}
break;
case "-textasshapes":
textAsShapes = true;
break;
case "-ignoreparse":
ignoreParse = true;
break;
case "-extractembedded":
extractEmbedded = true;
break;
case "-charset":
if (opt != null) {
charset = Charset.forName(opt);
i++;
} else {
charset = LocaleUtil.CHARSET_1252;
}
break;
case "-emfheaderbounds":
emfHeaderBounds = true;
break;
case "-fontdir":
if (opt != null) {
fontDir = opt;
i++;
} else {
fontDir = null;
}
break;
case "-fontttf":
if (opt != null) {
fontTtf = opt;
i++;
} else {
fontTtf = null;
}
break;
case "-fontmap":
if (opt != null) {
fontMap = opt;
i++;
} else {
fontMap = null;
}
break;
default:
file = new File(args[i]);
break;
}
}
final boolean isStdin = file != null && "stdin".equalsIgnoreCase(file.getName());
if (!isStdin && (file == null || !file.exists())) {
usage("File not specified or it doesn't exist");
return false;
}
if (format == null || !format.matches("^(png|gif|jpg|null|svg|pdf|log)$")) {
usage("Invalid format given");
return false;
}
if (outdir == null) {
if (isStdin) {
usage("When reading from STDIN, you need to specify an outdir.");
return false;
} else {
outdir = file.getAbsoluteFile().getParentFile();
}
}
if (!outdir.exists()) {
usage("Outdir doesn't exist");
return false;
}
if (!"null".equals(format) && (outdir == null || !outdir.exists() || !outdir.isDirectory())) {
usage("Output directory doesn't exist");
return false;
}
if (scale < 0) {
usage("Invalid scale given");
return false;
}
if (!"long,short,width,height,scale".contains(fixSide)) {
usage("<fixside> must be one of long / short / width / height");
return false;
}
return true;
}
private void processFile() throws IOException {
if (!quiet) {
System.out.println("Processing " + file);
}
try (MFProxy proxy = initProxy(file)) {
final Set<Integer> slidenum = proxy.slideIndexes(slidenumStr);
if (slidenum.isEmpty()) {
usage("slidenum must be either -1 (for all) or within range: [1.." + proxy.getSlideCount() + "] for " + file);
return;
}
final Dimension2D dim = new Dimension2DDouble();
final double lenSide = getDimensions(proxy, dim);
final int width = Math.max((int)Math.rint(dim.getWidth()),1);
final int height = Math.max((int)Math.rint(dim.getHeight()),1);
try (OutputFormat outputFormat = getOutput()) {
for (int slideNo : slidenum) {
proxy.setSlideNo(slideNo);
if (!quiet) {
String title = proxy.getTitle();
System.out.println("Rendering slide " + slideNo + (title == null ? "" : ": " + title.trim()));
}
dumpRecords(proxy);
extractEmbedded(proxy, slideNo);
Graphics2D graphics = outputFormat.addSlide(width, height);
// default rendering options
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
graphics.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY);
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
graphics.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
graphics.setRenderingHint(Drawable.DEFAULT_CHARSET, getDefaultCharset());
graphics.setRenderingHint(Drawable.EMF_FORCE_HEADER_BOUNDS, emfHeaderBounds);
if (fontMap != null) {
Map<String,String> fmap = Arrays.stream(fontMap.split(";"))
.map(s -> s.split(":"))
.collect(Collectors.toMap(s -> s[0], s -> s[1]));
graphics.setRenderingHint(Drawable.FONT_MAP, fmap);
}
graphics.scale(scale / lenSide, scale / lenSide);
graphics.setComposite(AlphaComposite.Clear);
graphics.fillRect(0, 0, width, height);
graphics.setComposite(AlphaComposite.SrcOver);
// draw stuff
proxy.draw(graphics);
outputFormat.writeSlide(proxy, new File(outdir, calcOutFile(proxy, slideNo)));
}
outputFormat.writeDocument(proxy, new File(outdir, calcOutFile(proxy, 0)));
}
} catch (NoScratchpadException e) {
usage("'"+file.getName()+"': Format not supported - try to include poi-scratchpad.jar into the CLASSPATH.");
return;
}
if (!quiet) {
System.out.println("Done");
}
}
private OutputFormat getOutput() {
switch (format) {
case "svg": {
try {
return new SVGFormat(textAsShapes);
} catch (Exception | NoClassDefFoundError e) {
LOG.atError().withThrowable(e).log("Batik is not not added to/working on the module-path. Use classpath mode instead of JPMS. Fallback to PNG.");
return new BitmapFormat("png");
}
}
case "pdf":
return new PDFFormat(textAsShapes,fontDir,fontTtf);
case "log":
return new DummyFormat();
default:
return new BitmapFormat(format);
}
}
private double getDimensions(MFProxy proxy, Dimension2D dim) {
final Dimension2D pgsize = proxy.getSize();
final double lenSide;
switch (fixSide) {
default:
case "scale":
lenSide = 1;
break;
case "long":
lenSide = Math.max(pgsize.getWidth(), pgsize.getHeight());
break;
case "short":
lenSide = Math.min(pgsize.getWidth(), pgsize.getHeight());
break;
case "width":
lenSide = pgsize.getWidth();
break;
case "height":
lenSide = pgsize.getHeight();
break;
}
dim.setSize(pgsize.getWidth() * scale / lenSide, pgsize.getHeight() * scale / lenSide);
return lenSide;
}
private void dumpRecords(MFProxy proxy) throws IOException {
if (dumpfile == null || "null".equals(dumpfile.getPath())) {
return;
}
GenericRecord gr = proxy.getRoot();
try (GenericRecordJsonWriter fw = new GenericRecordJsonWriter(dumpfile) {
protected boolean printBytes(String name, Object o) {
return false;
}
}) {
if (gr == null) {
fw.writeError(file.getName()+" doesn't support GenericRecord interface and can't be dumped to a file.");
} else {
fw.write(gr);
}
}
}
private void extractEmbedded(MFProxy proxy, int slideNo) throws IOException {
if (!extractEmbedded) {
return;
}
for (EmbeddedPart ep : proxy.getEmbeddings(slideNo)) {
String filename = ep.getName();
// do some sanitizing for creative filenames ...
filename = new File(filename == null ? "dummy.dat" : filename).getName();
filename = calcOutFile(proxy, slideNo).replaceFirst("\\.\\w+$", "")+"_"+filename;
try (FileOutputStream fos = new FileOutputStream(new File(outdir, filename))) {
fos.write(ep.getData().get());
}
}
}
private interface ProxyConsumer {
void parse(MFProxy proxy) throws IOException;
}
@SuppressWarnings({"resource", "squid:S2095"})
private MFProxy initProxy(File file) throws IOException {
MFProxy proxy;
final String fileName = file.getName().toLowerCase(Locale.ROOT);
FileMagic fm;
ProxyConsumer con;
if ("stdin".equals(fileName)) {
InputStream bis = FileMagic.prepareToCheckMagic(System.in);
fm = FileMagic.valueOf(bis);
con = (p) -> p.parse(bis);
} else {
fm = FileMagic.valueOf(file);
con = (p) -> p.parse(file);
}
if (fm == FileMagic.UNKNOWN) {
fm = defaultFileType;
}
switch (fm) {
case EMF:
proxy = new EMFHandler();
break;
case WMF:
proxy = new WMFHandler();
break;
default:
proxy = new PPTHandler();
break;
}
proxy.setIgnoreParse(ignoreParse);
proxy.setQuite(quiet);
con.parse(proxy);
proxy.setDefaultCharset(charset);
return proxy;
}
private String calcOutFile(MFProxy proxy, int slideNo) {
if (outfile != null) {
return outfile;
}
String inname = String.format(Locale.ROOT, "%04d|%s|%s", slideNo, format, file.getName());
String outpat = (proxy.getSlideCount() > 1 && slideNo > 0 ? outPattern : outPattern.replaceAll("-?\\$\\{slideno}", ""));
return INPUT_PATTERN.matcher(inname).replaceAll(outpat);
}
private Charset getDefaultCharset() {
return charset;
}
static class NoScratchpadException extends IOException {
NoScratchpadException() {
}
NoScratchpadException(Throwable cause) {
super(cause);
}
}
}