Files
peoplelib/src/ui/cover-renderer.mjs
T
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 3ccd044527 feat: 内置阅读器、批注笔记与 AI 助手,发布 1.3.0
新增 PDF/EPUB/MOBI/AZW 内置阅读器,PDF 分段读取支持超大文件,
批注、读书与画布笔记、封面生成与本地导入。AI 助手支持三种协议、
图像上下文与安全 Markdown 渲染,上下文范围改为 选中/当前页/全文,
页面与全文无需选中文本即可发送,全文会提示可能超出模型限制。

便携版输出目录固定为 PeopleLib-windows-x64,不再随版本号变化,
避免升级后 data/ 被遗留在旧目录。

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-03 12:13:02 +08:00

296 lines
12 KiB
JavaScript

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