iSolution PDF Downloader

Download iSolution ebooks as searchable PDFs with text overlay and disable image blur

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==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);
})();