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)">&#x3C;</button>
+		<button id="search-next" onclick="run_search(1, 1)">&#x3E;</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>