From 32883010e2e305006add54b8884934f43696230f Mon Sep 17 00:00:00 2001 From: seeones Date: Wed, 5 Aug 2026 20:41:02 +0800 Subject: [PATCH] Improve MD060 wide-character coverage (arrows, rare CJK, fullwidth forms) Replace the string-width dependency with a local visualWidth helper based on Intl.Segmenter plus an explicit UAX #11 Wide/Fullwidth character class, so table column alignment also handles arrows (U+2190-U+21FF), Hangul Jamo, CJK extensions/rare ideographs, vertical forms, and fullwidth symbols. Adds arrow and rare-CJK cases to the wide-character table test and updates its snapshot. --- lib/md060.mjs | 34 +++++++++++++++++-- test/markdownlint-test-scenarios.mjs.snapshot | 2 +- test/table-column-style-wide-characters.md | 15 ++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/lib/md060.mjs b/lib/md060.mjs index 58e7fc6d3..204e96982 100644 --- a/lib/md060.mjs +++ b/lib/md060.mjs @@ -2,7 +2,37 @@ import { filterByTypes } from "../helpers/micromark-helpers.cjs"; import { filterByTypesCached } from "./cache.mjs"; -import stringWidth from "string-width"; + +// Emoji and other characters with double visual width (width 2) +// Extended_Pictographic covers graphical emoji; the remaining ranges cover +// the Wide/Fullwidth values of UAX #11 (East Asian Width, Unicode 17.0): +// https://www.unicode.org/reports/tr11/ +// Note that arrows (U+2190-U+21FF) are "Ambiguous" in UAX #11 but are +// rendered double-width in CJK contexts, so they are included here. +// The U+1F200-U+1F265 range is kept explicit because only part of it is +// Extended_Pictographic (the lint rule's Unicode data disagrees with V8). +// eslint-disable-next-line regexp/no-dupe-characters-character-class +const doubleWidthRe = /[\p{Extended_Pictographic}\u{1100}-\u{115F}\u{2190}-\u{21FF}\u{2329}\u{232A}\u{2E80}-\u{2FFF}\u{3000}-\u{9FFF}\u{A000}-\u{A4CF}\u{A960}-\u{A97F}\u{AC00}-\u{D7FF}\u{F900}-\u{FAFF}\u{FE10}-\u{FE19}\u{FE30}-\u{FE6B}\u{FF01}-\u{FF60}\u{FFE0}-\u{FFE6}\u{16FE0}-\u{16FF6}\u{17000}-\u{18D1E}\u{1AFF0}-\u{1AFFE}\u{1B000}-\u{1B2FB}\u{1D300}-\u{1D376}\u{1F200}-\u{1F265}\u{20000}-\u{3FFFD}]/gu; + +const segmenter = new Intl.Segmenter(); + +/** + * Gets the visual width of a string (number of columns to display it). + * + * Computed as the number of grapheme clusters plus the number of + * double-width characters (emoji and UAX #11 Wide/Fullwidth characters). + * + * @param {string} text Input text. + * @returns {number} Visual width. + */ +function visualWidth(text) { + let graphemes = 0; + // eslint-disable-next-line no-unused-vars + for (const unused of segmenter.segment(text)) { + graphemes++; + } + return graphemes + (text.match(doubleWidthRe)?.length ?? 0); +} /** @typedef {typeof import("micromark-extension-gfm-table")} _ambient_ */ /** @typedef {import("markdownlint").MicromarkToken} MicromarkToken */ @@ -47,7 +77,7 @@ function getTableDividerColumns(lines, row) { ).map( (divider) => ({ "actual": divider.startColumn, - "effective": stringWidth(lines[row.startLine - 1].slice(0, divider.startColumn - 1)) + "effective": visualWidth(lines[row.startLine - 1].slice(0, divider.startColumn - 1)) }) ); } diff --git a/test/markdownlint-test-scenarios.mjs.snapshot b/test/markdownlint-test-scenarios.mjs.snapshot index 1fae94c68..6241d28d1 100644 --- a/test/markdownlint-test-scenarios.mjs.snapshot +++ b/test/markdownlint-test-scenarios.mjs.snapshot @@ -60518,7 +60518,7 @@ exports[`markdownlint-test-scenarios.mjs > table-column-style-trailing-spaces.md exports[`markdownlint-test-scenarios.mjs > table-column-style-wide-characters.md 1`] = ` { "errors": [], - "fixed": "# Table Column Style - Wide Characters\\n\\n## Emoji\\n\\n| AB | CD |\\n| -- | -- |\\n| EF | GH |\\n| ✅ | KL |\\n| MN | ✅ |\\n| ✅ | ✅ |\\n\\n| ✅ | CD |\\n| -- | -- |\\n| EF | GH |\\n| ✅ | KL |\\n| MN | ✅ |\\n| ✅ | ✅ |\\n\\n| AB | ✅ |\\n| -- | -- |\\n| EF | GH |\\n| ✅ | KL |\\n| MN | ✅ |\\n| ✅ | ✅ |\\n\\n| ✅ | ✅ |\\n| -- | -- |\\n| EF | GH |\\n| ✅ | KL |\\n| MN | ✅ |\\n| ✅ | ✅ |\\n\\n## Hello World\\n\\n| Language | Translation |\\n|---------------------|----------------|\\n| Emoji | 👋🌎 |\\n| Portuguese (Brazil) | Olá mundo |\\n| Turkish | Merhaba dünya |\\n| Chinese (Mandarin) | 你好,世界 |\\n| Japanese | こんにちは世界 |\\n| Korean | 안녕 세상 |\\n\\n## ANSI Escape Codes\\n\\n| Style | Escape codes |\\n| --------- | ------------------------------------------------------- |\\n| Bold | \\\\u001B[1m大胆な\\\\u001B[22m |\\n| Underline | \\\\u001B[4mUnderline\\\\u001B[0m |\\n| Link | \\\\u001B]8;;https://example.com\\\\u0007Link\\\\u001B]8;;\\\\u0007 |\\n\\n\\n" + "fixed": "# Table Column Style - Wide Characters\\n\\n## Emoji\\n\\n| AB | CD |\\n| -- | -- |\\n| EF | GH |\\n| ✅ | KL |\\n| MN | ✅ |\\n| ✅ | ✅ |\\n\\n| ✅ | CD |\\n| -- | -- |\\n| EF | GH |\\n| ✅ | KL |\\n| MN | ✅ |\\n| ✅ | ✅ |\\n\\n| AB | ✅ |\\n| -- | -- |\\n| EF | GH |\\n| ✅ | KL |\\n| MN | ✅ |\\n| ✅ | ✅ |\\n\\n| ✅ | ✅ |\\n| -- | -- |\\n| EF | GH |\\n| ✅ | KL |\\n| MN | ✅ |\\n| ✅ | ✅ |\\n\\n## Hello World\\n\\n| Language | Translation |\\n|---------------------|----------------|\\n| Emoji | 👋🌎 |\\n| Portuguese (Brazil) | Olá mundo |\\n| Turkish | Merhaba dünya |\\n| Chinese (Mandarin) | 你好,世界 |\\n| Japanese | こんにちは世界 |\\n| Korean | 안녕 세상 |\\n\\n## ANSI Escape Codes\\n\\n| Style | Escape codes |\\n| --------- | ------------------------------------------------------- |\\n| Bold | \\\\u001B[1m大胆な\\\\u001B[22m |\\n| Underline | \\\\u001B[4mUnderline\\\\u001B[0m |\\n| Link | \\\\u001B]8;;https://example.com\\\\u0007Link\\\\u001B]8;;\\\\u0007 |\\n\\n## Arrows\\n\\n| Dir | Icon |\\n| --- | ---- |\\n| Up | ↑ |\\n| Dn | ↓ |\\n| LfR | ←→ |\\n\\n## Rare CJK\\n\\n| Rare | Note |\\n| ---- | ---- |\\n| 𠀀 | ExtB |\\n| 〇 | Zero |\\n\\n\\n" } `; diff --git a/test/table-column-style-wide-characters.md b/test/table-column-style-wide-characters.md index abb302385..89499150d 100644 --- a/test/table-column-style-wide-characters.md +++ b/test/table-column-style-wide-characters.md @@ -49,6 +49,21 @@ | Underline | \u001B[4mUnderline\u001B[0m | | Link | \u001B]8;;https://example.com\u0007Link\u001B]8;;\u0007 | +## Arrows + +| Dir | Icon | +| --- | ---- | +| Up | ↑ | +| Dn | ↓ | +| LfR | ←→ | + +## Rare CJK + +| Rare | Note | +| ---- | ---- | +| 𠀀 | ExtB | +| 〇 | Zero | +