@@ -95,6 +95,186 @@ test("renderMarkdown preserves empty table cells", () => {
9595 assert . equal ( ( bodyRow . match ( / │ / g) ?? [ ] ) . length , 4 ) ;
9696} ) ;
9797
98+ test ( "renderMarkdown aligns table borders when rows contain flag emoji" , ( ) => {
99+ // Flag emoji (🇧🇷) are regional-indicator pairs — each code is wide by
100+ // EastAsianWidth, but together they render as one 2-cell flag. visualWidth
101+ // must treat the pair as 2 cells, otherwise data rows misalign with borders.
102+ const table = [
103+ "| 对阵 | 比分 | 状态 |" ,
104+ "|---|---|---|" ,
105+ "| 🇧🇷 巴西 vs 🇳🇴 挪威 | 1 - 2 | 已结束 |" ,
106+ "| 🇲🇽 墨西哥 vs 🏴 英格兰 | 1 - 2 | 进行中 |" ,
107+ ] . join ( "\n" ) ;
108+
109+ const segment = renderMarkdownSegments ( table , 120 ) . find ( ( item ) => item . kind === "table" ) ;
110+ assert . ok ( segment ) ;
111+ const stripped = stripAnsi ( segment . body ) ;
112+ const lines = stripped . split ( "\n" ) ;
113+ const border = lines . find ( ( l ) => l . startsWith ( "├" ) ) ;
114+ assert . ok ( border ) ;
115+
116+ // Border lines contain only single-cell box characters, so character index
117+ // equals visual column there. Collect every pipe position on the border.
118+ const expectedCols : number [ ] = [ ] ;
119+ for ( let i = 0 ; i < border . length ; i ++ ) {
120+ const c = border [ i ] ;
121+ if ( c === "├" || c === "┼" || c === "┤" ) expectedCols . push ( i ) ;
122+ }
123+ assert . ok ( expectedCols . length >= 3 ) ;
124+
125+ // On data rows the character index of '│' is NOT the visual column (emoji
126+ // and CJK shift things), so walk the string tracking visual width to find
127+ // the visual column where each '│' actually lands.
128+ const visualColsOfPipes = ( line : string ) : number [ ] => {
129+ const cols : number [ ] = [ ] ;
130+ let col = 0 ;
131+ const chars = Array . from ( line ) ;
132+ for ( let i = 0 ; i < chars . length ; i ++ ) {
133+ const ch = chars [ i ] ;
134+ if ( ch === "│" ) {
135+ cols . push ( col ) ;
136+ col += 1 ;
137+ continue ;
138+ }
139+ const code = ch . codePointAt ( 0 ) ?? 0 ;
140+ // Zero-width sequences: ZWJ, emoji tags, variation selectors, combining
141+ // marks, and the keycap combining enclosure (U+20E3).
142+ if (
143+ code === 0x200d ||
144+ ( code >= 0xe0020 && code <= 0xe007f ) ||
145+ ( code >= 0xfe00 && code <= 0xfe0f ) ||
146+ ( code >= 0x0300 && code <= 0x036f ) ||
147+ code === 0x20e3
148+ )
149+ continue ;
150+ // Regional indicators form a flag pair — count 2 cells for the pair.
151+ if ( code >= 0x1f1e6 && code <= 0x1f1ff ) {
152+ const next = chars [ i + 1 ] ?. codePointAt ( 0 ) ?? 0 ;
153+ if ( next >= 0x1f1e6 && next <= 0x1f1ff ) {
154+ col += 2 ;
155+ i ++ ;
156+ continue ;
157+ }
158+ }
159+ // Astral-plane chars (surrogate pairs) are wide.
160+ if ( ch . length >= 2 ) {
161+ col += 2 ;
162+ continue ;
163+ }
164+ // CJK / fullwidth / pictographs are wide.
165+ const isWide =
166+ ( code >= 0x1100 && code <= 0x115f ) ||
167+ ( code >= 0x2e80 && code <= 0xa4cf ) ||
168+ ( code >= 0xac00 && code <= 0xd7af ) ||
169+ ( code >= 0xf900 && code <= 0xfaff ) ||
170+ ( code >= 0xfe10 && code <= 0xfe6f ) ||
171+ ( code >= 0xff00 && code <= 0xffe6 ) ||
172+ ( code >= 0x20000 && code <= 0x3fffd ) ||
173+ ( code >= 0x1f300 && code <= 0x1faff ) ||
174+ ( code >= 0x2600 && code <= 0x27bf ) ||
175+ ( code >= 0x2300 && code <= 0x23ff ) ||
176+ ( code >= 0x2b00 && code <= 0x2bff ) ||
177+ ( code >= 0x1f000 && code <= 0x1f02f ) ;
178+ col += isWide ? 2 : 1 ;
179+ }
180+ return cols ;
181+ } ;
182+
183+ for ( const line of lines ) {
184+ if ( ! line . includes ( "│" ) || line . startsWith ( "┌" ) || line . startsWith ( "├" ) || line . startsWith ( "└" ) ) continue ;
185+ assert . deepEqual (
186+ visualColsOfPipes ( line ) ,
187+ expectedCols ,
188+ `data row pipes must line up with border crossings: ${ line } `
189+ ) ;
190+ }
191+ } ) ;
192+
193+ test ( "renderMarkdown aligns table borders when rows contain keycap emoji" , ( ) => {
194+ // Keycap emoji like 0️⃣ are a base digit + U+FE0F variation selector + U+20E3
195+ // combining enclosing keycap. Both modifiers are zero-width — if visualWidth
196+ // counts them as 1 each, the cell ends up 2 cells too wide and pipes misalign.
197+ const table = [
198+ "| 日期 | 对阵 | 比分 | 晋级方 | 备注 |" ,
199+ "|---|---|---|---|---|" ,
200+ "| 7月4日 | 加拿大 vs 摩洛哥 | 0️⃣ - 3 🏆 | 摩洛哥 | 首场1/8决赛 |" ,
201+ "| 7月5日 | 巴西 vs 挪威 | 1️⃣ - 2 🏆 | 挪威 ⚡ | 大冷门! |" ,
202+ "| 7月6日 🟡 | 葡萄牙 vs 西班牙 | 今晚 7:00PM | ⏳ 待赛 | 伊比利亚德比🔥 |" ,
203+ ] . join ( "\n" ) ;
204+
205+ const segment = renderMarkdownSegments ( table , 120 ) . find ( ( item ) => item . kind === "table" ) ;
206+ assert . ok ( segment ) ;
207+ const stripped = stripAnsi ( segment . body ) ;
208+ const lines = stripped . split ( "\n" ) ;
209+ const border = lines . find ( ( l ) => l . startsWith ( "├" ) ) ;
210+ assert . ok ( border ) ;
211+
212+ const expectedCols : number [ ] = [ ] ;
213+ for ( let i = 0 ; i < border . length ; i ++ ) {
214+ const c = border [ i ] ;
215+ if ( c === "├" || c === "┼" || c === "┤" ) expectedCols . push ( i ) ;
216+ }
217+
218+ const visualColsOfPipes = ( line : string ) : number [ ] => {
219+ const cols : number [ ] = [ ] ;
220+ let col = 0 ;
221+ const chars = Array . from ( line ) ;
222+ for ( let i = 0 ; i < chars . length ; i ++ ) {
223+ const ch = chars [ i ] ;
224+ if ( ch === "│" ) {
225+ cols . push ( col ) ;
226+ col += 1 ;
227+ continue ;
228+ }
229+ const code = ch . codePointAt ( 0 ) ?? 0 ;
230+ if (
231+ code === 0x200d ||
232+ ( code >= 0xe0020 && code <= 0xe007f ) ||
233+ ( code >= 0xfe00 && code <= 0xfe0f ) ||
234+ ( code >= 0x0300 && code <= 0x036f ) ||
235+ code === 0x20e3
236+ )
237+ continue ;
238+ if ( code >= 0x1f1e6 && code <= 0x1f1ff ) {
239+ const next = chars [ i + 1 ] ?. codePointAt ( 0 ) ?? 0 ;
240+ if ( next >= 0x1f1e6 && next <= 0x1f1ff ) {
241+ col += 2 ;
242+ i ++ ;
243+ continue ;
244+ }
245+ }
246+ if ( ch . length >= 2 ) {
247+ col += 2 ;
248+ continue ;
249+ }
250+ const isWide =
251+ ( code >= 0x1100 && code <= 0x115f ) ||
252+ ( code >= 0x2e80 && code <= 0xa4cf ) ||
253+ ( code >= 0xac00 && code <= 0xd7af ) ||
254+ ( code >= 0xf900 && code <= 0xfaff ) ||
255+ ( code >= 0xfe10 && code <= 0xfe6f ) ||
256+ ( code >= 0xff00 && code <= 0xffe6 ) ||
257+ ( code >= 0x20000 && code <= 0x3fffd ) ||
258+ ( code >= 0x1f300 && code <= 0x1faff ) ||
259+ ( code >= 0x2600 && code <= 0x27bf ) ||
260+ ( code >= 0x2300 && code <= 0x23ff ) ||
261+ ( code >= 0x2b00 && code <= 0x2bff ) ||
262+ ( code >= 0x1f000 && code <= 0x1f02f ) ;
263+ col += isWide ? 2 : 1 ;
264+ }
265+ return cols ;
266+ } ;
267+
268+ for ( const line of lines ) {
269+ if ( ! line . includes ( "│" ) || line . startsWith ( "┌" ) || line . startsWith ( "├" ) || line . startsWith ( "└" ) ) continue ;
270+ assert . deepEqual (
271+ visualColsOfPipes ( line ) ,
272+ expectedCols ,
273+ `data row pipes must line up with border crossings: ${ line } `
274+ ) ;
275+ }
276+ } ) ;
277+
98278test ( "renderMarkdown keeps text separated from rendered table blocks" , ( ) => {
99279 const result = stripAnsi ( renderMarkdown ( "Before\n| A | B |\n|---|---|\n| 1 | 2 |\nAfter" , 40 ) ) ;
100280 assert . equal ( result . includes ( "Before\n┌" ) , true ) ;
0 commit comments