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
72 changes: 72 additions & 0 deletions pkgs/id/web/src/world-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,4 +358,76 @@ describe("world screen presenter", () => {
expect(renderWorldSurface(root, makeScreen([{ symbol, fg: 0, bg: 0, bold: false, columns: 1 }]))).toBe(false);
}
});

it("measures U+FE0F clusters by the base's emoji-presentability, like Rust unicode-width 0.2.2 (#3265)", () => {
const root = document.createElement("div");
const screenWith = (symbol: string, columns: 1 | 2): ScreenSurface =>
columns === 1
? makeScreen([{ symbol, fg: 0, bg: 0, bold: false, columns: 1 }])
: {
kind: "screen",
version: 1,
width: 2,
height: 1,
cells: [
{ symbol, fg: 0, bg: 0, bold: false, columns: 2 },
{ symbol: "", fg: 0, bg: 0, bold: false, columns: 0 },
],
};
// Bases unicode-width 0.2.2 does NOT widen with U+FE0F — letters,
// punctuation, U+2192 and space measure 1, so the Rust-valid
// columns:1 cell renders and a columns:2 claim is refused. A
// combining mark between the base and U+FE0F also breaks the
// widening (© + U+0301 + U+FE0F measures 1).
const narrowGlyphs = [
"\u0061\uFE0F",
"\u0078\uFE0F",
"\u005A\uFE0F",
"\u00E9\uFE0F",
"\u03A9\uFE0F",
"\u2192\uFE0F",
"\u0021\uFE0F",
"\u003F\uFE0F",
"\u0025\uFE0F",
"\u0020\uFE0F",
"\u00A9\u0301\uFE0F",
];
for (const symbol of narrowGlyphs) {
expect(renderWorldSurface(root, screenWith(symbol, 1))).toBe(true);
expect(renderWorldSurface(root, screenWith(symbol, 2))).toBe(false);
}
// A lone U+FE0F measures 0 in Rust: no columns value can carry it.
expect(renderWorldSurface(root, screenWith("\uFE0F", 1))).toBe(false);
expect(renderWorldSurface(root, screenWith("\uFE0F", 2))).toBe(false);
// Emoji-presentable bases still widen to 2: ⚠ ☝ ❤ © ® ™ ♠ ♥ ♦ ▶ ◀
// ↔ ↩ ⤴ 〰 〽 ㊗ ㊙, digits / # / * with U+FE0F, and keycap sequences.
const wideGlyphs = [
"\u26A0\uFE0F",
"\u261D\uFE0F",
"\u2764\uFE0F",
"\u00A9\uFE0F",
"\u00AE\uFE0F",
"\u2122\uFE0F",
"\u2660\uFE0F",
"\u2665\uFE0F",
"\u2666\uFE0F",
"\u25B6\uFE0F",
"\u25C0\uFE0F",
"\u2194\uFE0F",
"\u21A9\uFE0F",
"\u2934\uFE0F",
"\u3030\uFE0F",
"\u303D\uFE0F",
"\u3297\uFE0F",
"\u3299\uFE0F",
"\u0031\uFE0F",
"\u0023\uFE0F",
"\u002A\uFE0F",
"\u0039\uFE0F\u20E3",
];
for (const symbol of wideGlyphs) {
expect(renderWorldSurface(root, screenWith(symbol, 2))).toBe(true);
expect(renderWorldSurface(root, screenWith(symbol, 1))).toBe(false);
}
});
});
61 changes: 58 additions & 3 deletions pkgs/id/web/src/world-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,49 @@ function isTextPresentationCodePoint(codePoint: number): boolean {
return TEXT_PRESENTATION_RANGES.some(([low, high]) => codePoint >= low && codePoint <= high);
}

