笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致: 标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增, 否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并 LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与 关窗都做二次确认,取消关闭必须回报主进程复位 closePending, 否则窗口再也关不掉而看门狗仍会销毁未保存内容。 AI 助手支持多轮会话:会话独立落盘,先取历史再写提问, 历史只发文本不重发图像,失败与取消都保留已流出的残片。 新增 TXT/MD 内置阅读(转内存 EPUB 复用 epub 渲染管线), 补上渲染层遗漏的可阅读格式白名单:主进程本就放行 txt/md, 但渲染层另有两份白名单漏了,表现为卡片上没有「阅读」按钮。 书库卡片封面改用 contain 完整显示,留白由同图模糊层垫底, 修正不同比例封面被裁切程度不一导致的观感不一致;多选复选框 去掉衬底色块,恢复原生外观。 其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、 GitHub Pages 站点。
270 lines
13 KiB
JavaScript
270 lines
13 KiB
JavaScript
const test = require('node:test');
|
||
const assert = require('node:assert');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const adapterFile = path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs');
|
||
const src = fs.readFileSync(adapterFile, 'utf8');
|
||
|
||
// pdf-adapter.mjs 直接 import vendor 的 pdf.min.mjs,在 Node 里加载会因缺 DOMMatrix 而抛错,
|
||
// 所以只把不依赖 DOM 的钳制段落切出来求值,断言的仍是生产源码本身。
|
||
function loadPureBlock() {
|
||
const start = src.indexOf('const MAX_CANVAS_SIDE');
|
||
const end = src.indexOf('const CSS = `');
|
||
assert.ok(start >= 0 && end > start, '找不到画布上限段落,钳制代码可能被移动或删除');
|
||
const block = src.slice(start, end).replace(/^export /gm, '');
|
||
assert.doesNotMatch(
|
||
block,
|
||
/\b(window|document|navigator)\b/,
|
||
'钳制逻辑必须是纯函数,一旦依赖 DOM 就无法在单测里做真实数值断言'
|
||
);
|
||
return new Function(`${block}\nreturn { clampCanvasSize, clampRenderQuality };`)();
|
||
}
|
||
|
||
const { clampCanvasSize, clampRenderQuality } = loadPureBlock();
|
||
|
||
const MAX_SIDE = 16384;
|
||
const MAX_AREA = 268435456;
|
||
|
||
function assertWithinLimits(out, label) {
|
||
assert.ok(
|
||
out.width <= MAX_SIDE && out.height <= MAX_SIDE,
|
||
`${label} 单边 ${out.width}x${out.height} 超过 ${MAX_SIDE},浏览器会静默给出不可用画布,整页空白且不报错`
|
||
);
|
||
assert.ok(
|
||
out.width * out.height <= MAX_AREA,
|
||
`${label} 面积 ${out.width * out.height} 超过 ${MAX_AREA},同样会静默失败`
|
||
);
|
||
assert.ok(
|
||
out.width >= 1 && out.height >= 1,
|
||
`${label} 钳制后出现 ${out.width}x${out.height},0 尺寸画布会让 getContext 之后的绘制全部丢弃`
|
||
);
|
||
}
|
||
|
||
test('A4 常规缩放与超采样不触发钳制', () => {
|
||
// 612x792pt 的 A4 @ scale1.2,倍率 2:1468x1900,远在上限内
|
||
const out = clampCanvasSize(612 * 1.2, 792 * 1.2, 2);
|
||
assert.strictEqual(out.clamped, false, '常规页被误钳会白白牺牲清晰度');
|
||
assert.strictEqual(out.quality, 2, '未触发上限时必须原样保留名义倍率');
|
||
assert.strictEqual(out.width, 1468, `期望 1468 实际 ${out.width}`);
|
||
assert.strictEqual(out.height, 1900, `期望 1900 实际 ${out.height}`);
|
||
assertWithinLimits(out, 'A4@1.2x2');
|
||
});
|
||
|
||
test('A4 最高缩放叠加 dpr3 仍不触发钳制', () => {
|
||
// 实测 8929x12628(113M)是可用的,钳制不该把这一档也压下去
|
||
const out = clampCanvasSize(612 * 5, 792 * 5, 3);
|
||
assert.strictEqual(out.clamped, false, '这一档实测可用,钳制过度会让高缩放反而更糊');
|
||
assert.strictEqual(out.width, 9180, `期望 9180 实际 ${out.width}`);
|
||
assert.strictEqual(out.height, 11880, `期望 11880 实际 ${out.height}`);
|
||
assertWithinLimits(out, 'A4@5x3');
|
||
});
|
||
|
||
test('2000pt 大幅面 @ scale5 dpr2 被钳回上限内', () => {
|
||
// 期望 backing 20000x14000(280M),实测不可用
|
||
const out = clampCanvasSize(2000 * 5, 1400 * 5, 2);
|
||
assert.strictEqual(out.clamped, true, '280M 画布必须被识别为超限,否则用户只看到空白页且没有任何提示');
|
||
assertWithinLimits(out, '2000pt@5x2');
|
||
assert.strictEqual(out.width, MAX_SIDE, `长边应正好压到上限以保留最大清晰度,实际 ${out.width}`);
|
||
assert.ok(out.quality < 2, `生效倍率 ${out.quality} 应低于名义倍率 2`);
|
||
});
|
||
|
||
test('4000pt 图纸 @ scale3 dpr2 被钳回上限内', () => {
|
||
// 期望 backing 24000x18000(432M),实测不可用
|
||
const out = clampCanvasSize(4000 * 3, 3000 * 3, 2);
|
||
assert.strictEqual(out.clamped, true, '432M 画布必须被识别为超限');
|
||
assertWithinLimits(out, '4000pt@3x2');
|
||
assert.strictEqual(out.width, MAX_SIDE, `长边应正好压到上限,实际 ${out.width}`);
|
||
assert.strictEqual(out.height, 12288, `期望 12288 实际 ${out.height}`);
|
||
});
|
||
|
||
test('钳制后的实际比例如实报告,供 render 的 transform 使用', () => {
|
||
const cssWidth = 4000 * 3;
|
||
const cssHeight = 3000 * 3;
|
||
const out = clampCanvasSize(cssWidth, cssHeight, 2);
|
||
// transform 用名义倍率而不是取整后的实际比例,画面会错位或只画出一角
|
||
assert.strictEqual(out.scaleX, out.width / cssWidth, 'scaleX 必须等于 backing 宽除以 CSS 宽');
|
||
assert.strictEqual(out.scaleY, out.height / cssHeight, 'scaleY 必须等于 backing 高除以 CSS 高');
|
||
assert.ok(
|
||
Math.abs(out.scaleX - out.scaleY) < 1e-6,
|
||
`等比钳制下 x/y 比例应基本一致,实际 ${out.scaleX} / ${out.scaleY}`
|
||
);
|
||
});
|
||
|
||
test('短边的取整损失也要反映到比例上,不能直接沿用生效倍率', () => {
|
||
// 长边正好压到上限时它的比例恰等于生效倍率,只有短边能暴露 floor 带来的偏差
|
||
const out = clampCanvasSize(16000, 17000, 1);
|
||
assert.strictEqual(out.scaleX, out.width / 16000, 'scaleX 必须按 floor 之后的实际宽算');
|
||
assert.notStrictEqual(
|
||
out.scaleX,
|
||
out.quality,
|
||
`短边 ${out.width}px 的实际比例应当略小于生效倍率 ${out.quality},直接沿用会让内容画出画布边界`
|
||
);
|
||
assert.strictEqual(out.scaleY, out.height / 17000, 'scaleY 必须按 floor 之后的实际高算');
|
||
});
|
||
|
||
test('取整误差不会让某一边掉到 0 像素', () => {
|
||
const out = clampCanvasSize(100000, 1, 1);
|
||
assertWithinLimits(out, '极端长条页');
|
||
assert.strictEqual(out.height, 1, `期望至少 1px,实际 ${out.height}`);
|
||
assert.strictEqual(out.scaleY, 1, 'scaleY 必须按补足到 1px 后的实际比例报告,否则内容会被压到画布外');
|
||
});
|
||
|
||
test('长边超限而面积未超限的页也被钳', () => {
|
||
// 20000x2000 = 40M,面积远未超限,只有单边超限
|
||
const out = clampCanvasSize(20000, 2000, 1);
|
||
assert.strictEqual(out.clamped, true, '只超单边同样不可用,不能只看面积');
|
||
assertWithinLimits(out, '超长单边');
|
||
assert.strictEqual(out.width, MAX_SIDE, `期望 ${MAX_SIDE} 实际 ${out.width}`);
|
||
});
|
||
|
||
test('接近正方形的超大页同时压住面积与单边', () => {
|
||
// 16000x17000 = 272M,面积超限且短边已经贴着上限,是最容易只压一头的形状
|
||
const out = clampCanvasSize(16000, 17000, 1);
|
||
assert.strictEqual(out.clamped, true, '272M 面积必须被识别为超限');
|
||
assertWithinLimits(out, '16000x17000');
|
||
assert.strictEqual(out.height, MAX_SIDE, `长边应压到上限,实际 ${out.height}`);
|
||
});
|
||
|
||
test('面积约束是真在起作用,而不是被单边约束顺带盖住', () => {
|
||
// 当前常量下 MAX_AREA 恰好等于 MAX_SIDE 的平方,压住长边就顺带压住了面积,
|
||
// 面积检查的价值只有在上限常量按别的浏览器口径调整时才显现。
|
||
// 这里把单边上限换成 65535 重新求值,确认删掉面积项会立刻放出 600M 的画布。
|
||
const start = src.indexOf('const MAX_CANVAS_SIDE');
|
||
const end = src.indexOf('const CSS = `');
|
||
const block = src.slice(start, end)
|
||
.replace(/^export /gm, '')
|
||
.replace('const MAX_CANVAS_SIDE = 16384;', 'const MAX_CANVAS_SIDE = 65535;');
|
||
const wide = new Function(`${block}\nreturn clampCanvasSize;`)();
|
||
const out = wide(30000, 20000, 1);
|
||
assert.ok(
|
||
out.width * out.height <= MAX_AREA,
|
||
`面积 ${out.width * out.height} 超过 ${MAX_AREA}:面积约束没有独立生效,只靠单边约束挡不住扁平的大幅面页`
|
||
);
|
||
assert.strictEqual(out.clamped, true, '面积超限也必须如实上报,否则外壳无法解释清晰度为何被降');
|
||
});
|
||
|
||
test('大范围尺寸与倍率组合下上限恒成立', () => {
|
||
const sides = [1, 200, 612, 792, 1190, 2384, 5000, 10000, 20000, 40000];
|
||
const qualities = [1, 1.25, 1.5, 2, 2.5, 3, 4];
|
||
const scales = [0.25, 1, 1.2, 2, 3, 5];
|
||
for (const w of sides) {
|
||
for (const h of sides) {
|
||
for (const q of qualities) {
|
||
for (const s of scales) {
|
||
const out = clampCanvasSize(w * s, h * s, q);
|
||
const label = `${w}x${h} @scale${s} @${q}x`;
|
||
assertWithinLimits(out, label);
|
||
assert.ok(
|
||
out.quality <= q + 1e-9,
|
||
`${label}:生效倍率 ${out.quality} 不该超过名义倍率 ${q}`
|
||
);
|
||
assert.strictEqual(out.scaleX, out.width / (w * s), `${label}:scaleX 与实际 backing 宽不符`);
|
||
assert.strictEqual(out.scaleY, out.height / (h * s), `${label}:scaleY 与实际 backing 高不符`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
test('非法尺寸与倍率退回安全值而不是抛错或产出 NaN', () => {
|
||
for (const bad of [undefined, null, NaN, 0, -5, 'abc', Infinity]) {
|
||
const out = clampCanvasSize(bad, bad, bad);
|
||
assert.ok(
|
||
Number.isInteger(out.width) && Number.isInteger(out.height),
|
||
`尺寸 ${String(bad)} 产出了非整数 ${out.width}x${out.height},canvas.width 赋 NaN 会静默变 0`
|
||
);
|
||
assertWithinLimits(out, `非法输入 ${String(bad)}`);
|
||
}
|
||
});
|
||
|
||
test('renderQuality 只接受 1 到 4,非法值回退到 1', () => {
|
||
assert.strictEqual(clampRenderQuality(1), 1);
|
||
assert.strictEqual(clampRenderQuality(2), 2);
|
||
assert.strictEqual(clampRenderQuality(4), 4);
|
||
assert.strictEqual(clampRenderQuality(1.5), 1.5, '允许非整数档位,1.5x 实测已能显著降低误差');
|
||
assert.strictEqual(clampRenderQuality(8), 4, '超过 4 倍换不来可感知的清晰度,只会成倍吃显存');
|
||
assert.strictEqual(clampRenderQuality(0.5), 1, '低于 1 会比现状更糊');
|
||
for (const bad of [undefined, null, NaN, 'abc', {}, Infinity, -Infinity]) {
|
||
assert.strictEqual(
|
||
clampRenderQuality(bad),
|
||
1,
|
||
`非法值 ${String(bad)} 必须回退到 1,回退到 NaN 会让整块 backing 计算失效`
|
||
);
|
||
}
|
||
});
|
||
|
||
test('源码锁死画布上限常量与两条约束', () => {
|
||
assert.match(src, /const MAX_CANVAS_SIDE = 16384;/, 'Chromium 单边上限,改动前必须先实测');
|
||
assert.match(src, /const MAX_CANVAS_AREA = 268435456;/, 'Chromium 总面积上限');
|
||
assert.match(
|
||
src,
|
||
/Math\.min\(want, bySide, byArea\)/,
|
||
'单边与面积两条约束都要参与,只留一条在上限常量变动后就会漏放超限画布'
|
||
);
|
||
assert.match(src, /MAX_CANVAS_SIDE \/ Math\.max\(cssWidth, cssHeight\)/, '单边约束按长边算');
|
||
assert.match(src, /Math\.sqrt\(MAX_CANVAS_AREA \/ \(cssWidth \* cssHeight\)\)/, '面积约束按开方算');
|
||
assert.match(src, /Math\.max\(1, Math\.floor\(cssWidth \* applied\)\)/, '钳制后至少保留 1px');
|
||
assert.match(src, /Math\.max\(1, Math\.floor\(cssHeight \* applied\)\)/, '钳制后至少保留 1px');
|
||
});
|
||
|
||
test('源码锁死超采样倍率的取值与生效方式', () => {
|
||
assert.match(
|
||
src,
|
||
/Math\.max\(1, Math\.min\(4, n\)\)/,
|
||
'renderQuality 必须被 clamp 到 1..4'
|
||
);
|
||
// 一次锁死整条链路:dpr 取大而不是叠乘、期望倍率必须过钳制、画布尺寸只能来自钳制结果
|
||
assert.match(
|
||
src,
|
||
/const dpr = window\.devicePixelRatio \|\| 1;\s*\n\s*const wanted = Math\.max\(dpr, clampRenderQuality\(renderQuality\)\);\s*\n\s*const fit = clampCanvasSize\(vp\.width, vp\.height, wanted\);\s*\n\s*p\.canvas\.width = fit\.width;\s*\n\s*p\.canvas\.height = fit\.height;/,
|
||
'有效倍率必须是 max(dpr, renderQuality) 且画布尺寸只能取钳制结果,绕过任一步都会重新引入静默空白页'
|
||
);
|
||
assert.strictEqual(
|
||
[...src.matchAll(/Math\.max\(dpr, clampRenderQuality\(renderQuality\)\)/g)].length,
|
||
2,
|
||
'renderPage 与 renderStats 必须用同一个有效倍率公式,否则界面报告的清晰度和实际渲染的不一致'
|
||
);
|
||
assert.match(
|
||
src,
|
||
/transform: identity \? null : \[fit\.scaleX, 0, 0, fit\.scaleY, 0, 0\]/,
|
||
'transform 必须用钳制后的实际比例,用名义倍率会画错位或只画出一角'
|
||
);
|
||
assert.doesNotMatch(
|
||
src,
|
||
/transform: dpr === 1 \? null : \[dpr, 0, 0, dpr, 0, 0\]/,
|
||
'旧的 dpr 直接当 transform 的写法在钳制生效时会画错'
|
||
);
|
||
});
|
||
|
||
test('源码锁死倍率变化触发全页重建', () => {
|
||
assert.match(
|
||
src,
|
||
/if \(nextScale !== scale \|\| nextQuality !== renderQuality\) \{\s*\n\s*epoch\+\+;/,
|
||
'倍率变化不走 epoch++ 重建,同屏会残留旧清晰度的页'
|
||
);
|
||
assert.match(
|
||
src,
|
||
/nextQuality = opts && opts\.renderQuality !== undefined\s*\n?\s*\? clampRenderQuality\(opts\.renderQuality\)\s*\n?\s*: renderQuality/,
|
||
'缺省 renderQuality 时必须沿用当前值,否则每次渲染都会把用户设置重置成 1'
|
||
);
|
||
assert.match(src, /renderStats\(\)/, 'renderStats 是外壳读取当前清晰度状态的唯一正当出口');
|
||
assert.doesNotMatch(src, /window\.__test/, '不允许为测试往生产代码加全局钩子');
|
||
});
|
||
|
||
test('AI 截图路径同样受画布上限保护', () => {
|
||
assert.match(
|
||
src,
|
||
/const fit = clampCanvasSize\(area\.width, area\.height, wantScale\);/,
|
||
'大幅面页在 renderScale 下限 1 时画布等于页面点尺寸,MediaBox 异常的文件会顶到上限'
|
||
);
|
||
assert.match(src, /const renderScale = fit\.quality;/, '截图的 viewport 与 transform 必须用钳制后的倍率');
|
||
assert.match(src, /canvas\.width = fit\.width;/, '截图画布尺寸直接取钳制结果');
|
||
// 图像只保留一条 JPEG 编码路径,钳制不该顺手引入格式回退
|
||
assert.deepStrictEqual(
|
||
[...src.matchAll(/toDataURL\('([^']+)'/g)].map((m) => m[1]),
|
||
[],
|
||
'编码仍应集中在 visual-context.mjs,适配器里不该出现新的编码路径'
|
||
);
|
||
});
|