Skip to content

Commit 0c96ff3

Browse files
committed
fix(wcwidth): treat surrogates as non-printable
utf8_decode() accepts CESU-8 surrogate encodings, and the Unicode 17 tables default unlisted codepoints to width 1, so a surrogate reaching draw_text was emitted to the terminal as invalid UTF-8 (the old termbox2 table returned -1 for U+D800..U+DFFF and the emit path sanitized it to U+FFFD). Guard the surrogate range in the generated wcwidth() so iswprint() rejects it and the emit path substitutes U+FFFD again. Also derive the generated header's Unicode version from the same constant as the data URL; it was left saying 16.0 after the 17.0 upgrade.
1 parent 6a42d10 commit 0c96ff3

3 files changed

Lines changed: 72 additions & 10 deletions

File tree

src/wcwidth.c

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* wcwidth.c - Unicode character width lookup
2-
* Unicode 16.0 - generated by tasks/gen-wcwidth.ts
2+
* Unicode 17.0 - generated by tasks/gen-wcwidth.ts
33
* Data hash: d73f21ced2426cb4
44
*
55
* Only zero-width (combining marks + default ignorable) and double-width
@@ -19,7 +19,8 @@
1919
* Noncharacters (U+FDD0-U+FDEF and U+nFFFE/U+nFFFF) return -1: they are
2020
* permanently unassigned and never printable. They cannot be stored in the
2121
* packed table (the width bit only encodes 0 vs 2), so wcwidth() handles them
22-
* with a dedicated guard.
22+
* with a dedicated guard. Surrogates (U+D800-U+DFFF) return -1 for the same
23+
* reason: they are not scalar values and only reach us via malformed UTF-8.
2324
*
2425
* Combining (width 0) and wide (width 2) ranges are merged into a single
2526
* sorted table so wcwidth() needs only one binary search for any codepoint.
@@ -182,6 +183,10 @@ int wcwidth(uint32_t codepoint) {
182183
return 1;
183184
if (codepoint < 0x20 || (codepoint > 0x7e && codepoint < 0xa0))
184185
return codepoint == 0 ? 0 : -1;
186+
/* Surrogates are not scalar values; they only appear via malformed
187+
* UTF-8 and are never printable. */
188+
if ((codepoint & 0xfffff800) == 0xd800)
189+
return -1;
185190
/* Noncharacters are permanently unassigned and never printable:
186191
* U+FDD0..U+FDEF and the last two codepoints of every plane
187192
* (U+nFFFE/U+nFFFF). */

tasks/gen-wcwidth.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88
// BMP coarse filter: a 64-byte bitmap (1 bit per 128-codepoint block) gates
99
// the binary search. Clean blocks return width 1 without touching the table.
1010
//
11-
// Lookup order in wcwidth(): ASCII/Latin-1 fast paths, then a noncharacter
12-
// guard, then codepoint_in_special() which checks (1) a TUI box-drawing fast
13-
// lane, (2) the large contiguous blocks (CJK/Hangul/SIP/tags), (3) the BMP
14-
// coarse filter, (4) the packed small-range binary search.
11+
// Lookup order in wcwidth(): ASCII/Latin-1 fast paths, then surrogate and
12+
// noncharacter guards, then codepoint_in_special() which checks (1) a TUI
13+
// box-drawing fast lane, (2) the large contiguous blocks
14+
// (CJK/Hangul/SIP/tags), (3) the BMP coarse filter, (4) the packed
15+
// small-range binary search.
1516

16-
let UNICODE_BASE = "https://www.unicode.org/Public/17.0.0/ucd";
17+
let UNICODE_VERSION = "17.0";
18+
let UNICODE_BASE = `https://www.unicode.org/Public/${UNICODE_VERSION}.0/ucd`;
1719

1820
interface Interval {
1921
start: number;
@@ -389,7 +391,7 @@ let dataHash = (await sha256Hex(
389391

390392
let output = `\
391393
/* wcwidth.c - Unicode character width lookup
392-
* Unicode 16.0 - generated by tasks/gen-wcwidth.ts
394+
* Unicode ${UNICODE_VERSION} - generated by tasks/gen-wcwidth.ts
393395
* Data hash: ${dataHash}
394396
*
395397
* Only zero-width (combining marks + default ignorable) and double-width
@@ -409,7 +411,8 @@ let output = `\
409411
* Noncharacters (U+FDD0-U+FDEF and U+nFFFE/U+nFFFF) return -1: they are
410412
* permanently unassigned and never printable. They cannot be stored in the
411413
* packed table (the width bit only encodes 0 vs 2), so wcwidth() handles them
412-
* with a dedicated guard.
414+
* with a dedicated guard. Surrogates (U+D800-U+DFFF) return -1 for the same
415+
* reason: they are not scalar values and only reach us via malformed UTF-8.
413416
*
414417
* Combining (width 0) and wide (width 2) ranges are merged into a single
415418
* sorted table so wcwidth() needs only one binary search for any codepoint.
@@ -510,6 +513,10 @@ int wcwidth(uint32_t codepoint) {
510513
return 1;
511514
if (codepoint < 0x20 || (codepoint > 0x7e && codepoint < 0xa0))
512515
return codepoint == 0 ? 0 : -1;
516+
/* Surrogates are not scalar values; they only appear via malformed
517+
* UTF-8 and are never printable. */
518+
if ((codepoint & 0xfffff800) == 0xd800)
519+
return -1;
513520
/* Noncharacters are permanently unassigned and never printable:
514521
* U+FDD0..U+FDEF and the last two codepoints of every plane
515522
* (U+nFFFE/U+nFFFF). */

test/width.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
import { describe, expect, it } from "./suite.ts";
22
import { createTerm } from "../term.ts";
3-
import { close, grow, open, text } from "../ops.ts";
3+
import { close, grow, open, pack, text } from "../ops.ts";
4+
import { createTermNative } from "../term-native.ts";
45
import { print } from "./print.ts";
56

67
const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes);
78

9+
function indexOfSeq(haystack: Uint8Array, needle: number[]): number {
10+
outer: for (let i = 0; i + needle.length <= haystack.length; i++) {
11+
for (let j = 0; j < needle.length; j++) {
12+
if (haystack[i + j] !== needle[j]) continue outer;
13+
}
14+
return i;
15+
}
16+
return -1;
17+
}
18+
819
describe("width", () => {
920
it("measures non-printable codepoints as one replacement cell", async () => {
1021
let term = await createTerm({ width: 20, height: 4 });
@@ -44,4 +55,43 @@ describe("width", () => {
4455

4556
expect(out).toContain("a�b");
4657
});
58+
59+
it("replaces surrogate codepoints from malformed UTF-8 with U+FFFD", async () => {
60+
let native = await createTermNative(10, 3);
61+
62+
// TextEncoder never produces surrogate bytes, so pack a placeholder
63+
// (U+1234 = E1 88 B4) and patch it to CESU-8 U+D800 (ED A0 80) to
64+
// reach the decoder the way a malformed C-side buffer would.
65+
let ops = [
66+
open("root", {
67+
layout: { width: grow(), height: grow(), direction: "ttb" },
68+
}),
69+
text("aሴb"),
70+
close(),
71+
];
72+
let len = pack(
73+
ops,
74+
native.memory.buffer,
75+
native.opsBuf,
76+
native.memory.buffer.byteLength,
77+
);
78+
// pack() returns a length in 32-bit words
79+
let buf = new Uint8Array(native.memory.buffer, native.opsBuf, len * 4);
80+
let at = indexOfSeq(buf, [0xe1, 0x88, 0xb4]);
81+
expect(at).toBeGreaterThan(-1);
82+
buf[at] = 0xed;
83+
buf[at + 1] = 0xa0;
84+
buf[at + 2] = 0x80;
85+
86+
native.reduce(native.statePtr, native.opsBuf, len, 0, 1, 0);
87+
let out = new Uint8Array(
88+
native.memory.buffer,
89+
native.output(native.statePtr),
90+
native.length(native.statePtr),
91+
);
92+
93+
// the invalid sequence must never reach the terminal
94+
expect(indexOfSeq(out, [0xed, 0xa0, 0x80])).toBe(-1);
95+
expect(print(decode(out), 10, 3)).toContain("a�b");
96+
});
4797
});

0 commit comments

Comments
 (0)