Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/check-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions build/scripts/check-bundle-ascii.cjs
Original file line number Diff line number Diff line change
@@ -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 };
15 changes: 14 additions & 1 deletion build/scripts/deploy-assets.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
69 changes: 69 additions & 0 deletions build/test/webpack-sdk-terser-options.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}`);
});
32 changes: 32 additions & 0 deletions build/webpack.sdk.factory.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading