pragma ComponentBehavior: Bound import QtQuick import QtWebEngine Item { id: pane clip: true property var views: ({}) property var activeView: null property var pendingView: null property string searchQuery: "" property var companionView: null property var spreadSelection: ({}) property string failedCompanionUrl: "" property bool arrangingPages: false property real spreadZoom: 1 property bool spreadFitWidth: false property real pagePanX: 0 property real pagePanY: 0 property real maxPagePanX: 0 property real maxPagePanY: 0 // Navigation requests expose a display string, while signal arguments and // QML url properties can use percent encoding. Compare the same URL type. function canonicalUrl(value) { return String(Qt.url(value)) } // A spread always follows spine order; CSS direction does not reorder pages. // Unspecified first pages occupy the trailing slot (the conventional cover). function spreadFor(spine, current, rtl, landscape) { var leading = rtl ? "right" : "left", trailing = rtl ? "left" : "right" var groups = [], pending = null, first = true function flush() { if (pending) { groups.push(pending); pending = null } } for (var i = 0; i < spine.length; ++i) { var row = spine[i] if (row.linear === false) continue if (row.missing || !row.href) { flush(); continue } var policy = row.renditionSpread || "auto" var eligible = row.layout === "fixed" && policy !== "none" && (policy === "both" || landscape) if (!eligible || row.spread === "center") { flush(); groups.push({center:i}); first = false; continue } var side = row.spread === "left" || row.spread === "right" ? row.spread : (pending ? trailing : (first ? trailing : leading)) first = false if (side === leading) { flush(); pending = {}; pending[side] = i } else { var group = pending || {}; group[side] = i; groups.push(group); pending = null } } flush() for (var j = 0; j < groups.length; ++j) if (groups[j].left === current || groups[j].right === current || groups[j].center === current) return groups[j] return {center:current} // nonlinear items are independently navigable. } function resourcePath(url) { return decodeURIComponent(String(url).replace(/^doc:\/\/[^/]+\//, "").split("#")[0]) } function retireView(view) { if (!view) return resetHeartbeat(view) view.alive = false view.stop() view.visible = false if (typeof reader.registerRenderer === "function") reader.registerRenderer(view.documentToken, 0, view.rendererSlot) view.destroy() } function closeCompanion() { var old = companionView companionView = null if (old) retireView(old) } function synchronizeSpread() { var view = activeView if (!view || !view.alive || !view.documentReady) return var opts = optionsFor(view), spine = opts.spine || [], current = -1 for (var i = 0; i < spine.length; ++i) if (resourcePath(view.url) === decodeURIComponent(String(spine[i].href || "").split("#")[0])) { current = i; break } spreadSelection = current >= 0 ? spreadFor(spine, current, !!opts.rtl, width > height) : ({center:-1}) var other = spreadSelection.left === current ? spreadSelection.right : spreadSelection.left if (other === undefined || other === current || !view.fixedLayout || pendingView) { closeCompanion(); arrangePages(); return } var url = "doc://" + view.documentToken + "/" + spine[other].href if (canonicalUrl(url) === canonicalUrl(failedCompanionUrl) || !reader.navigationAllowed(url, WebEngineView.TypedNavigation, view.documentToken)) { closeCompanion(); arrangePages(); return } if (!companionView) { companionView = webComponent.createObject(pane, {profile:view.profile, documentToken:view.documentToken, rendererSlot:1, isCompanion:true, permittedUrl:url}) if (!companionView) { arrangePages(); return } companionView.url = url } else if (canonicalUrl(companionView.url) !== canonicalUrl(url)) { companionView.documentReady = false companionView.pagePlaced = false companionView.fixedViewport = ({}) companionView.permittedUrl = url companionView.url = url } arrangePages() } function arrangePages() { if (arrangingPages || !activeView) return arrangingPages = true var view = activeView, other = companionView var a = view.fixedViewport, b = other ? other.fixedViewport : ({}) if (!view.fixedLayout || !(a.width > 0) || !(a.height > 0)) { view.x = 0; view.y = 0; view.width = width; view.height = height; view.scale = 1 } else { var currentLeft = spreadSelection.left !== undefined && resourcePath(view.url) === decodeURIComponent(String((optionsFor(view).spine || [])[spreadSelection.left].href).split("#")[0]) var paired = other && other.documentReady && b.width > 0 && b.height > 0 var slots = spreadSelection.center === undefined var leftSize = paired && !currentLeft ? b : a, rightSize = paired && currentLeft ? b : a var totalWidth = slots ? leftSize.width + rightSize.width : a.width var totalHeight = paired ? Math.max(a.height, b.height) : a.height var factor = (spreadFitWidth ? width / totalWidth : Math.min(width / totalWidth, height / totalHeight)) * spreadZoom maxPagePanX = Math.max(0, totalWidth * factor - width) maxPagePanY = Math.max(0, totalHeight * factor - height) pagePanX = Math.max(0, Math.min(maxPagePanX, pagePanX)) pagePanY = Math.max(0, Math.min(maxPagePanY, pagePanY)) var origin = Math.max(0, (width - totalWidth * factor) / 2) - pagePanX function place(page, size, left) { page.zoomFactor = Math.max(0.25, Math.min(5, factor)) page.scale = factor / page.zoomFactor page.width = size.width * page.zoomFactor; page.height = size.height * page.zoomFactor page.x = origin + left * factor; page.y = Math.max(0, (height - size.height * factor) / 2) - pagePanY page.pagePlaced = true } place(view, a, slots && !currentLeft ? leftSize.width : 0) if (paired) place(other, b, currentLeft ? leftSize.width : 0) } arrangingPages = false } function collectResourceIssues(view) { if (typeof reader.webResourceIssues !== "function") return run(view, "resource.issues", {}, function(value) { if (value) reader.webResourceIssues(view.documentToken, value) }) } WheelHandler { target: null enabled: !!pane.activeView && pane.activeView.fixedLayout onWheel: function(event) { pane.pagePanX -= event.angleDelta.x / 2 pane.pagePanY -= event.angleDelta.y / 2 pane.arrangePages() } } onWidthChanged: Qt.callLater(synchronizeSpread) onHeightChanged: Qt.callLater(synchronizeSpread) function focusContent() { if (activeView) activeView.forceActiveFocus() } function optionsFor(view) { var opts = typeof reader.webOptions === "function" ? reader.webOptions(view.documentToken) : ({}) var reading = opts.reading || reader.settings.reading || ({}) var currentPath = decodeURIComponent(String(view.url).replace(/^doc:\/\/[^/]+\//, "").split("#")[0]) var spine = opts.spine || [] for (var i = 0; i < spine.length; ++i) { if (decodeURIComponent(String(spine[i].href || "").split("#")[0]) === currentPath) { opts.fixed = spine[i].layout === "fixed"; break } } opts.publisherStyle = reading.use_publisher_style !== false opts.fontSize = reading.font_size || 18 opts.lineHeight = reading.line_height || 1.6 return opts } function resetHeartbeat(view) { if (view.heartbeatPending && typeof reader.finishRendererProbe === "function") reader.finishRendererProbe(view.documentToken, view.rendererSlot, view.heartbeatPending) view.heartbeatPending = 0 } function trackRenderer(view, pid) { if (!view || !view.alive || view.registeredRendererPid === pid || typeof reader.registerRenderer !== "function") return resetHeartbeat(view) view.registeredRendererPid = pid reader.registerRenderer(view.documentToken, pid, view.rendererSlot) } function heartbeat(view) { if (!view || !view.alive) return // Qt can expose the initial process through the getter before delivering // a PID notification. Reconcile once, without resetting healthy probes. trackRenderer(view, view.renderProcessPid) if (!view || !view.alive || view.heartbeatPending || view.renderProcessPid <= 0 || typeof reader.beginRendererProbe !== "function") return var probe = reader.beginRendererProbe(view.documentToken, view.rendererSlot) if (!probe) return view.heartbeatPending = probe var serial = view.documentSerial, token = view.documentToken // The response requires the renderer main thread, including while the // document is loading. Document JavaScript remains disabled. view.runJavaScript(String(probe), WebEngineScript.ApplicationWorld, function(result) { if (!view || !view.alive || view.documentSerial !== serial || view.documentToken !== token || view.heartbeatPending !== probe || result !== probe) return reader.finishRendererProbe(token, view.rendererSlot, probe) view.heartbeatPending = 0 // Polling work is queued only after the previous heartbeat returns; // a stalled renderer cannot accumulate periodic DOM operations. if (view === activeView) updateLocation(view) collectResourceIssues(view) }) } function run(view, id, args, callback) { if (!view || !view.alive || !view.documentReady) return var serial = view.documentSerial var token = view.documentToken // Only JSON data is interpolated; all executable source is app-owned. var data = JSON.stringify({id: id, args: args || ({})}) var script = "(function(data){return globalThis.__docviewReader ? globalThis.__docviewReader.command(data.id,data.args) : null;})(" + data + ")" view.runJavaScript(script, WebEngineScript.ApplicationWorld, function(result) { if (!view || !view.alive || view.documentSerial !== serial || view.documentToken !== token) return if (callback) callback(result) }) } function updateLocation(view) { run(view, "location", {}, function(value) { if (!value || typeof value.url !== "string") return value.zoom = (view.fixedLayout ? spreadZoom : view.zoomFactor) * 100 if (view.fixedLayout) { value.fitWidth = spreadFitWidth value.panX = maxPagePanX > 0 ? pagePanX / maxPagePanX : 0 value.panY = maxPagePanY > 0 ? pagePanY / maxPagePanY : 0 } view.lastLocation = value if (view === activeView) reader.webLocation(view.documentToken, value) }) } function configureNavigation(view) { if (!view || view !== activeView || typeof reader.webNavigationTargets !== "function") return var args = reader.webNavigationTargets(view.documentToken, view.url) run(view, "navigation.configure", args, function() { updateLocation(view) }) } function collectIndex(view) { run(view, "index", {}, function(value) { if (!value) return if (view === activeView) reader.webIndex(view.documentToken, value) if (view === activeView && value.truncated) reader.notify(qsTr("見出し一覧は先頭2,000件を表示しています")) view.fixedViewport = value.fixedViewport || ({}) if (view.fixedLayout) arrangePages() if (view === activeView) configureNavigation(view) }) } function fitFixed(view, widthOnly) { var size = view.fixedViewport if (!view.fixedLayout || !(size.width > 0) || !(size.height > 0)) return var factor = widthOnly ? view.width / size.width : Math.min(view.width / size.width, view.height / size.height) view.zoomFactor = Math.max(0.25, Math.min(5, factor)) } function applyLayout(view, callback) { if (!view || !view.documentReady) return var opts = optionsFor(view) var wasFixed = view.fixedLayout view.fixedLayout = !!opts.fixed if (wasFixed && !view.fixedLayout) view.zoomFactor = 1 run(view, "layout", opts, function(result) { if (!result) return view.fixedViewport = result.fixedViewport || ({}) if (view === activeView) synchronizeSpread() else if (view.isCompanion) arrangePages() if (callback) callback() }) } function restoreAfterLayout(view, saved, restoreDisplay, applied) { if (!saved || !view || !view.alive) return if (restoreDisplay) { if (typeof saved.zoom === "number" && isFinite(saved.zoom) && saved.zoom >= 25 && saved.zoom <= 500) { if (view.fixedLayout) spreadZoom = saved.zoom / 100 else view.zoomFactor = saved.zoom / 100 } if (view.fixedLayout) { spreadFitWidth = saved.fitWidth === true arrangePages() pagePanX = (saved.panX || 0) * maxPagePanX pagePanY = (saved.panY || 0) * maxPagePanY arrangePages() } } run(view, "location.restore", saved, function(result) { if (result && result.approximate) reader.notify(qsTr("E_LOCATOR_UNRESOLVED: 近い読書位置へ戻りました")) if (applied) applied() updateLocation(view) }) } function applyBoundary(view) { if (!view || !view.documentReady || !view.pendingBoundary) return var boundary = view.pendingBoundary var request = view.pendingBoundaryRequest view.pendingBoundary = "" if (view !== activeView || request !== view.commandSerial) return run(view, boundary, {}, function() { if (view !== activeView || request !== view.commandSerial) return reader.webApplied(view.documentToken, boundary, request) updateLocation(view) }) } function command(id, args) { var view = activeView if (!view) return var request = ++view.commandSerial var operation = id === "navigate" && args.boundary ? args.boundary : id reader.webStarted(view.documentToken, operation, request) view.cancelResizeRestore() view.restorePending = null // A newer user command supersedes a deferred restoration. view.pendingBoundary = "" if (id === "navigate") { view.pendingBoundary = ["nav.document_start", "nav.document_end", "heading.first", "heading.last"].indexOf(args.boundary) >= 0 ? args.boundary : "" view.pendingBoundaryRequest = request view.permittedUrl = String(args.url) var sameResource = canonicalUrl(view.url).split("#")[0] === canonicalUrl(args.url).split("#")[0] // A following CFI restoration supplies the exact position. Avoid // an identical-URL reload or fragment scroll overwriting that jump. if (sameResource && args.preserveLocation) { view.permittedUrl = ""; return } // Assigning an identical URL may reload the page. Keep an endpoint // jump inside this DOM so a new load cannot consume its callback. if (sameResource && view.documentReady && view.pendingBoundary) { view.permittedUrl = "" applyLayout(view, function() { applyBoundary(view) }) return } if (!sameResource) { view.documentReady = false; failedCompanionUrl = ""; pagePanX = 0; pagePanY = 0; closeCompanion() } view.url = args.url return } if (view.fixedLayout && id.indexOf("scroll.") === 0) { var distance = id.indexOf("half_") >= 0 ? height / 2 : id.indexOf("page_") >= 0 ? height * 0.9 : 48 if (id === "scroll.left") pagePanX -= 48 else if (id === "scroll.right") pagePanX += 48 else if (id.indexOf("up") >= 0) pagePanY -= distance else if (id.indexOf("down") >= 0) pagePanY += distance arrangePages() return } if (id === "location.restore") { if (!view.documentReady) view.restorePending = args else applyLayout(view, function() { if (view === activeView && request === view.commandSerial) restoreAfterLayout(view, args, true, function() { if (view === activeView && request === view.commandSerial) reader.webApplied(view.documentToken, id, request) }) }) return } if (id === "layout") { applyLayout(view); return } if (id === "zoom.in" || id === "zoom.out" || id === "zoom.fit_width") { if (view.fixedLayout) { spreadFitWidth = id === "zoom.fit_width" spreadZoom = spreadFitWidth ? 1 : Math.max(0.25, Math.min(5, spreadZoom * (id === "zoom.in" ? 1.15 : 1 / 1.15))) arrangePages() updateLocation(view) return } var saved = view.lastLocation if (id === "zoom.fit_width") { if (view.fixedLayout) fitFixed(view, true) else view.zoomFactor = 1 } else view.zoomFactor = Math.max(0.25, Math.min(5, view.zoomFactor * (id === "zoom.in" ? 1.15 : 1 / 1.15))) Qt.callLater(function() { if (view !== activeView || !view.alive || request !== view.commandSerial) return if (saved) restoreAfterLayout(view, saved) else updateLocation(view) }) return } if (id === "search.cancel") { ++view.searchRevision view.findText("") return } if (id === "search" || id === "search.next" || id === "search.previous") { if (id === "search") searchQuery = String(args.query || "").slice(0, 4096) var serial = view.documentSerial var revision = ++view.searchRevision view.findText(searchQuery, id === "search.previous" ? WebEngineView.FindBackward : 0, function(found) { if (!view || !view.alive || view !== activeView || view.documentSerial !== serial || view.searchRevision !== revision) return if (!found) reader.notify(qsTr("一致する文字がありません")) updateLocation(view) }) return } run(view, id, args, function(result) { if (view !== activeView || request !== view.commandSerial) return if (typeof reader.webApplied === "function") reader.webApplied(view.documentToken, id, request) if (id === "link.activate" && result) { if (result.url && reader.navigationAllowed(result.url, WebEngineView.LinkClickedNavigation, view.documentToken)) pane.command("navigate", {url: result.url}) else if (result.blocked) reader.notify(qsTr("E_RESOURCE_BLOCKED: このリンクは開けません")) else if (result.empty) reader.notify(qsTr("Tabでリンクを選択してください")) } else if ((id === "link.next" || id === "link.previous") && result && result.empty) reader.notify(qsTr("この文書にはリンクがありません")) if (result && result.boundary) { if (reader.format === "epub") { reader.execute(result.boundary > 0 ? "nav.chapter_next" : "nav.chapter_previous", {headingBoundary: result.boundary > 0 ? "heading.first" : "heading.last"}) } else reader.notify(result.empty ? qsTr("この文書には見出しがありません") : qsTr("見出しの端です")) } if (id === "location.restore" && result && result.approximate) reader.notify(qsTr("E_LOCATOR_UNRESOLVED: 近い読書位置へ戻りました")) updateLocation(view) }) } Connections { target: reader function onStageWeb(profile, url, token) { closeCompanion() var view = webComponent.createObject(pane, {profile: profile, documentToken: token, permittedUrl: String(url)}) if (!view) { reader.webLoaded(token, false); return } views[token] = view pendingView = view view.url = url } function onActivateWeb(token) { if (activeView) { activeView.isCurrent = false; activeView.enabled = false } activeView = views[token] || null if (activeView) { spreadZoom = 1; spreadFitWidth = false; pagePanX = 0; pagePanY = 0 activeView.isCurrent = true activeView.enabled = true if (pendingView === activeView) pendingView = null Qt.callLater(function() { if (activeView && activeView.documentToken === token) { applyLayout(activeView); collectIndex(activeView) } }) } } function onDisposeWeb(token) { var view = views[token] if (!view) return if (companionView && companionView.documentToken === token) closeCompanion() if (activeView === view) activeView = null if (pendingView === view) pendingView = null delete views[token] retireView(view) if (activeView && !pendingView) Qt.callLater(synchronizeSpread) } function onWebCommand(id, args) { pane.command(id, args) } function onNavigationChanged() { pane.configureNavigation(pane.activeView) } function onSettingsChanged() { if (!activeView) return var view = activeView run(view, "location", {}, function(saved) { applyLayout(view, function() { restoreAfterLayout(view, saved) }) }) } } Timer { interval: 400 running: !!pane.activeView || !!pane.pendingView || !!pane.companionView repeat: true onTriggered: { pane.heartbeat(pane.activeView) pane.heartbeat(pane.pendingView) pane.heartbeat(pane.companionView) } } Component { id: webComponent WebEngineView { id: web width: pane.width height: pane.height transformOrigin: Item.TopLeft property int rendererSlot: 0 property bool isCompanion: false property bool pagePlaced: false property string documentToken: "" property string permittedUrl: "" property bool alive: true property bool isCurrent: false property bool documentReady: false property int documentSerial: 0 property double heartbeatPending: 0 property double registeredRendererPid: 0 property int searchRevision: 0 property double commandSerial: 0 property double pendingBoundaryRequest: 0 property var lastLocation: null property var resizeLocation: null property var restorePending: null property var fixedViewport: ({}) property bool fixedLayout: false property string pendingBoundary: "" opacity: isCurrent || (isCompanion && documentReady && pagePlaced) ? 1 : 0 z: isCurrent || isCompanion ? 1 : 0 enabled: isCurrent || (isCompanion && pagePlaced) audioMuted: true backgroundColor: "white" webChannel: null activeFocusOnPress: true settings.javascriptEnabled: false settings.javascriptCanOpenWindows: false settings.javascriptCanAccessClipboard: false settings.javascriptCanPaste: false settings.localStorageEnabled: false settings.localContentCanAccessRemoteUrls: false settings.localContentCanAccessFileUrls: false settings.hyperlinkAuditingEnabled: false settings.pluginsEnabled: false settings.fullScreenSupportEnabled: false settings.screenCaptureEnabled: false settings.webGLEnabled: false settings.accelerated2dCanvasEnabled: false settings.autoLoadIconsForPage: false settings.touchIconsEnabled: false settings.allowRunningInsecureContent: false settings.allowGeolocationOnInsecureOrigins: false settings.allowWindowActivationFromJavaScript: false settings.dnsPrefetchEnabled: false settings.pdfViewerEnabled: false settings.navigateOnDropEnabled: false settings.playbackRequiresUserGesture: true settings.webRTCPublicInterfacesOnly: true settings.unknownUrlSchemePolicy: WebEngineSettings.DisallowUnknownUrlSchemes settings.focusOnNavigationEnabled: false userScripts.collection: [{ name: "DocView Reader", injectionPoint: WebEngineScript.DocumentCreation, worldId: WebEngineScript.ApplicationWorld, runsOnSubFrames: false, sourceCode: reader.script("reader") }] onActiveFocusChanged: if (activeFocus) reader.context = "content" onNavigationRequested: function(request) { var type = request.navigationType var known = type === WebEngineView.LinkClickedNavigation || ((type === WebEngineView.OtherNavigation || type === WebEngineView.TypedNavigation) && pane.canonicalUrl(request.url) === pane.canonicalUrl(permittedUrl)) if (isCompanion && type === WebEngineView.LinkClickedNavigation) { request.action = WebEngineView.IgnoreRequest if (reader.navigationAllowed(request.url, type, documentToken)) pane.command("navigate", {url:request.url}) return } request.action = known && reader.navigationAllowed(request.url, type, documentToken) ? WebEngineView.AcceptRequest : WebEngineView.IgnoreRequest if (known && type !== WebEngineView.LinkClickedNavigation) permittedUrl = "" } onLoadingChanged: function(info) { if (info.status === WebEngineView.LoadStartedStatus) { pane.resetHeartbeat(web); ++documentSerial; documentReady = false } else if (info.status === WebEngineView.LoadSucceededStatus) { documentReady = true if (!isCompanion) { reader.webLoaded(documentToken, true) if (typeof reader.webDocumentLoaded === "function") reader.webDocumentLoaded(documentToken, url) } pane.applyLayout(web, function() { pane.applyBoundary(web) if (restorePending) { var saved = restorePending; restorePending = null; pane.restoreAfterLayout(web, saved, true) } }) pane.collectIndex(web) pane.collectResourceIssues(web) } else if (info.status === WebEngineView.LoadFailedStatus && alive) { if (isCompanion) { pane.failedCompanionUrl = String(url); pane.closeCompanion(); pane.arrangePages() } else reader.webLoaded(documentToken, false) } } onNewWindowRequested: function(request) { if (request.userInitiated && reader.navigationAllowed(request.requestedUrl, WebEngineView.LinkClickedNavigation, documentToken)) { pane.command("navigate", {url:request.requestedUrl}) } } onJavaScriptConsoleMessage: function(level, message, lineNumber, sourceId) { /* Document text is not written to application logs. */ } onPermissionRequested: function(permissionRequest) { permissionRequest.deny() } onFeaturePermissionRequested: function(origin, feature) { grantFeaturePermission(origin, feature, false) } onFileDialogRequested: function(request) { request.accepted = true; request.dialogReject() } onColorDialogRequested: function(request) { request.accepted = true; request.dialogReject() } onAuthenticationDialogRequested: function(request) { request.accepted = true; request.dialogReject() } onJavaScriptDialogRequested: function(request) { request.accepted = true; request.dialogReject() } onContextMenuRequested: function(request) { request.accepted = true } onFullScreenRequested: function(request) { request.reject() } onCertificateError: function(error) { error.rejectCertificate() } onQuotaRequested: function(request) { request.reject() } onRegisterProtocolHandlerRequested: function(request) { request.reject() } onFileSystemAccessRequested: function(request) { request.reject() } onSelectClientCertificate: function(selection) { selection.selectNone() } onDesktopMediaRequested: function(request) { request.cancel() } onWebAuthUxRequested: function(request) { request.cancel() } onRenderProcessTerminated: function(status, code) { if (!alive) return documentReady = false if (isCurrent || isCompanion) reader.renderStopped() else reader.webLoaded(documentToken, false) } onRenderProcessPidChanged: function(pid) { pane.trackRenderer(web, pid) } Timer { id: resizeDelay interval: 150 onTriggered: { if (web.fixedLayout) pane.arrangePages() else pane.restoreAfterLayout(web, web.resizeLocation) web.resizeLocation = null } } function preserveResizeLocation() { if (!documentReady || pane.arrangingPages) return if (!resizeDelay.running) resizeLocation = lastLocation resizeDelay.restart() } function cancelResizeRestore() { resizeDelay.stop(); resizeLocation = null } onWidthChanged: preserveResizeLocation() onHeightChanged: preserveResizeLocation() } } }