1# This file is part of the qpageview package.
2#
3# Copyright (c) 2016 - 2019 by Wilbert Berendsen
4#
5# This program is free software; you can redistribute it and/or
6# modify it under the terms of the GNU General Public License
7# as published by the Free Software Foundation; either version 2
8# of the License, or (at your option) any later version.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program; if not, write to the Free Software
17# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
18# See http://www.gnu.org/licenses/ for more information.
19
20"""
21The View, deriving from QAbstractScrollArea.
22"""
23
24import collections
25import contextlib
26import weakref
27
28from PyQt5.QtCore import pyqtSignal, QEvent, QPoint, QRect, QSize, Qt
29from PyQt5.QtGui import QCursor, QPainter, QPalette, QRegion
30from PyQt5.QtWidgets import QGestureEvent, QPinchGesture, QStyle
31from PyQt5.QtPrintSupport import QPrinter, QPrintDialog
32
33from . import layout
34from . import page
35from . import scrollarea
36from . import util
37
38from .constants import (
39
40    # rotation:
41    Rotate_0,
42    Rotate_90,
43    Rotate_180,
44    Rotate_270,
45
46    # viewModes:
47    FixedScale,
48    FitWidth,
49    FitHeight,
50    FitBoth,
51
52    # orientation:
53    Horizontal,
54    Vertical,
55)
56
57
58Position = collections.namedtuple("Position", "pageNumber x y")
59
60
61class View(scrollarea.ScrollArea):
62    """View is a generic scrollable widget to display Pages in a layout.
63
64    Using setPageLayout() you can set a PageLayout to the View, and you can
65    add Pages to the layout using a list-like api. (PageLayout derives from
66    list). A simple PageLayout is set by default. Call updatePageLayout() after
67    every change to the layout (like adding or removing pages).
68
69    You can also add a Magnifier to magnify parts of a Page, and a Rubberband
70    to enable selecting a rectangular region.
71
72    View emits the following signals:
73
74    `pageCountChanged`          When the number of pages changes.
75
76    `currentPageNumberChanged`  When the current page number changes.
77
78    `viewModeChanged`   When the user changes the view mode (one of FixedScale,
79                        FitWidth, FitHeight and FitBoth)
80
81    `rotationChanged`   When the user changes the rotation (one of Rotate_0,
82                        Rotate_90, Rotate_180, Rotate_270)
83
84    `zoomFactorChanged` When the zoomfactor changes
85
86    `pageLayoutUpdated` When the page layout is updated (e.g. after adding
87                        or removing pages, but also zoom and rotation cause a
88                        layout update)
89
90    `continuousModeChanged` When the user toggle the continuousMode() setting.
91
92    `pageLayoutModeChanged` When the page layout mode is changed. The page
93                            layout mode is set using setPageLayoutMode() and
94                            internally implemented by using different qpageview
95                            LayoutEngine classes.
96
97    The following instance variables can be set, and default to:
98
99    MIN_ZOOM = 0.05
100    MAX_ZOOM = 64.0
101
102    wheelZoomingEnabled = True
103        zoom the View using the mouse wheel
104
105    kineticPagingEnabled = True
106        scroll smoothly on setCurrentPageNumber
107
108    pagingOnScrollEnabled = True
109        keep track of current page while scrolling
110
111    clickToSetCurrentPageEnabled = True
112        any mouseclick on a page sets it the current page
113
114    strictPagingEnabled = False
115        PageUp, PageDown and wheel call setCurrentPageNumber i.s.o. scroll
116
117    documentPropertyStore
118        can be set to a DocumentPropertyStore object. If set, the object is
119        used to store certain View settings on a per-document basis.
120        (This happens in the clear() and setDocument() methods.)
121
122    """
123
124    MIN_ZOOM = 0.05
125    MAX_ZOOM = 64.0
126
127    wheelZoomingEnabled = True
128    kineticPagingEnabled = True  # scroll smoothly on setCurrentPageNumber
129    pagingOnScrollEnabled = True # keep track of current page while scrolling
130    clickToSetCurrentPageEnabled = True  # any mouseclick on a page sets it the current page
131    strictPagingEnabled = False  # PageUp and PageDown call setCurrentPageNumber i.s.o. scroll
132
133    documentPropertyStore = None
134
135    pageCountChanged = pyqtSignal(int)
136    currentPageNumberChanged = pyqtSignal(int)
137    viewModeChanged = pyqtSignal(int)
138    rotationChanged = pyqtSignal(int)
139    orientationChanged = pyqtSignal(int)
140    zoomFactorChanged = pyqtSignal(float)
141    pageLayoutUpdated = pyqtSignal()
142    continuousModeChanged = pyqtSignal(bool)
143    pageLayoutModeChanged = pyqtSignal(str)
144
145    def __init__(self, parent=None, **kwds):
146        super().__init__(parent, **kwds)
147        self._document = None
148        self._currentPageNumber = 0
149        self._pageCount = 0
150        self._scrollingToPage = 0
151        self._prev_pages_to_paint = set()
152        self._viewMode = FixedScale
153        self._pageLayout = None
154        self._magnifier = None
155        self._rubberband = None
156        self._pinchStartFactor = None
157        self.grabGesture(Qt.PinchGesture)
158        self.viewport().setBackgroundRole(QPalette.Dark)
159        self.verticalScrollBar().setSingleStep(20)
160        self.horizontalScrollBar().setSingleStep(20)
161        self.setMouseTracking(True)
162        self.setMinimumSize(QSize(60, 60))
163        self.setPageLayout(layout.PageLayout())
164        props = self.properties().setdefaults()
165        self._viewMode = props.viewMode
166        self._pageLayout.continuousMode = props.continuousMode
167        self._pageLayout.orientation = props.orientation
168        self._pageLayoutMode = props.pageLayoutMode
169        self.pageLayout().engine = self.pageLayoutModes()[props.pageLayoutMode]()
170
171    def pageCount(self):
172        """Return the number of pages in the view."""
173        return self._pageCount
174
175    def currentPageNumber(self):
176        """Return the current page number in view (starting with 1)."""
177        return self._currentPageNumber
178
179    def setCurrentPageNumber(self, num):
180        """Scrolls to the specified page number (starting with 1).
181
182        If the page is already in view, the view is not scrolled, otherwise
183        the view is scrolled to center the page. (If the page is larger than
184        the view, the top-left corner is positioned top-left in the view.)
185
186        """
187        self.updateCurrentPageNumber(num)
188        page = self.currentPage()
189        if page:
190            margins = self._pageLayout.margins() + self._pageLayout.pageMargins()
191            with self.pagingOnScrollDisabled():
192                self.ensureVisible(page.geometry(), margins, self.kineticPagingEnabled)
193            if self.isScrolling():
194                self._scrollingToPage = True
195
196    def updateCurrentPageNumber(self, num):
197        """Set the current page number without scrolling the view."""
198        count = self.pageCount()
199        n = max(min(count, num), 1 if count else 0)
200        if n == num and n != self._currentPageNumber:
201            self._currentPageNumber = num
202            self.currentPageNumberChanged.emit(num)
203
204    def gotoNextPage(self):
205        """Convenience method to go to the next page."""
206        num = self.currentPageNumber()
207        if num < self.pageCount():
208            self.setCurrentPageNumber(num + 1)
209
210    def gotoPreviousPage(self):
211        """Convenience method to go to the previous page."""
212        num = self.currentPageNumber()
213        if num > 1:
214            self.setCurrentPageNumber(num - 1)
215
216    def currentPage(self):
217        """Return the page pointed to by currentPageNumber()."""
218        if self._pageCount:
219            return self._pageLayout[self._currentPageNumber-1]
220
221    def page(self, num):
222        """Return the page at the specified number (starting at 1)."""
223        if 0 < num <= self._pageCount:
224            return self._pageLayout[num-1]
225
226    def position(self):
227        """Return a three-tuple Position(pageNumber, x, y).
228
229        The Position describes where the center of the viewport is on the layout.
230        The page is the page number (starting with 1) and x and y the position
231        on the page, in a 0..1 range. This way a position can be remembered even
232        if the zoom or orientation of the layout changes.
233
234        """
235        pos = self.viewport().rect().center()
236        i, x, y = self._pageLayout.pos2offset(pos - self.layoutPosition())
237        return Position(i + 1, x, y)
238
239    def setPosition(self, position, allowKinetic=True):
240        """Centers the view on the spot stored in the specified Position.
241
242        If allowKinetic is False, immediately jumps to the position, otherwise
243        scrolls smoothly (if kinetic scrolling is enabled).
244
245        """
246        i, x, y = position
247        rect = self.viewport().rect()
248        rect.moveCenter(self._pageLayout.offset2pos((i - 1, x, y)))
249        self.ensureVisible(rect, allowKinetic=allowKinetic)
250
251    def setPageLayout(self, layout):
252        """Set our current PageLayout instance.
253
254        The dpiX and dpiY attributes of the layout are set to the physical
255        resolution of the widget, which should result in a natural size of 100%
256        at zoom factor 1.0.
257
258        """
259        if self._pageLayout:
260            self._unschedulePages(self._pageLayout)
261        layout.dpiX = self.physicalDpiX()
262        layout.dpiY = self.physicalDpiY()
263        self._pageLayout = layout
264        self.updatePageLayout()
265
266    def pageLayout(self):
267        """Return our current PageLayout instance."""
268        return self._pageLayout
269
270    def pageLayoutModes(self):
271        """Return a dictionary mapping names to callables.
272
273        The callable returns a configured LayoutEngine that is set to the
274        page layout. You can reimplement this method to returns more layout
275        modes, but it is required that the name "single" exists.
276
277        """
278        def single():
279            return layout.LayoutEngine()
280
281        def raster():
282            return layout.RasterLayoutEngine()
283
284        def double_left():
285            engine = layout.RowLayoutEngine()
286            engine.pagesPerRow = 2
287            engine.pagesFirstRow = 0
288            return engine
289
290        def double_right():
291            engine = double_left()
292            engine.pagesFirstRow = 1
293            return engine
294
295        return locals()
296
297    def pageLayoutMode(self):
298        """Return the currently set page layout mode."""
299        return self._pageLayoutMode
300
301    def setPageLayoutMode(self, mode):
302        """Set the page layout mode.
303
304        The mode is one of the names returned by pageLayoutModes().
305        The mode name "single" is guaranteed to exist.
306
307        """
308        if mode != self._pageLayoutMode:
309            # get a suitable LayoutEngine
310            try:
311                engine = self.pageLayoutModes()[mode]()
312            except KeyError:
313                return
314            self._pageLayout.engine = engine
315            # keep the current page in view
316            page = self.currentPage()
317            self.updatePageLayout()
318            if page:
319                margins = self._pageLayout.margins() + self._pageLayout.pageMargins()
320                with self.pagingOnScrollDisabled():
321                    self.ensureVisible(page.geometry(), margins, False)
322            self._pageLayoutMode = mode
323            self.pageLayoutModeChanged.emit(mode)
324            if self.viewMode():
325                with self.keepCentered():
326                    self.fitPageLayout()
327
328    def updatePageLayout(self, lazy=False):
329        """Update layout, adjust scrollbars, keep track of page count.
330
331        If lazy is set to True, calls lazyUpdate() to update the view.
332
333        """
334        self._pageLayout.update()
335
336        # keep track of page count
337        count = self._pageLayout.count()
338        if count != self._pageCount:
339            self._pageCount = count
340            self.pageCountChanged.emit(count)
341            n = max(min(count, self._currentPageNumber), 1 if count else 0)
342            self.updateCurrentPageNumber(n)
343
344        self.setAreaSize(self._pageLayout.size())
345        self.pageLayoutUpdated.emit()
346        self.lazyUpdate() if lazy else self.viewport().update()
347
348    @contextlib.contextmanager
349    def modifyPages(self):
350        """Return the list of pages and enter a context to make modifications.
351
352        Note that the first page is at index 0.
353        On exit of the context the page layout is updated.
354
355        """
356        pages = list(self._pageLayout)
357        if self.rubberband():
358            selectedpages = set(p for p, r in self.rubberband().selectedPages())
359        else:
360            selectedpages = set()
361        lazy = bool(pages)
362        try:
363            yield pages
364        finally:
365            lazy &= bool(pages)
366            removedpages = set(self._pageLayout) - set(pages)
367            if selectedpages & removedpages:
368                self.rubberband().clearSelection() # rubberband'll always be there
369            self._unschedulePages(removedpages)
370            self._pageLayout[:] = pages
371            if self._viewMode:
372                zoomFactor = self._pageLayout.zoomFactor
373                self.fitPageLayout()
374                if zoomFactor != self._pageLayout.zoomFactor:
375                    lazy = False
376            self.updatePageLayout(lazy)
377
378    @contextlib.contextmanager
379    def modifyPage(self, num):
380        """Return the page (numbers start with 1) and enter a context.
381
382        On exit of the context, the page layout is updated.
383
384        """
385        page = self.page(num)
386        yield page
387        if page:
388            self._unschedulePages((page,))
389            self.updatePageLayout(True)
390
391    def clear(self):
392        """Convenience method to clear the current layout."""
393        if self.documentPropertyStore and self._document:
394            self.documentPropertyStore.set(self._document, self.properties().get(self))
395        self._document = None
396        with self.modifyPages() as pages:
397            pages.clear()
398
399    def setDocument(self, document):
400        """Set the Document to display (see document.Document)."""
401        store = self._document is not document and self.documentPropertyStore
402        if store and self._document:
403            store.set(self._document, self.properties().get(self))
404        self._document = document
405        with self.modifyPages() as pages:
406            pages[:] = document.pages()
407        if store:
408            (store.get(document) or store.default or self.properties()).set(self)
409
410    def document(self):
411        """Return the Document currently displayed (see document.Document)."""
412        return self._document
413
414    def reload(self):
415        """If a Document was set, invalidate()s it and then reloads it."""
416        if self._document:
417            self._document.invalidate()
418            with self.modifyPages() as pages:
419                pages[:] = self._document.pages()
420
421    def loadPdf(self, filename, renderer=None):
422        """Convenience method to load the specified PDF file.
423
424        The filename can also be a QByteArray or an already loaded
425        popplerqt5.Poppler.Document instance.
426
427        """
428        from . import poppler
429        self.setDocument(poppler.PopplerDocument(filename, renderer))
430
431    def loadSvgs(self, filenames, renderer=None):
432        """Convenience method to load the specified list of SVG files.
433
434        Each SVG file is loaded in one Page. A filename can also be a
435        QByteArray.
436
437        """
438        from . import svg
439        self.setDocument(svg.SvgDocument(filenames, renderer))
440
441    def loadImages(self, filenames, renderer=None):
442        """Convenience method to load images from the specified list of files.
443
444        Each image is loaded in one Page. A filename can also be a
445        QByteArray or a QImage.
446
447        """
448        from . import image
449        self.setDocument(image.ImageDocument(filenames, renderer))
450
451    def print(self, printer=None, pageNumbers=None, showDialog=True):
452        """Print all, or speficied pages to QPrinter printer.
453
454        If given the pageNumbers should be a list containing page numbers
455        starting with 1. If showDialog is True, a print dialog is shown, and
456        printing is canceled when the user cancels the dialog.
457
458        If the QPrinter to use is not specified, a default one is created.
459        The print job is started and returned (a printing.PrintJob instance),
460        so signals for monitoring the progress could be connected to. (If the
461        user cancels the dialog, no print job is returned.)
462
463        """
464        if printer is None:
465            printer = QPrinter()
466            printer.setResolution(300)
467        if showDialog:
468            dlg = QPrintDialog(printer, self)
469            dlg.setMinMax(1, self.pageCount())
470            if not dlg.exec_():
471                return  # cancelled
472        if not pageNumbers:
473            if printer.printRange() == QPrinter.CurrentPage:
474                pageNumbers = [self.currentPageNumber()]
475            else:
476                if printer.printRange() == QPrinter.PageRange:
477                    first = printer.toPage() or 1
478                    last = printer.fromPage() or self.pageCount()
479                else:
480                    first, last = 1, self.pageCount()
481                pageNumbers = list(range(first, last + 1))
482            if printer.pageOrder() == QPrinter.LastPageFirst:
483                pageNumbers.reverse()
484        # add the page objects
485        pageList = [(n, self.page(n)) for n in pageNumbers]
486        from . import printing
487        job = printing.PrintJob(printer, pageList)
488        job.start()
489        return job
490
491    @staticmethod
492    def properties():
493        """Return an uninitialized ViewProperties object."""
494        return ViewProperties()
495
496    def readProperties(self, settings):
497        """Read View settings from the QSettings object.
498
499        If a documentPropertyStore is set, the settings are also set
500        as default for the DocumentPropertyStore.
501
502        """
503        props = self.properties().load(settings)
504        props.position = None   # storing the position makes no sense
505        props.set(self)
506        if self.documentPropertyStore:
507            self.documentPropertyStore.default = props
508
509    def writeProperties(self, settings):
510        """Write the current View settings to the QSettings object.
511
512        If a documentPropertyStore is set, the settings are also set
513        as default for the DocumentPropertyStore.
514
515        """
516        props = self.properties().get(self)
517        props.position = None   # storing the position makes no sense
518        props.save(settings)
519        if self.documentPropertyStore:
520            self.documentPropertyStore.default = props
521
522    def setViewMode(self, mode):
523        """Sets the current ViewMode."""
524        if mode == self._viewMode:
525            return
526        self._viewMode = mode
527        if mode:
528            with self.keepCentered():
529                self.fitPageLayout()
530        else:
531            # call layout once to tell FixedScale is active
532            self.pageLayout().fit(QSize(), mode)
533        self.viewModeChanged.emit(mode)
534
535    def viewMode(self):
536        """Returns the current ViewMode."""
537        return self._viewMode
538
539    def setRotation(self, rotation):
540        """Set the current rotation."""
541        layout = self._pageLayout
542        if rotation != layout.rotation:
543            with self.keepCentered():
544                layout.rotation = rotation
545                self.fitPageLayout()
546            self.rotationChanged.emit(rotation)
547
548    def rotation(self):
549        """Return the current rotation."""
550        return self._pageLayout.rotation
551
552    def rotateLeft(self):
553        """Rotate the pages 270 degrees."""
554        self.setRotation((self.rotation() - 1) & 3)
555
556    def rotateRight(self):
557        """Rotate the pages 90 degrees."""
558        self.setRotation((self.rotation() + 1) & 3)
559
560    def setOrientation(self, orientation):
561        """Set the orientation (Horizontal or Vertical)."""
562        layout = self._pageLayout
563        if orientation != layout.orientation:
564            with self.keepCentered():
565                layout.orientation = orientation
566                self.fitPageLayout()
567            self.orientationChanged.emit(orientation)
568
569    def orientation(self):
570        """Return the current orientation (Horizontal or Vertical)."""
571        return self._pageLayout.orientation
572
573    def setContinuousMode(self, continuous):
574        """Sets whether the layout should display all pages.
575
576        If True, the layout shows all pages. If False, only the page set
577        containing the current page is displayed. If the pageLayout() does not
578        support the PageSetLayoutMixin methods, this method does nothing.
579
580        """
581        layout = self._pageLayout
582        oldcontinuous = layout.continuousMode
583        if continuous:
584            if not oldcontinuous:
585                with self.pagingOnScrollDisabled(), self.keepCentered():
586                    layout.continuousMode = True
587                    self.fitPageLayout()
588                self.continuousModeChanged.emit(True)
589        elif oldcontinuous:
590            p = self.currentPage()
591            index = layout.index(p) if p else 0
592            with self.pagingOnScrollDisabled(), self.keepCentered():
593                layout.continuousMode = False
594                layout.currentPageSet = layout.pageSet(index)
595                self.fitPageLayout()
596            self.continuousModeChanged.emit(False)
597
598    def continuousMode(self):
599        """Return True if the layout displays all pages."""
600        return self._pageLayout.continuousMode
601
602    def displayPageSet(self, what):
603        """Try to display a page set (if the layout is not in continuous mode).
604
605        `what` can be:
606
607            "next":     go to the next page set
608            "previous": go to the previous page set
609            "first":    go to the first page set
610            "last":     go to the last page set
611            integer:    go to the specified page set
612
613        """
614        layout = self._pageLayout
615        if layout.continuousMode:
616            return
617
618        sb = None  # where to move the scrollbar after fitlayout
619        if what == "first":
620            what = 0
621            sb = "up"   # move to the start
622        elif what == "last":
623            what = layout.pageSetCount() - 1
624            sb = "down" # move to the end
625        elif what == "previous":
626            what = layout.currentPageSet - 1
627            if what < 0:
628                return
629            sb = "down"
630        elif what == "next":
631            what = layout.currentPageSet + 1
632            if what >= layout.pageSetCount():
633                return
634            sb = "up"
635        elif not 0 <= what < layout.pageSetCount():
636            return
637        layout.currentPageSet = what
638        self.fitPageLayout()
639        self.updatePageLayout()
640        if sb:
641            self.verticalScrollBar().setValue(0 if sb == "up" else self.verticalScrollBar().maximum())
642        if self.pagingOnScrollEnabled and not self._scrollingToPage:
643            s = layout.currentPageSetSlice()
644            num = s.stop - 1 if sb == "down" else s.start
645            self.updateCurrentPageNumber(num + 1)
646
647    def setMagnifier(self, magnifier):
648        """Sets the Magnifier to use (or None to disable the magnifier).
649
650        The viewport takes ownership of the Magnifier.
651
652        """
653        if self._magnifier:
654            self.viewport().removeEventFilter(self._magnifier)
655            self._magnifier.setParent(None)
656        self._magnifier = magnifier
657        if magnifier:
658            magnifier.setParent(self.viewport())
659            self.viewport().installEventFilter(magnifier)
660
661    def magnifier(self):
662        """Returns the currently set magnifier."""
663        return self._magnifier
664
665    def setRubberband(self, rubberband):
666        """Sets the Rubberband to use for selections (or None to not use one)."""
667        if self._rubberband:
668            self.viewport().removeEventFilter(self._rubberband)
669            self.zoomFactorChanged.disconnect(self._rubberband.slotZoomChanged)
670            self.rotationChanged.disconnect(self._rubberband.clearSelection)
671            self._rubberband.setParent(None)
672        self._rubberband = rubberband
673        if rubberband:
674            rubberband.setParent(self.viewport())
675            rubberband.clearSelection()
676            self.viewport().installEventFilter(rubberband)
677            self.zoomFactorChanged.connect(rubberband.slotZoomChanged)
678            self.rotationChanged.connect(rubberband.clearSelection)
679
680    def rubberband(self):
681        """Return the currently set rubberband."""
682        return self._rubberband
683
684    @contextlib.contextmanager
685    def pagingOnScrollDisabled(self):
686        """During this context a scroll is not tracked to update the current page number."""
687        old, self._scrollingToPage = self._scrollingToPage, True
688        try:
689            yield
690        finally:
691            self._scrollingToPage = old
692
693    def scrollContentsBy(self, dx, dy):
694        """Reimplemented to move the rubberband and adjust the mouse cursor."""
695        if self._rubberband:
696            self._rubberband.scrollBy(QPoint(dx, dy))
697        if not self.isScrolling() and not self.isDragging():
698            # don't adjust the cursor during a kinetic scroll
699            pos = self.viewport().mapFromGlobal(QCursor.pos())
700            if pos in self.viewport().rect() and not self.viewport().childAt(pos):
701                self.adjustCursor(pos)
702        self.viewport().update()
703
704        # keep track of current page. If the scroll wasn't initiated by the
705        # setCurrentPage() call, check # whether the current page number needs
706        # to be updated
707        if self.pagingOnScrollEnabled and not self._scrollingToPage and self.pageCount() > 0:
708            # do nothing if current page is still fully in view
709            if self.currentPage().geometry() not in self.visibleRect():
710                # find the page in the center of the view
711                layout = self._pageLayout
712                pos = self.visibleRect().center()
713                p = layout.pageAt(pos) or layout.nearestPageAt(pos)
714                if p:
715                    num = layout.index(p) + 1
716                    self.updateCurrentPageNumber(num)
717
718    def stopScrolling(self):
719        """Reimplemented to adjust the mouse cursor on scroll stop."""
720        super().stopScrolling()
721        self._scrollingToPage = False
722        pos = self.viewport().mapFromGlobal(QCursor.pos())
723        if pos in self.viewport().rect() and not self.viewport().childAt(pos):
724            self.adjustCursor(pos)
725
726    def fitPageLayout(self):
727        """Fit the layout according to the view mode.
728
729        Does nothing in FixedScale mode. Prevents scrollbar/resize loops by
730        precalculating which scrollbars will appear.
731
732        """
733        mode = self.viewMode()
734        if mode == FixedScale:
735            return
736
737        maxsize = self.maximumViewportSize()
738
739        # can vertical or horizontal scrollbars appear?
740        vcan = self.verticalScrollBarPolicy() == Qt.ScrollBarAsNeeded
741        hcan = self.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded
742
743        # width a scrollbar takes off the viewport size
744        framewidth = 0
745        if self.style().styleHint(QStyle.SH_ScrollView_FrameOnlyAroundContents, None, self):
746            framewidth = self.style().pixelMetric(QStyle.PM_DefaultFrameWidth) * 2
747        scrollbarextent = self.style().pixelMetric(QStyle.PM_ScrollBarExtent, None, self) + framewidth
748
749        # remember old factor
750        zoom_factor = self.zoomFactor()
751
752        # first try to fit full size
753        layout = self._pageLayout
754        layout.fit(maxsize, mode)
755        layout.update()
756
757        # minimal values
758        minwidth = maxsize.width()
759        minheight = maxsize.height()
760        if vcan:
761            minwidth -= scrollbarextent
762        if hcan:
763            minheight -= scrollbarextent
764
765        # do width and/or height fit?
766        fitw = layout.width <= maxsize.width()
767        fith = layout.height <= maxsize.height()
768
769        if not fitw and not fith:
770            if vcan or hcan:
771                layout.fit(QSize(minwidth, minheight), mode)
772        elif mode & FitWidth and fitw and not fith and vcan:
773            # a vertical scrollbar will appear
774            w = minwidth
775            layout.fit(QSize(w, maxsize.height()), mode)
776            layout.update()
777            if layout.height <= maxsize.height():
778                # now the vert. scrollbar would disappear!
779                # enlarge it as long as the vertical scrollbar would not be needed
780                while True:
781                    w += 1
782                    layout.fit(QSize(w, maxsize.height()), mode)
783                    layout.update()
784                    if layout.height > maxsize.height():
785                        layout.fit(QSize(w - 1, maxsize.height()), mode)
786                        break
787        elif mode & FitHeight and fith and not fitw and hcan:
788            # a horizontal scrollbar will appear
789            h = minheight
790            layout.fit(QSize(maxsize.width(), h), mode)
791            layout.update()
792            if layout.width <= maxsize.width():
793                # now the horizontal scrollbar would disappear!
794                # enlarge it as long as the horizontal scrollbar would not be needed
795                while True:
796                    h += 1
797                    layout.fit(QSize(maxsize.width(), h), mode)
798                    layout.update()
799                    if layout.width > maxsize.width():
800                        layout.fit(QSize(maxsize.width(), h - 1), mode)
801                        break
802        if zoom_factor != self.zoomFactor():
803            self.zoomFactorChanged.emit(self.zoomFactor())
804            self._unschedulePages(layout)
805
806    @contextlib.contextmanager
807    def keepCentered(self, pos=None):
808        """Context manager to keep the same spot centered while changing the layout.
809
810        If pos is not given, the viewport's center is used.
811        After yielding, updatePageLayout() is called.
812
813        """
814        if pos is None:
815            pos = self.viewport().rect().center()
816
817        # find the spot on the page
818        layout = self._pageLayout
819        layout_pos = self.layoutPosition()
820        pos_on_layout = pos - layout_pos
821        offset = layout.pos2offset(pos_on_layout)
822        pos_on_layout -= layout.pos()   # pos() of the layout might change
823
824        yield
825        self.updatePageLayout()
826
827        new_pos_on_layout = layout.offset2pos(offset) - layout.pos()
828        diff = new_pos_on_layout - pos
829        self.verticalScrollBar().setValue(diff.y())
830        self.horizontalScrollBar().setValue(diff.x())
831
832    def setZoomFactor(self, factor, pos=None):
833        """Set the zoom factor (1.0 by default).
834
835        If pos is given, that position (in viewport coordinates) is kept in the
836        center if possible. If None, zooming centers around the viewport center.
837
838        """
839        factor = max(self.MIN_ZOOM, min(self.MAX_ZOOM, factor))
840        if factor != self._pageLayout.zoomFactor:
841            with self.keepCentered(pos):
842                self._pageLayout.zoomFactor = factor
843            if self._pageLayout.zoomsToFit():
844                self.setViewMode(FixedScale)
845            self.zoomFactorChanged.emit(factor)
846            self._unschedulePages(self._pageLayout)
847
848    def zoomFactor(self):
849        """Return the page layout's zoom factor."""
850        return self._pageLayout.zoomFactor
851
852    def zoomIn(self, pos=None, factor=1.1):
853        """Zoom in.
854
855        If pos is given, it is the position in the viewport to keep centered.
856        Otherwise zooming centers around the viewport center.
857
858        """
859        self.setZoomFactor(self.zoomFactor() * factor, pos)
860
861    def zoomOut(self, pos=None, factor=1.1):
862        """Zoom out.
863
864        If pos is given, it is the position in the viewport to keep centered.
865        Otherwise zooming centers around the viewport center.
866
867        """
868        self.setZoomFactor(self.zoomFactor() / factor, pos)
869
870    def zoomNaturalSize(self, pos=None):
871        """Zoom to the natural pixel size of the current page.
872
873        The natural pixel size zoom factor can be different than 1.0, if the
874        screen's DPI differs from the current page's DPI.
875
876        """
877        p = self.currentPage()
878        factor = p.dpi / self.physicalDpiX() if p else 1.0
879        self.setZoomFactor(factor, pos)
880
881    def layoutPosition(self):
882        """Return the position of the PageLayout relative to the viewport.
883
884        This is the top-left position of the layout, relative to the
885        top-left position of the viewport.
886
887        If the layout is smaller than the viewport it is centered by default.
888        (See ScrollArea.alignment.)
889
890        """
891        return self.areaPos() - self._pageLayout.pos()
892
893    def visibleRect(self):
894        """Return the QRect of the page layout that is currently visible in the viewport."""
895        return self.visibleArea().translated(self._pageLayout.pos())
896
897    def visiblePages(self, rect=None):
898        """Yield the Page instances that are currently visible.
899
900        If rect is not given, the visibleRect() is used.  The pages are sorted
901        so that the pages with the largest visible part come first.
902
903        """
904        if rect is None:
905            rect = self.visibleRect()
906        def key(page):
907            overlayrect = rect & page.geometry()
908            return overlayrect.width() * overlayrect.height()
909        return sorted(self._pageLayout.pagesAt(rect), key=key, reverse=True)
910
911    def ensureVisible(self, rect, margins=None, allowKinetic=True):
912        """Ensure rect is visible, switching page set if necessary."""
913        if not any(self.pageLayout().pagesAt(rect)):
914            if self.continuousMode():
915                return
916            # we might need to switch page set
917            # find the rect
918            for p in layout.PageRects(self.pageLayout()).intersecting(*rect.getCoords()):
919                num = self.pageLayout().index(p)
920                self.displayPageSet(self.pageLayout().pageSet(num))
921                break
922            else:
923                return
924        rect = rect.translated(-self._pageLayout.pos())
925        super().ensureVisible(rect, margins, allowKinetic)
926
927    def adjustCursor(self, pos):
928        """Sets the correct mouse cursor for the position on the page."""
929        pass
930
931    def repaintPage(self, page):
932        """Call this when you want to redraw the specified page."""
933        rect = page.geometry().translated(self.layoutPosition())
934        self.viewport().update(rect)
935
936    def lazyUpdate(self, page=None):
937        """Lazily repaint page (if visible) or all visible pages.
938
939        Defers updating the viewport for a page until all rendering tasks for
940        that page have finished. This reduces flicker.
941
942        """
943        viewport = self.viewport()
944        full = True
945        updates = []
946        for p in self.visiblePages():
947            rect = self.visibleRect() & p.geometry()
948            if rect and p.renderer:
949                imgs, missing, key, *rest = p.renderer.info(p, viewport, rect.translated(-p.pos()))
950                if missing:
951                    full = False
952                    if page is p or page is None:
953                        p.renderer.schedule(p, key, missing, self.lazyUpdate)
954                elif page is p or page is None:
955                    updates.append(rect.translated(self.layoutPosition()))
956        if full:
957            viewport.update()
958        elif updates:
959            viewport.update(sum(updates, QRegion()))
960
961    def rerender(self, page=None):
962        """Schedule the specified page or all pages for rerendering.
963
964        Call this when you have changed render options or page contents.
965        Repaints the page or visible pages lazily, reducing flicker.
966
967        """
968        renderers = collections.defaultdict(list)
969        pages = (page,) if page else self._pageLayout
970        for p in pages:
971            if p.renderer:
972                renderers[p.renderer].append(p)
973        for renderer, pages in renderers.items():
974            renderer.invalidate(pages)
975        self.lazyUpdate(page)
976
977    def _unschedulePages(self, pages):
978        """(Internal.)
979        Unschedule rendering of pages that are pending but not needed anymore.
980
981        Called inside paintEvent, on zoomFactor change and some other places.
982        This prevents rendering jobs hogging the cpu for pages that are deleted
983        or out of view.
984
985        """
986        unschedule = collections.defaultdict(set)
987        for page in pages:
988            if page.renderer:
989                unschedule[page.renderer].add(page)
990        for renderer, pages in unschedule.items():
991            renderer.unschedule(pages, self.repaintPage)
992
993    def pagesToPaint(self, rect, painter):
994        """Yield (page, rect) to paint in the specified rectangle.
995
996        The specified rect is in viewport coordinates, as in the paint event.
997        The returned rect describes the part of the page actually to draw, in
998        page coordinates. (The full rect can be found in page.rect().)
999        Translates the painter to the top left of each page.
1000
1001        The pages are sorted with largest area last.
1002
1003        """
1004        layout_pos = self.layoutPosition()
1005        ev_rect = rect.translated(-layout_pos)
1006        for p in self.visiblePages(ev_rect):
1007            r = (p.geometry() & ev_rect).translated(-p.pos())
1008            painter.save()
1009            painter.translate(layout_pos + p.pos())
1010            yield p, r
1011            painter.restore()
1012
1013    def event(self, ev):
1014        """Reimplemented to get Gesture events."""
1015        if isinstance(ev, QGestureEvent) and self.handleGestureEvent(ev):
1016            ev.accept() # Accepts all gestures in the event
1017            return True
1018        return super().event(ev)
1019
1020    def handleGestureEvent(self, event):
1021        """Gesture event handler.
1022
1023        Return False if event is not accepted. Currently only cares about
1024        PinchGesture. Could also handle Swipe and Pan gestures.
1025
1026        """
1027        ## originally contributed by David Rydh, 2017
1028        pinch = event.gesture(Qt.PinchGesture)
1029        if pinch:
1030            return self.pinchGesture(pinch)
1031        return False
1032
1033    def pinchGesture(self, gesture):
1034        """Pinch gesture event handler.
1035
1036        Return False if event is not accepted. Currently only cares about
1037        ScaleFactorChanged and not RotationAngleChanged.
1038
1039        """
1040        ## originally contributed by David Rydh, 2017
1041        # Gesture start? Reset _pinchStartFactor in case we didn't
1042        # catch the finish event
1043        if gesture.state() == Qt.GestureStarted:
1044            self._pinchStartFactor = None
1045
1046        changeFlags = gesture.changeFlags()
1047        if changeFlags & QPinchGesture.ScaleFactorChanged:
1048            factor = gesture.property("totalScaleFactor")
1049            if not self._pinchStartFactor: # Gesture start?
1050                self._pinchStartFactor = self.zoomFactor()
1051            self.setZoomFactor(self._pinchStartFactor * factor,
1052                      self.mapFromGlobal(gesture.hotSpot().toPoint()))
1053
1054        # Gesture finished?
1055        if gesture.state() in (Qt.GestureFinished, Qt.GestureCanceled):
1056            self._pinchStartFactor = None
1057
1058        return True
1059
1060    def paintEvent(self, ev):
1061        """Paint the contents of the viewport."""
1062        painter = QPainter(self.viewport())
1063        pages_to_paint = set()
1064        for p, r in self.pagesToPaint(ev.rect(), painter):
1065            p.paint(painter, r, self.repaintPage)
1066            pages_to_paint.add(p)
1067
1068        # remove pending render jobs for pages that were visible, but are not
1069        # visible now
1070        rect = self.visibleRect()
1071        pages = set(page
1072            for page in self._prev_pages_to_paint - pages_to_paint
1073                if not rect.intersects(page.geometry()))
1074        self._unschedulePages(pages)
1075        self._prev_pages_to_paint = pages_to_paint
1076
1077    def resizeEvent(self, ev):
1078        """Reimplemented to scale the view if needed and update the scrollbars."""
1079        if self._viewMode and not self._pageLayout.empty():
1080            with self.pagingOnScrollDisabled():
1081                # sensible repositioning
1082                vbar = self.verticalScrollBar()
1083                hbar = self.horizontalScrollBar()
1084                x, xm = hbar.value(), hbar.maximum()
1085                y, ym = vbar.value(), vbar.maximum()
1086                self.fitPageLayout()
1087                self.updatePageLayout()
1088                if xm: hbar.setValue(round(x * hbar.maximum() / xm))
1089                if ym: vbar.setValue(round(y * vbar.maximum() / ym))
1090        super().resizeEvent(ev)
1091
1092    def wheelEvent(self, ev):
1093        """Reimplemented to support wheel zooming and paging through page sets."""
1094        if self.wheelZoomingEnabled and ev.angleDelta().y() and ev.modifiers() & Qt.CTRL:
1095            factor = 1.1 ** util.sign(ev.angleDelta().y())
1096            self.setZoomFactor(self.zoomFactor() * factor, ev.pos())
1097        elif not ev.modifiers():
1098            # if scrolling is not possible, try going to next or previous pageset.
1099            sb = self.verticalScrollBar()
1100            sp = self.strictPagingEnabled
1101            if ev.angleDelta().y() > 0 and sb.value() == 0:
1102                self.gotoPreviousPage() if sp else self.displayPageSet("previous")
1103            elif ev.angleDelta().y() < 0 and sb.value() == sb.maximum():
1104                self.gotoNextPage() if sp else self.displayPageSet("next")
1105            else:
1106                super().wheelEvent(ev)
1107        else:
1108            super().wheelEvent(ev)
1109
1110    def mousePressEvent(self, ev):
1111        """Implemented to set the clicked page as current, without moving it."""
1112        if self.clickToSetCurrentPageEnabled:
1113            page = self._pageLayout.pageAt(ev.pos() - self.layoutPosition())
1114            if page:
1115                num = self._pageLayout.index(page) + 1
1116                self.updateCurrentPageNumber(num)
1117        super().mousePressEvent(ev)
1118
1119    def mouseMoveEvent(self, ev):
1120        """Implemented to adjust the mouse cursor depending on the page contents."""
1121        # no cursor updates when dragging the background is busy, see scrollarea.py.
1122        if not self.isDragging():
1123            self.adjustCursor(ev.pos())
1124        super().mouseMoveEvent(ev)
1125
1126    def keyPressEvent(self, ev):
1127        """Reimplemented to go to next or previous page set if possible."""
1128        # ESC clears the selection, if any.
1129        if (ev.key() == Qt.Key_Escape and not ev.modifiers()
1130            and self.rubberband() and self.rubberband().hasSelection()):
1131            self.rubberband().clearSelection()
1132            return
1133
1134        # Paging through page sets?
1135        sb = self.verticalScrollBar()
1136        sp = self.strictPagingEnabled
1137        if ev.key() == Qt.Key_PageUp and sb.value() == 0:
1138            self.gotoPreviousPage() if sp else self.displayPageSet("previous")
1139        elif ev.key() == Qt.Key_PageDown and sb.value() == sb.maximum():
1140            self.gotoNextPage() if sp else self.displayPageSet("next")
1141        elif ev.key() == Qt.Key_Home and ev.modifiers() == Qt.ControlModifier:
1142            self.setCurrentPageNumber(1) if sp else self.displayPageSet("first")
1143        elif ev.key() == Qt.Key_End and ev.modifiers() == Qt.ControlModifier:
1144            self.setCurrentPageNumber(self.pageCount()) if sp else self.displayPageSet("last")
1145        else:
1146            super().keyPressEvent(ev)
1147
1148
1149class ViewProperties:
1150    """Simple helper class encapsulating certain settings of a View.
1151
1152    The settings can be set to and got from a View, and saved to or loaded
1153    from a QSettings group.
1154
1155    Class attributes serve as default values, None means: no change.
1156    All methods return self, so operations can easily be chained.
1157
1158    If you inherit from a View and add more settings, you can also add
1159    properties to this class by inheriting from it. Reimplement
1160    View.properties() to return an instance of your new ViewProperties
1161    subclass.
1162
1163    """
1164    position = None
1165    rotation = Rotate_0
1166    zoomFactor = 1.0
1167    viewMode = FixedScale
1168    orientation = None
1169    continuousMode = None
1170    pageLayoutMode = None
1171
1172    def setdefaults(self):
1173        """Set all properties to default values. Also used by View on init."""
1174        self.orientation = Vertical
1175        self.continuousMode = True
1176        self.pageLayoutMode = "single"
1177        return self
1178
1179    def copy(self):
1180        """Return a copy or ourselves."""
1181        cls = type(self)
1182        props = cls.__new__(cls)
1183        props.__dict__.update(self.__dict__)
1184        return props
1185
1186    def names(self):
1187        """Return a tuple with all the property names we support."""
1188        return (
1189            'position',
1190            'rotation',
1191            'zoomFactor',
1192            'viewMode',
1193            'orientation',
1194            'continuousMode',
1195            'pageLayoutMode',
1196        )
1197
1198    def mask(self, names):
1199        """Set properties not listed in names to None."""
1200        for name in self.names():
1201            if name not in names and getattr(self, name) is not None:
1202                setattr(self, name, None)
1203        return self
1204
1205    def get(self, view):
1206        """Get the properties of a View."""
1207        self.position = view.position()
1208        self.rotation = view.rotation()
1209        self.orientation = view.orientation()
1210        self.viewMode = view.viewMode()
1211        self.zoomFactor = view.zoomFactor()
1212        self.continuousMode = view.continuousMode()
1213        self.pageLayoutMode = view.pageLayoutMode()
1214        return self
1215
1216    def set(self, view):
1217        """Set all our properties that are not None to a View."""
1218        if self.pageLayoutMode is not None:
1219            view.setPageLayoutMode(self.pageLayoutMode)
1220        if self.rotation is not None:
1221            view.setRotation(self.rotation)
1222        if self.orientation is not None:
1223            view.setOrientation(self.orientation)
1224        if self.continuousMode is not None:
1225            view.setContinuousMode(self.continuousMode)
1226        if self.viewMode is not None:
1227            view.setViewMode(self.viewMode)
1228        if self.zoomFactor is not None:
1229            if self.viewMode is FixedScale or not view.pageLayout().zoomsToFit():
1230                view.setZoomFactor(self.zoomFactor)
1231        if self.position is not None:
1232            view.setPosition(self.position, False)
1233        return self
1234
1235    def save(self, settings):
1236        """Save the properties that are not None to a QSettings group."""
1237        if self.pageLayoutMode is not None:
1238            settings.setValue("pageLayoutMode", self.pageLayoutMode)
1239        else:
1240            settings.remove("pageLayoutMode")
1241        if self.rotation is not None:
1242            settings.setValue("rotation", self.rotation)
1243        else:
1244            settings.remove("rotation")
1245        if self.orientation is not None:
1246            settings.setValue("orientation", self.orientation)
1247        else:
1248            settings.remove("orientation")
1249        if self.continuousMode is not None:
1250            settings.setValue("continuousMode", self.continuousMode)
1251        else:
1252            settings.remove("continuousMode")
1253        if self.viewMode is not None:
1254            settings.setValue("viewMode", self.viewMode)
1255        else:
1256            settings.remove("viewMode")
1257        if self.zoomFactor is not None:
1258            settings.setValue("zoomFactor", self.zoomFactor)
1259        else:
1260            settings.remove("zoomFactor")
1261        if self.position is not None:
1262            settings.setValue("position/pageNumber", self.position.pageNumber)
1263            settings.setValue("position/x", self.position.x)
1264            settings.setValue("position/y", self.position.y)
1265        else:
1266            settings.remove("position")
1267        return self
1268
1269    def load(self, settings):
1270        """Load the properties from a QSettings group."""
1271        if settings.contains("pageLayoutMode"):
1272            v = settings.value("pageLayoutMode", "", str)
1273            if v:
1274                self.pageLayoutMode = v
1275        if settings.contains("rotation"):
1276            v = settings.value("rotation", -1, int)
1277            if v in (Rotate_0, Rotate_90, Rotate_180, Rotate_270):
1278                self.rotation = v
1279        if settings.contains("orientation"):
1280            v = settings.value("orientation", 0, int)
1281            if v in (Horizontal, Vertical):
1282                self.orientation = v
1283        if settings.contains("continuousMode"):
1284            v = settings.value("continuousMode", True, bool)
1285            self.continuousMode = v
1286        if settings.contains("viewMode"):
1287            v = settings.value("viewMode", -1, int)
1288            if v in (FixedScale, FitHeight, FitWidth, FitBoth):
1289                self.viewMode = v
1290        if settings.contains("zoomFactor"):
1291            v = settings.value("zoomFactor", 0, float)
1292            if v:
1293                self.zoomFactor = v
1294        if settings.contains("position/pageNumber"):
1295            pageNumber = settings.value("position/pageNumber", -1, int)
1296            if pageNumber != -1:
1297                x = settings.value("position/x", 0.0, float)
1298                y = settings.value("position/y", 0.0, float)
1299                self.position = Position(pageNumber, x, y)
1300        return self
1301
1302
1303class DocumentPropertyStore:
1304    """Store ViewProperties (settings) on a per-Document basis.
1305
1306    If you create a DocumentPropertyStore and install it in the
1307    documentPropertyStore attribute of a View, the View will automatically
1308    remember its settings for earlier displayed Document instances.
1309
1310    """
1311
1312    default = None
1313    mask = None
1314
1315    def __init__(self):
1316        self._properties = weakref.WeakKeyDictionary()
1317
1318    def get(self, document):
1319        """Get the View properties stored for the document, if available.
1320
1321        If a ViewProperties instance is stored in the `default` attribute,
1322        it is returned when no properties were available. Otherwise, None
1323        is returned.
1324
1325        """
1326        props = self._properties.get(document)
1327        if props is None:
1328            if self.default:
1329                props = self.default
1330                if self.mask:
1331                    props = props.copy().mask(self.mask)
1332        return props
1333
1334    def set(self, document, properties):
1335        """Store the View properties for the document.
1336
1337        If the `mask` attribute is set to a list or tuple of names, only the
1338        listed properties are remembered.
1339
1340        """
1341        if self.mask:
1342            properties.mask(self.mask)
1343        self._properties[document] = properties
1344
1345