diff --git a/.github/workflows/check-build.yml b/.github/workflows/check-build.yml index 99208b5606..ea85d236c0 100644 --- a/.github/workflows/check-build.yml +++ b/.github/workflows/check-build.yml @@ -145,6 +145,22 @@ jobs: npm ci --prefix build npm run --prefix build build + # doctrenderer/x2t execute these bundles on a V8 built with + # v8_enable_i18n_support=false, whose reduced Unicode tables reject + # supplementary-plane characters used as identifiers. Terser emitted the + # LaTeX symbol table's astral keys bare, and that V8 then failed the whole + # bundle with "SyntaxError: Invalid or unexpected token", aborting x2t on + # first start (issue #80). Every JS gate in this workflow runs on Node and + # headless Chromium -- both full-ICU -- so none of them can catch it. + # + # The check is a script rather than a grep because the residual failure + # mode is pure ASCII: with format.ascii_only but no format.quote_keys, + # Terser emits `\u{1d552}:"\\doublea"`, which no byte-range test flags. + - name: Assert built bundles are ASCII-only with no bare astral keys (#80) + run: | + cd sdkjs + node build/scripts/check-bundle-ascii.cjs + - name: Install QUnit runner dependencies run: | sudo apt-get update diff --git a/build/scripts/check-bundle-ascii.cjs b/build/scripts/check-bundle-ascii.cjs new file mode 100644 index 0000000000..64be308d07 --- /dev/null +++ b/build/scripts/check-bundle-ascii.cjs @@ -0,0 +1,103 @@ +/** + * SPDX-FileCopyrightText: 2026 Euro-Office contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * Guards the two invariants the built sdkjs bundles must satisfy for + * doctrenderer/x2t, which run them on a V8 built with + * v8_enable_i18n_support=false (core: Common/3dParty/v8/tools/8.9/*). + * + * 1. No raw non-ASCII byte. + * 2. No object key that is a bare supplementary-plane ("astral") identifier. + * + * (2) is the subtle one and the reason a plain "is it ASCII" grep is not + * enough: with format.ascii_only but without format.quote_keys, Terser emits + * + * \u{1d552}:"\\doublea" + * + * which contains no byte > 0x7F yet still parses as a bare identifier whose + * code point is U+1D552. A no-ICU V8 rejects that exactly as it rejects the + * raw UTF-8 spelling, so invariant (1) alone would let issue #80 regress + * silently. See build/webpack.sdk.factory.mjs. + * + * Usage: node build/scripts/check-bundle-ascii.cjs [file...] + * With no arguments, scans the built sdk-all bundles under the deploy root. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { sync: globSync } = require('glob'); +const { resolveBuildRoot } = require('../lib/env.cjs'); + +// An unquoted object key in `{`/`,` position that starts with either a raw +// astral character or a unicode escape. A quoted key cannot match: the quote +// sits between the delimiter and the escape. +const BARE_ASTRAL_RAW = /[{,]\s*[\u{10000}-\u{10FFFF}]/u; +const BARE_ASTRAL_ESC = /[{,]\s*\\u(?:\{[0-9a-fA-F]{5,6}\}|[dD][89abAB][0-9a-fA-F]{2})/; + +function sampleAround(text, regex, limit = 3) { + const global = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : regex.flags + 'g'); + const out = []; + let m; + while ((m = global.exec(text)) !== null && out.length < limit) { + out.push(text.slice(m.index, Math.min(m.index + 40, text.length))); + } + return out; +} + +function checkFile(file) { + const buf = fs.readFileSync(file); + const problems = []; + + let nonAscii = 0; + for (const byte of buf) if (byte > 0x7f) nonAscii++; + if (nonAscii > 0) { + problems.push(`${nonAscii} raw non-ASCII byte(s); Terser format.ascii_only must stay enabled`); + } + + const text = buf.toString('utf8'); + for (const [label, re] of [['raw', BARE_ASTRAL_RAW], ['escaped', BARE_ASTRAL_ESC]]) { + if (re.test(text)) { + const samples = sampleAround(text, re).map((s) => JSON.stringify(s)).join(', '); + problems.push(`bare astral object key (${label} form); Terser format.quote_keys must stay enabled -- e.g. ${samples}`); + } + } + + return problems; +} + +function main(argv) { + let files = argv.slice(2); + if (files.length === 0) { + const buildDir = path.resolve(__dirname, '..'); + const deployRoot = resolveBuildRoot(buildDir); + files = globSync('*/sdk-all{,-min}.js', { cwd: deployRoot, absolute: true }).sort(); + if (files.length === 0) { + console.error(`check-bundle-ascii: no sdk-all bundles found under ${deployRoot} -- nothing was verified`); + return 1; + } + } + + let failed = 0; + for (const file of files) { + const problems = checkFile(file); + if (problems.length === 0) { + console.log(`ok: ${file}`); + continue; + } + failed++; + for (const problem of problems) { + console.error(`::error file=${file}::${problem} (see issue #80)`); + } + } + + console.log(`check-bundle-ascii: ${files.length} bundle(s) scanned, ${failed} failed`); + return failed === 0 ? 0 : 1; +} + +if (require.main === module) process.exit(main(process.argv)); + +module.exports = { checkFile, BARE_ASTRAL_RAW, BARE_ASTRAL_ESC }; diff --git a/build/scripts/deploy-assets.cjs b/build/scripts/deploy-assets.cjs index 64d3acea59..9eae56f11c 100644 --- a/build/scripts/deploy-assets.cjs +++ b/build/scripts/deploy-assets.cjs @@ -89,7 +89,20 @@ async function deployJsFile(srcPath, destPath) { const result = await minify(source, { compress: false, mangle: false, - format: { comments: false }, + format: { + comments: false, + // Same invariant as the sdk-all bundles in webpack.sdk.factory.mjs: + // several of the files deployed here (Native/*.js, and the + // libfont/engine/fonts_*.js that doctrenderer concatenates into the + // script it compiles) are executed by a V8 built with + // v8_enable_i18n_support=false, which rejects supplementary-plane + // characters used as identifiers. These inputs happen to be + // ASCII-clean today, so this is prophylactic rather than a fix -- + // but nothing else stops the next non-ASCII string landing in one of + // them and reproducing issue #80 outside the bundle scan's reach. + ascii_only: true, + quote_keys: true, + }, }); const content = licenseText + '\n' + (result.code != null ? result.code : source); fs.mkdirSync(path.dirname(destPath), { recursive: true }); diff --git a/build/test/webpack-sdk-terser-options.test.cjs b/build/test/webpack-sdk-terser-options.test.cjs index 4802d130db..85a26d96cc 100644 --- a/build/test/webpack-sdk-terser-options.test.cjs +++ b/build/test/webpack-sdk-terser-options.test.cjs @@ -21,6 +21,10 @@ const assert = require('node:assert/strict'); const path = require('node:path'); const url = require('node:url'); +// Share one definition of "bare astral key" with the CI bundle scan so the two +// cannot drift apart. +const { BARE_ASTRAL_RAW, BARE_ASTRAL_ESC } = require('../scripts/check-bundle-ascii.cjs'); + async function loadSdkConfig() { const mod = await import(url.pathToFileURL(path.join(__dirname, '..', 'webpack.sdk.factory.mjs'))); return mod.sdkConfig; @@ -104,3 +108,68 @@ test('sdkConfig: DROP_CONSOLE is opt-in, not the default, for non-desktop/mobile } } }); + +// --- Regression coverage for issue #80 --------------------------------------- +// doctrenderer/x2t execute these bundles on a V8 built with +// v8_enable_i18n_support=false, whose reduced Unicode tables do not accept +// supplementary-plane ("astral") characters as identifiers. The webpack +// pipeline emitted the LaTeX symbol table's astral keys as bare identifiers +// (the reverse map in word/Math/NamesOfLiterals.js), and that V8 rejected the +// whole bundle with "SyntaxError: Invalid or unexpected token" -- aborting x2t +// on first start and making Euro-Office 9.3.4 unusable. ASCII-only output is +// the invariant the previous Closure Compiler build provided implicitly. + +for (const platform of ['', 'desktop', 'mobile']) { + test(`sdkConfig: Terser is configured for ASCII-only output on platform '${platform || 'web'}' (#80)`, async () => { + const sdkConfig = await loadSdkConfig(); + const terserOptions = withPlatform(platform, () => terserOptionsOf(sdkConfig('word'))); + + assert.equal(terserOptions.format.ascii_only, true); + assert.equal(terserOptions.format.quote_keys, true); + }); +} + +// terser-webpack-plugin derives an `ecma` from webpack's target and injects it +// into terserOptions; Terser's own default (ecma unset) is conservative and +// quotes astral keys regardless. Calling terser.minify() without ecma therefore +// exercises a code path the real build never takes -- and would pass even with +// quote_keys deleted. Pin it so these assertions test the shipped behaviour. +const PIPELINE_ECMA = 2020; + +// Shape lifted from word/Math/NamesOfLiterals.js's reverse symbol table: +// U+2219 (BMP) plus U+1D552 / U+1D538 (astral) used as object keys. +const SYMBOL_TABLE_SRC = 'var Reverse={"\u2219":"\\bullet","\u{1d552}":"\\doublea","\u{1d538}":"\\doubleA"};'; + +test('sdkConfig: Terser escapes astral object keys rather than emitting them bare (#80)', async () => { + const terser = require('terser'); + const sdkConfig = await loadSdkConfig(); + const terserOptions = withPlatform('', () => terserOptionsOf(sdkConfig('word'))); + + const { code } = await terser.minify(SYMBOL_TABLE_SRC, { ...terserOptions, ecma: PIPELINE_ECMA }); + + assert.ok(!/[^\x00-\x7F]/.test(code), + `Terser emitted non-ASCII output, which doctrenderer's no-ICU V8 rejects: ${code}`); + assert.equal(BARE_ASTRAL_RAW.test(code), false, + `Terser emitted a raw bare astral object key: ${code}`); + assert.equal(BARE_ASTRAL_ESC.test(code), false, + `Terser emitted an escaped bare astral object key, which is still a bare astral identifier to a no-ICU V8: ${code}`); +}); + +// Guards the guard: without quote_keys the same input must produce exactly the +// failure this PR fixes. If this ever stops failing, the assertions above have +// gone blind and the ones in the test before it prove nothing. +test('sdkConfig: dropping quote_keys reintroduces the bare astral key (#80)', async () => { + const terser = require('terser'); + const sdkConfig = await loadSdkConfig(); + const terserOptions = withPlatform('', () => terserOptionsOf(sdkConfig('word'))); + + const withoutQuoteKeys = { + ...terserOptions, + ecma: PIPELINE_ECMA, + format: { ...terserOptions.format, quote_keys: false }, + }; + const { code } = await terser.minify(SYMBOL_TABLE_SRC, withoutQuoteKeys); + + assert.equal(BARE_ASTRAL_ESC.test(code), true, + `Expected a bare escaped astral key without quote_keys, got: ${code}`); +}); diff --git a/build/webpack.sdk.factory.mjs b/build/webpack.sdk.factory.mjs index 9df3aa4987..1c3446b6d5 100644 --- a/build/webpack.sdk.factory.mjs +++ b/build/webpack.sdk.factory.mjs @@ -296,6 +296,38 @@ export function sdkConfig(moduleName) { // text also matches the identical per-file header repeated in // all ~400+ concatenated source files. comments: /@@license-banner@@/, + + // BOTH of the options below are required; neither is + // sufficient alone. doctrenderer/x2t run these bundles on a + // V8 built with v8_enable_i18n_support=false, whose reduced + // Unicode tables do not classify supplementary-plane + // ("astral") characters as ID_Start. The LaTeX symbol table + // in word/Math/NamesOfLiterals.js keys an object on such + // characters, and this pipeline emits them unquoted. + // + // ascii_only escapes non-ASCII *characters*, but an escaped + // bare key is still a bare astral identifier: with + // ascii_only alone this build emits 131 keys of the form + // \u{1d552}:"\\doublea" + // which is pure ASCII text yet still resolves to U+1D552 for + // ID_Start classification, so that V8 rejects it exactly as + // it rejects the raw UTF-8 form. Measured: with ascii_only + // alone, `x2t -create-js-cache` still aborts and writes a + // 0-byte sdk-all.cache. + // + // quote_keys is therefore the load-bearing option -- it + // turns the key into a string literal, which ascii_only then + // escapes into `"\u{1d552}"`. ascii_only remains necessary + // in its own right so no raw non-ASCII byte reaches the + // bundle at all. + // + // quote_keys must live inside `format`; Terser rejects a + // top-level one with "`quote_keys` is not a supported + // option". Covered by + // build/test/webpack-sdk-terser-options.test.cjs and by the + // bundle scan in check-build.yml. See #80. + ascii_only: true, + quote_keys: true, }, compress: (platform === 'desktop' || platform === 'mobile') // Old build-desktop.bat/build-mobile.command ran Closure's