1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374
|
# -*- coding: utf-8 -*-
#
# This file is part of the qpageview package.
#
# Copyright (c) 2019 - 2019 by Wilbert Berendsen
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
# See http://www.gnu.org/licenses/ for more information.
"""
A MultiPage has no contents itself (but it has a size!), and renders a list of
embedded pages.
The MultiPageRenderer has the same interface as an ordinary renderer, but defers
rendering to the renderer of the embedded pages.
"""
import collections
import itertools
from PyQt5.QtCore import QPoint, QRect, QRectF, Qt
from PyQt5.QtGui import QColor, QImage, QPainter, QPixmap, QRegion, QTransform
from . import document
from . import page
from . import render
class MultiPage(page.AbstractRenderedPage):
"""A special Page that has a list of embedded sub pages.
The sub pages are in the pages attribute, the first one is on top.
The position and size of the embedded pages is set in the updateSize()
method, which is inherited from AbstractPage. By default all sub pages
are centered in their natural size.
Rotation of sub pages is relative to the MultiPage.
The `scalePages` instance attribute can be used to multiply the zoomfactor
for the sub pages.
The `opaquePages` instance attribute optimizes some procedures when set to
True (i.e. it prevents rendering sub pages that are hidden below others).
By default, only links in the first sub page are handled.
Set `linksOnlyFirstSubPage` to False if you want links in all sub pages.
"""
scalePages = 1.0
opaquePages = True
linksOnlyFirstSubPage = True
def __init__(self, renderer=None):
self.pages = []
if renderer is not None:
self.renderer = renderer
@classmethod
def createPages(cls, pageLists, renderer=None, pad=page.BlankPage):
"""Yield pages, taking each page from every pageList.
If pad is given and is not None, it is a callable that instantiates
blank pages, to pad the shorter pageLists with. In that case, the
returned list of pages has the same length as the longest pageList
given. If pad is None, the returned list of pages has the same length
as the shortest pageList given.
"""
it = itertools.zip_longest(*pageLists) if pad else zip(*pageLists)
for pages in it:
page = cls(renderer)
page.pages[:] = (p if p else pad() for p in pages)
yield page
def copy(self, owner=None, matrix=None):
"""Reimplemented to also copy the sub pages."""
page = super().copy(owner, matrix)
page.pages = [p.copy(owner, matrix) for p in self.pages]
return page
def updateSize(self, dpiX, dpiY, zoomFactor):
"""Reimplemented to also position our sub-pages.
The default implementation of this method zooms the sub pages
at the zoom level of the page * self.scalePages.
"""
super().updateSize(dpiX, dpiY, zoomFactor)
# zoom the sub pages, using the same zoomFactor
for page in self.pages:
page.computedRotation = (page.rotation + self.computedRotation) & 3
page.updateSize(dpiX, dpiY, zoomFactor * self.scalePages)
self.updatePagePositions()
def updatePagePositions(self):
"""Called by updateSize(), set the page positions.
The default implementation of this method centers the pages.
"""
# center the pages
center = self.rect().center()
for page in self.pages:
r = page.rect()
r.moveCenter(center)
page.setGeometry(r)
def visiblePagesAt(self, rect):
"""Yield (page, rect) for all subpages.
The rect may be invalid when opaquePages is False. If opaquePages is
True, pages outside rect or hidden below others are exclued. The
yielded rect is always valid in that case.
"""
if not self.opaquePages:
for p in self.pages:
yield p, rect & p.geometry()
else:
covered = QRegion()
for p in self.pages:
overlayrect = rect & p.geometry()
if not overlayrect or not QRegion(overlayrect).subtracted(covered):
continue # skip if this part is hidden below the other
covered += overlayrect
yield p, overlayrect
if not QRegion(rect).subtracted(covered):
break
def printablePagesAt(self, rect):
"""Yield (page, matrix) for all subpages that are visible in rect.
If opaquePages is True, excludes pages outside rect or hidden below
others. The matrix (QTransform) describes the transformation from the
page to the sub page. Rect is in original coordinates, as with the
print() method.
"""
origmatrix = self.transform().inverted()[0] # map pos to original page
origmatrix.scale(self.scaleX, self.scaleY) # undo the scaling done in printing.py
for p, r in self.visiblePagesAt(self.mapToPage().rect(rect)):
center = origmatrix.map(QRectF(p.geometry()).center())
m = QTransform() # matrix from page to subpage
m.translate(center.x(), center.y())
m.rotate(p.rotation * 90) # rotation relative to us
m.scale(
self.scalePages * p.scaleX * self.dpi / p.dpi,
self.scalePages * p.scaleY * self.dpi / p.dpi)
m.translate(p.pageWidth / -2, p.pageHeight / -2)
yield p, m
def print(self, painter, rect=None, paperColor=None):
"""Prints our sub pages."""
if rect is None:
rect = self.pageRect()
else:
rect = rect & self.pageRect()
painter.translate(-rect.topLeft())
# print from bottom to top
for p, m in reversed(list(self.printablePagesAt(rect))):
# find center of the page corresponding to our center
painter.save()
painter.setTransform(m, True)
# handle rect clipping
clip = m.inverted()[0].mapRect(rect) & p.pageRect()
painter.fillRect(clip, paperColor or Qt.white) # draw a white background
painter.translate(clip.topLeft()) # the page will go back...
p.print(painter, clip)
painter.restore()
def text(self, rect):
"""Reimplemented to get text from sub pages."""
for p, rect in self.visiblePagesAt(rect):
if rect:
text = p.text(rect.translated(-p.pos()))
if text:
return text
def _linkPages(self, rect=None):
"""Internal. Yield the pages allowed for links (and visible in rect if given)."""
for p, rect in self.visiblePagesAt(rect or self.rect()):
yield p, rect
if self.linksOnlyFirstSubPage:
break
def linksAt(self, point):
"""Reimplemented to find links in sub pages."""
result = []
for p, rect in self._linkPages():
if point in rect:
result.extend(p.linksAt(point - p.pos()))
return result
def linksIn(self, rect):
"""Reimplemented to find links in sub pages."""
result = set()
for p, rect in self._linkPages(rect):
result.update(p.linksIn(rect.translated(-p.pos())))
return result
def linkRect(self, link):
"""Reimplemented to get correct area on the page the link belongs to."""
for p, r in self._linkPages():
if link in p.links():
return p.linkRect(link).translated(p.pos())
return QRect() # just in case
class MultiPageDocument(document.MultiSourceDocument):
"""A Document that combines pages from different documents."""
pageClass = MultiPage
def createPages(self):
pageLists = [[p.copy() for p in doc.pages()] for doc in self.sources()]
return self.pageClass.createPages(pageLists, self.renderer)
class MultiPageRenderer(render.AbstractRenderer):
"""A renderer that interfaces with the renderers of the sub pages of a MultiPage."""
def update(self, page, device, rect, callback=None):
"""Reimplemented to check/rerender (if needed) all sub pages."""
# make the call back return with the original page, not the overlay page
newcallback = CallBack(callback, page) if callback else None
ok = True
for p, overlayrect in page.visiblePagesAt(rect):
if (overlayrect and p.renderer and
not p.renderer.update(p, device, overlayrect.translated(-p.pos()), newcallback)):
ok = False
return ok
def paint(self, page, painter, rect, callback=None):
"""Reimplemented to paint all the sub pages on top of each other."""
# make the call back return with the original page, not the overlay page
newcallback = CallBack(callback, page) if callback else None
# get the device pixel ratio to paint for
try:
ratio = painter.device().devicePixelRatioF()
except AttributeError:
ratio = painter.device().devicePixelRatio()
pixmaps = []
covered = QRegion()
for p, overlayrect in page.visiblePagesAt(rect):
pixmap = QPixmap(overlayrect.size() * ratio)
if not pixmap.isNull():
pixmap.setDevicePixelRatio(ratio)
pt = QPainter(pixmap)
pt.translate(p.pos() - overlayrect.topLeft())
p.paint(pt, overlayrect.translated(-p.pos()), newcallback)
pt.end()
# even an empty pixmap is appended, otherwise the layer count when
# compositing goes awry
pos = overlayrect.topLeft()
pixmaps.append((pos, pixmap))
covered += overlayrect
if QRegion(rect).subtracted(covered):
painter.fillRect(rect, page.paperColor or self.paperColor)
self.combine(painter, pixmaps)
def image(self, page, rect, dpiX, dpiY, paperColor):
"""Return a QImage of the specified rectangle, of all images combined."""
overlays = []
# find out the scale used for the image, to be able to position the
# overlay images correctly (code copied from AbstractRenderer.image())
s = page.defaultSize()
hscale = s.width() * dpiX / page.dpi / page.width
vscale = s.height() * dpiY / page.dpi / page.height
ourscale = s.width() / page.width
for p, overlayrect in page.visiblePagesAt(rect):
# compute the correct resolution, find out which scale was
# used by updateSize() (which may have been reimplemented)
overlaywidth = p.pageWidth * p.scaleX * page.dpi / p.dpi
if p.computedRotation & 1:
overlayscale = overlaywidth / p.height
else:
overlayscale = overlaywidth / p.width
scale = ourscale / overlayscale
img = p.image(overlayrect.translated(-p.pos()), dpiX * scale, dpiY * scale, paperColor)
pos = overlayrect.topLeft() - rect.topLeft()
pos = QPoint(round(pos.x() * hscale), round(pos.y() * vscale))
overlays.append((pos, img))
image = QImage(rect.width() * hscale, rect.height() * vscale, self.imageFormat)
image.fill(paperColor or page.paperColor or self.paperColor)
self.combine(QPainter(image), overlays)
return image
def unschedule(self, pages, callback):
"""Reimplemented to unschedule all sub pages."""
for page in pages:
newcallback = CallBack(callback, page) if callback else None
for p in page.pages:
if p.renderer:
p.renderer.unschedule((p,), newcallback)
def invalidate(self, pages):
"""Reimplemented to invalidate the base and overlay pages."""
renderers = collections.defaultdict(list)
for page in pages:
for p in page.pages:
if p.renderer:
renderers[p.renderer].append(p)
for renderer, pages in renderers.items():
renderer.invalidate(pages)
def combine(self, painter, images):
"""Paints images on the painter.
Each image is a tuple(QPoint, QPixmap), describing where to draw.
The image on top is first, so drawing should start with the last.
"""
for pos, image in reversed(images):
if isinstance(image, QPixmap):
painter.drawPixmap(pos, image)
else:
painter.drawImage(pos, image)
class CallBack:
"""A wrapper for a callable that is called with the original Page."""
def __new__(cls, origcallable, page):
# if the callable is already a CallBack instance, just return it. This
# would happen if a MultiPage has a subpage that is also a MultiPage.
if cls == type(origcallable):
return origcallable
cb = object.__new__(cls)
cb.origcallable = origcallable
cb.page = page
return cb
def __hash__(self):
"""Return the hash of the original callable.
This way only one callback will be in the Job.callbacks attribute,
despite of multiple pages, and unscheduling a job with subpages still
works.
"""
return hash(self.origcallable)
def __call__(self, page):
"""Call the original callback with the original Page."""
self.origcallable(self.page)
# install a default renderer, so MultiPage can be used directly
MultiPage.renderer = MultiPageRenderer()
|