fix: address Mermaid rendering review
This commit is contained in:
@@ -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
@@ -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
@@ -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++) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
/**
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user