Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions plugins/markdown/core/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@

<fileEditorProvider id="markdown-preview-editor" implementation="org.intellij.plugins.markdown.ui.preview.MarkdownSplitEditorProvider"/>
<textEditorCustomizer implementation="org.intellij.plugins.markdown.ui.floating.AddFloatingToolbarTextEditorCustomizer"/>
<textEditorCustomizer implementation="org.intellij.plugins.markdown.editor.MarkdownCharacterGridCustomizer"/>

<fileDropHandler implementation="org.intellij.plugins.markdown.fileActions.importFrom.docx.MarkdownDocxFileDropHandler"/>

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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

// 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
codePoint in 0x1100..0x11FF -> true

// Hangul Jamo Extended-A
codePoint in 0xA960..0xA97F -> true

// Hangul Jamo Extended-B
codePoint in 0xD7B0..0xD7FF -> true
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hangul Jamo range over-classifies narrow characters as wide

Low Severity

The Hangul Jamo range 0x1100..0x11FF is entirely classified as full-width, but per the Unicode East Asian Width property only 0x1100..0x115F (leading consonants) are Wide — characters 0x1160..0x11FF (vowels and trailing consonants) are Neutral/narrow. Similarly, Hangul Jamo Extended-B (0xD7B0..0xD7FF) is entirely Neutral but classified as full-width here. This over-counts the display width for these characters.

Fix in Cursor Fix in Web


// 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
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing CJK Symbols and Punctuation full-width range

Medium Severity

isFullWidthCharacter is missing the CJK Symbols and Punctuation range (U+3000–U+303F), which contains frequently-used East Asian Wide characters like ideographic space (U+3000), 、(U+3001), 。(U+3002), 「」brackets, and 〈〉angle brackets. These fall through to getCharacterWidth's else -> 1 default instead of returning width 2. Tables containing common CJK punctuation will still have misaligned columns. Since this function also drives the editor's DoubleWidthCharacterStrategy, the mismatch would compound in rendering.

Fix in Cursor Fix in Web

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(), "")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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(' ')
Expand All @@ -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))}"
}
}

Expand All @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
| 名字 | 年龄 |
|----------|------|
| 投放账号 | 3 . * |
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
| 名字 | 年龄 |
|------|-------|
| 投放账号 | 3 . * |
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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 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"))
}
}