mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-08-15 00:10:44 +02:00
417 lines
16 KiB
JavaScript
417 lines
16 KiB
JavaScript
/**
|
|
* 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 = '<i class="fas ' + icon + '"></i>';
|
|
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();
|
|
});
|
|
},
|
|
};
|
|
},
|
|
]);
|