Download unscrambled pages as ZIP or PDF from MediaDo bsreader (parallel fetch)
// ==UserScript==
// @name BSReader Book Downloader
// @namespace https://github.com/andylilfs0217/libby-media-do-downloader
// @version 1.3.4
// @description Download unscrambled pages as ZIP or PDF from MediaDo bsreader (parallel fetch)
// @author Andy Li
// @license MIT
// @homepageURL https://github.com/andylilfs0217/libby-media-do-downloader#readme
// @supportURL https://github.com/andylilfs0217/libby-media-do-downloader/issues
// @contributionURL https://github.com/sponsors/andylilfs0217
// @match https://api.distribution.mediadotech.com/viewers/bsreader/v2/index.html*
// @icon https://www.google.com/s2/favicons?sz=64&domain=mediadotech.com
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js
// @grant none
// @connect cdnjs.cloudflare.com
// @connect cdn.jsdelivr.net
// @run-at document-idle
// ==/UserScript==
//
// ==OpenUserJS==
// @author andyli0217
// ==/OpenUserJS==
(function () {
'use strict';
const CONCURRENCY = 6;
/** Tip jar (Ko-fi, GitHub Sponsors, PayPal, etc.). Set to '' to hide the link. */
const DONATION_URL = 'https://github.com/sponsors/andylilfs0217';
/**
* Official Clip Studio / MediaDo viewer math (csr-web-core): each cell’s tile side length is
* floored to a multiple of 8px; the remainder becomes unscrambled right/bottom strips outside the grid.
* @returns {{ tw: number, th: number, padRight: number, padBottom: number }}
*/
function computeOfficialTileLayout(w, h, gridW, gridH) {
const tw = 8 * Math.floor(Math.floor(w / gridW) / 8);
const th = 8 * Math.floor(Math.floor(h / gridH) / 8);
const padRight = w - gridW * tw;
const padBottom = h - gridH * th;
return { tw, th, padRight, padBottom };
}
/** Console prefix for debugging (DevTools → Console). */
const LOG = '[BSReader DL]';
/**
* @param {string} phase
* @param {unknown} err
* @param {Record<string, unknown>} [meta]
*/
function logError(phase, err, meta) {
console.error(LOG, phase, err);
if (meta && Object.keys(meta).length) console.error(LOG, 'context:', meta);
if (err instanceof Error && err.stack) console.error(LOG, 'stack:', err.stack);
}
function logJsPDFProbe(reason) {
try {
const w = window;
const pkg = w.jspdf;
console.error(LOG, 'jsPDF probe (' + reason + '):', {
hasJspdf: !!pkg,
jspdfType: pkg == null ? null : typeof pkg,
jspdfKeys:
pkg && typeof pkg === 'object' ? Object.keys(pkg).slice(0, 24) : null,
hasGlobalJsPDF: typeof w.jsPDF,
hasJSZip: typeof w.JSZip,
hasSaveAs: typeof saveAs,
});
} catch (probeErr) {
console.error(LOG, 'jsPDF probe threw', probeErr);
}
}
/**
* jsPDF UMD sets `global.jspdf = { jsPDF, default, ... }`. Tampermonkey may expose
* that on the sandbox `window`, on `unsafeWindow` (page), or only after a DOM script tag load.
* @returns {function|null}
*/
function resolveJsPDFConstructor() {
/** @type {Set<Window & typeof globalThis>} */
const roots = new Set();
try {
if (typeof unsafeWindow !== 'undefined') roots.add(unsafeWindow);
} catch (_) {}
roots.add(window);
if (typeof globalThis !== 'undefined') roots.add(globalThis);
try {
if (typeof window !== 'undefined' && window.wrappedJSObject) {
roots.add(window.wrappedJSObject);
}
} catch (_) {}
for (const root of roots) {
if (!root) continue;
try {
const pkg = root.jspdf;
if (pkg && typeof pkg.jsPDF === 'function') return pkg.jsPDF;
if (pkg && typeof pkg.default === 'function') return pkg.default;
if (typeof root.jsPDF === 'function') return root.jsPDF;
} catch (_) {}
}
return null;
}
/**
* Fallback if @require did not attach to this realm (blocked CDN, load order, etc.).
* @param {string} url
* @returns {Promise<void>}
*/
function appendJsPDFScript(url) {
return new Promise((resolve, reject) => {
if ([...document.scripts].some((s) => s.src === url)) {
resolve();
return;
}
const s = document.createElement('script');
s.src = url;
s.async = true;
s.dataset.bsreaderJspdfSrc = url;
s.onload = () => resolve();
s.onerror = () => reject(new Error('Failed to load: ' + url));
(document.head || document.documentElement).appendChild(s);
});
}
/** @returns {Promise<function>} */
async function ensureJsPDFConstructor() {
let Ctor = resolveJsPDFConstructor();
if (Ctor) return Ctor;
const urls = [
'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js',
'https://cdn.jsdelivr.net/npm/[email protected]/dist/jspdf.umd.min.js',
];
for (const url of urls) {
try {
await appendJsPDFScript(url);
Ctor = resolveJsPDFConstructor();
if (Ctor) return Ctor;
} catch (e) {
logError('jsPDF script load attempt failed', e, { url });
}
}
logJsPDFProbe('ensureJsPDFConstructor: all attempts failed');
throw new Error(
'jsPDF could not be loaded. Check Tampermonkey script permissions, disable ad blockers for cdnjs/jsdelivr, or try updating Tampermonkey.'
);
}
/** @type {typeof JSZip} */
const ZipCtor = typeof JSZip !== 'undefined' ? JSZip : window.JSZip;
/**
* @template T
* @param {number} count
* @param {Array<() => Promise<T>>} tasks
* @returns {Promise<T[]>}
*/
async function withConcurrency(count, tasks) {
if (tasks.length === 0) return /** @type {T[]} */ ([]);
const results = /** @type {T[]} */ (new Array(tasks.length));
let next = 0;
async function worker() {
while (next < tasks.length) {
const i = next++;
results[i] = await tasks[i]();
}
}
const n = Math.min(count, tasks.length);
await Promise.all(Array.from({ length: n }, () => worker()));
return results;
}
/** @param {string} cgi */
function buildApiUrl(cgi, mode, file, param, time) {
const u = new URL(cgi);
u.searchParams.set('mode', String(mode));
u.searchParams.set('file', file);
u.searchParams.set('reqtype', '0');
u.searchParams.set('vm', '4');
u.searchParams.set('param', param);
u.searchParams.set('time', String(time));
return u.toString();
}
function parseFaceXml(text) {
const doc = new DOMParser().parseFromString(text, 'text/xml');
const err = doc.querySelector('parsererror');
if (err) throw new Error('face.xml parse error');
const totalEl = doc.querySelector('TotalPage');
const wEl = doc.querySelector('Scramble > Width');
const hEl = doc.querySelector('Scramble > Height');
const totalPage = totalEl ? parseInt(totalEl.textContent.trim(), 10) : 0;
const gridW = wEl ? parseInt(wEl.textContent.trim(), 10) : 4;
const gridH = hEl ? parseInt(hEl.textContent.trim(), 10) : 4;
if (!Number.isFinite(totalPage) || totalPage < 1) {
throw new Error('Invalid TotalPage in face.xml');
}
return { totalPage, gridW, gridH };
}
function parsePageXml(text) {
const doc = new DOMParser().parseFromString(text, 'text/xml');
const err = doc.querySelector('parsererror');
if (err) throw new Error('page XML parse error');
const scrambleEl = doc.querySelector('Page > Scramble') || doc.querySelector('Scramble');
let scrambleOrder = [];
if (scrambleEl && scrambleEl.textContent) {
scrambleOrder = scrambleEl.textContent
.split(',')
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n));
}
const parts = [];
const partEls = doc.querySelectorAll('Part Kind');
partEls.forEach((kind) => {
const no = kind.getAttribute('No') || '0000';
const scrambleAttr = kind.getAttribute('scramble');
const needUnscramble = scrambleAttr !== '0';
parts.push({ no, needUnscramble });
});
if (parts.length === 0) {
parts.push({ no: '0000', needUnscramble: scrambleOrder.length > 0 });
}
return { scrambleOrder, parts };
}
/**
* Unscramble tile grid; optional right/bottom strips are copied as-is (not part of scramble).
* @param {HTMLImageElement | HTMLCanvasElement | ImageBitmap} img
* @param {number[]} scrambleOrder
* @param {number} gridW
* @param {number} gridH
*/
function unscrambleToCanvas(img, scrambleOrder, gridW, gridH) {
const w = img.width;
const h = img.height;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('2d context unavailable');
const cells = gridW * gridH;
if (scrambleOrder.length !== cells) {
ctx.drawImage(img, 0, 0);
return canvas;
}
const { tw, th, padRight, padBottom } = computeOfficialTileLayout(
w,
h,
gridW,
gridH
);
const useOfficial = tw > 0 && th > 0;
if (useOfficial) {
const contentW = gridW * tw;
const contentH = gridH * th;
for (let i = 0; i < cells; i++) {
const srcIdx = scrambleOrder[i];
const srcCol = srcIdx % gridW;
const srcRow = Math.floor(srcIdx / gridW);
const dstCol = i % gridW;
const dstRow = Math.floor(i / gridW);
const sx = srcCol * tw;
const sy = srcRow * th;
const dx = dstCol * tw;
const dy = dstRow * th;
const sw = Math.min(tw, contentW - sx);
const sh = Math.min(th, contentH - sy);
const dw = Math.min(tw, contentW - dx);
const dh = Math.min(th, contentH - dy);
const drawW = Math.min(sw, dw);
const drawH = Math.min(sh, dh);
ctx.drawImage(img, sx, sy, drawW, drawH, dx, dy, drawW, drawH);
}
if (padRight > 0) {
ctx.drawImage(
img,
contentW,
0,
padRight,
h,
contentW,
0,
padRight,
h
);
}
if (padBottom > 0) {
ctx.drawImage(
img,
0,
contentH,
w,
padBottom,
0,
contentH,
w,
padBottom
);
}
return canvas;
}
console.warn(
LOG,
'Official tile layout unavailable (tw/th); using full-image tile split. Image:',
w,
'x',
h,
'grid:',
gridW,
'x',
gridH
);
const cellW = Math.ceil(w / gridW);
const cellH = Math.ceil(h / gridH);
for (let i = 0; i < cells; i++) {
const srcIdx = scrambleOrder[i];
const srcCol = srcIdx % gridW;
const srcRow = Math.floor(srcIdx / gridW);
const dstCol = i % gridW;
const dstRow = Math.floor(i / gridW);
const sx = srcCol * cellW;
const sy = srcRow * cellH;
const dx = dstCol * cellW;
const dy = dstRow * cellH;
const sw = Math.min(cellW, w - sx);
const sh = Math.min(cellH, h - sy);
const dw = Math.min(cellW, w - dx);
const dh = Math.min(cellH, h - dy);
const drawW = Math.min(sw, dw);
const drawH = Math.min(sh, dh);
ctx.drawImage(img, sx, sy, drawW, drawH, dx, dy, drawW, drawH);
}
return canvas;
}
function loadImageFromBlob(blob) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
const err = new Error('Image load failed (decode or not an image)');
logError('loadImageFromBlob', err, { blobSize: blob && blob.size });
reject(err);
};
img.src = url;
});
}
async function fetchBinary(cgi, param, mode, file, time) {
const url = buildApiUrl(cgi, mode, file, param, time);
const res = await fetch(url, {
credentials: 'include',
headers: {
Accept: mode === 3 ? 'image/*,*/*' : '*/*',
Referer: location.href,
},
});
if (!res.ok) {
const err = new Error(`HTTP ${res.status} for ${file}`);
logError('fetchBinary', err, { mode, file, status: res.status, statusText: res.statusText });
throw err;
}
return res.blob();
}
async function fetchText(cgi, param, mode, file, time) {
const url = buildApiUrl(cgi, mode, file, param, time);
const res = await fetch(url, {
credentials: 'include',
headers: {
Accept: '*/*',
Referer: location.href,
},
});
if (!res.ok) {
const err = new Error(`HTTP ${res.status} for ${file}`);
logError('fetchText', err, { mode, file, status: res.status, statusText: res.statusText });
throw err;
}
return res.text();
}
/**
* @param {number} page
* @param {string} cgi
* @param {string} param
* @param {number} timeBase
* @param {number} gridW
* @param {number} gridH
* @returns {Promise<{ pageIndex: number, parts: Array<{no:string,needUnscramble:boolean}>, canvases: HTMLCanvasElement[] }>}
*/
async function downloadAndRenderPage(page, cgi, param, timeBase, gridW, gridH) {
const pageFile = `${String(page).padStart(4, '0')}.xml`;
const pageXml = await fetchText(cgi, param, 8, pageFile, timeBase + page);
const { scrambleOrder, parts } = parsePageXml(pageXml);
const canvases = [];
for (let pi = 0; pi < parts.length; pi++) {
const { no, needUnscramble } = parts[pi];
const binName = `${String(page).padStart(4, '0')}_${no}.bin`;
const blob = await fetchBinary(cgi, param, 3, binName, timeBase + page * 1000 + pi);
const img = await loadImageFromBlob(blob);
let outCanvas;
if (needUnscramble && scrambleOrder.length === gridW * gridH) {
outCanvas = unscrambleToCanvas(img, scrambleOrder, gridW, gridH);
} else {
outCanvas = document.createElement('canvas');
outCanvas.width = img.width;
outCanvas.height = img.height;
const ctx = outCanvas.getContext('2d');
if (ctx) ctx.drawImage(img, 0, 0);
}
canvases.push(outCanvas);
}
return { pageIndex: page, parts, canvases };
}
/**
* @param {{ pageIndex: number, parts: Array<{ no: string }> }} pageResult
* @param {number} partIndex
*/
function zipNameForPage(pageResult, partIndex) {
const { pageIndex, parts } = pageResult;
const no = parts[partIndex].no;
return parts.length === 1
? `page_${String(pageIndex).padStart(4, '0')}.png`
: `page_${String(pageIndex).padStart(4, '0')}_part_${no}.png`;
}
/**
* @param {Awaited<ReturnType<typeof downloadAndRenderPage>>[]} pageResults
*/
async function buildPdfFromCanvases(pageResults) {
const flat = [];
pageResults.forEach((r) => {
r.canvases.forEach((c) => flat.push(c));
});
if (flat.length === 0) throw new Error('No pages to export');
const jsPDF = await ensureJsPDFConstructor();
const first = flat[0];
const pdf = new jsPDF({
unit: 'px',
format: [first.width, first.height],
orientation: first.width > first.height ? 'l' : 'p',
compress: true,
});
pdf.addImage(
first.toDataURL('image/jpeg', 0.88),
'JPEG',
0,
0,
first.width,
first.height
);
for (let i = 1; i < flat.length; i++) {
const c = flat[i];
pdf.addPage([c.width, c.height], c.width > c.height ? 'l' : 'p');
pdf.addImage(
c.toDataURL('image/jpeg', 0.88),
'JPEG',
0,
0,
c.width,
c.height
);
}
return pdf.output('blob');
}
function injectUi() {
if (document.getElementById('bsreader-book-dl-root')) return;
const root = document.createElement('div');
root.id = 'bsreader-book-dl-root';
root.style.cssText = [
'position:fixed',
'top:10px',
'right:10px',
'z-index:2147483647',
'font-family:system-ui,-apple-system,sans-serif',
'font-size:12px',
'line-height:1.35',
].join(';');
const panel = document.createElement('div');
panel.style.cssText = [
'width:min(220px,calc(100vw - 20px))',
'border-radius:10px',
'border:1px solid rgba(148,163,184,.28)',
'background:rgba(15,23,42,.88)',
'backdrop-filter:saturate(1.2) blur(10px)',
'-webkit-backdrop-filter:saturate(1.2) blur(10px)',
'box-shadow:0 4px 24px rgba(0,0,0,.22),0 0 0 1px rgba(255,255,255,.04) inset',
'color:#e2e8f0',
'overflow:hidden',
].join(';');
const header = document.createElement('div');
header.style.cssText = [
'display:flex',
'align-items:center',
'justify-content:space-between',
'gap:8px',
'padding:6px 8px 6px 10px',
'border-bottom:1px solid rgba(148,163,184,.15)',
'background:rgba(0,0,0,.12)',
].join(';');
const title = document.createElement('span');
title.textContent = 'Export';
title.style.cssText =
'font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:#94a3b8';
const btnDismissPanel = document.createElement('button');
btnDismissPanel.type = 'button';
btnDismissPanel.setAttribute('aria-label', 'Hide download panel');
btnDismissPanel.textContent = '×';
btnDismissPanel.style.cssText = [
'flex-shrink:0',
'width:22px',
'height:22px',
'padding:0',
'margin:0',
'border:none',
'border-radius:6px',
'background:transparent',
'color:#94a3b8',
'font-size:18px',
'line-height:1',
'cursor:pointer',
'display:flex',
'align-items:center',
'justify-content:center',
].join(';');
btnDismissPanel.addEventListener('mouseenter', () => {
btnDismissPanel.style.background = 'rgba(255,255,255,.08)';
btnDismissPanel.style.color = '#f1f5f9';
});
btnDismissPanel.addEventListener('mouseleave', () => {
btnDismissPanel.style.background = 'transparent';
btnDismissPanel.style.color = '#94a3b8';
});
const actions = document.createElement('div');
actions.style.cssText = 'display:flex;gap:6px;padding:8px 8px 8px';
const btnBase = [
'flex:1',
'min-width:0',
'padding:6px 8px',
'border-radius:7px',
'border:1px solid rgba(255,255,255,.12)',
'font-size:11px',
'font-weight:600',
'letter-spacing:.02em',
'cursor:pointer',
'color:#f8fafc',
'transition:filter .15s',
].join(';');
const btnZip = document.createElement('button');
btnZip.type = 'button';
btnZip.title = 'Download all pages as PNG in a ZIP';
btnZip.textContent = 'ZIP';
btnZip.style.cssText =
btnBase +
';background:linear-gradient(180deg,rgba(59,130,246,.35),rgba(30,58,138,.5))';
const btnPdf = document.createElement('button');
btnPdf.type = 'button';
btnPdf.title = 'Download all pages as one PDF';
btnPdf.textContent = 'PDF';
btnPdf.style.cssText =
btnBase +
';background:linear-gradient(180deg,rgba(244,63,94,.28),rgba(136,19,55,.45))';
const statusWrap = document.createElement('div');
statusWrap.style.cssText = [
'display:none',
'align-items:flex-start',
'gap:4px',
'padding:0 8px 8px',
'border-top:1px solid rgba(148,163,184,.1)',
].join(';');
const status = document.createElement('div');
status.style.cssText = [
'flex:1',
'min-width:0',
'font-size:11px',
'line-height:1.4',
'color:#cbd5e1',
'word-break:break-word',
].join(';');
const btnDismissStatus = document.createElement('button');
btnDismissStatus.type = 'button';
btnDismissStatus.setAttribute('aria-label', 'Dismiss message');
btnDismissStatus.textContent = '×';
btnDismissStatus.style.cssText = [
'flex-shrink:0',
'width:18px',
'height:18px',
'padding:0',
'border:none',
'border-radius:4px',
'background:transparent',
'color:#64748b',
'font-size:14px',
'line-height:1',
'cursor:pointer',
'margin-top:-1px',
].join(';');
btnDismissStatus.addEventListener('click', () => {
statusWrap.style.display = 'none';
});
btnDismissStatus.addEventListener('mouseenter', () => {
btnDismissStatus.style.background = 'rgba(255,255,255,.06)';
btnDismissStatus.style.color = '#94a3b8';
});
btnDismissStatus.addEventListener('mouseleave', () => {
btnDismissStatus.style.background = 'transparent';
btnDismissStatus.style.color = '#64748b';
});
const restore = document.createElement('button');
restore.type = 'button';
restore.setAttribute('aria-label', 'Show download panel');
restore.textContent = 'Export';
restore.style.cssText = [
'display:none',
'position:fixed',
'top:10px',
'right:10px',
'z-index:2147483646',
'padding:5px 10px',
'border-radius:999px',
'border:1px solid rgba(148,163,184,.35)',
'background:rgba(15,23,42,.9)',
'backdrop-filter:blur(8px)',
'color:#e2e8f0',
'font-family:inherit',
'font-size:11px',
'font-weight:600',
'letter-spacing:.03em',
'cursor:pointer',
'box-shadow:0 2px 12px rgba(0,0,0,.2)',
].join(';');
header.appendChild(title);
header.appendChild(btnDismissPanel);
actions.appendChild(btnZip);
actions.appendChild(btnPdf);
statusWrap.appendChild(status);
statusWrap.appendChild(btnDismissStatus);
panel.appendChild(header);
panel.appendChild(actions);
if (DONATION_URL) {
const donateRow = document.createElement('div');
donateRow.style.cssText = 'padding:0 8px 6px;text-align:center';
const donateLink = document.createElement('a');
donateLink.href = DONATION_URL;
donateLink.target = '_blank';
donateLink.rel = 'noopener noreferrer';
donateLink.textContent = 'Sponsor on GitHub';
donateLink.title = 'Optional: support maintenance via GitHub Sponsors (opens in a new tab)';
donateLink.style.cssText = [
'font-size:10px',
'font-weight:500',
'color:#64748b',
'text-decoration:none',
'border-bottom:1px solid transparent',
].join(';');
donateLink.addEventListener('mouseenter', () => {
donateLink.style.color = '#a5b4fc';
donateLink.style.borderBottomColor = 'rgba(165,180,252,.45)';
});
donateLink.addEventListener('mouseleave', () => {
donateLink.style.color = '#64748b';
donateLink.style.borderBottomColor = 'transparent';
});
donateRow.appendChild(donateLink);
panel.appendChild(donateRow);
}
panel.appendChild(statusWrap);
root.appendChild(panel);
btnDismissPanel.addEventListener('click', () => {
root.style.display = 'none';
restore.style.display = 'block';
});
restore.addEventListener('click', () => {
root.style.display = '';
restore.style.display = 'none';
});
document.body.appendChild(root);
document.body.appendChild(restore);
function showStatus(msg) {
status.textContent = msg;
statusWrap.style.display = 'flex';
}
/**
* @param {'zip' | 'pdf'} format
*/
async function runDownload(format) {
const params = new URLSearchParams(location.search);
const cgi = params.get('cgi');
const param = params.get('param');
if (!cgi || !param) {
logError('missing URL params', new Error('cgi or param absent'), {
hasCgi: !!cgi,
hasParam: !!param,
href: location.href.slice(0, 200),
});
showStatus('Missing cgi or param in page URL.');
return;
}
btnZip.disabled = true;
btnPdf.disabled = true;
showStatus('Starting…');
const timeBase = Date.now();
let doneCount = 0;
try {
const faceText = await fetchText(cgi, param, 7, 'face.xml', timeBase);
const { totalPage, gridW, gridH } = parseFaceXml(faceText);
const tasks = [];
for (let page = 0; page < totalPage; page++) {
const p = page;
tasks.push(async () => {
try {
const result = await downloadAndRenderPage(
p,
cgi,
param,
timeBase,
gridW,
gridH
);
doneCount += 1;
status.textContent = `Downloaded ${doneCount} / ${totalPage} pages…`;
return result;
} catch (err) {
logError('page task failed', err, {
pageIndex: p,
pageXml: `${String(p).padStart(4, '0')}.xml`,
});
throw err;
}
});
}
showStatus(`Fetching pages (up to ${CONCURRENCY} at once)…`);
const pageResults = await withConcurrency(CONCURRENCY, tasks);
if (format === 'zip') {
showStatus('Building ZIP…');
const zip = new ZipCtor();
const folder = zip.folder('pages');
for (const pr of pageResults) {
for (let pi = 0; pi < pr.canvases.length; pi++) {
const canvas = pr.canvases[pi];
const pngBlob = await new Promise((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('toBlob failed'))),
'image/png'
);
});
folder.file(zipNameForPage(pr, pi), pngBlob);
}
}
const zipBlob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
});
const safeName = `bsreader_book_${new Date().toISOString().slice(0, 10)}.zip`;
saveAs(zipBlob, safeName);
showStatus(`Done: ${totalPage} page(s) → ${safeName}`);
} else {
showStatus('Loading PDF library…');
const pdfBlob = await buildPdfFromCanvases(pageResults);
const safeName = `bsreader_book_${new Date().toISOString().slice(0, 10)}.pdf`;
saveAs(pdfBlob, safeName);
showStatus(`Done: ${totalPage} page(s) → ${safeName}`);
}
} catch (e) {
logError(`runDownload (${format})`, e, {
format,
timeBase,
href: location.href.slice(0, 160),
});
showStatus(`Error: ${e && e.message ? e.message : String(e)}`);
} finally {
btnZip.disabled = false;
btnPdf.disabled = false;
}
}
btnZip.addEventListener('click', () => runDownload('zip'));
btnPdf.addEventListener('click', () => runDownload('pdf'));
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectUi);
} else {
injectUi();
}
})();