// Code points that can start an emoji presentation sequence under
// unicode-width 0.2.2 (its `starts_emoji_presentation_seq` table, decoded
// verbatim from the crate's EMOJI_PRESENTATION_LEAVES bit table): a cluster
// whose U+FE0F immediately follows one of these measures 2; U+FE0F after
// any other base measures 0 in the plain sum (#3265).
const EMOJI_PRESENTATION_SEQ_RANGES: ReadonlyArray<readonly [number, number]> = [
[0x23, 0x23], [0x2a, 0x2a], [0x30, 0x39], [0xa9, 0xa9], [0xae, 0xae], [0x203c, 0x203c],
[0x2049, 0x2049], [0x2122, 0x2122], [0x2139, 0x2139], [0x2194, 0x2199], [0x21a9, 0x21aa], [0x231a, 0x231b],
[0x2328, 0x2328], [0x23cf, 0x23cf], [0x23e9, 0x23f3], [0x23f8, 0x23fa], [0x24c2, 0x24c2], [0x25aa, 0x25ab],
[0x25b6, 0x25b6], [0x25c0, 0x25c0], [0x25fb, 0x25fe], [0x2600, 0x2604], [0x260e, 0x260e], [0x2611, 0x2611],
[0x2614, 0x2615], [0x2618, 0x2618], [0x261d, 0x261d], [0x2620, 0x2620], [0x2622, 0x2623], [0x2626, 0x2626],
[0x262a, 0x262a], [0x262e, 0x262f], [0x2638, 0x263a], [0x2640, 0x2640], [0x2642, 0x2642], [0x2648, 0x2653],
[0x265f, 0x2660], [0x2663, 0x2663], [0x2665, 0x2666], [0x2668, 0x2668], [0x267b, 0x267b], [0x267e, 0x267f],
[0x2692, 0x2697], [0x2699, 0x2699], [0x269b, 0x269c], [0x26a0, 0x26a1], [0x26a7, 0x26a7], [0x26aa, 0x26ab],
[0x26b0, 0x26b1], [0x26bd, 0x26be], [0x26c4, 0x26c5], [0x26c8, 0x26c8], [0x26ce, 0x26cf], [0x26d1, 0x26d1],
[0x26d3, 0x26d4], [0x26e9, 0x26ea], [0x26f0, 0x26f5], [0x26f7, 0x26fa], [0x26fd, 0x26fd], [0x2702, 0x2702],
[0x2705, 0x2705], [0x2708, 0x270d], [0x270f, 0x270f], [0x2712, 0x2712], [0x2714, 0x2714], [0x2716, 0x2716],
[0x271d, 0x271d], [0x2721, 0x2721], [0x2728, 0x2728], [0x2733, 0x2734], [0x2744, 0x2744], [0x2747, 0x2747],
[0x274c, 0x274c], [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2763, 0x2764], [0x2795, 0x2797],
[0x27a1, 0x27a1], [0x27b0, 0x27b0], [0x27bf, 0x27bf], [0x2934, 0x2935], [0x2b05, 0x2b07], [0x2b1b, 0x2b1c],
[0x2b50, 0x2b50], [0x2b55, 0x2b55], [0x3030, 0x3030], [0x303d, 0x303d], [0x3297, 0x3297], [0x3299, 0x3299],
[0x1f004, 0x1f004], [0x1f170, 0x1f171], [0x1f17e, 0x1f17f], [0x1f202, 0x1f202], [0x1f21a, 0x1f21a], [0x1f22f, 0x1f22f],
[0x1f237, 0x1f237], [0x1f30d, 0x1f30f], [0x1f315, 0x1f315], [0x1f31c, 0x1f31c], [0x1f321, 0x1f321], [0x1f324, 0x1f32c],
[0x1f336, 0x1f336], [0x1f378, 0x1f378], [0x1f37d, 0x1f37d], [0x1f393, 0x1f393], [0x1f396, 0x1f397], [0x1f399, 0x1f39b],
[0x1f39e, 0x1f39f], [0x1f3a7, 0x1f3a7], [0x1f3ac, 0x1f3ae], [0x1f3c2, 0x1f3c2], [0x1f3c4, 0x1f3c4], [0x1f3c6, 0x1f3c6],
[0x1f3ca, 0x1f3ce], [0x1f3d4, 0x1f3e0], [0x1f3ed, 0x1f3ed], [0x1f3f3, 0x1f3f3], [0x1f3f5, 0x1f3f5], [0x1f3f7, 0x1f3f7],
[0x1f408, 0x1f408], [0x1f415, 0x1f415], [0x1f41f, 0x1f41f], [0x1f426, 0x1f426], [0x1f43f, 0x1f43f], [0x1f441, 0x1f442],
[0x1f446, 0x1f449], [0x1f44d, 0x1f44e], [0x1f453, 0x1f453], [0x1f46a, 0x1f46a], [0x1f47d, 0x1f47d], [0x1f4a3, 0x1f4a3],
[0x1f4b0, 0x1f4b0], [0x1f4b3, 0x1f4b3], [0x1f4bb, 0x1f4bb], [0x1f4bf, 0x1f4bf], [0x1f4cb, 0x1f4cb], [0x1f4da, 0x1f4da],
[0x1f4df, 0x1f4df], [0x1f4e4, 0x1f4e6], [0x1f4ea, 0x1f4ed], [0x1f4f7, 0x1f4f7], [0x1f4f9, 0x1f4fb], [0x1f4fd, 0x1f4fd],
[0x1f508, 0x1f508], [0x1f50d, 0x1f50d], [0x1f512, 0x1f513], [0x1f549, 0x1f54a], [0x1f550, 0x1f567], [0x1f56f, 0x1f570],
[0x1f573, 0x1f579], [0x1f587, 0x1f587], [0x1f58a, 0x1f58d], [0x1f590, 0x1f590], [0x1f5a5, 0x1f5a5], [0x1f5a8, 0x1f5a8],
[0x1f5b1, 0x1f5b2], [0x1f5bc, 0x1f5bc], [0x1f5c2, 0x1f5c4], [0x1f5d1, 0x1f5d3], [0x1f5dc, 0x1f5de], [0x1f5e1, 0x1f5e1],
[0x1f5e3, 0x1f5e3], [0x1f5e8, 0x1f5e8], [0x1f5ef, 0x1f5ef], [0x1f5f3, 0x1f5f3], [0x1f5fa, 0x1f5fa], [0x1f610, 0x1f610],
[0x1f687, 0x1f687], [0x1f68d, 0x1f68d], [0x1f691, 0x1f691], [0x1f694, 0x1f694], [0x1f698, 0x1f698], [0x1f6ad, 0x1f6ad],
[0x1f6b2, 0x1f6b2], [0x1f6b9, 0x1f6ba], [0x1f6bc, 0x1f6bc], [0x1f6cb, 0x1f6cb], [0x1f6cd, 0x1f6cf], [0x1f6e0, 0x1f6e5],
[0x1f6e9, 0x1f6e9], [0x1f6f0, 0x1f6f0], [0x1f6f3, 0x1f6f3],
];

