fix(utils): masked routed lines beneath edge labels

Added opaque label-space cells so Mermaid ASCII edge labels erase routed paths while preserving spaces during serialization.

Added horizontal and vertical regression coverage.

Fixes #8098
This commit is contained in:
roboomp
2026-08-09 19:03:32 +00:00
parent 45e12e5bb7
commit 5d92336e19
5 changed files with 33 additions and 8 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed Mermaid ASCII multi-word edge labels allowing routed lines to show through spaces ([#8098](https://github.com/can1357/oh-my-pi/issues/8098)).
## [17.2.11] - 2026-08-07
### Added
+6 -6
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, WIDE_PAD } from '../text-metrics'
import { displayWidth, LABEL_SPACE, 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 === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c)
return c === LABEL_SPACE || c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c)
}
/**
@@ -268,7 +268,7 @@ 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]!
// WIDE_PAD cells are written atomically with their lead below
// Spaces are transparent; 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
@@ -327,8 +327,8 @@ export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions):
let line = ''
for (let x = 0; x <= maxX; x++) {
const c = canvas[x]![y]!
// Skip wide-glyph continuation cells: the glyph itself spans 2 columns
if (c !== WIDE_PAD) line += c
// Skip wide-glyph continuation cells and restore opaque label spaces.
if (c !== WIDE_PAD) line += c === LABEL_SPACE ? ' ' : c
}
lines.push(line)
} else {
@@ -338,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)
chars.push(c === LABEL_SPACE ? ' ' : c)
roles.push(roleCanvas[x]?.[y] ?? null)
}
lines.push(colorizeLine(chars, roles, theme, colorMode))
+2 -2
View File
@@ -21,7 +21,7 @@ import { gridToDrawingCoord, lineToDrawing } from './grid'
import { splitLines } from './multiline-utils'
import { getCorners } from './shapes/corners'
import { getShapeAttachmentPoint } from './shapes/index'
import { displayWidth, toCells, WIDE_PAD } from '../text-metrics'
import { displayWidth, LABEL_SPACE, toCells, WIDE_PAD } from '../text-metrics'
// ============================================================================
// Node drawing — renders a node using shape-aware rendering
@@ -677,7 +677,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)
drawText(canvas, { x: startX, y: startY + i }, lineText)
drawText(canvas, { x: startX, y: startY + i }, lineText.replaceAll(' ', LABEL_SPACE))
}
}
@@ -29,6 +29,9 @@
*/
export const WIDE_PAD = '\u0000'
/** Opaque label-space placeholder that serializes back to a regular space. */
export const LABEL_SPACE = '\u0001'
const graphemeSegmenter = new Intl.Segmenter()
/**
+18
View File
@@ -13,6 +13,24 @@ describe("renderMermaidAscii", () => {
expect(rendered).not.toContain("──A─");
});
it("masks routed lines behind spaces in edge labels", () => {
const horizontal = renderMermaidAscii(
["graph LR", " A[Agent] -->|on Mac| B[Server]", " A -->|on Linux| C[Cluster]"].join("\n"),
{ colorMode: "none", useAscii: false },
);
const vertical = renderMermaidAscii("graph TD\n A[Agent] -->|to c| C[Cluster]", {
colorMode: "none",
useAscii: false,
});
expect(horizontal).toContain("on Mac");
expect(horizontal).toContain("on Linux");
expect(horizontal).not.toContain("on─Mac");
expect(horizontal).not.toContain("on─Linux");
expect(vertical).toContain("to c");
expect(vertical).not.toContain("to│c");
});
it("returns a bounded fallback for declaration orders that make a clean route unreachable", () => {
const rendered = renderMermaidAsciiSafe(
[