diff --git a/plugins/markdown/core/resources/META-INF/plugin.xml b/plugins/markdown/core/resources/META-INF/plugin.xml index e391045543f1b..0115ad5dc9737 100644 --- a/plugins/markdown/core/resources/META-INF/plugin.xml +++ b/plugins/markdown/core/resources/META-INF/plugin.xml @@ -180,6 +180,7 @@ + diff --git a/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/MarkdownCharacterGridCustomizer.kt b/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/MarkdownCharacterGridCustomizer.kt new file mode 100644 index 0000000000000..cabc06b49c4ea --- /dev/null +++ b/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/MarkdownCharacterGridCustomizer.kt @@ -0,0 +1,23 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.intellij.plugins.markdown.editor + +import com.intellij.openapi.editor.impl.EditorImpl +import com.intellij.openapi.editor.impl.view.DoubleWidthCharacterStrategy +import com.intellij.openapi.fileEditor.TextEditor +import com.intellij.openapi.fileEditor.impl.text.TextEditorCustomizer +import org.intellij.plugins.markdown.editor.tables.TableCharacterWidthUtils +import org.intellij.plugins.markdown.lang.hasMarkdownType + +internal class MarkdownCharacterGridCustomizer : TextEditorCustomizer { + override suspend fun execute(textEditor: TextEditor) { + if (!textEditor.file.hasMarkdownType()) return + + val editor = textEditor.editor as? EditorImpl ?: return + editor.settings.characterGridWidthMultiplier = 1.0f + + val grid = editor.characterGrid ?: return + grid.doubleWidthCharacterStrategy = DoubleWidthCharacterStrategy { codePoint -> + TableCharacterWidthUtils.isFullWidthCharacter(codePoint) + } + } +} diff --git a/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/tables/TableCharacterWidthUtils.kt b/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/tables/TableCharacterWidthUtils.kt new file mode 100644 index 0000000000000..b22260a28bf8c --- /dev/null +++ b/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/tables/TableCharacterWidthUtils.kt @@ -0,0 +1,112 @@ +// Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +package org.intellij.plugins.markdown.editor.tables + +/** + * Utility class for calculating character display width in Markdown tables. + * Handles proper width calculation for CJK and other full-width characters. + */ +internal object TableCharacterWidthUtils { + + /** + * Calculates the display width of a string, considering different character widths. + * Full-width characters (CJK, etc.) are counted as 2 units, + * while half-width characters (ASCII, etc.) are counted as 1 unit. + * + * @param text The text to measure + * @return The display width in character units + */ + fun calculateDisplayWidth(text: String): Int { + if (text.isEmpty()) return 0 + + var width = 0 + var i = 0 + while (i < text.length) { + val codePoint = text.codePointAt(i) + width += getCharacterWidth(codePoint) + i += Character.charCount(codePoint) + } + return width + } + + private fun getCharacterWidth(codePoint: Int): Int { + return when { + // ASCII printable characters (half-width) + codePoint in 0x20..0x7E -> 1 + + // Control characters + codePoint < 0x20 -> 0 + + // Full-width characters (CJK, emoji, etc.) + isFullWidthCharacter(codePoint) -> 2 + + // Default to 1 for other characters (Latin Extended, Cyrillic, etc.) + else -> 1 + } + } + + internal fun isFullWidthCharacter(codePoint: Int): Boolean { + return when { + // CJK Unified Ideographs + codePoint in 0x4E00..0x9FFF -> true + + // CJK Extension A + codePoint in 0x3400..0x4DBF -> true + + // CJK Extension B + codePoint in 0x20000..0x2A6DF -> true + + // CJK Extension C + codePoint in 0x2A700..0x2B73F -> true + + // CJK Extension D + codePoint in 0x2B740..0x2B81F -> true + + // CJK Extension E + codePoint in 0x2B820..0x2CEAF -> true + + // CJK Extension F + codePoint in 0x2CEB0..0x2EBEF -> true + + // CJK Compatibility Ideographs + codePoint in 0xF900..0xFAFF -> true + + // CJK Compatibility Ideographs Supplement + codePoint in 0x2F800..0x2FA1F -> true + + // CJK Symbols and Punctuation (ideographic space, 、。「」〈〉 etc.) + codePoint in 0x3000..0x303F -> true + + // Hiragana + codePoint in 0x3040..0x309F -> true + + // Katakana + codePoint in 0x30A0..0x30FF -> true + + // Katakana Phonetic Extensions + codePoint in 0x31F0..0x31FF -> true + + // Hangul Syllables + codePoint in 0xAC00..0xD7AF -> true + + // Hangul Jamo (only leading consonants are Wide per Unicode EAW) + codePoint in 0x1100..0x115F -> true + + // Hangul Jamo Extended-A + codePoint in 0xA960..0xA97F -> true + + // Fullwidth ASCII variants (FF01-FF60) and Fullwidth brackets (FF5F-FF60) + codePoint in 0xFF01..0xFF60 -> true + + // Fullwidth signs (FFE0-FFE6) + codePoint in 0xFFE0..0xFFE6 -> true + + // Emoji + codePoint in 0x1F600..0x1F64F -> true // Emoticons + codePoint in 0x1F300..0x1F5FF -> true // Misc Symbols and Pictographs + codePoint in 0x1F680..0x1F6FF -> true // Transport and Map + codePoint in 0x1F1E0..0x1F1FF -> true // Regional Indicator Symbols + + else -> false + } + } +} diff --git a/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/tables/TableFormattingUtils.kt b/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/tables/TableFormattingUtils.kt index f6b0a6dbe59b7..f21177df5afd6 100644 --- a/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/tables/TableFormattingUtils.kt +++ b/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/tables/TableFormattingUtils.kt @@ -64,8 +64,10 @@ object TableFormattingUtils { ): Int { val trimToMaxContent = trimToMaxContent && !cells.all { it.text.isBlank() } val contentCellsWidth = when { - trimToMaxContent -> cellsContentsWithCarets.asSequence().map { it.trimmedContentWithoutCarets }.maxOfOrNull { it.length + 2 } - else -> cells.maxOfOrNull { it.textRange.length } + trimToMaxContent -> cellsContentsWithCarets.asSequence().map { it.trimmedContentWithoutCarets }.maxOfOrNull { + TableCharacterWidthUtils.calculateDisplayWidth(it) + 2 + } + else -> cells.maxOfOrNull { TableCharacterWidthUtils.calculateDisplayWidth(it.text) } } checkNotNull(contentCellsWidth) return max(contentCellsWidth, separatorCellRange?.length ?: 1) @@ -93,12 +95,12 @@ object TableFormattingUtils { ) { val expectedContent = TableModificationUtils.buildRealignedCellContent( state.trimmedContentWithCarets, - maxCellWidth + state.caretsInside.size, + maxCellWidth, alignment ) val range = cell.textRange val cellContent = document.charsSequence.substring(range.startOffset, range.endOffset) - if (preventExpand && cellContent.length < maxCellWidth) { + if (preventExpand && TableCharacterWidthUtils.calculateDisplayWidth(cellContent) < maxCellWidth) { return } val expectedContentWithoutCarets = expectedContent.replace(TableProps.CARET_REPLACE_CHAR.toString(), "") diff --git a/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/tables/TableModificationUtils.kt b/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/tables/TableModificationUtils.kt index c8f6739c8b2b8..7e7d40f127bb0 100644 --- a/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/tables/TableModificationUtils.kt +++ b/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/tables/TableModificationUtils.kt @@ -48,7 +48,7 @@ object TableModificationUtils { } private fun getCellPotentialWidth(cellText: String): Int { - var width = cellText.length + var width = TableCharacterWidthUtils.calculateDisplayWidth(cellText) if (!cellText.startsWith(' ')) { width += 1 } @@ -84,7 +84,7 @@ object TableModificationUtils { } return cells.all { val selfWidth = getCellPotentialWidth(it.text) - it.hasCorrectPadding() && selfWidth == it.textRange.length && selfWidth == width + it.hasCorrectPadding() && selfWidth == TableCharacterWidthUtils.calculateDisplayWidth(it.text) && selfWidth == width } } @@ -142,12 +142,14 @@ object TableModificationUtils { } fun buildRealignedCellContent(cellContent: String, wholeCellWidth: Int, alignment: CellAlignment): String { - check(wholeCellWidth >= cellContent.length) + val contentDisplayWidth = TableCharacterWidthUtils.calculateDisplayWidth(cellContent) + check(wholeCellWidth >= contentDisplayWidth) + val paddingNeeded = wholeCellWidth - contentDisplayWidth return when (alignment) { - CellAlignment.RIGHT -> "${" ".repeat((wholeCellWidth - cellContent.length - 1).coerceAtLeast(0))}$cellContent " + CellAlignment.RIGHT -> "${" ".repeat((paddingNeeded - 1).coerceAtLeast(0))}$cellContent " CellAlignment.CENTER -> { - val leftPadding = (wholeCellWidth - cellContent.length) / 2 - val rightPadding = wholeCellWidth - cellContent.length - leftPadding + val leftPadding = paddingNeeded / 2 + val rightPadding = paddingNeeded - leftPadding buildString { repeat(leftPadding) { append(' ') @@ -159,7 +161,7 @@ object TableModificationUtils { } } // MarkdownTableSeparatorRow.CellAlignment.LEFT - else -> " $cellContent${" ".repeat((wholeCellWidth - cellContent.length - 1).coerceAtLeast(0))}" + else -> " $cellContent${" ".repeat((paddingNeeded - 1).coerceAtLeast(0))}" } } @@ -184,7 +186,7 @@ object TableModificationUtils { val cellRange = textRange val cellText = documentText.substring(cellRange.startOffset, cellRange.endOffset) val actualContent = cellText.trim(' ') - val replacement = buildRealignedCellContent(actualContent, cellText.length, alignment) + val replacement = buildRealignedCellContent(actualContent, TableCharacterWidthUtils.calculateDisplayWidth(cellText), alignment) document.replaceString(cellRange.startOffset, cellRange.endOffset, replacement) } diff --git a/plugins/markdown/test/data/editor/tables/format/chinese_table_test.after.md b/plugins/markdown/test/data/editor/tables/format/chinese_table_test.after.md new file mode 100644 index 0000000000000..1a3a1967df4b4 --- /dev/null +++ b/plugins/markdown/test/data/editor/tables/format/chinese_table_test.after.md @@ -0,0 +1,3 @@ +| 名字 | 年龄 | +|----------|------| +| 投放账号 | 3 . * | diff --git a/plugins/markdown/test/data/editor/tables/format/chinese_table_test.before.md b/plugins/markdown/test/data/editor/tables/format/chinese_table_test.before.md new file mode 100644 index 0000000000000..1e990304599ba --- /dev/null +++ b/plugins/markdown/test/data/editor/tables/format/chinese_table_test.before.md @@ -0,0 +1,3 @@ +| 名字 | 年龄 | +|------|-------| +| 投放账号 | 3 . * | diff --git a/plugins/markdown/test/src/org/intellij/plugins/markdown/editor/tables/MarkdownTablePostFormatProcessorTest.kt b/plugins/markdown/test/src/org/intellij/plugins/markdown/editor/tables/MarkdownTablePostFormatProcessorTest.kt index ad4b24ad60a30..1901f5d1c05bc 100644 --- a/plugins/markdown/test/src/org/intellij/plugins/markdown/editor/tables/MarkdownTablePostFormatProcessorTest.kt +++ b/plugins/markdown/test/src/org/intellij/plugins/markdown/editor/tables/MarkdownTablePostFormatProcessorTest.kt @@ -22,6 +22,9 @@ class MarkdownTablePostFormatProcessorTest: LightPlatformCodeInsightTestCase() { @Test fun `table without end newline`() = doTest() + @Test + fun `chinese table test`() = doTest() + private fun doTest() { val before = getTestName(true) + ".before.md" val after = getTestName(true) + ".after.md" diff --git a/plugins/markdown/test/src/org/intellij/plugins/markdown/editor/tables/TableCharacterWidthUtilsTest.kt b/plugins/markdown/test/src/org/intellij/plugins/markdown/editor/tables/TableCharacterWidthUtilsTest.kt new file mode 100644 index 0000000000000..bcbc9ab14897a --- /dev/null +++ b/plugins/markdown/test/src/org/intellij/plugins/markdown/editor/tables/TableCharacterWidthUtilsTest.kt @@ -0,0 +1,91 @@ +package org.intellij.plugins.markdown.editor.tables + +import org.junit.Test +import org.junit.Assert.* + +class TableCharacterWidthUtilsTest { + + @Test + fun `test ASCII characters width calculation`() { + assertEquals(1, TableCharacterWidthUtils.calculateDisplayWidth("a")) + assertEquals(1, TableCharacterWidthUtils.calculateDisplayWidth("A")) + assertEquals(1, TableCharacterWidthUtils.calculateDisplayWidth("1")) + assertEquals(1, TableCharacterWidthUtils.calculateDisplayWidth("!")) + assertEquals(5, TableCharacterWidthUtils.calculateDisplayWidth("hello")) + } + + @Test + fun `test Chinese characters width calculation`() { + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\u4E2D")) + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\u6587")) + assertEquals(4, TableCharacterWidthUtils.calculateDisplayWidth("\u4E2D\u6587")) + assertEquals(8, TableCharacterWidthUtils.calculateDisplayWidth("\u6295\u653E\u8D26\u53F7")) + } + + @Test + fun `test mixed content width calculation`() { + assertEquals(3, TableCharacterWidthUtils.calculateDisplayWidth("a\u4E2D")) + assertEquals(3, TableCharacterWidthUtils.calculateDisplayWidth("\u4E2Da")) + assertEquals(7, TableCharacterWidthUtils.calculateDisplayWidth("hello\u4E2D")) + assertEquals(7, TableCharacterWidthUtils.calculateDisplayWidth("\u4E2Dhello")) + } + + @Test + fun `test empty string width calculation`() { + assertEquals(0, TableCharacterWidthUtils.calculateDisplayWidth("")) + } + + @Test + fun `test control characters width calculation`() { + assertEquals(0, TableCharacterWidthUtils.calculateDisplayWidth("\t")) + assertEquals(0, TableCharacterWidthUtils.calculateDisplayWidth("\n")) + assertEquals(0, TableCharacterWidthUtils.calculateDisplayWidth("\r")) + } + + @Test + fun `test emoji width calculation`() { + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\uD83D\uDE00")) + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\uD83D\uDE80")) + assertEquals(4, TableCharacterWidthUtils.calculateDisplayWidth("\uD83D\uDE00\uD83D\uDE80")) + } + + @Test + fun `test Japanese characters width calculation`() { + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\u3042")) // Hiragana a + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\u30A2")) // Katakana a + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\u6F22")) // Kanji + } + + @Test + fun `test Korean characters width calculation`() { + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\uD55C")) // Hangul + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\uAE00")) + } + + @Test + fun `test CJK symbols and punctuation width calculation`() { + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\u3000")) // Ideographic space + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\u3001")) // 、 + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\u3002")) // 。 + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\u300C")) // 「 + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\u300D")) // 」 + } + + @Test + fun `test full-width ASCII variants width calculation`() { + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\uFF21")) // Fullwidth A + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\uFF11")) // Fullwidth 1 + assertEquals(2, TableCharacterWidthUtils.calculateDisplayWidth("\uFF01")) // Fullwidth ! + } + + @Test + fun `test table cell content examples`() { + // Test cases from the user's problem + assertEquals(4, TableCharacterWidthUtils.calculateDisplayWidth("\u540D\u5B57")) // 名字 + assertEquals(4, TableCharacterWidthUtils.calculateDisplayWidth("\u5E74\u9F84")) // 年龄 + assertEquals(8, TableCharacterWidthUtils.calculateDisplayWidth("\u6295\u653E\u8D26\u53F7")) // 投放账号 + assertEquals(4, TableCharacterWidthUtils.calculateDisplayWidth("name")) + assertEquals(3, TableCharacterWidthUtils.calculateDisplayWidth("age")) + assertEquals(4, TableCharacterWidthUtils.calculateDisplayWidth("demo")) + } +}