function isEmojiPresentationSeqBase(codePoint: number): boolean {
return EMOJI_PRESENTATION_SEQ_RANGES.some(([low, high]) => codePoint >= low && codePoint <= high);
}

// Non-spacing and enclosing marks (Mn, Me) measure 0 under unicode-width
// 0.2, but spacing marks (Mc, e.g. U+0903) measure 1, like a base character
// — a blanket \p{M} rule diverged on lone spacing marks (#1581).
Expand Down Expand Up @@ -324,8 +367,15 @@ function codePointWidth(character: string, codePoint: number): number {
/**
* Width of a single grapheme cluster under unicode-width 0.2 (#761):
* a ZWJ sequence containing a wide/emoji code point measures 2 in total
* (not per component), and U+FE0F (emoji presentation) forces width 2 —
* e.g. 😀, 𠮷, 👨‍👩‍👧 and ⚠️ are all width 2, matching the Rust validator.
* (not per component), and U+FE0F (emoji presentation) forces width 2
* only when the code point immediately before it can start an emoji
* presentation sequence (EMOJI_PRESENTATION_SEQ_RANGES) — e.g. 😀, 𠮷,
* 👨‍👩‍👧 and ⚠️ are all width 2, matching the Rust validator, while a
* letter followed by U+FE0F measures 1 and a lone U+FE0F measures 0
* (#3265). (A lone U+200D measures 1 here vs 0 in Rust, but the server
* never emits one as a cell: width-0 graphemes only ever appear as the
* empty-symbol continuation cells, so that divergence is unreachable
* from valid wire data.)
* An emoji-modifier sequence (base + U+1F3FB..U+1F3FF) measures 2 as a
* whole under unicode-width 0.2 — even when the base alone is narrow
* (☝ U+261D is width 1, ☝🏻 is width 2) — and tag characters contribute
Expand All @@ -350,7 +400,12 @@ function graphemeWidth(symbol: string): number {
});
return codePoints.some((codePoint, i) => !narrowed.has(i) && isWideCodePoint(codePoint)) ? 2 : 1;
}
if (codePoints.includes(0xfe0f)) return 2;
// U+FE0F widens only an emoji-presentable base (the code point
// immediately before it); after any other base it measures 0 in the
// plain sum below, so a letter+FE0F cluster measures 1 and a lone
// U+FE0F measures 0, matching unicode-width 0.2.2 (#3265).
const fe0fIndex = codePoints.indexOf(0xfe0f);
if (fe0fIndex > 0 && isEmojiPresentationSeqBase(codePoints[fe0fIndex - 1])) return 2;
const isModifier = (codePoint: number) => codePoint >= 0x1f3fb && codePoint <= 0x1f3ff;
if (codePoints.some(isModifier)) {
const base = characters.filter((_, i) => !isModifier(codePoints[i]));
Expand Down
Loading