Files

299 lines
12 KiB
JavaScript
Raw Permalink Normal View History

2026-08-27 21:09:14 +00:00
// Generates src/jsc/bindings/stringWidthTables.h: the fused 3-stage
// codepoint classification table used by stringWidth.cpp.
//
// Each codepoint maps to one packed byte:
// bits 0-4 grapheme break class (GraphemeBreakClass ordinal, see stringWidth.cpp)
// bits 5-6 width class: 0 = zero-width, 1 = narrow, 2 = wide, 3 = East Asian Ambiguous
// bit 7 Emoji property (with the isEmojiPresentation() early-outs baked in)
//
// The width and emoji bits are derived from the Unicode Character Database
// at UNICODE_VERSION (EastAsianWidth.txt, DerivedGeneralCategory.txt,
// emoji-data.txt) plus the zero-width rules in isZeroWidth(). The grapheme
// break class bits are carried over from the previous header (originally
// uucode-derived), so this script needs an existing stringWidthTables.h to
// bootstrap from.
//
// Usage: bun scripts/generate-stringwidth-tables.mjs [--ucd <dir>]
// Rewrites src/jsc/bindings/stringWidthTables.h in place. Downloads
// the UCD files from unicode.org unless --ucd points at a directory
// holding EastAsianWidth.txt, DerivedGeneralCategory.txt and
// emoji-data.txt for UNICODE_VERSION.
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const UNICODE_VERSION = "17.0.0";
const UCD_BASE = `https://www.unicode.org/Public/${UNICODE_VERSION}/ucd`;
const headerPath = join(dirname(fileURLToPath(import.meta.url)), "../src/jsc/bindings/stringWidthTables.h");
const header = readFileSync(headerPath, "utf8");
// ---------------------------------------------------------------------------
// Load Unicode data
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
const ucdDirIndex = args.indexOf("--ucd");
if (ucdDirIndex !== -1 && !args[ucdDirIndex + 1]) throw new Error("--ucd needs a directory argument");
const ucdDir = ucdDirIndex !== -1 ? args[ucdDirIndex + 1] : null;
async function loadUCD(remotePath) {
const name = remotePath.split("/").pop();
if (ucdDir) return readFileSync(join(ucdDir, name), "utf8");
const url = `${UCD_BASE}/${remotePath}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`GET ${url} failed: ${response.status} ${response.statusText}`);
return await response.text();
}
const eastAsianWidthText = await loadUCD("EastAsianWidth.txt");
const generalCategoryText = await loadUCD("extracted/DerivedGeneralCategory.txt");
const emojiDataText = await loadUCD("emoji/emoji-data.txt");
// Parse `LO(..HI) ; VALUE # comment` lines whose VALUE is accepted by `wanted`.
function parseUCDRanges(text, wanted) {
const ranges = [];
for (const m of text.matchAll(/^([0-9A-F]{4,6})(?:\.\.([0-9A-F]{4,6}))?\s*;\s*(\w+)\s*#/gm)) {
if (!wanted(m[3])) continue;
ranges.push([parseInt(m[1], 16), m[2] === undefined ? parseInt(m[1], 16) : parseInt(m[2], 16)]);
}
return mergeRanges(ranges);
}
function mergeRanges(ranges) {
ranges.sort((a, b) => a[0] - b[0]);
const merged = [];
for (const [lo, hi] of ranges) {
const last = merged[merged.length - 1];
if (last && lo <= last[1] + 1) last[1] = Math.max(last[1], hi);
else merged.push([lo, hi]);
}
return merged;
}
// East Asian Width `W` (wide) + `F` (fullwidth), and `A` (ambiguous).
const wideRanges = parseUCDRanges(eastAsianWidthText, type => type === "W" || type === "F");
const ambiguousRanges = parseUCDRanges(eastAsianWidthText, type => type === "A");
// General_Category Mn (nonspacing mark) + Me (enclosing mark).
const nonspacingMarkRanges = parseUCDRanges(generalCategoryText, gc => gc === "Mn" || gc === "Me");
// The `Emoji` binary property (not Emoji_Presentation / Emoji_Modifier / ...).
const emojiRanges = parseUCDRanges(emojiDataText, property => property === "Emoji");
// ---------------------------------------------------------------------------
// Carry the grapheme break classes over from the existing table
// ---------------------------------------------------------------------------
function parseArray(name) {
const m = header.match(new RegExp(`${name}\\[[0-9]*\\] = \\{([\\s\\S]*?)\\n\\};`));
if (!m) throw new Error(`could not find ${name} in stringWidthTables.h (needed to bootstrap the grapheme classes)`);
return m[1]
.split(/[,\s]+/)
.filter(tok => /^\d+$/.test(tok))
.map(Number);
}
const stage1 = parseArray("kGraphemeBreakStage1");
const stage2 = parseArray("kGraphemeBreakStage2");
const stage3 = parseArray("kGraphemeBreakStage3");
// Existing stage3 holds fused bytes; the grapheme class is always the low 5 bits.
const classOf = cp => {
const high = cp >> 8;
const low = cp & 0xff;
return stage3[stage2[stage1[high] + low]] & 0x1f;
};
const inRanges = (cp, ranges) => {
let lo = 0;
let hi = ranges.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (ranges[mid][0] <= cp) lo = mid + 1;
else hi = mid;
}
return lo > 0 && cp <= ranges[lo - 1][1];
};
// Guard against a UCD format change silently emptying a table.
if (!inRanges(0x4e00, wideRanges) || !inRanges(0x300, nonspacingMarkRanges) || !inRanges(0x1f600, emojiRanges))
throw new Error("UCD parse sanity check failed");
// ---------------------------------------------------------------------------
// Width classification
// ---------------------------------------------------------------------------
// Zero-width: controls, every nonspacing/enclosing mark (Mn/Me), conjoining
// Hangul jungseong/jongseong, and the invisible format characters glibc
// wcwidth() and string-width treat as zero-width. Deliberate deviations:
// prepended concatenation marks (U+0600..) are zero-width, and the Indic
// block heuristic below also zero-widths spacing vowel signs (Mc) so a
// consonant+vowel-sign syllable stays one column.
function isZeroWidth(cp) {
if (cp <= 0x1f) return true;
if (cp >= 0x7f && cp <= 0x9f) return true;
if (cp === 0xad) return true;
if (inRanges(cp, nonspacingMarkRanges)) return true;
// Hangul jungseong (V) and jongseong (T): an L+V(+T) conjoining cluster is
// 2 columns wide, all carried by the leading consonant.
if (cp >= 0x1160 && cp <= 0x11ff) return true;
if (cp >= 0xd7b0 && cp <= 0xd7ff) return true;
if (cp >= 0x200b && cp <= 0x200f) return true;
// The bidi embedding/override controls (LRE..RLO) and the rest of the
// U+2060-U+206F block (word joiner, invisible operators, the LRI..PDI bidi
// isolates, deprecated format characters): invisible format characters,
// exactly like U+200B-U+200F above.
if (cp >= 0x202a && cp <= 0x202e) return true;
if (cp >= 0x2060 && cp <= 0x206f) return true;
// U+061C ARABIC LETTER MARK, the remaining bidi control character.
if (cp === 0x61c) return true;
// The remaining default-ignorable format characters (Cf): the shorthand and
// musical notation controls. glibc wcwidth() and string-width return 0.
if (cp >= 0x1bca0 && cp <= 0x1bca3) return true;
if (cp >= 0x1d173 && cp <= 0x1d17a) return true;
// Mongolian free variation selectors and vowel separator.
if (cp >= 0x180b && cp <= 0x180f) return true;
// Blocks reserved wholly for combining marks: their unassigned tails stay
// zero-width too.
if (cp >= 0x1ab0 && cp <= 0x1aff) return true;
if (cp >= 0x20d0 && cp <= 0x20ff) return true;
if (cp === 0xfeff) return true;
if (cp >= 0xd800 && cp <= 0xdfff) return true;
if ((cp >= 0x600 && cp <= 0x605) || cp === 0x6dd || cp === 0x70f || cp === 0x8e2) return true;
// Indic vowel-sign heuristic (covers the spacing Mc vowel signs, not just
// the Mn ones): a consonant plus vowel sign renders as one column.
if (cp >= 0x900 && cp <= 0xd4f) {
const offset = cp & 0x7f;
if (offset <= 0x02) return true;
if (offset >= 0x3a && offset <= 0x4d && offset !== 0x3d) return true;
if (offset >= 0x51 && offset <= 0x57) return true;
if (offset >= 0x62 && offset <= 0x63) return true;
}
// Tag characters.
if (cp >= 0xe0000 && cp <= 0xe007f) return true;
return false;
}
// 0 = zero-width, 1 = narrow, 2 = wide, 3 = ambiguous
function widthClass(cp) {
if (isZeroWidth(cp)) return 0;
if (inRanges(cp, wideRanges)) return 2;
if (inRanges(cp, ambiguousRanges)) return 3;
return 1;
}
// Emoji property with the isEmojiPresentation() early-outs baked in.
function isEmoji(cp) {
if (cp < 0x203c) return false;
if (cp >= 0x2c00 && cp < 0x1f000) return false;
if (cp === 0xfe0e || cp === 0xfe0f || cp === 0x200d) return false;
return inRanges(cp, emojiRanges);
}
const packed = cp => classOf(cp) | (widthClass(cp) << 5) | (isEmoji(cp) ? 0x80 : 0);
// ---------------------------------------------------------------------------
// Rebuild the 3-stage table over the packed values
// ---------------------------------------------------------------------------
const highCount = stage1.length; // 8192 blocks of 256 codepoints
const blockKeyToOffset = new Map();
const newStage1 = new Array(highCount);
const newStage2 = [];
const valueToIndex = new Map();
const newStage3 = [];
for (let high = 0; high < highCount; high++) {
const block = new Array(256);
for (let low = 0; low < 256; low++) {
const value = packed(high * 256 + low);
let index = valueToIndex.get(value);
if (index === undefined) {
index = newStage3.length;
if (index > 255) throw new Error("more than 256 distinct packed values");
valueToIndex.set(value, index);
newStage3.push(value);
}
block[low] = index;
}
const key = block.join(",");
let offset = blockKeyToOffset.get(key);
if (offset === undefined) {
offset = newStage2.length;
blockKeyToOffset.set(key, offset);
newStage2.push(...block);
}
newStage1[high] = offset;
}
if (newStage2.length - 256 > 0xffff) throw new Error("stage1 offsets no longer fit in uint16_t");
// Verify the rebuilt table roundtrips for every codepoint and preserves the
// original grapheme classes exactly.
for (let cp = 0; cp <= 0x10ffff; cp++) {
const got = newStage3[newStage2[newStage1[cp >> 8] + (cp & 0xff)]];
if (got !== packed(cp)) throw new Error(`roundtrip mismatch at U+${cp.toString(16)}`);
if ((got & 0x1f) !== classOf(cp)) throw new Error(`class mismatch at U+${cp.toString(16)}`);
}
// ---------------------------------------------------------------------------
// Emit
// ---------------------------------------------------------------------------
function formatArray(values, perLine) {
const lines = [];
for (let i = 0; i < values.length; i += perLine) {
lines.push(" " + values.slice(i, i + perLine).join(", ") + ",");
}
return lines.join("\n");
}
const output = `// clang-format off
// Generated by scripts/generate-stringwidth-tables.mjs from the Unicode
// ${UNICODE_VERSION} Character Database. Do not edit manually; regenerate with
// bun scripts/generate-stringwidth-tables.mjs
//
// 3-stage lookup of the packed per-codepoint classification used by
// stringWidth.cpp: stage1[cp >> 8] + (cp & 0xFF) indexes stage2, which
// indexes stage3. Each stage3 byte packs:
// bits 0-4 GraphemeBreakClass ordinal (grapheme break property + Indic
// Conjunct Break, uucode-derived)
// bits 5-6 width class: 0 zero-width, 1 narrow, 2 wide (East Asian Width
// W/F), 3 East Asian Ambiguous
// bit 7 the Unicode Emoji property, with the isEmojiPresentation()
// early-outs (< U+203C, [U+2C00, U+1F000), VS15/VS16/ZWJ) baked in
// Zero-width: C0/C1 controls, soft hyphen, every Mn/Me mark, conjoining Hangul
// jungseong/jongseong (U+1160-U+11FF, U+D7B0-U+D7FF), invisible format
// characters (bidi controls, ZWSP/ZWJ/ZWNJ, U+2060-206F, tags, BOM),
// surrogates and the Indic vowel-sign heuristic; see isZeroWidth() in the
// generator.
#pragma once
#include <cstdint>
namespace Bun {
namespace StringWidthTables {
static constexpr uint16_t kGraphemeBreakStage1[${newStage1.length}] = {
${formatArray(newStage1, 24)}
};
static constexpr uint8_t kGraphemeBreakStage2[${newStage2.length}] = {
${formatArray(newStage2, 32)}
};
static constexpr uint8_t kGraphemeBreakStage3[${newStage3.length}] = {
${formatArray(newStage3, 24)}
};
} // namespace StringWidthTables
} // namespace Bun
`;
writeFileSync(headerPath, output);
console.log(
`Unicode ${UNICODE_VERSION}: stage1: ${newStage1.length} entries, stage2: ${newStage2.length} entries, ` +
`stage3: ${newStage3.length} distinct packed values`,
);