import * as pdfjs from './vendor/pdf.min.mjs'; pdfjs.GlobalWorkerOptions.workerSrc = new URL('./vendor/pdf.worker.min.mjs', import.meta.url).href; const WIDTH = 320; const HEIGHT = 440; const PDF_ASSET_OPTIONS = Object.freeze({ cMapUrl: new URL('./vendor/pdfjs/cmaps/', import.meta.url).href, cMapPacked: true, iccUrl: new URL('./vendor/pdfjs/iccs/', import.meta.url).href, standardFontDataUrl: new URL('./vendor/pdfjs/standard_fonts/', import.meta.url).href, wasmUrl: new URL('./vendor/pdfjs/wasm/', import.meta.url).href }); const IMAGE_MIMES = new Set([ 'image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/bmp', 'image/svg+xml', 'image/avif' ]); const EXT_MIMES = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', bmp: 'image/bmp', svg: 'image/svg+xml', avif: 'image/avif' }; function toBytes(value) { if (value instanceof Uint8Array) return value.slice(); if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0)); if (value && value.buffer instanceof ArrayBuffer) { return new Uint8Array(value.buffer, value.byteOffset || 0, value.byteLength).slice(); } throw new Error('文件数据无效'); } function resolvePath(base, href) { const raw = String(href || '').split('#')[0].split('?')[0].trim(); if (!raw || /^[a-z][a-z0-9+.\-]*:/i.test(raw)) return ''; const parts = (raw.startsWith('/') ? raw.slice(1) : base + raw).split('/'); const out = []; for (const part of parts) { if (!part || part === '.') continue; if (part === '..') out.pop(); else out.push(part); } return out.join('/'); } function zipEntry(zip, name) { let entry = zip.file(name); if (entry) return entry; let decoded = name; try { decoded = decodeURIComponent(name); } catch (e) { /* keep original */ } const target = decoded.toLowerCase(); return (zip.file(/./) || []).find((item) => { let itemName = item.name; try { itemName = decodeURIComponent(itemName); } catch (e) { /* keep original */ } return itemName.toLowerCase() === target; }) || null; } async function zipText(entry, maxBytes) { const declared = entry && entry._data && Number(entry._data.uncompressedSize); if (!entry || (Number.isFinite(declared) && declared > maxBytes)) throw new Error('EPUB 资源过大'); const text = await entry.async('text'); if (text.length > maxBytes) throw new Error('EPUB 资源过大'); return text; } async function zipBytes(entry, maxBytes) { const declared = entry && entry._data && Number(entry._data.uncompressedSize); if (!entry || (Number.isFinite(declared) && declared > maxBytes)) return null; const bytes = await entry.async('uint8array'); return bytes.length <= maxBytes ? bytes : null; } function canvasToJpeg(canvas) { return canvas.toDataURL('image/jpeg', 0.86); } async function pdfCover(bytes) { const loadingTask = pdfjs.getDocument({ ...PDF_ASSET_OPTIONS, data: toBytes(bytes), isEvalSupported: false, enableXfa: false }); let doc; try { doc = await loadingTask.promise; const page = await doc.getPage(1); const base = page.getViewport({ scale: 1 }); const scale = Math.min(WIDTH / base.width, HEIGHT / base.height); const viewport = page.getViewport({ scale }); const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(viewport.width)); canvas.height = Math.max(1, Math.round(viewport.height)); const context = canvas.getContext('2d', { alpha: false }); context.fillStyle = '#fff'; context.fillRect(0, 0, canvas.width, canvas.height); await page.render({ canvasContext: context, viewport }).promise; page.cleanup(); const cover = document.createElement('canvas'); cover.width = WIDTH; cover.height = HEIGHT; const coverContext = cover.getContext('2d', { alpha: false }); coverContext.fillStyle = '#e7e3dc'; coverContext.fillRect(0, 0, WIDTH, HEIGHT); coverContext.drawImage(canvas, (WIDTH - canvas.width) / 2, (HEIGHT - canvas.height) / 2); return canvasToJpeg(cover); } finally { if (doc && typeof doc.destroy === 'function') await doc.destroy(); else if (loadingTask && typeof loadingTask.destroy === 'function') await loadingTask.destroy(); } } async function loadImage(data, mime) { const blobUrl = URL.createObjectURL(new Blob([data], { type: mime })); try { const image = new Image(); image.decoding = 'async'; image.src = blobUrl; await image.decode(); return image; } finally { URL.revokeObjectURL(blobUrl); } } function imageCover(image) { const canvas = document.createElement('canvas'); canvas.width = WIDTH; canvas.height = HEIGHT; const context = canvas.getContext('2d', { alpha: false }); context.fillStyle = '#f5f1e8'; context.fillRect(0, 0, WIDTH, HEIGHT); const scale = Math.min(WIDTH / image.naturalWidth, HEIGHT / image.naturalHeight); const width = Math.max(1, image.naturalWidth * scale); const height = Math.max(1, image.naturalHeight * scale); context.drawImage(image, (WIDTH - width) / 2, (HEIGHT - height) / 2, width, height); return canvasToJpeg(canvas); } function titleCover(title, authors) { const canvas = document.createElement('canvas'); canvas.width = WIDTH; canvas.height = HEIGHT; const context = canvas.getContext('2d', { alpha: false }); const gradient = context.createLinearGradient(0, 0, WIDTH, HEIGHT); gradient.addColorStop(0, '#242225'); gradient.addColorStop(1, '#6f5546'); context.fillStyle = gradient; context.fillRect(0, 0, WIDTH, HEIGHT); context.fillStyle = '#c49a6c'; context.fillRect(28, 34, 3, HEIGHT - 68); const text = String(title || '未命名书籍').trim() || '未命名书籍'; context.fillStyle = '#fffaf2'; context.font = '600 28px sans-serif'; context.textBaseline = 'top'; const maxWidth = WIDTH - 76; const lines = []; let line = ''; for (const char of text) { const next = line + char; if (line && context.measureText(next).width > maxWidth) { lines.push(line); line = char; if (lines.length === 6) break; } else { line = next; } } if (line && lines.length < 7) lines.push(line); lines.forEach((value, index) => context.fillText(value, 48, 84 + index * 38, maxWidth)); const authorText = (Array.isArray(authors) ? authors : []).filter(Boolean).join(' · '); if (authorText) { context.fillStyle = '#decbb8'; context.font = '16px sans-serif'; context.fillText(authorText, 48, HEIGHT - 72, maxWidth); } return canvasToJpeg(canvas); } async function epubCover(bytes, fallbackTitle, authors) { if (!window.JSZip) throw new Error('缺少 JSZip'); const zip = await window.JSZip.loadAsync(toBytes(bytes)); const encryptedPaths = new Set(); const encryptionEntry = zipEntry(zip, 'META-INF/encryption.xml'); if (encryptionEntry) { try { const encryption = new DOMParser().parseFromString(await zipText(encryptionEntry, 1024 * 1024), 'text/xml'); Array.from(encryption.getElementsByTagName('*')) .filter((item) => item.localName === 'CipherReference') .forEach((item) => { const encryptedPath = resolvePath('', item.getAttribute('URI')); if (encryptedPath) encryptedPaths.add(encryptedPath); }); } catch (e) { /* individual encrypted resources will fail safely if selected */ } } const containerEntry = zipEntry(zip, 'META-INF/container.xml'); if (!containerEntry) throw new Error('EPUB 缺少 container.xml'); const container = new DOMParser().parseFromString(await zipText(containerEntry, 512 * 1024), 'text/xml'); const rootfile = container.querySelector('rootfile'); const opfPath = resolvePath('', rootfile && rootfile.getAttribute('full-path')); const opfEntry = zipEntry(zip, opfPath); if (!opfEntry) throw new Error('EPUB 缺少 OPF'); const opf = new DOMParser().parseFromString(await zipText(opfEntry, 2 * 1024 * 1024), 'text/xml'); if (opf.querySelector('parsererror')) throw new Error('EPUB 的 OPF 无法解析'); const opfBase = opfPath.includes('/') ? opfPath.slice(0, opfPath.lastIndexOf('/') + 1) : ''; const manifest = Array.from(opf.querySelectorAll('manifest > item, item')).map((item) => ({ id: item.getAttribute('id') || '', href: item.getAttribute('href') || '', mime: (item.getAttribute('media-type') || '').toLowerCase(), properties: (item.getAttribute('properties') || '').split(/\s+/) })).filter((item, index, all) => item.id && all.findIndex((other) => other.id === item.id) === index); manifest.forEach((item) => { item.path = resolvePath(opfBase, item.href); }); const byId = new Map(manifest.map((item) => [item.id, item])); const byPath = new Map(manifest.map((item) => [item.path, item])); const mimeFromPath = (value) => EXT_MIMES[value.slice(value.lastIndexOf('.') + 1).toLowerCase()] || ''; const pageImage = async (pagePath) => { const entry = zipEntry(zip, pagePath); if (!entry) return null; let text; try { text = await zipText(entry, 2 * 1024 * 1024); } catch (e) { return null; } const page = new DOMParser().parseFromString(text, 'text/html'); const image = page.querySelector('img[src], image[href], image[xlink\\:href]'); if (!image) return null; const href = image.getAttribute('src') || image.getAttribute('href') || image.getAttribute('xlink:href'); const base = pagePath.includes('/') ? pagePath.slice(0, pagePath.lastIndexOf('/') + 1) : ''; const imagePath = resolvePath(base, href); const known = byPath.get(imagePath); return imagePath ? { path: imagePath, mime: (known && known.mime) || mimeFromPath(imagePath) } : null; }; const coverMeta = Array.from(opf.querySelectorAll('meta')).find((item) => ( (item.getAttribute('name') || '').toLowerCase() === 'cover' )); const declared = manifest.find((item) => item.properties.includes('cover-image')) || (coverMeta && byId.get(coverMeta.getAttribute('content'))); const guideRef = Array.from(opf.querySelectorAll('guide > reference, reference')).find((item) => ( /\bcover\b/i.test(item.getAttribute('type') || '') )); const guidePath = resolvePath(opfBase, guideRef && guideRef.getAttribute('href')); let guideCandidate = guidePath && byPath.get(guidePath); if (guidePath && (!guideCandidate || !IMAGE_MIMES.has(guideCandidate.mime))) { guideCandidate = await pageImage(guidePath); } const firstSpineRef = opf.querySelector('spine > itemref, itemref'); const firstSpineItem = firstSpineRef && byId.get(firstSpineRef.getAttribute('idref')); const firstPageCandidate = firstSpineItem && await pageImage(firstSpineItem.path); const candidates = [ declared, guideCandidate, firstPageCandidate, ...manifest.filter((item) => IMAGE_MIMES.has(item.mime) && /(^|[\/_.-])(cover|title|front|book)([\/_.-]|$)/i.test(item.href)), ...manifest.filter((item) => IMAGE_MIMES.has(item.mime)) ].filter(Boolean); const seen = new Set(); for (const item of candidates) { const imagePath = item.path || resolvePath(opfBase, item.href); if (!imagePath || seen.has(imagePath) || encryptedPaths.has(imagePath)) continue; seen.add(imagePath); const entry = zipEntry(zip, imagePath); if (!entry) continue; const data = await zipBytes(entry, 12 * 1024 * 1024); if (!data || !data.length) continue; try { const image = await loadImage(data, item.mime || mimeFromPath(imagePath)); if (image.naturalWidth < 32 || image.naturalHeight < 32) continue; return imageCover(image); } catch (e) { /* try the next image */ } } const titleNode = Array.from(opf.querySelectorAll('title')).find((node) => /(^|:)title$/i.test(node.nodeName)); return titleCover((titleNode && titleNode.textContent) || fallbackTitle, authors); } window.coverBridge.onExtract(async (payload) => { const id = payload && payload.id; try { const format = String(payload && payload.format || '').toLowerCase(); const dataUrl = format === 'pdf' ? await pdfCover(payload.bytes) : await epubCover(payload.bytes, payload.title, payload.authors); window.coverBridge.complete({ id, ok: true, dataUrl }); } catch (error) { window.coverBridge.complete({ id, ok: false, error: (error && error.message) || String(error) }); } }); window.coverBridge.ready();