/** * AngularJS PDF viewer directive built on pdf.js. * * Replaces the old ng-pdfviewer directive, which drove pdf.js 0.8.505 (2013) * through the long-removed `PDFJS.getDocument()` global API. That build * emitted malformed CFF/OpenType data for subsetted fonts — the browser * rejected the font ("OTS parsing error: CFF: Failed to parse Top DICT * Data") and every glyph fell back to the wrong character, so PDFs rendered * as garbage text rather than failing outright (#771). * * Pages rasterise lazily as they scroll into view: a LaTeX/Quarto report can * run to dozens of pages, and drawing every canvas up front costs hundreds of * megabytes. Visibility is computed from element rects rather than an * IntersectionObserver — an observer reports nothing while the tab is * occluded, which would leave the reader looking at empty placeholders. */ angular.module("ngPDFViewer", []).directive("pdfviewer", [ "$window", function ($window) { // How far beyond the viewport to rasterise, so scrolling stays ahead of // the reader. const PRERENDER_MARGIN = 400; const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]; // Widest a page is drawn at 100%; beyond this a page is more comfortable // to read zoomed than stretched across a wide monitor. const FIT_MAX_WIDTH = 1000; return { restrict: "E", scope: { src: "@", }, link: function (scope, element) { const container = element[0]; container.classList.add("pdf-viewer"); let doc = null; let slots = []; let scheduled = false; let resizeTimer = null; let zoom = 1; let currentPage = 1; // Page the reader asked for, held against layout shifts until // anchorUntil passes. let anchorPage = 0; let anchorUntil = 0; let aspect = 612 / 792; // US Letter, replaced once page 1 is known // Bumped on every load so pages still resolving from a previous // document are dropped instead of appended to the new one. let generation = 0; // --------------------------------------------------------------- // Toolbar // --------------------------------------------------------------- const toolbar = document.createElement("div"); toolbar.className = "pdf-toolbar"; const pages = document.createElement("div"); pages.className = "pdf-pages"; // Focusable so the arrow/Page keys scroll the document. pages.tabIndex = 0; pages.setAttribute("aria-label", "PDF pages"); container.appendChild(toolbar); container.appendChild(pages); function button(icon, label, onClick) { const b = document.createElement("button"); b.type = "button"; b.className = "pdf-toolbar-btn"; b.title = label; b.setAttribute("aria-label", label); b.innerHTML = ''; b.addEventListener("click", onClick); return b; } const prevBtn = button("fa-chevron-up", "Previous page", function () { goToPage(currentPage - 1); }); const nextBtn = button("fa-chevron-down", "Next page", function () { goToPage(currentPage + 1); }); const pageInput = document.createElement("input"); pageInput.type = "text"; pageInput.className = "pdf-toolbar-page"; pageInput.setAttribute("aria-label", "Page number"); pageInput.title = "Page number — type a page and press Enter"; pageInput.addEventListener("keydown", function (e) { if (e.key === "Enter") { goToPage(parseInt(pageInput.value, 10)); pageInput.blur(); } }); pageInput.addEventListener("blur", function () { pageInput.value = String(currentPage); }); const pageCount = document.createElement("span"); pageCount.className = "pdf-toolbar-count"; const zoomOutBtn = button("fa-search-minus", "Zoom out", function () { setZoom(ZOOM_STEPS[Math.max(0, zoomIndex() - 1)]); }); const zoomInBtn = button("fa-search-plus", "Zoom in", function () { setZoom(ZOOM_STEPS[Math.min(ZOOM_STEPS.length - 1, zoomIndex() + 1)]); }); const zoomLabel = document.createElement("button"); zoomLabel.type = "button"; zoomLabel.className = "pdf-toolbar-btn pdf-toolbar-zoom"; zoomLabel.title = "Reset zoom to fit the width"; zoomLabel.setAttribute("aria-label", "Reset zoom to fit the width"); zoomLabel.addEventListener("click", function () { setZoom(1); }); toolbar.appendChild(prevBtn); toolbar.appendChild(nextBtn); const pageGroup = document.createElement("span"); pageGroup.className = "pdf-toolbar-group"; pageGroup.appendChild(pageInput); pageGroup.appendChild(pageCount); toolbar.appendChild(pageGroup); const zoomGroup = document.createElement("span"); zoomGroup.className = "pdf-toolbar-group pdf-toolbar-right"; zoomGroup.appendChild(zoomOutBtn); zoomGroup.appendChild(zoomLabel); zoomGroup.appendChild(zoomInBtn); toolbar.appendChild(zoomGroup); function zoomIndex() { let best = 0; for (let i = 0; i < ZOOM_STEPS.length; i++) { if (Math.abs(ZOOM_STEPS[i] - zoom) < Math.abs(ZOOM_STEPS[best] - zoom)) { best = i; } } return best; } function syncToolbar() { const total = doc ? doc.numPages : 0; toolbar.style.display = total ? "" : "none"; pageCount.textContent = "/ " + total; if (document.activeElement !== pageInput) { pageInput.value = String(currentPage); } prevBtn.disabled = currentPage <= 1; nextBtn.disabled = currentPage >= total; zoomLabel.textContent = Math.round(zoom * 100) + "%"; zoomOutBtn.disabled = zoomIndex() === 0; zoomInBtn.disabled = zoomIndex() === ZOOM_STEPS.length - 1; } function scrollToPage(target) { const slot = slots[target - 1]; if (!slot) return; pages.scrollTop += slot.getBoundingClientRect().top - pages.getBoundingClientRect().top; } function goToPage(num) { if (!doc || isNaN(num)) return; const target = Math.min(Math.max(1, num), doc.numPages); if (!slots[target - 1]) return; scrollToPage(target); currentPage = target; // Pages below are still placeholders whose estimated heights shift // as they rasterise, which would drag the reader off the page they // asked for. Hold the target until the layout around it settles. anchorPage = target; anchorUntil = Date.now() + 800; syncToolbar(); renderVisible(); } // The page filling most of the viewport is the one the reader is on. function updateCurrentPage() { if (!slots.length) return; // Don't fight a jump that is still settling. if (Date.now() < anchorUntil) return; const view = pages.getBoundingClientRect(); const middle = view.top + view.height / 2; let page = 1; for (let i = 0; i < slots.length; i++) { const rect = slots[i].getBoundingClientRect(); if (rect.top <= middle) page = i + 1; else break; } if (page !== currentPage) { currentPage = page; syncToolbar(); } } // --------------------------------------------------------------- // Rendering // --------------------------------------------------------------- function teardown() { generation++; if (resizeTimer) { clearTimeout(resizeTimer); resizeTimer = null; } if (doc) { doc.destroy(); doc = null; } slots = []; currentPage = 1; pages.innerHTML = ""; syncToolbar(); } function showError() { pages.innerHTML = ""; const msg = document.createElement("div"); msg.className = "pdf-viewer-error"; msg.textContent = "This PDF could not be displayed."; pages.appendChild(msg); } function pageWidth() { // clientWidth excludes the scrollbar, so the pages never overflow // horizontally at 100%. const available = Math.max(120, pages.clientWidth - 24); return Math.round(Math.min(available, FIT_MAX_WIDTH) * zoom); } // Match the canvas backing store to the device pixel ratio, else text // is visibly soft on HiDPI screens. function renderPage(page, slot) { const ratio = $window.devicePixelRatio || 1; const unscaled = page.getViewport({ scale: 1 }); const cssWidth = parseFloat(slot.style.width); if (!cssWidth) return; const viewport = page.getViewport({ scale: (cssWidth / unscaled.width) * ratio, }); const canvas = document.createElement("canvas"); canvas.width = Math.floor(viewport.width); canvas.height = Math.floor(viewport.height); canvas.style.width = cssWidth + "px"; canvas.style.height = Math.floor(viewport.height / ratio) + "px"; slot.innerHTML = ""; slot.appendChild(canvas); // The placeholder height was an estimate; the canvas is now the // authority on how tall this page is. slot.style.height = ""; return page.render({ canvasContext: canvas.getContext("2d"), viewport: viewport, }).promise; } function renderVisible() { if (!doc) return; const myGeneration = generation; // Compared in viewport coordinates so this doesn't depend on which // ancestor happens to be the slots' offsetParent. clientHeight, not // the rect's height, bounds the window: on a hard page load the // scroller can be measured before it is clipped, and trusting the // rect there would rasterise the whole document at once. const view = pages.getBoundingClientRect(); const top = view.top - PRERENDER_MARGIN; const bottom = view.top + pages.clientHeight + PRERENDER_MARGIN; slots.forEach(function (slot, i) { if (slot.dataset.state) return; const rect = slot.getBoundingClientRect(); if (rect.top > bottom || rect.bottom < top) return; slot.dataset.state = "rendering"; doc .getPage(i + 1) .then(function (page) { if (myGeneration !== generation) return; return renderPage(page, slot); }) .then(function () { if (myGeneration !== generation) return; slot.dataset.state = "done"; // A real page height replaces an estimate, moving everything // below it — re-pin the page the reader asked for. if (anchorPage && Date.now() < anchorUntil) { scrollToPage(anchorPage); } // The shift can also bring further pages into view. Called // directly rather than through schedule() so this doesn't // depend on a frame callback firing. renderVisible(); }) .catch(function () { // A single unrenderable page shouldn't blank the whole // document — leave the placeholder empty and move on. slot.dataset.state = "failed"; }); }); } function schedule() { if (scheduled) return; scheduled = true; $window.requestAnimationFrame(function () { scheduled = false; updateCurrentPage(); renderVisible(); }); } // Give layout a frame to settle before the first measurement; the // timeout is the fallback for occluded tabs, where frame callbacks // never run and the reader would otherwise see only placeholders. function renderVisibleSoon() { let ran = false; function once() { if (ran) return; ran = true; renderVisible(); } $window.requestAnimationFrame(once); $window.setTimeout(once, 50); } // Reset every page to a placeholder at the current width, then redraw // whatever is on screen. Used for zoom changes and window resizes, // both of which invalidate canvases rasterised at the old width. function relayout() { const width = pageWidth(); slots.forEach(function (slot) { delete slot.dataset.state; slot.innerHTML = ""; slot.style.width = width + "px"; slot.style.height = Math.round(width / aspect) + "px"; }); renderVisible(); } function setZoom(value) { if (!doc || value === zoom) return; const anchor = currentPage; zoom = value; syncToolbar(); relayout(); goToPage(anchor); } function onResize() { if (!doc) return; if (resizeTimer) clearTimeout(resizeTimer); resizeTimer = setTimeout(function () { resizeTimer = null; if (doc) relayout(); }, 250); } function load(url) { teardown(); if (!url) return; const myGeneration = generation; pdfjsLib .getDocument({ url: url, isEvalSupported: false, // Needed for PDFs that reference the 14 standard fonts without // embedding them, and for CJK encodings — without these the // text silently fails to draw. standardFontDataUrl: "/script/external/pdf-standard-fonts/", cMapUrl: "/script/external/pdf-cmaps/", cMapPacked: true, }) .promise.then(function (_doc) { if (myGeneration !== generation) { _doc.destroy(); return; } doc = _doc; // Page 1's shape sizes the placeholders. Without a realistic // estimate the scrollbar lies and every page counts as visible. return doc.getPage(1).then(function (first) { if (myGeneration !== generation) return; const v = first.getViewport({ scale: 1 }); if (v.width && v.height) aspect = v.width / v.height; const width = pageWidth(); for (let i = 1; i <= doc.numPages; i++) { const slot = document.createElement("div"); slot.className = "pdf-viewer-page"; slot.style.width = width + "px"; slot.style.height = Math.round(width / aspect) + "px"; pages.appendChild(slot); slots.push(slot); } syncToolbar(); renderVisibleSoon(); }); }) .catch(function () { if (myGeneration !== generation) return; showError(); }); } pages.addEventListener("scroll", schedule, { passive: true }); angular.element($window).on("resize", onResize); syncToolbar(); scope.$watch("src", load); scope.$on("$destroy", function () { pages.removeEventListener("scroll", schedule); angular.element($window).off("resize", onResize); teardown(); }); }, }; }, ]);