508a9f543e
- 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.
1683 lines
73 KiB
Diff
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.
|
|
*
|