fix: address Mermaid rendering review

This commit is contained in:
Shawn Koh
2026-08-11 01:14:29 +08:00
parent a42868f550
commit e658d9f7be
7 changed files with 43 additions and 61 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed Mermaid ASCII state pseudostates rendering empty boxes, miscoloring final-state borders, and inverting rounded corners in bottom-to-top diagrams.
## [17.2.11] - 2026-08-07
### Added
+7 -25
View File
@@ -8,7 +8,7 @@
import type { Canvas, DrawingCoord, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types'
import { colorizeLine, DEFAULT_ASCII_THEME } from './ansi'
import { displayWidth, toCells, LABEL_CELL_PREFIX, OPAQUE_SPACE, WIDE_PAD } from '../text-metrics'
import { displayWidth, toCells, WIDE_PAD } from '../text-metrics'
/**
* Create a blank canvas filled with spaces.
@@ -189,7 +189,7 @@ export function isJunctionChar(c: string): boolean {
* letter/digit test misses.
*/
function isLabelChar(c: string): boolean {
return c === OPAQUE_SPACE || c.startsWith(LABEL_CELL_PREFIX) || c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c)
return c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c)
}
/**
@@ -327,9 +327,8 @@ export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions):
let line = ''
for (let x = 0; x <= maxX; x++) {
const c = canvas[x]![y]!
if (c !== WIDE_PAD) {
line += c === OPAQUE_SPACE ? ' ' : c.startsWith(LABEL_CELL_PREFIX) ? c.slice(LABEL_CELL_PREFIX.length) : c
}
// Skip wide-glyph continuation cells: the glyph itself spans 2 columns
if (c !== WIDE_PAD) line += c
}
lines.push(line)
} else {
@@ -339,7 +338,7 @@ export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions):
for (let x = 0; x <= maxX; x++) {
const c = canvas[x]![y]!
if (c === WIDE_PAD) continue
chars.push(c === OPAQUE_SPACE ? ' ' : c.startsWith(LABEL_CELL_PREFIX) ? c.slice(LABEL_CELL_PREFIX.length) : c)
chars.push(c)
roles.push(roleCanvas[x]?.[y] ?? null)
}
lines.push(colorizeLine(chars, roles, theme, colorMode))
@@ -371,6 +370,8 @@ const VERTICAL_FLIP_MAP: Record<string, string> = {
// Unicode corners
'┌': '└', '└': '┌',
'┐': '┘', '┘': '┐',
'╭': '╰', '╰': '╭',
'╮': '╯', '╯': '╮',
// Unicode junctions (T-pieces flip vertically)
'┬': '┴', '┴': '┬',
// Box-start junctions (exit points from node boxes)
@@ -444,25 +445,6 @@ export function drawText(
}
}
/**
* Draw edge-label text with ownership markers so later canvas merges preserve
* punctuation and spaces while allowing the label to clear connector strokes.
*/
export function drawLabelText(canvas: Canvas, start: DrawingCoord, text: string): void {
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
if (cells[i + 1] === WIDE_PAD) {
writeCell(canvas, x, start.y, cell)
writeCell(canvas, x + 1, start.y, WIDE_PAD)
} else {
writeCell(canvas, x, start.y, cell === ' ' ? OPAQUE_SPACE : LABEL_CELL_PREFIX + cell)
}
}
}
/**
* Set the canvas size to fit all grid columns and rows.
+4 -4
View File
@@ -14,7 +14,7 @@ import {
Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight, Middle,
drawingCoordEquals,
} from './types'
import { mkCanvas, copyCanvas, getCanvasSize, mergeCanvases, drawText, drawLabelText, mkRoleCanvas, setRole, mergeRoleCanvases } from './canvas'
import { mkCanvas, copyCanvas, getCanvasSize, mergeCanvases, drawText, mkRoleCanvas, setRole, mergeRoleCanvases } from './canvas'
import type { RoleCanvas, CharRole } from './types'
import { determineDirection, dirEquals } from './edge-routing'
import { gridToDrawingCoord, lineToDrawing } from './grid'
@@ -82,7 +82,7 @@ function drawBoxWithGridDimensions(node: AsciiNode, graph: AsciiGraph): Canvas {
const vChar = useAscii ? (isStateEnd ? '‖' : '|') : (isStateEnd ? '║' : '│')
const stateStartCorners = useAscii
? { tl: '.', tr: '.', bl: "'", br: "'" }
? { tl: '+', tr: '+', bl: '+', br: '+' }
: { tl: '╭', tr: '╮', bl: '╰', br: '╯' }
const stateEndCorners = useAscii
? { tl: '#', tr: '#', bl: '#', br: '#' }
@@ -679,7 +679,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(displayWidth(lineText) / 2)
drawLabelText(canvas, { x: startX, y: startY + i }, lineText)
drawText(canvas, { x: startX, y: startY + i }, lineText)
}
}
@@ -1232,7 +1232,7 @@ function fillRolesForNodeBox(
canvas: Canvas,
offset: DrawingCoord,
): void {
const isBorderChar = (c: string) => /^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|.':]$/.test(c)
const isBorderChar = (c: string) => /^[┌┐└┘├┤┬┴┼│─╭╮╰╯╔╗╚╝═║+\-|.':#=‖]$/.test(c)
for (let x = 0; x < canvas.length; x++) {
for (let y = 0; y < (canvas[0]?.length ?? 0); y++) {
-11
View File
@@ -29,17 +29,6 @@
*/
export const WIDE_PAD = '\u0000'
/**
* Space owned by a text label rather than unused canvas. Merge layers treat
* ordinary spaces as transparent, so labels use this sentinel to clear edge
* lines between words. Serialization converts it back to a regular space.
*/
export const OPAQUE_SPACE = '\u0001'
/** Prefix marking a one-column cell as label-owned through canvas merges. */
export const LABEL_CELL_PREFIX = '\u0002'
const graphemeSegmenter = new Intl.Segmenter()
/**
+18 -19
View File
@@ -13,29 +13,28 @@ describe("renderMermaidAscii", () => {
expect(rendered).not.toContain("──A─");
});
it("renders state pseudostates with their UML markers", () => {
const rendered = renderMermaidAscii(["stateDiagram-v2", " [*] --> Created", " Created --> [*]"].join("\n"), {
colorMode: "none",
});
it("renders Unicode and ASCII state pseudostates with distinct UML markers", () => {
const source = ["stateDiagram-v2", " [*] --> Created", " Created --> [*]"].join("\n");
const unicode = renderMermaidAscii(source, { colorMode: "none" });
const ascii = renderMermaidAscii(source, { colorMode: "none", useAscii: true });
expect(rendered).toMatch(/│\s+●\s+│/);
expect(rendered).toMatch(/║\s+◎\s+║/);
expect(unicode).toMatch(/│\s+●\s+│/);
expect(unicode).toMatch(/║\s+◎\s+║/);
expect(ascii).toMatch(/\|\s+\*\s+\|/);
expect(ascii).toMatch(/‖\s+\*\s+‖/);
expect(ascii).toMatch(/#=+#/);
});
it("keeps dense transition labels intact above connector lines", () => {
const rendered = renderMermaidAscii(
[
"stateDiagram-v2",
" Working --> Working: sessions die and respawn freely",
" Working --> Archived: cheap exit, branches kept",
" Archived --> Working: resume rebuilds substrate",
].join("\n"),
{ colorMode: "none" },
);
it("keeps rounded pseudostate corners upright in bottom-to-top diagrams", () => {
const rendered = renderMermaidAscii(["stateDiagram-v2", " direction BT", " [*] --> Created"].join("\n"), {
colorMode: "none",
});
const rows = rendered.split("\n");
const markerRow = rows.findIndex(row => row.includes("●"));
expect(rendered).toContain("sessions die and respawn freely");
expect(rendered).toContain("cheap exit, branches kept");
expect(rendered).toContain("resume rebuilds substrate");
expect(markerRow).toBeGreaterThan(0);
expect(rows[markerRow - 1]).toMatch(/╭─+╮/);
expect(rows[markerRow + 1]).toMatch(/╰─+╯/);
});
it("returns a bounded fallback for declaration orders that make a clean route unreachable", () => {