Skip to content

Commit d86c150

Browse files
drunkhackerclaude
andcommitted
fix: support CJK/Unicode character rendering in sequence diagrams
Use a custom runewidth.Condition with EastAsianWidth=false to properly calculate display widths for both box drawing characters and CJK text. The issue: runewidth library treats box drawing characters (┌, ─, │) as East Asian Ambiguous, returning width=2, while they actually display as width=1 in most terminals. This caused misalignment when CJK characters (which correctly have width=2) were used in participant names. Changes: - Add widthCondition with EastAsianWidth=false for consistent width calculation - Replace runewidth.StringWidth() with widthCondition.StringWidth() - Rewrite label rendering to use display width-based positioning - Add east_asian_characters.txt test case with Japanese, Korean, Chinese Based on upstream PR AlexanderGrooff#55 by ColtWindy, adapted for multi-line label support. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fdf3288 commit d86c150

3 files changed

Lines changed: 102 additions & 35 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
sequenceDiagram
2+
participant C as Client
3+
participant S as サーバー
4+
participant DB as 데이터베이스
5+
C->>S: Login Request
6+
S->>DB: ユーザー検索
7+
DB-->>S: 사용자 정보
8+
S-->>C: Success 成功
9+
---
10+
┌────────┐ ┌──────────┐ ┌──────────────┐
11+
│ Client │ │ サーバー │ │ 데이터베이스 │
12+
└────┬───┘ └─────┬────┘ └───────┬──────┘
13+
│ │ │
14+
│ Login Request │ │
15+
├──────────────►│ │
16+
│ │ │
17+
│ │ ユーザー検索 │
18+
│ ├─────────────────►│
19+
│ │ │
20+
│ │ 사용자 정보 │
21+
│ │◄┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┤
22+
│ │ │
23+
│ Success 成功 │ │
24+
│◄┈┈┈┈┈┈┈┈┈┈┈┈┈┈┤ │
25+
│ │ │

internal/sequence/renderer.go

Lines changed: 75 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ const (
1919
labelBufferSpace = 10
2020
)
2121

