5 |
5 |
6 """ |
6 """ |
7 Module implementing a specialized PDF view class. |
7 Module implementing a specialized PDF view class. |
8 """ |
8 """ |
9 |
9 |
10 from PyQt6.QtCore import QSize, Qt, pyqtSlot, QEvent |
10 import collections |
11 from PyQt6.QtGui import QGuiApplication |
11 import enum |
|
12 |
|
13 from dataclasses import dataclass |
|
14 |
|
15 from PyQt6.QtCore import ( |
|
16 QSize, Qt, pyqtSlot, QEvent, QSizeF, QRect, QPoint, QPointF, QRectF, |
|
17 pyqtSignal |
|
18 ) |
|
19 from PyQt6.QtGui import QGuiApplication, QPainter, QColor, QPen |
|
20 from PyQt6.QtPdf import QPdfDocument, QPdfLink |
12 from PyQt6.QtPdfWidgets import QPdfView |
21 from PyQt6.QtPdfWidgets import QPdfView |
|
22 from PyQt6.QtWidgets import QRubberBand |
13 |
23 |
14 from .PdfZoomSelector import PdfZoomSelector |
24 from .PdfZoomSelector import PdfZoomSelector |
15 |
25 |
16 |
26 |
|
27 class PdfMarkerType(enum.Enum): |
|
28 """ |
|
29 Class defining the various marker types. |
|
30 """ |
|
31 |
|
32 SearchResult = 0 |
|
33 Selection = 1 |
|
34 |
|
35 |
|
36 @dataclass |
|
37 class PdfMarker: |
|
38 """ |
|
39 Class defining the data structure for markers. |
|
40 """ |
|
41 |
|
42 rectangle: QRectF |
|
43 markerType: PdfMarkerType |
|
44 |
|
45 |
|
46 @dataclass |
|
47 class PdfMarkerGeometry: |
|
48 """ |
|
49 Class defining the data structure for marker geometries. |
|
50 """ |
|
51 |
|
52 rectangle: QRect |
|
53 markerType: PdfMarkerType |
|
54 |
|
55 |
17 class PdfView(QPdfView): |
56 class PdfView(QPdfView): |
18 """ |
57 """ |
19 Class implementing a specialized PDF view. |
58 Class implementing a specialized PDF view. |
20 """ |
59 """ |
|
60 |
|
61 MarkerColors = { |
|
62 # merker type: (pen color, brush color) |
|
63 PdfMarkerType.SearchResult: (QColor(255, 200, 0, 255), QColor(255, 200, 0, 64)), |
|
64 PdfMarkerType.Selection: (QColor(0, 0, 255, 255), QColor(0, 0, 255, 64)), |
|
65 } |
|
66 |
|
67 selectionAvailable = pyqtSignal(bool) |
21 |
68 |
22 def __init__(self, parent): |
69 def __init__(self, parent): |
23 """ |
70 """ |
24 Constructor |
71 Constructor |
25 |
72 |
157 evt.accept() |
231 evt.accept() |
158 return |
232 return |
159 |
233 |
160 super().wheelEvent(evt) |
234 super().wheelEvent(evt) |
161 |
235 |
|
236 def keyPressEvent(self, evt): |
|
237 """ |
|
238 Protected method handling key press events. |
|
239 |
|
240 @param evt reference to the key event |
|
241 @type QKeyEvent |
|
242 """ |
|
243 if evt.key() == Qt.Key.Key_Escape: |
|
244 self.clearSelection() |
|
245 |
|
246 def mousePressEvent(self, evt): |
|
247 """ |
|
248 Protected method to handle mouse press events. |
|
249 |
|
250 @param evt reference to the mouse event |
|
251 @type QMouseEvent |
|
252 """ |
|
253 if evt.button() == Qt.MouseButton.LeftButton: |
|
254 self.clearMarkers(PdfMarkerType.Selection) |
|
255 self.selectionAvailable.emit(False) |
|
256 |
|
257 self.__rubberBandOrigin = evt.pos() |
|
258 if self.__rubberBand is None: |
|
259 self.__rubberBand = QRubberBand( |
|
260 QRubberBand.Shape.Rectangle, self.viewport() |
|
261 ) |
|
262 self.__rubberBand.setGeometry(QRect(self.__rubberBandOrigin, QSize())) |
|
263 self.__rubberBand.show() |
|
264 |
|
265 super().mousePressEvent(evt) |
|
266 |
|
267 def mouseMoveEvent(self, evt): |
|
268 """ |
|
269 Protected method to handle mouse move events. |
|
270 |
|
271 @param evt reference to the mouse event |
|
272 @type QMouseEvent |
|
273 """ |
|
274 if evt.buttons() & Qt.MouseButton.LeftButton: |
|
275 self.__rubberBand.setGeometry( |
|
276 QRect(self.__rubberBandOrigin, evt.pos()).normalized() |
|
277 ) |
|
278 |
|
279 super().mousePressEvent(evt) |
|
280 |
|
281 def mouseReleaseEvent(self, evt): |
|
282 """ |
|
283 Protected method to handle mouse release events. |
|
284 |
|
285 @param evt reference to the mouse event |
|
286 @type QMouseEvent |
|
287 """ |
|
288 if evt.button() == Qt.MouseButton.LeftButton: |
|
289 self.__rubberBand.hide() |
|
290 translatedRubber = self.__rubberBand.geometry().translated( |
|
291 self.__documentViewport.topLeft() |
|
292 ) |
|
293 for page in self.__pageGeometries: |
|
294 if self.__pageGeometries[page].intersects(translatedRubber): |
|
295 translatedRubber = translatedRubber.translated( |
|
296 -self.__pageGeometries[page].topLeft() |
|
297 ) |
|
298 factor = self.__zoomFactorForMode(self.zoomMode()) |
|
299 selectionSize = ( |
|
300 QSizeF(translatedRubber.size()) / factor |
|
301 / self.__screenResolution |
|
302 ) |
|
303 selectionTopLeft = ( |
|
304 QPointF(translatedRubber.topLeft()) / factor |
|
305 / self.__screenResolution |
|
306 ) |
|
307 selectionRect = QRectF(selectionTopLeft, selectionSize) |
|
308 selection = self.document().getSelection( |
|
309 page, selectionRect.topLeft(), selectionRect.bottomRight() |
|
310 ) |
|
311 if selection.isValid(): |
|
312 for bound in selection.bounds(): |
|
313 self.addMarker( |
|
314 page, bound.boundingRect(), PdfMarkerType.Selection |
|
315 ) |
|
316 self.selectionAvailable.emit(True) |
|
317 |
|
318 super().mousePressEvent(evt) |
|
319 |
162 def event(self, evt): |
320 def event(self, evt): |
163 """ |
321 """ |
164 Public method handling events. |
322 Public method handling events. |
165 |
323 |
166 @param evt reference to the event |
324 @param evt reference to the event |
191 self.zoomModeChanged.emit(QPdfView.ZoomMode.Custom) |
349 self.zoomModeChanged.emit(QPdfView.ZoomMode.Custom) |
192 zoomFactor = pinch.totalScaleFactor() |
350 zoomFactor = pinch.totalScaleFactor() |
193 self.setZoomFactor(zoomFactor) |
351 self.setZoomFactor(zoomFactor) |
194 self.zoomFactorChanged.emit(zoomFactor) |
352 self.zoomFactorChanged.emit(zoomFactor) |
195 evt.accept() |
353 evt.accept() |
|
354 |
|
355 def resizeEvent(self, evt): |
|
356 """ |
|
357 Protected method to handle a widget resize. |
|
358 |
|
359 @param evt reference to the resize event |
|
360 @type QResizeEvent |
|
361 """ |
|
362 super().resizeEvent(evt) |
|
363 |
|
364 self.__calculateDocumentViewport() |
|
365 |
|
366 def paintEvent(self, evt): |
|
367 """ |
|
368 Protected method to paint the view. |
|
369 |
|
370 This event handler calls the original paint event handler of the super class |
|
371 and paints the markers on top of the result. |
|
372 |
|
373 @param evt reference to the paint event |
|
374 @type QPaintEvent |
|
375 """ |
|
376 super().paintEvent(evt) |
|
377 |
|
378 painter = QPainter(self.viewport()) |
|
379 painter.translate(-self.__documentViewport.x(), -self.__documentViewport.y()) |
|
380 for page in self.__markerGeometries: |
|
381 for markerGeom in self.__markerGeometries[page]: |
|
382 if markerGeom.rectangle.intersects(self.__documentViewport): |
|
383 painter.setPen(QPen( |
|
384 PdfView.MarkerColors[markerGeom.markerType][0], 2 |
|
385 )) |
|
386 painter.setBrush(PdfView.MarkerColors[markerGeom.markerType][1]) |
|
387 painter.drawRect(markerGeom.rectangle) |
|
388 painter.end() |
|
389 |
|
390 def __calculateDocumentViewport(self): |
|
391 """ |
|
392 Private method to calculate the document viewport. |
|
393 |
|
394 This is a PyQt implementation of the code found in the QPdfView class |
|
395 because it is calculated in a private part and not accessible. |
|
396 """ |
|
397 x = self.horizontalScrollBar().value() |
|
398 y = self.verticalScrollBar().value() |
|
399 width = self.viewport().width() |
|
400 height = self.viewport().height() |
|
401 |
|
402 docViewport = QRect(x, y, width, height) |
|
403 if self.__documentViewport == docViewport: |
|
404 return |
|
405 |
|
406 oldSize = self.__documentViewport.size() |
|
407 |
|
408 self.__documentViewport = docViewport |
|
409 |
|
410 if oldSize != self.__documentViewport.size(): |
|
411 self.__calculateDocumentLayout() |
|
412 |
|
413 @pyqtSlot() |
|
414 def __calculateDocumentLayout(self): |
|
415 """ |
|
416 Private slot to calculate the document layout data. |
|
417 |
|
418 This is a PyQt implementation of the code found in the QPdfView class |
|
419 because it is calculated in a private part and not accessible. |
|
420 """ |
|
421 self.__documentSize = QSize() |
|
422 self.__pageGeometries.clear() |
|
423 self.__markerGeometries.clear() |
|
424 |
|
425 document = self.document() |
|
426 margins = self.documentMargins() |
|
427 |
|
428 if document is None or document.status() != QPdfDocument.Status.Ready: |
|
429 return |
|
430 |
|
431 pageCount = document.pageCount() |
|
432 |
|
433 totalWidth = 0 |
|
434 |
|
435 startPage = ( |
|
436 self.pageNavigator().currentPage() |
|
437 if self.pageMode == QPdfView.PageMode.SinglePage |
|
438 else 0 |
|
439 ) |
|
440 endPage = ( |
|
441 self.pageNavigator().currentPage() + 1 |
|
442 if self.pageMode == QPdfView.PageMode.SinglePage |
|
443 else pageCount |
|
444 ) |
|
445 |
|
446 # calculate pageSizes |
|
447 for page in range(startPage, endPage): |
|
448 if self.zoomMode() == QPdfView.ZoomMode.Custom: |
|
449 pageSize = QSizeF( |
|
450 document.pagePointSize(page) * self.__screenResolution |
|
451 * self.zoomFactor() |
|
452 ).toSize() |
|
453 elif self.zoomMode() == QPdfView.ZoomMode.FitToWidth: |
|
454 pageSize = QSizeF( |
|
455 document.pagePointSize(page) * self.__screenResolution |
|
456 ).toSize() |
|
457 factor = ( |
|
458 self.__documentViewport.width() - margins.left() - margins.right() |
|
459 ) / pageSize.width() |
|
460 pageSize *= factor |
|
461 elif self.zoomMode() == QPdfView.ZoomMode.FitInView: |
|
462 viewportSize = ( |
|
463 self.__documentViewport.size() |
|
464 + QSize(-margins.left() - margins.right(), -self.pageSpacing()) |
|
465 ) |
|
466 pageSize = QSizeF( |
|
467 document.pagePointSize(page) * self.__screenResolution |
|
468 ).toSize() |
|
469 pageSize = pageSize.scaled( |
|
470 viewportSize, Qt.AspectRatioMode.KeepAspectRatio |
|
471 ) |
|
472 |
|
473 totalWidth = max(totalWidth, pageSize.width()) |
|
474 |
|
475 self.__pageGeometries[page] = QRect(QPoint(0, 0), pageSize) |
|
476 |
|
477 totalWidth += margins.left() + margins.right() |
|
478 |
|
479 pageY = margins.top() |
|
480 |
|
481 # calculate page positions |
|
482 for page in range(startPage, endPage): |
|
483 pageSize = self.__pageGeometries[page].size() |
|
484 |
|
485 # center horizontally inside the viewport |
|
486 pageX = ( |
|
487 max(totalWidth, self.__documentViewport.width()) - pageSize.width() |
|
488 ) // 2 |
|
489 self.__pageGeometries[page].moveTopLeft(QPoint(pageX, pageY)) |
|
490 |
|
491 self.__calculateMarkerGeometries(page, QPoint(pageX, pageY)) |
|
492 |
|
493 pageY += pageSize.height() + self.pageSpacing() |
|
494 |
|
495 pageY += margins.bottom() |
|
496 |
|
497 self.__documentSize = QSize(totalWidth, pageY) |
|
498 |
|
499 @pyqtSlot() |
|
500 def __currentPageChanged(self): |
|
501 """ |
|
502 Private slot to handle a change of the current page. |
|
503 """ |
|
504 if self.pageMode() == QPdfView.PageMode.SinglePage: |
|
505 self.__calculateDocumentLayout() |
|
506 |
|
507 def __calculateMarkerGeometries(self, page, offset): |
|
508 """ |
|
509 Private method to calculate the marker geometries. |
|
510 |
|
511 @param page page number |
|
512 @type int |
|
513 @param offset page offset |
|
514 @type QPoint or QPointF |
|
515 """ |
|
516 # calculate search marker sizes |
|
517 if page in self.__markers: |
|
518 factor = self.__zoomFactorForMode(self.zoomMode()) |
|
519 for marker in self.__markers[page]: |
|
520 markerSize = ( |
|
521 QSizeF(marker.rectangle.size()) * factor * self.__screenResolution |
|
522 ).toSize() |
|
523 markerTopLeft = ( |
|
524 QPointF(marker.rectangle.topLeft()) * factor |
|
525 * self.__screenResolution |
|
526 ).toPoint() |
|
527 |
|
528 markerGeometry = QRect(markerTopLeft, markerSize) |
|
529 self.__markerGeometries[page].append( |
|
530 PdfMarkerGeometry( |
|
531 rectangle=markerGeometry.translated(offset), |
|
532 markerType=marker.markerType |
|
533 ) |
|
534 ) |
|
535 |
|
536 def scrollContentsBy(self, dx, dy): |
|
537 """ |
|
538 Public method called when the scrollbars are moved. |
|
539 |
|
540 @param dx change of the horizontal scroll bar |
|
541 @type int |
|
542 @param dy change of the vertical scroll bar |
|
543 @type int |
|
544 """ |
|
545 super().scrollContentsBy(dx, dy) |
|
546 |
|
547 self.__calculateDocumentViewport() |
|
548 |
|
549 def __updateView(self): |
|
550 """ |
|
551 Private method to update the view. |
|
552 """ |
|
553 self.__calculateDocumentLayout() |
|
554 self.update() |
|
555 |
|
556 @pyqtSlot(int, QRectF, PdfMarkerType) |
|
557 @pyqtSlot(int, QRect, PdfMarkerType) |
|
558 def addMarker(self, page, rect, markerType): |
|
559 """ |
|
560 Public slot to add a marker. |
|
561 |
|
562 @param page page number for the marker |
|
563 @type int |
|
564 @param rect marker rectangle |
|
565 @type QRect or QRectF |
|
566 @param markerType type of the marker |
|
567 @type PdfMarkerType |
|
568 """ |
|
569 marker = PdfMarker(rectangle=QRectF(rect), markerType=markerType) |
|
570 if marker not in self.__markers[page]: |
|
571 self.__markers[page].append(marker) |
|
572 self.__updateView() |
|
573 |
|
574 @pyqtSlot(PdfMarkerType) |
|
575 def clearMarkers(self, markerType): |
|
576 """ |
|
577 Public slot to clear the markers of a specific type. |
|
578 |
|
579 @param markerType type of the marker |
|
580 @type PdfMarkerType |
|
581 """ |
|
582 markers = collections.defaultdict(list) |
|
583 for page in self.__markers: |
|
584 markersList = [ |
|
585 m for m in self.__markers[page] if m.markerType != markerType |
|
586 ] |
|
587 if markersList: |
|
588 markers[page] = markersList |
|
589 |
|
590 self.__markers = markers |
|
591 self.__updateView() |
|
592 |
|
593 @pyqtSlot() |
|
594 def clearAllMarkers(self): |
|
595 """ |
|
596 Public slot to clear all markers. |
|
597 """ |
|
598 self.__markers.clear() |
|
599 self.__updateView() |
|
600 |
|
601 @pyqtSlot(QPdfLink) |
|
602 def addSearchMarker(self, link): |
|
603 """ |
|
604 Public slot to add a search marker given a PDF link. |
|
605 |
|
606 @param link reference to the PDF link object |
|
607 @type QPdfLink |
|
608 """ |
|
609 for rect in link.rectangles(): |
|
610 self.addMarker(link.page(), rect, PdfMarkerType.SearchResult) |
|
611 |
|
612 @pyqtSlot() |
|
613 def clearSearchMarkers(self): |
|
614 """ |
|
615 Public slot to clear the search markers. |
|
616 """ |
|
617 self.clearMarkers(PdfMarkerType.SearchResult) |
|
618 |
|
619 def hasSelection(self): |
|
620 """ |
|
621 Public method to check the presence of a selection. |
|
622 |
|
623 @return flag indicating the presence of a selection |
|
624 @rtype bool |
|
625 """ |
|
626 return any( |
|
627 m.markerType == PdfMarkerType.Selection |
|
628 for p in self.__markers |
|
629 for m in self.__markers[p] |
|
630 ) |
|
631 |
|
632 def getSelection(self): |
|
633 """ |
|
634 Public method to get a PDF selection object. |
|
635 |
|
636 @return reference to the PDF selection object |
|
637 @rtype QPdfSelection |
|
638 """ |
|
639 for page in self.__markers: |
|
640 markersList = [ |
|
641 m for m in self.__markers[page] |
|
642 if m.markerType == PdfMarkerType.Selection |
|
643 ] |
|
644 if markersList: |
|
645 selection = self.document().getSelection( |
|
646 page, |
|
647 markersList[0].rectangle.topLeft(), |
|
648 markersList[-1].rectangle.bottomRight(), |
|
649 ) |
|
650 if selection.isValid(): |
|
651 return selection |
|
652 |
|
653 return None |
|
654 |
|
655 @pyqtSlot() |
|
656 def clearSelection(self): |
|
657 """ |
|
658 Public slot to clear the current selection. |
|
659 """ |
|
660 self.clearMarkers(PdfMarkerType.Selection) |