diff --git a/bun.lock b/bun.lock index bef02e5b7..b618728b6 100644 --- a/bun.lock +++ b/bun.lock @@ -299,6 +299,9 @@ }, }, }, + "patchedDependencies": { + "beautiful-mermaid@1.1.3": "patches/beautiful-mermaid@1.1.3.patch", + }, "catalog": { "@agentclientprotocol/sdk": "0.25.0", "@babel/generator": "^7.29.7", diff --git a/package.json b/package.json index bf17e8456..56a41eefc 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "private": true, "type": "module", "packageManager": "bun@1.3.14", + "patchedDependencies": { + "beautiful-mermaid@1.1.3": "patches/beautiful-mermaid@1.1.3.patch" + }, "workspaces": { "packages": [ "packages/*", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index eabadbfca..850279b02 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,6 +17,7 @@ - Retried assistant turns that stop with reasoning/thinking only and no final text or tool call, so Gemini/Antigravity thought-only `STOP` responses continue instead of silently ending the session. - Fixed `~/.agent[s]/skills` not appearing as `/skill:` commands when every named source toggle (`skills.enableCodexUser`, `skills.enableClaudeUser`, `skills.enableClaudeProject`, `skills.enablePiUser`, `skills.enablePiProject`) was off: `loadSkills` gated the `agents` provider on `anyBuiltInSkillSourceEnabled`, so a user who turned off the Claude/Codex/Pi sources to clean noise also lost their own canonical OMP-native skills. The `agents` provider now reads the dedicated `enableAgentsUser`/`enableAgentsProject` toggles, decoupled from the third-party fall-through ([#2401](https://github.com/can1357/oh-my-pi/issues/2401)). - Fixed Windows PowerShell image paste so Ctrl+V can fall back to the PowerShell clipboard bridge when the native clipboard reader reports no image ([#2429](https://github.com/can1357/oh-my-pi/issues/2429)). +- Fixed misaligned box borders in Mermaid ASCII rendering for CJK (Korean/Japanese/Chinese) and emoji labels — affects both fenced `mermaid` code blocks in assistant messages and the `render_mermaid` tool. `beautiful-mermaid@1.1.3` measures label width in UTF-16 code units while terminals render East Asian characters 2 columns wide; a `patchedDependencies` entry rebuilds its ASCII renderer to measure terminal display columns (grapheme-cluster aware, wcwidth-style policy). The patch mirrors the upstream PR (lukilabs/beautiful-mermaid#128) and should be dropped once it ships in a release. ### Fixed diff --git a/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts b/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts index 9f5121732..2732220b9 100644 --- a/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts +++ b/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts @@ -66,6 +66,28 @@ describe("AssistantMessageComponent mermaid markdown", () => { expect(rendered).not.toContain("flowchart TD"); }); + it("aligns box borders for CJK labels in display columns", () => { + // Defends the beautiful-mermaid patchedDependencies entry: Hangul is 2 + // terminal columns wide, so every row of a single-node diagram must + // measure the same display width or the right border drifts. + const rendered = renderAssistantMessage("```mermaid\nflowchart TD\n A[수집 스케줄러]\n```"); + const displayCols = (line: string): number => { + let width = 0; + for (const ch of line) { + const code = ch.codePointAt(0) ?? 0; + const wide = + (code >= 0xac00 && code <= 0xd7a3) || // Hangul syllables + (code >= 0x2e80 && code <= 0x9fff) || // CJK radicals/ideographs + (code >= 0xff00 && code <= 0xff60); // fullwidth forms + width += wide ? 2 : 1; + } + return width; + }; + const boxRows = rendered.split("\n").filter(line => /[┌│└]/.test(line)); + expect(boxRows.length).toBeGreaterThanOrEqual(3); + expect(new Set(boxRows.map(displayCols)).size).toBe(1); + }); + it("falls back to the fenced code block when Mermaid rendering fails", () => { const rendered = renderAssistantMessage("```mermaid\nthis is not mermaid\n```"); diff --git a/patches/beautiful-mermaid@1.1.3.patch b/patches/beautiful-mermaid@1.1.3.patch new file mode 100644 index 000000000..33cd29d0e --- /dev/null +++ b/patches/beautiful-mermaid@1.1.3.patch @@ -0,0 +1,1612 @@ +diff --git a/dist/index.js b/dist/index.js +index e7e087b..1351413 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -219,6 +219,45 @@ var EMOJI_REGEX = /\p{Emoji_Presentation}|\p{Extended_Pictographic}/u; + function isEmoji(char) { + return EMOJI_REGEX.test(char); + } ++var WIDE_PAD = "\0"; ++var EMOJI_PRESENTATION = /\p{Emoji_Presentation}/u; ++var ZWJ = "\u200D"; ++var VS16 = "\uFE0F"; ++var graphemeSegmenter = new Intl.Segmenter(); ++function clusterWidth(cluster) { ++ const first = cluster.codePointAt(0) ?? 0; ++ if (first >= 127462 && first <= 127487) return 2; ++ if (cluster.includes(ZWJ)) return 2; ++ const base = String.fromCodePoint(first); ++ let width = isFullwidth(first) || EMOJI_PRESENTATION.test(base) ? 2 : 1; ++ if (width === 1 && cluster.includes(VS16)) width = 2; ++ return width; ++} ++function displayWidth(text) { ++ let ascii = true; ++ for (let i = 0; i < text.length; i++) { ++ if (text.charCodeAt(i) > 126) { ++ ascii = false; ++ break; ++ } ++ } ++ if (ascii) return text.length; ++ let width = 0; ++ for (const seg of graphemeSegmenter.segment(text)) { ++ width += clusterWidth(seg.segment); ++ } ++ return width; ++} ++function toCells(text) { ++ const cells = []; ++ for (const seg of graphemeSegmenter.segment(text)) { ++ cells.push(seg.segment); ++ if (clusterWidth(seg.segment) === 2) { ++ cells.push(WIDE_PAD); ++ } ++ } ++ return cells; ++} + function getCharWidth(char) { + const code = char.codePointAt(0); + if (code === void 0) return 0; +@@ -1102,8 +1141,17 @@ var JUNCTION_CHARS = /* @__PURE__ */ new Set([ + function isJunctionChar(c) { + return JUNCTION_CHARS.has(c); + } +-function isAlphanumeric(c) { +- return /^[a-zA-Z0-9]$/.test(c); ++function isLabelChar(c) { ++ return c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c); ++} ++function writeCell(canvas, x, y, c) { ++ const current = canvas[x][y]; ++ if (current === WIDE_PAD && x > 0 && c !== WIDE_PAD) { ++ canvas[x - 1][y] = " "; ++ } else if (current !== WIDE_PAD && canvas[x + 1]?.[y] === WIDE_PAD && c !== current) { ++ canvas[x + 1][y] = " "; ++ } ++ canvas[x][y] = c; + } + var JUNCTION_MAP = { + "\u2500": { "\u2502": "\u253C", "\u250C": "\u252C", "\u2510": "\u252C", "\u2514": "\u2534", "\u2518": "\u2534", "\u251C": "\u253C", "\u2524": "\u253C", "\u252C": "\u252C", "\u2534": "\u2534" }, +@@ -1139,16 +1187,21 @@ function mergeCanvases(base, offset, useAscii, ...overlays) { + for (let x = 0; x < overlay.length; x++) { + for (let y = 0; y < overlay[0].length; y++) { + const c = overlay[x][y]; +- if (c !== " ") { +- const mx = x + offset.x; +- const my = y + offset.y; +- const current = merged[mx][my]; +- if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) { +- merged[mx][my] = mergeJunctions(current, c); +- } else if (isAlphanumeric(current) && isAlphanumeric(c)) { +- } else { +- merged[mx][my] = c; ++ if (c === " " || c === WIDE_PAD) continue; ++ const mx = x + offset.x; ++ const my = y + offset.y; ++ const current = merged[mx][my]; ++ const isWide = overlay[x + 1]?.[y] === WIDE_PAD; ++ if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) { ++ merged[mx][my] = mergeJunctions(current, c); ++ } else if (isWide) { ++ if (!isLabelChar(current) && !isLabelChar(merged[mx + 1]?.[my] ?? " ")) { ++ writeCell(merged, mx, my, c); ++ writeCell(merged, mx + 1, my, WIDE_PAD); + } ++ } else if (isLabelChar(current) && isLabelChar(c)) { ++ } else { ++ writeCell(merged, mx, my, c); + } + } + } +@@ -1165,14 +1218,17 @@ function canvasToString(canvas, options) { + if (colorMode === "none" || !roleCanvas) { + let line = ""; + for (let x = 0; x <= maxX; x++) { +- line += canvas[x][y]; ++ const c = canvas[x][y]; ++ if (c !== WIDE_PAD) line += c; + } + lines.push(line); + } else { + const chars = []; + const roles = []; + for (let x = 0; x <= maxX; x++) { +- chars.push(canvas[x][y]); ++ const c = canvas[x][y]; ++ if (c === WIDE_PAD) continue; ++ chars.push(c); + roles.push(roleCanvas[x]?.[y] ?? null); + } + lines.push(colorizeLine(chars, roles, theme, colorMode)); +@@ -1222,12 +1278,20 @@ function flipRoleCanvasVertically(roleCanvas) { + return roleCanvas; + } + function drawText(canvas, start, text, forceOverwrite = false) { +- increaseSize(canvas, start.x + text.length, start.y); +- for (let i = 0; i < text.length; i++) { ++ const cells = toCells(text); ++ increaseSize(canvas, start.x + cells.length, start.y); ++ for (let i = 0; i < cells.length; i++) { ++ const cell = cells[i]; ++ if (cell === WIDE_PAD) continue; + const x = start.x + i; +- const current = canvas[x][start.y]; +- if (forceOverwrite || current === " ") { +- canvas[x][start.y] = text[i]; ++ if (cells[i + 1] === WIDE_PAD) { ++ const pairFree = canvas[x][start.y] === " " && canvas[x + 1][start.y] === " "; ++ if (forceOverwrite || pairFree) { ++ writeCell(canvas, x, start.y, cell); ++ writeCell(canvas, x + 1, start.y, WIDE_PAD); ++ } ++ } else if (forceOverwrite || canvas[x][start.y] === " ") { ++ writeCell(canvas, x, start.y, cell); + } + } + } +@@ -1690,7 +1754,7 @@ function determinePath(graph, edge) { + } + function determineLabelLine(graph, edge) { + if (edge.text.length === 0) return; +- const lenLabel = edge.text.length; ++ const lenLabel = displayWidth(edge.text); + const pathLen = edge.path.length; + const isVerticalFlow = graph.config.graphDirection === "TD"; + const segments = []; +@@ -1897,7 +1961,7 @@ function splitLines(label) { + } + function maxLineWidth(label) { + const lines = splitLines(label); +- return Math.max(...lines.map((l) => l.length), 0); ++ return Math.max(...lines.map((l) => displayWidth(l)), 0); + } + function lineCount(label) { + return splitLines(label).length; +@@ -1981,7 +2045,7 @@ function getCorners(shape, useAscii) { + // src/ascii/shapes/rectangle.ts + function getBoxDimensions(label, options) { + const lines = splitLines(label); +- const maxLineWidth3 = Math.max(...lines.map((l) => l.length), 0); ++ const maxLineWidth3 = Math.max(...lines.map((l) => displayWidth(l)), 0); + const lineCount3 = lines.length; + const innerWidth = 2 * options.padding + maxLineWidth3; + const width = innerWidth + 2; +@@ -2028,12 +2092,13 @@ function renderBox(label, dimensions, corners, useAscii) { + const startY = centerY - Math.floor((lines.length - 1) / 2); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; +- const textX = Math.floor(w / 2) - Math.ceil(line.length / 2) + 1; +- for (let j = 0; j < line.length; j++) { ++ const cells = toCells(line); ++ const textX = Math.floor(w / 2) - Math.ceil(cells.length / 2) + 1; ++ for (let j = 0; j < cells.length; j++) { + const x = textX + j; + const y = startY + i; + if (x >= 0 && x < canvas.length && y >= 0 && y < canvas[0].length) { +- canvas[x][y] = line[j]; ++ canvas[x][y] = cells[j]; + } + } + } +@@ -2214,7 +2279,7 @@ var roundedRenderer = { + var stadiumRenderer = { + getDimensions(label, options) { + const lines = splitLines(label); +- const maxLineWidth3 = Math.max(...lines.map((l) => l.length), 0); ++ const maxLineWidth3 = Math.max(...lines.map((l) => displayWidth(l)), 0); + const lineCount3 = lines.length; + const innerWidth = 2 * options.padding + maxLineWidth3; + const width = innerWidth + 4; +@@ -2266,12 +2331,13 @@ var stadiumRenderer = { + const startY = centerY - Math.floor((lines.length - 1) / 2); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; +- const textX = Math.floor(width / 2) - Math.floor(line.length / 2); +- for (let j = 0; j < line.length; j++) { ++ const cells = toCells(line); ++ const textX = Math.floor(width / 2) - Math.floor(cells.length / 2); ++ for (let j = 0; j < cells.length; j++) { + const x = textX + j; + const y = startY + i; + if (x > 0 && x < width - 1 && y >= 0 && y < height) { +- canvas[x][y] = line[j]; ++ canvas[x][y] = cells[j]; + } + } + } +@@ -2294,7 +2360,7 @@ var hexagonRenderer = { + var subroutineRenderer = { + getDimensions(label, options) { + const lines = splitLines(label); +- const maxLineWidth3 = Math.max(...lines.map((l) => l.length), 0); ++ const maxLineWidth3 = Math.max(...lines.map((l) => displayWidth(l)), 0); + const lineCount3 = lines.length; + const innerWidth = 2 * options.padding + maxLineWidth3; + const width = innerWidth + 4; +@@ -2339,12 +2405,13 @@ var subroutineRenderer = { + const startY = centerY - Math.floor((lines.length - 1) / 2); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; +- const textX = Math.floor(width / 2) - Math.floor(line.length / 2); +- for (let j = 0; j < line.length; j++) { ++ const cells = toCells(line); ++ const textX = Math.floor(width / 2) - Math.floor(cells.length / 2); ++ for (let j = 0; j < cells.length; j++) { + const x = textX + j; + const y = startY + i; + if (x > 1 && x < width - 2 && y > 0 && y < height - 1) { +- canvas[x][y] = line[j]; ++ canvas[x][y] = cells[j]; + } + } + } +@@ -2363,7 +2430,7 @@ var doublecircleRenderer = { + var cylinderRenderer = { + getDimensions(label, options) { + const lines = splitLines(label); +- const maxLineWidth3 = Math.max(...lines.map((l) => l.length), 0); ++ const maxLineWidth3 = Math.max(...lines.map((l) => displayWidth(l)), 0); + const lineCount3 = lines.length; + const innerWidth = 2 * options.padding + maxLineWidth3; + const width = innerWidth + 2; +@@ -2408,12 +2475,13 @@ var cylinderRenderer = { + const startY = centerY - Math.floor((lines.length - 1) / 2); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; +- const textX = Math.floor(width / 2) - Math.floor(line.length / 2); +- for (let j = 0; j < line.length; j++) { ++ const cells = toCells(line); ++ const textX = Math.floor(width / 2) - Math.floor(cells.length / 2); ++ for (let j = 0; j < cells.length; j++) { + const x = textX + j; + const y = startY + i; + if (x > 0 && x < width - 1 && y > 1 && y < height - 2) { +- canvas[x][y] = line[j]; ++ canvas[x][y] = cells[j]; + } + } + } +@@ -2517,10 +2585,11 @@ function drawBoxWithGridDimensions(node, graph) { + const startY = textCenterY - Math.floor((lines.length - 1) / 2); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; +- const textX = from.x + Math.floor(w / 2) - Math.ceil(line.length / 2) + 1; +- for (let j = 0; j < line.length; j++) { ++ const cells = toCells(line); ++ const textX = from.x + Math.floor(w / 2) - Math.ceil(cells.length / 2) + 1; ++ for (let j = 0; j < cells.length; j++) { + if (textX + j >= 0 && textX + j < box.length && startY + i >= 0 && startY + i < box[0].length) { +- box[textX + j][startY + i] = line[j]; ++ box[textX + j][startY + i] = cells[j]; + } + } + } +@@ -2533,7 +2602,7 @@ function drawMultiBox(sections, useAscii, padding = 1) { + let maxTextWidth = 0; + for (const section of sections) { + for (const line of section) { +- maxTextWidth = Math.max(maxTextWidth, line.length); ++ maxTextWidth = Math.max(maxTextWidth, displayWidth(line)); + } + } + const innerWidth = maxTextWidth + 2 * padding; +@@ -2569,8 +2638,9 @@ function drawMultiBox(sections, useAscii, padding = 1) { + const lines = section.length > 0 ? section : [""]; + for (const line of lines) { + const startX = 1 + padding; +- for (let i = 0; i < line.length; i++) { +- canvas[startX + i][row] = line[i]; ++ const cells = toCells(line); ++ for (let i = 0; i < cells.length; i++) { ++ canvas[startX + i][row] = cells[i]; + } + row++; + } +@@ -2862,7 +2932,7 @@ function drawTextOnLine(canvas, line, label, isUpwardEdge) { + const startY = middleY - Math.floor((lines.length - 1) / 2); + for (let i = 0; i < lines.length; i++) { + const lineText = lines[i]; +- const startX = middleX - Math.floor(lineText.length / 2); ++ const startX = middleX - Math.floor(displayWidth(lineText) / 2); + drawText(canvas, { x: startX, y: startY + i }, lineText); + } + } +@@ -3162,12 +3232,17 @@ function drawSubgraphLabel(sg, graph) { + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const labelY = 1 + i; +- let labelX = Math.floor(width / 2) - Math.floor(line.length / 2); ++ let labelX = Math.floor(width / 2) - Math.floor(displayWidth(line) / 2); + if (labelX < 1) labelX = 1; +- for (let j = 0; j < line.length; j++) { +- if (labelX + j < width && labelY < height) { +- canvas[labelX + j][labelY] = line[j]; +- } ++ const cells = toCells(line); ++ for (let j = 0; j < cells.length; j++) { ++ const cell = cells[j]; ++ if (cell === WIDE_PAD) continue; ++ const cx = labelX + j; ++ const wide = cells[j + 1] === WIDE_PAD; ++ if (cx + (wide ? 1 : 0) >= width || labelY >= height) continue; ++ canvas[cx][labelY] = cell; ++ if (wide) canvas[cx + 1][labelY] = WIDE_PAD; + } + } + return [canvas, { x: sg.minX, y: sg.minY }]; +@@ -3864,7 +3939,7 @@ function renderSequenceAscii(text, config, colorMode, theme) { + curY += 1; + const note = diagram.notes[n]; + const nLines = splitLines(note.text); +- const nWidth = Math.max(...nLines.map((l) => l.length)) + 4; ++ const nWidth = Math.max(...nLines.map((l) => displayWidth(l))) + 4; + const nHeight = nLines.length + 2; + const aIdx = actorIdx.get(note.actorIds[0]) ?? 0; + let nx; +@@ -3903,7 +3978,7 @@ function renderSequenceAscii(text, config, colorMode, theme) { + const msg = diagram.messages[m]; + if (msg.from === msg.to) { + const fi = actorIdx.get(msg.from); +- const selfRight = llX[fi] + 6 + 2 + msg.label.length; ++ const selfRight = llX[fi] + 6 + 2 + displayWidth(msg.label); + totalW = Math.max(totalW, selfRight + 1); + } + } +@@ -3918,6 +3993,18 @@ function renderSequenceAscii(text, config, colorMode, theme) { + setRole(rc, x, y, role); + } + } ++ function setCells(x0, y, cells, role, minX = 0, maxXExcl = canvas.length) { ++ const limit = Math.min(maxXExcl, canvas.length); ++ for (let i = 0; i < cells.length; i++) { ++ const cell = cells[i]; ++ if (cell === WIDE_PAD) continue; ++ const x = x0 + i; ++ const wide = cells[i + 1] === WIDE_PAD; ++ if (x < minX || x + (wide ? 1 : 0) >= limit) continue; ++ setC(x, y, cell, role); ++ if (wide) setC(x + 1, y, WIDE_PAD, role); ++ } ++ } + function drawActorBox(cx, topY, label) { + const lines2 = splitLines(label); + const maxW = maxLineWidth(label); +@@ -3932,10 +4019,9 @@ function renderSequenceAscii(text, config, colorMode, theme) { + setC(left, row, V, "border"); + setC(left + w - 1, row, V, "border"); + const line = lines2[i]; +- const ls = left + 1 + boxPad + Math.floor((maxW - line.length) / 2); +- for (let j = 0; j < line.length; j++) { +- setC(ls + j, row, line[j], "text"); +- } ++ const cells = toCells(line); ++ const ls = left + 1 + boxPad + Math.floor((maxW - cells.length) / 2); ++ setCells(ls, row, cells, "text"); + } + const bottomY = topY + h - 1; + setC(left, bottomY, BL, "border"); +@@ -3975,9 +4061,8 @@ function renderSequenceAscii(text, config, colorMode, theme) { + setC(fromX + loopW, y0, useAscii ? "+" : "\u2510", "corner"); + setC(fromX + loopW, y0 + 1, V, "line"); + const labelX = fromX + loopW + 2; +- for (let i = 0; i < msg.label.length; i++) { +- if (labelX + i < totalW) setC(labelX + i, y0 + 1, msg.label[i], "text"); +- } ++ const selfCells = toCells(msg.label); ++ setCells(labelX, y0 + 1, selfCells, "text", 0, totalW); + const arrowChar = isFilled ? useAscii ? "<" : "\u25C0" : useAscii ? "<" : "\u25C1"; + setC(fromX, y0 + 2, arrowChar, "arrow"); + for (let x = fromX + 1; x < fromX + loopW; x++) setC(x, y0 + 2, lineChar, "line"); +@@ -3989,13 +4074,10 @@ function renderSequenceAscii(text, config, colorMode, theme) { + const midX = Math.floor((fromX + toX) / 2); + const msgLines = splitLines(msg.label); + for (let lineIdx = 0; lineIdx < msgLines.length; lineIdx++) { +- const line = msgLines[lineIdx]; +- const labelStart = midX - Math.floor(line.length / 2); ++ const cells = toCells(msgLines[lineIdx]); ++ const labelStart = midX - Math.floor(cells.length / 2); + const y = labelY + lineIdx; +- for (let i = 0; i < line.length; i++) { +- const lx = labelStart + i; +- if (lx >= 0 && lx < totalW) setC(lx, y, line[i], "text"); +- } ++ setCells(labelStart, y, cells, "text", 0, totalW); + } + if (leftToRight) { + for (let x = fromX + 1; x < toX; x++) setC(x, arrowY, lineChar, "line"); +@@ -4031,10 +4113,8 @@ function renderSequenceAscii(text, config, colorMode, theme) { + const hdrLabel = block.label ? `${block.type} [${block.label}]` : block.type; + const hdrLines = splitLines(hdrLabel); + for (let lineIdx = 0; lineIdx < hdrLines.length && topY + lineIdx < botY; lineIdx++) { +- const line = hdrLines[lineIdx]; +- for (let i = 0; i < line.length && bLeft + 1 + i < bRight; i++) { +- setC(bLeft + 1 + i, topY + lineIdx, line[i], "text"); +- } ++ const cells = toCells(hdrLines[lineIdx]); ++ setCells(bLeft + 1, topY + lineIdx, cells, "text", bLeft + 1, bRight); + } + setC(bLeft, botY, BL, "border"); + for (let x = bLeft + 1; x < bRight; x++) setC(x, botY, H, "border"); +@@ -4052,10 +4132,8 @@ function renderSequenceAscii(text, config, colorMode, theme) { + setC(bRight, dY, JR, "junction"); + const dLabel = block.dividers[d].label; + if (dLabel) { +- const dStr = `[${dLabel}]`; +- for (let i = 0; i < dStr.length && bLeft + 1 + i < bRight; i++) { +- setC(bLeft + 1 + i, dY, dStr[i], "text"); +- } ++ const dCells = toCells(`[${dLabel}]`); ++ setCells(bLeft + 1, dY, dCells, "text", bLeft + 1, bRight); + } + } + } +@@ -4069,9 +4147,8 @@ function renderSequenceAscii(text, config, colorMode, theme) { + const ly = np.y + 1 + l; + setC(np.x, ly, V, "border"); + setC(np.x + np.width - 1, ly, V, "border"); +- for (let i = 0; i < np.lines[l].length; i++) { +- setC(np.x + 2 + i, ly, np.lines[l][i], "text"); +- } ++ const cells = toCells(np.lines[l]); ++ setCells(np.x + 2, ly, cells, "text"); + } + const by = np.y + np.height - 1; + setC(np.x, by, BL, "border"); +@@ -4373,7 +4450,7 @@ function renderClassAscii(text, config, colorMode, theme) { + classSections.set(cls.id, sections); + let maxTextW = 0; + for (const section of sections) { +- for (const line of section) maxTextW = Math.max(maxTextW, line.length); ++ for (const line of section) maxTextW = Math.max(maxTextW, displayWidth(line)); + } + const boxW = maxTextW + 4; + let totalLines = 0; +@@ -4689,7 +4766,7 @@ function renderClassAscii(text, config, colorMode, theme) { + } + if (rel.label) { + const lines2 = splitLines(rel.label); +- const maxLabelWidth = Math.max(...lines2.map((l) => l.length)) + 2; ++ const maxLabelWidth = Math.max(...lines2.map((l) => displayWidth(l))) + 2; + let baseMidY; + let idealMidX; + if (fromBY < toTY) { +@@ -4740,18 +4817,19 @@ function renderClassAscii(text, config, colorMode, theme) { + const startY = labelY - halfHeight; + for (let lineIdx = 0; lineIdx < lines2.length; lineIdx++) { + const paddedLine = ` ${lines2[lineIdx]} `; +- const idealLabelStart = idealMidX - Math.floor(paddedLine.length / 2); ++ const cells = toCells(paddedLine); ++ const idealLabelStart = idealMidX - Math.floor(cells.length / 2); + const labelStart = Math.max(0, idealLabelStart); + const y = startY + lineIdx; +- const labelEnd = labelStart + paddedLine.length; ++ const labelEnd = labelStart + cells.length; + if (labelEnd > 0 && y >= 0) { + increaseSize(canvas, Math.max(labelEnd, 1), Math.max(y + 1, 1)); + increaseRoleCanvasSize(rc, Math.max(labelEnd, 1), Math.max(y + 1, 1)); + } +- for (let i = 0; i < paddedLine.length; i++) { ++ for (let i = 0; i < cells.length; i++) { + const lx = labelStart + i; + if (lx >= 0 && y >= 0) { +- setC(lx, y, paddedLine[i], "text"); ++ setC(lx, y, cells[i], "text"); + } + } + } +@@ -4950,7 +5028,7 @@ function renderErAscii(text, config, colorMode, theme) { + entitySections.set(ent.id, sections); + let maxTextW = 0; + for (const section of sections) { +- for (const line of section) maxTextW = Math.max(maxTextW, line.length); ++ for (const line of section) maxTextW = Math.max(maxTextW, displayWidth(line)); + } + const boxW = maxTextW + 4; + let totalLines = 0; +@@ -5060,15 +5138,19 @@ function renderErAscii(text, config, colorMode, theme) { + const gapMid = Math.floor((startX + endX) / 2); + for (let lineIdx = 0; lineIdx < lines2.length; lineIdx++) { + const line = lines2[lineIdx]; +- const labelStart = Math.max(startX, gapMid - Math.floor(line.length / 2)); ++ const cells = toCells(line); ++ const labelStart = Math.max(startX, gapMid - Math.floor(cells.length / 2)); + const labelY = lineY + 1 + lineIdx; +- increaseSize(canvas, Math.max(labelStart + line.length, 1), Math.max(labelY + 1, 1)); +- increaseRoleCanvasSize(rc, Math.max(labelStart + line.length, 1), Math.max(labelY + 1, 1)); +- for (let i = 0; i < line.length; i++) { ++ increaseSize(canvas, Math.max(labelStart + cells.length, 1), Math.max(labelY + 1, 1)); ++ increaseRoleCanvasSize(rc, Math.max(labelStart + cells.length, 1), Math.max(labelY + 1, 1)); ++ for (let i = 0; i < cells.length; i++) { ++ const cell = cells[i]; ++ if (cell === WIDE_PAD) continue; + const lx = labelStart + i; +- if (lx >= startX && lx <= endX) { +- setC(lx, labelY, line[i], "text"); +- } ++ const wide = cells[i + 1] === WIDE_PAD; ++ if (lx < startX || lx + (wide ? 1 : 0) > endX) continue; ++ setC(lx, labelY, cell, "text"); ++ if (wide) setC(lx + 1, labelY, WIDE_PAD, "text"); + } + } + } +@@ -5107,16 +5189,16 @@ function renderErAscii(text, config, colorMode, theme) { + const midY = Math.floor((startY + endY) / 2); + const startLabelY = midY - Math.floor((lines2.length - 1) / 2); + for (let lineIdx = 0; lineIdx < lines2.length; lineIdx++) { +- const line = lines2[lineIdx]; ++ const cells = toCells(lines2[lineIdx]); + const labelX = lineX + 2; + const y = startLabelY + lineIdx; + if (y >= 0) { +- for (let i = 0; i < line.length; i++) { ++ for (let i = 0; i < cells.length; i++) { + const lx = labelX + i; + if (lx >= 0) { + increaseSize(canvas, lx + 1, y + 1); + increaseRoleCanvasSize(rc, lx + 1, y + 1); +- setC(lx, y, line[i], "text"); ++ setC(lx, y, cells[i], "text"); + } + } + } +@@ -5343,7 +5425,7 @@ function renderVertical(chart, ch, colorMode, theme) { + const yRange = chart.yAxis.range; + const yTicks = niceTickValues(yRange.min, yRange.max); + const yLabels = yTicks.map((v) => formatTickValue(v)); +- const yGutter = Math.max(...yLabels.map((l) => l.length)) + 1; ++ const yGutter = Math.max(...yLabels.map((l) => displayWidth(l))) + 1; + const plotW = Math.max(PLOT_WIDTH, dataCount * 6); + const plotH = PLOT_HEIGHT; + const bandW = Math.floor(plotW / dataCount); +@@ -5369,7 +5451,7 @@ function renderVertical(chart, ch, colorMode, theme) { + }; + const bandCenter = (i) => plotLeft + Math.floor(bandW * (i + 0.5)); + if (hasTitle && titleRow >= 0) { +- writeText(canvas, roles, titleRow, Math.floor(totalW / 2 - chart.title.length / 2), chart.title, "text"); ++ writeText(canvas, roles, titleRow, Math.floor(totalW / 2 - displayWidth(chart.title) / 2), chart.title, "text"); + } + if (hasLegend) { + const legendRow = hasTitle ? 1 : 0; +@@ -5386,7 +5468,7 @@ function renderVertical(chart, ch, colorMode, theme) { + const displayRow = plotTop + (plotH - 1 - row); + const label = formatTickValue(tick); + set(canvas, roles, displayRow, plotLeft - 1, row === 0 ? ch.origin : ch.yTick, "border"); +- const labelStart = yGutter - label.length; ++ const labelStart = yGutter - displayWidth(label); + writeText(canvas, roles, displayRow, Math.max(0, labelStart), label, "text"); + } + for (let c = plotLeft; c < plotLeft + bandW * dataCount; c++) { +@@ -5396,12 +5478,12 @@ function renderVertical(chart, ch, colorMode, theme) { + const cx = bandCenter(i); + set(canvas, roles, xAxisRow, cx, ch.xTick, "border"); + const label = catLabels[i]; +- const labelStart = cx - Math.floor(label.length / 2); ++ const labelStart = cx - Math.floor(displayWidth(label) / 2); + writeText(canvas, roles, xLabelRow, Math.max(0, labelStart), label, "text"); + } + if (hasXTitle && xTitleRow >= 0) { + const title = chart.xAxis.title; +- writeText(canvas, roles, xTitleRow, Math.floor(totalW / 2 - title.length / 2), title, "text"); ++ writeText(canvas, roles, xTitleRow, Math.floor(totalW / 2 - displayWidth(title) / 2), title, "text"); + } + for (const tick of yTicks) { + const row = valueToRow(tick); +@@ -5459,7 +5541,7 @@ function renderHorizontal(chart, ch, colorMode, theme) { + const yRange = chart.yAxis.range; + const valueTicks = niceTickValues(yRange.min, yRange.max); + const catLabels = getCategoryLabels(chart, dataCount); +- const catGutter = Math.max(...catLabels.map((l) => l.length)) + 1; ++ const catGutter = Math.max(...catLabels.map((l) => displayWidth(l))) + 1; + const plotW = Math.max(PLOT_WIDTH, 40); + const bandH = Math.max(2, Math.floor(PLOT_HEIGHT / dataCount)); + const plotH = bandH * dataCount; +@@ -5481,7 +5563,7 @@ function renderHorizontal(chart, ch, colorMode, theme) { + }; + const bandMid = (i) => plotTop + Math.floor(bandH * (i + 0.5)); + if (hasTitle) { +- writeText(canvas, roles, 0, Math.floor(totalW / 2 - chart.title.length / 2), chart.title, "text"); ++ writeText(canvas, roles, 0, Math.floor(totalW / 2 - displayWidth(chart.title) / 2), chart.title, "text"); + } + if (hasLegend) { + const legendRow = hasTitle ? 1 : 0; +@@ -5494,7 +5576,7 @@ function renderHorizontal(chart, ch, colorMode, theme) { + for (let i = 0; i < dataCount; i++) { + const my = bandMid(i); + const label = catLabels[i]; +- const labelStart = catGutter - label.length; ++ const labelStart = catGutter - displayWidth(label); + writeText(canvas, roles, my, Math.max(0, labelStart), label, "text"); + } + for (let c = plotLeft; c < plotLeft + plotW; c++) { +@@ -5505,11 +5587,11 @@ function renderHorizontal(chart, ch, colorMode, theme) { + if (cx < plotLeft || cx >= plotLeft + plotW) continue; + set(canvas, roles, xAxisRow, cx, ch.xTick, "border"); + const label = formatTickValue(tick); +- writeText(canvas, roles, xAxisRow + 1, cx - Math.floor(label.length / 2), label, "text"); ++ writeText(canvas, roles, xAxisRow + 1, cx - Math.floor(displayWidth(label) / 2), label, "text"); + } + if (hasYTitle) { + const title = chart.yAxis.title; +- writeText(canvas, roles, totalH - 1, Math.floor(totalW / 2 - title.length / 2), title, "text"); ++ writeText(canvas, roles, totalH - 1, Math.floor(totalW / 2 - displayWidth(title) / 2), title, "text"); + } + for (const tick of valueTicks) { + const cx = valueToCol(tick); +@@ -5685,7 +5767,7 @@ function drawLegend(canvas, roles, hexCanvas, chart, row, totalW, ch, seriesColo + let totalLen = 0; + for (let i = 0; i < items.length; i++) { + if (i > 0) totalLen += 2; +- totalLen += 1 + 1 + items[i].label.length; ++ totalLen += 1 + 1 + displayWidth(items[i].label); + } + const startCol = Math.max(0, Math.floor(totalW / 2 - totalLen / 2)); + let col = startCol; +@@ -5696,7 +5778,7 @@ function drawLegend(canvas, roles, hexCanvas, chart, row, totalW, ch, seriesColo + col += 1; + col += 1; + writeText(canvas, roles, row, col, item.label, "text"); +- col += item.label.length; ++ col += displayWidth(item.label); + } + } + function createCanvas(width, height) { +@@ -5722,8 +5804,15 @@ function get(canvas, row, col) { + return " "; + } + function writeText(canvas, roles, row, startCol, text, role) { +- for (let i = 0; i < text.length; i++) { +- set(canvas, roles, row, startCol + i, text[i], role); ++ const cells = toCells(text); ++ for (let i = 0; i < cells.length; i++) { ++ const cell = cells[i]; ++ if (cell === WIDE_PAD) continue; ++ const col = startCol + i; ++ const wide = cells[i + 1] === WIDE_PAD; ++ if (col < 0 || col + (wide ? 1 : 0) >= canvas.length) continue; ++ set(canvas, roles, row, col, cell, role); ++ if (wide) set(canvas, roles, row, col + 1, WIDE_PAD, role); + } + } + function canvasToString2(canvas, roles, hexCanvas, colorMode, theme) { +@@ -5736,7 +5825,9 @@ function canvasToString2(canvas, roles, hexCanvas, colorMode, theme) { + const rowRoles = []; + const rowHex = []; + for (let col = 0; col < width; col++) { +- chars.push(canvas[col][row]); ++ const c = canvas[col][row]; ++ if (c === WIDE_PAD) continue; ++ chars.push(c); + rowRoles.push(roles[col][row]); + rowHex.push(hexCanvas[col][row]); + } +diff --git a/src/ascii/canvas.ts b/src/ascii/canvas.ts +index e9949fe..22a0031 100644 +--- a/src/ascii/canvas.ts ++++ b/src/ascii/canvas.ts +@@ -8,6 +8,7 @@ + + import type { Canvas, DrawingCoord, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types.ts' + import { colorizeLine, DEFAULT_ASCII_THEME } from './ansi.ts' ++import { displayWidth, toCells, WIDE_PAD } from '../text-metrics.ts' + + /** + * Create a blank canvas filled with spaces. +@@ -178,9 +179,33 @@ export function isJunctionChar(c: string): boolean { + return JUNCTION_CHARS.has(c) + } + +-/** Check if a character is alphanumeric (part of a label). */ +-function isAlphanumeric(c: string): boolean { +- return /^[a-zA-Z0-9]$/.test(c) ++/** ++ * Check if a cell holds label content for first-label-wins collision ++ * handling during merges: letters/digits in any script, the continuation ++ * cell of a wide glyph, or any 2-column glyph. Wide glyphs are only ever ++ * produced by labels (CJK ideographs, Hangul, emoji) — the renderer's own ++ * structural glyphs (borders, the narrow arrowheads ◀▶) are 1 column — so ++ * width 2 is a sufficient signal for emoji labels (🚀, 🇨🇳, 👍🏽) that the ++ * letter/digit test misses. ++ */ ++function isLabelChar(c: string): boolean { ++ return c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c) ++} ++ ++/** ++ * Write one cell, dissolving any wide-glyph pair the write would split: ++ * overwriting a WIDE_PAD orphans its lead, and overwriting a lead orphans ++ * its pad — the orphaned half becomes a space so serialized rows keep ++ * exactly one column per cell. ++ */ ++function writeCell(canvas: Canvas, x: number, y: number, c: string): void { ++ const current = canvas[x]![y]! ++ if (current === WIDE_PAD && x > 0 && c !== WIDE_PAD) { ++ canvas[x - 1]![y] = ' ' ++ } else if (current !== WIDE_PAD && canvas[x + 1]?.[y] === WIDE_PAD && c !== current) { ++ canvas[x + 1]![y] = ' ' ++ } ++ canvas[x]![y] = c + } + + /** +@@ -243,18 +268,25 @@ export function mergeCanvases( + for (let x = 0; x < overlay.length; x++) { + for (let y = 0; y < overlay[0]!.length; y++) { + const c = overlay[x]![y]! +- if (c !== ' ') { +- const mx = x + offset.x +- const my = y + offset.y +- const current = merged[mx]![my]! +- if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) { +- merged[mx]![my] = mergeJunctions(current, c) +- } else if (isAlphanumeric(current) && isAlphanumeric(c)) { +- // Don't overwrite existing label text with new label text +- // This prevents label collisions (first label wins) +- } else { +- merged[mx]![my] = c ++ // WIDE_PAD cells are written atomically with their lead below ++ if (c === ' ' || c === WIDE_PAD) continue ++ const mx = x + offset.x ++ const my = y + offset.y ++ const current = merged[mx]![my]! ++ const isWide = overlay[x + 1]?.[y] === WIDE_PAD ++ if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) { ++ merged[mx]![my] = mergeJunctions(current, c) ++ } else if (isWide) { ++ // Wide glyphs land or yield as a whole pair (first label wins) ++ if (!isLabelChar(current) && !isLabelChar(merged[mx + 1]?.[my] ?? ' ')) { ++ writeCell(merged, mx, my, c) ++ writeCell(merged, mx + 1, my, WIDE_PAD) + } ++ } else if (isLabelChar(current) && isLabelChar(c)) { ++ // Don't overwrite existing label text with new label text ++ // This prevents label collisions (first label wins) ++ } else { ++ writeCell(merged, mx, my, c) + } + } + } +@@ -294,7 +326,9 @@ export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions): + // Plain text output — no colors + let line = '' + for (let x = 0; x <= maxX; x++) { +- line += canvas[x]![y]! ++ const c = canvas[x]![y]! ++ // Skip wide-glyph continuation cells: the glyph itself spans 2 columns ++ if (c !== WIDE_PAD) line += c + } + lines.push(line) + } else { +@@ -302,7 +336,9 @@ export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions): + const chars: string[] = [] + const roles: (CharRole | null)[] = [] + for (let x = 0; x <= maxX; x++) { +- chars.push(canvas[x]![y]!) ++ const c = canvas[x]![y]! ++ if (c === WIDE_PAD) continue ++ chars.push(c) + roles.push(roleCanvas[x]?.[y] ?? null) + } + lines.push(colorizeLine(chars, roles, theme, colorMode)) +@@ -387,13 +423,22 @@ export function drawText( + text: string, + forceOverwrite = false + ): void { +- increaseSize(canvas, start.x + text.length, start.y) +- for (let i = 0; i < text.length; i++) { ++ const cells = toCells(text) ++ increaseSize(canvas, start.x + cells.length, start.y) ++ for (let i = 0; i < cells.length; i++) { ++ const cell = cells[i]! ++ // WIDE_PAD cells are written atomically with their lead below ++ if (cell === WIDE_PAD) continue + const x = start.x + i +- const current = canvas[x]![start.y]! +- // Only write if target is empty or we're forcing overwrite +- if (forceOverwrite || current === ' ') { +- canvas[x]![start.y] = text[i]! ++ if (cells[i + 1] === WIDE_PAD) { ++ // Wide glyph: needs both its cells free (or forced) to land ++ const pairFree = canvas[x]![start.y] === ' ' && canvas[x + 1]![start.y] === ' ' ++ if (forceOverwrite || pairFree) { ++ writeCell(canvas, x, start.y, cell) ++ writeCell(canvas, x + 1, start.y, WIDE_PAD) ++ } ++ } else if (forceOverwrite || canvas[x]![start.y] === ' ') { ++ writeCell(canvas, x, start.y, cell) + } + } + } +diff --git a/src/ascii/class-diagram.ts b/src/ascii/class-diagram.ts +index 95e2c76..cdf20cd 100644 +--- a/src/ascii/class-diagram.ts ++++ b/src/ascii/class-diagram.ts +@@ -16,6 +16,7 @@ import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } + import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas.ts' + import { drawMultiBox } from './draw.ts' + import { splitLines } from './multiline-utils.ts' ++import { displayWidth, toCells } from '../text-metrics.ts' + + /** Classify a character from a box drawing as 'border' or 'text'. */ + function classifyBoxChar(ch: string): CharRole { +@@ -170,7 +171,7 @@ export function renderClassAscii(text: string, config: AsciiConfig, colorMode?: + // Compute box dimensions from drawMultiBox logic + let maxTextW = 0 + for (const section of sections) { +- for (const line of section) maxTextW = Math.max(maxTextW, line.length) ++ for (const line of section) maxTextW = Math.max(maxTextW, displayWidth(line)) + } + const boxW = maxTextW + 4 // 2 border + 2 padding + +@@ -602,7 +603,7 @@ export function renderClassAscii(text: string, config: AsciiConfig, colorMode?: + // Add padding around the label for readability + if (rel.label) { + const lines = splitLines(rel.label) +- const maxLabelWidth = Math.max(...lines.map(l => l.length)) + 2 // +2 for padding ++ const maxLabelWidth = Math.max(...lines.map(l => displayWidth(l))) + 2 // +2 for padding + + // Calculate ideal label position based on routing direction + let baseMidY: number +@@ -672,21 +673,22 @@ export function renderClassAscii(text: string, config: AsciiConfig, colorMode?: + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const paddedLine = ` ${lines[lineIdx]!} ` // Add space padding on both sides ++ const cells = toCells(paddedLine) + // Calculate label start, but ensure it doesn't go negative +- const idealLabelStart = idealMidX - Math.floor(paddedLine.length / 2) ++ const idealLabelStart = idealMidX - Math.floor(cells.length / 2) + const labelStart = Math.max(0, idealLabelStart) + const y = startY + lineIdx + // Ensure canvas is wide enough for the label +- const labelEnd = labelStart + paddedLine.length ++ const labelEnd = labelStart + cells.length + if (labelEnd > 0 && y >= 0) { + increaseSize(canvas, Math.max(labelEnd, 1), Math.max(y + 1, 1)) + increaseRoleCanvasSize(rc, Math.max(labelEnd, 1), Math.max(y + 1, 1)) + } + // Clear the area first (overwrite line characters) then draw the padded label +- for (let i = 0; i < paddedLine.length; i++) { ++ for (let i = 0; i < cells.length; i++) { + const lx = labelStart + i + if (lx >= 0 && y >= 0) { +- setC(lx, y, paddedLine[i]!, 'text') ++ setC(lx, y, cells[i]!, 'text') + } + } + } +diff --git a/src/ascii/draw.ts b/src/ascii/draw.ts +index f8020f5..e1e205d 100644 +--- a/src/ascii/draw.ts ++++ b/src/ascii/draw.ts +@@ -21,6 +21,7 @@ import { gridToDrawingCoord, lineToDrawing } from './grid.ts' + import { splitLines } from './multiline-utils.ts' + import { getCorners } from './shapes/corners.ts' + import { getShapeAttachmentPoint } from './shapes/index.ts' ++import { displayWidth, toCells, WIDE_PAD } from '../text-metrics.ts' + + // ============================================================================ + // Node drawing — renders a node using shape-aware rendering +@@ -104,10 +105,11 @@ function drawBoxWithGridDimensions(node: AsciiNode, graph: AsciiGraph): Canvas { + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! +- const textX = from.x + Math.floor(w / 2) - Math.ceil(line.length / 2) + 1 +- for (let j = 0; j < line.length; j++) { ++ const cells = toCells(line) ++ const textX = from.x + Math.floor(w / 2) - Math.ceil(cells.length / 2) + 1 ++ for (let j = 0; j < cells.length; j++) { + if (textX + j >= 0 && textX + j < box.length && startY + i >= 0 && startY + i < box[0]!.length) { +- box[textX + j]![startY + i] = line[j]! ++ box[textX + j]![startY + i] = cells[j]! + } + } + } +@@ -147,7 +149,7 @@ export function drawMultiBox( + let maxTextWidth = 0 + for (const section of sections) { + for (const line of section) { +- maxTextWidth = Math.max(maxTextWidth, line.length) ++ maxTextWidth = Math.max(maxTextWidth, displayWidth(line)) + } + } + const innerWidth = maxTextWidth + 2 * padding +@@ -198,8 +200,9 @@ export function drawMultiBox( + // Draw section text lines + for (const line of lines) { + const startX = 1 + padding +- for (let i = 0; i < line.length; i++) { +- canvas[startX + i]![row] = line[i]! ++ const cells = toCells(line) ++ for (let i = 0; i < cells.length; i++) { ++ canvas[startX + i]![row] = cells[i]! + } + row++ + } +@@ -673,7 +676,7 @@ function drawTextOnLine(canvas: Canvas, line: DrawingCoord[], label: string, isU + + for (let i = 0; i < lines.length; i++) { + const lineText = lines[i]! +- const startX = middleX - Math.floor(lineText.length / 2) ++ const startX = middleX - Math.floor(displayWidth(lineText) / 2) + drawText(canvas, { x: startX, y: startY + i }, lineText) + } + } +@@ -1142,13 +1145,19 @@ export function drawSubgraphLabel(sg: AsciiSubgraph, graph: AsciiGraph): [Canvas + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const labelY = 1 + i +- let labelX = Math.floor(width / 2) - Math.floor(line.length / 2) ++ let labelX = Math.floor(width / 2) - Math.floor(displayWidth(line) / 2) + if (labelX < 1) labelX = 1 + +- for (let j = 0; j < line.length; j++) { +- if (labelX + j < width && labelY < height) { +- canvas[labelX + j]![labelY] = line[j]! +- } ++ const cells = toCells(line) ++ for (let j = 0; j < cells.length; j++) { ++ const cell = cells[j]! ++ if (cell === WIDE_PAD) continue // written atomically with its lead ++ const cx = labelX + j ++ const wide = cells[j + 1] === WIDE_PAD ++ // Keep wide-glyph pairs atomic at the subgraph edge ++ if (cx + (wide ? 1 : 0) >= width || labelY >= height) continue ++ canvas[cx]![labelY] = cell ++ if (wide) canvas[cx + 1]![labelY] = WIDE_PAD + } + } + +diff --git a/src/ascii/edge-routing.ts b/src/ascii/edge-routing.ts +index e9a9dd4..e69234e 100644 +--- a/src/ascii/edge-routing.ts ++++ b/src/ascii/edge-routing.ts +@@ -13,6 +13,7 @@ import { + } from './types.ts' + import { getPath, mergePath } from './pathfinder.ts' + import { getEffectiveDirection, getNodeSubgraph } from './grid.ts' ++import { displayWidth } from '../text-metrics.ts' + + // ============================================================================ + // Direction utilities +@@ -227,7 +228,7 @@ export function determinePath(graph: AsciiGraph, edge: AsciiEdge): void { + export function determineLabelLine(graph: AsciiGraph, edge: AsciiEdge): void { + if (edge.text.length === 0) return + +- const lenLabel = edge.text.length ++ const lenLabel = displayWidth(edge.text) + const pathLen = edge.path.length + const isVerticalFlow = graph.config.graphDirection === 'TD' + +diff --git a/src/ascii/er-diagram.ts b/src/ascii/er-diagram.ts +index 7ff86b7..66410d2 100644 +--- a/src/ascii/er-diagram.ts ++++ b/src/ascii/er-diagram.ts +@@ -15,6 +15,7 @@ import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } + import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas.ts' + import { drawMultiBox } from './draw.ts' + import { splitLines } from './multiline-utils.ts' ++import { displayWidth, toCells, WIDE_PAD } from '../text-metrics.ts' + + /** Classify a character from a box drawing as 'border' or 'text'. */ + function classifyBoxChar(ch: string): CharRole { +@@ -180,7 +181,7 @@ export function renderErAscii(text: string, config: AsciiConfig, colorMode?: Col + + let maxTextW = 0 + for (const section of sections) { +- for (const line of section) maxTextW = Math.max(maxTextW, line.length) ++ for (const line of section) maxTextW = Math.max(maxTextW, displayWidth(line)) + } + const boxW = maxTextW + 4 // 2 border + 2 padding + +@@ -344,16 +345,21 @@ export function renderErAscii(text: string, config: AsciiConfig, colorMode?: Col + // Place lines below the relationship line (lineY + 1, lineY + 2, ...) + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx]! +- const labelStart = Math.max(startX, gapMid - Math.floor(line.length / 2)) ++ const cells = toCells(line) ++ const labelStart = Math.max(startX, gapMid - Math.floor(cells.length / 2)) + const labelY = lineY + 1 + lineIdx + // Ensure canvas is tall enough +- increaseSize(canvas, Math.max(labelStart + line.length, 1), Math.max(labelY + 1, 1)) +- increaseRoleCanvasSize(rc, Math.max(labelStart + line.length, 1), Math.max(labelY + 1, 1)) +- for (let i = 0; i < line.length; i++) { ++ increaseSize(canvas, Math.max(labelStart + cells.length, 1), Math.max(labelY + 1, 1)) ++ increaseRoleCanvasSize(rc, Math.max(labelStart + cells.length, 1), Math.max(labelY + 1, 1)) ++ for (let i = 0; i < cells.length; i++) { ++ const cell = cells[i]! ++ if (cell === WIDE_PAD) continue // written atomically with its lead + const lx = labelStart + i +- if (lx >= startX && lx <= endX) { +- setC(lx, labelY, line[i]!, 'text') +- } ++ const wide = cells[i + 1] === WIDE_PAD ++ // Keep wide-glyph pairs atomic within the gap region ++ if (lx < startX || lx + (wide ? 1 : 0) > endX) continue ++ setC(lx, labelY, cell, 'text') ++ if (wide) setC(lx + 1, labelY, WIDE_PAD, 'text') + } + } + } +@@ -413,16 +419,16 @@ export function renderErAscii(text: string, config: AsciiConfig, colorMode?: Col + const startLabelY = midY - Math.floor((lines.length - 1) / 2) + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { +- const line = lines[lineIdx]! ++ const cells = toCells(lines[lineIdx]!) + const labelX = lineX + 2 + const y = startLabelY + lineIdx + if (y >= 0) { +- for (let i = 0; i < line.length; i++) { ++ for (let i = 0; i < cells.length; i++) { + const lx = labelX + i + if (lx >= 0) { + increaseSize(canvas, lx + 1, y + 1) + increaseRoleCanvasSize(rc, lx + 1, y + 1) +- setC(lx, y, line[i]!, 'text') ++ setC(lx, y, cells[i]!, 'text') + } + } + } +diff --git a/src/ascii/multiline-utils.ts b/src/ascii/multiline-utils.ts +index 347d260..7519789 100644 +--- a/src/ascii/multiline-utils.ts ++++ b/src/ascii/multiline-utils.ts +@@ -8,6 +8,7 @@ + + import type { Canvas } from './types.ts' + import { drawText } from './canvas.ts' ++import { displayWidth } from '../text-metrics.ts' + + /** + * Split a label into lines. +@@ -23,7 +24,7 @@ export function splitLines(label: string): string[] { + */ + export function maxLineWidth(label: string): number { + const lines = splitLines(label) +- return Math.max(...lines.map(l => l.length), 0) ++ return Math.max(...lines.map(l => displayWidth(l)), 0) + } + + /** +@@ -53,7 +54,7 @@ export function drawMultilineTextCentered( + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + // Center each line horizontally +- const startX = cx - Math.floor(line.length / 2) ++ const startX = cx - Math.floor(displayWidth(line) / 2) + // Force overwrite for node labels (they take priority) + drawText(canvas, { x: startX, y: startY + i }, line, true) + } +diff --git a/src/ascii/sequence.ts b/src/ascii/sequence.ts +index 5ff5eaa..93b3eea 100644 +--- a/src/ascii/sequence.ts ++++ b/src/ascii/sequence.ts +@@ -14,6 +14,7 @@ import type { SequenceDiagram, Block } from '../sequence/types.ts' + import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types.ts' + import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas.ts' + import { splitLines, maxLineWidth, lineCount } from './multiline-utils.ts' ++import { displayWidth, toCells, WIDE_PAD } from '../text-metrics.ts' + + /** Classify a box-drawing character as 'border' or 'text'. */ + function classifyBoxChar(ch: string): CharRole { +@@ -148,7 +149,7 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode + curY += 1 + const note = diagram.notes[n]! + const nLines = splitLines(note.text) +- const nWidth = Math.max(...nLines.map(l => l.length)) + 4 ++ const nWidth = Math.max(...nLines.map(l => displayWidth(l))) + 4 + const nHeight = nLines.length + 2 + + // Determine x position based on note.position +@@ -198,7 +199,7 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode + const msg = diagram.messages[m]! + if (msg.from === msg.to) { + const fi = actorIdx.get(msg.from)! +- const selfRight = llX[fi]! + 6 + 2 + msg.label.length ++ const selfRight = llX[fi]! + 6 + 2 + displayWidth(msg.label) + totalW = Math.max(totalW, selfRight + 1) + } + } +@@ -217,6 +218,24 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode + } + } + ++ /** ++ * Write label cells starting at x0, keeping wide-glyph pairs atomic: ++ * a glyph and its WIDE_PAD continuation land together or not at all, ++ * clamped to [minX, maxXExcl) and the canvas bounds. ++ */ ++ function setCells(x0: number, y: number, cells: string[], role: CharRole, minX = 0, maxXExcl = canvas.length): void { ++ const limit = Math.min(maxXExcl, canvas.length) ++ for (let i = 0; i < cells.length; i++) { ++ const cell = cells[i]! ++ if (cell === WIDE_PAD) continue // written atomically with its lead ++ const x = x0 + i ++ const wide = cells[i + 1] === WIDE_PAD ++ if (x < minX || x + (wide ? 1 : 0) >= limit) continue ++ setC(x, y, cell, role) ++ if (wide) setC(x + 1, y, WIDE_PAD, role) ++ } ++ } ++ + // ---- DRAW: helper to place a bordered actor box (supports multi-line labels) ---- + + function drawActorBox(cx: number, topY: number, label: string): void { +@@ -238,10 +257,9 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode + setC(left + w - 1, row, V, 'border') + // Center this line within the box + const line = lines[i]! +- const ls = left + 1 + boxPad + Math.floor((maxW - line.length) / 2) +- for (let j = 0; j < line.length; j++) { +- setC(ls + j, row, line[j]!, 'text') +- } ++ const cells = toCells(line) ++ const ls = left + 1 + boxPad + Math.floor((maxW - cells.length) / 2) ++ setCells(ls, row, cells, 'text') + } + + // Bottom border +@@ -305,9 +323,8 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode + // Row 1: vertical on right side + label + setC(fromX + loopW, y0 + 1, V, 'line') + const labelX = fromX + loopW + 2 +- for (let i = 0; i < msg.label.length; i++) { +- if (labelX + i < totalW) setC(labelX + i, y0 + 1, msg.label[i]!, 'text') +- } ++ const selfCells = toCells(msg.label) ++ setCells(labelX, y0 + 1, selfCells, 'text', 0, totalW) + + // Row 2: arrow-back + horizontal + bottom-right corner + const arrowChar = isFilled ? (useAscii ? '<' : '◀') : (useAscii ? '<' : '◁') +@@ -325,13 +342,10 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode + const msgLines = splitLines(msg.label) + + for (let lineIdx = 0; lineIdx < msgLines.length; lineIdx++) { +- const line = msgLines[lineIdx]! +- const labelStart = midX - Math.floor(line.length / 2) ++ const cells = toCells(msgLines[lineIdx]!) ++ const labelStart = midX - Math.floor(cells.length / 2) + const y = labelY + lineIdx +- for (let i = 0; i < line.length; i++) { +- const lx = labelStart + i +- if (lx >= 0 && lx < totalW) setC(lx, y, line[i]!, 'text') +- } ++ setCells(labelStart, y, cells, 'text', 0, totalW) + } + + // Draw arrow line +@@ -380,10 +394,8 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode + const hdrLines = splitLines(hdrLabel) + + for (let lineIdx = 0; lineIdx < hdrLines.length && topY + lineIdx < botY; lineIdx++) { +- const line = hdrLines[lineIdx]! +- for (let i = 0; i < line.length && bLeft + 1 + i < bRight; i++) { +- setC(bLeft + 1 + i, topY + lineIdx, line[i]!, 'text') +- } ++ const cells = toCells(hdrLines[lineIdx]!) ++ setCells(bLeft + 1, topY + lineIdx, cells, 'text', bLeft + 1, bRight) + } + + // Bottom border +@@ -408,10 +420,8 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode + // Divider label + const dLabel = block.dividers[d]!.label + if (dLabel) { +- const dStr = `[${dLabel}]` +- for (let i = 0; i < dStr.length && bLeft + 1 + i < bRight; i++) { +- setC(bLeft + 1 + i, dY, dStr[i]!, 'text') +- } ++ const dCells = toCells(`[${dLabel}]`) ++ setCells(bLeft + 1, dY, dCells, 'text', bLeft + 1, bRight) + } + } + } +@@ -431,9 +441,8 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode + const ly = np.y + 1 + l + setC(np.x, ly, V, 'border') + setC(np.x + np.width - 1, ly, V, 'border') +- for (let i = 0; i < np.lines[l]!.length; i++) { +- setC(np.x + 2 + i, ly, np.lines[l]![i]!, 'text') +- } ++ const cells = toCells(np.lines[l]!) ++ setCells(np.x + 2, ly, cells, 'text') + } + // Bottom border + const by = np.y + np.height - 1 +diff --git a/src/ascii/shapes/rectangle.ts b/src/ascii/shapes/rectangle.ts +index 5d35dc3..ae2f3d8 100644 +--- a/src/ascii/shapes/rectangle.ts ++++ b/src/ascii/shapes/rectangle.ts +@@ -13,6 +13,7 @@ import { splitLines } from '../multiline-utils.ts' + import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types.ts' + import { dirEquals } from '../edge-routing.ts' + import { type CornerChars, getCorners } from './corners.ts' ++import { displayWidth, toCells } from '../../text-metrics.ts' + + // ============================================================================ + // Shared dimension calculation +@@ -24,7 +25,7 @@ import { type CornerChars, getCorners } from './corners.ts' + */ + export function getBoxDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { + const lines = splitLines(label) +- const maxLineWidth = Math.max(...lines.map(l => l.length), 0) ++ const maxLineWidth = Math.max(...lines.map(l => displayWidth(l)), 0) + const lineCount = lines.length + + // Width: 2*padding + maxLineWidth + 2 border chars +@@ -108,12 +109,13 @@ export function renderBox( + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! +- const textX = Math.floor(w / 2) - Math.ceil(line.length / 2) + 1 +- for (let j = 0; j < line.length; j++) { ++ const cells = toCells(line) ++ const textX = Math.floor(w / 2) - Math.ceil(cells.length / 2) + 1 ++ for (let j = 0; j < cells.length; j++) { + const x = textX + j + const y = startY + i + if (x >= 0 && x < canvas.length && y >= 0 && y < canvas[0]!.length) { +- canvas[x]![y] = line[j]! ++ canvas[x]![y] = cells[j]! + } + } + } +diff --git a/src/ascii/shapes/special.ts b/src/ascii/shapes/special.ts +index e7ce95d..6a8a5f1 100644 +--- a/src/ascii/shapes/special.ts ++++ b/src/ascii/shapes/special.ts +@@ -13,6 +13,7 @@ import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types + import { dirEquals } from '../edge-routing.ts' + import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle.ts' + import { getCorners } from './corners.ts' ++import { displayWidth, toCells } from '../../text-metrics.ts' + + // ============================================================================ + // Subroutine — keeps custom double-border rendering +@@ -28,7 +29,7 @@ import { getCorners } from './corners.ts' + export const subroutineRenderer: ShapeRenderer = { + getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { + const lines = splitLines(label) +- const maxLineWidth = Math.max(...lines.map(l => l.length), 0) ++ const maxLineWidth = Math.max(...lines.map(l => displayWidth(l)), 0) + const lineCount = lines.length + + const innerWidth = 2 * options.padding + maxLineWidth +@@ -86,12 +87,13 @@ export const subroutineRenderer: ShapeRenderer = { + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! +- const textX = Math.floor(width / 2) - Math.floor(line.length / 2) +- for (let j = 0; j < line.length; j++) { ++ const cells = toCells(line) ++ const textX = Math.floor(width / 2) - Math.floor(cells.length / 2) ++ for (let j = 0; j < cells.length; j++) { + const x = textX + j + const y = startY + i + if (x > 1 && x < width - 2 && y > 0 && y < height - 1) { +- canvas[x]![y] = line[j]! ++ canvas[x]![y] = cells[j]! + } + } + } +@@ -142,7 +144,7 @@ export const doublecircleRenderer: ShapeRenderer = { + export const cylinderRenderer: ShapeRenderer = { + getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { + const lines = splitLines(label) +- const maxLineWidth = Math.max(...lines.map(l => l.length), 0) ++ const maxLineWidth = Math.max(...lines.map(l => displayWidth(l)), 0) + const lineCount = lines.length + + const innerWidth = 2 * options.padding + maxLineWidth +@@ -204,12 +206,13 @@ export const cylinderRenderer: ShapeRenderer = { + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! +- const textX = Math.floor(width / 2) - Math.floor(line.length / 2) +- for (let j = 0; j < line.length; j++) { ++ const cells = toCells(line) ++ const textX = Math.floor(width / 2) - Math.floor(cells.length / 2) ++ for (let j = 0; j < cells.length; j++) { + const x = textX + j + const y = startY + i + if (x > 0 && x < width - 1 && y > 1 && y < height - 2) { +- canvas[x]![y] = line[j]! ++ canvas[x]![y] = cells[j]! + } + } + } +diff --git a/src/ascii/shapes/stadium.ts b/src/ascii/shapes/stadium.ts +index b400cad..c6c7af5 100644 +--- a/src/ascii/shapes/stadium.ts ++++ b/src/ascii/shapes/stadium.ts +@@ -11,6 +11,7 @@ import { mkCanvas } from '../canvas.ts' + import { splitLines } from '../multiline-utils.ts' + import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types.ts' + import { getBoxAttachmentPoint } from './rectangle.ts' ++import { displayWidth, toCells } from '../../text-metrics.ts' + + /** + * Stadium (pill) shape renderer. +@@ -30,7 +31,7 @@ import { getBoxAttachmentPoint } from './rectangle.ts' + export const stadiumRenderer: ShapeRenderer = { + getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { + const lines = splitLines(label) +- const maxLineWidth = Math.max(...lines.map(l => l.length), 0) ++ const maxLineWidth = Math.max(...lines.map(l => displayWidth(l)), 0) + const lineCount = lines.length + + const innerWidth = 2 * options.padding + maxLineWidth +@@ -95,12 +96,13 @@ export const stadiumRenderer: ShapeRenderer = { + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! +- const textX = Math.floor(width / 2) - Math.floor(line.length / 2) +- for (let j = 0; j < line.length; j++) { ++ const cells = toCells(line) ++ const textX = Math.floor(width / 2) - Math.floor(cells.length / 2) ++ for (let j = 0; j < cells.length; j++) { + const x = textX + j + const y = startY + i + if (x > 0 && x < width - 1 && y >= 0 && y < height) { +- canvas[x]![y] = line[j]! ++ canvas[x]![y] = cells[j]! + } + } + } +diff --git a/src/ascii/xychart.ts b/src/ascii/xychart.ts +index 0dafc16..30713e6 100644 +--- a/src/ascii/xychart.ts ++++ b/src/ascii/xychart.ts +@@ -16,6 +16,7 @@ import type { XYChart } from '../xychart/types.ts' + import type { AsciiConfig, AsciiTheme, ColorMode, CharRole, Canvas, RoleCanvas } from './types.ts' + import { colorizeText } from './ansi.ts' + import { getSeriesColor, CHART_ACCENT_FALLBACK } from '../xychart/colors.ts' ++import { displayWidth, toCells, WIDE_PAD } from '../text-metrics.ts' + + // ============================================================================ + // Constants +@@ -117,7 +118,7 @@ function renderVertical( + const yRange = chart.yAxis.range! + const yTicks = niceTickValues(yRange.min, yRange.max) + const yLabels = yTicks.map(v => formatTickValue(v)) +- const yGutter = Math.max(...yLabels.map(l => l.length)) + 1 ++ const yGutter = Math.max(...yLabels.map(l => displayWidth(l))) + 1 + + const plotW = Math.max(PLOT_WIDTH, dataCount * 6) + const plotH = PLOT_HEIGHT +@@ -154,7 +155,7 @@ function renderVertical( + + // 1. Title + if (hasTitle && titleRow >= 0) { +- writeText(canvas, roles, titleRow, Math.floor(totalW / 2 - chart.title!.length / 2), chart.title!, 'text') ++ writeText(canvas, roles, titleRow, Math.floor(totalW / 2 - displayWidth(chart.title!) / 2), chart.title!, 'text') + } + + // 2. Legend +@@ -179,7 +180,7 @@ function renderVertical( + // Tick mark on axis + set(canvas, roles, displayRow, plotLeft - 1, row === 0 ? ch.origin : ch.yTick, 'border') + // Label +- const labelStart = yGutter - label.length ++ const labelStart = yGutter - displayWidth(label) + writeText(canvas, roles, displayRow, Math.max(0, labelStart), label, 'text') + } + +@@ -192,14 +193,14 @@ function renderVertical( + set(canvas, roles, xAxisRow, cx, ch.xTick, 'border') + // Label below + const label = catLabels[i]! +- const labelStart = cx - Math.floor(label.length / 2) ++ const labelStart = cx - Math.floor(displayWidth(label) / 2) + writeText(canvas, roles, xLabelRow, Math.max(0, labelStart), label, 'text') + } + + // 5. X-axis title + if (hasXTitle && xTitleRow >= 0) { + const title = chart.xAxis.title! +- writeText(canvas, roles, xTitleRow, Math.floor(totalW / 2 - title.length / 2), title, 'text') ++ writeText(canvas, roles, xTitleRow, Math.floor(totalW / 2 - displayWidth(title) / 2), title, 'text') + } + + // 6. Grid lines (subtle horizontal dots at y-tick positions) +@@ -279,7 +280,7 @@ function renderHorizontal( + const yRange = chart.yAxis.range! + const valueTicks = niceTickValues(yRange.min, yRange.max) + const catLabels = getCategoryLabels(chart, dataCount) +- const catGutter = Math.max(...catLabels.map(l => l.length)) + 1 ++ const catGutter = Math.max(...catLabels.map(l => displayWidth(l))) + 1 + + const plotW = Math.max(PLOT_WIDTH, 40) + const bandH = Math.max(2, Math.floor(PLOT_HEIGHT / dataCount)) +@@ -310,7 +311,7 @@ function renderHorizontal( + + // Title + if (hasTitle) { +- writeText(canvas, roles, 0, Math.floor(totalW / 2 - chart.title!.length / 2), chart.title!, 'text') ++ writeText(canvas, roles, 0, Math.floor(totalW / 2 - displayWidth(chart.title!) / 2), chart.title!, 'text') + } + + // Legend +@@ -328,7 +329,7 @@ function renderHorizontal( + for (let i = 0; i < dataCount; i++) { + const my = bandMid(i) + const label = catLabels[i]! +- const labelStart = catGutter - label.length ++ const labelStart = catGutter - displayWidth(label) + writeText(canvas, roles, my, Math.max(0, labelStart), label, 'text') + } + +@@ -341,13 +342,13 @@ function renderHorizontal( + if (cx < plotLeft || cx >= plotLeft + plotW) continue + set(canvas, roles, xAxisRow, cx, ch.xTick, 'border') + const label = formatTickValue(tick) +- writeText(canvas, roles, xAxisRow + 1, cx - Math.floor(label.length / 2), label, 'text') ++ writeText(canvas, roles, xAxisRow + 1, cx - Math.floor(displayWidth(label) / 2), label, 'text') + } + + // Y-axis title + if (hasYTitle) { + const title = chart.yAxis.title! +- writeText(canvas, roles, totalH - 1, Math.floor(totalW / 2 - title.length / 2), title, 'text') ++ writeText(canvas, roles, totalH - 1, Math.floor(totalW / 2 - displayWidth(title) / 2), title, 'text') + } + + // Grid lines (vertical at value tick positions) +@@ -646,7 +647,7 @@ function drawLegend( + let totalLen = 0 + for (let i = 0; i < items.length; i++) { + if (i > 0) totalLen += 2 // gap between items +- totalLen += 1 + 1 + items[i]!.label.length // symbol + space + label ++ totalLen += 1 + 1 + displayWidth(items[i]!.label) // symbol + space + label + } + + const startCol = Math.max(0, Math.floor(totalW / 2 - totalLen / 2)) +@@ -662,7 +663,7 @@ function drawLegend( + col += 1 + // Label text + writeText(canvas, roles, row, col, item.label, 'text') +- col += item.label.length ++ col += displayWidth(item.label) + } + } + +@@ -702,8 +703,16 @@ function get(canvas: Canvas, row: number, col: number): string { + } + + function writeText(canvas: Canvas, roles: RoleCanvas, row: number, startCol: number, text: string, role: CharRole): void { +- for (let i = 0; i < text.length; i++) { +- set(canvas, roles, row, startCol + i, text[i]!, role) ++ const cells = toCells(text) ++ for (let i = 0; i < cells.length; i++) { ++ const cell = cells[i]! ++ if (cell === WIDE_PAD) continue // written atomically with its lead ++ const col = startCol + i ++ const wide = cells[i + 1] === WIDE_PAD ++ // Keep wide-glyph pairs atomic at canvas bounds ++ if (col < 0 || col + (wide ? 1 : 0) >= canvas.length) continue ++ set(canvas, roles, row, col, cell, role) ++ if (wide) set(canvas, roles, row, col + 1, WIDE_PAD, role) + } + } + +@@ -728,7 +737,10 @@ function canvasToString( + const rowRoles: (CharRole | null)[] = [] + const rowHex: (string | null)[] = [] + for (let col = 0; col < width; col++) { +- chars.push(canvas[col]![row]!) ++ const c = canvas[col]![row]! ++ // Skip wide-glyph continuation cells: the glyph itself spans 2 columns ++ if (c === WIDE_PAD) continue ++ chars.push(c) + rowRoles.push(roles[col]![row]!) + rowHex.push(hexCanvas[col]![row]!) + } +diff --git a/src/text-metrics.ts b/src/text-metrics.ts +index 07766ab..bcac021 100644 +--- a/src/text-metrics.ts ++++ b/src/text-metrics.ts +@@ -110,6 +110,103 @@ function isEmoji(char: string): boolean { + return EMOJI_REGEX.test(char) + } + ++// ============================================================================ ++// Terminal display width — used by the ASCII renderer ++// ++// Terminals render most characters in 1 column, but East Asian wide ++// characters (CJK ideographs, Hangul, kana, fullwidth forms) and ++// emoji-presentation glyphs occupy 2 columns. The ASCII canvas is a ++// cell-per-column grid, so its width math counts display columns rather ++// than code units. ++// ++// Text is measured in grapheme clusters (Intl.Segmenter): a cluster that ++// renders 2 columns is stored as its full string in one canvas cell ++// followed by WIDE_PAD in the continuation cell it covers. The ASCII ++// serializers drop WIDE_PAD cells when joining a row, so the emitted line ++// occupies exactly as many terminal columns as the canvas has cells. ++// ++// Width policy (terminal wcwidth-style, locale-insensitive): ++// - East Asian Wide/Fullwidth code points ............ 2 ++// - Emoji_Presentation scalars (e.g. 🚀, ⌚) .......... 2 ++// - VS16 / ZWJ emoji sequences, regional-indicator flags 2 ++// - combining marks / variation selectors / ZWJ ....... 0 (within cluster) ++// - everything else (incl. text-presentation symbols ++// like ◀ ▶ △ that the renderer uses as 1-cell glyphs) 1 ++// East Asian Ambiguous characters (e.g. ½ ·) are treated as narrow. ++// ============================================================================ ++ ++/** ++ * Placeholder occupying the second cell of a fullwidth glyph on the ASCII ++ * canvas. U+0000 cannot appear in parsed Mermaid labels, is treated as ++ * occupied label content by canvas merging, and is stripped at ++ * serialization time. Invariant: a WIDE_PAD cell always sits immediately ++ * right of its lead cell; canvas writes keep the pair atomic. ++ */ ++export const WIDE_PAD = '\u0000' ++ ++/** Scalars that default to emoji presentation render 2 columns wide. */ ++const EMOJI_PRESENTATION = /\p{Emoji_Presentation}/u ++ ++const ZWJ = '\u200d' ++const VS16 = '\ufe0f' ++ ++const graphemeSegmenter = new Intl.Segmenter() ++ ++/** Display width of a single grapheme cluster in terminal columns. */ ++function clusterWidth(cluster: string): number { ++ const first = cluster.codePointAt(0) ?? 0 ++ // Regional-indicator pair renders as one 2-column flag glyph. ++ if (first >= 0x1f1e6 && first <= 0x1f1ff) return 2 ++ // ZWJ emoji sequence renders as a single 2-column glyph. ++ if (cluster.includes(ZWJ)) return 2 ++ // An extended grapheme cluster renders as one glyph at its base scalar's ++ // width; trailing extenders (combining marks, emoji/skin-tone modifiers, ++ // variation selectors) fold into the base and add no columns. ++ const base = String.fromCodePoint(first) ++ let width = isFullwidth(first) || EMOJI_PRESENTATION.test(base) ? 2 : 1 ++ // VS16 upgrades a text-presentation base to emoji presentation (2 cols). ++ if (width === 1 && cluster.includes(VS16)) width = 2 ++ return width ++} ++ ++/** ++ * Display width of a string in terminal columns, measured over grapheme ++ * clusters. ASCII-only strings take a fast path. ++ */ ++export function displayWidth(text: string): number { ++ let ascii = true ++ for (let i = 0; i < text.length; i++) { ++ if (text.charCodeAt(i) > 0x7e) { ++ ascii = false ++ break ++ } ++ } ++ if (ascii) return text.length ++ ++ let width = 0 ++ for (const seg of graphemeSegmenter.segment(text)) { ++ width += clusterWidth(seg.segment) ++ } ++ return width ++} ++ ++/** ++ * Expand a string into ASCII-canvas cells: each 2-column grapheme cluster ++ * is stored whole in one cell and followed by WIDE_PAD, so that ++ * cells.length === displayWidth(text). Per-character placement loops can ++ * iterate the result with plain cell offsets. ++ */ ++export function toCells(text: string): string[] { ++ const cells: string[] = [] ++ for (const seg of graphemeSegmenter.segment(text)) { ++ cells.push(seg.segment) ++ if (clusterWidth(seg.segment) === 2) { ++ cells.push(WIDE_PAD) ++ } ++ } ++ return cells ++} ++ + /** + * Get the relative width of a single character. + *