feat: 笔记独立窗口改为多标签,AI 多轮会话与 TXT/MD 阅读

笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致:
标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增,
否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并
LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与
关窗都做二次确认,取消关闭必须回报主进程复位 closePending,
否则窗口再也关不掉而看门狗仍会销毁未保存内容。

AI 助手支持多轮会话:会话独立落盘,先取历史再写提问,
历史只发文本不重发图像,失败与取消都保留已流出的残片。

新增 TXT/MD 内置阅读(转内存 EPUB 复用 epub 渲染管线),
补上渲染层遗漏的可阅读格式白名单:主进程本就放行 txt/md,
但渲染层另有两份白名单漏了,表现为卡片上没有「阅读」按钮。

书库卡片封面改用 contain 完整显示,留白由同图模糊层垫底,
修正不同比例封面被裁切程度不一导致的观感不一致;多选复选框
去掉衬底色块,恢复原生外观。

其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、
GitHub Pages 站点。
This commit is contained in:
lofyer
2026-08-04 16:19:06 +08:00
parent 522b0f74a5
commit 0fd7c59e08
68 changed files with 11721 additions and 301 deletions
@@ -11,6 +11,8 @@ const EPUB_FILE = path.join(TMP, 'reader-features.epub');
const MOBI_FILE = path.join(TMP, 'reader-features.mobi');
const DRM_MOBI_FILE = path.join(TMP, 'reader-features-drm.azw');
const LARGE_EPUB_FILE = path.join(TMP, 'reader-features-large.epub');
const TXT_FILE = path.join(TMP, 'reader-features.txt');
const MD_FILE = path.join(TMP, 'reader-features.md');
app.setPath('userData', TMP);
app.setPath('appData', TMP);
@@ -194,6 +196,58 @@ function makeMobi(file) {
fs.writeFileSync(file, output);
}
function makeTxt(file) {
const lines = ['纯文本夹具标题', ''];
for (let i = 1; i <= 3; i++) {
lines.push(`${i}章 编码与分章`, '');
for (let k = 0; k < 12; k++) {
lines.push(`${i}章第${k + 1}段:TXT-MARK-${i}-${k} 中文正文用于校验解码与全文提取。`);
}
lines.push('');
}
const text = lines.join('\r\n');
// 带 BOM 的 UTF-16LE 是 Windows 记事本另存的默认之一,编码探测必须在真实浏览器里也成立
const body = Buffer.from(text, 'utf16le');
fs.writeFileSync(file, Buffer.concat([Buffer.from([0xff, 0xfe]), body]));
return text;
}
function makeMd(file) {
const source = [
'# Markdown 夹具',
'',
'正文段落包含 **加粗** 与 `行内代码`,用于确认渲染而不是纯文本显示。',
'',
'## 危险内容小节',
'',
'<script>window.__mdScriptExecuted = true</script>',
'',
'<img src="x" onerror="window.__mdHandlerExecuted = true">',
'',
'[不安全链接](javascript:window.__mdLinkExecuted=true)',
'',
'<iframe src="https://untrusted.example/frame"></iframe>',
'',
'## 结构小节',
'',
'- 列表项一',
'- 列表项二',
'',
'```js',
'const fenced = "code block";',
'```',
'',
'| 列一 | 列二 |',
'|---|---|',
'| 单元格 | MD-TABLE-CELL |',
'',
'> 引用块 MD-QUOTE-MARK',
''
].join('\n');
fs.writeFileSync(file, source, 'utf8');
return source;
}
async function js(win, source) {
try {
return await win.webContents.executeJavaScript(source);
@@ -515,6 +569,8 @@ app.whenReady().then(async () => {
fs.copyFileSync(MOBI_FILE, DRM_MOBI_FILE);
fs.writeFileSync(LARGE_EPUB_FILE, Buffer.alloc(0));
fs.truncateSync(LARGE_EPUB_FILE, 256 * 1024 * 1024 + 1);
makeTxt(TXT_FILE);
makeMd(MD_FILE);
const drmFixture = fs.readFileSync(DRM_MOBI_FILE);
drmFixture.writeUInt16BE(1, 96 + 12);
fs.writeFileSync(DRM_MOBI_FILE, drmFixture);
@@ -549,7 +605,9 @@ app.whenReady().then(async () => {
{ path: EPUB_FILE, name: 'reader-features.epub', format: 'EPUB' },
{ path: MOBI_FILE, name: 'reader-features.mobi', format: 'MOBI' },
{ path: DRM_MOBI_FILE, name: 'reader-features-drm.azw', format: 'AZW' },
{ path: LARGE_EPUB_FILE, name: 'reader-features-large.epub', format: 'EPUB' }
{ path: LARGE_EPUB_FILE, name: 'reader-features-large.epub', format: 'EPUB' },
{ path: TXT_FILE, name: 'reader-features.txt', format: 'TXT' },
{ path: MD_FILE, name: 'reader-features.md', format: 'MD' }
]
});
await wait(1200);
@@ -671,6 +729,95 @@ app.whenReady().then(async () => {
pdfWin.setSize(1280, 900);
await waitForJs(pdfWin, `document.querySelector('.pdfx-pages')
.classList.contains('pdfx-layout-single')`);
// 前面的分页导航把视口停在第 3 页,离屏页会被回收成空白占位,
// 必须先回到第 1 页再测画质,否则量到的是占位画布而不是真实渲染结果
await js(pdfWin, `(() => {
const range = document.getElementById('progressRange');
range.value = '0';
range.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 1 页'`);
const canvasProbe = `(() => {
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
const css = canvas.getBoundingClientRect().width;
return {
dpr: window.devicePixelRatio,
backing: canvas.width,
css: Math.round(css),
ratio: canvas.width / css
};
})()`;
await waitForJs(pdfWin, `(() => {
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
return canvas && canvas.width > 0;
})()`);
check('标准画质下第 1 页有真实墨迹',
imageHasInk(await js(pdfWin,
`document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas').toDataURL('image/png')`)));
const qualityBase = await js(pdfWin, canvasProbe);
// 生效倍率是 max(dpr, quality)HiDPI 屏上不叠乘,所以不能断言"backing 翻倍"
check('默认画质下 backing 比例等于设备像素比',
Math.abs(qualityBase.ratio - qualityBase.dpr) < 0.05,
`dpr=${qualityBase.dpr} ratio=${qualityBase.ratio.toFixed(3)}`);
const targetQuality = Math.min(3, Math.ceil(qualityBase.dpr + 1));
await js(pdfWin, `(() => {
const select = document.getElementById('pdfRenderQuality');
select.value = '${targetQuality}';
select.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `(() => {
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
if (!canvas || !canvas.width) return false;
return Math.abs(canvas.width / canvas.getBoundingClientRect().width - ${targetQuality}) < 0.05;
})()`);
const qualityHigh = await js(pdfWin, canvasProbe);
// 超采样只能放大 backing store,CSS 盒子必须原地不动,否则是把页面放大而不是提高画质
check('PDF 画质档提高 backing 分辨率且不改变版面尺寸',
Math.abs(qualityHigh.ratio - targetQuality) < 0.05
&& qualityHigh.backing > qualityBase.backing
&& Math.abs(qualityHigh.css - qualityBase.css) <= 1,
`ratio ${qualityBase.ratio.toFixed(3)}->${qualityHigh.ratio.toFixed(3)} `
+ `backing ${qualityBase.backing}->${qualityHigh.backing} css ${qualityBase.css}->${qualityHigh.css}`);
const qualityRatios = await js(pdfWin, `Array.from(document.querySelectorAll('.pdfx-canvas'))
.filter((canvas) => canvas.width > 0)
.map((canvas) => Math.round(canvas.width / canvas.getBoundingClientRect().width * 100) / 100)`);
// 只改新渲染的页会让同屏出现清晰度不一致,倍率变化必须整篇重建
check('画质切换后同屏各页倍率一致',
qualityRatios.length > 0 && new Set(qualityRatios).size === 1,
JSON.stringify(qualityRatios));
// 画布尺寸在重建时立即变大,墨迹要等这一页重绘完才落上去,只等比例会量到空白中间态
let highInk = false;
try {
await waitUntil(async () => {
highInk = imageHasInk(await js(pdfWin,
`document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas').toDataURL('image/png')`));
return highInk;
}, 15000, 250);
} catch (error) { /* 交给下面的断言报告 */ }
check('提高画质后仍渲染出真实墨迹而不是空白画布', highInk);
await waitUntil(() => Promise.resolve(
settings.get('reader.pdfRenderQuality', 0) === targetQuality
));
check('PDF 画质偏好已持久化', true);
await js(pdfWin, `(() => {
const select = document.getElementById('pdfRenderQuality');
select.value = '1';
select.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `(() => {
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
if (!canvas || !canvas.width) return false;
return Math.abs(canvas.width / canvas.getBoundingClientRect().width - ${qualityBase.dpr}) < 0.05;
})()`);
check('画质调回标准档后恢复设备像素比', true);
// 画质检查需要停在第 1 页,后续用例仍按第 3 页断言,这里把视口还回去
await js(pdfWin, `(() => {
const range = document.getElementById('progressRange');
range.value = '1000';
range.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 3 页'`);
try {
await waitForJs(pdfWin, `Array.from(document.querySelectorAll('.pdfx-text span'))
.some((node) => node.firstChild && node.firstChild.data.trim())`);
@@ -1356,6 +1503,181 @@ app.whenReady().then(async () => {
drmReader.errors.slice(0, 3).join(' | '));
drmReader.win.close();
const txtReader = await openReader(entry.id, 5);
const txtWin = txtReader.win;
await waitForJs(txtWin, `!document.querySelector('.doc-overlay')
&& !!document.querySelector('.host-epub iframe')?.contentDocument?.body?.textContent.trim()`, 30000);
check('TXT 通过内置阅读器打开而不是回退到外部程序',
await js(txtWin, `!document.querySelector('.doc-overlay.err')
&& !!document.querySelector('.host-epub iframe')`));
const txtToc = await js(txtWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.map((button) => button.textContent)`);
check('TXT 按章节标题切分并生成可跳转目录',
txtToc.length >= 3 && txtToc.some((label) => /第1章/.test(label)),
JSON.stringify(txtToc.slice(0, 5)));
await js(txtWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.find((button) => /第1章/.test(button.textContent)).click()`);
await waitForJs(txtWin, `document.querySelector('.host-epub iframe').contentDocument.body
.textContent.includes('TXT-MARK-1-0')`, 20000);
const txtDecoded = await js(txtWin, `(() => {
const text = document.querySelector('.host-epub iframe').contentDocument.body.textContent;
return {
chinese: text.includes('第1章第1段'),
replacement: text.includes('\\ufffd'),
nul: text.includes('\\u0000')
};
})()`);
// 带 BOM 的 UTF-16LE 是记事本另存的默认之一,探测错会整本变乱码且用户无从修正
check('带 BOM 的 UTF-16LE 纯文本正确解码为中文而不是乱码',
txtDecoded.chinese && !txtDecoded.replacement && !txtDecoded.nul,
JSON.stringify(txtDecoded));
// AI 的"全文"范围依赖 textOf(locator,'document'),缺章会让模型答非所问且用户无从察觉
const txtFullText = await js(txtWin, `(async () => {
const module = await import('./reader/text-adapter.mjs');
const adapter = module.createTextAdapter('txt');
try {
const bytes = await window.api.reader.bytes(${JSON.stringify(entry.id)}, 5);
await adapter.load(bytes.data, {});
const host = document.createElement('div');
document.body.appendChild(host);
await adapter.renderTo(host, null, { fontSize: 16, theme: 'light', lineHeight: 1.7 });
const full = await adapter.textOf(null, 'document');
const page = await adapter.textOf(null, 'page');
host.remove();
return {
marks: (full.match(/TXT-MARK-\\d+-\\d+/g) || []).length,
pageMarks: (page.match(/TXT-MARK-\\d+-\\d+/g) || []).length,
length: full.length
};
} finally {
adapter.destroy();
}
})()`);
check('TXT 全文范围覆盖所有章节而不是只有当前章',
txtFullText.marks === 36 && txtFullText.pageMarks < txtFullText.marks,
JSON.stringify(txtFullText));
check('TXT 阅读器没有控制台错误', txtReader.errors.length === 0,
txtReader.errors.slice(0, 3).join(' | '));
txtWin.close();
// 上面几条都是直接调 IPC 打开的,绕过了书库界面。
// 渲染层自己那份可阅读格式白名单漏掉 txt/md 时,IPC 照样能开,
// 但卡片上根本不会出现「阅读」按钮,用户看到的就是"内置阅读器打不开 txt"
{
const mainWin = BrowserWindow.getAllWindows()
.find((w) => !w.isDestroyed() && String(w.webContents.getURL()).includes('index.html'));
check('存在主窗口用于校验书库入口', !!mainWin);
if (mainWin) {
mainWin.show();
await js(mainWin, `document.querySelector('.tab[data-tab="library"]').click()`);
await waitForJs(mainWin,
`document.querySelectorAll('#libGrid > .card').length > 0`, 20000);
const cardEntry = await js(mainWin, `(() => {
const card = document.querySelector('#libGrid .card[data-id="${entry.id}"]');
if (!card) return { missing: true };
return {
coverReadable: !!card.querySelector('.card-cover.readable'),
coverActs: !!card.querySelector('.card-cover[data-act="read"]'),
hasRead: [...card.querySelectorAll('.lib-card-actions button')]
.some((b) => /阅读/.test(b.title || ''))
};
})()`);
check('书库卡片提供内置阅读入口', cardEntry.hasRead && cardEntry.coverReadable
&& cardEntry.coverActs, JSON.stringify(cardEntry));
// 只含 txt 的条目也要能读:白名单漏项时这条会失败
const txtOnly = library.add({
title: 'TXT Only Fixture',
files: [{ path: TXT_FILE, name: 'txt-only.txt', format: 'TXT' }]
});
await js(mainWin, `document.getElementById('rescanBtn').click()`);
await waitForJs(mainWin,
`!!document.querySelector('#libGrid .card[data-id="${txtOnly.id}"]')`, 20000);
const txtCard = await js(mainWin, `(() => {
const card = document.querySelector('#libGrid .card[data-id="${txtOnly.id}"]');
if (!card) return { missing: true };
return {
coverReadable: !!card.querySelector('.card-cover.readable'),
hasRead: [...card.querySelectorAll('.lib-card-actions button')]
.some((b) => /阅读/.test(b.title || ''))
};
})()`);
check('纯 TXT 条目在书库里也有阅读入口',
txtCard.hasRead && txtCard.coverReadable, JSON.stringify(txtCard));
library.remove(txtOnly.id);
mainWin.hide();
}
}
const mdReader = await openReader(entry.id, 6);
const mdWin = mdReader.win;
await waitForJs(mdWin, `document.querySelector('.host-epub iframe')?.contentDocument?.body
?.textContent.includes('Markdown 夹具')`, 30000);
const mdHeadStructure = await js(mdWin, `(() => {
const doc = document.querySelector('.host-epub iframe').contentDocument;
return {
h1: doc.querySelectorAll('h1').length,
strong: doc.querySelectorAll('strong').length,
code: doc.querySelectorAll('code').length
};
})()`);
check('Markdown 首节渲染成标题与行内标记而不是纯文本',
mdHeadStructure.h1 >= 1 && mdHeadStructure.strong >= 1 && mdHeadStructure.code >= 1,
JSON.stringify(mdHeadStructure));
const mdToc = await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.map((button) => button.textContent)`);
check('Markdown 按标题层级生成目录',
mdToc.length >= 3 && mdToc.some((label) => /结构小节/.test(label)),
JSON.stringify(mdToc));
await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.find((button) => /结构小节/.test(button.textContent)).click()`);
await waitForJs(mdWin, `document.querySelector('.host-epub iframe').contentDocument
.body.textContent.includes('MD-TABLE-CELL')`, 20000);
const mdStructure = await js(mdWin, `(() => {
const doc = document.querySelector('.host-epub iframe').contentDocument;
return {
h2: doc.querySelectorAll('h2').length,
li: doc.querySelectorAll('li').length,
pre: doc.querySelectorAll('pre').length,
table: doc.querySelectorAll('table td, table th').length,
quote: doc.querySelectorAll('blockquote').length
};
})()`);
check('Markdown 列表、代码块、表格与引用渲染成真实块级结构',
mdStructure.h2 >= 1 && mdStructure.li >= 2 && mdStructure.pre >= 1
&& mdStructure.table >= 1 && mdStructure.quote >= 1,
JSON.stringify(mdStructure));
await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.find((button) => /危险内容小节/.test(button.textContent)).click()`);
await wait(1200);
// 裸 HTML 被 markdown-it 转义成文本,所以只能按 DOM 断言,
// 用 innerHTML 匹配 "onerror" 会把转义后的字面量误判成漏网
const mdSafety = await js(mdWin, `(() => {
const frame = document.querySelector('.host-epub iframe');
const doc = frame.contentDocument;
const nodes = Array.from(doc.querySelectorAll('*'));
return {
script: doc.querySelectorAll('script').length,
iframe: doc.querySelectorAll('iframe').length,
img: doc.querySelectorAll('img').length,
eventAttrs: nodes.filter((node) => Array.from(node.attributes || [])
.some((attr) => /^on/i.test(attr.name))).length,
unsafeHref: Array.from(doc.querySelectorAll('a[href]'))
.filter((a) => /^(javascript|vbscript|data|file):/i.test(a.getAttribute('href') || '')).length,
executedScript: !!(frame.contentWindow.__mdScriptExecuted || window.__mdScriptExecuted),
executedHandler: !!(frame.contentWindow.__mdHandlerExecuted || window.__mdHandlerExecuted),
executedLink: !!(frame.contentWindow.__mdLinkExecuted || window.__mdLinkExecuted)
};
})()`);
check('Markdown 中的脚本、事件属性与 javascript: 链接被净化且未执行',
mdSafety.script === 0 && mdSafety.iframe === 0 && mdSafety.img === 0
&& mdSafety.eventAttrs === 0 && mdSafety.unsafeHref === 0
&& !mdSafety.executedScript && !mdSafety.executedHandler && !mdSafety.executedLink,
JSON.stringify(mdSafety));
check('Markdown 阅读器没有控制台错误', mdReader.errors.length === 0,
mdReader.errors.slice(0, 3).join(' | '));
mdWin.close();
const readerFile = path.join(TMP, 'reader.json');
const readerJson = JSON.parse(fs.readFileSync(readerFile, 'utf8'));
check('readerStore 使用隔离目录中的 v6 存储',