fix(runtime): support PDF preview on Chrome 143

This commit is contained in:
2026-10-18 15:51:26 +08:00
parent 605a48adb0
commit e36f3c1fdd
5 changed files with 147 additions and 0 deletions
+2
View File
@@ -40,6 +40,8 @@ Linux 文档预览通过构建期补丁将 Office-to-PDF 转换入口接到系
每次转换使用私有临时目录、独立 profile、最高宏安全级别和独立进程组;保留默认 60 秒超时,取消时终止进程组。检查输入大小、OOXML 条目数、解压总量和 CRC、输出大小及完整性。子进程采用最小环境变量;校验器地址空间上限 512 MiB,LibreOffice 为 2 GiB,单文件写入上限为 max(maxOutputBytes, 16 MiB),最终 PDF 仍严格检查 maxOutputBytes。进程资源限制不是进程树总量或临时目录总量限制,部署时仍须容器内存、CPU、PID 和临时磁盘限制。 每次转换使用私有临时目录、独立 profile、最高宏安全级别和独立进程组;保留默认 60 秒超时,取消时终止进程组。检查输入大小、OOXML 条目数、解压总量和 CRC、输出大小及完整性。子进程采用最小环境变量;校验器地址空间上限 512 MiB,LibreOffice 为 2 GiB,单文件写入上限为 max(maxOutputBytes, 16 MiB),最终 PDF 仍严格检查 maxOutputBytes。进程资源限制不是进程树总量或临时目录总量限制,部署时仍须容器内存、CPU、PID 和临时磁盘限制。
PDF 浏览器兼容补丁:image/install-pdf-compat.mjs 在构建期为 DSH / documentpreview 0.2.1-rc.2 的 PDF.js 主线程及内嵌 worker 注入缺失的 Map.getOrInsert、Map.getOrInsertComputed 和 Math.sumPrecise,不覆盖原生方法。版本不匹配、worker 入口变化或重复安装时直接失败。精确求和使用 BigInt 累加,避免嵌入字体因 Chrome 143 缺少 API 而回退。回归测试命令:node --test image/pdf-compat.test.mjs。已用 Chrome for Testing 143.0.7499.192(移动视口、触摸模拟)验证镜像内真实 PDF 加载/渲染代码:补丁前复现 methodPromises 报错,补丁后 14 页样本加载、第 1→2→1 页渲染与文本提取成功,控制台无错误或警告;不等同 Android 真机或完整业务 UI 测试。现有部署须重新构建并更新镜像才会获得补丁。
安全边界:原生 LibreOffice 与 DSH 使用同一 UID,独立 profile 不是文件系统或网络沙箱;不应将此功能视为隔离恶意文档的服务。容器内敏感文件及网络访问仍需部署侧隔离,尤其是处理不可信外部文档时。 安全边界:原生 LibreOffice 与 DSH 使用同一 UID,独立 profile 不是文件系统或网络沙箱;不应将此功能视为隔离恶意文档的服务。容器内敏感文件及网络访问仍需部署侧隔离,尤其是处理不可信外部文档时。
字体由系统解析;显式 fontDirectories/fontFallbacks 配置会被拒绝,应移除并改用系统字体配置。kit 的 maxFontFiles/maxFontFileBytes/maxLoadedFontBytes 不再约束系统字体。missingFonts 为空仅表示不提供缺失字体诊断,不代表没有字体替换。 字体由系统解析;显式 fontDirectories/fontFallbacks 配置会被拒绝,应移除并改用系统字体配置。kit 的 maxFontFiles/maxFontFileBytes/maxLoadedFontBytes 不再约束系统字体。missingFonts 为空仅表示不提供缺失字体诊断,不代表没有字体替换。
+2
View File
@@ -34,6 +34,8 @@ RUN node /usr/local/lib/dsh-mgr/install-misans.mjs
COPY MiSans-NOTICE.txt /usr/local/share/fonts/misans/NOTICE.txt COPY MiSans-NOTICE.txt /usr/local/share/fonts/misans/NOTICE.txt
COPY install-office-preview.mjs native-office.mjs validate-office.py office-worker.py /usr/local/lib/dsh-mgr/ COPY install-office-preview.mjs native-office.mjs validate-office.py office-worker.py /usr/local/lib/dsh-mgr/
RUN node /usr/local/lib/dsh-mgr/install-office-preview.mjs RUN node /usr/local/lib/dsh-mgr/install-office-preview.mjs
COPY install-pdf-compat.mjs pdf-map-compat.js /usr/local/lib/dsh-mgr/
RUN node /usr/local/lib/dsh-mgr/install-pdf-compat.mjs
COPY preview-download /usr/local/lib/dsh-mgr/preview-download COPY preview-download /usr/local/lib/dsh-mgr/preview-download
COPY install-preview-download.mjs /usr/local/lib/dsh-mgr/install-preview-download.mjs COPY install-preview-download.mjs /usr/local/lib/dsh-mgr/install-preview-download.mjs
RUN node /usr/local/lib/dsh-mgr/install-preview-download.mjs RUN node /usr/local/lib/dsh-mgr/install-preview-download.mjs
+25
View File
@@ -0,0 +1,25 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
export const workerTarget = 'new Blob([_dsh_pdf_worker_default,';
export const marker = '// dsh-pdf-map-compat-v1';
export const compat = readFileSync(new URL('./pdf-map-compat.js', import.meta.url), 'utf8');
export function patchPdf(source, hostVersion, packageVersion) {
if (hostVersion !== '0.2.1-rc.2' || packageVersion !== '0.2.1-rc.2') throw new Error('unsupported PDF preview version');
if (source.includes(marker)) throw new Error('PDF compatibility patch already installed');
if (source.split(workerTarget).length !== 2 || !source.includes('this.#methodPromises.getOrInsertComputed(')) {
throw new Error('unexpected PDF.js bundle: expected main-thread upsert and exactly one worker Blob');
}
return marker + '\n' + compat + '\n' + source.replace(workerTarget, 'new Blob([' + JSON.stringify(compat) + ', _dsh_pdf_worker_default,');
}
export function install(root = '/usr/local/lib/node_modules/@deepseek-ai/dsh') {
const dir = join(root, 'node_modules/@deepseek-ai/dsh-client-ui-sidebar-documentpreview');
const version = path => JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')).version;
const file = join(dir, 'lib/client.pdf.js');
writeFileSync(file, patchPdf(readFileSync(file, 'utf8'), version(root), version(dir)));
console.log('Installed PDF Map compatibility patch (main thread + worker)');
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) install(process.argv[2]);
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { runInNewContext } from 'node:vm';
import { compat, patchPdf, workerTarget, marker } from './install-pdf-compat.mjs';
function run(code) {
return runInNewContext('delete Math.sumPrecise; delete Map.prototype.getOrInsert; delete Map.prototype.getOrInsertComputed;\n' + compat + '\n' + code);
}
test('precise summation handles cancellation, rounding, overflow, subnormals and special values', () => {
assert.equal(run('Math.sumPrecise([1e20, 1, -1e20])'), 1);
assert.equal(run('Math.sumPrecise([Number.MAX_VALUE, Number.MAX_VALUE, -Number.MAX_VALUE])'), Number.MAX_VALUE);
assert.equal(run('Math.sumPrecise([Number.MAX_VALUE, Number.MAX_VALUE])'), Infinity);
assert.equal(run('Math.sumPrecise([Number.MIN_VALUE, Number.MIN_VALUE])'), 2 * Number.MIN_VALUE);
assert.equal(run('Math.sumPrecise([1, 2 ** -53])'), 1);
assert.equal(run('Math.sumPrecise([1, 2 ** -53, 2 ** -54])'), 1 + Number.EPSILON);
assert.ok(Object.is(run('Math.sumPrecise([])'), -0));
assert.ok(Object.is(run('Math.sumPrecise([-0])'), -0));
assert.ok(Object.is(run('Math.sumPrecise([-1, 1])'), 0));
assert.ok(Number.isNaN(run('Math.sumPrecise([Infinity, -Infinity])')));
assert.throws(() => run('Math.sumPrecise([Infinity, "1"])'), /numbers/);
});
test('computed insertion caches undefined and promises without recomputation', () => {
assert.equal(run('const m = new Map(); let n = 0; m.getOrInsertComputed("x", () => { n++; }); m.getOrInsertComputed("x", () => { n++; }); n'), 1);
assert.equal(run('const m = new Map(); const p = Promise.resolve(1); m.getOrInsertComputed("x", () => p) === m.getOrInsertComputed("x", () => 2)'), true);
});
test('callback validation, errors, receiver, key normalization and reentrancy', () => {
assert.throws(() => run('new Map([[1, 2]]).getOrInsertComputed(1, null)'), /callable/);
assert.throws(() => run('Map.prototype.getOrInsertComputed.call({}, 1, () => 2)'), /incompatible receiver/);
assert.equal(run('new Map().getOrInsertComputed(-0, k => Object.is(k, 0))'), true);
assert.equal(run('const m = new Map(); m.getOrInsertComputed(1, k => { m.set(k, 2); return 3; }); m.get(1)'), 3);
assert.equal(run('const m = new Map(); try { m.getOrInsertComputed(1, () => { throw Error(); }); } catch {} m.size'), 0);
assert.equal(run('new Map([[NaN, 4]]).getOrInsertComputed(NaN, () => 5)'), 4);
});
test('plain insertion and non-enumerable, repeat-safe methods', () => {
assert.equal(run('const m = new Map([[1, undefined]]); m.getOrInsert(1, 2) === undefined && m.getOrInsert(2, 3) === 3'), true);
assert.equal(run('Object.getOwnPropertyDescriptor(Map.prototype, "getOrInsertComputed").enumerable'), false);
assert.equal(runInNewContext('Map.prototype.getOrInsertComputed = () => 42;\n' + compat + '\nnew Map().getOrInsertComputed()'), 42);
});
test('patch guards and worker injection', () => {
const source = 'this.#methodPromises.getOrInsertComputed(name, f); ' + workerTarget + ' tail]);';
const patched = patchPdf(source, '0.2.1-rc.2', '0.2.1-rc.2');
assert.ok(patched.startsWith(marker + '\n' + compat));
assert.ok(patched.includes('new Blob([' + JSON.stringify(compat) + ', _dsh_pdf_worker_default,'));
assert.throws(() => patchPdf(patched, '0.2.1-rc.2', '0.2.1-rc.2'), /already/);
assert.throws(() => patchPdf(source, 'other', '0.2.1-rc.2'), /version/);
for (const bad of ['', source + workerTarget]) assert.throws(() => patchPdf(bad, '0.2.1-rc.2', '0.2.1-rc.2'), /unexpected/);
});
+71
View File
@@ -0,0 +1,71 @@
// PDF.js 6 uses Map upsert APIs absent from Chrome 143 / older WebViews.
// Run in both the document and its separate worker realm. Never replace natives.
(() => {
'use strict';
const { has, get, set } = Map.prototype;
// Accumulate finite doubles exactly in units of the smallest subnormal.
// Round only once, avoiding both cancellation loss and intermediate overflow.
if (typeof Math.sumPrecise !== 'function') {
Object.defineProperty(Math, 'sumPrecise', {
configurable: true, writable: true,
value: function sumPrecise(values) {
const bits = new DataView(new ArrayBuffer(8));
let total = 0n, negativeZero = true, positiveInfinity = false, negativeInfinity = false, nan = false;
for (const value of values) {
if (typeof value !== 'number') throw new TypeError('sumPrecise requires numbers');
if (!Object.is(value, -0)) negativeZero = false;
if (!Number.isFinite(value)) {
if (Number.isNaN(value)) nan = true;
else if (value > 0) positiveInfinity = true;
else negativeInfinity = true;
continue;
}
bits.setFloat64(0, value);
const high = bits.getUint32(0), low = bits.getUint32(4);
const exponent = (high >>> 20) & 0x7ff;
let mantissa = (BigInt(high & 0xfffff) << 32n) | BigInt(low);
if (exponent) mantissa = (mantissa | (1n << 52n)) << BigInt(exponent - 1);
total += high >>> 31 ? -mantissa : mantissa;
}
if (nan || (positiveInfinity && negativeInfinity)) return NaN;
if (positiveInfinity) return Infinity;
if (negativeInfinity) return -Infinity;
if (total === 0n) return negativeZero ? -0 : 0;
const sign = total < 0n ? -1 : 1;
if (total < 0n) total = -total;
const shift = Math.max(0, total.toString(2).length - 53);
let rounded = total >> BigInt(shift);
if (shift) {
const remainder = total - (rounded << BigInt(shift));
const half = 1n << BigInt(shift - 1);
if (remainder > half || (remainder === half && (rounded & 1n))) rounded++;
}
return sign * Number(rounded) * 2 ** (shift - 1074);
},
});
}
if (typeof Map.prototype.getOrInsert !== 'function') {
Object.defineProperty(Map.prototype, 'getOrInsert', {
configurable: true, writable: true,
value: function getOrInsert(key, value) {
if (has.call(this, key)) return get.call(this, key);
set.call(this, key, value);
return value;
},
});
}
if (typeof Map.prototype.getOrInsertComputed !== 'function') {
Object.defineProperty(Map.prototype, 'getOrInsertComputed', {
configurable: true, writable: true,
value: function getOrInsertComputed(key, callback) {
const present = has.call(this, key); // Also validates the Map receiver.
if (typeof callback !== 'function') throw new TypeError('callback must be callable');
if (present) return get.call(this, key);
if (key === 0) key = 0; // Canonicalize -0 before calling the callback.
const value = callback(key);
set.call(this, key, value); // Callback may have inserted the same key.
return value;
},
});
}
})();