1/****************************************************************************
2**
3** Copyright (C) 2020 The Qt Company Ltd.
4** Contact: http://www.qt.io/licensing/
5**
6** This file is part of the QtPDF module of the Qt Toolkit.
7**
8** $QT_BEGIN_LICENSE:LGPL3$
9** Commercial License Usage
10** Licensees holding valid commercial Qt licenses may use this file in
11** accordance with the commercial license agreement provided with the
12** Software or, alternatively, in accordance with the terms contained in
13** a written agreement between you and The Qt Company. For licensing terms
14** and conditions see http://www.qt.io/terms-conditions. For further
15** information use the contact form at http://www.qt.io/contact-us.
16**
17** GNU Lesser General Public License Usage
18** Alternatively, this file may be used under the terms of the GNU Lesser
19** General Public License version 3 as published by the Free Software
20** Foundation and appearing in the file LICENSE.LGPLv3 included in the
21** packaging of this file. Please review the following information to
22** ensure the GNU Lesser General Public License version 3 requirements
23** will be met: https://www.gnu.org/licenses/lgpl.html.
24**
25** GNU General Public License Usage
26** Alternatively, this file may be used under the terms of the GNU
27** General Public License version 2.0 or later as published by the Free
28** Software Foundation and appearing in the file LICENSE.GPL included in
29** the packaging of this file. Please review the following information to
30** ensure the GNU General Public License version 2.0 requirements will be
31** met: http://www.gnu.org/licenses/gpl-2.0.html.
32**
33** $QT_END_LICENSE$
34**
35****************************************************************************/
36import QtQuick 2.14
37import QtQuick.Controls 2.14
38import QtQuick.Layouts 1.14
39import QtQuick.Pdf 5.15
40import QtQuick.Shapes 1.14
41import QtQuick.Window 2.14
42
43Item {
44    // public API
45    // TODO 5.15: required property
46    property var document: undefined
47    property bool debug: false
48
49    property string selectedText
50    function selectAll() {
51        var currentItem = tableHelper.itemAtCell(tableHelper.cellAtPos(root.width / 2, root.height / 2))
52        if (currentItem)
53            currentItem.selection.selectAll()
54    }
55    function copySelectionToClipboard() {
56        var currentItem = tableHelper.itemAtCell(tableHelper.cellAtPos(root.width / 2, root.height / 2))
57        if (debug)
58            console.log("currentItem", currentItem, "sel", currentItem.selection.text)
59        if (currentItem)
60            currentItem.selection.copyToClipboard()
61    }
62
63    // page navigation
64    property alias currentPage: navigationStack.currentPage
65    property alias backEnabled: navigationStack.backAvailable
66    property alias forwardEnabled: navigationStack.forwardAvailable
67    function back() { navigationStack.back() }
68    function forward() { navigationStack.forward() }
69    function goToPage(page) {
70        if (page === navigationStack.currentPage)
71            return
72        goToLocation(page, Qt.point(-1, -1), 0)
73    }
74    function goToLocation(page, location, zoom) {
75        if (zoom > 0) {
76            navigationStack.jumping = true // don't call navigationStack.update() because we will push() instead
77            root.renderScale = zoom
78            tableView.forceLayout() // but do ensure that the table layout is correct before we try to jump
79            navigationStack.jumping = false
80        }
81        navigationStack.push(page, location, zoom) // actually jump
82    }
83    property vector2d jumpLocationMargin: Qt.vector2d(10, 10)  // px from top-left corner
84    property int currentPageRenderingStatus: Image.Null
85
86    // page scaling
87    property real renderScale: 1
88    property real pageRotation: 0
89    function resetScale() { root.renderScale = 1 }
90    function scaleToWidth(width, height) {
91        root.renderScale = width / (tableView.rot90 ? tableView.firstPagePointSize.height : tableView.firstPagePointSize.width)
92    }
93    function scaleToPage(width, height) {
94        var windowAspect = width / height
95        var pageAspect = tableView.firstPagePointSize.width / tableView.firstPagePointSize.height
96        if (tableView.rot90) {
97            if (windowAspect > pageAspect) {
98                root.renderScale = height / tableView.firstPagePointSize.width
99            } else {
100                root.renderScale = width / tableView.firstPagePointSize.height
101            }
102        } else {
103            if (windowAspect > pageAspect) {
104                root.renderScale = height / tableView.firstPagePointSize.height
105            } else {
106                root.renderScale = width / tableView.firstPagePointSize.width
107            }
108        }
109    }
110
111    // text search
112    property alias searchModel: searchModel
113    property alias searchString: searchModel.searchString
114    function searchBack() { --searchModel.currentResult }
115    function searchForward() { ++searchModel.currentResult }
116
117    id: root
118    PdfStyle { id: style }
119    TableView {
120        id: tableView
121        anchors.fill: parent
122        anchors.leftMargin: 2
123        model: modelInUse && root.document !== undefined ? root.document.pageCount : 0
124        // workaround to make TableView do scheduleRebuildTable(RebuildOption::All) in cases when forceLayout() doesn't
125        property bool modelInUse: true
126        function rebuild() {
127            modelInUse = false
128            modelInUse = true
129        }
130        // end workaround
131        rowSpacing: 6
132        property real rotationNorm: Math.round((360 + (root.pageRotation % 360)) % 360)
133        property bool rot90: rotationNorm == 90 || rotationNorm == 270
134        onRot90Changed: forceLayout()
135        property size firstPagePointSize: document === undefined ? Qt.size(0, 0) : document.pagePointSize(0)
136        property real pageHolderWidth: Math.max(root.width, document === undefined ? 0 :
137                                         (rot90 ? document.maxPageHeight : document.maxPageWidth) * root.renderScale)
138        contentWidth: document === undefined ? 0 : pageHolderWidth + vscroll.width + 2
139        rowHeightProvider: function(row) { return (rot90 ? document.pagePointSize(row).width : document.pagePointSize(row).height) * root.renderScale }
140        TableViewExtra {
141            id: tableHelper
142            tableView: tableView
143        }
144        delegate: Rectangle {
145            id: pageHolder
146            color: root.debug ? "beige" : "transparent"
147            Text {
148                visible: root.debug
149                anchors { right: parent.right; verticalCenter: parent.verticalCenter }
150                rotation: -90; text: pageHolder.width.toFixed(1) + "x" + pageHolder.height.toFixed(1) + "\n" +
151                                     image.width.toFixed(1) + "x" + image.height.toFixed(1)
152            }
153            implicitWidth: tableView.pageHolderWidth
154            implicitHeight: tableView.rot90 ? image.width : image.height
155            property alias selection: selection
156            Rectangle {
157                id: paper
158                width: image.width
159                height: image.height
160                rotation: root.pageRotation
161                anchors.centerIn: pinch.active ? undefined : parent
162                property size pagePointSize: document.pagePointSize(index)
163                property real pageScale: image.paintedWidth / pagePointSize.width
164                Image {
165                    id: image
166                    source: document.source
167                    currentFrame: index
168                    asynchronous: true
169                    fillMode: Image.PreserveAspectFit
170                    width: paper.pagePointSize.width * root.renderScale
171                    height: paper.pagePointSize.height * root.renderScale
172                    property real renderScale: root.renderScale
173                    property real oldRenderScale: 1
174                    onRenderScaleChanged: {
175                        image.sourceSize.width = paper.pagePointSize.width * renderScale
176                        image.sourceSize.height = 0
177                        paper.scale = 1
178                        searchHighlights.update()
179                    }
180                    onStatusChanged: {
181                        if (index === navigationStack.currentPage)
182                            root.currentPageRenderingStatus = status
183                    }
184                }
185                Shape {
186                    anchors.fill: parent
187                    visible: image.status === Image.Ready
188                    onVisibleChanged: searchHighlights.update()
189                    ShapePath {
190                        strokeWidth: -1
191                        fillColor: style.pageSearchResultsColor
192                        scale: Qt.size(paper.pageScale, paper.pageScale)
193                        PathMultiline {
194                            id: searchHighlights
195                            function update() {
196                                // paths could be a binding, but we need to be able to "kick" it sometimes
197                                paths = searchModel.boundingPolygonsOnPage(index)
198                            }
199                        }
200                    }
201                    Connections {
202                        target: searchModel
203                        // whenever the highlights on the _current_ page change, they actually need to change on _all_ pages
204                        // (usually because the search string has changed)
205                        function onCurrentPageBoundingPolygonsChanged() { searchHighlights.update() }
206                    }
207                    ShapePath {
208                        strokeWidth: -1
209                        fillColor: style.selectionColor
210                        scale: Qt.size(paper.pageScale, paper.pageScale)
211                        PathMultiline {
212                            paths: selection.geometry
213                        }
214                    }
215                }
216                Shape {
217                    anchors.fill: parent
218                    visible: image.status === Image.Ready && searchModel.currentPage === index
219                    ShapePath {
220                        strokeWidth: style.currentSearchResultStrokeWidth
221                        strokeColor: style.currentSearchResultStrokeColor
222                        fillColor: "transparent"
223                        scale: Qt.size(paper.pageScale, paper.pageScale)
224                        PathMultiline {
225                            paths: searchModel.currentResultBoundingPolygons
226                        }
227                    }
228                }
229                PinchHandler {
230                    id: pinch
231                    minimumScale: 0.1
232                    maximumScale: root.renderScale < 4 ? 2 : 1
233                    minimumRotation: root.pageRotation
234                    maximumRotation: root.pageRotation
235                    enabled: image.sourceSize.width < 5000
236                    onActiveChanged:
237                        if (active) {
238                            paper.z = 10
239                        } else {
240                            paper.z = 0
241                            var centroidInPoints = Qt.point(pinch.centroid.position.x / root.renderScale,
242                                                            pinch.centroid.position.y / root.renderScale)
243                            var centroidInFlickable = tableView.mapFromItem(paper, pinch.centroid.position.x, pinch.centroid.position.y)
244                            var newSourceWidth = image.sourceSize.width * paper.scale
245                            var ratio = newSourceWidth / image.sourceSize.width
246                            if (root.debug)
247                                console.log("pinch ended on page", index, "with centroid", pinch.centroid.position, centroidInPoints, "wrt flickable", centroidInFlickable,
248                                            "page at", pageHolder.x.toFixed(2), pageHolder.y.toFixed(2),
249                                            "contentX/Y were", tableView.contentX.toFixed(2), tableView.contentY.toFixed(2))
250                            if (ratio > 1.1 || ratio < 0.9) {
251                                var centroidOnPage = Qt.point(centroidInPoints.x * root.renderScale * ratio, centroidInPoints.y * root.renderScale * ratio)
252                                paper.scale = 1
253                                paper.x = 0
254                                paper.y = 0
255                                root.renderScale *= ratio
256                                tableView.forceLayout()
257                                if (tableView.rotationNorm == 0) {
258                                    tableView.contentX = pageHolder.x + tableView.originX + centroidOnPage.x - centroidInFlickable.x
259                                    tableView.contentY = pageHolder.y + tableView.originY + centroidOnPage.y - centroidInFlickable.y
260                                } else if (tableView.rotationNorm == 90) {
261                                    tableView.contentX = pageHolder.x + tableView.originX + image.height - centroidOnPage.y - centroidInFlickable.x
262                                    tableView.contentY = pageHolder.y + tableView.originY + centroidOnPage.x - centroidInFlickable.y
263                                } else if (tableView.rotationNorm == 180) {
264                                    tableView.contentX = pageHolder.x + tableView.originX + image.width - centroidOnPage.x - centroidInFlickable.x
265                                    tableView.contentY = pageHolder.y + tableView.originY + image.height - centroidOnPage.y - centroidInFlickable.y
266                                } else if (tableView.rotationNorm == 270) {
267                                    tableView.contentX = pageHolder.x + tableView.originX + centroidOnPage.y - centroidInFlickable.x
268                                    tableView.contentY = pageHolder.y + tableView.originY + image.width - centroidOnPage.x - centroidInFlickable.y
269                                }
270                                if (root.debug)
271                                    console.log("contentX/Y adjusted to", tableView.contentX.toFixed(2), tableView.contentY.toFixed(2), "y @top", pageHolder.y)
272                                tableView.returnToBounds()
273                            }
274                        }
275                    grabPermissions: PointerHandler.CanTakeOverFromAnything
276                }
277                DragHandler {
278                    id: textSelectionDrag
279                    acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus
280                    target: null
281                }
282                TapHandler {
283                    id: mouseClickHandler
284                    acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus
285                }
286                TapHandler {
287                    id: touchTapHandler
288                    acceptedDevices: PointerDevice.TouchScreen
289                    onTapped: {
290                        selection.clear()
291                        selection.forceActiveFocus()
292                    }
293                }
294                Repeater {
295                    model: PdfLinkModel {
296                        id: linkModel
297                        document: root.document
298                        page: image.currentFrame
299                    }
300                    delegate: Shape {
301                        x: rect.x * paper.pageScale
302                        y: rect.y * paper.pageScale
303                        width: rect.width * paper.pageScale
304                        height: rect.height * paper.pageScale
305                        visible: image.status === Image.Ready
306                        ShapePath {
307                            strokeWidth: style.linkUnderscoreStrokeWidth
308                            strokeColor: style.linkUnderscoreColor
309                            strokeStyle: style.linkUnderscoreStrokeStyle
310                            dashPattern: style.linkUnderscoreDashPattern
311                            startX: 0; startY: height
312                            PathLine { x: width; y: height }
313                        }
314                        MouseArea { // TODO switch to TapHandler / HoverHandler in 5.15
315                            id: linkMA
316                            anchors.fill: parent
317                            cursorShape: Qt.PointingHandCursor
318                            hoverEnabled: true
319                            onClicked: {
320                                if (page >= 0)
321                                    root.goToLocation(page, location, zoom)
322                                else
323                                    Qt.openUrlExternally(url)
324                            }
325                        }
326                        ToolTip {
327                            visible: linkMA.containsMouse
328                            delay: 1000
329                            text: page >= 0 ?
330                                      ("page " + (page + 1) +
331                                       " location " + location.x.toFixed(1) + ", " + location.y.toFixed(1) +
332                                       " zoom " + zoom) : url
333                        }
334                    }
335                }
336                PdfSelection {
337                    id: selection
338                    anchors.fill: parent
339                    document: root.document
340                    page: image.currentFrame
341                    renderScale: image.renderScale
342                    fromPoint: textSelectionDrag.centroid.pressPosition
343                    toPoint: textSelectionDrag.centroid.position
344                    hold: !textSelectionDrag.active && !mouseClickHandler.pressed
345                    onTextChanged: root.selectedText = text
346                    focus: true
347                }
348            }
349        }
350        ScrollBar.vertical: ScrollBar {
351            id: vscroll
352            property bool moved: false
353            onPositionChanged: moved = true
354            onActiveChanged: {
355                var cell = tableHelper.cellAtPos(root.width / 2, root.height / 2)
356                var currentItem = tableHelper.itemAtCell(cell)
357                var currentLocation = Qt.point(0, 0)
358                if (currentItem) { // maybe the delegate wasn't loaded yet
359                    currentLocation = Qt.point((tableView.contentX - currentItem.x + jumpLocationMargin.x) / root.renderScale,
360                                               (tableView.contentY - currentItem.y + jumpLocationMargin.y) / root.renderScale)
361                }
362                if (active) {
363                    moved = false
364                    // emitJumped false to avoid interrupting a pinch if TableView thinks it should scroll at the same time
365                    navigationStack.push(cell.y, currentLocation, root.renderScale, false)
366                } else if (moved) {
367                    navigationStack.update(cell.y, currentLocation, root.renderScale)
368                }
369            }
370        }
371        ScrollBar.horizontal: ScrollBar { }
372    }
373    onRenderScaleChanged: {
374        // if navigationStack.jumped changes the scale, don't turn around and update the stack again;
375        // and don't force layout either, because positionViewAtCell() will do that
376        if (navigationStack.jumping)
377            return
378        // make TableView rebuild from scratch, because otherwise it doesn't know the delegates are changing size
379        tableView.rebuild()
380        var cell = tableHelper.cellAtPos(root.width / 2, root.height / 2)
381        var currentItem = tableHelper.itemAtCell(cell)
382        if (currentItem) {
383            var currentLocation = Qt.point((tableView.contentX - currentItem.x + jumpLocationMargin.x) / root.renderScale,
384                                           (tableView.contentY - currentItem.y + jumpLocationMargin.y) / root.renderScale)
385            navigationStack.update(cell.y, currentLocation, renderScale)
386        }
387    }
388    PdfNavigationStack {
389        id: navigationStack
390        property bool jumping: false
391        property int previousPage: 0
392        onJumped: {
393            jumping = true
394            root.renderScale = zoom
395            if (location.y < 0) {
396                // invalid to indicate that a specific location was not needed,
397                // so attempt to position the new page just as the current page is
398                var currentYOffset = 0
399                var previousPageDelegate = tableHelper.itemAtCell(0, previousPage)
400                if (previousPageDelegate)
401                    currentYOffset = tableView.contentY - previousPageDelegate.y
402                tableHelper.positionViewAtRow(page, Qt.AlignTop, currentYOffset)
403                if (root.debug) {
404                    console.log("going from page", previousPage, "to", page, "offset", currentYOffset,
405                                "ended up @", tableView.contentX.toFixed(1) + ", " + tableView.contentY.toFixed(1))
406                }
407            } else {
408                // jump to a page and position the given location relative to the top-left corner of the viewport
409                var pageSize = root.document.pagePointSize(page)
410                pageSize.width *= root.renderScale
411                pageSize.height *= root.renderScale
412                var xOffsetLimit = Math.max(0, pageSize.width - root.width) / 2
413                var offset = Qt.point(Math.max(-xOffsetLimit, Math.min(xOffsetLimit,
414                                        location.x * root.renderScale - jumpLocationMargin.x)),
415                                      Math.max(0, location.y * root.renderScale - jumpLocationMargin.y))
416                tableHelper.positionViewAtCell(0, page, Qt.AlignLeft | Qt.AlignTop, offset)
417                if (root.debug) {
418                    console.log("going to zoom", zoom, "loc", location, "on page", page,
419                                "ended up @", tableView.contentX.toFixed(1) + ", " + tableView.contentY.toFixed(1))
420                }
421            }
422            jumping = false
423            previousPage = page
424        }
425        onCurrentPageChanged: searchModel.currentPage = currentPage
426    }
427    PdfSearchModel {
428        id: searchModel
429        document: root.document === undefined ? null : root.document
430        // TODO maybe avoid jumping if the result is already fully visible in the viewport
431        onCurrentResultBoundingRectChanged: root.goToLocation(currentPage,
432            Qt.point(currentResultBoundingRect.x, currentResultBoundingRect.y), 0)
433    }
434}
435