<!doctype html>
<html lang="zh-cn">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF 查看及转图片</title>
<script>
// Chrome 78 没有这三个方法,pdf.js 2.16 legacy 原版会调用(不转译,靠这里补)
if (!Array.prototype.at) {
Object.defineProperty(Array.prototype, "at", {
value: function (n) { n = Math.trunc(n) || 0; if (n < 0) n += this.length; if (n < 0 || n >= this.length) return undefined; return this[n]; },
writable: true, enumerable: false, configurable: true
});
}
if (!String.prototype.at) {
Object.defineProperty(String.prototype, "at", {
value: function (n) { n = Math.trunc(n) || 0; if (n < 0) n += this.length; if (n < 0 || n >= this.length) return undefined; return this[n]; },
writable: true, enumerable: false, configurable: true
});
}
if (!String.prototype.replaceAll) {
Object.defineProperty(String.prototype, "replaceAll", {
value: function (s, r) { return typeof s === "string" ? this.split(s).join(r) : this.replace(s, r); },
writable: true, enumerable: false, configurable: true
});
}
</script>
</head>
<body>
<header class="pdf-bar">
<input id="pdf-file" class="pdf-file" type="file" accept="application/pdf" />
<button id="pdf-demo" class="pdf-btn" type="button">Demo</button>
<span class="pdf-pop pdf-hide" id="pdf-pop">
<button id="pdf-export" class="pdf-btn pdf-btn-key" type="button" aria-haspopup="true" aria-expanded="false">导出图片</button>
<span class="pdf-menu" id="pdf-menu" hidden>
<button class="pdf-menu-item" type="button" data-action="page">当前页面</button>
<button class="pdf-menu-item" type="button" data-action="all">全部页面</button>
</span>
</span>
<span class="pdf-nav pdf-hide">
<button id="pdf-prev" class="pdf-btn pdf-btn-nav" type="button" title="上一页">«</button>
<button id="pdf-next" class="pdf-btn pdf-btn-nav" type="button" title="下一页">»</button>
<input id="pdf-number" class="pdf-number" type="number" value="1" min="1" max="9999" />
<button id="pdf-last" class="pdf-btn pdf-btn-nav" type="button">/ 0</button>
</span>
<select id="pdf-scale" class="pdf-select pdf-hide">
<option value="0.5">50%</option>
<option value="0.75">75%</option>
<option value="1" selected>100%</option>
<option value="2">200%</option>
<option value="3">300%</option>
<option value="auto">页宽</option>
</select>
<button id="pdf-rotate" class="pdf-btn pdf-hide" type="button">旋转</button>
</header>
<main id="pdf-view" class="pdf-view"></main>
<output id="pdf-tip" class="pdf-tip" hidden></output>
<script src="https://netnr.eu.org/pdfjs-dist@2.16.105/legacy/build/pdf.min.js"></script>
<script src="https://netnr.eu.org/jszip@3.10.1/dist/jszip.min.js"></script>
</body>
</html>
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
color: #212529;
background: #f5f6f8;
font: 14px/1.6 -apple-system, "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
}
[hidden] {
display: none !important;
}
.pdf-hide {
display: none !important;
}
/* 工具条:小屏换行,间距一律用 margin(Chrome 78 的 flex 不支持 gap) */
.pdf-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 12px 16px 4px 16px;
background: #fff;
border-bottom: 1px solid #e6e8ec;
}
.pdf-bar>* {
margin: 0 10px 10px 0;
}
.pdf-file {
max-width: 260px;
padding: 5px 8px;
border: 1px solid #ced4da;
border-radius: 4px;
background: #fff;
}
.pdf-btn {
padding: 6px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
background: #fff;
color: #212529;
cursor: pointer;
font: inherit;
}
.pdf-btn:hover:not(:disabled) {
background: #eef1f5;
}
.pdf-btn:disabled {
opacity: .55;
cursor: not-allowed;
}
.pdf-btn-key {
border-color: #4a6cf0;
background: #4a6cf0;
color: #fff;
}
.pdf-btn-key:hover:not(:disabled) {
background: #3c5ce0;
}
.pdf-btn-nav {
min-width: 36px;
}
/* 导航四个控件贴在一起,用负 margin 合并边框 */
.pdf-nav {
display: flex;
align-items: center;
}
.pdf-nav>*+* {
margin-left: -1px;
border-radius: 0;
}
.pdf-nav> :first-child {
border-radius: 4px 0 0 4px;
}
.pdf-nav> :last-child {
border-radius: 0 4px 4px 0;
}
.pdf-number {
width: 68px;
padding: 6px 8px;
border: 1px solid #ced4da;
background: #fff;
font: inherit;
text-align: center;
}
.pdf-select {
padding: 6px 8px;
border: 1px solid #ced4da;
border-radius: 4px;
background: #fff;
font: inherit;
}
/* 导出下拉 */
.pdf-pop {
position: relative;
}
.pdf-menu {
position: absolute;
top: 100%;
left: 0;
z-index: 20;
display: block;
min-width: 130px;
margin-top: 4px;
padding: 4px 0;
border: 1px solid #e6e8ec;
border-radius: 4px;
background: #fff;
box-shadow: 0 2px 8px 0 rgba(99, 99, 99, .2);
}
.pdf-menu-item {
display: block;
width: 100%;
padding: 6px 14px;
border: 0;
background: none;
color: #212529;
text-align: left;
cursor: pointer;
font: inherit;
}
.pdf-menu-item:hover {
background: #eef1f5;
}
/* 页面显示区 */
.pdf-view {
min-height: 240px;
padding: 20px 16px 60px 16px;
text-align: center;
}
.pdf-page {
max-width: 100%;
border: 1px solid #e0e2e6;
background: #fff;
box-shadow: 0 2px 8px 0 rgba(99, 99, 99, .2);
}
/* 逐页导出时先把画布挪出视野,而不是 visibility:hidden(那样还会占位) */
.pdf-exporting {
position: absolute;
left: -10000px;
top: 0;
}
/* 提示条 */
.pdf-tip {
position: fixed;
left: 50%;
bottom: 24px;
z-index: 30;
max-width: 80%;
padding: 10px 16px;
border-radius: 4px;
background: #343a40;
color: #fff;
text-align: center;
transform: translateX(-50%);
}
.pdf-tip-error {
background: #c0392b;
}
// 版本号要与 index.html 里那两个 script 标签一致(CDN 是 immutable 缓存,只能靠换版本号失效)
var PDFJS_VERSION = "2.16.105";
var PDFJS_WORKER = "https://netnr.eu.org/pdfjs-dist@" + PDFJS_VERSION + "/legacy/build/pdf.worker.min.js";
var DEMO_URL = "https://s1.netnr.eu.org/static/older/20190706_181724f50f.pdf";
var PDF_TO_CSS_UNITS = 96 / 72;
// pdf.js 自带的跨源 worker 包装只 importScripts、不补方法 ⇒ 自己包一层(worker 线程里同样缺 at/replaceAll)
var WORKER_POLYFILL = [
'if(!Array.prototype.at){Object.defineProperty(Array.prototype,"at",{value:function(n){n=Math.trunc(n)||0;if(n<0)n+=this.length;return n<0||n>=this.length?void 0:this[n]},writable:!0,enumerable:!1,configurable:!0})}',
'if(!String.prototype.at){Object.defineProperty(String.prototype,"at",{value:function(n){n=Math.trunc(n)||0;if(n<0)n+=this.length;return n<0||n>=this.length?void 0:this[n]},writable:!0,enumerable:!1,configurable:!0})}',
'if(!String.prototype.replaceAll){Object.defineProperty(String.prototype,"replaceAll",{value:function(s,r){return typeof s==="string"?this.split(s).join(r):this.replace(s,r)},writable:!0,enumerable:!1,configurable:!0})}'
].join(";");
var dom = {
file: document.getElementById("pdf-file"),
demo: document.getElementById("pdf-demo"),
pop: document.getElementById("pdf-pop"),
export: document.getElementById("pdf-export"),
menu: document.getElementById("pdf-menu"),
prev: document.getElementById("pdf-prev"),
next: document.getElementById("pdf-next"),
number: document.getElementById("pdf-number"),
last: document.getElementById("pdf-last"),
scale: document.getElementById("pdf-scale"),
rotate: document.getElementById("pdf-rotate"),
view: document.getElementById("pdf-view"),
tip: document.getElementById("pdf-tip")
};
var state = { pdf: null, rotate: 0, zip: null, workerUrl: "", chain: Promise.resolve() };
var tipTimer = 0;
function showTip(text, isError) {
dom.tip.textContent = text;
dom.tip.className = isError ? "pdf-tip pdf-tip-error" : "pdf-tip";
dom.tip.hidden = false;
clearTimeout(tipTimer);
tipTimer = setTimeout(function () { dom.tip.hidden = true; }, isError ? 6000 : 2500);
}
function setBusy(on) {
var list = [dom.file, dom.demo, dom.export, dom.prev, dom.next, dom.last, dom.rotate, dom.number, dom.scale];
for (var i = 0; i < list.length; i++) {
list[i].disabled = on;
}
}
function workerSource() {
if (!state.workerUrl) {
var code = WORKER_POLYFILL + ';importScripts("' + PDFJS_WORKER + '");';
state.workerUrl = URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
}
return state.workerUrl;
}
function showTools() {
var hidden = document.getElementsByClassName("pdf-hide");
while (hidden.length) {
hidden[0].classList.remove("pdf-hide");
}
}
function currentIndex() {
var index = parseInt(dom.number.value, 10);
if (isNaN(index) || index < 1) {
return 1;
}
if (state.pdf && index > state.pdf.numPages) {
return state.pdf.numPages;
}
return index;
}
// 页面自带的 /Rotate 与手动旋转叠加
function rotationOf(page) {
return (page.rotate + state.rotate) % 360;
}
// 「页宽」按旋转后的框宽铺满显示区
function scaleValue(page) {
if (dom.scale.value !== "auto") {
return parseFloat(dom.scale.value);
}
var box = page.getViewport({ scale: 1, rotation: rotationOf(page) });
var avail = dom.view.clientWidth - 34;
if (!(avail > 0)) {
avail = document.documentElement.clientWidth - 34;
}
return avail / box.width / PDF_TO_CSS_UNITS;
}
function paintPage(doc, index) {
return doc.getPage(index).then(function (page) {
dom.number.value = index;
var viewport = page.getViewport({ scale: scaleValue(page) * PDF_TO_CSS_UNITS, rotation: rotationOf(page) });
var canvas = document.createElement("canvas");
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
canvas.className = "pdf-page";
dom.view.textContent = "";
dom.view.appendChild(canvas);
return page.render({ canvasContext: canvas.getContext("2d"), viewport: viewport }).promise;
});
}
// 串行:连续翻页/逐页导出时不让两个 render 叠在一条链上;文档句柄在这一刻就定下来
function renderPage(index) {
var doc = state.pdf;
var page = parseInt(index, 10);
if (isNaN(page) || page < 1) {
page = 1;
}
if (!doc || page > doc.numPages) {
return state.chain;
}
state.chain = state.chain.then(function () {
return paintPage(doc, page);
}).catch(function (ex) {
showTip("渲染失败:" + ex.message, true);
});
return state.chain;
}
function loadDocument(source) {
if (typeof pdfjsLib === "undefined") {
showTip("PDF 组件没加载成功,检查 CDN 是否可达", true);
return Promise.resolve();
}
setBusy(true);
pdfjsLib.GlobalWorkerOptions.workerSrc = workerSource();
var previous = state.pdf;
var fresh = false;
state.chain = state.chain.then(function () {
return pdfjsLib.getDocument(source).promise;
}).then(function (pdf) {
state.pdf = pdf;
fresh = true;
state.rotate = 0;
dom.number.max = pdf.numPages;
dom.last.textContent = "/ " + pdf.numPages;
showTools();
if (previous) {
// 旧文档到新文档到手之后才销毁;销毁不进渲染链,它会等自己那批渲染收尾
previous.destroy().catch(function () { });
}
}).catch(function (ex) {
showTip("打开失败:" + ex.message, true);
}).then(function () {
setBusy(false);
});
// 首页必须在这一条链结算之后再排队画:写在链内会变成 renderPage 等的就是包含它自己的那条链,永不结算
state.chain.then(function () {
if (fresh) {
renderPage(1);
}
});
return state.chain;
}
function saveCanvas(canvas, name) {
var link = document.createElement("a");
link.href = canvas.toDataURL("image/png");
link.download = name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function currentPageCanvas() {
return dom.view.querySelector("canvas");
}
function exportPage() {
var canvas = currentPageCanvas();
if (!canvas) {
showTip("没有可导出的页面", true);
return;
}
saveCanvas(canvas, "page-" + dom.number.value + ".png");
}
function exportAll(index) {
if (!state.pdf) {
showTip("请先选择 PDF 文件", true);
return Promise.resolve();
}
if (state.pdf.numPages === 1) {
exportPage();
return Promise.resolve();
}
if (typeof JSZip === "undefined") {
showTip("压缩组件没加载成功,检查 CDN 是否可达", true);
return Promise.resolve();
}
if (index === 1) {
setBusy(true);
dom.view.classList.add("pdf-exporting");
state.zip = new JSZip();
}
return renderPage(index).then(function () {
state.zip.file("page-" + index + ".png", currentPageCanvas().toDataURL("image/png").split(",")[1], { base64: true });
if (index < state.pdf.numPages) {
return exportAll(index + 1);
}
return state.zip.generateAsync({ type: "blob" }).then(function (content) {
dom.view.classList.remove("pdf-exporting");
setBusy(false);
var link = document.createElement("a");
link.href = URL.createObjectURL(content);
link.download = "pages-" + state.pdf.numPages + ".zip";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(function () { URL.revokeObjectURL(link.href); }, 60000);
});
});
}
function openMenu(on) {
dom.menu.hidden = !on;
dom.export.setAttribute("aria-expanded", on ? "true" : "false");
}
dom.file.addEventListener("change", function () {
// this.files 是实时列表,先取住句柄再清空输入框(先清空会让下面一个文件都读不到)
var picked = null, skipped = 0, list = this.files;
for (var i = 0; i < list.length; i++) {
if (list[i].type === "application/pdf") {
picked = list[i];
break;
}
skipped++;
}
this.value = "";
if (!picked) {
if (skipped) {
showTip("请选择 PDF 文件", true);
}
return;
}
picked.arrayBuffer().then(function (buf) {
loadDocument({ data: new Uint8Array(buf) });
}, function (ex) {
showTip("读取文件失败:" + ex.message, true);
});
});
dom.demo.addEventListener("click", function () {
loadDocument(DEMO_URL);
});
dom.prev.addEventListener("click", function () {
renderPage(currentIndex() - 1);
});
dom.next.addEventListener("click", function () {
renderPage(currentIndex() + 1);
});
dom.last.addEventListener("click", function () {
renderPage(state.pdf ? state.pdf.numPages : 1);
});
dom.number.addEventListener("input", function () {
if (!isNaN(parseInt(this.value, 10))) {
renderPage(parseInt(this.value, 10));
}
});
dom.number.addEventListener("blur", function () {
dom.number.value = currentIndex();
});
dom.scale.addEventListener("change", function () {
renderPage(currentIndex());
});
dom.rotate.addEventListener("click", function () {
state.rotate = (state.rotate + 90) % 360;
renderPage(currentIndex());
});
dom.export.addEventListener("click", function () {
openMenu(dom.menu.hidden);
});
dom.menu.addEventListener("click", function (event) {
var action = event.target.dataset.action;
openMenu(false);
if (action === "page") {
exportPage();
} else if (action === "all") {
exportAll(1);
}
});
document.addEventListener("click", function (event) {
if (!dom.pop.contains(event.target)) {
openMenu(false);
}
});