| /**************************************************************************** |
| ** |
| ** Copyright (C) 2020 The Qt Company Ltd. |
| ** Contact: http://www.qt.io/licensing/ |
| ** |
| ** This file is part of the QtPDF module of the Qt Toolkit. |
| ** |
| ** $QT_BEGIN_LICENSE:LGPL3$ |
| ** Commercial License Usage |
| ** Licensees holding valid commercial Qt licenses may use this file in |
| ** accordance with the commercial license agreement provided with the |
| ** Software or, alternatively, in accordance with the terms contained in |
| ** a written agreement between you and The Qt Company. For licensing terms |
| ** and conditions see http://www.qt.io/terms-conditions. For further |
| ** information use the contact form at http://www.qt.io/contact-us. |
| ** |
| ** GNU Lesser General Public License Usage |
| ** Alternatively, this file may be used under the terms of the GNU Lesser |
| ** General Public License version 3 as published by the Free Software |
| ** Foundation and appearing in the file LICENSE.LGPLv3 included in the |
| ** packaging of this file. Please review the following information to |
| ** ensure the GNU Lesser General Public License version 3 requirements |
| ** will be met: https://www.gnu.org/licenses/lgpl.html. |
| ** |
| ** GNU General Public License Usage |
| ** Alternatively, this file may be used under the terms of the GNU |
| ** General Public License version 2.0 or later as published by the Free |
| ** Software Foundation and appearing in the file LICENSE.GPL included in |
| ** the packaging of this file. Please review the following information to |
| ** ensure the GNU General Public License version 2.0 requirements will be |
| ** met: http://www.gnu.org/licenses/gpl-2.0.html. |
| ** |
| ** $QT_END_LICENSE$ |
| ** |
| ****************************************************************************/ |
| import QtQuick 2.14 |
| import QtQuick.Controls 2.14 |
| import QtQuick.Layouts 1.14 |
| import QtQuick.Pdf 5.15 |
| import QtQuick.Shapes 1.14 |
| import QtQuick.Window 2.14 |
| |
| Item { |
| // public API |
| // TODO 5.15: required property |
| property var document: undefined |
| property bool debug: false |
| |
| property string selectedText |
| function selectAll() { |
| var currentItem = tableHelper.itemAtCell(tableHelper.cellAtPos(root.width / 2, root.height / 2)) |
| if (currentItem) |
| currentItem.selection.selectAll() |
| } |
| function copySelectionToClipboard() { |
| var currentItem = tableHelper.itemAtCell(tableHelper.cellAtPos(root.width / 2, root.height / 2)) |
| if (debug) |
| console.log("currentItem", currentItem, "sel", currentItem.selection.text) |
| if (currentItem) |
| currentItem.selection.copyToClipboard() |
| } |
| |
| // page navigation |
| property alias currentPage: navigationStack.currentPage |
| property alias backEnabled: navigationStack.backAvailable |
| property alias forwardEnabled: navigationStack.forwardAvailable |
| function back() { navigationStack.back() } |
| function forward() { navigationStack.forward() } |
| function goToPage(page) { |
| if (page === navigationStack.currentPage) |
| return |
| goToLocation(page, Qt.point(-1, -1), 0) |
| } |
| function goToLocation(page, location, zoom) { |
| if (zoom > 0) { |
| navigationStack.jumping = true // don't call navigationStack.update() because we will push() instead |
| root.renderScale = zoom |
| tableView.forceLayout() // but do ensure that the table layout is correct before we try to jump |
| navigationStack.jumping = false |
| } |
| navigationStack.push(page, location, zoom) // actually jump |
| } |
| property vector2d jumpLocationMargin: Qt.vector2d(10, 10) // px from top-left corner |
| property int currentPageRenderingStatus: Image.Null |
| |
| // page scaling |
| property real renderScale: 1 |
| property real pageRotation: 0 |
| function resetScale() { root.renderScale = 1 } |
| function scaleToWidth(width, height) { |
| root.renderScale = width / (tableView.rot90 ? tableView.firstPagePointSize.height : tableView.firstPagePointSize.width) |
| } |
| function scaleToPage(width, height) { |
| var windowAspect = width / height |
| var pageAspect = tableView.firstPagePointSize.width / tableView.firstPagePointSize.height |
| if (tableView.rot90) { |
| if (windowAspect > pageAspect) { |
| root.renderScale = height / tableView.firstPagePointSize.width |
| } else { |
| root.renderScale = width / tableView.firstPagePointSize.height |
| } |
| } else { |
| if (windowAspect > pageAspect) { |
| root.renderScale = height / tableView.firstPagePointSize.height |
| } else { |
| root.renderScale = width / tableView.firstPagePointSize.width |
| } |
| } |
| } |
| |
| // text search |
| property alias searchModel: searchModel |
| property alias searchString: searchModel.searchString |
| function searchBack() { --searchModel.currentResult } |
| function searchForward() { ++searchModel.currentResult } |
| |
| id: root |
| PdfStyle { id: style } |
| TableView { |
| id: tableView |
| anchors.fill: parent |
| anchors.leftMargin: 2 |
| model: modelInUse && root.document !== undefined ? root.document.pageCount : 0 |
| // workaround to make TableView do scheduleRebuildTable(RebuildOption::All) in cases when forceLayout() doesn't |
| property bool modelInUse: true |
| function rebuild() { |
| modelInUse = false |
| modelInUse = true |
| } |
| // end workaround |
| rowSpacing: 6 |
| property real rotationNorm: Math.round((360 + (root.pageRotation % 360)) % 360) |
| property bool rot90: rotationNorm == 90 || rotationNorm == 270 |
| onRot90Changed: forceLayout() |
| property size firstPagePointSize: document === undefined ? Qt.size(0, 0) : document.pagePointSize(0) |
| property real pageHolderWidth: Math.max(root.width, document === undefined ? 0 : |
| (rot90 ? document.maxPageHeight : document.maxPageWidth) * root.renderScale) |
| contentWidth: document === undefined ? 0 : pageHolderWidth + vscroll.width + 2 |
| rowHeightProvider: function(row) { return (rot90 ? document.pagePointSize(row).width : document.pagePointSize(row).height) * root.renderScale } |
| TableViewExtra { |
| id: tableHelper |
| tableView: tableView |
| } |
| delegate: Rectangle { |
| id: pageHolder |
| color: root.debug ? "beige" : "transparent" |
| Text { |
| visible: root.debug |
| anchors { right: parent.right; verticalCenter: parent.verticalCenter } |
| rotation: -90; text: pageHolder.width.toFixed(1) + "x" + pageHolder.height.toFixed(1) + "\n" + |
| image.width.toFixed(1) + "x" + image.height.toFixed(1) |
| } |
| implicitWidth: tableView.pageHolderWidth |
| implicitHeight: tableView.rot90 ? image.width : image.height |
| property alias selection: selection |
| Rectangle { |
| id: paper |
| width: image.width |
| height: image.height |
| rotation: root.pageRotation |
| anchors.centerIn: pinch.active ? undefined : parent |
| property size pagePointSize: document.pagePointSize(index) |
| property real pageScale: image.paintedWidth / pagePointSize.width |
| Image { |
| id: image |
| source: document.source |
| currentFrame: index |
| asynchronous: true |
| fillMode: Image.PreserveAspectFit |
| width: paper.pagePointSize.width * root.renderScale |
| height: paper.pagePointSize.height * root.renderScale |
| property real renderScale: root.renderScale |
| property real oldRenderScale: 1 |
| onRenderScaleChanged: { |
| image.sourceSize.width = paper.pagePointSize.width * renderScale |
| image.sourceSize.height = 0 |
| paper.scale = 1 |
| searchHighlights.update() |
| } |
| onStatusChanged: { |
| if (index === navigationStack.currentPage) |
| root.currentPageRenderingStatus = status |
| } |
| } |
| Shape { |
| anchors.fill: parent |
| visible: image.status === Image.Ready |
| onVisibleChanged: searchHighlights.update() |
| ShapePath { |
| strokeWidth: -1 |
| fillColor: style.pageSearchResultsColor |
| scale: Qt.size(paper.pageScale, paper.pageScale) |
| PathMultiline { |
| id: searchHighlights |
| function update() { |
| // paths could be a binding, but we need to be able to "kick" it sometimes |
| paths = searchModel.boundingPolygonsOnPage(index) |
| } |
| } |
| } |
| Connections { |
| target: searchModel |
| // whenever the highlights on the _current_ page change, they actually need to change on _all_ pages |
| // (usually because the search string has changed) |
| function onCurrentPageBoundingPolygonsChanged() { searchHighlights.update() } |
| } |
| ShapePath { |
| strokeWidth: -1 |
| fillColor: style.selectionColor |
| scale: Qt.size(paper.pageScale, paper.pageScale) |
| PathMultiline { |
| paths: selection.geometry |
| } |
| } |
| } |
| Shape { |
| anchors.fill: parent |
| visible: image.status === Image.Ready && searchModel.currentPage === index |
| ShapePath { |
| strokeWidth: style.currentSearchResultStrokeWidth |
| strokeColor: style.currentSearchResultStrokeColor |
| fillColor: "transparent" |
| scale: Qt.size(paper.pageScale, paper.pageScale) |
| PathMultiline { |
| paths: searchModel.currentResultBoundingPolygons |
| } |
| } |
| } |
| PinchHandler { |
| id: pinch |
| minimumScale: 0.1 |
| maximumScale: root.renderScale < 4 ? 2 : 1 |
| minimumRotation: root.pageRotation |
| maximumRotation: root.pageRotation |
| enabled: image.sourceSize.width < 5000 |
| onActiveChanged: |
| if (active) { |
| paper.z = 10 |
| } else { |
| paper.z = 0 |
| var centroidInPoints = Qt.point(pinch.centroid.position.x / root.renderScale, |
| pinch.centroid.position.y / root.renderScale) |
| var centroidInFlickable = tableView.mapFromItem(paper, pinch.centroid.position.x, pinch.centroid.position.y) |
| var newSourceWidth = image.sourceSize.width * paper.scale |
| var ratio = newSourceWidth / image.sourceSize.width |
| if (root.debug) |
| console.log("pinch ended on page", index, "with centroid", pinch.centroid.position, centroidInPoints, "wrt flickable", centroidInFlickable, |
| "page at", pageHolder.x.toFixed(2), pageHolder.y.toFixed(2), |
| "contentX/Y were", tableView.contentX.toFixed(2), tableView.contentY.toFixed(2)) |
| if (ratio > 1.1 || ratio < 0.9) { |
| var centroidOnPage = Qt.point(centroidInPoints.x * root.renderScale * ratio, centroidInPoints.y * root.renderScale * ratio) |
| paper.scale = 1 |
| paper.x = 0 |
| paper.y = 0 |
| root.renderScale *= ratio |
| tableView.forceLayout() |
| if (tableView.rotationNorm == 0) { |
| tableView.contentX = pageHolder.x + tableView.originX + centroidOnPage.x - centroidInFlickable.x |
| tableView.contentY = pageHolder.y + tableView.originY + centroidOnPage.y - centroidInFlickable.y |
| } else if (tableView.rotationNorm == 90) { |
| tableView.contentX = pageHolder.x + tableView.originX + image.height - centroidOnPage.y - centroidInFlickable.x |
| tableView.contentY = pageHolder.y + tableView.originY + centroidOnPage.x - centroidInFlickable.y |
| } else if (tableView.rotationNorm == 180) { |
| tableView.contentX = pageHolder.x + tableView.originX + image.width - centroidOnPage.x - centroidInFlickable.x |
| tableView.contentY = pageHolder.y + tableView.originY + image.height - centroidOnPage.y - centroidInFlickable.y |
| } else if (tableView.rotationNorm == 270) { |
| tableView.contentX = pageHolder.x + tableView.originX + centroidOnPage.y - centroidInFlickable.x |
| tableView.contentY = pageHolder.y + tableView.originY + image.width - centroidOnPage.x - centroidInFlickable.y |
| } |
| if (root.debug) |
| console.log("contentX/Y adjusted to", tableView.contentX.toFixed(2), tableView.contentY.toFixed(2), "y @top", pageHolder.y) |
| tableView.returnToBounds() |
| } |
| } |
| grabPermissions: PointerHandler.CanTakeOverFromAnything |
| } |
| DragHandler { |
| id: textSelectionDrag |
| acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus |
| target: null |
| } |
| TapHandler { |
| id: mouseClickHandler |
| acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus |
| } |
| TapHandler { |
| id: touchTapHandler |
| acceptedDevices: PointerDevice.TouchScreen |
| onTapped: { |
| selection.clear() |
| selection.forceActiveFocus() |
| } |
| } |
| Repeater { |
| model: PdfLinkModel { |
| id: linkModel |
| document: root.document |
| page: image.currentFrame |
| } |
| delegate: Shape { |
| x: rect.x * paper.pageScale |
| y: rect.y * paper.pageScale |
| width: rect.width * paper.pageScale |
| height: rect.height * paper.pageScale |
| visible: image.status === Image.Ready |
| ShapePath { |
| strokeWidth: style.linkUnderscoreStrokeWidth |
| strokeColor: style.linkUnderscoreColor |
| strokeStyle: style.linkUnderscoreStrokeStyle |
| dashPattern: style.linkUnderscoreDashPattern |
| startX: 0; startY: height |
| PathLine { x: width; y: height } |
| } |
| MouseArea { // TODO switch to TapHandler / HoverHandler in 5.15 |
| id: linkMA |
| anchors.fill: parent |
| cursorShape: Qt.PointingHandCursor |
| hoverEnabled: true |
| onClicked: { |
| if (page >= 0) |
| root.goToLocation(page, location, zoom) |
| else |
| Qt.openUrlExternally(url) |
| } |
| } |
| ToolTip { |
| visible: linkMA.containsMouse |
| delay: 1000 |
| text: page >= 0 ? |
| ("page " + (page + 1) + |
| " location " + location.x.toFixed(1) + ", " + location.y.toFixed(1) + |
| " zoom " + zoom) : url |
| } |
| } |
| } |
| PdfSelection { |
| id: selection |
| anchors.fill: parent |
| document: root.document |
| page: image.currentFrame |
| renderScale: image.renderScale |
| fromPoint: textSelectionDrag.centroid.pressPosition |
| toPoint: textSelectionDrag.centroid.position |
| hold: !textSelectionDrag.active && !mouseClickHandler.pressed |
| onTextChanged: root.selectedText = text |
| focus: true |
| } |
| } |
| } |
| ScrollBar.vertical: ScrollBar { |
| id: vscroll |
| property bool moved: false |
| onPositionChanged: moved = true |
| onActiveChanged: { |
| var cell = tableHelper.cellAtPos(root.width / 2, root.height / 2) |
| var currentItem = tableHelper.itemAtCell(cell) |
| var currentLocation = Qt.point(0, 0) |
| if (currentItem) { // maybe the delegate wasn't loaded yet |
| currentLocation = Qt.point((tableView.contentX - currentItem.x + jumpLocationMargin.x) / root.renderScale, |
| (tableView.contentY - currentItem.y + jumpLocationMargin.y) / root.renderScale) |
| } |
| if (active) { |
| moved = false |
| // emitJumped false to avoid interrupting a pinch if TableView thinks it should scroll at the same time |
| navigationStack.push(cell.y, currentLocation, root.renderScale, false) |
| } else if (moved) { |
| navigationStack.update(cell.y, currentLocation, root.renderScale) |
| } |
| } |
| } |
| ScrollBar.horizontal: ScrollBar { } |
| } |
| onRenderScaleChanged: { |
| // if navigationStack.jumped changes the scale, don't turn around and update the stack again; |
| // and don't force layout either, because positionViewAtCell() will do that |
| if (navigationStack.jumping) |
| return |
| // make TableView rebuild from scratch, because otherwise it doesn't know the delegates are changing size |
| tableView.rebuild() |
| var cell = tableHelper.cellAtPos(root.width / 2, root.height / 2) |
| var currentItem = tableHelper.itemAtCell(cell) |
| if (currentItem) { |
| var currentLocation = Qt.point((tableView.contentX - currentItem.x + jumpLocationMargin.x) / root.renderScale, |
| (tableView.contentY - currentItem.y + jumpLocationMargin.y) / root.renderScale) |
| navigationStack.update(cell.y, currentLocation, renderScale) |
| } |
| } |
| PdfNavigationStack { |
| id: navigationStack |
| property bool jumping: false |
| property int previousPage: 0 |
| onJumped: { |
| jumping = true |
| root.renderScale = zoom |
| if (location.y < 0) { |
| // invalid to indicate that a specific location was not needed, |
| // so attempt to position the new page just as the current page is |
| var currentYOffset = 0 |
| var previousPageDelegate = tableHelper.itemAtCell(0, previousPage) |
| if (previousPageDelegate) |
| currentYOffset = tableView.contentY - previousPageDelegate.y |
| tableHelper.positionViewAtRow(page, Qt.AlignTop, currentYOffset) |
| if (root.debug) { |
| console.log("going from page", previousPage, "to", page, "offset", currentYOffset, |
| "ended up @", tableView.contentX.toFixed(1) + ", " + tableView.contentY.toFixed(1)) |
| } |
| } else { |
| // jump to a page and position the given location relative to the top-left corner of the viewport |
| var pageSize = root.document.pagePointSize(page) |
| pageSize.width *= root.renderScale |
| pageSize.height *= root.renderScale |
| var xOffsetLimit = Math.max(0, pageSize.width - root.width) / 2 |
| var offset = Qt.point(Math.max(-xOffsetLimit, Math.min(xOffsetLimit, |
| location.x * root.renderScale - jumpLocationMargin.x)), |
| Math.max(0, location.y * root.renderScale - jumpLocationMargin.y)) |
| tableHelper.positionViewAtCell(0, page, Qt.AlignLeft | Qt.AlignTop, offset) |
| if (root.debug) { |
| console.log("going to zoom", zoom, "loc", location, "on page", page, |
| "ended up @", tableView.contentX.toFixed(1) + ", " + tableView.contentY.toFixed(1)) |
| } |
| } |
| jumping = false |
| previousPage = page |
| } |
| onCurrentPageChanged: searchModel.currentPage = currentPage |
| } |
| PdfSearchModel { |
| id: searchModel |
| document: root.document === undefined ? null : root.document |
| // TODO maybe avoid jumping if the result is already fully visible in the viewport |
| onCurrentResultBoundingRectChanged: root.goToLocation(currentPage, |
| Qt.point(currentResultBoundingRect.x, currentResultBoundingRect.y), 0) |
| } |
| } |