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