Mercurial > hgrepos > Python2 > PyMuPDF
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 1:1d09e1dec1d9 | 2:b50eed0cc0ef |
|---|---|
| 1 <!DOCTYPE html> | |
| 2 | |
| 3 <!-- | |
| 4 Copyright (C) 2022, 2024, 2025 Artifex Software, Inc. | |
| 5 | |
| 6 This file is part of MuPDF. | |
| 7 | |
| 8 MuPDF is free software: you can redistribute it and/or modify it under the | |
| 9 terms of the GNU Affero General Public License as published by the Free | |
| 10 Software Foundation, either version 3 of the License, or (at your option) | |
| 11 any later version. | |
| 12 | |
| 13 MuPDF is distributed in the hope that it will be useful, but WITHOUT ANY | |
| 14 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS | |
| 15 FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more | |
| 16 details. | |
| 17 | |
| 18 You should have received a copy of the GNU Affero General Public License | |
| 19 along with MuPDF. If not, see <https://www.gnu.org/licenses/agpl-3.0.en.html> | |
| 20 | |
| 21 Alternative licensing terms are available from the licensor. | |
| 22 For commercial licensing, see <https://www.artifex.com/> or contact | |
| 23 Artifex Software, Inc., 39 Mesa Street, Suite 108A, San Francisco, | |
| 24 CA 94129, USA, for further information. | |
| 25 --> | |
| 26 | |
| 27 <title>MuPDF.js</title> | |
| 28 | |
| 29 <meta charset="utf-8"> | |
| 30 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"> | |
| 31 | |
| 32 <link rel="shortcut icon" href="favicon.svg"> | |
| 33 | |
| 34 <style> | |
| 35 * { | |
| 36 box-sizing: border-box; | |
| 37 } | |
| 38 | |
| 39 /* APPEARANCE */ | |
| 40 | |
| 41 html { | |
| 42 font-family: sans-serif; | |
| 43 font-size: 18px; | |
| 44 background-color: gray; | |
| 45 } | |
| 46 | |
| 47 header { | |
| 48 border-bottom: 1px solid black; | |
| 49 background-color: gainsboro; | |
| 50 } | |
| 51 | |
| 52 footer { | |
| 53 border-top: 1px solid black; | |
| 54 background-color: gainsboro; | |
| 55 } | |
| 56 | |
| 57 aside { | |
| 58 background-color: white; | |
| 59 border-right: 1px solid black; | |
| 60 } | |
| 61 | |
| 62 #message { | |
| 63 text-align: center; | |
| 64 font-size: 24pt; | |
| 65 font-weight: bold; | |
| 66 color: silver; | |
| 67 } | |
| 68 | |
| 69 details[open] > summary { | |
| 70 background-color: #0004; | |
| 71 } | |
| 72 | |
| 73 menu { | |
| 74 min-width: 140px; | |
| 75 border: 1px solid black; | |
| 76 background-color: white; | |
| 77 color: black; | |
| 78 } | |
| 79 | |
| 80 menu li:hover { | |
| 81 background-color: black; | |
| 82 color: white; | |
| 83 } | |
| 84 | |
| 85 /* LAYOUT */ | |
| 86 | |
| 87 html { | |
| 88 margin: 0; | |
| 89 padding: 0; | |
| 90 width: 100%; | |
| 91 height: 100%; | |
| 92 } | |
| 93 | |
| 94 body { | |
| 95 margin: 0; | |
| 96 padding: 0; | |
| 97 display: grid; | |
| 98 grid-template-columns: auto minmax(0, 1fr); | |
| 99 grid-template-rows: auto minmax(0, 1fr) auto; | |
| 100 width: 100%; | |
| 101 height: 100%; | |
| 102 overflow: clip; | |
| 103 } | |
| 104 | |
| 105 header { | |
| 106 position: relative; | |
| 107 user-select: none; | |
| 108 grid-column: 1/3; | |
| 109 grid-row: 1; | |
| 110 display: flex; | |
| 111 flex-wrap: wrap; | |
| 112 } | |
| 113 | |
| 114 footer { | |
| 115 grid-column: 1/3; | |
| 116 grid-row: 3; | |
| 117 display: flex; | |
| 118 padding: 8px; | |
| 119 gap: 8px; | |
| 120 } | |
| 121 | |
| 122 aside { | |
| 123 grid-column: 1; | |
| 124 grid-row: 2; | |
| 125 overflow-y: auto; | |
| 126 width: 250px; | |
| 127 } | |
| 128 | |
| 129 main { | |
| 130 grid-column: 2; | |
| 131 grid-row: 2; | |
| 132 overflow: scroll; | |
| 133 } | |
| 134 | |
| 135 summary { | |
| 136 padding: 4px 8px; | |
| 137 cursor: pointer; | |
| 138 list-style: none; | |
| 139 } | |
| 140 | |
| 141 /* workaround for bug in Safari details appearance */ | |
| 142 summary::-webkit-details-marker { | |
| 143 display: none; | |
| 144 } | |
| 145 | |
| 146 menu { | |
| 147 position: absolute; | |
| 148 overflow-y: auto; | |
| 149 margin: 0; | |
| 150 padding: 0; | |
| 151 list-style: none; | |
| 152 z-index: 500; | |
| 153 } | |
| 154 | |
| 155 menu li { | |
| 156 padding: 4px 8px; | |
| 157 cursor: pointer; | |
| 158 } | |
| 159 | |
| 160 /* OUTLINE */ | |
| 161 | |
| 162 #outline { | |
| 163 font-size: 12px; | |
| 164 } | |
| 165 | |
| 166 #outline ul { | |
| 167 margin: 0; | |
| 168 padding-left: 20px; | |
| 169 } | |
| 170 | |
| 171 #outline a { | |
| 172 color: black; | |
| 173 text-decoration: none; | |
| 174 } | |
| 175 | |
| 176 #outline a:hover { | |
| 177 color: blue; | |
| 178 text-decoration: underline; | |
| 179 } | |
| 180 | |
| 181 /* PAGES */ | |
| 182 | |
| 183 #pages { | |
| 184 margin: 0 auto; | |
| 185 } | |
| 186 | |
| 187 div.page { | |
| 188 position: relative; | |
| 189 background-color: white; | |
| 190 margin: 16px auto; | |
| 191 box-shadow: 0px 2px 8px #0004; | |
| 192 } | |
| 193 | |
| 194 div.page * { | |
| 195 position: absolute; | |
| 196 } | |
| 197 | |
| 198 div.page canvas { | |
| 199 user-select: none; | |
| 200 } | |
| 201 | |
| 202 svg.text { | |
| 203 width: 100%; | |
| 204 height: 100%; | |
| 205 } | |
| 206 | |
| 207 svg.text text { | |
| 208 white-space: pre; | |
| 209 line-height: 1; | |
| 210 fill: transparent; | |
| 211 } | |
| 212 | |
| 213 svg.text ::selection { | |
| 214 background: hsla(220, 100%, 50%, 0.2); | |
| 215 color: transparent; | |
| 216 } | |
| 217 | |
| 218 div.link a:hover { | |
| 219 border: 1px dotted blue; | |
| 220 } | |
| 221 | |
| 222 #pages.do-content-select div.link { | |
| 223 pointer-events: none; | |
| 224 } | |
| 225 | |
| 226 div.search > div { | |
| 227 pointer-events: none; | |
| 228 border: 1px solid hotpink; | |
| 229 background-color: lightpink; | |
| 230 mix-blend-mode: multiply; | |
| 231 } | |
| 232 </style> | |
| 233 | |
| 234 <body> | |
| 235 | |
| 236 <header id="menubar-panel"> | |
| 237 <details> | |
| 238 <summary>File</summary> | |
| 239 <menu> | |
| 240 <li onclick="document.getElementById('open-file-input').click()">Open File... | |
| 241 </menu> | |
| 242 </details> | |
| 243 <details> | |
| 244 <summary>Edit</summary> | |
| 245 <menu> | |
| 246 <li onclick="show_search_panel()">Search... | |
| 247 </menu> | |
| 248 </details> | |
| 249 <details> | |
| 250 <summary>View</summary> | |
| 251 <menu> | |
| 252 <li onclick="toggle_fullscreen()">Fullscreen | |
| 253 <li onclick="toggle_outline_panel()">Outline | |
| 254 <li onclick="zoom_to(48)">50% | |
| 255 <li onclick="zoom_to(72)">75% (72 dpi) | |
| 256 <li onclick="zoom_to(96)">100% (96 dpi) | |
| 257 <li onclick="zoom_to(120)">125% | |
| 258 <li onclick="zoom_to(144)">150% | |
| 259 <li onclick="zoom_to(192)">200% | |
| 260 </menu> | |
| 261 </details> | |
| 262 </header> | |
| 263 | |
| 264 <aside id="outline-panel" style="display:none"> | |
| 265 <ul id="outline"> | |
| 266 <!-- outline inserted here --> | |
| 267 </ul> | |
| 268 </aside> | |
| 269 | |
| 270 <main id="page-panel"> | |
| 271 <div id="message"> | |
| 272 Loading MuPDF.js... | |
| 273 </div> | |
| 274 <div id="pages"> | |
| 275 <!-- pages inserted here --> | |
| 276 </div> | |
| 277 </main> | |
| 278 | |
| 279 <footer id="search-panel" style="display:none"> | |
| 280 <input | |
| 281 id="search-input" | |
| 282 type="search" | |
| 283 size="40" | |
| 284 placeholder="Search..." | |
| 285 > | |
| 286 <button id="search-prev" onclick="run_search(-1, 1)"><</button> | |
| 287 <button id="search-next" onclick="run_search(1, 1)">></button> | |
| 288 <div id="search-status" style="flex-grow:1"></div> | |
| 289 <button onclick="hide_search_panel()">X</button> | |
| 290 </footer> | |
| 291 | |
| 292 <!-- hidden input for file dialog --> | |
| 293 <input | |
| 294 style="display: none" | |
| 295 id="open-file-input" | |
| 296 type="file" | |
| 297 accept=".pdf,application/pdf" | |
| 298 onchange="open_document_from_file(event.target.files[0])" | |
| 299 > | |
| 300 | |
| 301 </body> | |
| 302 | |
| 303 <script> | |
| 304 "use strict" | |
| 305 | |
| 306 // FAST SORTED ARRAY FUNCTIONS | |
| 307 | |
| 308 function array_remove(array, index) { | |
| 309 let n = array.length | |
| 310 for (let i = index + 1; i < n; ++i) | |
| 311 array[i - 1] = array[i] | |
| 312 array.length = n - 1 | |
| 313 } | |
| 314 | |
| 315 function array_insert(array, index, item) { | |
| 316 for (let i = array.length; i > index; --i) | |
| 317 array[i] = array[i - 1] | |
| 318 array[index] = item | |
| 319 } | |
| 320 | |
| 321 function set_has(set, item) { | |
| 322 let a = 0 | |
| 323 let b = set.length - 1 | |
| 324 while (a <= b) { | |
| 325 let m = (a + b) >> 1 | |
| 326 let x = set[m] | |
| 327 if (item < x) | |
| 328 b = m - 1 | |
| 329 else if (item > x) | |
| 330 a = m + 1 | |
| 331 else | |
| 332 return true | |
| 333 } | |
| 334 return false | |
| 335 } | |
| 336 | |
| 337 function set_add(set, item) { | |
| 338 let a = 0 | |
| 339 let b = set.length - 1 | |
| 340 while (a <= b) { | |
| 341 let m = (a + b) >> 1 | |
| 342 let x = set[m] | |
| 343 if (item < x) | |
| 344 b = m - 1 | |
| 345 else if (item > x) | |
| 346 a = m + 1 | |
| 347 else | |
| 348 return | |
| 349 } | |
| 350 array_insert(set, a, item) | |
| 351 } | |
| 352 | |
| 353 function set_delete(set, item) { | |
| 354 let a = 0 | |
| 355 let b = set.length - 1 | |
| 356 while (a <= b) { | |
| 357 let m = (a + b) >> 1 | |
| 358 let x = set[m] | |
| 359 if (item < x) | |
| 360 b = m - 1 | |
| 361 else if (item > x) | |
| 362 a = m + 1 | |
| 363 else { | |
| 364 array_remove(set, m) | |
| 365 return | |
| 366 } | |
| 367 } | |
| 368 } | |
| 369 | |
| 370 // LOADING AND ERROR MESSAGES | |
| 371 | |
| 372 function show_message(msg) { | |
| 373 document.getElementById("message").textContent = msg | |
| 374 } | |
| 375 | |
| 376 function clear_message() { | |
| 377 document.getElementById("message").textContent = "" | |
| 378 } | |
| 379 | |
| 380 // MENU BAR | |
| 381 | |
| 382 function close_all_menus(self) { | |
| 383 for (let node of document.querySelectorAll("header > details")) | |
| 384 if (node !== self) | |
| 385 node.removeAttribute("open") | |
| 386 } | |
| 387 | |
| 388 /* close menu if opening another */ | |
| 389 for (let node of document.querySelectorAll("header > details")) { | |
| 390 node.addEventListener("click", function () { | |
| 391 close_all_menus(node) | |
| 392 }) | |
| 393 } | |
| 394 | |
| 395 /* close menu after selecting something */ | |
| 396 for (let node of document.querySelectorAll("header > details > menu")) { | |
| 397 node.addEventListener("click", function () { | |
| 398 close_all_menus(null) | |
| 399 }) | |
| 400 } | |
| 401 | |
| 402 /* click anywhere outside the menu to close it */ | |
| 403 window.addEventListener("mousedown", function (evt) { | |
| 404 let e = evt.target | |
| 405 while (e) { | |
| 406 if (e.tagName === "DETAILS") | |
| 407 return | |
| 408 e = e.parentElement | |
| 409 } | |
| 410 close_all_menus(null) | |
| 411 }) | |
| 412 | |
| 413 /* close menus if window loses focus */ | |
| 414 window.addEventListener("blur", function () { | |
| 415 close_all_menus(null) | |
| 416 }) | |
| 417 | |
| 418 // BACKGROUND WORKER | |
| 419 | |
| 420 const worker = new Worker("worker.js", { type: "module" }) | |
| 421 | |
| 422 worker._promise_id = 1 | |
| 423 worker._promise_map = new Map() | |
| 424 | |
| 425 worker.wrap = function (name) { | |
| 426 return function (...args) { | |
| 427 return new Promise(function (resolve, reject) { | |
| 428 let id = worker._promise_id++ | |
| 429 worker._promise_map.set(id, { resolve, reject }) | |
| 430 if (args[0] instanceof ArrayBuffer) | |
| 431 worker.postMessage([ name, id, args ], [ args[0] ]) | |
| 432 else | |
| 433 worker.postMessage([ name, id, args ]) | |
| 434 }) | |
| 435 } | |
| 436 } | |
| 437 | |
| 438 worker.onmessage = function (event) { | |
| 439 let [ type, id, result ] = event.data | |
| 440 let error | |
| 441 | |
| 442 switch (type) { | |
| 443 case "INIT": | |
| 444 for (let method of result) | |
| 445 worker[method] = worker.wrap(method) | |
| 446 main() | |
| 447 break | |
| 448 | |
| 449 case "RESULT": | |
| 450 worker._promise_map.get(id).resolve(result) | |
| 451 worker._promise_map.delete(id) | |
| 452 break | |
| 453 | |
| 454 case "ERROR": | |
| 455 error = new Error(result.message) | |
| 456 error.name = result.name | |
| 457 error.stack = result.stack | |
| 458 worker._promise_map.get(id).reject(error) | |
| 459 worker._promise_map.delete(id) | |
| 460 break | |
| 461 | |
| 462 default: | |
| 463 error = new Error(`Invalid message: ${type}`) | |
| 464 worker._promise_map.get(id).reject(error) | |
| 465 break | |
| 466 } | |
| 467 } | |
| 468 | |
| 469 // PAGE VIEW | |
| 470 | |
| 471 class PageView { | |
| 472 constructor(doc, pageNumber, defaultSize, zoom) { | |
| 473 this.doc = doc | |
| 474 this.pageNumber = pageNumber // 0-based | |
| 475 this.size = defaultSize | |
| 476 | |
| 477 this.loadPromise = false | |
| 478 this.drawPromise = false | |
| 479 | |
| 480 this.rootNode = document.createElement("div") | |
| 481 this.rootNode.id = "page" + (pageNumber + 1) | |
| 482 this.rootNode.className = "page" | |
| 483 this.rootNode.page = this | |
| 484 | |
| 485 this.canvasNode = document.createElement("canvas") | |
| 486 this.canvasCtx = this.canvasNode.getContext("2d") | |
| 487 this.rootNode.appendChild(this.canvasNode) | |
| 488 | |
| 489 this.textData = null | |
| 490 this.textNode = document.createElementNS("http://www.w3.org/2000/svg", "svg") | |
| 491 this.textNode.classList.add("text") | |
| 492 this.rootNode.appendChild(this.textNode) | |
| 493 | |
| 494 this.linkData = null | |
| 495 this.linkNode = document.createElement("div") | |
| 496 this.linkNode.className = "link" | |
| 497 this.rootNode.appendChild(this.linkNode) | |
| 498 | |
| 499 this.needle = null | |
| 500 this.loadNeedle = null | |
| 501 this.showNeedle = null | |
| 502 | |
| 503 this.searchData = null | |
| 504 this.searchNode = document.createElement("div") | |
| 505 this.searchNode.className = "search" | |
| 506 this.rootNode.appendChild(this.searchNode) | |
| 507 | |
| 508 this.zoom = zoom | |
| 509 this._updateSize() | |
| 510 } | |
| 511 | |
| 512 // Update page element size for current zoom level. | |
| 513 _updateSize() { | |
| 514 // We Math.ceil to match the behavior of fz_irect_from_rect that is used by the worker. | |
| 515 this.rootNode.style.width = Math.ceil((this.size.width * this.zoom) / 72) + "px" | |
| 516 this.rootNode.style.height = Math.ceil((this.size.height * this.zoom) / 72) + "px" | |
| 517 this.canvasNode.style.width = Math.ceil((this.size.width * this.zoom) / 72) + "px" | |
| 518 this.canvasNode.style.height = Math.ceil((this.size.height * this.zoom) / 72) + "px" | |
| 519 } | |
| 520 | |
| 521 setZoom(zoom) { | |
| 522 if (this.zoom !== zoom) { | |
| 523 this.zoom = zoom | |
| 524 this._updateSize() | |
| 525 } | |
| 526 } | |
| 527 | |
| 528 setSearch(needle) { | |
| 529 if (this.needle !== needle) | |
| 530 this.needle = needle | |
| 531 } | |
| 532 | |
| 533 async _load() { | |
| 534 console.log("LOADING", this.pageNumber) | |
| 535 | |
| 536 this.size = await worker.getPageSize(this.doc, this.pageNumber) | |
| 537 this.textData = await worker.getPageText(this.doc, this.pageNumber) | |
| 538 this.linkData = await worker.getPageLinks(this.doc, this.pageNumber) | |
| 539 | |
| 540 this._updateSize() | |
| 541 } | |
| 542 | |
| 543 async _loadSearch() { | |
| 544 if (this.loadNeedle !== this.needle) { | |
| 545 this.loadNeedle = this.needle | |
| 546 if (!this.needle) | |
| 547 this.searchData = null | |
| 548 else | |
| 549 this.searchData = await worker.search(this.doc, this.pageNumber, this.needle) | |
| 550 } | |
| 551 } | |
| 552 | |
| 553 async _show() { | |
| 554 if (!this.loadPromise) | |
| 555 this.loadPromise = this._load() | |
| 556 await this.loadPromise | |
| 557 | |
| 558 // Render image if zoom factor has changed! | |
| 559 if (this.canvasNode.zoom !== this.zoom) | |
| 560 this._render() | |
| 561 | |
| 562 // (Re-)create HTML nodes if zoom factor has changed | |
| 563 if (this.textNode.zoom !== this.zoom) | |
| 564 this._showText() | |
| 565 | |
| 566 // (Re-)create HTML nodes if zoom factor has changed | |
| 567 if (this.linkNode.zoom !== this.zoom) | |
| 568 this._showLinks() | |
| 569 | |
| 570 // Reload search hits if the needle has changed. | |
| 571 // TODO: race condition with multiple queued searches | |
| 572 if (this.loadNeedle !== this.needle) | |
| 573 await this._loadSearch() | |
| 574 | |
| 575 // (Re-)create HTML nodes if search changed or zoom factor changed | |
| 576 if (this.showNeedle !== this.needle || this.searchNode.zoom !== this.zoom) | |
| 577 this._showSearch() | |
| 578 } | |
| 579 | |
| 580 async _render() { | |
| 581 // Remember zoom value when we start rendering. | |
| 582 let zoom = this.zoom | |
| 583 | |
| 584 // If the current image node was rendered with the same arguments we skip the render. | |
| 585 if (this.canvasNode.zoom === this.zoom) | |
| 586 return | |
| 587 | |
| 588 if (this.drawPromise) { | |
| 589 // If a render is ongoing, don't queue a new render immediately! | |
| 590 // When the on-going render finishes, we check the page zoom value. | |
| 591 // If it is stale, we immediately queue a new render. | |
| 592 console.log("BUSY DRAWING", this.pageNumber) | |
| 593 return | |
| 594 } | |
| 595 | |
| 596 console.log("DRAWING", this.pageNumber, zoom) | |
| 597 | |
| 598 this.canvasNode.zoom = this.zoom | |
| 599 | |
| 600 this.drawPromise = worker.drawPageAsPixmap(this.doc, this.pageNumber, zoom * devicePixelRatio) | |
| 601 | |
| 602 let imageData = await this.drawPromise | |
| 603 if (imageData == null) | |
| 604 return | |
| 605 | |
| 606 this.drawPromise = null | |
| 607 | |
| 608 if (this.zoom === zoom) { | |
| 609 // Render is still valid. Use it! | |
| 610 console.log("FRESH IMAGE", this.pageNumber) | |
| 611 this.canvasNode.width = imageData.width | |
| 612 this.canvasNode.height = imageData.height | |
| 613 this.canvasCtx.putImageData(imageData, 0, 0) | |
| 614 } else { | |
| 615 // Uh-oh. This render is already stale. Try again! | |
| 616 console.log("STALE IMAGE", this.pageNumber) | |
| 617 if (set_has(page_visible, this.pageNumber)) | |
| 618 this._render() | |
| 619 } | |
| 620 } | |
| 621 | |
| 622 _showText() { | |
| 623 let frag = document.createDocumentFragment() | |
| 624 let scale = this.zoom / 72 | |
| 625 | |
| 626 for (let block of this.textData.blocks) { | |
| 627 if (block.type === "text") { | |
| 628 for (let line of block.lines) { | |
| 629 let text = document.createElementNS("http://www.w3.org/2000/svg", "text") | |
| 630 text.setAttribute("x", line.bbox.x * scale + "px") | |
| 631 text.setAttribute("y", line.y * scale + "px") | |
| 632 text.style.fontSize = line.font.size * scale + "px" | |
| 633 text.style.fontFamily = line.font.family | |
| 634 text.style.fontWeight = line.font.weight | |
| 635 text.style.fontStyle = line.font.style | |
| 636 text.setAttribute("textLength", line.bbox.w * scale + "px") | |
| 637 text.setAttribute("lengthAdjust", "spacingAndGlyphs") | |
| 638 text.textContent = line.text | |
| 639 frag.appendChild(text) | |
| 640 } | |
| 641 } | |
| 642 } | |
| 643 | |
| 644 this.textNode.zoom = this.zoom | |
| 645 this.textNode.replaceChildren(frag) | |
| 646 } | |
| 647 | |
| 648 _showLinks() { | |
| 649 this.linkNode.zoom = this.zoom | |
| 650 this.linkNode.replaceChildren() | |
| 651 | |
| 652 let scale = this.zoom / 72 | |
| 653 for (let link of this.linkData) { | |
| 654 let a = document.createElement("a") | |
| 655 a.href = link.href | |
| 656 a.style.left = link.x * scale + "px" | |
| 657 a.style.top = link.y * scale + "px" | |
| 658 a.style.width = link.w * scale + "px" | |
| 659 a.style.height = link.h * scale + "px" | |
| 660 this.linkNode.appendChild(a) | |
| 661 } | |
| 662 } | |
| 663 | |
| 664 _showSearch() { | |
| 665 this.showNeedle = this.needle | |
| 666 this.searchNode.zoom = this.zoom | |
| 667 this.searchNode.replaceChildren() | |
| 668 | |
| 669 if (this.searchData) { | |
| 670 let scale = this.zoom / 72 | |
| 671 for (let bbox of this.searchData) { | |
| 672 let div = document.createElement("div") | |
| 673 div.style.left = bbox.x * scale + "px" | |
| 674 div.style.top = bbox.y * scale + "px" | |
| 675 div.style.width = bbox.w * scale + "px" | |
| 676 div.style.height = bbox.h * scale + "px" | |
| 677 this.searchNode.appendChild(div) | |
| 678 } | |
| 679 } | |
| 680 } | |
| 681 } | |
| 682 | |
| 683 // DOCUMENT VIEW | |
| 684 | |
| 685 var current_doc = 0 | |
| 686 var current_zoom = 96 | |
| 687 | |
| 688 var page_list = null // all pages in document | |
| 689 | |
| 690 // Track page visibility as the user scrolls through the document. | |
| 691 // When a page comes near the viewport, we add it to the list of | |
| 692 // "visible" pages and queue up rendering it. | |
| 693 var page_visible = [] | |
| 694 var page_observer = new IntersectionObserver( | |
| 695 function (entries) { | |
| 696 for (let entry of entries) { | |
| 697 let page = entry.target.page | |
| 698 if (entry.isIntersecting) | |
| 699 set_add(page_visible, page.pageNumber) | |
| 700 else | |
| 701 set_delete(page_visible, page.pageNumber) | |
| 702 } | |
| 703 queue_update_view() | |
| 704 }, | |
| 705 { | |
| 706 // This means we have 3 viewports of vertical "head start" where | |
| 707 // the page is rendered before it becomes visible. | |
| 708 root: document.getElementById("page-panel"), | |
| 709 rootMargin: "25% 0px 300% 0px", | |
| 710 } | |
| 711 ) | |
| 712 | |
| 713 | |
| 714 // Timer that waits until things settle before kicking off rendering. | |
| 715 var update_view_timer = 0 | |
| 716 function queue_update_view() { | |
| 717 if (update_view_timer) | |
| 718 clearTimeout(update_view_timer) | |
| 719 update_view_timer = setTimeout(update_view, 50) | |
| 720 } | |
| 721 | |
| 722 function update_view() { | |
| 723 if (update_view_timer) | |
| 724 clearTimeout(update_view_timer) | |
| 725 update_view_timer = 0 | |
| 726 | |
| 727 for (let i of page_visible) | |
| 728 page_list[i]._show() | |
| 729 } | |
| 730 | |
| 731 function find_visible_page() { | |
| 732 let panel = document.getElementById("page-panel").getBoundingClientRect() | |
| 733 let panel_mid = (panel.top + panel.bottom) / 2 | |
| 734 for (let p of page_visible) { | |
| 735 let rect = page_list[p].rootNode.getBoundingClientRect() | |
| 736 if (rect.top <= panel_mid && rect.bottom >= panel_mid) | |
| 737 return p | |
| 738 } | |
| 739 return page_visible[0] | |
| 740 } | |
| 741 | |
| 742 function zoom_in() { | |
| 743 zoom_to(Math.min(current_zoom + 12, 384)) | |
| 744 } | |
| 745 | |
| 746 function zoom_out() { | |
| 747 zoom_to(Math.max(current_zoom - 12, 48)) | |
| 748 } | |
| 749 | |
| 750 function zoom_to(new_zoom) { | |
| 751 if (current_zoom === new_zoom) | |
| 752 return | |
| 753 current_zoom = new_zoom | |
| 754 | |
| 755 // TODO: keep page coord at center of cursor in place when zooming | |
| 756 | |
| 757 let p = find_visible_page() | |
| 758 | |
| 759 for (let page of page_list) | |
| 760 page.setZoom(current_zoom) | |
| 761 | |
| 762 page_list[p].rootNode.scrollIntoView() | |
| 763 | |
| 764 queue_update_view() | |
| 765 } | |
| 766 | |
| 767 // KEY BINDINGS & MOUSE WHEEL ZOOM | |
| 768 | |
| 769 window.addEventListener("wheel", | |
| 770 function (event) { | |
| 771 // Intercept Ctl+MOUSEWHEEL that change browser zoom. | |
| 772 // Our page rendering requires a 1-to-1 pixel scale. | |
| 773 if (event.ctrlKey || event.metaKey) { | |
| 774 if (event.deltaY < 0) | |
| 775 zoom_in() | |
| 776 else if (event.deltaY > 0) | |
| 777 zoom_out() | |
| 778 event.preventDefault() | |
| 779 } | |
| 780 }, | |
| 781 { passive: false } | |
| 782 ) | |
| 783 | |
| 784 window.addEventListener("keydown", function (event) { | |
| 785 // Intercept and override some keyboard shortcuts. | |
| 786 // We must override the Ctl-PLUS and Ctl-MINUS shortcuts that change browser zoom. | |
| 787 // Our page rendering requires a 1-to-1 pixel scale. | |
| 788 if (event.ctrlKey || event.metaKey) { | |
| 789 switch (event.keyCode) { | |
| 790 // '=' / '+' on various keyboards | |
| 791 case 61: | |
| 792 case 107: | |
| 793 case 187: | |
| 794 case 171: | |
| 795 zoom_in() | |
| 796 event.preventDefault() | |
| 797 break | |
| 798 // '-' | |
| 799 case 173: | |
| 800 case 109: | |
| 801 case 189: | |
| 802 zoom_out() | |
| 803 event.preventDefault() | |
| 804 break | |
| 805 // '0' | |
| 806 case 48: | |
| 807 case 96: | |
| 808 zoom_to(100) | |
| 809 break | |
| 810 // 'A' | |
| 811 case 65: | |
| 812 // Ctrl-A full selection | |
| 813 document.getSelection().selectAllChildren(document.getElementById("pages")) | |
| 814 event.preventDefault() | |
| 815 break | |
| 816 // 'F' | |
| 817 case 70: | |
| 818 show_search_panel() | |
| 819 event.preventDefault() | |
| 820 break | |
| 821 | |
| 822 // 'G' | |
| 823 case 71: | |
| 824 show_search_panel() | |
| 825 run_search(event.shiftKey ? -1 : 1, 1) | |
| 826 event.preventDefault() | |
| 827 break | |
| 828 } | |
| 829 } | |
| 830 | |
| 831 if (event.key === "Escape") { | |
| 832 hide_search_panel() | |
| 833 } | |
| 834 }) | |
| 835 | |
| 836 function toggle_fullscreen() { | |
| 837 // Safari on iPhone doesn't support Fullscreen | |
| 838 if (typeof document.documentElement.requestFullscreen !== "function") | |
| 839 return | |
| 840 if (document.fullscreenElement) | |
| 841 document.exitFullscreen() | |
| 842 else | |
| 843 document.documentElement.requestFullscreen() | |
| 844 } | |
| 845 | |
| 846 // Mark TEXT-SELECTION State | |
| 847 function remove_selection_state(e) { | |
| 848 document.getElementById("pages").classList.remove("do-content-select") | |
| 849 document.removeEventListener("mouseup", remove_selection_state) | |
| 850 } | |
| 851 | |
| 852 document.addEventListener("selectstart", function (event) { | |
| 853 document.getElementById("pages").classList.add("do-content-select") | |
| 854 document.addEventListener("mouseup", remove_selection_state) | |
| 855 }) | |
| 856 | |
| 857 // SEARCH | |
| 858 | |
| 859 let search_panel = document.getElementById("search-panel") | |
| 860 let search_status = document.getElementById("search-status") | |
| 861 let search_input = document.getElementById("search-input") | |
| 862 | |
| 863 var current_search_needle = "" | |
| 864 var last_search_page = -1 | |
| 865 | |
| 866 search_input.onchange = function (event) { | |
| 867 last_search_page = -1 | |
| 868 } | |
| 869 | |
| 870 search_input.onkeyup = function (event) { | |
| 871 if (event.key === 'Enter') { | |
| 872 if (event.shiftKey) | |
| 873 document.getElementById("search-prev").click() | |
| 874 else | |
| 875 document.getElementById("search-next").click() | |
| 876 } | |
| 877 } | |
| 878 | |
| 879 function show_search_panel() { | |
| 880 if (!page_list) | |
| 881 return | |
| 882 search_panel.style.display = "" | |
| 883 search_input.focus() | |
| 884 search_input.select() | |
| 885 } | |
| 886 | |
| 887 function hide_search_panel() { | |
| 888 search_panel.style.display = "none" | |
| 889 search_input.value = "" | |
| 890 set_search_needle("") | |
| 891 } | |
| 892 | |
| 893 function set_search_needle(needle) { | |
| 894 search_status.textContent = "" | |
| 895 current_search_needle = needle | |
| 896 | |
| 897 if (!page_list) | |
| 898 return | |
| 899 | |
| 900 for (let page of page_list) | |
| 901 page.setSearch(current_search_needle) | |
| 902 | |
| 903 queue_update_view() | |
| 904 } | |
| 905 | |
| 906 async function run_search(direction, step) { | |
| 907 // start search from visible page | |
| 908 set_search_needle(search_input.value) | |
| 909 | |
| 910 let page = 0; | |
| 911 if (last_search_page === -1) | |
| 912 page = find_visible_page() | |
| 913 else { | |
| 914 page = last_search_page | |
| 915 if (step) | |
| 916 page += direction | |
| 917 } | |
| 918 | |
| 919 while (page >= 0 && page < page_list.length) { | |
| 920 // We run the check once per loop iteration, | |
| 921 // in case the search was cancel during the 'await' below. | |
| 922 if (current_search_needle === "") { | |
| 923 search_status.textContent = "" | |
| 924 return | |
| 925 } | |
| 926 | |
| 927 search_status.textContent = `Searching page ${page + 1}.` | |
| 928 | |
| 929 if (page_list[page].loadNeedle !== page_list[page].needle) | |
| 930 await page_list[page]._loadSearch() | |
| 931 | |
| 932 const hits = page_list[page].searchData | |
| 933 if (hits && hits.length > 0) { | |
| 934 page_list[page].rootNode.scrollIntoView() | |
| 935 last_search_page = page | |
| 936 const word = hits.length === 1 ? "hit" : "hits" | |
| 937 search_status.textContent = `${hits.length} ${word} on page ${page + 1}.` | |
| 938 return | |
| 939 } | |
| 940 | |
| 941 page += direction | |
| 942 } | |
| 943 | |
| 944 search_status.textContent = "No more search hits." | |
| 945 } | |
| 946 | |
| 947 // OUTLINE | |
| 948 | |
| 949 function build_outline(parent, outline) { | |
| 950 for (let item of outline) { | |
| 951 let node = document.createElement("li") | |
| 952 let a = document.createElement("a") | |
| 953 a.href = "#page" + (item.page + 1) | |
| 954 a.textContent = item.title | |
| 955 node.appendChild(a) | |
| 956 if (item.down) { | |
| 957 let down = document.createElement("ul") | |
| 958 build_outline(down, item.down) | |
| 959 node.appendChild(down) | |
| 960 } | |
| 961 parent.appendChild(node) | |
| 962 } | |
| 963 } | |
| 964 | |
| 965 function toggle_outline_panel() { | |
| 966 if (document.getElementById("outline-panel").style.display === "none") | |
| 967 show_outline_panel() | |
| 968 else | |
| 969 hide_outline_panel() | |
| 970 } | |
| 971 | |
| 972 function show_outline_panel() { | |
| 973 if (!page_list) | |
| 974 return | |
| 975 document.getElementById("outline-panel").style.display = "block" | |
| 976 } | |
| 977 | |
| 978 function hide_outline_panel() { | |
| 979 document.getElementById("outline-panel").style.display = "none" | |
| 980 } | |
| 981 | |
| 982 // DOCUMENT LOADING | |
| 983 | |
| 984 function close_document() { | |
| 985 clear_message() | |
| 986 hide_outline_panel() | |
| 987 hide_search_panel() | |
| 988 | |
| 989 if (current_doc) { | |
| 990 worker.closeDocument(current_doc) | |
| 991 current_doc = 0 | |
| 992 document.getElementById("outline").replaceChildren() | |
| 993 document.getElementById("pages").replaceChildren() | |
| 994 for (let page of page_list) | |
| 995 page_observer.unobserve(page.rootNode) | |
| 996 page_visible.length = 0 | |
| 997 } | |
| 998 | |
| 999 page_list = null | |
| 1000 } | |
| 1001 | |
| 1002 async function init_document(title) { | |
| 1003 document.title = await worker.documentTitle(current_doc) || title | |
| 1004 | |
| 1005 var page_count = await worker.countPages(current_doc) | |
| 1006 | |
| 1007 // Use second page as default page size (the cover page is often differently sized) | |
| 1008 var page_size = await worker.getPageSize(current_doc, page_count > 1 ? 1 : 0) | |
| 1009 | |
| 1010 page_list = [] | |
| 1011 for (let i = 0; i < page_count; ++i) | |
| 1012 page_list[i] = new PageView(current_doc, i, page_size, current_zoom) | |
| 1013 | |
| 1014 for (let page of page_list) { | |
| 1015 document.getElementById("pages").appendChild(page.rootNode) | |
| 1016 page_observer.observe(page.rootNode) | |
| 1017 } | |
| 1018 | |
| 1019 var outline = await worker.documentOutline(current_doc) | |
| 1020 if (outline) { | |
| 1021 build_outline(document.getElementById("outline"), outline) | |
| 1022 show_outline_panel() | |
| 1023 } else { | |
| 1024 hide_outline_panel() | |
| 1025 } | |
| 1026 | |
| 1027 clear_message() | |
| 1028 | |
| 1029 current_search_needle = "" | |
| 1030 last_search_page = -1 | |
| 1031 } | |
| 1032 | |
| 1033 async function open_document_from_buffer(buffer, magic, title) { | |
| 1034 current_doc = await worker.openDocumentFromBuffer(buffer, magic) | |
| 1035 await init_document(title) | |
| 1036 } | |
| 1037 | |
| 1038 async function open_document_from_blob(blob, magic, title) { | |
| 1039 current_doc = await worker.openDocumentFromBlob(blob, magic) | |
| 1040 await init_document(title) | |
| 1041 } | |
| 1042 | |
| 1043 async function open_document_from_file(file) { | |
| 1044 close_document() | |
| 1045 try { | |
| 1046 show_message("Opening " + file.name) | |
| 1047 history.replaceState(null, null, window.location.pathname) | |
| 1048 await open_document_from_blob(file, file.name, file.name) | |
| 1049 } catch (error) { | |
| 1050 show_message(error.name + ": " + error.message) | |
| 1051 console.error(error) | |
| 1052 } | |
| 1053 } | |
| 1054 | |
| 1055 async function open_document_from_url(path) { | |
| 1056 close_document() | |
| 1057 try { | |
| 1058 show_message("Loading " + path) | |
| 1059 let response = await fetch(path) | |
| 1060 if (!response.ok) | |
| 1061 throw new Error("Could not fetch document.") | |
| 1062 await open_document_from_buffer(await response.arrayBuffer(), path, path) | |
| 1063 } catch (error) { | |
| 1064 show_message(error.name + ": " + error.message) | |
| 1065 console.error(error) | |
| 1066 } | |
| 1067 } | |
| 1068 | |
| 1069 function main() { | |
| 1070 clear_message() | |
| 1071 let params = new URLSearchParams(window.location.search) | |
| 1072 if (params.has("file")) | |
| 1073 open_document_from_url(params.get("file")) | |
| 1074 } | |
| 1075 | |
| 1076 </script> |
