Mercurial > hgrepos > Python2 > PyMuPDF
view mupdf-source/platform/wasm/examples/simple-viewer/index.html @ 29:f76e6575dca9 v1.26.4+1
+++++ v1.26.4+1
| author | Franz Glasner <fzglas.hg@dom66.de> |
|---|---|
| date | Fri, 19 Sep 2025 19:59:23 +0200 |
| parents | b50eed0cc0ef |
| children |
line wrap: on
line source
<!DOCTYPE html> <!-- Copyright (C) 2022, 2024, 2025 Artifex Software, Inc. This file is part of MuPDF. MuPDF is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. MuPDF 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 Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with MuPDF. If not, see <https://www.gnu.org/licenses/agpl-3.0.en.html> Alternative licensing terms are available from the licensor. For commercial licensing, see <https://www.artifex.com/> or contact Artifex Software, Inc., 39 Mesa Street, Suite 108A, San Francisco, CA 94129, USA, for further information. --> <title>MuPDF.js</title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"> <link rel="shortcut icon" href="favicon.svg"> <style> * { box-sizing: border-box; } /* APPEARANCE */ html { font-family: sans-serif; font-size: 18px; background-color: gray; } header { border-bottom: 1px solid black; background-color: gainsboro; } footer { border-top: 1px solid black; background-color: gainsboro; } aside { background-color: white; border-right: 1px solid black; } #message { text-align: center; font-size: 24pt; font-weight: bold; color: silver; } details[open] > summary { background-color: #0004; } menu { min-width: 140px; border: 1px solid black; background-color: white; color: black; } menu li:hover { background-color: black; color: white; } /* LAYOUT */ html { margin: 0; padding: 0; width: 100%; height: 100%; } body { margin: 0; padding: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr) auto; width: 100%; height: 100%; overflow: clip; } header { position: relative; user-select: none; grid-column: 1/3; grid-row: 1; display: flex; flex-wrap: wrap; } footer { grid-column: 1/3; grid-row: 3; display: flex; padding: 8px; gap: 8px; } aside { grid-column: 1; grid-row: 2; overflow-y: auto; width: 250px; } main { grid-column: 2; grid-row: 2; overflow: scroll; } summary { padding: 4px 8px; cursor: pointer; list-style: none; } /* workaround for bug in Safari details appearance */ summary::-webkit-details-marker { display: none; } menu { position: absolute; overflow-y: auto; margin: 0; padding: 0; list-style: none; z-index: 500; } menu li { padding: 4px 8px; cursor: pointer; } /* OUTLINE */ #outline { font-size: 12px; } #outline ul { margin: 0; padding-left: 20px; } #outline a { color: black; text-decoration: none; } #outline a:hover { color: blue; text-decoration: underline; } /* PAGES */ #pages { margin: 0 auto; } div.page { position: relative; background-color: white; margin: 16px auto; box-shadow: 0px 2px 8px #0004; } div.page * { position: absolute; } div.page canvas { user-select: none; } svg.text { width: 100%; height: 100%; } svg.text text { white-space: pre; line-height: 1; fill: transparent; } svg.text ::selection { background: hsla(220, 100%, 50%, 0.2); color: transparent; } div.link a:hover { border: 1px dotted blue; } #pages.do-content-select div.link { pointer-events: none; } div.search > div { pointer-events: none; border: 1px solid hotpink; background-color: lightpink; mix-blend-mode: multiply; } </style> <body> <header id="menubar-panel"> <details> <summary>File</summary> <menu> <li onclick="document.getElementById('open-file-input').click()">Open File... </menu> </details> <details> <summary>Edit</summary> <menu> <li onclick="show_search_panel()">Search... </menu> </details> <details> <summary>View</summary> <menu> <li onclick="toggle_fullscreen()">Fullscreen <li onclick="toggle_outline_panel()">Outline <li onclick="zoom_to(48)">50% <li onclick="zoom_to(72)">75% (72 dpi) <li onclick="zoom_to(96)">100% (96 dpi) <li onclick="zoom_to(120)">125% <li onclick="zoom_to(144)">150% <li onclick="zoom_to(192)">200% </menu> </details> </header> <aside id="outline-panel" style="display:none"> <ul id="outline"> <!-- outline inserted here --> </ul> </aside> <main id="page-panel"> <div id="message"> Loading MuPDF.js... </div> <div id="pages"> <!-- pages inserted here --> </div> </main> <footer id="search-panel" style="display:none"> <input id="search-input" type="search" size="40" placeholder="Search..." > <button id="search-prev" onclick="run_search(-1, 1)"><</button> <button id="search-next" onclick="run_search(1, 1)">></button> <div id="search-status" style="flex-grow:1"></div> <button onclick="hide_search_panel()">X</button> </footer> <!-- hidden input for file dialog --> <input style="display: none" id="open-file-input" type="file" accept=".pdf,application/pdf" onchange="open_document_from_file(event.target.files[0])" > </body> <script> "use strict" // FAST SORTED ARRAY FUNCTIONS function array_remove(array, index) { let n = array.length for (let i = index + 1; i < n; ++i) array[i - 1] = array[i] array.length = n - 1 } function array_insert(array, index, item) { for (let i = array.length; i > index; --i) array[i] = array[i - 1] array[index] = item } function set_has(set, item) { let a = 0 let b = set.length - 1 while (a <= b) { let m = (a + b) >> 1 let x = set[m] if (item < x) b = m - 1 else if (item > x) a = m + 1 else return true } return false } function set_add(set, item) { let a = 0 let b = set.length - 1 while (a <= b) { let m = (a + b) >> 1 let x = set[m] if (item < x) b = m - 1 else if (item > x) a = m + 1 else return } array_insert(set, a, item) } function set_delete(set, item) { let a = 0 let b = set.length - 1 while (a <= b) { let m = (a + b) >> 1 let x = set[m] if (item < x) b = m - 1 else if (item > x) a = m + 1 else { array_remove(set, m) return } } } // LOADING AND ERROR MESSAGES function show_message(msg) { document.getElementById("message").textContent = msg } function clear_message() { document.getElementById("message").textContent = "" } // MENU BAR function close_all_menus(self) { for (let node of document.querySelectorAll("header > details")) if (node !== self) node.removeAttribute("open") } /* close menu if opening another */ for (let node of document.querySelectorAll("header > details")) { node.addEventListener("click", function () { close_all_menus(node) }) } /* close menu after selecting something */ for (let node of document.querySelectorAll("header > details > menu")) { node.addEventListener("click", function () { close_all_menus(null) }) } /* click anywhere outside the menu to close it */ window.addEventListener("mousedown", function (evt) { let e = evt.target while (e) { if (e.tagName === "DETAILS") return e = e.parentElement } close_all_menus(null) }) /* close menus if window loses focus */ window.addEventListener("blur", function () { close_all_menus(null) }) // BACKGROUND WORKER const worker = new Worker("worker.js", { type: "module" }) worker._promise_id = 1 worker._promise_map = new Map() worker.wrap = function (name) { return function (...args) { return new Promise(function (resolve, reject) { let id = worker._promise_id++ worker._promise_map.set(id, { resolve, reject }) if (args[0] instanceof ArrayBuffer) worker.postMessage([ name, id, args ], [ args[0] ]) else worker.postMessage([ name, id, args ]) }) } } worker.onmessage = function (event) { let [ type, id, result ] = event.data let error switch (type) { case "INIT": for (let method of result) worker[method] = worker.wrap(method) main() break case "RESULT": worker._promise_map.get(id).resolve(result) worker._promise_map.delete(id) break case "ERROR": error = new Error(result.message) error.name = result.name error.stack = result.stack worker._promise_map.get(id).reject(error) worker._promise_map.delete(id) break default: error = new Error(`Invalid message: ${type}`) worker._promise_map.get(id).reject(error) break } } // PAGE VIEW class PageView { constructor(doc, pageNumber, defaultSize, zoom) { this.doc = doc this.pageNumber = pageNumber // 0-based this.size = defaultSize this.loadPromise = false this.drawPromise = false this.rootNode = document.createElement("div") this.rootNode.id = "page" + (pageNumber + 1) this.rootNode.className = "page" this.rootNode.page = this this.canvasNode = document.createElement("canvas") this.canvasCtx = this.canvasNode.getContext("2d") this.rootNode.appendChild(this.canvasNode) this.textData = null this.textNode = document.createElementNS("http://www.w3.org/2000/svg", "svg") this.textNode.classList.add("text") this.rootNode.appendChild(this.textNode) this.linkData = null this.linkNode = document.createElement("div") this.linkNode.className = "link" this.rootNode.appendChild(this.linkNode) this.needle = null this.loadNeedle = null this.showNeedle = null this.searchData = null this.searchNode = document.createElement("div") this.searchNode.className = "search" this.rootNode.appendChild(this.searchNode) this.zoom = zoom this._updateSize() } // Update page element size for current zoom level. _updateSize() { // We Math.ceil to match the behavior of fz_irect_from_rect that is used by the worker. this.rootNode.style.width = Math.ceil((this.size.width * this.zoom) / 72) + "px" this.rootNode.style.height = Math.ceil((this.size.height * this.zoom) / 72) + "px" this.canvasNode.style.width = Math.ceil((this.size.width * this.zoom) / 72) + "px" this.canvasNode.style.height = Math.ceil((this.size.height * this.zoom) / 72) + "px" } setZoom(zoom) { if (this.zoom !== zoom) { this.zoom = zoom this._updateSize() } } setSearch(needle) { if (this.needle !== needle) this.needle = needle } async _load() { console.log("LOADING", this.pageNumber) this.size = await worker.getPageSize(this.doc, this.pageNumber) this.textData = await worker.getPageText(this.doc, this.pageNumber) this.linkData = await worker.getPageLinks(this.doc, this.pageNumber) this._updateSize() } async _loadSearch() { if (this.loadNeedle !== this.needle) { this.loadNeedle = this.needle if (!this.needle) this.searchData = null else this.searchData = await worker.search(this.doc, this.pageNumber, this.needle) } } async _show() { if (!this.loadPromise) this.loadPromise = this._load() await this.loadPromise // Render image if zoom factor has changed! if (this.canvasNode.zoom !== this.zoom) this._render() // (Re-)create HTML nodes if zoom factor has changed if (this.textNode.zoom !== this.zoom) this._showText() // (Re-)create HTML nodes if zoom factor has changed if (this.linkNode.zoom !== this.zoom) this._showLinks() // Reload search hits if the needle has changed. // TODO: race condition with multiple queued searches if (this.loadNeedle !== this.needle) await this._loadSearch() // (Re-)create HTML nodes if search changed or zoom factor changed if (this.showNeedle !== this.needle || this.searchNode.zoom !== this.zoom) this._showSearch() } async _render() { // Remember zoom value when we start rendering. let zoom = this.zoom // If the current image node was rendered with the same arguments we skip the render. if (this.canvasNode.zoom === this.zoom) return if (this.drawPromise) { // If a render is ongoing, don't queue a new render immediately! // When the on-going render finishes, we check the page zoom value. // If it is stale, we immediately queue a new render. console.log("BUSY DRAWING", this.pageNumber) return } console.log("DRAWING", this.pageNumber, zoom) this.canvasNode.zoom = this.zoom this.drawPromise = worker.drawPageAsPixmap(this.doc, this.pageNumber, zoom * devicePixelRatio) let imageData = await this.drawPromise if (imageData == null) return this.drawPromise = null if (this.zoom === zoom) { // Render is still valid. Use it! console.log("FRESH IMAGE", this.pageNumber) this.canvasNode.width = imageData.width this.canvasNode.height = imageData.height this.canvasCtx.putImageData(imageData, 0, 0) } else { // Uh-oh. This render is already stale. Try again! console.log("STALE IMAGE", this.pageNumber) if (set_has(page_visible, this.pageNumber)) this._render() } } _showText() { let frag = document.createDocumentFragment() let scale = this.zoom / 72 for (let block of this.textData.blocks) { if (block.type === "text") { for (let line of block.lines) { let text = document.createElementNS("http://www.w3.org/2000/svg", "text") text.setAttribute("x", line.bbox.x * scale + "px") text.setAttribute("y", line.y * scale + "px") text.style.fontSize = line.font.size * scale + "px" text.style.fontFamily = line.font.family text.style.fontWeight = line.font.weight text.style.fontStyle = line.font.style text.setAttribute("textLength", line.bbox.w * scale + "px") text.setAttribute("lengthAdjust", "spacingAndGlyphs") text.textContent = line.text frag.appendChild(text) } } } this.textNode.zoom = this.zoom this.textNode.replaceChildren(frag) } _showLinks() { this.linkNode.zoom = this.zoom this.linkNode.replaceChildren() let scale = this.zoom / 72 for (let link of this.linkData) { let a = document.createElement("a") a.href = link.href a.style.left = link.x * scale + "px" a.style.top = link.y * scale + "px" a.style.width = link.w * scale + "px" a.style.height = link.h * scale + "px" this.linkNode.appendChild(a) } } _showSearch() { this.showNeedle = this.needle this.searchNode.zoom = this.zoom this.searchNode.replaceChildren() if (this.searchData) { let scale = this.zoom / 72 for (let bbox of this.searchData) { let div = document.createElement("div") div.style.left = bbox.x * scale + "px" div.style.top = bbox.y * scale + "px" div.style.width = bbox.w * scale + "px" div.style.height = bbox.h * scale + "px" this.searchNode.appendChild(div) } } } } // DOCUMENT VIEW var current_doc = 0 var current_zoom = 96 var page_list = null // all pages in document // Track page visibility as the user scrolls through the document. // When a page comes near the viewport, we add it to the list of // "visible" pages and queue up rendering it. var page_visible = [] var page_observer = new IntersectionObserver( function (entries) { for (let entry of entries) { let page = entry.target.page if (entry.isIntersecting) set_add(page_visible, page.pageNumber) else set_delete(page_visible, page.pageNumber) } queue_update_view() }, { // This means we have 3 viewports of vertical "head start" where // the page is rendered before it becomes visible. root: document.getElementById("page-panel"), rootMargin: "25% 0px 300% 0px", } ) // Timer that waits until things settle before kicking off rendering. var update_view_timer = 0 function queue_update_view() { if (update_view_timer) clearTimeout(update_view_timer) update_view_timer = setTimeout(update_view, 50) } function update_view() { if (update_view_timer) clearTimeout(update_view_timer) update_view_timer = 0 for (let i of page_visible) page_list[i]._show() } function find_visible_page() { let panel = document.getElementById("page-panel").getBoundingClientRect() let panel_mid = (panel.top + panel.bottom) / 2 for (let p of page_visible) { let rect = page_list[p].rootNode.getBoundingClientRect() if (rect.top <= panel_mid && rect.bottom >= panel_mid) return p } return page_visible[0] } function zoom_in() { zoom_to(Math.min(current_zoom + 12, 384)) } function zoom_out() { zoom_to(Math.max(current_zoom - 12, 48)) } function zoom_to(new_zoom) { if (current_zoom === new_zoom) return current_zoom = new_zoom // TODO: keep page coord at center of cursor in place when zooming let p = find_visible_page() for (let page of page_list) page.setZoom(current_zoom) page_list[p].rootNode.scrollIntoView() queue_update_view() } // KEY BINDINGS & MOUSE WHEEL ZOOM window.addEventListener("wheel", function (event) { // Intercept Ctl+MOUSEWHEEL that change browser zoom. // Our page rendering requires a 1-to-1 pixel scale. if (event.ctrlKey || event.metaKey) { if (event.deltaY < 0) zoom_in() else if (event.deltaY > 0) zoom_out() event.preventDefault() } }, { passive: false } ) window.addEventListener("keydown", function (event) { // Intercept and override some keyboard shortcuts. // We must override the Ctl-PLUS and Ctl-MINUS shortcuts that change browser zoom. // Our page rendering requires a 1-to-1 pixel scale. if (event.ctrlKey || event.metaKey) { switch (event.keyCode) { // '=' / '+' on various keyboards case 61: case 107: case 187: case 171: zoom_in() event.preventDefault() break // '-' case 173: case 109: case 189: zoom_out() event.preventDefault() break // '0' case 48: case 96: zoom_to(100) break // 'A' case 65: // Ctrl-A full selection document.getSelection().selectAllChildren(document.getElementById("pages")) event.preventDefault() break // 'F' case 70: show_search_panel() event.preventDefault() break // 'G' case 71: show_search_panel() run_search(event.shiftKey ? -1 : 1, 1) event.preventDefault() break } } if (event.key === "Escape") { hide_search_panel() } }) function toggle_fullscreen() { // Safari on iPhone doesn't support Fullscreen if (typeof document.documentElement.requestFullscreen !== "function") return if (document.fullscreenElement) document.exitFullscreen() else document.documentElement.requestFullscreen() } // Mark TEXT-SELECTION State function remove_selection_state(e) { document.getElementById("pages").classList.remove("do-content-select") document.removeEventListener("mouseup", remove_selection_state) } document.addEventListener("selectstart", function (event) { document.getElementById("pages").classList.add("do-content-select") document.addEventListener("mouseup", remove_selection_state) }) // SEARCH let search_panel = document.getElementById("search-panel") let search_status = document.getElementById("search-status") let search_input = document.getElementById("search-input") var current_search_needle = "" var last_search_page = -1 search_input.onchange = function (event) { last_search_page = -1 } search_input.onkeyup = function (event) { if (event.key === 'Enter') { if (event.shiftKey) document.getElementById("search-prev").click() else document.getElementById("search-next").click() } } function show_search_panel() { if (!page_list) return search_panel.style.display = "" search_input.focus() search_input.select() } function hide_search_panel() { search_panel.style.display = "none" search_input.value = "" set_search_needle("") } function set_search_needle(needle) { search_status.textContent = "" current_search_needle = needle if (!page_list) return for (let page of page_list) page.setSearch(current_search_needle) queue_update_view() } async function run_search(direction, step) { // start search from visible page set_search_needle(search_input.value) let page = 0; if (last_search_page === -1) page = find_visible_page() else { page = last_search_page if (step) page += direction } while (page >= 0 && page < page_list.length) { // We run the check once per loop iteration, // in case the search was cancel during the 'await' below. if (current_search_needle === "") { search_status.textContent = "" return } search_status.textContent = `Searching page ${page + 1}.` if (page_list[page].loadNeedle !== page_list[page].needle) await page_list[page]._loadSearch() const hits = page_list[page].searchData if (hits && hits.length > 0) { page_list[page].rootNode.scrollIntoView() last_search_page = page const word = hits.length === 1 ? "hit" : "hits" search_status.textContent = `${hits.length} ${word} on page ${page + 1}.` return } page += direction } search_status.textContent = "No more search hits." } // OUTLINE function build_outline(parent, outline) { for (let item of outline) { let node = document.createElement("li") let a = document.createElement("a") a.href = "#page" + (item.page + 1) a.textContent = item.title node.appendChild(a) if (item.down) { let down = document.createElement("ul") build_outline(down, item.down) node.appendChild(down) } parent.appendChild(node) } } function toggle_outline_panel() { if (document.getElementById("outline-panel").style.display === "none") show_outline_panel() else hide_outline_panel() } function show_outline_panel() { if (!page_list) return document.getElementById("outline-panel").style.display = "block" } function hide_outline_panel() { document.getElementById("outline-panel").style.display = "none" } // DOCUMENT LOADING function close_document() { clear_message() hide_outline_panel() hide_search_panel() if (current_doc) { worker.closeDocument(current_doc) current_doc = 0 document.getElementById("outline").replaceChildren() document.getElementById("pages").replaceChildren() for (let page of page_list) page_observer.unobserve(page.rootNode) page_visible.length = 0 } page_list = null } async function init_document(title) { document.title = await worker.documentTitle(current_doc) || title var page_count = await worker.countPages(current_doc) // Use second page as default page size (the cover page is often differently sized) var page_size = await worker.getPageSize(current_doc, page_count > 1 ? 1 : 0) page_list = [] for (let i = 0; i < page_count; ++i) page_list[i] = new PageView(current_doc, i, page_size, current_zoom) for (let page of page_list) { document.getElementById("pages").appendChild(page.rootNode) page_observer.observe(page.rootNode) } var outline = await worker.documentOutline(current_doc) if (outline) { build_outline(document.getElementById("outline"), outline) show_outline_panel() } else { hide_outline_panel() } clear_message() current_search_needle = "" last_search_page = -1 } async function open_document_from_buffer(buffer, magic, title) { current_doc = await worker.openDocumentFromBuffer(buffer, magic) await init_document(title) } async function open_document_from_blob(blob, magic, title) { current_doc = await worker.openDocumentFromBlob(blob, magic) await init_document(title) } async function open_document_from_file(file) { close_document() try { show_message("Opening " + file.name) history.replaceState(null, null, window.location.pathname) await open_document_from_blob(file, file.name, file.name) } catch (error) { show_message(error.name + ": " + error.message) console.error(error) } } async function open_document_from_url(path) { close_document() try { show_message("Loading " + path) let response = await fetch(path) if (!response.ok) throw new Error("Could not fetch document.") await open_document_from_buffer(await response.arrayBuffer(), path, path) } catch (error) { show_message(error.name + ": " + error.message) console.error(error) } } function main() { clear_message() let params = new URLSearchParams(window.location.search) if (params.has("file")) open_document_from_url(params.get("file")) } </script>
