package org.eclipse.ui.views.pdf; import java.awt.geom.Rectangle2D; import java.awt.image.BufferedImage; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.Status; import org.eclipse.core.runtime.URIUtil; import org.eclipse.core.runtime.jobs.Job; import org.eclipse.jface.util.Util; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.ScrolledComposite; import org.eclipse.swt.events.PaintEvent; import org.eclipse.swt.events.PaintListener; import org.eclipse.swt.graphics.Image; import org.eclipse.swt.graphics.Point; import org.eclipse.swt.graphics.Rectangle; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.util.ImageUtils; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Label; import org.eclipse.ui.views.pdf.PdfViewToolbarManager.FitToAction; import org.jpedal.PdfDecoderFX; import org.jpedal.exception.PdfException; import org.jpedal.objects.PdfPageData; import org.jpedal.objects.acroforms.AcroRenderer; import org.jpedal.objects.raw.FormObject; import org.jpedal.objects.raw.PdfArrayIterator; import org.jpedal.objects.raw.PdfDictionary; import org.jpedal.objects.raw.PdfObject; public class PdfViewPage extends ScrolledComposite { public PdfViewPage(Composite parent, IFile file) throws PdfException { super(parent, SWT.H_SCROLL | SWT.V_SCROLL); pdfDisplay = new Composite(this, SWT.NONE); pdfDisplay.setBackgroundMode(SWT.INHERIT_FORCE); if(pdfDecoder==null){ pdfDisplay.setLayout(new GridLayout()); Label errorLabel = new Label(pdfDisplay, SWT.CENTER); errorLabel.setLayoutData(new GridData(SWT.CENTER, SWT.CENTER, true, true)); errorLabel.setText(Activator.MISSING_JVM_ARGUMENT_ERROR); pdfDisplay.pack(true); }else{ getHorizontalBar().setIncrement(getHorizontalBar().getIncrement() * 4); getVerticalBar().setIncrement(getVerticalBar().getIncrement() * 4); pdfDisplay.addPaintListener(new HyperlinkHighlightPaintListener()); setFile(file); } setShowFocusedControl(true); setContent(pdfDisplay); PdfViewScrollHandler.fixNegativeOriginMouseScrollBug(this); } // Rendering /** * The control displaying the current page of the PDF file. */ private final Composite pdfDisplay; /** * The PDF engine which renders the pages. */ private final PdfDecoderFX pdfDecoder = createDecoder(); private PdfDecoderFX createDecoder(){ try { return new PdfDecoderFX(); } catch (NoClassDefFoundError e) { return null; } } private final RenderJob renderJob=new RenderJob(); private static final boolean IS_MAC=Util.isMac(); private class RenderJob extends Job{ private BufferedImage pageAsImage; public RenderJob() { super("Rendering PDF page"); } public void obtainImage(){ if(IS_MAC){ Activator.initializeToolkit(); } pdfDecoder.setPageParameters(getZoom(), getPage()); try { pageAsImage=pdfDecoder.getPageAsImage(getPage()); } catch (PdfException e) { Activator.logError("Can't render PDF page", e); pageAsImage=null; } } @Override protected IStatus run(IProgressMonitor monitor) { if(monitor.isCanceled()||pageAsImage==null){ return Status.CANCEL_STATUS; } final BufferedImage awtImage=pageAsImage; final Image swtImage = new Image(Display.getDefault(), ImageUtils.convertBufferedImageToImageData(awtImage)); Display.getDefault().syncExec(new Runnable() { @Override public void run() { if(pdfDisplay.isDisposed()){ return; } Image oldImage = pdfDisplay.getBackgroundImage(); if (oldImage != null) { oldImage.dispose(); } pdfDisplay.setBackgroundImage(swtImage); int width = awtImage.getWidth(); int height = awtImage.getHeight(); pdfDisplay.setSize(width, height); align(); refreshToolbar(); } }); if(!monitor.isCanceled()){ loadAnnotationsJob.schedule(); } return monitor.isCanceled()?Status.CANCEL_STATUS:Status.OK_STATUS; } } @Override public boolean setFocus() { //prevent setting focus to child element (pdf annotation) causing accidental scrolling //copied from Control#setFocus checkWidget (); if ((getStyle() & SWT.NO_FOCUS) != 0) return false; return forceFocus (); }; @Override public void redraw() { if (isFileOpen()) { renderJob.cancel(); loadAnnotationsJob.cancel(); createHyperlinksJob.cancel(); waitForJob(loadAnnotationsJob); //waiting for renderJob is not necessary - done by loadAnnotationsJob renderJob.obtainImage(); renderJob.schedule(); waitForJob(renderJob); createHyperlinks(); } } private void align() { Rectangle clientArea = getClientArea(); Point size = pdfDisplay.getSize(); Point location = pdfDisplay.getLocation(); int left = location.x < 0 ? location.x : Math.max(0, clientArea.width / 2 - size.x / 2); int top = location.y < 0 ? location.y : Math.max(0, clientArea.height / 2 - size.y / 2); pdfDisplay.setLocation(left, top); } @Override public void setBounds(Rectangle rect) { super.setBounds(rect); align(); } @Override public void setOrigin(Point origin) { super.setOrigin(origin); align(); } // File handling /** * The open PDF file. */ private IFile file; public IFile getFile() { return file; } private String getFileName(){ return getFile().getFullPath().toOSString(); } public void setFile(IFile file) throws PdfException { if(pdfDecoder==null){ return; } pdfDecoder.openPdfFile(file.getLocation().toOSString()); int pageToSet=1; if (file.equals(this.file)) { pageToSet=getPage(); } else { this.file = file; } resetAnnotationsJob.schedule(); waitForJob(resetAnnotationsJob); setPage(pageToSet); } public void reload() throws PdfException { setFile(getFile()); } public boolean isFileOpen() { return pdfDecoder.isOpen(); } public void closeFile() { if(pdfDecoder!=null){ renderJob.cancel(); waitForJob(renderJob); loadAnnotationsJob.cancel(); waitForJob(loadAnnotationsJob); createHyperlinksJob.cancel(); waitForJob(createHyperlinksJob); pdfDecoder.closePdfFile(); } pdfDisplay.dispose(); this.dispose(); } // Navigation /** * The number of the currently viewed page, 1-based. */ private int page = 1; public int getPage() { return page; } public void setPage(int page) { if (page > getPageCount()) { this.page = getPageCount(); } else if (page < 1) { this.page = 1; } else { this.page = page; } redraw(); } /** * Returns the number of pages in the PDF file. */ public int getPageCount() { return pdfDecoder.getPageCount(); } /** * Checks whether the page with the given number exists. */ public boolean isPageValid(int page) { return ((page >= 1) && (page <= getPageCount())); } // Page info /** * Returns the real, zoom-independent width of the current page in PostScript * points. */ public int getPageWidth() { return getPageDimension(false); } /** * Returns the real, zoom-independent height of the current page in PostScript * points. */ public int getPageHeight() { return getPageDimension(true); } private int getPageDimension(boolean height) { PdfPageData pageData = pdfDecoder.getPdfPageData(); int page = getPage(); int rotation = getPageRotation(); if ((rotation == 90) || (rotation == 270)) { return height ? pageData.getMediaBoxWidth(page) : pageData.getMediaBoxHeight(page); } else { return height ? pageData.getMediaBoxHeight(page) : pageData.getMediaBoxWidth(page); } } /** * Returns the rotation of the page in degrees. */ public int getPageRotation() { return pdfDecoder.getPdfPageData().getRotation(getPage()); } // Zoom /** * The current zoom factor. */ private float zoom = 1; public float getZoom() { return zoom; } public void setZoom(float zoom) { if (isZoomValid(zoom) && (zoom != getZoom())) { this.zoom = zoom; redraw(); } } public static final float MIN_SIZE = 16; /** * Checks whether the given zoom factor is in a sensible range. */ public boolean isZoomValid(float zoom) { Rectangle screenSize = Display.getDefault().getBounds(); float newWidth = getPageWidth() * zoom; float newHeight = getPageHeight() * zoom; boolean tooBig = newWidth > screenSize.width; boolean tooSmall = (newWidth < MIN_SIZE) || (newHeight < MIN_SIZE); return !(tooBig || tooSmall); } /** * The currently selected special zoom setting. */ private FitToAction fitToAction; public FitToAction getFitToAction() { return fitToAction; } public void setFitToAction(FitToAction fitToAction) { this.fitToAction = fitToAction; } // Toolbar /** * Manages the contributions to the toolbar. */ private PdfViewToolbarManager toolbar; public PdfViewToolbarManager getToolbar() { return toolbar; } public void setToolbar(PdfViewToolbarManager toolbar) { this.toolbar = toolbar; } private void refreshToolbar() { if (getToolbar() != null) { getToolbar().refresh(); } } // Annotations // The intended annotation loading and hyperlink creation lifecycle is as follows. // Load all annotations when the score is first opened or the pdf file changes. // Create hyperlinks for the currently visible page - as quickly as possible. // resetAnnotationsJob: clear the annotations on reload due to file change (noop on initally opening the file) // loadAnnotationsJob: loads annotations one page per run, reschedules itself for the next page // createHyperlinkJob: transforms annotations to hyperlinks for the current page; if annotations are not yet loaded // the page is marked as to be loaded by the next running loadAnnotationsJob /** * Map of page numer to hyperlink annotations on that page in the PDF file. */ private final Map<Integer, List<PdfAnnotation>> annotations = new HashMap<Integer, List<PdfAnnotation>>(); private Integer pageWithPriorityToLoad=null; public PdfAnnotation[] getAnnotationsOnPage(int page) { List<PdfAnnotation> loadedAnnotations=annotations.get(page); if(loadedAnnotations==null){ return new PdfAnnotation[0]; }else{ return loadedAnnotations.toArray(new PdfAnnotation[0]); } } private final Job resetAnnotationsJob=new Job("Resetting point-and-click hyperlinks"){ @Override public IStatus run(IProgressMonitor monitor) { if(renderJob.getResult() != null){ renderJob.cancel(); loadAnnotationsJob.cancel(); waitForJob(loadAnnotationsJob); annotations.clear(); } return Status.OK_STATUS; } }; private static final Charset ISOCHARSET=Charset.forName("ISO-8859-1");//$NON-NLS-1$ private final Job loadAnnotationsJob = new Job("Loading annotations for point-and-click hyperlinks") { @Override public IStatus run(IProgressMonitor monitor) { waitForJob(renderJob); if(monitor.isCanceled()){ return Status.CANCEL_STATUS; } Integer page=getNextPageToLoad(); if(page==null){ return Status.OK_STATUS; } else if(monitor.isCanceled()){ return Status.CANCEL_STATUS; } List<PdfAnnotation> annotationsOnPage=getPossiblyIncompleteListOfAnnotationsForPage(page, monitor); if(monitor.isCanceled()){ return Status.CANCEL_STATUS; } annotations.put(page, annotationsOnPage); this.schedule(); return Status.OK_STATUS; } private Integer getNextPageToLoad(){ final Integer currentPriorityPage=pageWithPriorityToLoad; int pageCount=getPageCount(); if(currentPriorityPage!=null && !annotations.containsKey(currentPriorityPage) && currentPriorityPage<=pageCount){ return currentPriorityPage; }else{ for(int i=1;i<=pageCount; i++){ if(!annotations.containsKey(i)){ return i; } } } return null; } private void addRawObjectToPdfAnnotationList(Integer page, FormObject formObject, List<PdfAnnotation> list, Map<String, IFile> fileCache){ int subtype = formObject.getParameterConstant(PdfDictionary.Subtype); if (subtype == PdfDictionary.Link) { PdfObject anchor = formObject.getDictionary(PdfDictionary.A); try { byte[] uriDecodedBytes = anchor.getTextStreamValue(PdfDictionary.URI).getBytes(ISOCHARSET); URI uri = new URI(new String(uriDecodedBytes)); if (uri.getScheme().equals("textedit")) { //$NON-NLS-1$ String[] sections = uri.getPath().split(":"); //$NON-NLS-1$ String path = (uri.getAuthority() == null ? "" : uri.getAuthority()) + sections[0]; //$NON-NLS-1$ IFile targetFile=null; if(fileCache.containsKey(path)){ targetFile=fileCache.get(path); }else{ URL url = new URL("file", null, path); //$NON-NLS-1$ IFile[] files = ResourcesPlugin.getWorkspace().getRoot().findFilesForLocationURI(URIUtil.toURI(url)); if(files.length>0){ targetFile=files[0]; } fileCache.put(path, targetFile); } if (targetFile!=null) { PdfAnnotation annotation = new PdfAnnotation(); annotation.page = page; annotation.file = targetFile; annotation.lineNumber = Integer.parseInt(sections[1]) - 1; annotation.columnNumber = Integer.parseInt(sections[2]); // This value is independent of tab width float[] rectangle = formObject.getFloatArray(PdfDictionary.Rect); annotation.left = rectangle[0]; annotation.bottom = rectangle[1]; annotation.right = rectangle[2]; annotation.top = rectangle[3]; list.add(annotation); } } } catch (URISyntaxException e) { Activator.logError("Invalid annotation URI", e); } catch (ArrayIndexOutOfBoundsException e) { Activator.logError("Error while parsing annotation URI", e); } catch (MalformedURLException e) { Activator.logError("Can't transform URI to URL", e); } } } /** * the list is incomplete if the job was cancelled * */ private List<PdfAnnotation> getPossiblyIncompleteListOfAnnotationsForPage(Integer page, IProgressMonitor monitor){ AcroRenderer formRenderer = pdfDecoder.getFormRenderer(); monitor.setTaskName(getFileName()+" page "+page); List<PdfAnnotation> annotationsOnPage = new ArrayList<PdfAnnotation>(); //TODO This getter call accounts for 70-99% of the time spent in this method!! //Is there a later more performant jpedal version that can be used? //Can the currently used version be patched? PdfArrayIterator pdfAnnotations = formRenderer.getAnnotsOnPage(page); Map<String, IFile> fileCache=new HashMap<String, IFile>(); while (!monitor.isCanceled() && pdfAnnotations.hasMoreTokens()) { String key = pdfAnnotations.getNextValueAsString(true); FormObject rawObject = formRenderer.getFormObject(key); addRawObjectToPdfAnnotationList(page, rawObject, annotationsOnPage, fileCache); } return annotationsOnPage; } }; private static void waitForJob(Job job) { try { job.join(); } catch (InterruptedException e) { Activator.logError("Interrupted while waiting for job", e); } } // Hyperlinks /** * The annotation-to-hyperlink mappings. */ private final Map<PdfAnnotation, PdfAnnotationHyperlink> annotationHyperlinkMap = new HashMap<PdfAnnotation, PdfAnnotationHyperlink>(); private final Job createHyperlinksJob = new Job("Creating point-and-click hyperlinks") { @Override public IStatus run(final IProgressMonitor monitor) { if(pdfDecoder==null){ cancel(); } waitForJob(renderJob); if(monitor.isCanceled()){ return Status.CANCEL_STATUS; } disposeOldHyperlinks(); annotationHyperlinkMap.clear(); waitForPageAnnotationsToBeLoaded(monitor); if(monitor.isCanceled()){ return Status.CANCEL_STATUS; } PdfAnnotation[] annotationsOnPage = getAnnotationsOnPage(page); monitor.setTaskName(getFileName() + " page "+page); fillAnnotationHyperlinkMap(annotationsOnPage, monitor); return monitor.isCanceled()?Status.CANCEL_STATUS:Status.OK_STATUS; } private void disposeOldHyperlinks(){ Display.getDefault().syncExec(new Runnable() { @Override public void run() { if(!pdfDisplay.isDisposed()){ Control[] oldHyperlinks = pdfDisplay.getChildren(); for (Control oldHyperlink : oldHyperlinks) { oldHyperlink.dispose(); } } } }); } private void waitForPageAnnotationsToBeLoaded(IProgressMonitor monitor){ while(!annotations.containsKey(page)){ monitor.setTaskName("waiting for annotations to be loaded"); if(monitor.isCanceled()){ return; } pageWithPriorityToLoad=page; if(loadAnnotationsJob.getResult()==Status.CANCEL_STATUS || loadAnnotationsJob.getResult()==null){ loadAnnotationsJob.schedule(); } waitForJob(loadAnnotationsJob); } } private void fillAnnotationHyperlinkMap(final PdfAnnotation[] annotationsOnPage, final IProgressMonitor monitor){ Display.getDefault().syncExec(new Runnable() { @Override public void run() { for (final PdfAnnotation annotation : annotationsOnPage) { if (monitor.isCanceled()) { return; } if(!pdfDisplay.isDisposed()){ PdfAnnotationHyperlink hyperlink = new PdfAnnotationHyperlink(pdfDisplay, annotation); annotationHyperlinkMap.put(annotation, hyperlink); float zoom = getZoom(); float left = annotation.left * zoom; float right = annotation.right * zoom; float width = Math.abs(right - left); float top = annotation.top * zoom; float bottom = annotation.bottom * zoom; float height = Math.abs(bottom - top); Rectangle2D.Float bounds = new Rectangle2D.Float(left, top, width, height); float pageWidth = getPageWidth() * zoom; float pageHeight = getPageHeight() * zoom; transform(bounds, getPageRotation(), pageWidth, pageHeight); hyperlink.setBounds((int)bounds.x, (int)bounds.y, (int)bounds.width, (int)bounds.height); } } } }); } }; /** * Creates point-and-click hyperlinks from the hyperlink annotations on the * current page. */ protected void createHyperlinks() { createHyperlinksJob.cancel(); createHyperlinksJob.schedule(); } private void transform(Rectangle2D.Float rectangle, int rotation, float pageWidth, float pageHeight) { float x = rectangle.x; float y = rectangle.y; float width = rectangle.width; float height = rectangle.height; switch (rotation) { case 0: rectangle.y = pageHeight - y; break; case 90: rectangle.x = y - height; rectangle.y = x - width; rectangle.width = height; rectangle.height = width; break; case 180: rectangle.x = pageWidth - x - width; break; case 270: rectangle.x = pageHeight - y; rectangle.y = x - width; rectangle.width = height; rectangle.height = width; break; } } // Hyperlink highlighting // TODO extract /** * The currently highlighted hyperlink. */ private PdfAnnotationHyperlink highlightedHyperlink; /** * The space between the highlighted hyperlink and its outline. */ private static final float HYPERLINK_HIGHLIGHT_PADDING = 3; /** * Reveals and highlights the hyperlink of the given annotation. */ public void highlightAnnotation(PdfAnnotation annotation) { setPage(annotation.page); waitForJob(renderJob); waitForJob(createHyperlinksJob); PdfAnnotationHyperlink hyperlink = annotationHyperlinkMap.get(annotation); if (hyperlink != null) { highlightedHyperlink = hyperlink; hyperlink.setFocus(); hyperlinkHighlightAnimator.start(); } } private static int hyperlinkHighlightAlpha; private static float hyperlinkHighlightPaddingScale; private class HyperlinkHighlightPaintListener implements PaintListener { @Override public void paintControl(PaintEvent e) { if ((highlightedHyperlink != null) && !highlightedHyperlink.isDisposed()) { e.gc.setForeground(Display.getDefault().getSystemColor(SWT.COLOR_BLUE)); e.gc.setLineWidth(2); Rectangle bounds = highlightedHyperlink.getBounds(); float padding = HYPERLINK_HIGHLIGHT_PADDING * getZoom() * hyperlinkHighlightPaddingScale; float x = bounds.x - padding; float y = bounds.y - padding; float width = bounds.width + 2 * padding; float height = bounds.height + 2 * padding; e.gc.setAlpha(hyperlinkHighlightAlpha); e.gc.drawRoundRectangle((int)x, (int)y, (int)width, (int)height, (int)padding, (int)padding); } } } private enum HyperlinkHighlightAnimatorState { FADE_IN { private final int MAX_ALPHA = 255; private final int ALPHA_STEP = 24; private final float INITIAL_PADDING_SCALE = 5; @Override public void init() { hyperlinkHighlightAlpha = 0; hyperlinkHighlightPaddingScale = INITIAL_PADDING_SCALE; } @Override public void step() { hyperlinkHighlightAlpha = Math.min(MAX_ALPHA, hyperlinkHighlightAlpha + ALPHA_STEP); hyperlinkHighlightPaddingScale -= (INITIAL_PADDING_SCALE - 1) / (MAX_ALPHA / ALPHA_STEP); } @Override public boolean isReady() { return hyperlinkHighlightAlpha >= MAX_ALPHA; } }, WAIT { private int delay; @Override public void init() { delay = 192; } @Override public void step() { delay--; } @Override public boolean isReady() { return delay == 0; } }, FADE_OUT { private final int ALPHA_STEP = 2; @Override public void init() { } @Override public void step() { hyperlinkHighlightAlpha = Math.max(0, hyperlinkHighlightAlpha - ALPHA_STEP); } @Override public boolean isReady() { return hyperlinkHighlightAlpha == 0; } }; public abstract void init(); public abstract void step(); public abstract boolean isReady(); } private final HyperlinkHighlightAnimator hyperlinkHighlightAnimator = new HyperlinkHighlightAnimator(); private class HyperlinkHighlightAnimator implements Runnable { private static final int INTERVAL = 10; private int stateIndex; private final HyperlinkHighlightAnimatorState[] states = HyperlinkHighlightAnimatorState.values(); public void start() { stateIndex = 0; initState(); Display.getDefault().timerExec(0, this); } private void initState() { states[stateIndex].init(); } @Override public void run() { HyperlinkHighlightAnimatorState state = states[stateIndex]; if (!state.isReady()) { state.step(); pdfDisplay.redraw(); } else { if (stateIndex < states.length - 1) { stateIndex++; initState(); } else { highlightedHyperlink = null; return; } } Display.getDefault().timerExec(INTERVAL, this); } } }