| /* |
| * ==================================================================== |
| * 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); |
| } |
| } |
| } |