/** * The contents of this file are subject to the license and copyright * detailed in the LICENSE and NOTICE files at the root of the source * tree and available online at * * http://www.dspace.org/license/ */ package org.dspace.app.mediafilter; import java.awt.Graphics2D; import java.awt.Color; import java.awt.image.*; import java.awt.RenderingHints; import java.awt.Transparency; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.util.Arrays; import java.util.regex.MatchResult; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.imageio.ImageIO; import org.apache.log4j.Logger; import org.dspace.core.ConfigurationManager; import org.dspace.core.Utils; /** * Thumbnail MediaFilter for PDF sources * * This filter generates thumbnail images for PDF documents, _including_ * 3D PDF documents with 2D "poster" images. Since the PDFBox library * does not understand these, and fails to render a lot of other PDFs, * this filter forks a process running the "pdftoppm" program from the * XPdf suite -- see http://www.foolabs.com/xpdf/ * This is a suite of open-source PDF tools that has been widely ported * to Unix platforms and the ones we use (pdftoppm, pdfinfo) even * run on Win32. * * This was written for the FACADE project but it is not directly connected * to any of the other FACADE-specific software. The FACADE UI expects * to find thumbnail images for 3D PDFs generated by this filter. * * Requires DSpace config properties keys: * * xpdf.path.pdftoppm -- absolute path to "pdftoppm" executable (required!) * xpdf.path.pdfinfo -- absolute path to "pdfinfo" executable (required!) * thumbnail.maxwidth -- borrowed from thumbnails, max dim of generated image * * @author Larry Stone * @see org.dspace.app.mediafilter.MediaFilter */ public class XPDF2Thumbnail extends MediaFilter { private static Logger log = Logger.getLogger(XPDF2Thumbnail.class); // maximum size of either preview image dimension private static final int MAX_PX = 800; // maxium DPI - use common screen res, 100dpi. private static final int MAX_DPI = 100; // command to get image from PDF; @FILE@, @OUTPUT@ are placeholders private static final String XPDF_PDFTOPPM_COMMAND[] = { "@COMMAND@", "-q", "-f", "1", "-l", "1", "-r", "@DPI@", "@FILE@", "@OUTPUTFILE@" }; // command to get image from PDF; @FILE@, @OUTPUT@ are placeholders private static final String XPDF_PDFINFO_COMMAND[] = { "@COMMAND@", "-f", "1", "-l", "1", "-box", "@FILE@" }; // executable path for "pdftoppm", comes from DSpace config at runtime. private String pdftoppmPath = null; // executable path for "pdfinfo", comes from DSpace config at runtime. private String pdfinfoPath = null; // match line in pdfinfo output that describes file's MediaBox private static final Pattern MEDIABOX_PATT = Pattern.compile( "^Page\\s+\\d+\\s+MediaBox:\\s+([\\.\\d-]+)\\s+([\\.\\d-]+)\\s+([\\.\\d-]+)\\s+([\\.\\d-]+)"); // also from thumbnail.maxwidth in config private int xmax = 0; // backup default for size, on the large side. private static final int DEFAULT_XMAX = 500; public String getFilteredName(String oldFilename) { return oldFilename + ".jpg"; } public String getBundleName() { return "THUMBNAIL"; } public String getFormatString() { return "JPEG"; } public String getDescription() { return "Generated Thumbnail"; } // canonical MediaFilter method to generate the thumbnail as stream. public InputStream getDestinationStream(InputStream sourceStream) throws Exception { // get config params float xmax = (float) ConfigurationManager .getIntProperty("thumbnail.maxwidth"); float ymax = (float) ConfigurationManager .getIntProperty("thumbnail.maxheight"); boolean blurring = (boolean) ConfigurationManager .getBooleanProperty("thumbnail.blurring"); boolean hqscaling = (boolean) ConfigurationManager .getBooleanProperty("thumbnail.hqscaling"); // sanity check: xpdf paths are required. can cache since it won't change if (pdftoppmPath == null || pdfinfoPath == null) { pdftoppmPath = ConfigurationManager.getProperty("xpdf.path.pdftoppm"); pdfinfoPath = ConfigurationManager.getProperty("xpdf.path.pdfinfo"); if (pdftoppmPath == null) { throw new IllegalStateException("No value for key \"xpdf.path.pdftoppm\" in DSpace configuration! Should be path to XPDF pdftoppm executable."); } if (pdfinfoPath == null) { throw new IllegalStateException("No value for key \"xpdf.path.pdfinfo\" in DSpace configuration! Should be path to XPDF pdfinfo executable."); } if (xmax == 0) { xmax = DEFAULT_XMAX; } } // make local file copy of source PDF since the PDF tools // require a file for random access. // XXX fixme would be nice to optimize this if we ever get // XXX a DSpace method to access (optionally!) the _file_ of // a Bitstream in the asset store, only when there is one of course. File sourceTmp = File.createTempFile("DSfilt",".pdf"); sourceTmp.deleteOnExit(); int status = 0; BufferedImage source = null; try { OutputStream sto = new FileOutputStream(sourceTmp); Utils.copy(sourceStream, sto); sto.close(); sourceStream.close(); // First get max physical dim of bounding box of first page // to compute the DPI to ask for.. otherwise some AutoCAD // drawings can produce enormous files even at 75dpi, for // 48" drawings.. // run pdfinfo, look for MediaBox description in the output, e.g. // "Page 1 MediaBox: 0.00 0.00 612.00 792.00" // int dpi = 0; String pdfinfoCmd[] = XPDF_PDFINFO_COMMAND.clone(); pdfinfoCmd[0] = pdfinfoPath; pdfinfoCmd[pdfinfoCmd.length-1] = sourceTmp.toString(); BufferedReader lr = null; try { MatchResult mediaBox = null; Process pdfProc = Runtime.getRuntime().exec(pdfinfoCmd); lr = new BufferedReader(new InputStreamReader(pdfProc.getInputStream())); String line; for (line = lr.readLine(); line != null; line = lr.readLine()) { // if (line.matches(MEDIABOX_PATT)) Matcher mm = MEDIABOX_PATT.matcher(line); if (mm.matches()) { mediaBox = mm.toMatchResult(); } } int istatus = pdfProc.waitFor(); if (istatus != 0) { log.error("XPDF pdfinfo proc failed, exit status=" + istatus + ", file=" + sourceTmp); } if (mediaBox == null) { log.error("Sanity check: Did not find \"MediaBox\" line in output of XPDF pdfinfo, file="+sourceTmp); throw new IllegalArgumentException("Failed to get MediaBox of PDF with pdfinfo, cannot compute thumbnail."); } else { double x0 = Double.parseDouble(mediaBox.group(1)); double y0 = Double.parseDouble(mediaBox.group(2)); double x1 = Double.parseDouble(mediaBox.group(3)); double y1 = Double.parseDouble(mediaBox.group(4)); int maxdim = (int)Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)); dpi = Math.min(MAX_DPI, (MAX_PX * 72 / maxdim)); log.debug("DPI: pdfinfo method got dpi="+dpi+" for max dim="+maxdim+" (points, 1/72\")"); } } catch (InterruptedException e) { log.error("Failed transforming file for preview: ",e); throw new IllegalArgumentException("Failed transforming file for thumbnail: ",e); } catch (NumberFormatException e) { log.error("Failed interpreting pdfinfo results, check regexp: ",e); throw new IllegalArgumentException("Failed transforming file for thumbnail: ",e); } finally { if (lr != null) { lr.close(); } } // Render page 1 using xpdf's pdftoppm // Requires Sun JAI imageio additions to read ppm directly. // this will get "-000001.ppm" appended to it by pdftoppm File outPrefixF = File.createTempFile("prevu","out"); String outPrefix = outPrefixF.toString(); if (!outPrefixF.delete()) { log.error("Unable to delete output file"); } String pdfCmd[] = XPDF_PDFTOPPM_COMMAND.clone(); pdfCmd[0] = pdftoppmPath; pdfCmd[pdfCmd.length-3] = String.valueOf(dpi); pdfCmd[pdfCmd.length-2] = sourceTmp.toString(); pdfCmd[pdfCmd.length-1] = outPrefix; File outf = new File(outPrefix+"-000001.ppm"); log.debug("Running xpdf command: "+Arrays.deepToString(pdfCmd)); try { Process pdfProc = Runtime.getRuntime().exec(pdfCmd); status = pdfProc.waitFor(); if (!outf.exists()) outf = new File(outPrefix+"-00001.ppm"); if (!outf.exists()) outf = new File(outPrefix+"-0001.ppm"); if (!outf.exists()) outf = new File(outPrefix+"-001.ppm"); if (!outf.exists()) outf = new File(outPrefix+"-01.ppm"); if (!outf.exists()) outf = new File(outPrefix+"-1.ppm"); log.debug("PDFTOPPM output is: "+outf+", exists="+outf.exists()); source = ImageIO.read(outf); } catch (InterruptedException e) { log.error("Failed transforming file for preview: ",e); throw new IllegalArgumentException("Failed transforming file for preview: ",e); } finally { if (!outf.delete()) { log.error("Unable to delete file"); } } } finally { if (!sourceTmp.delete()) { log.error("Unable to delete temporary source"); } if (status != 0) { log.error("PDF conversion proc failed, exit status=" + status + ", file=" + sourceTmp); } } if (source == null) { throw new IOException("Unknown failure while transforming file to preview: no image produced."); } // read in bitstream's image BufferedImage buf = source; // now get the image dimensions float xsize = (float) buf.getWidth(null); float ysize = (float) buf.getHeight(null); // if verbose flag is set, print out dimensions // to STDOUT if (MediaFilterManager.isVerbose) { System.out.println("original size: " + xsize + "," + ysize); } // scale by x first if needed if (xsize > xmax) { // calculate scaling factor so that xsize * scale = new size (max) float scale_factor = xmax / xsize; // if verbose flag is set, print out extracted text // to STDOUT if (MediaFilterManager.isVerbose) { System.out.println("x scale factor: " + scale_factor); } // now reduce x size // and y size xsize = xsize * scale_factor; ysize = ysize * scale_factor; // if verbose flag is set, print out extracted text // to STDOUT if (MediaFilterManager.isVerbose) { System.out.println("new size: " + xsize + "," + ysize); } } // scale by y if needed if (ysize > ymax) { float scale_factor = ymax / ysize; // now reduce x size // and y size xsize = xsize * scale_factor; ysize = ysize * scale_factor; } // if verbose flag is set, print details to STDOUT if (MediaFilterManager.isVerbose) { System.out.println("created thumbnail size: " + xsize + ", " + ysize); } // create an image buffer for the thumbnail with the new xsize, ysize BufferedImage thumbnail = new BufferedImage((int) xsize, (int) ysize, BufferedImage.TYPE_INT_RGB); // Use blurring if selected in config. // a little blur before scaling does wonders for keeping moire in check. if (blurring) { // send the buffered image off to get blurred. buf = getBlurredInstance((BufferedImage) buf); } // Use high quality scaling method if selected in config. // this has a definite performance penalty. if (hqscaling) { // send the buffered image off to get an HQ downscale. buf = getScaledInstance((BufferedImage) buf, (int) xsize, (int) ysize, (Object) RenderingHints.VALUE_INTERPOLATION_BICUBIC, (boolean) true); } // now render the image into the thumbnail buffer Graphics2D g2d = thumbnail.createGraphics(); g2d.drawImage(buf, 0, 0, (int) xsize, (int) ysize, null); // now create an input stream for the thumbnail buffer and return it ByteArrayOutputStream baos = new ByteArrayOutputStream(); ImageIO.write(thumbnail, "jpeg", baos); // now get the array ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); return bais; // hope this gets written out before its garbage collected! } public String[] getInputMIMETypes() { return ImageIO.getReaderMIMETypes(); } public String[] getInputDescriptions() { return null; } public String[] getInputExtensions() { // Temporarily disabled as JDK 1.6 only // return ImageIO.getReaderFileSuffixes(); return null; } public BufferedImage getNormalizedInstance(BufferedImage buf) { int type = (buf.getTransparency() == Transparency.OPAQUE) ? BufferedImage.TYPE_INT_RGB : BufferedImage.TYPE_INT_ARGB_PRE; int w, h; w = buf.getWidth(); h = buf.getHeight(); BufferedImage normal = new BufferedImage(w, h, type); Graphics2D g2d = normal.createGraphics(); g2d.drawImage(buf, 0, 0, w, h, Color.WHITE, null); g2d.dispose(); return normal; } public BufferedImage getBlurredInstance(BufferedImage buf) { /** * Convenience method that returns a blurred instance of the * provided {@code BufferedImage}. * */ buf = getNormalizedInstance(buf); // kernel for blur op float[] matrix = { 0.111f, 0.111f, 0.111f, 0.111f, 0.111f, 0.111f, 0.111f, 0.111f, 0.111f, }; // perform the blur and return the blurred version. BufferedImageOp blur = new ConvolveOp( new Kernel(3, 3, matrix) ); BufferedImage blurbuf = blur.filter(buf, null); return blurbuf; } /** * Convenience method that returns a scaled instance of the * provided {@code BufferedImage}. * * @param buf the original image to be scaled * @param targetWidth the desired width of the scaled instance, * in pixels * @param targetHeight the desired height of the scaled instance, * in pixels * @param hint one of the rendering hints that corresponds to * {@code RenderingHints.KEY_INTERPOLATION} (e.g. * {@code RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR}, * {@code RenderingHints.VALUE_INTERPOLATION_BILINEAR}, * {@code RenderingHints.VALUE_INTERPOLATION_BICUBIC}) * @param higherQuality if true, this method will use a multi-step * scaling technique that provides higher quality than the usual * one-step technique (only useful in downscaling cases, where * {@code targetWidth} or {@code targetHeight} is * smaller than the original dimensions, and generally only when * the {@code BILINEAR} hint is specified) * @return a scaled version of the original {@code BufferedImage} */ public BufferedImage getScaledInstance(BufferedImage buf, int targetWidth, int targetHeight, Object hint, boolean higherQuality) { int type = (buf.getTransparency() == Transparency.OPAQUE) ? BufferedImage.TYPE_INT_RGB : BufferedImage.TYPE_INT_ARGB; BufferedImage scalebuf = (BufferedImage)buf; int w, h; if (higherQuality) { // Use multi-step technique: start with original size, then // scale down in multiple passes with drawImage() // until the target size is reached w = buf.getWidth(); h = buf.getHeight(); } else { // Use one-step technique: scale directly from original // size to target size with a single drawImage() call w = targetWidth; h = targetHeight; } do { if (higherQuality && w > targetWidth) { w /= 2; if (w < targetWidth) { w = targetWidth; } } if (higherQuality && h > targetHeight) { h /= 2; if (h < targetHeight) { h = targetHeight; } } BufferedImage tmp = new BufferedImage(w, h, type); Graphics2D g2d = tmp.createGraphics(); g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, hint); g2d.drawImage(scalebuf, 0, 0, w, h, Color.WHITE, null); g2d.dispose(); scalebuf = tmp; } while (w != targetWidth || h != targetHeight); return scalebuf; } }