diff --git a/README.md b/README.md index df6b7bc..e2fed63 100644 --- a/README.md +++ b/README.md @@ -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 和临时磁盘限制。 +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 不是文件系统或网络沙箱;不应将此功能视为隔离恶意文档的服务。容器内敏感文件及网络访问仍需部署侧隔离,尤其是处理不可信外部文档时。 字体由系统解析;显式 fontDirectories/fontFallbacks 配置会被拒绝,应移除并改用系统字体配置。kit 的 maxFontFiles/maxFontFileBytes/maxLoadedFontBytes 不再约束系统字体。missingFonts 为空仅表示不提供缺失字体诊断,不代表没有字体替换。 diff --git a/image/Dockerfile b/image/Dockerfile index c0f9a9f..b18a2ec 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -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 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 +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 install-preview-download.mjs /usr/local/lib/dsh-mgr/install-preview-download.mjs RUN node /usr/local/lib/dsh-mgr/install-preview-download.mjs diff --git a/image/install-pdf-compat.mjs b/image/install-pdf-compat.mjs new file mode 100644 index 0000000..ba8ebe8 --- /dev/null +++ b/image/install-pdf-compat.mjs @@ -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]); diff --git a/image/pdf-compat.test.mjs b/image/pdf-compat.test.mjs new file mode 100644 index 0000000..bd860a5 --- /dev/null +++ b/image/pdf-compat.test.mjs @@ -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/); +}); diff --git a/image/pdf-map-compat.js b/image/pdf-map-compat.js new file mode 100644 index 0000000..bc0bb1c --- /dev/null +++ b/image/pdf-map-compat.js @@ -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; + }, + }); + } +})();