001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.layer.geoimage;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Color;
007import java.awt.Dimension;
008import java.awt.FontMetrics;
009import java.awt.Graphics;
010import java.awt.Graphics2D;
011import java.awt.Image;
012import java.awt.MediaTracker;
013import java.awt.Point;
014import java.awt.Rectangle;
015import java.awt.RenderingHints;
016import java.awt.Toolkit;
017import java.awt.event.MouseEvent;
018import java.awt.event.MouseListener;
019import java.awt.event.MouseMotionListener;
020import java.awt.event.MouseWheelEvent;
021import java.awt.event.MouseWheelListener;
022import java.awt.geom.AffineTransform;
023import java.awt.geom.Rectangle2D;
024import java.awt.image.BufferedImage;
025import java.awt.image.ImageObserver;
026import java.io.File;
027
028import javax.swing.JComponent;
029import javax.swing.SwingUtilities;
030
031import org.openstreetmap.josm.data.preferences.BooleanProperty;
032import org.openstreetmap.josm.data.preferences.DoubleProperty;
033import org.openstreetmap.josm.spi.preferences.Config;
034import org.openstreetmap.josm.spi.preferences.PreferenceChangeEvent;
035import org.openstreetmap.josm.spi.preferences.PreferenceChangedListener;
036import org.openstreetmap.josm.tools.Destroyable;
037import org.openstreetmap.josm.tools.ExifReader;
038import org.openstreetmap.josm.tools.ImageProvider;
039import org.openstreetmap.josm.tools.Logging;
040
041/**
042 * GUI component to display an image (photograph).
043 *
044 * Offers basic mouse interaction (zoom, drag) and on-screen text.
045 */
046public class ImageDisplay extends JComponent implements Destroyable, PreferenceChangedListener {
047
048    /** The file that is currently displayed */
049    private ImageEntry entry;
050
051    /** The image currently displayed */
052    private transient Image image;
053
054    /** The image currently displayed */
055    private boolean errorLoading;
056
057    /** The rectangle (in image coordinates) of the image that is visible. This rectangle is calculated
058     * each time the zoom is modified */
059    private VisRect visibleRect;
060
061    /** When a selection is done, the rectangle of the selection (in image coordinates) */
062    private VisRect selectedRect;
063
064    /** The tracker to load the images */
065    private final MediaTracker tracker = new MediaTracker(this);
066
067    private final ImgDisplayMouseListener imgMouseListener = new ImgDisplayMouseListener();
068
069    private String emptyText;
070    private String osdText;
071
072    private static final BooleanProperty AGPIFO_STYLE =
073        new BooleanProperty("geoimage.agpifo-style-drag-and-zoom", false);
074    private static int dragButton;
075    private static int zoomButton;
076
077    /** Alternative to mouse wheel zoom; esp. handy if no mouse wheel is present **/
078    private static final BooleanProperty ZOOM_ON_CLICK =
079        new BooleanProperty("geoimage.use-mouse-clicks-to-zoom", true);
080
081    /** Zoom factor when click or wheel zooming **/
082    private static final DoubleProperty ZOOM_STEP =
083        new DoubleProperty("geoimage.zoom-step-factor", 3 / 2.0);
084
085    /** Maximum zoom allowed **/
086    private static final DoubleProperty MAX_ZOOM =
087        new DoubleProperty("geoimage.maximum-zoom-scale", 2.0);
088
089    /** Use bilinear filtering **/
090    private static final BooleanProperty BILIN_DOWNSAMP =
091        new BooleanProperty("geoimage.bilinear-downsampling-progressive", true);
092    private static final BooleanProperty BILIN_UPSAMP =
093        new BooleanProperty("geoimage.bilinear-upsampling", false);
094    private static double bilinUpper;
095    private static double bilinLower;
096
097    @Override
098    public void preferenceChanged(PreferenceChangeEvent e) {
099        if (e == null ||
100            e.getKey().equals(AGPIFO_STYLE.getKey())) {
101            dragButton = AGPIFO_STYLE.get() ? 1 : 3;
102            zoomButton = dragButton == 1 ? 3 : 1;
103        }
104        if (e == null ||
105            e.getKey().equals(MAX_ZOOM.getKey()) ||
106            e.getKey().equals(BILIN_DOWNSAMP.getKey()) ||
107            e.getKey().equals(BILIN_UPSAMP.getKey())) {
108            bilinUpper = (BILIN_UPSAMP.get() ? 2*MAX_ZOOM.get() : (BILIN_DOWNSAMP.get() ? 0.5 : 0));
109            bilinLower = (BILIN_DOWNSAMP.get() ? 0 : 1);
110        }
111    }
112
113    /**
114     * Manage the visible rectangle of an image with full bounds stored in init.
115     * @since 13127
116     */
117    public static class VisRect extends Rectangle {
118        private final Rectangle init;
119
120        /** set when this {@code VisRect} is updated by a mouse drag operation and
121         * unset on mouse release **/
122        public boolean isDragUpdate;
123
124        /**
125         * Constructs a new {@code VisRect}.
126         * @param     x the specified X coordinate
127         * @param     y the specified Y coordinate
128         * @param     width  the width of the rectangle
129         * @param     height the height of the rectangle
130         */
131        public VisRect(int x, int y, int width, int height) {
132            super(x, y, width, height);
133            init = new Rectangle(this);
134        }
135
136        /**
137         * Constructs a new {@code VisRect}.
138         * @param     x the specified X coordinate
139         * @param     y the specified Y coordinate
140         * @param     width  the width of the rectangle
141         * @param     height the height of the rectangle
142         * @param     peer share full bounds with this peer {@code VisRect}
143         */
144        public VisRect(int x, int y, int width, int height, VisRect peer) {
145            super(x, y, width, height);
146            init = peer.init;
147        }
148
149        /**
150         * Constructs a new {@code VisRect} from another one.
151         * @param v rectangle to copy
152         */
153        public VisRect(VisRect v) {
154            super(v);
155            init = v.init;
156        }
157
158        /**
159         * Constructs a new empty {@code VisRect}.
160         */
161        public VisRect() {
162            this(0, 0, 0, 0);
163        }
164
165        public boolean isFullView() {
166            return init.equals(this);
167        }
168
169        public boolean isFullView1D() {
170            return (init.x == x && init.width == width)
171                || (init.y == y && init.height == height);
172        }
173
174        public void reset() {
175            setBounds(init);
176        }
177
178        public void checkRectPos() {
179            if (x < 0) {
180                x = 0;
181            }
182            if (y < 0) {
183                y = 0;
184            }
185            if (x + width > init.width) {
186                x = init.width - width;
187            }
188            if (y + height > init.height) {
189                y = init.height - height;
190            }
191        }
192
193        public void checkRectSize() {
194            if (width > init.width) {
195                width = init.width;
196            }
197            if (height > init.height) {
198                height = init.height;
199            }
200        }
201
202        public void checkPointInside(Point p) {
203            if (p.x < x) {
204                p.x = x;
205            }
206            if (p.x > x + width) {
207                p.x = x + width;
208            }
209            if (p.y < y) {
210                p.y = y;
211            }
212            if (p.y > y + height) {
213                p.y = y + height;
214            }
215        }
216    }
217
218    /** The thread that reads the images. */
219    private class LoadImageRunnable implements Runnable, ImageObserver {
220
221        private final ImageEntry entry;
222        private final File file;
223
224        LoadImageRunnable(ImageEntry entry) {
225            this.entry = entry;
226            this.file = entry.getFile();
227        }
228
229        @Override
230        public boolean imageUpdate(Image img, int infoflags, int x, int y, int width, int height) {
231            if (((infoflags & ImageObserver.WIDTH) == ImageObserver.WIDTH) &&
232                ((infoflags & ImageObserver.HEIGHT) == ImageObserver.HEIGHT)) {
233                synchronized (entry) {
234                    entry.setWidth(width);
235                    entry.setHeight(height);
236                    entry.notifyAll();
237                    return false;
238                }
239            }
240            return true;
241        }
242
243        private boolean updateImageEntry(Image img) {
244            if (!(entry.getWidth() > 0 && entry.getHeight() > 0)) {
245                synchronized (entry) {
246                    img.getWidth(this);
247                    img.getHeight(this);
248
249                    long now = System.currentTimeMillis();
250                    while (!(entry.getWidth() > 0 && entry.getHeight() > 0)) {
251                        try {
252                            entry.wait(1000);
253                            if (this.entry != ImageDisplay.this.entry)
254                                return false;
255                            if (System.currentTimeMillis() - now > 10000)
256                                synchronized (ImageDisplay.this) {
257                                    errorLoading = true;
258                                    ImageDisplay.this.repaint();
259                                    return false;
260                                }
261                        } catch (InterruptedException e) {
262                            Logging.trace(e);
263                            Logging.warn("InterruptedException in {0} while getting properties of image {1}",
264                                    getClass().getSimpleName(), file.getPath());
265                            Thread.currentThread().interrupt();
266                        }
267                    }
268                }
269            }
270            return true;
271        }
272
273        private boolean mayFitMemory(long amountWanted) {
274            return amountWanted < (
275                   Runtime.getRuntime().maxMemory() -
276                   Runtime.getRuntime().totalMemory() +
277                   Runtime.getRuntime().freeMemory());
278        }
279
280        @Override
281        public void run() {
282            Image img = Toolkit.getDefaultToolkit().createImage(file.getPath());
283            if (!updateImageEntry(img))
284                return;
285
286            int width = entry.getWidth();
287            int height = entry.getHeight();
288
289            if (mayFitMemory(((long) width)*height*4*2)) {
290                Logging.info("Loading {0} using default toolkit", file.getPath());
291                tracker.addImage(img, 1);
292
293                // Wait for the end of loading
294                while (!tracker.checkID(1, true)) {
295                    if (this.entry != ImageDisplay.this.entry) {
296                        // The file has changed
297                        tracker.removeImage(img);
298                        return;
299                    }
300                    try {
301                        Thread.sleep(5);
302                    } catch (InterruptedException e) {
303                        Logging.trace(e);
304                        Logging.warn("InterruptedException in {0} while loading image {1}",
305                                getClass().getSimpleName(), file.getPath());
306                        Thread.currentThread().interrupt();
307                    }
308                }
309                if (tracker.isErrorID(1)) {
310                    // the tracker catches OutOfMemory conditions
311                    tracker.removeImage(img);
312                    img = null;
313                } else {
314                    tracker.removeImage(img);
315                }
316            } else {
317                img = null;
318            }
319
320            synchronized (ImageDisplay.this) {
321                if (this.entry != ImageDisplay.this.entry) {
322                    // The file has changed
323                    return;
324                }
325
326                if (img != null) {
327                    boolean switchedDim = false;
328                    if (ExifReader.orientationNeedsCorrection(entry.getExifOrientation())) {
329                        if (ExifReader.orientationSwitchesDimensions(entry.getExifOrientation())) {
330                            width = img.getHeight(null);
331                            height = img.getWidth(null);
332                            switchedDim = true;
333                        }
334                        final BufferedImage rot = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
335                        final AffineTransform xform = ExifReader.getRestoreOrientationTransform(
336                                entry.getExifOrientation(),
337                                img.getWidth(null),
338                                img.getHeight(null));
339                        final Graphics2D g = rot.createGraphics();
340                        g.drawImage(img, xform, null);
341                        g.dispose();
342                        img = rot;
343                    }
344
345                    ImageDisplay.this.image = img;
346                    visibleRect = new VisRect(0, 0, width, height);
347
348                    Logging.info("Loaded {0} with dimensions {1}x{2} memoryTaken={3}m exifOrientationSwitchedDimension={4}",
349                            file.getPath(), width, height, width*height*4/1024/1024, switchedDim);
350                }
351
352                selectedRect = null;
353                errorLoading = (img == null);
354            }
355            ImageDisplay.this.repaint();
356        }
357    }
358
359    private class ImgDisplayMouseListener implements MouseListener, MouseWheelListener, MouseMotionListener {
360
361        private MouseEvent lastMouseEvent;
362        private Point mousePointInImg;
363
364        private boolean mouseIsDragging(MouseEvent e) {
365            return (dragButton == 1 && SwingUtilities.isLeftMouseButton(e)) ||
366                   (dragButton == 2 && SwingUtilities.isMiddleMouseButton(e)) ||
367                   (dragButton == 3 && SwingUtilities.isRightMouseButton(e));
368        }
369
370        private boolean mouseIsZoomSelecting(MouseEvent e) {
371            return (zoomButton == 1 && SwingUtilities.isLeftMouseButton(e)) ||
372                   (zoomButton == 2 && SwingUtilities.isMiddleMouseButton(e)) ||
373                   (zoomButton == 3 && SwingUtilities.isRightMouseButton(e));
374        }
375
376        private boolean isAtMaxZoom(Rectangle visibleRect) {
377            return (visibleRect.width == (int) (getSize().width / MAX_ZOOM.get()) ||
378                    visibleRect.height == (int) (getSize().height / MAX_ZOOM.get()));
379        }
380
381        private void mouseWheelMovedImpl(int x, int y, int rotation, boolean refreshMousePointInImg) {
382            ImageEntry entry;
383            Image image;
384            VisRect visibleRect;
385
386            synchronized (ImageDisplay.this) {
387                entry = ImageDisplay.this.entry;
388                image = ImageDisplay.this.image;
389                visibleRect = ImageDisplay.this.visibleRect;
390            }
391
392            selectedRect = null;
393
394            if (image == null)
395                return;
396
397            // Calculate the mouse cursor position in image coordinates to center the zoom.
398            if (refreshMousePointInImg)
399                mousePointInImg = comp2imgCoord(visibleRect, x, y, getSize());
400
401            // Apply the zoom to the visible rectangle in image coordinates
402            if (rotation > 0) {
403                visibleRect.width = (int) (visibleRect.width * ZOOM_STEP.get());
404                visibleRect.height = (int) (visibleRect.height * ZOOM_STEP.get());
405            } else {
406                visibleRect.width = (int) (visibleRect.width / ZOOM_STEP.get());
407                visibleRect.height = (int) (visibleRect.height / ZOOM_STEP.get());
408            }
409
410            // Check that the zoom doesn't exceed MAX_ZOOM:1
411            if (visibleRect.width < getSize().width / MAX_ZOOM.get()) {
412                visibleRect.width = (int) (getSize().width / MAX_ZOOM.get());
413            }
414            if (visibleRect.height < getSize().height / MAX_ZOOM.get()) {
415                visibleRect.height = (int) (getSize().height / MAX_ZOOM.get());
416            }
417
418            // Set the same ratio for the visible rectangle and the display area
419            int hFact = visibleRect.height * getSize().width;
420            int wFact = visibleRect.width * getSize().height;
421            if (hFact > wFact) {
422                visibleRect.width = hFact / getSize().height;
423            } else {
424                visibleRect.height = wFact / getSize().width;
425            }
426
427            // The size of the visible rectangle is limited by the image size.
428            visibleRect.checkRectSize();
429
430            // Set the position of the visible rectangle, so that the mouse cursor doesn't move on the image.
431            Rectangle drawRect = calculateDrawImageRectangle(visibleRect, getSize());
432            visibleRect.x = mousePointInImg.x + ((drawRect.x - x) * visibleRect.width) / drawRect.width;
433            visibleRect.y = mousePointInImg.y + ((drawRect.y - y) * visibleRect.height) / drawRect.height;
434
435            // The position is also limited by the image size
436            visibleRect.checkRectPos();
437
438            synchronized (ImageDisplay.this) {
439                if (ImageDisplay.this.entry == entry) {
440                    ImageDisplay.this.visibleRect = visibleRect;
441                }
442            }
443            ImageDisplay.this.repaint();
444        }
445
446        /** Zoom in and out, trying to preserve the point of the image that was under the mouse cursor
447         * at the same place */
448        @Override
449        public void mouseWheelMoved(MouseWheelEvent e) {
450            boolean refreshMousePointInImg = false;
451
452            // To avoid issues when the user tries to zoom in on the image borders, this
453            // point is not recalculated as long as e occurs at roughly the same position.
454            if (lastMouseEvent == null || mousePointInImg == null ||
455                ((lastMouseEvent.getX()-e.getX())*(lastMouseEvent.getX()-e.getX())
456                +(lastMouseEvent.getY()-e.getY())*(lastMouseEvent.getY()-e.getY()) > 4*4)) {
457                lastMouseEvent = e;
458                refreshMousePointInImg = true;
459            }
460
461            mouseWheelMovedImpl(e.getX(), e.getY(), e.getWheelRotation(), refreshMousePointInImg);
462        }
463
464        /** Center the display on the point that has been clicked */
465        @Override
466        public void mouseClicked(MouseEvent e) {
467            // Move the center to the clicked point.
468            ImageEntry entry;
469            Image image;
470            VisRect visibleRect;
471
472            synchronized (ImageDisplay.this) {
473                entry = ImageDisplay.this.entry;
474                image = ImageDisplay.this.image;
475                visibleRect = ImageDisplay.this.visibleRect;
476            }
477
478            if (image == null)
479                return;
480
481            if (ZOOM_ON_CLICK.get()) {
482                // click notions are less coherent than wheel, refresh mousePointInImg on each click
483                lastMouseEvent = null;
484
485                if (mouseIsZoomSelecting(e) && !isAtMaxZoom(visibleRect)) {
486                    // zoom in if clicked with the zoom button
487                    mouseWheelMovedImpl(e.getX(), e.getY(), -1, true);
488                    return;
489                }
490                if (mouseIsDragging(e)) {
491                    // zoom out if clicked with the drag button
492                    mouseWheelMovedImpl(e.getX(), e.getY(), 1, true);
493                    return;
494                }
495            }
496
497            // Calculate the translation to set the clicked point the center of the view.
498            Point click = comp2imgCoord(visibleRect, e.getX(), e.getY(), getSize());
499            Point center = getCenterImgCoord(visibleRect);
500
501            visibleRect.x += click.x - center.x;
502            visibleRect.y += click.y - center.y;
503
504            visibleRect.checkRectPos();
505
506            synchronized (ImageDisplay.this) {
507                if (ImageDisplay.this.entry == entry) {
508                    ImageDisplay.this.visibleRect = visibleRect;
509                }
510            }
511            ImageDisplay.this.repaint();
512        }
513
514        /** Initialize the dragging, either with button 1 (simple dragging) or button 3 (selection of
515         * a picture part) */
516        @Override
517        public void mousePressed(MouseEvent e) {
518            Image image;
519            VisRect visibleRect;
520
521            synchronized (ImageDisplay.this) {
522                image = ImageDisplay.this.image;
523                visibleRect = ImageDisplay.this.visibleRect;
524            }
525
526            if (image == null)
527                return;
528
529            selectedRect = null;
530
531            if (mouseIsDragging(e) || mouseIsZoomSelecting(e))
532                mousePointInImg = comp2imgCoord(visibleRect, e.getX(), e.getY(), getSize());
533        }
534
535        @Override
536        public void mouseDragged(MouseEvent e) {
537            if (!mouseIsDragging(e) && !mouseIsZoomSelecting(e))
538                return;
539
540            ImageEntry entry;
541            Image image;
542            VisRect visibleRect;
543
544            synchronized (ImageDisplay.this) {
545                entry = ImageDisplay.this.entry;
546                image = ImageDisplay.this.image;
547                visibleRect = ImageDisplay.this.visibleRect;
548            }
549
550            if (image == null)
551                return;
552
553            if (mouseIsDragging(e) && mousePointInImg != null) {
554                Point p = comp2imgCoord(visibleRect, e.getX(), e.getY(), getSize());
555                visibleRect.isDragUpdate = true;
556                visibleRect.x += mousePointInImg.x - p.x;
557                visibleRect.y += mousePointInImg.y - p.y;
558                visibleRect.checkRectPos();
559                synchronized (ImageDisplay.this) {
560                    if (ImageDisplay.this.entry == entry) {
561                        ImageDisplay.this.visibleRect = visibleRect;
562                    }
563                }
564                ImageDisplay.this.repaint();
565            }
566
567            if (mouseIsZoomSelecting(e) && mousePointInImg != null) {
568                Point p = comp2imgCoord(visibleRect, e.getX(), e.getY(), getSize());
569                visibleRect.checkPointInside(p);
570                VisRect selectedRect = new VisRect(
571                        p.x < mousePointInImg.x ? p.x : mousePointInImg.x,
572                        p.y < mousePointInImg.y ? p.y : mousePointInImg.y,
573                        p.x < mousePointInImg.x ? mousePointInImg.x - p.x : p.x - mousePointInImg.x,
574                        p.y < mousePointInImg.y ? mousePointInImg.y - p.y : p.y - mousePointInImg.y,
575                        visibleRect);
576                selectedRect.checkRectSize();
577                selectedRect.checkRectPos();
578                ImageDisplay.this.selectedRect = selectedRect;
579                ImageDisplay.this.repaint();
580            }
581
582        }
583
584        @Override
585        public void mouseReleased(MouseEvent e) {
586            ImageEntry entry;
587            Image image;
588            VisRect visibleRect;
589
590            synchronized (ImageDisplay.this) {
591                entry = ImageDisplay.this.entry;
592                image = ImageDisplay.this.image;
593                visibleRect = ImageDisplay.this.visibleRect;
594            }
595
596            if (image == null)
597                return;
598
599            if (mouseIsDragging(e)) {
600                visibleRect.isDragUpdate = false;
601            }
602
603            if (mouseIsZoomSelecting(e) && selectedRect != null) {
604                int oldWidth = selectedRect.width;
605                int oldHeight = selectedRect.height;
606
607                // Check that the zoom doesn't exceed MAX_ZOOM:1
608                if (selectedRect.width < getSize().width / MAX_ZOOM.get()) {
609                    selectedRect.width = (int) (getSize().width / MAX_ZOOM.get());
610                }
611                if (selectedRect.height < getSize().height / MAX_ZOOM.get()) {
612                    selectedRect.height = (int) (getSize().height / MAX_ZOOM.get());
613                }
614
615                // Set the same ratio for the visible rectangle and the display area
616                int hFact = selectedRect.height * getSize().width;
617                int wFact = selectedRect.width * getSize().height;
618                if (hFact > wFact) {
619                    selectedRect.width = hFact / getSize().height;
620                } else {
621                    selectedRect.height = wFact / getSize().width;
622                }
623
624                // Keep the center of the selection
625                if (selectedRect.width != oldWidth) {
626                    selectedRect.x -= (selectedRect.width - oldWidth) / 2;
627                }
628                if (selectedRect.height != oldHeight) {
629                    selectedRect.y -= (selectedRect.height - oldHeight) / 2;
630                }
631
632                selectedRect.checkRectSize();
633                selectedRect.checkRectPos();
634            }
635
636            synchronized (ImageDisplay.this) {
637                if (entry == ImageDisplay.this.entry) {
638                    if (selectedRect == null) {
639                        ImageDisplay.this.visibleRect = visibleRect;
640                    } else {
641                        ImageDisplay.this.visibleRect.setBounds(selectedRect);
642                        selectedRect = null;
643                    }
644                }
645            }
646            ImageDisplay.this.repaint();
647        }
648
649        @Override
650        public void mouseEntered(MouseEvent e) {
651            // Do nothing
652        }
653
654        @Override
655        public void mouseExited(MouseEvent e) {
656            // Do nothing
657        }
658
659        @Override
660        public void mouseMoved(MouseEvent e) {
661            // Do nothing
662        }
663    }
664
665    /**
666     * Constructs a new {@code ImageDisplay}.
667     */
668    public ImageDisplay() {
669        addMouseListener(imgMouseListener);
670        addMouseWheelListener(imgMouseListener);
671        addMouseMotionListener(imgMouseListener);
672        Config.getPref().addPreferenceChangeListener(this);
673        preferenceChanged(null);
674    }
675
676    @Override
677    public void destroy() {
678        removeMouseListener(imgMouseListener);
679        removeMouseWheelListener(imgMouseListener);
680        removeMouseMotionListener(imgMouseListener);
681        Config.getPref().removePreferenceChangeListener(this);
682    }
683
684    /**
685     * Sets a new source image to be displayed by this {@code ImageDisplay}.
686     * @param entry new source image
687     * @since 13220
688     */
689    public void setImage(ImageEntry entry) {
690        synchronized (this) {
691            this.entry = entry;
692            image = null;
693            errorLoading = false;
694        }
695        repaint();
696        if (entry != null) {
697            new Thread(new LoadImageRunnable(entry), LoadImageRunnable.class.getName()).start();
698        }
699    }
700
701    /**
702     * Set the message displayed when there is no image to display.
703     * By default it display a simple No image
704     * @param emptyText the string to display
705     * @since 15333
706     */
707    public void setEmptyText(String emptyText) {
708        this.emptyText = emptyText;
709    }
710
711    /**
712     * Sets the On-Screen-Display text.
713     * @param text text to display on top of the image
714     */
715    public void setOsdText(String text) {
716        if (!text.equals(this.osdText)) {
717            this.osdText = text;
718            repaint();
719        }
720    }
721
722    @Override
723    public void paintComponent(Graphics g) {
724        ImageEntry entry;
725        Image image;
726        VisRect visibleRect;
727        boolean errorLoading;
728
729        synchronized (this) {
730            image = this.image;
731            entry = this.entry;
732            visibleRect = this.visibleRect;
733            errorLoading = this.errorLoading;
734        }
735
736        if (g instanceof Graphics2D) {
737            ((Graphics2D) g).setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
738        }
739
740        Dimension size = getSize();
741        if (entry == null) {
742            g.setColor(Color.black);
743            if (emptyText == null) {
744                emptyText = tr("No image");
745            }
746            String noImageStr = emptyText;
747            Rectangle2D noImageSize = g.getFontMetrics(g.getFont()).getStringBounds(noImageStr, g);
748            g.drawString(noImageStr,
749                    (int) ((size.width - noImageSize.getWidth()) / 2),
750                    (int) ((size.height - noImageSize.getHeight()) / 2));
751        } else if (image == null) {
752            g.setColor(Color.black);
753            String loadingStr;
754            if (!errorLoading) {
755                loadingStr = tr("Loading {0}", entry.getFile().getName());
756            } else {
757                loadingStr = tr("Error on file {0}", entry.getFile().getName());
758            }
759            Rectangle2D noImageSize = g.getFontMetrics(g.getFont()).getStringBounds(loadingStr, g);
760            g.drawString(loadingStr,
761                    (int) ((size.width - noImageSize.getWidth()) / 2),
762                    (int) ((size.height - noImageSize.getHeight()) / 2));
763        } else {
764            Rectangle r = new Rectangle(visibleRect);
765            Rectangle target = calculateDrawImageRectangle(visibleRect, size);
766            double scale = target.width / (double) r.width; // pixel ratio is 1:1
767
768            if (selectedRect == null && !visibleRect.isDragUpdate &&
769                bilinLower < scale && scale < bilinUpper) {
770                try {
771                    BufferedImage bi = ImageProvider.toBufferedImage(image, r);
772                    if (bi != null) {
773                        r.x = r.y = 0;
774
775                        // See https://community.oracle.com/docs/DOC-983611 - The Perils of Image.getScaledInstance()
776                        // Pre-scale image when downscaling by more than two times to avoid aliasing from default algorithm
777                        bi = ImageProvider.createScaledImage(bi, target.width, target.height,
778                                RenderingHints.VALUE_INTERPOLATION_BILINEAR);
779                        r.width = target.width;
780                        r.height = target.height;
781                        image = bi;
782                    }
783                } catch (OutOfMemoryError oom) {
784                    Logging.trace(oom);
785                    // fall-back to the non-bilinear scaler
786                    r.x = visibleRect.x;
787                    r.y = visibleRect.y;
788                }
789            } else {
790                // if target and r cause drawImage to scale image region to a tmp buffer exceeding
791                // its bounds, it will silently fail; crop with r first in such cases
792                // (might be impl. dependent, exhibited by openjdk 1.8.0_151)
793                if (scale*(r.x+r.width) > Short.MAX_VALUE || scale*(r.y+r.height) > Short.MAX_VALUE) {
794                    image = ImageProvider.toBufferedImage(image, r);
795                    r.x = r.y = 0;
796                }
797            }
798
799            g.drawImage(image,
800                    target.x, target.y, target.x + target.width, target.y + target.height,
801                    r.x, r.y, r.x + r.width, r.y + r.height, null);
802
803            if (selectedRect != null) {
804                Point topLeft = img2compCoord(visibleRect, selectedRect.x, selectedRect.y, size);
805                Point bottomRight = img2compCoord(visibleRect,
806                        selectedRect.x + selectedRect.width,
807                        selectedRect.y + selectedRect.height, size);
808                g.setColor(new Color(128, 128, 128, 180));
809                g.fillRect(target.x, target.y, target.width, topLeft.y - target.y);
810                g.fillRect(target.x, target.y, topLeft.x - target.x, target.height);
811                g.fillRect(bottomRight.x, target.y, target.x + target.width - bottomRight.x, target.height);
812                g.fillRect(target.x, bottomRight.y, target.width, target.y + target.height - bottomRight.y);
813                g.setColor(Color.black);
814                g.drawRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
815            }
816            if (errorLoading) {
817                String loadingStr = tr("Error on file {0}", entry.getFile().getName());
818                Rectangle2D noImageSize = g.getFontMetrics(g.getFont()).getStringBounds(loadingStr, g);
819                g.drawString(loadingStr,
820                        (int) ((size.width - noImageSize.getWidth()) / 2),
821                        (int) ((size.height - noImageSize.getHeight()) / 2));
822            }
823            if (osdText != null) {
824                FontMetrics metrics = g.getFontMetrics(g.getFont());
825                int ascent = metrics.getAscent();
826                Color bkground = new Color(255, 255, 255, 128);
827                int lastPos = 0;
828                int pos = osdText.indexOf('\n');
829                int x = 3;
830                int y = 3;
831                String line;
832                while (pos > 0) {
833                    line = osdText.substring(lastPos, pos);
834                    Rectangle2D lineSize = metrics.getStringBounds(line, g);
835                    g.setColor(bkground);
836                    g.fillRect(x, y, (int) lineSize.getWidth(), (int) lineSize.getHeight());
837                    g.setColor(Color.black);
838                    g.drawString(line, x, y + ascent);
839                    y += (int) lineSize.getHeight();
840                    lastPos = pos + 1;
841                    pos = osdText.indexOf('\n', lastPos);
842                }
843
844                line = osdText.substring(lastPos);
845                Rectangle2D lineSize = g.getFontMetrics(g.getFont()).getStringBounds(line, g);
846                g.setColor(bkground);
847                g.fillRect(x, y, (int) lineSize.getWidth(), (int) lineSize.getHeight());
848                g.setColor(Color.black);
849                g.drawString(line, x, y + ascent);
850            }
851        }
852    }
853
854    static Point img2compCoord(VisRect visibleRect, int xImg, int yImg, Dimension compSize) {
855        Rectangle drawRect = calculateDrawImageRectangle(visibleRect, compSize);
856        return new Point(drawRect.x + ((xImg - visibleRect.x) * drawRect.width) / visibleRect.width,
857                drawRect.y + ((yImg - visibleRect.y) * drawRect.height) / visibleRect.height);
858    }
859
860    static Point comp2imgCoord(VisRect visibleRect, int xComp, int yComp, Dimension compSize) {
861        Rectangle drawRect = calculateDrawImageRectangle(visibleRect, compSize);
862        Point p = new Point(
863                        ((xComp - drawRect.x) * visibleRect.width),
864                        ((yComp - drawRect.y) * visibleRect.height));
865        p.x += (((p.x % drawRect.width) << 1) >= drawRect.width) ? drawRect.width : 0;
866        p.y += (((p.y % drawRect.height) << 1) >= drawRect.height) ? drawRect.height : 0;
867        p.x = visibleRect.x + p.x / drawRect.width;
868        p.y = visibleRect.y + p.y / drawRect.height;
869        return p;
870    }
871
872    static Point getCenterImgCoord(Rectangle visibleRect) {
873        return new Point(visibleRect.x + visibleRect.width / 2,
874                         visibleRect.y + visibleRect.height / 2);
875    }
876
877    static VisRect calculateDrawImageRectangle(VisRect visibleRect, Dimension compSize) {
878        return calculateDrawImageRectangle(visibleRect, new Rectangle(0, 0, compSize.width, compSize.height));
879    }
880
881    /**
882     * calculateDrawImageRectangle
883     *
884     * @param imgRect the part of the image that should be drawn (in image coordinates)
885     * @param compRect the part of the component where the image should be drawn (in component coordinates)
886     * @return the part of compRect with the same width/height ratio as the image
887     */
888    static VisRect calculateDrawImageRectangle(VisRect imgRect, Rectangle compRect) {
889        int x = 0;
890        int y = 0;
891        int w = compRect.width;
892        int h = compRect.height;
893
894        int wFact = w * imgRect.height;
895        int hFact = h * imgRect.width;
896        if (wFact != hFact) {
897            if (wFact > hFact) {
898                w = hFact / imgRect.height;
899                x = (compRect.width - w) / 2;
900            } else {
901                h = wFact / imgRect.width;
902                y = (compRect.height - h) / 2;
903            }
904        }
905
906        // overscan to prevent empty edges when zooming in to zoom scales > 2:1
907        if (w > imgRect.width && h > imgRect.height && !imgRect.isFullView1D() && wFact != hFact) {
908            if (wFact > hFact) {
909                w = compRect.width;
910                x = 0;
911                h = wFact / imgRect.width;
912                y = (compRect.height - h) / 2;
913            } else {
914                h = compRect.height;
915                y = 0;
916                w = hFact / imgRect.height;
917                x = (compRect.width - w) / 2;
918            }
919        }
920
921        return new VisRect(x + compRect.x, y + compRect.y, w, h, imgRect);
922    }
923
924    /**
925     * Make the current image either scale to fit inside this component,
926     * or show a portion of image (1:1), if the image size is larger than
927     * the component size.
928     */
929    public void zoomBestFitOrOne() {
930        ImageEntry entry;
931        Image image;
932        VisRect visibleRect;
933
934        synchronized (this) {
935            entry = this.entry;
936            image = this.image;
937            visibleRect = this.visibleRect;
938        }
939
940        if (image == null)
941            return;
942
943        if (visibleRect.width != image.getWidth(null) || visibleRect.height != image.getHeight(null)) {
944            // The display is not at best fit. => Zoom to best fit
945            visibleRect.reset();
946        } else {
947            // The display is at best fit => zoom to 1:1
948            Point center = getCenterImgCoord(visibleRect);
949            visibleRect.setBounds(center.x - getWidth() / 2, center.y - getHeight() / 2,
950                    getWidth(), getHeight());
951            visibleRect.checkRectSize();
952            visibleRect.checkRectPos();
953        }
954
955        synchronized (this) {
956            if (this.entry == entry) {
957                this.visibleRect = visibleRect;
958            }
959        }
960        repaint();
961    }
962}