22+
// widthCondition is used for calculating display width.
23+
// EastAsianWidth is set to false so that box drawing characters
24+
// are treated as narrow (width=1) while CJK characters remain wide (width=2).
25+
var widthCondition = &runewidth.Condition{EastAsianWidth: false}
26+
2227
type diagramLayout struct {
2328
participantWidths []int
2429
participantCenters []int
@@ -35,7 +40,7 @@ func calculateLayout(sd *SequenceDiagram, config *diagram.Config) *diagramLayout
3540

3641
widths := make([]int, len(sd.Participants))
3742
for i, p := range sd.Participants {
38-
w := runewidth.StringWidth(p.Label) + boxPaddingLeftRight
43+
w := widthCondition.StringWidth(p.Label) + boxPaddingLeftRight
3944
if w < minBoxWidth {
4045
w = minBoxWidth
4146
}
@@ -99,7 +104,7 @@ func Render(sd *SequenceDiagram, config *diagram.Config) (string, error) {
99104

100105
lines = append(lines, buildLine(sd.Participants, layout, func(i int) string {
101106
w := layout.participantWidths[i]
102-
labelLen := runewidth.StringWidth(sd.Participants[i].Label)
107+
labelLen := widthCondition.StringWidth(sd.Participants[i].Label)
103108
pad := (w - labelLen) / 2
104109
return string(chars.Vertical) + strings.Repeat(" ", pad) + sd.Participants[i].Label +
105110
strings.Repeat(" ", w-pad-labelLen) + string(chars.Vertical)
@@ -134,7 +139,7 @@ func buildLine(participants []*Participant, layout *diagramLayout, draw func(int
134139
boxWidth := layout.participantWidths[i] + boxBorderWidth
135140
left := layout.participantCenters[i] - boxWidth/2
136141

137-
needed := left - len([]rune(sb.String()))
142+
needed := left - widthCondition.StringWidth(sb.String())
138143
if needed > 0 {
139144
sb.WriteString(strings.Repeat(" ", needed))
140145
}
@@ -169,25 +174,42 @@ func renderMessage(msg *Message, layout *diagramLayout, chars BoxChars) []string
169174
start := min(from, to) + labelLeftMargin
170175
labelLines := strings.Split(label, "\n")
171176
for _, labelLine := range labelLines {
172-
labelWidth := runewidth.StringWidth(labelLine)
173-
w := max(layout.totalWidth, start+labelWidth) + labelBufferSpace
174-
line := []rune(buildLifeline(layout, chars))
175-
if len(line) < w {
176-
padding := make([]rune, w-len(line))
177-
for k := range padding {
178-
padding[k] = ' '
177+
labelWidth := widthCondition.StringWidth(labelLine)
178+
totalW := max(layout.totalWidth, start+labelWidth) + labelBufferSpace
179+
180+
// Build the line using display width positioning
181+
var sb strings.Builder
182+
pos := 0 // current display position
183+
labelRunes := []rune(labelLine)
184+
labelIdx := 0
185+
186+
for pos < totalW {
187+
// Label takes priority over lifelines (overwrites them)
188+
if pos >= start && labelIdx < len(labelRunes) {
189+
r := labelRunes[labelIdx]
190+
sb.WriteRune(r)
191+
pos += widthCondition.RuneWidth(r)
192+
labelIdx++
193+
} else {
194+
// Check if current position is a lifeline
195+
isLifeline := false
196+
for _, c := range layout.participantCenters {
197+
if pos == c {
198+
isLifeline = true
199+
break
200+
}
201+
}
202+
203+
if isLifeline {
204+
sb.WriteRune(chars.Vertical)
205+
} else {
206+
sb.WriteRune(' ')
207+
}
208+
pos++
179209
}
180-
line = append(line, padding...)
181210
}
182211

183-
col := start
184-
for _, r := range labelLine {
185-
if col < len(line) {
186-
line[col] = r
187-
col++
188-
}
189-
}
190-
lines = append(lines, strings.TrimRight(string(line), " "))
212+
lines = append(lines, strings.TrimRight(sb.String(), " "))
191213
}
192214
}
193215

@@ -243,24 +265,42 @@ func renderSelfMessage(msg *Message, layout *diagramLayout, chars BoxChars) []st
243265
start := center + labelLeftMargin
244266
labelLines := strings.Split(label, "\n")
245267
for _, labelLine := range labelLines {
246-
line := ensureWidth(buildLifeline(layout, chars))
247-
labelWidth := runewidth.StringWidth(labelLine)
248-
needed := start + labelWidth + labelBufferSpace
249-
if len(line) < needed {
250-
pad := make([]rune, needed-len(line))
251-
for i := range pad {
252-
pad[i] = ' '
268+
labelWidth := widthCondition.StringWidth(labelLine)
269+
totalW := start + labelWidth + labelBufferSpace
270+
271+
// Build the line using display width positioning
272+
var sb strings.Builder
273+
pos := 0 // current display position
274+
labelRunes := []rune(labelLine)
275+
labelIdx := 0
276+
277+
for pos < totalW {
278+
// Label takes priority over lifelines
279+
if pos >= start && labelIdx < len(labelRunes) {
280+
r := labelRunes[labelIdx]
281+
sb.WriteRune(r)
282+
pos += widthCondition.RuneWidth(r)
283+
labelIdx++
284+
} else {
285+
// Check if current position is a lifeline
286+
isLifeline := false
287+
for _, c := range layout.participantCenters {
288+
if pos == c {
289+
isLifeline = true
290+
break
291+
}
292+
}
293+
294+
if isLifeline {
295+
sb.WriteRune(chars.Vertical)
296+
} else {
297+
sb.WriteRune(' ')
298+
}
299+
pos++
253300
}
254-
line = append(line, pad...)
255301
}
256-
col := start
257-
for _, c := range labelLine {
258-
if col < len(line) {
259-
line[col] = c
260-
col++
261-
}
262-
}
263-
lines = append(lines, strings.TrimRight(string(line), " "))
302+
303+
lines = append(lines, strings.TrimRight(sb.String(), " "))
264304
}
265305
}
266306

internal/sequence/renderer_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func TestSequenceDiagramRendering(t *testing.T) {
2929
"autonumber.txt",
3030
"bidirectional_messages.txt",
3131
"dotted_arrows_only.txt",
32+
"east_asian_characters.txt",
3233
"four_participants.txt",
3334
"long_participant_names.txt",
3435
"messages_without_labels.txt",
@@ -76,6 +77,7 @@ func TestSequenceDiagramRendering_ASCIISmokeTest(t *testing.T) {
7677
"autonumber.txt",
7778
"bidirectional_messages.txt",
7879
"dotted_arrows_only.txt",
80+
"east_asian_characters.txt",
7981
"four_participants.txt",
8082
"long_participant_names.txt",
8183
"messages_without_labels.txt",

0 commit comments

Comments
 (0)