Mercurial > hgrepos > Python2 > PyMuPDF
diff mupdf-source/platform/wasm/examples/simple-viewer/index.html @ 2:b50eed0cc0ef upstream
ADD: MuPDF v1.26.7: the MuPDF source as downloaded by a default build of PyMuPDF 1.26.4.
The directory name has changed: no version number in the expanded directory now.
| author | Franz Glasner <fzglas.hg@dom66.de> |
|---|---|
| date | Mon, 15 Sep 2025 11:43:07 +0200 |
| parents | |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mupdf-source/platform/wasm/examples/simple-viewer/index.html Mon Sep 15 11:43:07 2025 +0200 @@ -0,0 +1,1076 @@ +<!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>
