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