Files
oh-my-pi/patches/beautiful-mermaid@1.1.3.patch
T
can1357 508a9f543e feat: removed render_mermaid tool and added width-aware Mermaid clipping
- Removed render_mermaid from tool discovery, task definitions, and registries.
- Removed renderMermaid setting and prompt/docs references tied to the deleted tool.
- Added maxWidth and theme color options to Mermaid ASCII resolution in markdown flow.
- Re-rendered Mermaid ASCII in both directions and clipped output to available width.
2026-06-16 21:32:39 +02:00

1683 lines
73 KiB
Diff

diff --git a/dist/index.d.ts b/dist/index.d.ts
index 6239e30019b1bc36d0722921db4ecc84f9b277a6..8ef7097c0a90912fa301efe44d49dc02c70263df 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -224,6 +224,14 @@ interface AsciiRenderOptions {
paddingY?: number;
/** Padding inside node boxes. Default: 1 */
boxBorderPadding?: number;
+ /**
+ * Force the layout direction, overriding the direction parsed from the
+ * diagram source. Applies to the flowchart + state-diagram grid pipeline
+ * (sequence/class/ER/xychart renderers ignore it). Useful for re-fitting a
+ * wide `LR` graph into a narrow viewport by laying it out top-down.
+ * Default: undefined (use the source's own direction).
+ */
+ direction?: Direction;
/**
* Color mode for output.
* - 'none': No colors (plain text)
diff --git a/dist/index.js b/dist/index.js
index e7e087bcb6f29d0d8f36cd558e0183bc0f162fcb..ef3a86433637287555d889f292ec3ddae63f4fa9 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]);
}
@@ -5865,6 +5956,9 @@ function renderMermaidASCII(text, options = {}) {
case "flowchart":
default: {
const parsed = parseMermaid(text);
+ if (options.direction) {
+ parsed.direction = options.direction;
+ }
if (parsed.direction === "LR" || parsed.direction === "RL") {
config.graphDirection = "LR";
} else {
diff --git a/src/ascii/canvas.ts b/src/ascii/canvas.ts
index e9949feb129161f010483e74ab9c91d918fdab6c..22a003199fe8f07f4697ebf1891051bd03d69beb 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 95e2c76a43a4a23f6cf1e7299c753aefed01c197..cdf20cdf012dba6c6aa8645e853e02be2d5e6ef0 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 f8020f594fc9e27cc27eeecad17e2688c3900159..e1e205def44dc1d860b22ca2e872192c4561a958 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 e9a9dd4383973053561f5a05bc71949971c4e13e..e69234e4e5c4f7f83a23eccdba3e63b2e1e44a21 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 7ff86b78082c9486f12ac8a70b130bd4b33ee128..66410d25ceaa8d66660c0ddfd48ca8b60c1fa27c 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/index.ts b/src/ascii/index.ts
index 69161a9d592769abec4466421fe8ce038306d182..8eeca584904f92b8ebc484dccf03501aed51ddf4 100644
--- a/src/ascii/index.ts
+++ b/src/ascii/index.ts
@@ -27,6 +27,7 @@ import { renderErAscii } from './er-diagram.ts'
import { renderXYChartAscii } from './xychart.ts'
import { detectColorMode, DEFAULT_ASCII_THEME, diagramColorsToAsciiTheme } from './ansi.ts'
import type { AsciiConfig, AsciiTheme, ColorMode } from './types.ts'
+import type { Direction } from '../types.ts'
// Re-export types for external use
export type { AsciiTheme, ColorMode }
@@ -41,6 +42,14 @@ export interface AsciiRenderOptions {
paddingY?: number
/** Padding inside node boxes. Default: 1 */
boxBorderPadding?: number
+ /**
+ * Force the layout direction, overriding the direction parsed from the
+ * diagram source. Applies to the flowchart + state-diagram grid pipeline
+ * (sequence/class/ER/xychart renderers ignore it). Useful for re-fitting a
+ * wide `LR` graph into a narrow viewport by laying it out top-down.
+ * Default: undefined (use the source's own direction).
+ */
+ direction?: Direction
/**
* Color mode for output.
* - 'none': No colors (plain text)
@@ -138,6 +147,13 @@ export function renderMermaidASCII(
// Flowchart + state diagram pipeline (original)
const parsed = parseMermaid(text)
+ // Honor an explicit direction override. Applied before normalization so
+ // the LR/TD split and the BT vertical-flip below behave exactly as if the
+ // source had authored this direction.
+ if (options.direction) {
+ parsed.direction = options.direction
+ }
+
// Normalize direction for grid layout.
// BT is laid out as TD then flipped vertically after drawing.
// RL is treated as LR (full RL support not yet implemented).
diff --git a/src/ascii/multiline-utils.ts b/src/ascii/multiline-utils.ts
index 347d260f5bc50388087ffa45e196892b73a04d77..7519789014c7ebfde5d92e02a16f8331cfb43610 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 5ff5eaa1edc2a54828564ceb303271e436fb0642..93b3eea409b9479ad63204476f00e7675b0334fe 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 5d35dc314ebe35e907691d92940947c8b1bffd0c..ae2f3d8e77be49ad413c6f6fa5262b24f26df99a 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 e7ce95d83d1c83dcaaea862f5c26316f6beaad56..6a8a5f1b2977a2d6fc98ef9078375cdeeff27696 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 b400cad6ba588fa166e260c1bdfcf616645af094..c6c7af5209499b34a3df748138d09fd0bddf38de 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 0dafc16d6a565396ac61460bdd50a07c48492430..30713e6917ee29f4decc7ae1e30846bc23c5a14e 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 07766ab65303221352620180c63aa592b5842a10..bcac021da55fb83781d8fd920b40a04ee6ff22cc 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.
*