Download iSolution ebooks as searchable PDFs with text overlay and disable image blur
// ==UserScript==
// @name iSolution PDF Downloader
// @namespace https://isolution.oupchina.com.hk/
// @version 3.12.2
// @author Devcme
// @description Download iSolution ebooks as searchable PDFs with text overlay and disable image blur
// @match https://isolution.oupchina.com.hk/*
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const WAIT = ms => new Promise(r => setTimeout(r, ms));
const EXPORT_CANCELLED = '__EXPORT_CANCELLED__';
const exportState = {
running: false,
cancelled: false,
controller: null,
};
let blurAutoObserver = null;
let blurAutoDoc = null;
const lang = (navigator.language || 'en').toLowerCase();
const isZH = lang.startsWith('zh');
const isCN = isZH && (lang.includes('cn') || lang.includes('hans'));
const i18n = {
exportPDF: isCN ? '导出 PDF' : isZH ? '匯出 PDF' : 'Export PDF',
toggleBlur: isCN ? '切换模糊去除' : isZH ? '切換模糊去除' : 'Toggle Blur',
allPages: isCN ? '全部页面' : isZH ? '全部頁面' : 'All Pages',
pageRange: isCN ? '页面范围' : isZH ? '頁面範圍' : 'Page Range',
fromPage: isCN ? '从第' : isZH ? '由第' : 'From',
toPage: isCN ? '到第' : isZH ? '至第' : 'to',
page: isCN ? '页' : isZH ? '頁' : '',
exportMode: isCN ? '导出模式' : isZH ? '匯出模式' : 'Export Mode',
textMode: isCN ? '文字模式 (可搜索PDF)' : isZH ? '文字模式 (可搜尋PDF)' : 'Text Mode (Searchable PDF)',
screenshotMode: isCN ? '截图模式 (纯图片)' : isZH ? '截圖模式 (純圖片)' : 'Screenshot Mode (Image Only)',
exportBtn: isCN ? '导出' : isZH ? '匯出' : 'Export',
cancel: isCN ? '取消' : isZH ? '取消' : 'Cancel',
openingThumbs: isCN ? '正在打开缩略图...' : isZH ? '正在開啟縮圖...' : 'Opening thumbnails...',
noThumbs: isCN ? '无法打开缩略图面板' : isZH ? '無法開啟縮圖面板' : 'Cannot open thumbnails',
invalidRange: isCN ? '无效的页面范围' : isZH ? '無效的頁面範圍' : 'Invalid page range',
extracting: isCN ? '正在提取第 {p} 页 ({c}/{t})...' : isZH ? '正在提取第 {p} 頁 ({c}/{t})...' : 'Extracting page {p} ({c}/{t})...',
downloadingFonts: isCN ? '正在下载字体 ({c}/{t}): {n}' : isZH ? '正在下載字體 ({c}/{t}): {n}' : 'Downloading fonts ({c}/{t}): {n}',
rendering: isCN ? '正在渲染第 {c}/{t} 页...' : isZH ? '正在渲染第 {c}/{t} 頁...' : 'Rendering page {c}/{t}...',
savingPDF: isCN ? '正在保存 PDF...' : isZH ? '正在儲存 PDF...' : 'Saving PDF...',
done: isCN ? '导出完成,已下载PDF' : isZH ? '匯出完成,已下載PDF' : 'Export complete, PDF downloaded',
doneBtn: isCN ? '完成' : isZH ? '完成' : 'Done',
exportingHint: isCN ? '正在导出中,请不要离开此页面' : isZH ? '正在匯出中,請不要離開此頁面' : 'Exporting, please stay on this page',
cancelled: isCN ? '已取消导出' : isZH ? '已取消匯出' : 'Export cancelled',
cancelling: isCN ? '正在取消...' : isZH ? '正在取消...' : 'Cancelling...',
noBook: isCN ? '请先打开一本书' : isZH ? '請先開啟一本書' : 'Please open a book first',
totalPages: isCN ? '共 {n} 页' : isZH ? '共 {n} 頁' : '{n} pages total',
skip: isCN ? '跳过第 {p} 页: 导航失败' : isZH ? '跳過第 {p} 頁: 導航失敗' : 'Skip page {p}: navigation failed',
};
function t(key, vars) {
let s = i18n[key] || key;
if (vars) Object.entries(vars).forEach(([k, v]) => { s = s.replace(`{${k}}`, v); });
return s;
}
function startExportState() {
exportState.running = true;
exportState.cancelled = false;
exportState.controller = new AbortController();
}
function stopExportState() {
exportState.running = false;
exportState.controller = null;
}
function cancelExportState() {
exportState.cancelled = true;
if (exportState.controller) {
try { exportState.controller.abort(); } catch {}
}
}
function throwIfCancelled() {
if (exportState.cancelled) throw new Error(EXPORT_CANCELLED);
}
async function fetchCancellable(url, options = {}) {
const signal = exportState.controller ? exportState.controller.signal : undefined;
return fetch(url, { ...options, signal });
}
let _PDFLib = null;
let _fontkit = null;
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
const s = document.createElement('script');
s.src = src;
s.onload = resolve;
s.onerror = () => reject(new Error('Failed to load: ' + src));
document.head.appendChild(s);
});
}
async function loadPDFLib() {
if (_PDFLib) return _PDFLib;
await loadScript('https://unpkg.com/[email protected]/dist/pdf-lib.min.js');
_PDFLib = window.PDFLib;
return _PDFLib;
}
async function loadFontkit() {
if (_fontkit) return _fontkit;
await loadScript('https://unpkg.com/@pdf-lib/[email protected]/dist/fontkit.umd.min.js');
_fontkit = window.fontkit;
return _fontkit;
}
function getEbookDoc() {
const f = document.querySelector('.ebook-main-frame');
return f ? f.contentDocument : null;
}
function getToolbarDoc() {
const f = document.querySelector('.ebook-toolbar');
return f ? f.contentDocument : null;
}
function getToolsDoc() {
const f = document.querySelector('.ebook-tools-frame');
return f ? f.contentDocument : null;
}
function getBookId() {
const f = document.querySelector('.ebook-main-frame');
if (!f) return null;
const m = f.src.match(/ebook_user_content\/V\d+\/([^/]+)/);
return m ? m[1] : null;
}
function getBookBase() {
const f = document.querySelector('.ebook-main-frame');
if (!f) return null;
const m = f.src.match(/^(.*ebook_user_content\/V\d+\/[^/]+\/)/);
return m ? m[1] : null;
}
function removeBlurMasks() {
const doc = getEbookDoc();
if (!doc) return 0;
let n = 0;
doc.querySelectorAll('[id^="BY_maskImage"]').forEach(el => { el.remove(); n++; });
doc.querySelectorAll('.BY_maskImage').forEach(el => { el.style.display = 'none'; n++; });
const classPattern = /^blur\d+$/;
doc.querySelectorAll('[class*="blur"]').forEach(el => {
Array.from(el.classList).forEach(cls => {
if (classPattern.test(cls)) {
el.classList.remove(cls);
n++;
}
});
});
return n;
}
function isNearBlack(colorStr) {
const m = (colorStr || '').match(/rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
if (!m) return false;
const r = +m[1], g = +m[2], b = +m[3];
return r <= 8 && g <= 8 && b <= 8;
}
function removeBlurClasses(doc) {
if (!doc) return 0;
const classPattern = /^blur\d+$/;
let count = 0;
doc.querySelectorAll('[class*="blur"]').forEach(el => {
Array.from(el.classList).forEach(className => {
if (classPattern.test(className)) {
el.classList.remove(className);
count++;
}
});
});
return count;
}
function applyLiveBlurPatch(doc) {
if (!doc) return;
removeBlurClasses(doc);
doc.querySelectorAll('[id^="BY_maskImage"], .BY_maskImage').forEach(el => {
el.dataset.isolBlurPatched = '1';
el.style.setProperty('display', 'none', 'important');
el.style.setProperty('visibility', 'hidden', 'important');
el.style.setProperty('opacity', '0', 'important');
});
doc.querySelectorAll('[class*="blur"]').forEach(el => {
el.dataset.isolBlurPatched = '1';
el.style.setProperty('filter', 'none', 'important');
el.style.setProperty('-webkit-filter', 'none', 'important');
el.style.setProperty('backdrop-filter', 'none', 'important');
});
}
function clearLiveBlurPatch(doc) {
if (!doc) return;
doc.querySelectorAll('[data-isol-blur-patched="1"]').forEach(el => {
el.style.removeProperty('display');
el.style.removeProperty('visibility');
el.style.removeProperty('opacity');
el.style.removeProperty('filter');
el.style.removeProperty('-webkit-filter');
el.style.removeProperty('backdrop-filter');
delete el.dataset.isolBlurPatched;
});
}
function parseRgba(s) {
const m = (s || '').match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([0-9.]+))?\s*\)/i);
if (!m) return [0, 0, 0, 1];
return [+m[1], +m[2], +m[3], m[4] !== undefined ? parseFloat(m[4]) : 1];
}
function isTransparent(s) {
if (!s) return false;
if (s === 'transparent' || s === 'rgba(0, 0, 0, 0)') return true;
const m = s.match(/rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([0-9.]+)\s*\)/i);
return m && parseFloat(m[1]) < 0.05;
}
function parseColorAny(s, fallback = [0, 0, 0]) {
const rgb = (s || '').match(/rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
if (rgb) return [+rgb[1], +rgb[2], +rgb[3]];
const hex = (s || '').match(/#([0-9a-f]{6})/i);
if (hex) {
const v = hex[1];
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)];
}
return fallback;
}
function shadowColor(s) {
if (!s || s === 'none') return null;
const m = s.match(/(rgba?\([^\)]+\)|#[0-9a-fA-F]{6})/);
return m ? m[1] : null;
}
function parseMat(s) {
const m = (s || '').match(/matrix\(([^)]+)\)/i);
if (!m) return null;
const a = m[1].split(',').map(v => Number(v.trim()));
if (a.length !== 6 || a.some(Number.isNaN)) return null;
return { a: a[0], b: a[1], c: a[2], d: a[3], e: a[4], f: a[5] };
}
function getTransform(transform, style) {
if (transform && transform !== 'none') return parseMat(transform);
if (style) {
const m = style.match(/-webkit-transform\s*:\s*matrix\(([^)]+)\)/i);
if (m) return parseMat('matrix(' + m[1] + ')');
}
return null;
}
function parseTransformOrigin(s) {
if (!s) return [0, 0];
const parts = s.trim().split(/\s+/);
const p = v => {
if (v.endsWith('px')) return parseFloat(v) || 0;
if (v.endsWith('%')) return v;
return 0;
};
return [p(parts[0] || '0'), p(parts[1] || '0')];
}
function getTransformOrigin(origin, style) {
if (origin && origin !== '0px 0px') return parseTransformOrigin(origin);
if (style) {
const m = style.match(/-webkit-transform-origin\s*:\s*([^;]+)/i);
if (m) return parseTransformOrigin(m[1]);
}
return [0, 0];
}
function extractPageTokens() {
const doc = getEbookDoc();
if (!doc) return null;
const rez = doc.querySelector('#rez');
if (!rez) return null;
const panes = rez.querySelectorAll('pane');
let activePane = null;
panes.forEach(p => { if (getComputedStyle(p).display !== 'none') activePane = p; });
if (!activePane) return null;
const visible = [...doc.querySelectorAll('.by-html-page')].find(p => getComputedStyle(p).display !== 'none');
const pageId = visible ? visible.id : '';
const imgEl = doc.querySelector('img.bgcls') || doc.querySelector('.by-html-page img');
const imageUrl = imgEl ? imgEl.src : '';
const divs = [...activePane.children];
const tokens = [];
for (const div of divs) {
const sentencee = div.querySelector('sentencee');
const csDiv = getComputedStyle(div);
const csSentence = sentencee ? getComputedStyle(sentencee) : null;
const txt = sentencee ? sentencee.textContent : (div.childNodes[0]?.textContent || div.textContent);
const styleAttr = div.getAttribute('style') || '';
const leftM = styleAttr.match(/left\s*:\s*([0-9.]+)px/);
const topM = styleAttr.match(/top\s*:\s*([0-9.]+)px/);
const widthM = styleAttr.match(/width\s*:\s*([0-9.]+)px/);
let color = csDiv.color;
if (csSentence && csSentence.color && !(isNearBlack(csSentence.color) && !isNearBlack(csDiv.color))) {
color = csSentence.color;
}
const useSentenceFont = !!(csSentence && csSentence.fontFamily && csSentence.fontFamily !== csDiv.fontFamily);
const fontStyleSource = useSentenceFont ? csSentence : csDiv;
const strokeColorRaw = (csDiv.webkitTextStrokeColor && csDiv.webkitTextStrokeColor !== 'rgba(0, 0, 0, 0)')
? csDiv.webkitTextStrokeColor
: shadowColor(csDiv.textShadow);
const strokeWidth = parseFloat(csDiv.webkitTextStrokeWidth || '0') || 0;
tokens.push({
text: txt.trim(),
x: leftM ? parseFloat(leftM[1]) : 0,
y: topM ? parseFloat(topM[1]) : 0,
w: widthM ? parseFloat(widthM[1]) : 0,
fs: parseFloat(fontStyleSource.fontSize) || 12,
color,
colorTransparent: isTransparent(color),
fontFamily: fontStyleSource.fontFamily.split(',')[0].trim().replace(/^["']|["']$/g, ''),
fontWeight: fontStyleSource.fontWeight,
fontStyle: fontStyleSource.fontStyle,
strokeColor: strokeColorRaw || '',
strokeWidth,
className: div.className,
style: styleAttr,
transform: csDiv.transform,
transformOrigin: csDiv.transformOrigin
});
}
return { pageId, imageUrl, tokens };
}
async function discoverFontUrls(bookBase, sampleImageUrl) {
const fontMap = {};
const secMatch = sampleImageUrl.match(/^(.*ebook_user_content\/V\d+\/[^/]+\/s_\d+\/)/);
if (!secMatch) return fontMap;
const secBase = secMatch[1];
const cssUrls = [secBase + 'steps_1_font.css', secBase + 'steps.css'];
const pageHtmlUrl = sampleImageUrl.replace('/images/steps_', '/steps_').replace('.jpg', '.html');
try {
const html = await (await fetch(pageHtmlUrl)).text();
[...html.matchAll(/href=["']([^"']+\.css(?:\?[^"']*)?)["']/ig)].forEach(m => {
cssUrls.push(new URL(m[1], pageHtmlUrl).toString());
});
} catch {}
for (const url of cssUrls) {
try {
const txt = await (await fetch(url)).text();
const re = /@font-face\s*\{([^}]+)\}/gmi;
let mm;
while ((mm = re.exec(txt))) {
const block = mm[1];
const famM = block.match(/font-family\s*:\s*([^;]+)/i);
const srcM = block.match(/src\s*:\s*([^;]+)/i);
if (!famM || !srcM) continue;
const fam = famM[1].trim().replace(/^["']|["']$/g, '');
const src = srcM[1];
const all = [...src.matchAll(/url\(([^)]+\.(?:ttf|otf)(?:\?[^)]*)?)\)/ig)];
for (const um of all) {
let rel = um[1].trim().replace(/^["']|["']$/g, '');
const q = rel.indexOf('?');
if (q >= 0) rel = rel.slice(0, q);
if (!fontMap[fam]) { fontMap[fam] = new URL(rel, url).toString(); break; }
}
}
} catch {}
}
return fontMap;
}
async function downloadFontBytes(url) {
const r = await fetchCancellable(url);
if (!r.ok) return null;
return new Uint8Array(await r.arrayBuffer());
}
function getVisiblePageId() {
const doc = getEbookDoc();
if (!doc) return null;
const v = [...doc.querySelectorAll('.by-html-page')].find(p => getComputedStyle(p).display !== 'none');
return v ? v.id : null;
}
async function clickNextPage() {
const toolbarDoc = getToolbarDoc();
if (!toolbarDoc) return false;
const btn = toolbarDoc.getElementById('ebk-btn_0');
if (!btn) return false;
const before = getVisiblePageId();
btn.click();
for (let i = 0; i < 30; i++) {
await WAIT(100);
const after = getVisiblePageId();
if (after && after !== before) return true;
}
return false;
}
async function ensureThumbnailPanel() {
for (let i = 0; i < 6; i++) {
throwIfCancelled();
const toolsDoc = getToolsDoc();
if (toolsDoc) {
const container = toolsDoc.getElementById('tools_snapShotContentOuterDiv');
if (container && container.children.length > 0) return true;
}
const toolbarDoc = getToolbarDoc();
if (toolbarDoc) {
const btn = toolbarDoc.getElementById('ebk-btn_3');
if (btn) btn.click();
}
await WAIT(800);
}
return false;
}
function getTotalPageCount() {
const toolsDoc = getToolsDoc();
if (!toolsDoc) return 0;
const container = toolsDoc.getElementById('tools_snapShotContentOuterDiv');
return container ? container.children.length : 0;
}
function getPageImageUrl(pageNum) {
const toolsDoc = getToolsDoc();
if (!toolsDoc) return null;
const container = toolsDoc.getElementById('tools_snapShotContentOuterDiv');
if (!container || !container.children[pageNum - 1]) return null;
const img = container.children[pageNum - 1].querySelector('img');
return img ? img.src.replace('/thumbs/', '/images/') : null;
}
async function jumpToPage(pageNum, bookId) {
for (let attempt = 0; attempt < 3; attempt++) {
throwIfCancelled();
const panelOk = await ensureThumbnailPanel();
if (!panelOk) continue;
await WAIT(300);
const toolsDoc = getToolsDoc();
if (!toolsDoc) continue;
const container = toolsDoc.getElementById('tools_snapShotContentOuterDiv');
if (!container || !container.children[pageNum - 1]) continue;
const img = container.children[pageNum - 1].querySelector('img');
if (!img) continue;
const m = img.src.match(/\/s_(\d+)\/thumbs\/steps_(\d+)\.jpg/i);
const expectedId = m ? `${bookId}-s_${m[1]}-steps_${m[2]}` : null;
img.click();
for (let i = 0; i < 40; i++) {
await WAIT(100);
const vid = getVisiblePageId();
if (vid && vid === expectedId) return true;
}
}
return false;
}
function isDark() {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function addStyles() {
const dk = isDark();
const s = document.createElement('style');
s.textContent = `
#isol-export-dialog{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.55);z-index:999999;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
#isol-export-dialog .dialog{background:${dk?'#2d2d2d':'#fff'};border-radius:14px;padding:28px 32px;min-width:380px;box-shadow:0 8px 32px rgba(0,0,0,0.35);color:${dk?'#e0e0e0':'#333'}}
#isol-export-dialog h3{margin:0 0 18px 0;font-size:18px;color:${dk?'#fff':'#333'}}
#isol-export-dialog label{display:block;margin-bottom:14px;font-size:13px;color:${dk?'#bbb':'#555'}}
#isol-export-dialog input[type="number"],#isol-export-dialog select{width:100%;padding:8px 10px;border:1px solid ${dk?'#555':'#ccc'};border-radius:6px;font-size:14px;box-sizing:border-box;margin-top:4px;background:${dk?'#3a3a3a':'#fff'};color:${dk?'#e0e0e0':'#333'}}
#isol-export-dialog .radio-row{display:flex;gap:16px;margin-top:6px}
#isol-export-dialog .radio-row label{display:flex;align-items:center;gap:5px;margin:0;font-size:13px;cursor:pointer}
#isol-export-dialog .btn-row{display:flex;gap:10px;margin-top:22px}
#isol-export-dialog button{flex:1;padding:10px 16px;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer}
#isol-export-dialog .btn-export{background:#4CAF50;color:#fff}
#isol-export-dialog .btn-export:hover{background:#43a047}
#isol-export-dialog .btn-export:disabled{background:#999;cursor:not-allowed}
#isol-export-dialog .btn-cancel{background:${dk?'#444':'#f5f5f5'};color:${dk?'#ccc':'#666'};border:1px solid ${dk?'#555':'#ddd'}}
#isol-export-dialog .btn-cancel:hover{background:${dk?'#555':'#eee'}}
#isol-export-dialog .progress-bar{width:100%;height:14px;background:${dk?'#444':'#e0e0e0'};border-radius:7px;overflow:hidden;margin-top:16px}
#isol-export-dialog .progress-fill{height:100%;background:linear-gradient(90deg,#4CAF50,#66BB6A);transition:width 0.3s;width:0%;border-radius:7px}
#isol-export-dialog .bottom-row{display:flex;justify-content:space-between;margin-top:6px}
#isol-export-dialog .status{font-size:12px;color:${dk?'#999':'#888'}}
#isol-export-dialog .pct{font-size:11px;color:${dk?'#777':'#999'}}
#isol-btn-export,#isol-btn-blur{cursor:pointer}
#isol-btn-blur.active{opacity:0.5}
`;
document.head.appendChild(s);
}
function normalizeToolbarLayout() {
const toolbarDoc = getToolbarDoc();
if (!toolbarDoc) return;
const fixedBar = toolbarDoc.getElementById('fixed-bar');
if (!fixedBar) return;
fixedBar.style.removeProperty('width');
fixedBar.style.removeProperty('height');
fixedBar.style.removeProperty('line-height');
fixedBar.style.removeProperty('white-space');
fixedBar.style.removeProperty('overflow');
if (!toolbarDoc.getElementById('isol-toolbar-override')) {
const s = toolbarDoc.createElement('style');
s.id = 'isol-toolbar-override';
s.textContent = '#fixed-bar{width:auto!important;height:auto!important;overflow:visible!important;position:static!important}';
toolbarDoc.head.appendChild(s);
}
}
function ensureLiveBlurAuto() {
const doc = getEbookDoc();
if (!doc || !doc.body) return;
if (blurAutoDoc === doc && blurAutoObserver) return;
if (blurAutoObserver) {
try { blurAutoObserver.disconnect(); } catch {}
blurAutoObserver = null;
}
blurAutoDoc = doc;
applyLiveBlurPatch(doc);
blurAutoObserver = new MutationObserver(() => applyLiveBlurPatch(doc));
blurAutoObserver.observe(doc.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'style', 'id']
});
}
function insertToolbarButtons() {
const toolbarDoc = getToolbarDoc();
if (!toolbarDoc || toolbarDoc.getElementById('isol-btn-export')) return;
const host = toolbarDoc.querySelector('#animated-bar > div:nth-child(2)');
if (!host) return;
normalizeToolbarLayout();
const exportBtn = toolbarDoc.createElement('div');
exportBtn.id = 'isol-btn-export';
exportBtn.className = 'ebk-btn';
exportBtn.title = t('exportPDF');
exportBtn.style.backgroundImage = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 35' fill='none'%3E%3Crect width='36' height='35' fill='%23f9f9f9'/%3E%3Cpath d='M23 6H13c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V12l-6-6zm-1 7V8l5 5h-5zm-5 3h6v1.5h-6V16zm0 3h6v1.5h-6V19z' fill='%23555'/%3E%3C/svg%3E\")";
exportBtn.style.setProperty('background-size', '24px 24px', 'important');
exportBtn.style.setProperty('background-position', 'center', 'important');
exportBtn.style.setProperty('background-repeat', 'no-repeat', 'important');
exportBtn.style.setProperty('margin-left', '4px', 'important');
exportBtn.onclick = showExportDialog;
host.appendChild(exportBtn);
}
function showExportDialog() {
if (document.getElementById('isol-export-dialog')) return;
const dlg = document.createElement('div');
dlg.id = 'isol-export-dialog';
const dk = isDark();
dlg.innerHTML = `
<div class="dialog">
<h3>${t('exportPDF')}</h3>
<label>${t('pageRange')}
<div class="radio-row">
<label><input type="radio" name="isol-range" value="all" checked> ${t('allPages')}</label>
<label><input type="radio" name="isol-range" value="range"> ${t('fromPage')} <input type="number" id="isol-from" min="1" value="1" style="width:60px;display:inline;padding:4px 6px;margin:0 4px"> ${t('toPage')} <input type="number" id="isol-to" min="1" value="30" style="width:60px;display:inline;padding:4px 6px;margin:0 4px"> ${t('page')}</label>
</div>
</label>
<label>${t('exportMode')}
<select id="isol-mode">
<option value="text">${t('textMode')}</option>
<option value="screenshot">${t('screenshotMode')}</option>
</select>
</label>
<div style="font-size:11px;color:${dk?'#777':'#999'};margin-bottom:8px" id="isol-total-info"></div>
<div class="btn-row">
<button class="btn-export" id="isol-btn-start">${t('exportBtn')}</button>
<button class="btn-cancel" id="isol-btn-close">${t('cancel')}</button>
</div>
<div class="progress-bar" id="isol-pbar" style="display:none"><div class="progress-fill" id="isol-pfill"></div></div>
<div class="bottom-row">
<div class="status" id="isol-status"></div>
<div class="pct" id="isol-pct"></div>
</div>
<div class="status" id="isol-hint" style="margin-top:4px"></div>
</div>
`;
document.body.appendChild(dlg);
document.getElementById('isol-btn-close').onclick = () => {
if (exportState.running) {
cancelExportState();
setStatus(t('cancelling'));
const closeBtn = document.getElementById('isol-btn-close');
if (closeBtn) closeBtn.disabled = true;
return;
}
dlg.remove();
};
const radios = dlg.querySelectorAll('input[name="isol-range"]');
const numInputs = dlg.querySelectorAll('input[type="number"]');
radios.forEach(r => {
r.onchange = () => { numInputs.forEach(n => n.disabled = r.value === 'all' || !r.checked); };
});
numInputs.forEach(n => n.disabled = true);
document.getElementById('isol-btn-start').onclick = async () => {
const mode = document.getElementById('isol-mode').value;
const isAll = dlg.querySelector('input[name="isol-range"]:checked').value === 'all';
const from = parseInt(document.getElementById('isol-from').value) || 1;
const to = parseInt(document.getElementById('isol-to').value) || 1;
startExportState();
document.getElementById('isol-pbar').style.display = 'block';
document.getElementById('isol-btn-start').disabled = true;
document.getElementById('isol-btn-start').textContent = '...';
setStatus(t('openingThumbs'));
try {
const ok = await ensureThumbnailPanel();
if (!ok) {
setStatus(t('noThumbs'));
return;
}
const total = getTotalPageCount();
document.getElementById('isol-total-info').textContent = t('totalPages', { n: total });
let pages;
if (isAll) {
pages = Array.from({ length: total }, (_, i) => i + 1);
} else {
pages = [];
for (let i = Math.max(1, from); i <= Math.min(total, to); i++) pages.push(i);
}
if (!pages.length) {
alert(t('invalidRange'));
return;
}
const hint = document.getElementById('isol-hint');
if (hint) hint.textContent = t('exportingHint');
await doExport(pages, mode);
} catch (e) {
if (e && e.message === EXPORT_CANCELLED) {
setStatus(t('cancelled'));
const hint = document.getElementById('isol-hint');
if (hint) hint.textContent = '';
} else {
setStatus('Error: ' + e.message);
console.error(e);
}
} finally {
stopExportState();
const startBtn = document.getElementById('isol-btn-start');
if (startBtn) {
startBtn.disabled = false;
if (startBtn.textContent !== t('doneBtn')) {
startBtn.textContent = t('exportBtn');
}
}
const closeBtn = document.getElementById('isol-btn-close');
if (closeBtn) closeBtn.disabled = false;
}
};
}
function setStatus(msg) {
const el = document.getElementById('isol-status');
if (el) el.textContent = msg;
}
function setProgress(current, total) {
const fill = document.getElementById('isol-pfill');
const pctEl = document.getElementById('isol-pct');
if (fill) fill.style.width = Math.round(current / total * 100) + '%';
if (pctEl) pctEl.textContent = `${current}/${total} (${Math.round(current / total * 100)}%)`;
}
async function doExport(pages, mode) {
throwIfCancelled();
ensureLiveBlurAuto();
const bookId = getBookId();
if (!bookId) { setStatus(t('noBook')); return; }
const allData = [];
const allFamilies = new Set();
const totalPages = pages.length;
// Step 1: Navigate to first page via thumbnail, then use Next Page button
setStatus(t('extracting', { p: pages[0], c: 1, t: totalPages }));
throwIfCancelled();
const firstOk = await jumpToPage(pages[0], bookId);
if (!firstOk) { setStatus('Failed to navigate to first page'); return; }
ensureLiveBlurAuto();
removeBlurMasks();
await WAIT(200);
const data0 = extractPageTokens();
if (data0) {
if (mode === 'screenshot') {
allData.push({ imageUrl: getPageImageUrl(pages[0]) || data0.imageUrl, tokens: [] });
} else {
const filtered = data0.tokens.filter(t => t.text.length > 0);
filtered.forEach(t => allFamilies.add(t.fontFamily));
allData.push({ imageUrl: data0.imageUrl, tokens: filtered });
}
}
setProgress(1, totalPages);
// Step 2: Use Next Page button for remaining pages
for (let i = 1; i < pages.length; i++) {
throwIfCancelled();
const gap = pages[i] - pages[i - 1];
let navigated = false;
for (let g = 0; g < gap; g++) {
throwIfCancelled();
let ok = false;
for (let retry = 0; retry < 5; retry++) {
ok = await clickNextPage();
if (ok) break;
throwIfCancelled();
if (retry < 4) await WAIT(300);
}
if (!ok) break;
if (g === gap - 1) navigated = true;
}
if (!navigated) {
console.warn(t('skip', { p: pages[i] }));
continue;
}
ensureLiveBlurAuto();
removeBlurMasks();
await WAIT(150);
setStatus(t('extracting', { p: pages[i], c: i + 1, t: totalPages }));
const data = extractPageTokens();
if (!data) continue;
if (mode === 'screenshot') {
allData.push({ imageUrl: getPageImageUrl(pages[i]) || data.imageUrl, tokens: [] });
} else {
const filtered = data.tokens.filter(t => t.text.length > 0);
filtered.forEach(t => allFamilies.add(t.fontFamily));
allData.push({ imageUrl: data.imageUrl, tokens: filtered });
}
setProgress(i + 1, totalPages);
}
if (allData.length === 0) { setStatus('No pages extracted'); return; }
// Step 3: Generate PDF
setStatus('Loading PDF library...');
try {
throwIfCancelled();
await loadPDFLib();
if (mode !== 'screenshot') await loadFontkit();
} catch (e) { setStatus('Failed to load PDF library: ' + e.message); return; }
if (mode === 'screenshot') {
await generateScreenshotPDF(allData, bookId);
} else {
await generateTextPDF(allData, allFamilies, bookId);
}
setProgress(totalPages, totalPages);
setStatus(t('done'));
const hint = document.getElementById('isol-hint');
if (hint) hint.textContent = '';
const btn = document.getElementById('isol-btn-start');
if (btn) { btn.textContent = t('doneBtn'); btn.disabled = false; btn.onclick = () => { document.getElementById('isol-export-dialog')?.remove(); }; }
}
async function generateScreenshotPDF(pagesData, bookId) {
const { PDFDocument } = await loadPDFLib();
const pdfDoc = await PDFDocument.create();
for (let i = 0; i < pagesData.length; i++) {
throwIfCancelled();
const p = pagesData[i];
if (!p.imageUrl) continue;
try {
setStatus(`Downloading image ${i + 1}/${pagesData.length}...`);
const r = await fetchCancellable(p.imageUrl);
if (!r.ok) continue;
const imgBytes = new Uint8Array(await r.arrayBuffer());
let img;
try { img = await pdfDoc.embedJpg(imgBytes); } catch { try { img = await pdfDoc.embedPng(imgBytes); } catch { continue; } }
const dim = img.size();
const page = pdfDoc.addPage([dim.width, dim.height]);
page.drawImage(img, { x: 0, y: 0, width: dim.width, height: dim.height });
} catch (e) { console.warn('Skip image', i, e.message); }
}
setStatus(t('savingPDF'));
const pdfBytes = await pdfDoc.save();
downloadBlob(pdfBytes, `${bookId}_screenshot.pdf`);
}
async function generateTextPDF(pagesData, allFamilies, bookId) {
const PL = await loadPDFLib();
await loadFontkit();
const { PDFDocument, rgb, degrees, StandardFonts } = PL;
const pdfDoc = await PDFDocument.create();
pdfDoc.registerFontkit(window.fontkit);
const bookBase = getBookBase();
const sampleUrl = pagesData.find(p => p.imageUrl)?.imageUrl || '';
const cssFontMap = await discoverFontUrls(bookBase, sampleUrl);
const fontBytesCache = {};
const famArr = [...allFamilies];
for (let fi = 0; fi < famArr.length; fi++) {
throwIfCancelled();
const fam = famArr[fi];
setStatus(t('downloadingFonts', { c: fi + 1, t: famArr.length, n: fam }));
let url = cssFontMap[fam];
if (!url && bookBase) {
for (const ext of ['ttf', 'otf']) {
throwIfCancelled();
const tryUrl = bookBase + 'FONTS/' + fam + '.' + ext;
try {
const r = await fetchCancellable(tryUrl, { method: 'HEAD' });
if (r.ok) { url = tryUrl; break; }
} catch {}
}
}
if (url) {
try { fontBytesCache[fam] = await downloadFontBytes(url); } catch {}
}
}
const pdfFontCache = {};
const fallbackFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
async function getPdfFont(fam) {
if (pdfFontCache[fam]) return pdfFontCache[fam];
const bytes = fontBytesCache[fam];
if (!bytes) return null;
try { const f = await pdfDoc.embedFont(bytes); pdfFontCache[fam] = f; return f; }
catch { return null; }
}
for (let i = 0; i < pagesData.length; i++) {
throwIfCancelled();
const p = pagesData[i];
setProgress(i, pagesData.length);
setStatus(t('rendering', { c: i + 1, t: pagesData.length }));
let imgW = 1536, imgH = 2016;
let imgEmbedded = null;
if (p.imageUrl) {
try {
const r = await fetchCancellable(p.imageUrl);
if (r.ok) {
const imgBytes = new Uint8Array(await r.arrayBuffer());
try { imgEmbedded = await pdfDoc.embedJpg(imgBytes); } catch { try { imgEmbedded = await pdfDoc.embedPng(imgBytes); } catch {} }
if (imgEmbedded) { const dim = imgEmbedded.size(); imgW = dim.width; imgH = dim.height; }
}
} catch {}
}
const page = pdfDoc.addPage([imgW, imgH]);
if (imgEmbedded) page.drawImage(imgEmbedded, { x: 0, y: 0, width: imgW, height: imgH });
const sx = imgW / 1024, sy = imgH / 1344;
for (const tk of p.tokens) {
throwIfCancelled();
let txt = tk.text.replace(/\u00a0/g, ' ').replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '');
if (!txt) continue;
const [cr, cg, cb, ca] = parseRgba(tk.color);
const isMainTransparent = tk.colorTransparent || ca < 0.05;
const [sr, sg, sb] = parseColorAny(tk.strokeColor, [cr, cg, cb]);
const strokePx = Math.max(0, (tk.strokeWidth || 0) * sx);
const hasStroke = strokePx > 0.05;
const mat = getTransform(tk.transform, tk.style);
const isRotate = mat && Math.abs(mat.b) > 0.5 && Math.abs(mat.c) > 0.5;
const tx = mat && !isRotate ? (mat.e || 0) : 0;
const ty = mat && !isRotate ? (mat.f || 0) : 0;
const baseFs = Math.max(3, tk.fs * sy);
const fsPx = isRotate ? baseFs : Math.max(3, baseFs * Math.abs(mat ? mat.a : 1));
let pdfFont = await getPdfFont(tk.fontFamily);
if (!pdfFont) pdfFont = fallbackFont;
const textX = (tk.x + tx) * sx;
const textY = (tk.y + ty) * sy;
function drawStrokeOutlines(px, py, rot) {
const sc = rgb(sr / 255, sg / 255, sb / 255);
const d = Math.max(0.3, strokePx * 0.5);
const angles8 = [0, Math.PI/4, Math.PI/2, 3*Math.PI/4, Math.PI, 5*Math.PI/4, 3*Math.PI/2, 7*Math.PI/4];
const opts = rot != null ? { size: fsPx, font: pdfFont, color: sc, rotate: degrees(rot) } : { size: fsPx, font: pdfFont, color: sc };
for (const a of angles8) {
const dx = Math.cos(a) * d, dy = Math.sin(a) * d;
try { page.drawText(txt, { ...opts, x: px + dx, y: py + dy }); } catch {}
}
}
let pdfX, pdfY;
if (isRotate) {
const elW = tk.w || 0;
const elH = tk.fs || 12;
const [ox, oy] = getTransformOrigin(tk.transformOrigin, tk.style);
const oxPx = typeof ox === 'string' && ox.includes('%') ? elW * parseFloat(ox) / 100 * sx : parseFloat(ox) * sx;
const oyPx = typeof oy === 'string' && oy.includes('%') ? elH * parseFloat(oy) / 100 * sy : parseFloat(oy) * sy;
const cx = (tk.x + (mat.e || 0)) * sx + oxPx;
const cy = (tk.y + (mat.f || 0)) * sy + oyPx;
const ascent = (typeof pdfFont.heightAtSize === 'function'
? pdfFont.heightAtSize(fsPx, { descender: false })
: fsPx * 0.8);
const baseBx = (tk.x + (mat.e || 0)) * sx;
const baseBy = (tk.y + (mat.f || 0)) * sy + ascent;
const angle = mat.c > 0 ? -Math.PI / 2 : Math.PI / 2;
const cosA = Math.cos(angle), sinA = Math.sin(angle);
pdfX = cx + (baseBx - cx) * cosA - (baseBy - cy) * sinA;
pdfY = imgH - (cy + (baseBx - cx) * sinA + (baseBy - cy) * cosA);
const rotDeg = angle * 180 / Math.PI;
try {
if (hasStroke) drawStrokeOutlines(pdfX, pdfY, rotDeg);
if (!isMainTransparent) {
page.drawText(txt, {
x: pdfX, y: pdfY,
size: fsPx, font: pdfFont, color: rgb(cr / 255, cg / 255, cb / 255),
rotate: degrees(rotDeg)
});
}
} catch {}
} else {
const ascent = (typeof pdfFont.heightAtSize === 'function'
? pdfFont.heightAtSize(fsPx, { descender: false })
: fsPx * 0.8);
pdfX = textX;
pdfY = imgH - textY - ascent;
try {
if (hasStroke) drawStrokeOutlines(pdfX, pdfY, null);
if (!isMainTransparent) {
page.drawText(txt, {
x: pdfX, y: pdfY,
size: fsPx, font: pdfFont, color: rgb(cr / 255, cg / 255, cb / 255)
});
}
} catch {}
}
}
}
setStatus(t('savingPDF'));
const pdfBytes = await pdfDoc.save();
downloadBlob(pdfBytes, `${bookId}_text.pdf`);
}
function downloadBlob(bytes, filename) {
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
let toolbarWatchStarted = false;
function watchToolbar() {
if (toolbarWatchStarted) return;
toolbarWatchStarted = true;
setInterval(() => {
const toolbarDoc = getToolbarDoc();
if (!toolbarDoc) return;
if (!toolbarDoc.getElementById('isol-btn-export') && toolbarDoc.getElementById('ebk-btn_2')) {
insertToolbarButtons();
normalizeToolbarLayout();
}
}, 2000);
}
function init() {
console.log('[iSolution] v3.12 loaded');
addStyles();
let retries = 0;
const checkInterval = setInterval(() => {
retries++;
if (retries > 120) { clearInterval(checkInterval); return; }
const toolbarDoc = getToolbarDoc();
if (toolbarDoc && toolbarDoc.getElementById('ebk-btn_2')) {
clearInterval(checkInterval);
insertToolbarButtons();
normalizeToolbarLayout();
const fixedBar = toolbarDoc.getElementById('fixed-bar');
if (fixedBar) {
const obs = new MutationObserver(() => normalizeToolbarLayout());
obs.observe(fixedBar, { attributes: true, attributeFilter: ['style', 'class'] });
}
ensureLiveBlurAuto();
setInterval(() => ensureLiveBlurAuto(), 1000);
watchToolbar();
}
}, 1000);
}
if (document.readyState === 'complete') init();
else window.addEventListener('load', init);
})();