1#!/usr/local/bin/python3.8
2# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:fdm=marker:ai
3
4
5__license__   = 'GPL v3'
6__copyright__ = '2012, Kovid Goyal <kovid at kovidgoyal.net>'
7__docformat__ = 'restructuredtext en'
8
9from qt.core import (
10    QApplication, QCheckBox, QDialog, QDialogButtonBox, QHBoxLayout, QIcon, QImage,
11    QLabel, QPainter, QPalette, QPixmap, QScrollArea, QSize, QSizePolicy,
12    QSvgRenderer, Qt, QTransform, QUrl, QVBoxLayout, pyqtSignal
13)
14
15from calibre import fit_image
16from calibre.gui2 import (
17    NO_URL_FORMATTING, choose_save_file, gprefs, max_available_height
18)
19
20
21def render_svg(widget, path):
22    img = QPixmap()
23    rend = QSvgRenderer()
24    if rend.load(path):
25        dpr = getattr(widget, 'devicePixelRatioF', widget.devicePixelRatio)()
26        sz = rend.defaultSize()
27        h = (max_available_height() - 50)
28        w = int(h * sz.height() / float(sz.width()))
29        pd = QImage(w * dpr, h * dpr, QImage.Format.Format_RGB32)
30        pd.fill(Qt.GlobalColor.white)
31        p = QPainter(pd)
32        rend.render(p)
33        p.end()
34        img = QPixmap.fromImage(pd)
35        img.setDevicePixelRatio(dpr)
36    return img
37
38
39class Label(QLabel):
40
41    toggle_fit = pyqtSignal()
42
43    def __init__(self, scrollarea):
44        super().__init__(scrollarea)
45        self.setBackgroundRole(QPalette.ColorRole.Text if QApplication.instance().is_dark_theme else QPalette.ColorRole.Base)
46        self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
47        self.setScaledContents(True)
48        self.default_cursor = self.cursor()
49        self.in_drag = False
50        self.prev_drag_position = None
51        self.scrollarea = scrollarea
52
53    @property
54    def is_pannable(self):
55        return self.scrollarea.verticalScrollBar().isVisible() or self.scrollarea.horizontalScrollBar().isVisible()
56
57    def mousePressEvent(self, ev):
58        if ev.button() == Qt.MouseButton.LeftButton and self.is_pannable:
59            self.setCursor(Qt.CursorShape.ClosedHandCursor)
60            self.in_drag = True
61            self.prev_drag_position = ev.globalPos()
62        return super().mousePressEvent(ev)
63
64    def mouseReleaseEvent(self, ev):
65        if ev.button() == Qt.MouseButton.LeftButton and self.in_drag:
66            self.setCursor(self.default_cursor)
67            self.in_drag = False
68            self.prev_drag_position = None
69        return super().mousePressEvent(ev)
70
71    def mouseMoveEvent(self, ev):
72        if self.prev_drag_position is not None:
73            p = self.prev_drag_position
74            self.prev_drag_position = pos = ev.globalPos()
75            self.dragged(pos.x() - p.x(), pos.y() - p.y())
76        return super().mouseMoveEvent(ev)
77
78    def dragged(self, dx, dy):
79        h = self.scrollarea.horizontalScrollBar()
80        if h.isVisible():
81            h.setValue(h.value() - dx)
82        v = self.scrollarea.verticalScrollBar()
83        if v.isVisible():
84            v.setValue(v.value() - dy)
85
86
87class ScrollArea(QScrollArea):
88
89    toggle_fit = pyqtSignal()
90
91    def mouseDoubleClickEvent(self, ev):
92        if ev.button() == Qt.MouseButton.LeftButton:
93            self.toggle_fit.emit()
94
95
96class ImageView(QDialog):
97
98    def __init__(self, parent, current_img, current_url, geom_name='viewer_image_popup_geometry'):
99        QDialog.__init__(self)
100        self.current_image_name = ''
101        self.maximized_at_last_fullscreen = False
102        self.setWindowFlag(Qt.WindowType.WindowMinimizeButtonHint)
103        self.setWindowFlag(Qt.WindowType.WindowMaximizeButtonHint)
104        self.avail_geom = self.screen().availableGeometry()
105        self.current_img = current_img
106        self.current_url = current_url
107        self.factor = 1.0
108        self.geom_name = geom_name
109
110        self.scrollarea = sa = ScrollArea()
111        sa.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter)
112        sa.setBackgroundRole(QPalette.ColorRole.Dark)
113        self.label = l = Label(sa)
114        sa.toggle_fit.connect(self.toggle_fit)
115        sa.setWidget(l)
116
117        self.bb = bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
118        bb.accepted.connect(self.accept)
119        bb.rejected.connect(self.reject)
120        self.zi_button = zi = bb.addButton(_('Zoom &in'), QDialogButtonBox.ButtonRole.ActionRole)
121        self.zo_button = zo = bb.addButton(_('Zoom &out'), QDialogButtonBox.ButtonRole.ActionRole)
122        self.save_button = so = bb.addButton(_('&Save as'), QDialogButtonBox.ButtonRole.ActionRole)
123        self.rotate_button = ro = bb.addButton(_('&Rotate'), QDialogButtonBox.ButtonRole.ActionRole)
124        self.fullscreen_button = fo = bb.addButton(_('&Full screen'), QDialogButtonBox.ButtonRole.ActionRole)
125        zi.setIcon(QIcon(I('plus.png')))
126        zo.setIcon(QIcon(I('minus.png')))
127        so.setIcon(QIcon(I('save.png')))
128        ro.setIcon(QIcon(I('rotate-right.png')))
129        fo.setIcon(QIcon(I('page.png')))
130        zi.clicked.connect(self.zoom_in)
131        zo.clicked.connect(self.zoom_out)
132        so.clicked.connect(self.save_image)
133        ro.clicked.connect(self.rotate_image)
134        fo.setCheckable(True)
135
136        self.l = l = QVBoxLayout(self)
137        l.addWidget(sa)
138        self.h = h = QHBoxLayout()
139        h.setContentsMargins(0, 0, 0, 0)
140        l.addLayout(h)
141        self.fit_image = i = QCheckBox(_('&Fit image'))
142        i.setToolTip(_('Fit image inside the available space'))
143        i.setChecked(bool(gprefs.get('image_popup_fit_image')))
144        i.stateChanged.connect(self.fit_changed)
145        h.addWidget(i), h.addStretch(), h.addWidget(bb)
146        if self.fit_image.isChecked():
147            self.set_to_viewport_size()
148        geom = gprefs.get(self.geom_name)
149        if geom is not None:
150            self.restoreGeometry(geom)
151        fo.setChecked(self.isFullScreen())
152        fo.toggled.connect(self.toggle_fullscreen)
153
154    def set_to_viewport_size(self):
155        page_size = self.scrollarea.size()
156        pw, ph = page_size.width() - 2, page_size.height() - 2
157        img_size = self.current_img.size()
158        iw, ih = img_size.width(), img_size.height()
159        scaled, nw, nh = fit_image(iw, ih, pw, ph)
160        if scaled:
161            self.factor = min(nw/iw, nh/ih)
162        img_size.setWidth(nw), img_size.setHeight(nh)
163        self.label.resize(img_size)
164
165    def resizeEvent(self, ev):
166        if self.fit_image.isChecked():
167            self.set_to_viewport_size()
168
169    def factor_from_fit(self):
170        scaled_height = self.label.size().height()
171        actual_height = self.current_img.size().height()
172        return scaled_height / actual_height
173
174    def zoom_in(self):
175        if self.fit_image.isChecked():
176            factor = self.factor_from_fit()
177            self.fit_image.setChecked(False)
178            self.factor = factor
179        self.factor *= 1.25
180        self.adjust_image(1.25)
181
182    def zoom_out(self):
183        if self.fit_image.isChecked():
184            factor = self.factor_from_fit()
185            self.fit_image.setChecked(False)
186            self.factor = factor
187        self.factor *= 0.8
188        self.adjust_image(0.8)
189
190    def save_image(self):
191        filters=[('Images', ['png', 'jpeg', 'jpg'])]
192        f = choose_save_file(self, 'viewer image view save dialog',
193                _('Choose a file to save to'), filters=filters,
194                all_files=False, initial_filename=self.current_image_name or None)
195        if f:
196            from calibre.utils.img import save_image
197            save_image(self.current_img.toImage(), f)
198
199    def fit_changed(self):
200        fitted = bool(self.fit_image.isChecked())
201        gprefs.set('image_popup_fit_image', fitted)
202        if self.fit_image.isChecked():
203            self.set_to_viewport_size()
204        else:
205            self.factor = 1
206            self.adjust_image(1)
207
208    def toggle_fit(self):
209        self.fit_image.toggle()
210
211    def adjust_image(self, factor):
212        if self.fit_image.isChecked():
213            self.set_to_viewport_size()
214            return
215        self.label.resize(self.factor * self.current_img.size())
216        self.zi_button.setEnabled(self.factor <= 3)
217        self.zo_button.setEnabled(self.factor >= 0.3333)
218        self.adjust_scrollbars(factor)
219
220    def adjust_scrollbars(self, factor):
221        for sb in (self.scrollarea.horizontalScrollBar(),
222                self.scrollarea.verticalScrollBar()):
223            sb.setValue(int(factor*sb.value()) + int((factor - 1) * sb.pageStep()/2))
224
225    def rotate_image(self):
226        pm = self.label.pixmap()
227        t = QTransform()
228        t.rotate(90)
229        pm = self.current_img = pm.transformed(t)
230        self.label.setPixmap(pm)
231        self.label.adjustSize()
232        if self.fit_image.isChecked():
233            self.set_to_viewport_size()
234        else:
235            self.factor = 1
236            for sb in (self.scrollarea.horizontalScrollBar(),
237                    self.scrollarea.verticalScrollBar()):
238                sb.setValue(0)
239
240    def __call__(self, use_exec=False):
241        geom = self.avail_geom
242        self.label.setPixmap(self.current_img)
243        self.label.adjustSize()
244        self.resize(QSize(int(geom.width()/2.5), geom.height()-50))
245        geom = gprefs.get(self.geom_name, None)
246        if geom is not None:
247            QApplication.instance().safe_restore_geometry(self, geom)
248        try:
249            self.current_image_name = str(self.current_url.toString(NO_URL_FORMATTING)).rpartition('/')[-1]
250        except AttributeError:
251            self.current_image_name = self.current_url
252        reso = ''
253        if self.current_img and not self.current_img.isNull():
254            reso = f'[{self.current_img.width()}x{self.current_img.height()}]'
255        title = _('Image: {name} {resolution}').format(name=self.current_image_name, resolution=reso)
256        self.setWindowTitle(title)
257        if use_exec:
258            self.exec()
259        else:
260            self.show()
261
262    def done(self, e):
263        gprefs[self.geom_name] = bytearray(self.saveGeometry())
264        return QDialog.done(self, e)
265
266    def toggle_fullscreen(self):
267        on = not self.isFullScreen()
268        if on:
269            self.maximized_at_last_fullscreen = self.isMaximized()
270            self.showFullScreen()
271        else:
272            if self.maximized_at_last_fullscreen:
273                self.showMaximized()
274            else:
275                self.showNormal()
276
277    def wheelEvent(self, event):
278        d = event.angleDelta().y()
279        if abs(d) > 0 and not self.scrollarea.verticalScrollBar().isVisible():
280            event.accept()
281            (self.zoom_out if d < 0 else self.zoom_in)()
282
283
284class ImagePopup:
285
286    def __init__(self, parent):
287        self.current_img = QPixmap()
288        self.current_url = QUrl()
289        self.parent = parent
290        self.dialogs = []
291
292    def __call__(self):
293        if self.current_img.isNull():
294            return
295        d = ImageView(self.parent, self.current_img, self.current_url)
296        self.dialogs.append(d)
297        d.finished.connect(self.cleanup, type=Qt.ConnectionType.QueuedConnection)
298        d()
299
300    def cleanup(self):
301        for d in tuple(self.dialogs):
302            if not d.isVisible():
303                self.dialogs.remove(d)
304
305
306if __name__ == '__main__':
307    import sys
308
309    from calibre.gui2 import Application
310    app = Application([])
311    p = QPixmap()
312    p.load(sys.argv[-1])
313    u = QUrl.fromLocalFile(sys.argv[-1])
314    d = ImageView(None, p, u)
315    d()
316    app.exec()
317