File: multipage.py

package info (click to toggle)
python-qpageview 0.6.2-5
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 780 kB
  • sloc: python: 5,215; makefile: 22
file content (374 lines) | stat: -rw-r--r-- 14,415 bytes parent folder | download | duplicates (2)
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()