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

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

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

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

其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、
GitHub Pages 站点。
2026-08-04 16:19:06 +08:00

270 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,倍率 21468x1900,远在上限内
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 20000x14000280M),实测不可用
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 24000x18000432M),实测不可用
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,适配器里不该出现新的编码路径'
);
});