/* * $HeadURL$ * $Id$ * * Copyright (c) 2007-2012 by Public Library of Science * http://plos.org * http://ambraproject.org * * Licensed 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.ambraproject.action.article; import org.apache.poi.hslf.model.Hyperlink; import org.apache.poi.hslf.model.Picture; import org.apache.poi.hslf.model.Slide; import org.apache.poi.hslf.model.TextBox; import org.apache.poi.hslf.model.TextRun; import org.apache.poi.hslf.usermodel.RichTextRun; import org.apache.poi.hslf.usermodel.SlideShow; import org.im4java.core.ETOperation; import org.im4java.core.ExiftoolCmd; import org.im4java.core.IM4JavaException; import org.im4java.core.Operation; import org.im4java.process.ArrayListOutputConsumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.imageio.ImageIO; import javax.imageio.ImageReader; import javax.imageio.stream.ImageInputStream; import java.awt.Dimension; import java.awt.Rectangle; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.URL; import java.util.ArrayDeque; import java.util.Collections; import java.util.Deque; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Builds a PowerPoint slide from an article figure, encapsulating and using information provided by {@link * org.ambraproject.service.article.ArticleAssetServiceImpl}. */ public class FigureSlideShow { private static final Logger log = LoggerFactory.getLogger(FigureSlideShow.class); private static final int SLIDE_WIDTH = 792; // width of a landscape letter page (11 inches * 72 points/inch) private static final int SLIDE_HEIGHT = 612; // height of a landscape letter page (8.5 inches * 72 points/inch) private static final int LOGO_MARGIN = 5; private final String title; private final String citation; private final String journalName; private final URL citationLink; private final byte[] logoImage; private final String logoPath; private final String imgAbsolutePath; public FigureSlideShow(String title, String citation, String journalName, URL citationLink, byte[] logoImage, String logoPath, String imgAbsolutePath) { this.title = title; this.citation = citation; this.journalName = journalName; this.citationLink = citationLink; this.logoImage = logoImage; this.logoPath = logoPath; this.imgAbsolutePath = imgAbsolutePath; } /** * Build a PowerPoint slide show, containing one slide of the figure with title and citation. * * @return the slide show, represented as an Apache HSLF object * @throws IOException */ public SlideShow convert() throws IOException, IM4JavaException, InterruptedException { SlideShow slideShow = new SlideShow(); slideShow.setPageSize(new Dimension(SLIDE_WIDTH, SLIDE_HEIGHT)); Picture picture = setPictureBox(slideShow); Slide slide = slideShow.createSlide(); slide.addShape(picture); if (!title.isEmpty()) { putTitleInSlide(slide); } // Create and set the citation TextBox pptCitationText = buildCitationBox(slideShow); slide.addShape(pptCitationText); putJournalLogoInSlide(slide); includeCopyRightInfoInSlide(slide); return slideShow; } private void includeCopyRightInfoInSlide(Slide slide) throws IOException, IM4JavaException, InterruptedException { //to retrieve the xmp data from image ExiftoolCmd exiftoolCmd = new ExiftoolCmd(); Operation operation = new ETOperation().getTags("xmp:Rights").addImage(); ArrayListOutputConsumer outputConsumer = new ArrayListOutputConsumer(); exiftoolCmd.setOutputConsumer(outputConsumer); exiftoolCmd.run(operation, imgAbsolutePath); List<String> output = outputConsumer.getOutput(); String ccText = null; if (output != null && output.size() > 0) { ccText = output.get(0).split(":")[1].trim(); if (ccText != null && !ccText.equalsIgnoreCase("")) { TextBox pptCopyRightText = new TextBox(); pptCopyRightText.setText(ccText); pptCopyRightText.setAnchor(new Rectangle(25, 587, 370, 13)); RichTextRun rtr = pptCopyRightText.getTextRun().getRichTextRuns()[0]; rtr.setFontSize(11); slide.addShape(pptCopyRightText); } } else { log.warn("Copyright information is not available for this image"); } } /** * Write a *.ppt file representing this object to the stream. The stream is not closed by this method; it is the * invoker's responsibility to close the stream. * * @param buffer a ready stream * @throws IOException */ public void write(OutputStream buffer) throws IOException, IM4JavaException, InterruptedException { SlideShow show = convert(); show.write(buffer); } private void putTitleInSlide(Slide slide) { TextBox pptTitle = slide.addTitle(); pptTitle.setAnchor(new Rectangle(28, 22, 737, 36)); setRichText(pptTitle, title, AmbraStyle.TITLE); } private TextBox buildCitationBox(SlideShow slideShow) { TextBox pptCitationText = new TextBox(); String linkText = citationLink.toString(); pptCitationText.setAnchor(new Rectangle(35, 513, 723, 26)); String text = citation + '\r' + linkText; // '\r' for a line break within the paragraph, preserving paragraph style setRichText(pptCitationText, text, AmbraStyle.CITATION); text = pptCitationText.getText(); // update with actual display text (no rich formatting tags) Hyperlink link = new Hyperlink(); link.setAddress(linkText); link.setTitle("click to visit the article page"); int linkId = slideShow.addHyperlink(link); int startIndex = text.indexOf(linkText); pptCitationText.setHyperlink(linkId, startIndex, startIndex + linkText.length()); return pptCitationText; } private void putJournalLogoInSlide(Slide slide) throws IOException { File logoFile = new File(logoPath); if (logoFile.exists()) { InputStream input = null; Dimension dimension; try { input = new FileInputStream(logoFile); dimension = getImageDimension(input); } finally { if (input != null) { input.close(); } } int logoIdx = slide.getSlideShow().addPicture(logoFile, Picture.PNG); Picture logo = new Picture(logoIdx); logo.setAnchor(new Rectangle(SLIDE_WIDTH - LOGO_MARGIN - dimension.width, SLIDE_HEIGHT - LOGO_MARGIN - dimension.height, dimension.width, dimension.height)); slide.addShape(logo); } else { log.warn("Logo for journal " + journalName + " not found at " + logoPath); } } /** * set the dimension of picture box * * @param slideShow * @return * @throws IOException */ private Picture setPictureBox(SlideShow slideShow) throws IOException { int index = slideShow.addPicture(logoImage, Picture.PNG); InputStream input = new ByteArrayInputStream(logoImage); Dimension dimension = getImageDimension(input); input.close(); //get the image size int imW = dimension.width; int imH = dimension.height; //add the image to picture and add picture to shape Picture picture = new Picture(index); // Image box size 750x432 at xy=21,68 if (imW > 0 && imH > 0) { double pgRatio = 750.0 / 432.0; double imRatio = (double) imW / (double) imH; if (pgRatio >= imRatio) { // horizontal center int mw = (int) ((double) imW * 432.0 / (double) imH); int mx = 21 + (750 - mw) / 2; picture.setAnchor(new Rectangle(mx, 68, mw, 432)); } else { // vertical center int mh = (int) ((double) imH * 750.0 / (double) imW); int my = 68 + (432 - mh) / 2; picture.setAnchor(new Rectangle(21, my, 750, mh)); } } return picture; } /** * get the image dimension * * @param input * @return */ private static Dimension getImageDimension(InputStream input) { try { ImageInputStream in = ImageIO.createImageInputStream(input); try { Iterator readers = ImageIO.getImageReaders(in); if (readers.hasNext()) { ImageReader reader = (ImageReader) readers.next(); try { reader.setInput(in); return new Dimension(reader.getWidth(0), reader.getHeight(0)); } finally { reader.dispose(); } } } finally { if (in != null) in.close(); } } catch (Exception ex) { log.error("cannot get image dimension", ex); } return new Dimension(0, 0); } /* * Very narrow regex for matching the formatting tags expected to be in valid titles (which are specified at * <http://dtd.nlm.nih.gov/publishing/3.0/format3.ent>). Does NOT gracefully handle all XML syntax. In particular, * empty-element tags (for example, <br/>) are matched like opening tags (would be interpreted as <br>). * * Group 1 is "/" if the tag is closing and "" if it is opening. Group 2 is the tag name. Group 3 captures the * attributes if any, which are ignored. (Most formatting tags have no attributes, but the spec permits an "arrange" * attribute on <sup> and <sub>.) */ private static final Pattern TAG_PATTERN = Pattern.compile("<(/?)([^<>]*?)(\\s+[^<>]*)?\\s*>"); /** * Convert XML-formatted rich text and set it inside the text run. The text run's previous contents are overwritten. * * @param box the HSLF object that will receive the formatted text * @param text the text to format, with rich-text XML tags * @param globalStyle the font style to apply to the entire text run (underneath any tag formatting) */ private static void setRichText(TextBox box, String text, RichTextModifier globalStyle) { TextRun textRun = box.getTextRun(); Deque<RichTextModifier> tagStack = new ArrayDeque<RichTextModifier>(2); int cursor = 0; int length = text.length(); boolean textHasBeenOverwritten = false; while (cursor < length) { Matcher m = TAG_PATTERN.matcher(text); boolean tagFound = m.find(cursor); // Put everything up to the tag (or, if no tag, the text's end) into the output int chunkEnd = tagFound ? m.start() : length; String chunk = text.substring(cursor, chunkEnd); if (!chunk.isEmpty()) { RichTextRun richText; if (!textHasBeenOverwritten) { /* * Blank out the preexisting text with the first non-empty chunk of new text. Calling textRun.setText("") * before entering the loop seems cleaner but messes with the TextRun's internal RichTextRun objects, so use * this workaround. */ box.setText(chunk); richText = textRun.getRichTextRunAt(0); textHasBeenOverwritten = true; } else { richText = textRun.appendText(chunk); } // Apply the stack's pre-tag styles to the chunk globalStyle.modify(richText); Iterator<RichTextModifier> stackStyles = tagStack.descendingIterator(); while (stackStyles.hasNext()) { stackStyles.next().modify(richText); } } // Process the tag in order to modify the stack for the next chunk if (tagFound) { String tagName = m.group(2); boolean isOpeningTag = m.group(1).isEmpty(); readTag(tagStack, tagName, isOpeningTag); } // Iterate to next tag cursor = tagFound ? m.end() : length; } if (!tagStack.isEmpty()) { StringBuilder warning = new StringBuilder("Unclosed tags: "); for (RichTextModifier tag : tagStack) { warning.append('<').append(tag.getTag()).append("> "); } log.warn(warning.toString()); } if (textRun.getRichTextRuns().length > 1) { /* * This is a kludge to cover up a bug when an exported file is opened in LibreOffice. If there is more than one * RichTextRun, the text of the last one is repeated several times (once for each RichTextRun). By making that * text a newline, we prevent it from changing the visual appearance of the output. A Microsoft Office user will * see only one extra newline (and only then if they click the text box to edit it). */ RichTextRun dummyTerminator = textRun.appendText("\n"); globalStyle.modify(dummyTerminator); } } /** * Read a rich text formatting tag and modify the stack of open tags appropriately. An opening tag will be matched to * a rich text style pushed onto the stack. A closing tag is expected to match the tag from the top of the stack; it * will pop the stack if it does. In case of an invalid tag, log a warning but continue. * * @param tagStack the stack of tags that are open in the current state; open tags will be pushed onto the stack and * closed tags will be used to pop a tag from the top * @param tagName the element name * @param openingTag {@code true} if the tag is opening; {@code false} if it is closing */ private static void readTag(Deque<RichTextModifier> tagStack, String tagName, boolean openingTag) { if (openingTag) { RichTextModifier modifier = NlmTag.TAGS.get(tagName); if (modifier != null) { tagStack.push(modifier); } else { log.warn("Unrecognized formatting tag: <" + tagName + '>'); tagStack.push(new NullTag(tagName)); // Put in a dummy so it can be closed later } } else if (tagStack.isEmpty()) { log.warn("Imbalanced closing tag: </" + tagName + '>'); } else { RichTextModifier balancing = tagStack.peek(); if (tagName.equals(balancing.getTag())) { tagStack.pop(); } else { log.warn("Mismatched closing tag: </" + tagName + "> (expected </" + balancing.getTag() + ">)"); } } } /** * A formatting style to apply to a RichTextRun. */ private static interface RichTextModifier { /** * @return the XML tag label that means we should apply this style to the contents (may be {@code null} if this * object will be used only as a {@code globalStyle} for {@link #setRichText}) */ public abstract String getTag(); /** * Modify the run to match the formatting style represented by this object. * * @param rtr the text chunk to modify */ public abstract void modify(RichTextRun rtr); } /** * Does nothing. For degrading gracefully in case of an unrecognized tag. */ private static class NullTag implements RichTextModifier { private final String tag; public NullTag(String tag) { this.tag = tag; } @Override public String getTag() { return tag; } @Override public void modify(RichTextRun rtr) { } } private static enum AmbraStyle implements RichTextModifier { TITLE { @Override public void modify(RichTextRun rt) { rt.setFontSize(16); rt.setBold(true); rt.setAlignment(TextBox.AlignCenter); } }, CITATION { @Override public void modify(RichTextRun rtr) { rtr.setFontSize(12); } }; @Override public String getTag() { return null; } } /** * Formatting styles to go with markup specified by <http://dtd.nlm.nih.gov/publishing/3.0/format3.ent> */ private static enum NlmTag implements RichTextModifier { BOLD("bold") { @Override public void modify(RichTextRun rtr) { rtr.setBold(true); } }, ITALIC("italic") { @Override public void modify(RichTextRun rtr) { rtr.setItalic(true); } }, STRIKETHROUGH("strike") { @Override public void modify(RichTextRun rtr) { rtr.setStrikethrough(true); } }, SUBSCRIPT("sub") { @Override public void modify(RichTextRun rtr) { rtr.setSuperscript(-SCRIPT_PROPORTION); } }, SUPERSCRIPT("sup") { @Override public void modify(RichTextRun rtr) { rtr.setSuperscript(SCRIPT_PROPORTION); } }, UNDERLINE("underline") { @Override public void modify(RichTextRun rtr) { rtr.setUnderlined(true); } }; /* * Tags specified for NLM format but not supported here: * monospace, roman, sans-serif, sc, overline */ public static final Map<String, RichTextModifier> TAGS; static { Map<String, RichTextModifier> tags = new HashMap<String, RichTextModifier>((int) (values().length / 0.75) + 1); for (RichTextModifier tag : values()) { tags.put(tag.getTag(), tag); } TAGS = Collections.unmodifiableMap(tags); } /** * Percentage of font size for superscripts and subscripts to appear, expressed from 0 to 100. * * @see org.apache.poi.hslf.usermodel.RichTextRun#setSuperscript(int) */ private static final int SCRIPT_PROPORTION = 33; private final String tag; private NlmTag(String tag) { this.tag = tag; } @Override public String getTag() { return tag; } @Override public String toString() { return '<' + tag + '>'; } } }