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>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
b8c8d24107
commit
3ccd044527
@@ -0,0 +1,521 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const utilFile = path.join(__dirname, '..', 'ui', 'util.js');
|
||||
const libFile = path.join(__dirname, '..', 'ui', 'views', 'library.js');
|
||||
const utilSrc = fs.readFileSync(utilFile, 'utf8');
|
||||
|
||||
// util.js 只做 window.X = ... 赋值,没有加载期副作用,
|
||||
// 因此可以整体求值拿到真实实现(DOM 依赖都在调用时才触发)。
|
||||
function loadUtil() {
|
||||
const win = {};
|
||||
const store = new Map();
|
||||
win.localStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v))
|
||||
};
|
||||
new Function('window', 'localStorage', 'document', utilSrc)(win, win.localStorage, undefined);
|
||||
return win;
|
||||
}
|
||||
|
||||
// style="..." 里的值会先被 HTML 解码,再交给 CSS 解析
|
||||
function htmlDecode(s) {
|
||||
return s.replace(/'/g, "'").replace(/"/g, '"')
|
||||
.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
}
|
||||
|
||||
test('escapeHtml 覆盖全部危险字符', () => {
|
||||
const { escapeHtml } = loadUtil();
|
||||
assert.strictEqual(escapeHtml(`<a href="x">&'`), '<a href="x">&'');
|
||||
assert.strictEqual(escapeHtml(null), '');
|
||||
assert.strictEqual(escapeHtml(undefined), '');
|
||||
assert.strictEqual(escapeHtml(0), '0');
|
||||
});
|
||||
|
||||
test('coverStyle 阻断 style 属性逃逸', () => {
|
||||
const { coverStyle } = loadUtil();
|
||||
const out = coverStyle('https://evil/a.jpg") onerror="alert(1)');
|
||||
assert.ok(!out.includes('"'), '裸引号泄漏: ' + out);
|
||||
assert.ok(out.includes('"'), '未做 HTML 转义: ' + out);
|
||||
});
|
||||
|
||||
test('coverStyle 阻断 CSS 串逃逸', () => {
|
||||
const { coverStyle } = loadUtil();
|
||||
// 浏览器会先 HTML 解码属性值,再按 CSS 解析,这里模拟同样的两步
|
||||
const css = htmlDecode(coverStyle("https://evil/a.jpg'); background:url('x"));
|
||||
const inner = css.replace(/^background-image:url\('/, '').replace(/'\)$/, '');
|
||||
assert.ok(!/(^|[^\\])'/.test(inner), 'CSS 单引号未转义,可提前闭合 url(): ' + css);
|
||||
assert.ok(!/(^|[^\\])\)/.test(inner), 'CSS 右括号未转义: ' + css);
|
||||
});
|
||||
|
||||
test('coverStyle 拒绝换行注入', () => {
|
||||
const { coverStyle } = loadUtil();
|
||||
assert.strictEqual(coverStyle('https://x/a.jpg\n background:red'), '');
|
||||
});
|
||||
|
||||
test('coverStyle 正常输入仍可用', () => {
|
||||
const { coverStyle } = loadUtil();
|
||||
assert.strictEqual(coverStyle(''), '');
|
||||
// 转义后浏览器实际解析到的地址才是关注点
|
||||
const remote = htmlDecode(coverStyle('https://x/a.jpg')).replace(/\\(.)/g, '$1');
|
||||
assert.strictEqual(remote, "background-image:url('https://x/a.jpg')");
|
||||
const local = htmlDecode(coverStyle('C:\\books\\c.jpg')).replace(/\\([('")])/g, '$1');
|
||||
assert.ok(local.includes('file:///C:/books/c.jpg'), local);
|
||||
});
|
||||
|
||||
test('formatDate 补零', () => {
|
||||
const { formatDate } = loadUtil();
|
||||
assert.strictEqual(formatDate(0), '');
|
||||
assert.strictEqual(formatDate(new Date(2024, 0, 5).getTime()), '2024-01-05');
|
||||
});
|
||||
|
||||
test('enabledSources 存取;损坏数据回退为 null', () => {
|
||||
const win = loadUtil();
|
||||
assert.strictEqual(win.getEnabledSources(), null);
|
||||
win.setEnabledSources(['arxiv', 'pmc']);
|
||||
assert.deepStrictEqual(win.getEnabledSources(), ['arxiv', 'pmc']);
|
||||
win.localStorage.setItem('enabledSources', '{坏json');
|
||||
assert.strictEqual(win.getEnabledSources(), null, '损坏数据应回退而不是抛错');
|
||||
});
|
||||
|
||||
test('添加本地内容支持文件、文件夹和上级目录分类选项', () => {
|
||||
const src = fs.readFileSync(libFile, 'utf8');
|
||||
assert.match(src, /name="localImportSource" value="files"/);
|
||||
assert.match(src, /name="localImportSource" value="folder"/);
|
||||
assert.match(src, /window\.api\.library\.pickLocal\(source\)/);
|
||||
assert.match(src, /value="shelf"[\s\S]+上一级目录作为书架/);
|
||||
assert.match(src, /value="tag"[\s\S]+上一级目录作为标签/);
|
||||
assert.match(src, /window\.api\.library\.importLocal\(selection\.selectionId,\s*options\)/);
|
||||
});
|
||||
|
||||
test('写进 HTML 的字段插值都过 escapeHtml', () => {
|
||||
for (const f of ['views/browse.js', 'views/library.js']) {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'ui', f), 'utf8');
|
||||
// 只看真正拼 HTML 的行(含标签),DOM 选择器之类的插值不在此列
|
||||
const bad = [];
|
||||
src.split('\n').forEach((line, i) => {
|
||||
if (!/<[a-z]/i.test(line)) return;
|
||||
for (const m of line.match(/\$\{(?!escapeHtml|coverStyle)[^}]*\}/g) || []) {
|
||||
if (/^\$\{(it|d|f|l|s|e|b)\.[a-zA-Z_]+\}$/.test(m)) bad.push(`${f}:${i + 1} ${m}`);
|
||||
}
|
||||
});
|
||||
assert.deepStrictEqual(bad, [], `存在未转义的 HTML 插值:\n${bad.join('\n')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('index.html 保留 CSP 且未开启 nodeIntegration', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||
assert.ok(/Content-Security-Policy/.test(html), '缺少 CSP');
|
||||
assert.ok(/default-src 'self'/.test(html));
|
||||
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
|
||||
assert.ok(/contextIsolation:\s*true/.test(main));
|
||||
assert.ok(/nodeIntegration:\s*false/.test(main));
|
||||
});
|
||||
|
||||
test('我的笔记页可新建关联或无关联笔记', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||
const notes = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'notes.js'), 'utf8');
|
||||
assert.match(html, /data-tab="notes">我的笔记</);
|
||||
assert.match(html, /id="addGlobalNoteBtn"/);
|
||||
assert.match(notes, /window\.api\.library\.list\(\)/);
|
||||
assert.match(notes, /source:\s*'manual'/);
|
||||
assert.match(notes, /<option value="">不关联书籍<\/option>/);
|
||||
assert.match(notes, /window\.api\.reader\.addNote\(entryId,\s*note\)/);
|
||||
assert.match(notes, /window\.api\.reader\.addStandaloneNote\(note\)/);
|
||||
assert.match(notes, /note\.associated === false\s*\?\s*'未关联书籍'/);
|
||||
});
|
||||
|
||||
test('书库支持标题作者模糊搜索、侧栏滚动和稳定封面占位卡', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||
const library = fs.readFileSync(libFile, 'utf8');
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||
assert.match(html, /id="librarySearchInput"[^>]+搜索标题或作者/);
|
||||
assert.match(html, /id="librarySearchBtn"/);
|
||||
assert.match(html, /id="libraryClearSearchBtn"/);
|
||||
assert.match(html, /id="sortSelect"[\s\S]*value="recent">最近阅读/);
|
||||
assert.match(library, /recent:\s*\(a,\s*b\)[\s\S]*lastReadAt/);
|
||||
assert.match(library, /function matchesSearch\(item, query\)/);
|
||||
assert.match(library, /item\.authors/);
|
||||
assert.match(library, /isSubsequence/);
|
||||
assert.match(library, /function reconcileCards/);
|
||||
assert.doesNotMatch(library, /grid\.innerHTML\s*=\s*items\.map/);
|
||||
const sidebarRule = css.match(/\.library-sidebar\s*\{([^}]*)\}/);
|
||||
assert.ok(sidebarRule);
|
||||
assert.match(sidebarRule[1], /max-height:\s*calc\(100vh - 84px\)/);
|
||||
assert.match(sidebarRule[1], /overflow-y:\s*auto/);
|
||||
assert.match(css, /\.card:hover \.card-cover:not\(\[data-cover-state="pending"\]\)/);
|
||||
assert.match(library, /data-cover-state="\$\{it\.cover \? 'ready' : 'pending'\}"/);
|
||||
});
|
||||
|
||||
test('书库页提供可管理标签目录和整理多选下拉', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||
const library = fs.readFileSync(libFile, 'utf8');
|
||||
assert.match(html, /id="libraryShelfList"/);
|
||||
assert.match(html, /id="libraryTagList"/);
|
||||
assert.match(html, /id="addTagBtn"/);
|
||||
assert.match(library, /window\.api\.library\.listShelves\(\)/);
|
||||
assert.match(library, /window\.api\.library\.addTag\(\{ name \}\)/);
|
||||
assert.match(library, /window\.api\.library\.updateTag\(tag\.id/);
|
||||
assert.match(library, /window\.api\.library\.removeTag\(tag\.id\)/);
|
||||
assert.match(library, /cardAction\('organize'/);
|
||||
assert.match(library, /<details id="libraryBookTags"/);
|
||||
assert.match(library, /#libraryBookTags input\[type="checkbox"\]:checked/);
|
||||
assert.doesNotMatch(library, /id="libraryBookTags" type="text"/);
|
||||
});
|
||||
|
||||
test('下载区展示进度且完成按钮使用高对比绿色底色', () => {
|
||||
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||
assert.match(browse, /createDownloadProgress/);
|
||||
assert.match(browse, /updateDownloadProgress/);
|
||||
assert.match(browse, /classList\.add\('downloaded'\)/);
|
||||
const rule = css.match(/\.dl-btn\.downloaded\s*\{([^}]*)\}/);
|
||||
assert.ok(rule, '缺少下载完成按钮样式');
|
||||
assert.match(rule[1], /background:\s*var\(--green\)/);
|
||||
assert.doesNotMatch(rule[1], /background:\s*var\(--accent\)/);
|
||||
assert.match(rule[1], /color:\s*#07130b/);
|
||||
});
|
||||
|
||||
test('主窗口在设置旁提供持久化明暗主题切换', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||
const themeAt = html.indexOf('id="uiThemeBtn"');
|
||||
const settingsAt = html.indexOf('data-tab="settings"');
|
||||
assert.ok(themeAt >= 0 && themeAt < settingsAt, '主题按钮不在设置按钮旁边');
|
||||
assert.match(app, /window\.api\.ui\.getTheme\(\)/);
|
||||
assert.match(app, /window\.api\.ui\.setTheme\(next\)/);
|
||||
assert.match(css, /:root\[data-ui-theme="light"\]/);
|
||||
assert.match(css, /--bg:\s*#f4f7fb/);
|
||||
});
|
||||
|
||||
test('人民阅读器品牌与主题图标显示在界面左上角', () => {
|
||||
for (const file of ['index.html', 'reader.html']) {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', file), 'utf8');
|
||||
assert.match(html, /<title>PeopleLib<\/title>/);
|
||||
assert.match(html, /人民阅读器/);
|
||||
assert.match(html, /brand-logo-dark[^>]+icons\/dist\/dark\/icon-32\.png/);
|
||||
assert.match(html, /brand-logo-light[^>]+icons\/dist\/light\/icon-32\.png/);
|
||||
}
|
||||
});
|
||||
|
||||
test('阅读器控件隔离正文选择并提供 PDF 适宽、拖拽和文本选择工具', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||
const pdf = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs'), 'utf8');
|
||||
const pdfWorker = fs.readFileSync(path.join(__dirname, '..', 'ui', 'vendor', 'pdf.worker.range.mjs'), 'utf8');
|
||||
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
|
||||
assert.match(html, /id="fitWidthBtn"[\s\S]*aria-label="适应内容宽度"/);
|
||||
assert.match(html, /data-annotation-tool="pan"[\s\S]*data-annotation-tool="text-select"/);
|
||||
assert.match(css, /button,[\s\S]*\.statusbar,[\s\S]*user-select:\s*none/);
|
||||
assert.match(shell, /isReaderControlTarget/);
|
||||
assert.match(shell, /fitPdfWidth/);
|
||||
assert.match(pdf, /function fitWidthScale/);
|
||||
assert.match(pdf, /className = 'endOfContent'/);
|
||||
assert.match(pdf, /const endPage = pageOfNode\(range\.endContainer\)/);
|
||||
assert.match(pdf, /pdfx-tool-pan/);
|
||||
assert.match(pdf, /extends pdfjs\.PDFDataRangeTransport/);
|
||||
assert.match(pdf, /pdf\.worker\.range\.mjs/);
|
||||
assert.match(pdf, /disableAutoFetch\s*=\s*true/);
|
||||
assert.match(pdfWorker, /super\(new Uint8Array\(0\), 0, length, null\)/);
|
||||
assert.doesNotMatch(pdfWorker, /super\(new Uint8Array\(length\), 0, length, null\)/);
|
||||
assert.match(pdfWorker, /MAX_SPARSE_PDF_CACHE_BYTES = 256 \* 1024 \* 1024/);
|
||||
assert.match(pdfWorker, /MAX_GROUPED_RANGE_CHUNKS = 4/);
|
||||
assert.match(pdfWorker, /offset = offset \* 256 \+ offsetByte/);
|
||||
assert.match(pdfWorker, /_loadedChunks\.delete\(chunk\)/);
|
||||
// 稀疏基础缓冲区是空的,字体哈希不能再直接按 stream.bytes.buffer 建视图,
|
||||
// 否则字体会静默变成不可见的 ErrorFont
|
||||
assert.match(pdfWorker, /stream\.getByteRange\(stream\.start, stream\.end\)/);
|
||||
assert.doesNotMatch(pdfWorker, /new Uint8Array\(stream\.bytes\.buffer, stream\.start, stream\.end - stream\.start\)/);
|
||||
// 256 MB 以内仍用官方 worker,只有超出才启用稀疏 worker
|
||||
assert.match(pdf, /STANDARD_WORKER_MAX_BYTES/);
|
||||
assert.match(pdf, /SPARSE_WORKER_URL/);
|
||||
assert.match(pdf, /this\.active < 8/);
|
||||
assert.match(pdf, /Promise\.race\(\[task\.promise, rangeFailurePromise\]\)/);
|
||||
assert.match(shell, /openPdfRangeSource/);
|
||||
assert.match(shell, /api\.reader\.rangeRead/);
|
||||
assert.match(preload, /rangeOpen:[\s\S]*reader:rangeOpen/);
|
||||
assert.match(preload, /rangeRead:[\s\S]*reader:rangeRead/);
|
||||
assert.match(preload, /rangeClose:[\s\S]*reader:rangeClose/);
|
||||
});
|
||||
|
||||
test('AI 助手提供受限图像上下文和可扩展 OCR 契约', () => {
|
||||
const index = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||
const reader = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||
const pdf = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs'), 'utf8');
|
||||
const epub = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'epub-adapter.mjs'), 'utf8');
|
||||
const ocr = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'ocr-provider.mjs'), 'utf8');
|
||||
const contract = fs.readFileSync(path.join(__dirname, '..', 'reader', 'visual-context.js'), 'utf8');
|
||||
const client = fs.readFileSync(path.join(__dirname, '..', 'reader', 'ai-client.js'), 'utf8');
|
||||
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
|
||||
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
|
||||
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
|
||||
assert.match(index, /id="aiProtocol"[\s\S]*value="anthropic"[\s\S]*value="openai-responses"[\s\S]*value="chat-completions"/);
|
||||
assert.match(index, /id="aiVision"[^>]*type="checkbox"/);
|
||||
assert.match(app, /protocol:\s*\$\('aiProtocol'\)\.value/);
|
||||
assert.match(reader, /value="page-image"[\s\S]*value="region-image"/);
|
||||
assert.match(reader, /id="aiVisualCard"[\s\S]*id="aiOcrBtn"[\s\S]*disabled/);
|
||||
assert.deepStrictEqual(
|
||||
[...reader.matchAll(/data-ai-task="([^"]+)"/g)].map((match) => match[1]),
|
||||
['summarize']
|
||||
);
|
||||
assert.match(shell, /function beginVisualSelection/);
|
||||
assert.match(shell, /function confirmVisualSelection/);
|
||||
assert.match(shell, /toAiVisualContext/);
|
||||
assert.match(pdf, /async function captureVisual/);
|
||||
assert.match(pdf, /function visualPageAtPoint/);
|
||||
assert.match(epub, /function visualViewportRect/);
|
||||
assert.match(ocr, /function registerOcrProvider/);
|
||||
assert.match(ocr, /function recognizeOcr/);
|
||||
assert.match(ocr, /signal:\s*options\.signal/);
|
||||
assert.match(contract, /MAX_IMAGE_BYTES\s*=\s*3\s*\*\s*1024\s*\*\s*1024/);
|
||||
assert.match(contract, /MAX_VISUAL_CONTEXTS\s*=\s*1/);
|
||||
assert.match(contract, /图像内容与声明尺寸不匹配/);
|
||||
assert.match(client, /type:\s*'image_url'/);
|
||||
assert.match(client, /type:\s*'image'[\s\S]*type:\s*'base64'[\s\S]*media_type:/);
|
||||
assert.match(client, /type:\s*'input_image'/);
|
||||
assert.match(client, /当前模型配置未启用图像输入/);
|
||||
assert.match(app, /模型已配置[\s\S]*尚缺 API Key/);
|
||||
assert.match(app, /已保存的 API Key 无法读取,请重新输入/);
|
||||
assert.match(shell, /模型已配置,但尚缺 API Key/);
|
||||
assert.match(shell, /模型已配置,但已保存的 API Key 无法读取/);
|
||||
assert.match(preload, /onChanged:\s*\(cb\)[\s\S]*ipcRenderer\.on\('ai:changed'/);
|
||||
assert.match(shell, /api\.ai\.onChanged\(\(\)\s*=>\s*refreshAiStatus\(\)\)/);
|
||||
assert.match(preload, /function captureReaderRect\(rect\)/);
|
||||
assert.match(preload, /document\.getElementById\('docArea'\)/);
|
||||
assert.match(preload, /captureRect:\s*\(rect\)\s*=>\s*captureReaderRect\(rect\)/);
|
||||
assert.match(main, /ipcMain\.handle\('reader:captureRect'/);
|
||||
assert.match(main, /截图区域无效或超出阅读器窗口/);
|
||||
});
|
||||
|
||||
test('AI 上下文提供无需选中的当前页与全文范围', () => {
|
||||
const reader = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||
const pdf = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs'), 'utf8');
|
||||
const epub = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'epub-adapter.mjs'), 'utf8');
|
||||
|
||||
assert.match(reader, /<option value="document">全文<\/option>/);
|
||||
assert.doesNotMatch(reader, /value="chapter"/);
|
||||
|
||||
// 只有 selection 需要选区,page/document 直接走 textOf
|
||||
assert.match(shell, /if \(scope === 'selection'\)[\s\S]{0,400}请先在正文中选中文本/);
|
||||
assert.match(shell, /scope === 'page' \? 'page' : 'document'/);
|
||||
|
||||
// 全文必须提示可能超限,并且始终弹确认框
|
||||
assert.match(shell, /可能超过模型限制/);
|
||||
assert.match(shell, /全文可能超过模型的上下文限制/);
|
||||
assert.match(shell, /scope !== 'document' && chars <= CONFIRM_CHARS/);
|
||||
|
||||
// 旧设置迁移,避免升级后回落成 selection
|
||||
assert.match(shell, /storedScope === 'chapter' \? 'document' : storedScope/);
|
||||
assert.match(shell, /api\.settings\.set\('reader\.aiScope', 'document'\)/);
|
||||
|
||||
// 适配器真的取整本,而不是当前页 ±1
|
||||
assert.match(pdf, /if \(span !== 'document'\) return pageText\(page\)/);
|
||||
assert.match(pdf, /for \(let i = 1; i <= pageCount; i\+\+\)/);
|
||||
assert.match(epub, /if \(span === 'document'\)[\s\S]{0,400}chapter < spine\.length/);
|
||||
});
|
||||
|
||||
test('AI 图像上下文按更小的目标体积压缩且只用 JPEG', () => {
|
||||
const visual = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'visual-context.mjs'), 'utf8');
|
||||
assert.match(visual, /MAX_CAPTURE_DIMENSION = 1600/);
|
||||
assert.match(visual, /TARGET_CAPTURE_BYTES = 400 \* 1024/);
|
||||
assert.match(visual, /const qualities = \[0\.82, 0\.74, 0\.66, 0\.58\]/);
|
||||
assert.match(visual, /bytes <= TARGET_CAPTURE_BYTES/);
|
||||
// 缩到 800px 就停手,避免文字页被压糊
|
||||
assert.match(visual, /<= 800\) break/);
|
||||
// 只保留一条 JPEG 编码路径,不做格式回退
|
||||
assert.deepStrictEqual([...visual.matchAll(/toDataURL\('([^']+)'/g)].map((m) => m[1]), ['image/jpeg']);
|
||||
});
|
||||
|
||||
test('AI 回答使用固定版本 Markdown-it 和 DOMPurify 安全渲染', () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||
const renderer = fs.readFileSync(path.join(__dirname, '..', 'ui', 'ai-markdown.js'), 'utf8');
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
|
||||
assert.strictEqual(pkg.devDependencies['markdown-it'], '15.0.0');
|
||||
assert.strictEqual(pkg.devDependencies.dompurify, '3.4.12');
|
||||
assert.match(html, /vendor\/purify\.min\.js[\s\S]*vendor\/markdown-it\.min\.js[\s\S]*ai-markdown\.js/);
|
||||
assert.match(renderer, /html:\s*false/);
|
||||
assert.match(renderer, /purifier\.sanitize/);
|
||||
assert.match(renderer, /renderer\.rules\.image/);
|
||||
assert.match(renderer, /data-external-url/);
|
||||
assert.match(renderer, /MAX_MARKDOWN_LENGTH\s*=\s*256\s*\*\s*1024/);
|
||||
assert.match(shell, /scheduleAiOutput/);
|
||||
assert.match(shell, /window\.AiMarkdown\.externalUrl/);
|
||||
assert.match(shell, /addEventListener\('auxclick'/);
|
||||
assert.match(css, /\.ai-output pre[\s\S]*overflow:\s*auto/);
|
||||
for (const file of [
|
||||
'vendor/markdown-it.min.js',
|
||||
'vendor/markdown-it.LICENSE.txt',
|
||||
'vendor/purify.min.js',
|
||||
'vendor/DOMPurify.LICENSE.txt'
|
||||
]) {
|
||||
assert.ok(fs.existsSync(path.join(__dirname, '..', 'ui', file)), `${file} 未随应用提供`);
|
||||
}
|
||||
});
|
||||
|
||||
test('安装包和可执行文件保留 PeopleLib 产品名', () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
||||
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
|
||||
const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8');
|
||||
assert.strictEqual(pkg.build.productName, 'PeopleLib');
|
||||
assert.strictEqual(pkg.build.portable.artifactName, 'PeopleLib-${version}.exe');
|
||||
assert.match(main, /app\.setName\('PeopleLib'\)/);
|
||||
assert.match(build, /const PRODUCT = pkg\.productName \|\| 'PeopleLib'/);
|
||||
});
|
||||
|
||||
test('设置关于页与 README 列出书库和内置阅读格式', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||
const readme = fs.readFileSync(path.join(__dirname, '..', '..', 'README.md'), 'utf8');
|
||||
assert.match(html, /关于 PeopleLib/);
|
||||
assert.match(html, /内置阅读[\s\S]*PDF、EPUB、MOBI、AZW、AZW3/);
|
||||
assert.match(html, /书库导入与管理[\s\S]*TXT、DJVU、FB2、CBZ、CBR/);
|
||||
assert.match(html, /Foliate[\s\S]*MOBI\/KF7\/KF8/);
|
||||
assert.match(readme, /## 支持格式/);
|
||||
assert.match(readme, /MOBI \/ AZW \/ AZW3[\s\S]*Foliate/);
|
||||
assert.match(readme, /TXT \/ DJVU \/ FB2 \/ CBZ \/ CBR/);
|
||||
});
|
||||
|
||||
test('MOBI、AZW 和 AZW3 使用固定版本 Foliate 组件进入内置阅读器', () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
||||
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||
const adapter = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'mobi-adapter.mjs'), 'utf8');
|
||||
const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8');
|
||||
assert.strictEqual(pkg.dependencies['foliate-js'], '1.0.1');
|
||||
assert.match(main, /READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3'\]\)/);
|
||||
assert.match(shell, /mobi:\s*mobi\.createMobiAdapter/);
|
||||
assert.match(shell, /azw3:\s*mobi\.createMobiAdapter/);
|
||||
assert.match(adapter, /from '\.\.\/\.\.\/\.\.\/node_modules\/foliate-js\/mobi\.js'/);
|
||||
assert.match(adapter, /该 MOBI\/AZW 图书有 DRM 保护/);
|
||||
assert.match(shell, /使用系统应用打开/);
|
||||
assert.match(build, /node_modules', 'foliate-js'/);
|
||||
});
|
||||
|
||||
test('书库卡片操作使用带悬浮提示的纯图标按钮', () => {
|
||||
const library = fs.readFileSync(libFile, 'utf8');
|
||||
assert.match(library, /const CARD_ICONS =/);
|
||||
assert.match(library, /class="\$\{primary \? 'open-btn ' : ''\}icon-action"/);
|
||||
assert.match(library, /title="\$\{label\}" aria-label="\$\{label\}"/);
|
||||
for (const action of ['read', 'open', 'reveal', 'page', 'organize', 'remove']) {
|
||||
assert.match(library, new RegExp(`cardAction\\('${action}'`));
|
||||
}
|
||||
});
|
||||
|
||||
test('读书与画布笔记分型创建、分类展示并支持受管 PDF 底版', () => {
|
||||
const notes = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'notes.js'), 'utf8');
|
||||
const rich = fs.readFileSync(path.join(__dirname, '..', 'ui', 'rich-note.js'), 'utf8');
|
||||
const mixed = fs.readFileSync(path.join(__dirname, '..', 'ui', 'mixed-note.js'), 'utf8');
|
||||
const canvas = fs.readFileSync(path.join(__dirname, '..', 'ui', 'canvas-note.mjs'), 'utf8');
|
||||
const canvasFlow = fs.readFileSync(path.join(__dirname, '..', 'ui', 'canvas-flow.mjs'), 'utf8');
|
||||
const richCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'rich-note.css'), 'utf8');
|
||||
const appCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||
const readerCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
|
||||
const readerHtml = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||
const indexHtml = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||
const store = fs.readFileSync(path.join(__dirname, '..', 'reader', 'store.js'), 'utf8');
|
||||
const assets = fs.readFileSync(path.join(__dirname, '..', 'reader', 'note-assets.js'), 'utf8');
|
||||
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
|
||||
assert.match(notes, /id="newNoteRich"/);
|
||||
assert.match(notes, /id="noteEditRich"/);
|
||||
assert.match(notes, /window\.MixedNote\.mount/);
|
||||
assert.match(notes, /选择笔记类型/);
|
||||
assert.match(notes, /noteType/);
|
||||
assert.match(readerHtml, /id="noteRichEditor"/);
|
||||
assert.match(indexHtml, /vendor\/quill\/quill\.js/);
|
||||
assert.match(indexHtml, /vendor\/quill\/quill\.snow\.css/);
|
||||
assert.match(readerHtml, /vendor\/jspdf\.umd\.min\.js/);
|
||||
assert.match(readerHtml, /<script src="rich-note\.js"><\/script>/);
|
||||
assert.match(rich, /new window\.Quill/);
|
||||
assert.match(rich, /version:\s*2,\s*ops/);
|
||||
assert.doesNotMatch(rich, /document\.execCommand/);
|
||||
assert.ok(
|
||||
rich.indexOf("header.className = 'ql-header'") < rich.indexOf("['bold', '加粗']"),
|
||||
'段落类型必须位于 B/I 等格式按钮之前'
|
||||
);
|
||||
assert.match(richCss, /\.ql-toolbar \.ql-picker-options/);
|
||||
assert.match(richCss, /background:\s*var\(--bg-card\)/);
|
||||
assert.match(richCss, /color:\s*var\(--text\)/);
|
||||
assert.match(rich, /image\/jpeg,image\/png,image\/gif,image\/webp/);
|
||||
assert.match(rich, /单张图片不能超过 2 MB/);
|
||||
assert.match(mixed, /function mountTyped/);
|
||||
assert.match(mixed, /options\.noteType/);
|
||||
assert.match(indexHtml, /id="notesTypeTabs"/);
|
||||
assert.match(indexHtml, />全部</);
|
||||
assert.match(indexHtml, />画布笔记</);
|
||||
assert.match(indexHtml, />读书笔记</);
|
||||
assert.match(appCss, /\.notes-list[\s\S]*grid-template-columns/);
|
||||
assert.match(canvas, /Import PDF|导入 PDF/);
|
||||
assert.match(canvas, /Export PDF|导出 PDF/);
|
||||
assert.match(canvas, /canvasKind/);
|
||||
assert.match(canvas, /MAX_PAGES = 50/);
|
||||
assert.match(canvas, /const BUTTON_ICONS =/);
|
||||
assert.match(canvas, /canvas-note-icon/);
|
||||
assert.match(canvas, /canvas-note-tool-group/);
|
||||
assert.match(canvas, /\['flow-text', '全局文本'\]/);
|
||||
assert.match(canvas, /mountFlowText/);
|
||||
assert.match(canvasFlow, /canvasPageBreak/);
|
||||
assert.match(canvasFlow, /columnWidth/);
|
||||
assert.match(canvasFlow, /onPageCount/);
|
||||
assert.match(canvasFlow, /renderPage/);
|
||||
assert.match(canvasFlow, /suppressUserFollowSelection/);
|
||||
assert.match(canvas, /await flowEditor\.flush\(\)/);
|
||||
assert.match(canvas, /insertedPageId/);
|
||||
assert.match(richCss, /\.canvas-flow-toolbar/);
|
||||
assert.match(richCss, /\.canvas-flow-layer/);
|
||||
const toolbarRule = richCss.match(/\.canvas-note-toolbar\s*\{([^}]*)\}/);
|
||||
const viewportRule = richCss.match(/\.canvas-note-viewport\s*\{([^}]*)\}/);
|
||||
const mainCanvasBodyRule = appCss.match(/\.canvas-note-modal \.modal-body\s*\{([^}]*)\}/);
|
||||
const readerCanvasFieldsRule = readerCss.match(
|
||||
/\.canvas-note-modal \.note-editor-fields\s*\{([^}]*)\}/
|
||||
);
|
||||
assert.ok(toolbarRule && viewportRule && mainCanvasBodyRule && readerCanvasFieldsRule);
|
||||
assert.match(toolbarRule[1], /flex-wrap:\s*wrap/);
|
||||
assert.match(toolbarRule[1], /overflow:\s*visible/);
|
||||
assert.match(viewportRule[1], /overflow:\s*auto/);
|
||||
assert.match(mainCanvasBodyRule[1], /overflow:\s*hidden/);
|
||||
assert.match(readerCanvasFieldsRule[1], /overflow:\s*hidden/);
|
||||
assert.match(store, /richImageTotalBytes:\s*8 \* 1024 \* 1024/);
|
||||
assert.match(store, /normalizeCanvasContent/);
|
||||
assert.match(store, /const VERSION = 6/);
|
||||
assert.match(store, /normalizeCanvasFlow/);
|
||||
assert.match(store, /NOTE_TYPES/);
|
||||
assert.match(assets, /reader-note-assets/);
|
||||
assert.match(assets, /senderId/);
|
||||
assert.doesNotMatch(notes, /id="newNoteText"|id="noteEditText"/);
|
||||
const functionAt = browse.indexOf('async function downloadFile');
|
||||
const awaitAt = browse.indexOf('await window.api.library.findBySource', functionAt);
|
||||
const snapshotAt = browse.indexOf('const meta = entryMeta()', functionAt);
|
||||
assert.ok(snapshotAt > functionAt && snapshotAt < awaitAt, '下载元数据未在首次 await 前快照');
|
||||
});
|
||||
|
||||
test('书架操作对键盘焦点可见', () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||
assert.match(css, /\.library-shelf-row:focus-within \.library-shelf-actions/);
|
||||
assert.doesNotMatch(css, /\.library-shelf-actions\s*\{\s*display:\s*none/);
|
||||
});
|
||||
|
||||
test('书库长标题保持单行省略并提供完整悬浮提示', () => {
|
||||
const library = fs.readFileSync(libFile, 'utf8');
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||
const titleRule = css.match(/\.card-title\s*\{([^}]*)\}/);
|
||||
assert.ok(titleRule, '缺少书库标题样式');
|
||||
assert.match(titleRule[1], /white-space:\s*nowrap/);
|
||||
assert.match(titleRule[1], /overflow:\s*hidden/);
|
||||
assert.match(titleRule[1], /text-overflow:\s*ellipsis/);
|
||||
assert.match(library, /class="card-title" title="\$\{escapeHtml\(it\.title\)\}"/);
|
||||
});
|
||||
|
||||
test('可阅读图书封面支持鼠标与键盘打开内置阅读器', () => {
|
||||
const library = fs.readFileSync(libFile, 'utf8');
|
||||
assert.match(library, /data-act="read" role="button" tabindex="0"/);
|
||||
assert.match(library, /cover\.onclick[\s\S]*onAction\(id, 'read'\)/);
|
||||
assert.match(library, /event\.key !== 'Enter' && event\.key !== ' '/);
|
||||
assert.match(library, /window\.api\.reader\.open\(id, idx >= 0 \? idx : undefined\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user