diff --git a/crates/brush-builtins-vendored/LICENSE b/crates/brush-builtins-vendored/LICENSE index 3d3cb7866..a27368d14 100644 --- a/crates/brush-builtins-vendored/LICENSE +++ b/crates/brush-builtins-vendored/LICENSE @@ -1,6 +1,17 @@ -MIT License +This directory contains a vendored, locally-patched copy of brush-builtins from +the brush shell project (https://github.com/reubeno/brush), used under the MIT +License. It is wired into the workspace via `[patch.crates-io]` so these +modifications can be maintained against the published release (brush-builtins +0.2.0). -Copyright (c) 2024 reuben olinsky + Copyright (c) 2024 reuben olinsky + +Local modifications include: implementing previously-stubbed builtins, the +`wait` builtin (PID targeting plus the `-f`/`-n`/`-p` flags), and cancellation +support for the `read` builtin via poll. The remaining logic tracks upstream so +behavior matches the published crate. + +MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/crates/brush-core-vendored/LICENSE b/crates/brush-core-vendored/LICENSE index 3d3cb7866..b9909c384 100644 --- a/crates/brush-core-vendored/LICENSE +++ b/crates/brush-core-vendored/LICENSE @@ -1,6 +1,18 @@ -MIT License +This directory contains a vendored, locally-patched copy of brush-core from the +brush shell project (https://github.com/reubeno/brush), used under the MIT +License. It is wired into the workspace via `[patch.crates-io]` so these +modifications can be maintained against the published release (brush-core +0.5.0). -Copyright (c) 2024 reuben olinsky + Copyright (c) 2024 reuben olinsky + +Local modifications include: Windows path handling fixes (drive-alias and +rooted-path normalization in globs), an async background-PID fix for the nohup +wrapper, treating `/dev/null` as poll-ready to avoid an infinite poll on macOS, +and indentation/formatting standardization. The remaining logic tracks upstream +so behavior matches the published crate. + +MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/crates/pi-shell/ATTRIBUTION-RTK.md b/crates/pi-shell/ATTRIBUTION-RTK.md deleted file mode 100644 index 0be0b52ac..000000000 --- a/crates/pi-shell/ATTRIBUTION-RTK.md +++ /dev/null @@ -1,46 +0,0 @@ -# Third-party attribution — RTK - -Portions of the shell-output minimizer adapt algorithms from **RTK** -(`rtk-ai/rtk`), used under the MIT License, which is compatible with this -workspace's MIT License. - -## Ported component - -- **Upstream:** [`rtk-ai/rtk`](https://github.com/rtk-ai/rtk) @ commit - `878af7de99e0ba71da2e8fd996f6b52a1836e06c` -- **Upstream path:** `src/cmds/python/pytest_cmd.rs` -- **Local path:** `crates/pi-shell/src/minimizer/filters/python.rs` -- **What was adapted:** the `build_pytest_summary` algorithm — re-implemented - here as the pytest state machine (`filter_pytest`, `pytest_success`, - `is_pytest_*`, `looks_like_pytest_summary_part`). It preserves failures, - errors, and the final summary line; strips header framing, progress dots, and - verbose `PASSED` rows; and falls through unchanged on unknown-state lines - (RTK's defensive default) so xdist `[gwN]` prefixes and custom reporters never - cause data loss. - -## License (MIT) - -RTK is distributed under the MIT License. A copy of the upstream license text -is reproduced below for the pinned revision above. - -``` -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` diff --git a/crates/pi-shell/Cargo.toml b/crates/pi-shell/Cargo.toml index 74cec362f..be2cccc4d 100644 --- a/crates/pi-shell/Cargo.toml +++ b/crates/pi-shell/Cargo.toml @@ -6,7 +6,7 @@ license.workspace = true authors.workspace = true repository.workspace = true # Portions of the minimizer adapt MIT-licensed algorithms from rtk-ai/rtk. -# See ATTRIBUTION-RTK.md at this crate root (packaged on publish). +# See NOTICE at this crate root (packaged on publish). [lints] workspace = true diff --git a/crates/pi-shell/NOTICE b/crates/pi-shell/NOTICE new file mode 100644 index 000000000..821ec3763 --- /dev/null +++ b/crates/pi-shell/NOTICE @@ -0,0 +1,35 @@ +This file documents third-party attribution for the shell-output minimizer in +this crate, which adapts an algorithm from RTK (https://github.com/rtk-ai/rtk), +used under the MIT License. + + Copyright (c) the RTK authors (rtk-ai/rtk) + +Adapted component: the `build_pytest_summary` algorithm from the upstream +`src/cmds/python/pytest_cmd.rs` (pinned at commit +878af7de99e0ba71da2e8fd996f6b52a1836e06c), re-implemented here in +`src/minimizer/filters/python.rs` as the pytest state machine (`filter_pytest`, +`pytest_success`, `is_pytest_*`, `looks_like_pytest_summary_part`). It preserves +failures, errors, and the final summary line; strips header framing, progress +dots, and verbose `PASSED` rows; and falls through unchanged on unknown-state +lines (RTK's defensive default) so xdist `[gwN]` prefixes and custom reporters +never cause data loss. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/pi-shell/src/minimizer/filters/python.rs b/crates/pi-shell/src/minimizer/filters/python.rs index 943875a66..1635c9242 100644 --- a/crates/pi-shell/src/minimizer/filters/python.rs +++ b/crates/pi-shell/src/minimizer/filters/python.rs @@ -2,7 +2,7 @@ //! //! Ported from rtk-ai/rtk@878af7de99e0ba71da2e8fd996f6b52a1836e06c //! Path: `src/cmds/python/pytest_cmd.rs` -//! License: MIT (compatible with workspace MIT). See `ATTRIBUTION-RTK.md` at +//! License: MIT (compatible with workspace MIT). See `NOTICE` at //! the `pi-shell` crate root. //! //! The pytest state machine (`filter_pytest`, `pytest_success`, diff --git a/package.json b/package.json index b271dfe44..a27a32b4b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "type": "module", "packageManager": "bun@1.3.14", "patchedDependencies": { - "beautiful-mermaid@1.1.3": "patches/beautiful-mermaid@1.1.3.patch", "@ark/schema@0.56.0": "patches/@ark%2Fschema@0.56.0.patch" }, "workspaces": { @@ -55,7 +54,6 @@ "@typescript/native-preview": "7.0.0-dev.20260609.1", "@xterm/headless": "^6.0.0", "arktype": "^2.2.0", - "beautiful-mermaid": "^1.1.3", "chalk": "^5.6.2", "chart.js": "^4.5.1", "date-fns": "^4.4.0", diff --git a/packages/ai/test/auth-storage-email-dedupe.test.ts b/packages/ai/test/auth-storage-email-dedupe.test.ts index 143f932de..ca7bb3cb3 100644 --- a/packages/ai/test/auth-storage-email-dedupe.test.ts +++ b/packages/ai/test/auth-storage-email-dedupe.test.ts @@ -360,7 +360,7 @@ describe("AuthStorage openai-codex email dedupe", () => { const legacyDbPath = path.join(tempDir, "legacy-v1-anthropic-agent.db"); const legacyDb = new Database(legacyDbPath); - legacyDb.exec(` + legacyDb.run(` CREATE TABLE auth_schema_version ( id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL @@ -442,7 +442,7 @@ describe("AuthStorage openai-codex email dedupe", () => { const futureDbPath = path.join(tempDir, "future-schema-agent.db"); const futureDb = new Database(futureDbPath); - futureDb.exec(` + futureDb.run(` CREATE TABLE auth_schema_version ( id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL @@ -505,7 +505,7 @@ describe("AuthStorage openai-codex email dedupe", () => { const legacyDbPath = path.join(tempDir, "legacy-v3-agent.db"); const legacyDb = new Database(legacyDbPath); - legacyDb.exec(` + legacyDb.run(` CREATE TABLE auth_schema_version ( id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL @@ -561,7 +561,7 @@ describe("AuthStorage openai-codex email dedupe", () => { const legacyDbPath = path.join(tempDir, "legacy-v1-agent.db"); const legacyDb = new Database(legacyDbPath); - legacyDb.exec(` + legacyDb.run(` CREATE TABLE auth_schema_version ( id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL @@ -612,7 +612,7 @@ describe("AuthStorage openai-codex email dedupe", () => { const legacyDbPath = path.join(tempDir, "legacy-agent.db"); const legacyDb = new Database(legacyDbPath); - legacyDb.exec(` + legacyDb.run(` CREATE TABLE auth_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT NOT NULL, diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7025ca189..bb7fec891 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Changed + +- Changed Mermaid fenced-block ASCII rendering to use the first-party vendored renderer in `@oh-my-pi/pi-utils` (`src/vendor/mermaid-ascii`), dropping the `beautiful-mermaid` npm package, its transitive `elkjs` (~3.13MB), and the `beautiful-mermaid` `bun patch`; CJK/emoji width handling and the layout-direction override are preserved. ### Fixed diff --git a/packages/coding-agent/src/internal-urls/docs-index.generated.txt b/packages/coding-agent/src/internal-urls/docs-index.generated.txt new file mode 100644 index 000000000..e69de29bb diff --git a/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts b/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts index 2732220b9..a1e718a2e 100644 --- a/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts +++ b/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts @@ -67,7 +67,8 @@ describe("AssistantMessageComponent mermaid markdown", () => { }); it("aligns box borders for CJK labels in display columns", () => { - // Defends the beautiful-mermaid patchedDependencies entry: Hangul is 2 + // Defends the first-party vendored Mermaid ASCII renderer's CJK/East-Asian + // display-width handling (packages/utils/src/vendor/mermaid-ascii): Hangul is 2 // terminal columns wide, so every row of a single-node diagram must // measure the same display width or the right border drifts. const rendered = renderAssistantMessage("```mermaid\nflowchart TD\n A[수집 스케줄러]\n```"); diff --git a/packages/stats/src/db.ts b/packages/stats/src/db.ts index 2e0b5c0c0..2022f1f9b 100644 --- a/packages/stats/src/db.ts +++ b/packages/stats/src/db.ts @@ -56,11 +56,11 @@ export async function initDb(): Promise { db = new Database(getStatsDbPath()); // Install the busy handler BEFORE any lock-taking statement. See // https://github.com/can1357/oh-my-pi/issues/2421. - db.exec("PRAGMA busy_timeout = 5000"); - db.exec("PRAGMA journal_mode = WAL"); + db.run("PRAGMA busy_timeout = 5000"); + db.run("PRAGMA journal_mode = WAL"); // Create tables - db.exec(` + db.run(` CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_file TEXT NOT NULL, @@ -132,9 +132,9 @@ export async function initDb(): Promise { const messageColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[]; if (!messageColumns.some(column => column.name === "premium_requests")) { - db.exec("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0"); + db.run("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0"); } - db.exec("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL"); + db.run("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL"); // Each behavior-metric bump invalidates previously-ingested rows. We detect // the stale schema by column name and drop the table; `IF NOT EXISTS` above // already produced the new schema, but we want a clean wipe + re-ingest. @@ -159,8 +159,8 @@ export async function initDb(): Promise { const hasV4Columns = userMessageColumns.some(column => column.name === "negation"); const hasOldUserMessages = userMessageColumns.length > 0; if (hasStaleColumn || (hasOldUserMessages && !hasV4Columns)) { - db.exec("DROP TABLE user_messages"); - db.exec(` + db.run("DROP TABLE user_messages"); + db.run(` CREATE TABLE user_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_file TEXT NOT NULL, @@ -787,8 +787,8 @@ function backfillUserMessages(database: Database): void { | undefined; if (!shouldResetBackfill(row?.value)) return; - database.exec("DELETE FROM user_messages"); - database.exec("DELETE FROM file_offsets"); + database.run("DELETE FROM user_messages"); + database.run("DELETE FROM file_offsets"); database .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)") .run(USER_MESSAGES_BACKFILL_KEY, BACKFILL_PENDING); @@ -808,7 +808,7 @@ function repairUserMessageLinks(database: Database): void { | undefined; if (!shouldResetBackfill(row?.value)) return; - database.exec("DELETE FROM file_offsets"); + database.run("DELETE FROM file_offsets"); database .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)") .run(USER_MESSAGE_LINKS_REPAIR_KEY, BACKFILL_PENDING); @@ -830,7 +830,7 @@ function backfillPriorityPremiumRequests(database: Database): void { | undefined; if (!shouldResetBackfill(row?.value)) return; - database.exec("DELETE FROM file_offsets"); + database.run("DELETE FROM file_offsets"); database .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)") .run(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY, BACKFILL_PENDING); diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index c17e2869a..2610e414d 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog ## [Unreleased] +### Changed + +- Mermaid diagrams are now rendered to ASCII by a first-party vendored renderer (`src/vendor/mermaid-ascii`, derived from the MIT-licensed `beautiful-mermaid`, ASCII pipeline only) with terminal display width measured via `Bun.stringWidth` (grapheme-aware, correct for wide/East-Asian glyphs and emoji). Inline label formatting (HTML formatting tags and markdown emphasis) is now reduced to plain text instead of printed raw. + +### Removed + +- Removed the external `beautiful-mermaid` dependency (and its transitive `elkjs`, ~3.13MB) in favor of the vendored ASCII renderer. ## [16.0.3] - 2026-06-16 @@ -150,4 +157,4 @@ ### Added -- Added an XDG-aware tiny-title model cache directory helper for coding-agent local title models. +- Added an XDG-aware tiny-title model cache directory helper for coding-agent local title models. \ No newline at end of file diff --git a/packages/utils/package.json b/packages/utils/package.json index 0f2310c5a..b45ef4abd 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -32,7 +32,6 @@ }, "dependencies": { "@oh-my-pi/pi-natives": "catalog:", - "beautiful-mermaid": "catalog:", "handlebars": "catalog:", "winston": "catalog:", "winston-daily-rotate-file": "catalog:" diff --git a/packages/utils/src/mermaid-ascii.ts b/packages/utils/src/mermaid-ascii.ts index 9192d0516..69d07f9b6 100644 --- a/packages/utils/src/mermaid-ascii.ts +++ b/packages/utils/src/mermaid-ascii.ts @@ -1,4 +1,4 @@ -import { type AsciiRenderOptions, renderMermaidASCII } from "beautiful-mermaid"; +import { type AsciiRenderOptions, renderMermaidASCII } from "./vendor/mermaid-ascii"; export type { AsciiRenderOptions as MermaidAsciiRenderOptions }; diff --git a/packages/utils/src/vendor/mermaid-ascii/NOTICE b/packages/utils/src/vendor/mermaid-ascii/NOTICE new file mode 100644 index 000000000..396f08347 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/NOTICE @@ -0,0 +1,33 @@ +This directory contains an in-house Mermaid-diagram-to-ASCII renderer adapted +from beautiful-mermaid (https://github.com/lukilabs/beautiful-mermaid), used +under the MIT License. + + Copyright (c) 2026 Craft Docs + +Only the ASCII rendering pipeline is ported (flowchart/state, sequence, class, +ER, and xychart diagrams); the SVG renderer and its `elkjs` graph-layout +dependency, the browser entry point, and the SVG theme/style modules were +dropped. Terminal display width is reimplemented on `Bun.stringWidth`, and +inline label formatting (HTML tags, markdown emphasis) is reduced to plain text +for ASCII output. Layout and edge-routing logic is preserved faithfully so +ASCII output matches the upstream package. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/ansi.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/ansi.ts new file mode 100644 index 000000000..3ae0b8a5e --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/ansi.ts @@ -0,0 +1,409 @@ +// ============================================================================ +// ASCII renderer — color utilities +// +// Provides color output for themed ASCII diagrams. +// Supports ANSI terminal modes (16/256/truecolor) and HTML tags +// for browser rendering. +// ============================================================================ + +import type { CharRole, AsciiTheme, ColorMode } from './types' + +declare const document: unknown + +// ============================================================================ +// Default theme — matches SVG theme colors for consistency +// ============================================================================ + +/** + * Default ASCII theme derived from the SVG renderer's color palette. + * Uses the same mixing ratios to maintain visual consistency. + */ +export const DEFAULT_ASCII_THEME: AsciiTheme = { + fg: '#27272a', // zinc-800 — primary text + border: '#a1a1aa', // zinc-400 — node borders (12% mix) + line: '#71717a', // zinc-500 — edge lines (35% mix) + arrow: '#52525b', // zinc-600 — arrowheads (60% mix) + corner: '#71717a', // same as line + junction: '#a1a1aa', // same as border +} + +// ============================================================================ +// Color mode detection +// ============================================================================ + +/** + * Detect the best color mode for the current environment. + * + * Terminal detection order: + * 1. COLORTERM=truecolor or COLORTERM=24bit → truecolor + * 2. TERM contains "256color" → ansi256 + * 3. TERM is set and not "dumb" → ansi16 + * + * Browser: returns 'html' (uses tags with inline styles). + * Unknown/piped: returns 'none'. + */ +export function detectColorMode(): ColorMode { + // Check if we're in a Node.js-like environment with process object + // Use globalThis to safely check for process without TypeScript errors + const proc = (globalThis as { process?: { stdout?: { isTTY?: boolean }, env?: Record } }).process + + if (proc) { + // Check if stdout is a TTY (not piped/redirected) + if (!proc.stdout?.isTTY) { + return 'none' + } + + const colorTerm = proc.env?.COLORTERM?.toLowerCase() ?? '' + const term = proc.env?.TERM?.toLowerCase() ?? '' + + // True color support + if (colorTerm === 'truecolor' || colorTerm === '24bit') { + return 'truecolor' + } + + // 256 color support + if (term.includes('256color') || term.includes('256')) { + return 'ansi256' + } + + // Basic color support + if (term && term !== 'dumb') { + return 'ansi16' + } + + return 'none' + } + + // No process object → browser environment → use HTML color output + if (typeof document !== 'undefined') { + return 'html' + } + + return 'none' +} + +// ============================================================================ +// Hex color parsing +// ============================================================================ + +/** + * Parse a hex color string to RGB values. + * Supports both 3-char (#RGB) and 6-char (#RRGGBB) formats. + */ +function parseHex(hex: string): { r: number; g: number; b: number } { + const h = hex.replace('#', '') + if (h.length === 3) { + return { + r: parseInt(h[0]! + h[0]!, 16), + g: parseInt(h[1]! + h[1]!, 16), + b: parseInt(h[2]! + h[2]!, 16), + } + } + return { + r: parseInt(h.substring(0, 2), 16), + g: parseInt(h.substring(2, 4), 16), + b: parseInt(h.substring(4, 6), 16), + } +} + +// ============================================================================ +// ANSI escape code generation +// ============================================================================ + +/** ANSI escape sequence prefix */ +const ESC = '\x1b[' +/** Reset all attributes */ +const RESET = `${ESC}0m` + +/** + * Generate ANSI foreground color escape sequence for 24-bit true color. + * Format: ESC[38;2;R;G;Bm + */ +function truecolorFg(hex: string): string { + const { r, g, b } = parseHex(hex) + return `${ESC}38;2;${r};${g};${b}m` +} + +/** + * Find the closest 256-color palette index for an RGB color. + * The 256-color palette has: + * - 0-15: Standard colors (duplicates of 16-color) + * - 16-231: 6x6x6 color cube (216 colors) + * - 232-255: Grayscale ramp (24 shades) + */ +function rgbTo256(r: number, g: number, b: number): number { + // Check if it's close to grayscale + const avg = (r + g + b) / 3 + const maxDiff = Math.max(Math.abs(r - avg), Math.abs(g - avg), Math.abs(b - avg)) + + if (maxDiff < 10) { + // Use grayscale ramp (232-255) + // Each step is ~10.625 (256/24) + const gray = Math.round((avg / 255) * 23) + return 232 + Math.min(23, Math.max(0, gray)) + } + + // Use 6x6x6 color cube (16-231) + // Each channel maps to 0-5: 0, 95, 135, 175, 215, 255 + const toIndex = (v: number): number => { + if (v < 48) return 0 + if (v < 115) return 1 + return Math.min(5, Math.floor((v - 35) / 40)) + } + + const ri = toIndex(r) + const gi = toIndex(g) + const bi = toIndex(b) + + return 16 + (36 * ri) + (6 * gi) + bi +} + +/** + * Generate ANSI foreground color escape sequence for 256-color mode. + * Format: ESC[38;5;Nm + */ +function ansi256Fg(hex: string): string { + const { r, g, b } = parseHex(hex) + const index = rgbTo256(r, g, b) + return `${ESC}38;5;${index}m` +} + +/** + * Map an RGB color to the closest 16-color ANSI code. + * Returns the foreground color escape sequence. + * + * Standard 16 colors: + * 0=black, 1=red, 2=green, 3=yellow, 4=blue, 5=magenta, 6=cyan, 7=white + * 8-15 = bright versions + */ +function ansi16Fg(hex: string): string { + const { r, g, b } = parseHex(hex) + const luma = 0.299 * r + 0.587 * g + 0.114 * b + + // Determine brightness (use bright colors for better visibility) + const bright = luma > 100 ? 0 : 60 // 60 = bright variant offset + + // Determine base color based on dominant channel + let code: number + if (r > 180 && g < 100 && b < 100) code = 31 // red + else if (g > 180 && r < 100 && b < 100) code = 32 // green + else if (r > 150 && g > 150 && b < 100) code = 33 // yellow + else if (b > 180 && r < 100 && g < 100) code = 34 // blue + else if (r > 150 && b > 150 && g < 100) code = 35 // magenta + else if (g > 150 && b > 150 && r < 100) code = 36 // cyan + else if (luma > 200) code = 37 // white + else if (luma < 50) code = 30 // black + else code = 37 // default to white for grays + + return `${ESC}${code + bright}m` +} + +// ============================================================================ +// HTML color output (for browser rendering) +// ============================================================================ + +/** Escape characters that would break HTML output. */ +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>') +} + +/** Wrap text in a with an inline color style. */ +function htmlSpan(hex: string, text: string): string { + return `${escapeHtml(text)}` +} + +// ============================================================================ +// Role → color mapping +// ============================================================================ + +/** + * Get the color for a character role from the theme. + */ +function getRoleColor(role: CharRole, theme: AsciiTheme): string { + switch (role) { + case 'text': return theme.fg + case 'border': return theme.border + case 'line': return theme.line + case 'arrow': return theme.arrow + case 'corner': return theme.corner ?? theme.line + case 'junction': return theme.junction ?? theme.border + default: return theme.fg + } +} + +/** + * Generate the ANSI escape sequence for a role color. + */ +export function getAnsiColor(role: CharRole, theme: AsciiTheme, mode: ColorMode): string { + if (mode === 'none') return '' + + const hex = getRoleColor(role, theme) + + switch (mode) { + case 'truecolor': return truecolorFg(hex) + case 'ansi256': return ansi256Fg(hex) + case 'ansi16': return ansi16Fg(hex) + default: return '' + } +} + +/** + * Get the ANSI reset sequence. + */ +export function getAnsiReset(mode: ColorMode): string { + return mode === 'none' ? '' : RESET +} + +/** + * Wrap a character with ANSI color codes based on its role. + */ +export function colorizeChar( + char: string, + role: CharRole | null, + theme: AsciiTheme, + mode: ColorMode, +): string { + if (mode === 'none' || role === null || char === ' ') { + return char + } + + const colorCode = getAnsiColor(role, theme, mode) + return `${colorCode}${char}${RESET}` +} + +/** + * Colorize an entire line efficiently by grouping consecutive same-role characters. + * This reduces the number of escape sequences (ANSI) or span tags (HTML) in the output. + */ +export function colorizeLine( + chars: string[], + roles: (CharRole | null)[], + theme: AsciiTheme, + mode: ColorMode, +): string { + if (mode === 'none') { + return chars.join('') + } + + if (mode === 'html') { + return colorizeLineHtml(chars, roles, theme) + } + + let result = '' + let currentRole: CharRole | null = null + let buffer = '' + + for (let i = 0; i < chars.length; i++) { + const char = chars[i]! + const role = roles[i] ?? null + + // Whitespace doesn't need coloring + if (char === ' ') { + // Flush any buffered characters (with or without color) + if (buffer.length > 0) { + if (currentRole !== null) { + result += getAnsiColor(currentRole, theme, mode) + buffer + RESET + } else { + result += buffer + } + buffer = '' + currentRole = null + } + result += char + continue + } + + // Same role as previous — accumulate + if (role === currentRole) { + buffer += char + continue + } + + // Role changed — flush buffer (with or without color) and start new + if (buffer.length > 0) { + if (currentRole !== null) { + result += getAnsiColor(currentRole, theme, mode) + buffer + RESET + } else { + result += buffer + } + } + buffer = char + currentRole = role + } + + // Flush remaining buffer + if (buffer.length > 0 && currentRole !== null) { + result += getAnsiColor(currentRole, theme, mode) + buffer + RESET + } else if (buffer.length > 0) { + result += buffer + } + + return result +} + +/** + * HTML-specific line colorization. + * Groups consecutive same-role characters into tags with inline color styles. + * Whitespace is emitted bare (no wrapping) to keep output compact. + */ +function colorizeLineHtml( + chars: string[], + roles: (CharRole | null)[], + theme: AsciiTheme, +): string { + let result = '' + let currentRole: CharRole | null = null + let buffer = '' + + const flush = () => { + if (buffer.length === 0) return + if (currentRole !== null) { + result += htmlSpan(getRoleColor(currentRole, theme), buffer) + } else { + result += escapeHtml(buffer) + } + buffer = '' + currentRole = null + } + + for (let i = 0; i < chars.length; i++) { + const char = chars[i]! + const role = roles[i] ?? null + + if (char === ' ') { + flush() + result += ' ' + continue + } + + if (role === currentRole) { + buffer += char + continue + } + + flush() + buffer = char + currentRole = role + } + + flush() + return result +} + +/** + * Colorize a text string with a direct hex color. + * Used by renderers that need per-cell color control (e.g. multi-series xychart). + * Handles all output modes: ANSI (16/256/truecolor) and HTML. + */ +export function colorizeText(text: string, hex: string, mode: ColorMode): string { + if (mode === 'none' || text.length === 0) return text + if (mode === 'html') return htmlSpan(hex, text) + let code: string + switch (mode) { + case 'truecolor': code = truecolorFg(hex); break + case 'ansi256': code = ansi256Fg(hex); break + case 'ansi16': code = ansi16Fg(hex); break + default: return text + } + return `${code}${text}${RESET}` +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/canvas.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/canvas.ts new file mode 100644 index 000000000..2db2d19c6 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/canvas.ts @@ -0,0 +1,476 @@ +// ============================================================================ +// ASCII renderer — 2D text canvas +// +// Ported from AlexanderGrooff/mermaid-ascii cmd/draw.go. +// The canvas is a column-major 2D array of single-character strings. +// canvas[x][y] gives the character at column x, row y. +// ============================================================================ + +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' + +/** + * Create a blank canvas filled with spaces. + * Dimensions are inclusive: mkCanvas(3, 2) creates a 4x3 grid (indices 0..3, 0..2). + */ +export function mkCanvas(x: number, y: number): Canvas { + const canvas: Canvas = [] + for (let i = 0; i <= x; i++) { + const col: string[] = [] + for (let j = 0; j <= y; j++) { + col.push(' ') + } + canvas.push(col) + } + return canvas +} + +/** Create a blank canvas with the same dimensions as the given canvas. */ +export function copyCanvas(source: Canvas): Canvas { + const [maxX, maxY] = getCanvasSize(source) + return mkCanvas(maxX, maxY) +} + +// ============================================================================ +// Role canvas creation and management +// ============================================================================ + +/** + * Create a blank role canvas filled with nulls. + * Same dimensions as mkCanvas — column-major, roleCanvas[x][y]. + */ +export function mkRoleCanvas(x: number, y: number): RoleCanvas { + const roleCanvas: RoleCanvas = [] + for (let i = 0; i <= x; i++) { + const col: (CharRole | null)[] = [] + for (let j = 0; j <= y; j++) { + col.push(null) + } + roleCanvas.push(col) + } + return roleCanvas +} + +/** Create a blank role canvas with the same dimensions as the given role canvas. */ +export function copyRoleCanvas(source: RoleCanvas): RoleCanvas { + const maxX = source.length - 1 + const maxY = (source[0]?.length ?? 1) - 1 + return mkRoleCanvas(maxX, maxY) +} + +/** + * Grow the role canvas to fit at least (newX, newY), preserving existing roles. + * Mutates the role canvas in place and returns it. + */ +export function increaseRoleCanvasSize(roleCanvas: RoleCanvas, newX: number, newY: number): RoleCanvas { + const currX = roleCanvas.length - 1 + const currY = (roleCanvas[0]?.length ?? 1) - 1 + const targetX = Math.max(newX, currX) + const targetY = Math.max(newY, currY) + const grown = mkRoleCanvas(targetX, targetY) + for (let x = 0; x < grown.length; x++) { + for (let y = 0; y < grown[0]!.length; y++) { + if (x < roleCanvas.length && y < roleCanvas[0]!.length) { + grown[x]![y] = roleCanvas[x]![y]! + } + } + } + roleCanvas.length = 0 + roleCanvas.push(...grown) + return roleCanvas +} + +/** + * Set a role at a specific coordinate. + * Expands the role canvas if necessary. + */ +export function setRole(roleCanvas: RoleCanvas, x: number, y: number, role: CharRole): void { + if (x >= roleCanvas.length || y >= (roleCanvas[0]?.length ?? 0)) { + increaseRoleCanvasSize(roleCanvas, x, y) + } + roleCanvas[x]![y] = role +} + +/** + * Merge role canvases — same logic as mergeCanvases but for roles. + * Non-null roles in overlays overwrite null roles in base. + */ +export function mergeRoleCanvases( + base: RoleCanvas, + offset: DrawingCoord, + ...overlays: RoleCanvas[] +): RoleCanvas { + let maxX = base.length - 1 + let maxY = (base[0]?.length ?? 1) - 1 + + for (const overlay of overlays) { + const oX = overlay.length - 1 + const oY = (overlay[0]?.length ?? 1) - 1 + maxX = Math.max(maxX, oX + offset.x) + maxY = Math.max(maxY, oY + offset.y) + } + + const merged = mkRoleCanvas(maxX, maxY) + + // Copy base + for (let x = 0; x <= maxX; x++) { + for (let y = 0; y <= maxY; y++) { + if (x < base.length && y < base[0]!.length) { + merged[x]![y] = base[x]![y]! + } + } + } + + // Apply overlays + for (const overlay of overlays) { + for (let x = 0; x < overlay.length; x++) { + for (let y = 0; y < overlay[0]!.length; y++) { + const role = overlay[x]?.[y] + if (role !== null && role !== undefined) { + const mx = x + offset.x + const my = y + offset.y + merged[mx]![my] = role + } + } + } + } + + return merged +} + +/** Returns [maxX, maxY] — the highest valid indices in each dimension. */ +export function getCanvasSize(canvas: Canvas): [number, number] { + return [canvas.length - 1, (canvas[0]?.length ?? 1) - 1] +} + +/** + * Grow the canvas to fit at least (newX, newY), preserving existing content. + * Mutates the canvas in place and returns it. + */ +export function increaseSize(canvas: Canvas, newX: number, newY: number): Canvas { + const [currX, currY] = getCanvasSize(canvas) + const targetX = Math.max(newX, currX) + const targetY = Math.max(newY, currY) + const grown = mkCanvas(targetX, targetY) + for (let x = 0; x < grown.length; x++) { + for (let y = 0; y < grown[0]!.length; y++) { + if (x < canvas.length && y < canvas[0]!.length) { + grown[x]![y] = canvas[x]![y]! + } + } + } + // Mutate in place: splice old contents and replace with grown + canvas.length = 0 + canvas.push(...grown) + return canvas +} + +// ============================================================================ +// Junction merging — Unicode box-drawing character compositing +// ============================================================================ + +/** All Unicode box-drawing characters that participate in junction merging. */ +const JUNCTION_CHARS = new Set([ + '─', '│', '┌', '┐', '└', '┘', '├', '┤', '┬', '┴', '┼', '╴', '╵', '╶', '╷', +]) + +export function isJunctionChar(c: string): boolean { + return JUNCTION_CHARS.has(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 +} + +/** + * When two junction characters overlap during canvas merging, + * resolve them to the correct combined junction. + * E.g., '─' overlapping '│' becomes '┼'. + */ +const JUNCTION_MAP: Record> = { + '─': { '│': '┼', '┌': '┬', '┐': '┬', '└': '┴', '┘': '┴', '├': '┼', '┤': '┼', '┬': '┬', '┴': '┴' }, + '│': { '─': '┼', '┌': '├', '┐': '┤', '└': '├', '┘': '┤', '├': '├', '┤': '┤', '┬': '┼', '┴': '┼' }, + '┌': { '─': '┬', '│': '├', '┐': '┬', '└': '├', '┘': '┼', '├': '├', '┤': '┼', '┬': '┬', '┴': '┼' }, + '┐': { '─': '┬', '│': '┤', '┌': '┬', '└': '┼', '┘': '┤', '├': '┼', '┤': '┤', '┬': '┬', '┴': '┼' }, + '└': { '─': '┴', '│': '├', '┌': '├', '┐': '┼', '┘': '┴', '├': '├', '┤': '┼', '┬': '┼', '┴': '┴' }, + '┘': { '─': '┴', '│': '┤', '┌': '┼', '┐': '┤', '└': '┴', '├': '┼', '┤': '┤', '┬': '┼', '┴': '┴' }, + '├': { '─': '┼', '│': '├', '┌': '├', '┐': '┼', '└': '├', '┘': '┼', '┤': '┼', '┬': '┼', '┴': '┼' }, + '┤': { '─': '┼', '│': '┤', '┌': '┼', '┐': '┤', '└': '┼', '┘': '┤', '├': '┼', '┬': '┼', '┴': '┼' }, + '┬': { '─': '┬', '│': '┼', '┌': '┬', '┐': '┬', '└': '┼', '┘': '┼', '├': '┼', '┤': '┼', '┴': '┼' }, + '┴': { '─': '┴', '│': '┼', '┌': '┼', '┐': '┼', '└': '┴', '┘': '┴', '├': '┼', '┤': '┼', '┬': '┼' }, +} + +export function mergeJunctions(c1: string, c2: string): string { + return JUNCTION_MAP[c1]?.[c2] ?? c1 +} + +// ============================================================================ +// Canvas merging — composite multiple canvases with offset +// ============================================================================ + +/** + * Merge overlay canvases onto a base canvas at the given offset. + * Non-space characters in overlays overwrite the base. + * When both characters are Unicode junction chars, they're merged intelligently. + */ +export function mergeCanvases( + base: Canvas, + offset: DrawingCoord, + useAscii: boolean, + ...overlays: Canvas[] +): Canvas { + let [maxX, maxY] = getCanvasSize(base) + for (const overlay of overlays) { + const [oX, oY] = getCanvasSize(overlay) + maxX = Math.max(maxX, oX + offset.x) + maxY = Math.max(maxY, oY + offset.y) + } + + const merged = mkCanvas(maxX, maxY) + + // Copy base + for (let x = 0; x <= maxX; x++) { + for (let y = 0; y <= maxY; y++) { + if (x < base.length && y < base[0]!.length) { + merged[x]![y] = base[x]![y]! + } + } + } + + // Apply overlays + for (const overlay of overlays) { + 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 + 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) + } + } + } + } + + return merged +} + +// ============================================================================ +// Canvas → string conversion +// ============================================================================ + +/** Options for converting canvas to string with optional coloring. */ +export interface CanvasToStringOptions { + /** Role canvas for applying colors. If not provided, output is plain text. */ + roleCanvas?: RoleCanvas + /** Color mode for terminal output. Default: 'none' */ + colorMode?: ColorMode + /** Theme colors for ASCII output. Uses default theme if not provided. */ + theme?: AsciiTheme +} + +/** + * Convert the canvas to a multi-line string (row by row, left to right). + * Optionally applies ANSI color codes based on character roles. + */ +export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions): string { + const [maxX, maxY] = getCanvasSize(canvas) + const lines: string[] = [] + + const roleCanvas = options?.roleCanvas + const colorMode = options?.colorMode ?? 'none' + const theme = options?.theme ?? DEFAULT_ASCII_THEME + + for (let y = 0; y <= maxY; y++) { + if (colorMode === 'none' || !roleCanvas) { + // Plain text output — no colors + 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 + } + lines.push(line) + } else { + // Colored output — collect chars and roles for this row + const chars: string[] = [] + const roles: (CharRole | null)[] = [] + for (let x = 0; x <= maxX; x++) { + 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)) + } + } + + return lines.join('\n') +} + +// ============================================================================ +// Canvas vertical flip — used for BT (bottom-to-top) direction support. +// +// The ASCII renderer lays out graphs top-down (TD). For BT direction, we +// flip the finished canvas vertically and remap directional characters so +// arrows point upward and corners are mirrored correctly. +// ============================================================================ + +/** + * Characters that change meaning when the Y-axis is flipped. + * Symmetric characters (─, │, ├, ┤, ┼) are unchanged. + */ +const VERTICAL_FLIP_MAP: Record = { + // Unicode arrows + '▲': '▼', '▼': '▲', + '◤': '◣', '◣': '◤', + '◥': '◢', '◢': '◥', + // ASCII arrows + '^': 'v', 'v': '^', + // Unicode corners + '┌': '└', '└': '┌', + '┐': '┘', '┘': '┐', + // Unicode junctions (T-pieces flip vertically) + '┬': '┴', '┴': '┬', + // Box-start junctions (exit points from node boxes) + '╵': '╷', '╷': '╵', +} + +/** + * Flip the canvas vertically (mirror across the horizontal center). + * Reverses row order within each column and remaps directional characters + * (arrows, corners, junctions) so they point the correct way after flip. + * + * Used to transform a TD-rendered canvas into BT output. + * Mutates the canvas in place and returns it. + */ +export function flipCanvasVertically(canvas: Canvas): Canvas { + // Reverse each column array (Y-axis flip in column-major layout) + for (const col of canvas) { + col.reverse() + } + + // Remap directional characters that change meaning after vertical flip + for (const col of canvas) { + for (let y = 0; y < col.length; y++) { + const flipped = VERTICAL_FLIP_MAP[col[y]!] + if (flipped) col[y] = flipped + } + } + + return canvas +} + +/** + * Flip the role canvas vertically to match flipCanvasVertically. + * Mutates the role canvas in place and returns it. + */ +export function flipRoleCanvasVertically(roleCanvas: RoleCanvas): RoleCanvas { + for (const col of roleCanvas) { + col.reverse() + } + return roleCanvas +} + +/** + * Draw text string onto the canvas starting at the given coordinate. + * By default, preserves existing non-space characters (labels don't overwrite each other). + * Set forceOverwrite=true to always overwrite (for box content). + */ +export function drawText( + canvas: Canvas, + start: DrawingCoord, + text: string, + forceOverwrite = false +): 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]! + // WIDE_PAD cells are written atomically with their lead below + if (cell === WIDE_PAD) continue + const x = start.x + 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) + } + } +} + +/** + * Set the canvas size to fit all grid columns and rows. + * Called after layout to ensure the canvas covers the full drawing area. + */ +export function setCanvasSizeToGrid( + canvas: Canvas, + columnWidth: Map, + rowHeight: Map, +): void { + let maxX = 0 + let maxY = 0 + for (const w of columnWidth.values()) maxX += w + for (const h of rowHeight.values()) maxY += h + increaseSize(canvas, maxX - 1, maxY - 1) +} + +/** + * Set the role canvas size to match the grid dimensions. + * Should be called alongside setCanvasSizeToGrid. + */ +export function setRoleCanvasSizeToGrid( + roleCanvas: RoleCanvas, + columnWidth: Map, + rowHeight: Map, +): void { + let maxX = 0 + let maxY = 0 + for (const w of columnWidth.values()) maxX += w + for (const h of rowHeight.values()) maxY += h + increaseRoleCanvasSize(roleCanvas, maxX - 1, maxY - 1) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/class-diagram.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/class-diagram.ts new file mode 100644 index 000000000..fa34eec1c --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/class-diagram.ts @@ -0,0 +1,699 @@ +// ============================================================================ +// ASCII renderer — class diagrams +// +// Renders classDiagram text to ASCII/Unicode art. +// Each class is a multi-compartment box (header | attributes | methods). +// Relationships are drawn as lines between classes with UML markers. +// +// Layout: level-based top-down. "From" classes are placed above "to" classes +// for all relationship types, matching ELK/mermaid.com behavior. +// Relationship lines use simple Manhattan routing (vertical + horizontal). +// ============================================================================ + +import { parseClassDiagram } from '../class/parser' +import type { ClassDiagram, ClassNode, ClassMember, ClassRelationship, RelationshipType } from '../class/types' +import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types' +import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas' +import { drawMultiBox } from './draw' +import { splitLines } from './multiline-utils' +import { displayWidth, toCells } from '../text-metrics' + +/** Classify a character from a box drawing as 'border' or 'text'. */ +function classifyBoxChar(ch: string): CharRole { + if (/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|]$/.test(ch)) return 'border' + return 'text' +} + +// ============================================================================ +// Class member formatting +// ============================================================================ + +/** Format a class member as a display string: visibility + name + optional type */ +function formatMember(m: ClassMember): string { + const vis = m.visibility || '' + const type = m.type ? `: ${m.type}` : '' + return `${vis}${m.name}${type}` +} + +/** Build the text sections for a class box: [header], [attributes], [methods] */ +function buildClassSections(cls: ClassNode): string[][] { + // Header section: optional annotation + class name (may be multi-line) + const header: string[] = [] + if (cls.annotation) header.push(`<<${cls.annotation}>>`) + // Support multi-line class names + const nameLines = splitLines(cls.label) + header.push(...nameLines) + + // Attributes section + const attrs = cls.attributes.map(formatMember) + + // Methods section + const methods = cls.methods.map(formatMember) + + // If no attrs and no methods, just return header (1-section box) + if (attrs.length === 0 && methods.length === 0) return [header] + // If no methods, return header + attrs (2-section box) + if (methods.length === 0) return [header, attrs] + // Full 3-section box + return [header, attrs, methods] +} + +// ============================================================================ +// Relationship marker characters +// ============================================================================ + +interface RelMarker { + /** Relationship type (determines marker shape) */ + type: RelationshipType + /** Which end the marker is placed at */ + markerAt: 'from' | 'to' + /** Whether the line is dashed */ + dashed: boolean +} + +/** + * Build the marker metadata for a relationship. + * The actual marker character will be determined at placement time based on line direction. + */ +function getRelMarker(type: RelationshipType, markerAt: 'from' | 'to'): RelMarker { + const dashed = type === 'dependency' || type === 'realization' + return { type, markerAt, dashed } +} + +/** + * Get the UML marker shape character for a relationship type. + * For directional arrows (association/dependency), the direction parameter + * specifies which way the arrow should point. + */ +function getMarkerShape( + type: RelationshipType, + useAscii: boolean, + direction?: 'up' | 'down' | 'left' | 'right' +): string { + switch (type) { + case 'inheritance': + case 'realization': + // Hollow triangle - rotate based on line direction + // Triangle points TOWARD the parent class + if (direction === 'down') { + // Line goes down (parent above, child below) - triangle points UP + return useAscii ? '^' : '△' + } else if (direction === 'up') { + // Line goes up (parent below, child above) - triangle points DOWN + return useAscii ? 'v' : '▽' + } else if (direction === 'left') { + // Line goes left - triangle points LEFT + return useAscii ? '>' : '◁' + } else { + // Default: line goes right - triangle points RIGHT + return useAscii ? '<' : '▷' + } + case 'composition': + // Filled diamond - omnidirectional shape + return useAscii ? '*' : '◆' + case 'aggregation': + // Hollow diamond - omnidirectional shape + return useAscii ? 'o' : '◇' + case 'association': + case 'dependency': + // Directional arrow - rotate based on line direction + if (direction === 'down') { + return useAscii ? 'v' : '▼' + } else if (direction === 'up') { + return useAscii ? '^' : '▲' + } else if (direction === 'left') { + return useAscii ? '<' : '◀' + } else { + // Default to right (or when direction not specified) + return useAscii ? '>' : '▶' + } + } +} + +// ============================================================================ +// Layout and rendering +// ============================================================================ + +/** Positioned class node on the canvas */ +interface PlacedClass { + cls: ClassNode + sections: string[][] + x: number + y: number + width: number + height: number +} + +/** + * Render a Mermaid class diagram to ASCII/Unicode text. + * + * Pipeline: parse → build boxes → level-based layout → draw boxes → draw relationships → string. + */ +export function renderClassAscii(text: string, config: AsciiConfig, colorMode?: ColorMode, theme?: AsciiTheme): string { + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) + const diagram = parseClassDiagram(lines) + + if (diagram.classes.length === 0) return '' + + const useAscii = config.useAscii + const hGap = 4 // horizontal gap between class boxes + const vGap = 3 // vertical gap between levels (enough for relationship lines) + + // --- Build box dimensions for each class --- + const classSections = new Map() + const classBoxW = new Map() + const classBoxH = new Map() + + for (const cls of diagram.classes) { + const sections = buildClassSections(cls) + classSections.set(cls.id, sections) + + // Compute box dimensions from drawMultiBox logic + let maxTextW = 0 + for (const section of sections) { + for (const line of section) maxTextW = Math.max(maxTextW, displayWidth(line)) + } + const boxW = maxTextW + 4 // 2 border + 2 padding + + let totalLines = 0 + for (const section of sections) totalLines += Math.max(section.length, 1) + const boxH = totalLines + (sections.length - 1) + 2 // section lines + dividers + top/bottom border + + classBoxW.set(cls.id, boxW) + classBoxH.set(cls.id, boxH) + } + + // --- Assign levels: topological sort based on directed relationships --- + // All relationship types place "from" above "to" in the layout, matching + // ELK's layered algorithm and the official mermaid.com renderer behavior. + // For "Animal <|-- Dog": from="Animal", to="Dog" → Animal above Dog. + // + // Every relationship type (including association and dependency) forces nodes + // to different levels. Same-row routing for mixed diagrams causes collisions: + // detour lines overlap with cross-level routing, and labels overwrite box borders. + + const classById = new Map() + for (const cls of diagram.classes) classById.set(cls.id, cls) + + const parents = new Map>() // child → set of parent IDs + const children = new Map>() // parent → set of child IDs + + for (const rel of diagram.relationships) { + // For inheritance/realization, the marker (hollow triangle) points to the parent. + // - `Animal <|-- Dog` (markerAt='from'): Animal is parent, Dog is child + // - `Bird ..|> Flyable` (markerAt='to'): Flyable is parent, Bird is child + // For other relationships, use the default from→to direction. + const isHierarchical = rel.type === 'inheritance' || rel.type === 'realization' + const parentId = isHierarchical && rel.markerAt === 'to' ? rel.to : rel.from + const childId = isHierarchical && rel.markerAt === 'to' ? rel.from : rel.to + + if (!parents.has(childId)) parents.set(childId, new Set()) + parents.get(childId)!.add(parentId) + if (!children.has(parentId)) children.set(parentId, new Set()) + children.get(parentId)!.add(childId) + } + + // BFS from roots (classes that have no parents) to assign levels. + // Cap at classes.length - 1 to prevent infinite loops on cyclic graphs + // (e.g. View --> Model and Model ..> View would otherwise push levels + // upward forever). In a DAG the longest path has at most N-1 edges. + const level = new Map() + const roots = diagram.classes.filter(c => !parents.has(c.id) || parents.get(c.id)!.size === 0) + const queue: string[] = roots.map(c => c.id) + for (const id of queue) level.set(id, 0) + + const levelCap = diagram.classes.length - 1 + let qi = 0 + while (qi < queue.length) { + const id = queue[qi++]! + const childSet = children.get(id) + if (!childSet) continue + for (const childId of childSet) { + const newLevel = (level.get(id) ?? 0) + 1 + if (newLevel > levelCap) continue // cycle detected — skip to prevent infinite loop + if (!level.has(childId) || level.get(childId)! < newLevel) { + level.set(childId, newLevel) + queue.push(childId) + } + } + } + + // Assign remaining (unconnected) classes to level 0 + for (const cls of diagram.classes) { + if (!level.has(cls.id)) level.set(cls.id, 0) + } + + // --- Position classes by level --- + // Group classes by level + const maxLevel = Math.max(...[...level.values()], 0) + const levelGroups: string[][] = Array.from({ length: maxLevel + 1 }, () => []) + for (const cls of diagram.classes) { + levelGroups[level.get(cls.id)!]!.push(cls.id) + } + + // Compute positions: each level is a row, classes in a row are spaced horizontally + const placed = new Map() + let currentY = 0 + + for (let lv = 0; lv <= maxLevel; lv++) { + const group = levelGroups[lv]! + if (group.length === 0) continue + + let currentX = 0 + let maxH = 0 + + for (const id of group) { + const cls = classById.get(id)! + const w = classBoxW.get(id)! + const h = classBoxH.get(id)! + placed.set(id, { + cls, + sections: classSections.get(id)!, + x: currentX, + y: currentY, + width: w, + height: h, + }) + currentX += w + hGap + maxH = Math.max(maxH, h) + } + + currentY += maxH + vGap + } + + // --- Create canvas --- + let totalW = 0 + let totalH = 0 + for (const p of placed.values()) { + totalW = Math.max(totalW, p.x + p.width) + totalH = Math.max(totalH, p.y + p.height) + } + + // Extra space for relationship lines that may go below/beside + totalW += 4 + totalH += 2 + + const canvas = mkCanvas(totalW - 1, totalH - 1) + const rc = mkRoleCanvas(totalW - 1, totalH - 1) + + /** Set a character on the canvas and track its role. */ + function setC(x: number, y: number, ch: string, role: CharRole): void { + if (x >= 0 && x < canvas.length && y >= 0 && y < (canvas[0]?.length ?? 0)) { + canvas[x]![y] = ch + setRole(rc, x, y, role) + } + } + + // --- Draw class boxes --- + for (const p of placed.values()) { + const boxCanvas = drawMultiBox(p.sections, useAscii) + // Copy box onto main canvas at (p.x, p.y) with role tracking + for (let bx = 0; bx < boxCanvas.length; bx++) { + for (let by = 0; by < boxCanvas[0]!.length; by++) { + const ch = boxCanvas[bx]![by]! + if (ch !== ' ') { + const cx = p.x + bx + const cy = p.y + by + if (cx < totalW && cy < totalH) { + setC(cx, cy, ch, classifyBoxChar(ch)) + } + } + } + } + } + + // --- Build occupancy map for collision avoidance --- + // Track which x positions are occupied at each y level (to avoid routing through boxes) + const boxOccupancy: { x1: number; x2: number; y1: number; y2: number }[] = [] + for (const p of placed.values()) { + boxOccupancy.push({ + x1: p.x, + x2: p.x + p.width - 1, + y1: p.y, + y2: p.y + p.height - 1, + }) + } + + /** Check if a point (x, y) is inside any class box */ + function isInsideBox(x: number, y: number, excludeIds?: Set): boolean { + for (const [id, p] of placed.entries()) { + if (excludeIds?.has(id)) continue + if (x >= p.x && x <= p.x + p.width - 1 && y >= p.y && y <= p.y + p.height - 1) { + return true + } + } + return false + } + + /** Find a clear vertical column for routing that doesn't pass through any boxes */ + function findClearColumn(startX: number, y1: number, y2: number, excludeIds: Set): number { + // Try the original column first + let clear = true + for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) { + if (isInsideBox(startX, y, excludeIds)) { + clear = false + break + } + } + if (clear) return startX + + // Try columns to the left and right, alternating + for (let offset = 1; offset < totalW + 10; offset++) { + // Try right + const rightX = startX + offset + clear = true + for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) { + if (isInsideBox(rightX, y, excludeIds)) { + clear = false + break + } + } + if (clear) return rightX + + // Try left + const leftX = startX - offset + if (leftX >= 0) { + clear = true + for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) { + if (isInsideBox(leftX, y, excludeIds)) { + clear = false + break + } + } + if (clear) return leftX + } + } + + // Fallback to right edge of canvas + some extra space + return totalW + 2 + } + + // --- Draw relationship lines --- + const H = useAscii ? '-' : '─' + const V = useAscii ? '|' : '│' + const dashH = useAscii ? '.' : '╌' + const dashV = useAscii ? ':' : '┊' + + for (const rel of diagram.relationships) { + const fromP = placed.get(rel.from) + const toP = placed.get(rel.to) + if (!fromP || !toP) continue + + const marker = getRelMarker(rel.type, rel.markerAt) + const lineH = marker.dashed ? dashH : H + const lineV = marker.dashed ? dashV : V + + // Exclude source and target boxes from collision detection + const excludeIds = new Set([rel.from, rel.to]) + + // Connection points: center-bottom of source → center-top of target + const fromCX = fromP.x + Math.floor(fromP.width / 2) + const fromBY = fromP.y + fromP.height - 1 + const toCX = toP.x + Math.floor(toP.width / 2) + const toTY = toP.y + + // Route: Manhattan routing with collision avoidance + // If target is below source: vertical down from source, horizontal if needed, vertical down to target + // If same row: horizontal line with a small vertical detour above or below + if (fromBY < toTY) { + // Target is below source — routing with collision avoidance + // Find a clear vertical column for the ENTIRE path from source to target + const routeX = findClearColumn(fromCX, fromBY + 1, toTY - 1, excludeIds) + const needsDetour = routeX !== fromCX + + // Expand canvas if needed to accommodate routing column + if (routeX >= totalW) { + increaseSize(canvas, routeX + 2, totalH) + } + + if (needsDetour) { + // COLLISION CASE: Route around intermediate boxes + // Path: source center → horizontal to routeX → vertical to entry → horizontal to target center + + const exitY = fromBY + 1 + const entryY = toTY - 1 + + // 1. Horizontal from source center to route column + const lx1 = Math.min(fromCX, routeX) + const rx1 = Math.max(fromCX, routeX) + for (let x = lx1; x <= rx1; x++) { + setC(x, exitY, lineH, 'line') + } + if (!useAscii && exitY < (canvas[0]?.length ?? 0)) { + if (fromCX < routeX) { + setC(fromCX, exitY, '└', 'corner') + setC(routeX, exitY, '┐', 'corner') + } else { + setC(fromCX, exitY, '┘', 'corner') + setC(routeX, exitY, '┌', 'corner') + } + } + + // 2. Vertical at routeX from exit to entry + for (let y = exitY + 1; y <= entryY; y++) { + setC(routeX, y, lineV, 'line') + } + + // 3. Horizontal from routeX to target center at entry + if (routeX !== toCX) { + const lx2 = Math.min(routeX, toCX) + const rx2 = Math.max(routeX, toCX) + for (let x = lx2; x <= rx2; x++) { + setC(x, entryY, lineH, 'line') + } + if (!useAscii && entryY < (canvas[0]?.length ?? 0)) { + if (routeX < toCX) { + setC(routeX, entryY, '└', 'corner') + setC(toCX, entryY, '┐', 'corner') + } else { + setC(routeX, entryY, '┘', 'corner') + setC(toCX, entryY, '┌', 'corner') + } + } + } + + // Markers for detour case + if (marker.markerAt === 'to') { + const markerChar = getMarkerShape(marker.type, useAscii, 'down') + setC(toCX, entryY, markerChar, 'arrow') + } + if (marker.markerAt === 'from') { + const markerChar = getMarkerShape(marker.type, useAscii, 'down') + setC(fromCX, fromBY + 1, markerChar, 'arrow') + } + } else { + // NO COLLISION CASE: Use original midpoint-based routing + // Path: source center → vertical to midY → horizontal at midY → vertical to target + + const midY = fromBY + Math.floor((toTY - fromBY) / 2) + + // 1. Vertical from source bottom to midY + for (let y = fromBY + 1; y <= midY; y++) { + setC(fromCX, y, lineV, 'line') + } + + // 2. Horizontal from fromCX to toCX at midY (if needed) + if (fromCX !== toCX && midY < (canvas[0]?.length ?? 0)) { + const lx = Math.min(fromCX, toCX) + const rx = Math.max(fromCX, toCX) + for (let x = lx; x <= rx; x++) { + setC(x, midY, lineH, 'line') + } + if (!useAscii) { + setC(fromCX, midY, fromCX < toCX ? '└' : '┘', 'corner') + setC(toCX, midY, fromCX < toCX ? '┐' : '┌', 'corner') + } + } + + // 3. Vertical from midY to target top + for (let y = midY + 1; y < toTY; y++) { + setC(toCX, y, lineV, 'line') + } + + // Markers for no-collision case + if (marker.markerAt === 'to') { + setC(toCX, toTY - 1, getMarkerShape(marker.type, useAscii, 'down'), 'arrow') + } + if (marker.markerAt === 'from') { + setC(fromCX, fromBY + 1, getMarkerShape(marker.type, useAscii, 'down'), 'arrow') + } + } + } else if (toP.y + toP.height - 1 < fromP.y) { + // Target is ABOVE source — draw upward from source top to target bottom + const fromTY = fromP.y + const toBY = toP.y + toP.height - 1 + const midY = toBY + Math.floor((fromTY - toBY) / 2) + + for (let y = fromTY - 1; y >= midY; y--) { + setC(fromCX, y, lineV, 'line') + } + + if (fromCX !== toCX) { + const lx = Math.min(fromCX, toCX) + const rx = Math.max(fromCX, toCX) + for (let x = lx; x <= rx; x++) { + setC(x, midY, lineH, 'line') + } + if (!useAscii && midY >= 0 && midY < totalH) { + setC(fromCX, midY, fromCX < toCX ? '┌' : '┐', 'corner') + setC(toCX, midY, fromCX < toCX ? '┘' : '└', 'corner') + } + } + + for (let y = midY - 1; y > toBY; y--) { + setC(toCX, y, lineV, 'line') + } + + // Draw markers - arrows point in the direction of the vertical segment (upward) + if (marker.markerAt === 'from') { + const markerChar = getMarkerShape(marker.type, useAscii, 'up') + const my = fromTY - 1 + for (let i = 0; i < markerChar.length; i++) { + setC(fromCX - Math.floor(markerChar.length / 2) + i, my, markerChar[i]!, 'arrow') + } + } + if (marker.markerAt === 'to') { + const isHierarchical = marker.type === 'inheritance' || marker.type === 'realization' + const markerDir = isHierarchical ? 'down' : 'up' + const markerChar = getMarkerShape(marker.type, useAscii, markerDir) + const my = toBY + 1 + for (let i = 0; i < markerChar.length; i++) { + setC(toCX - Math.floor(markerChar.length / 2) + i, my, markerChar[i]!, 'arrow') + } + } + } else { + // Same level — draw horizontal line with a detour below both boxes + const detourY = Math.max(fromBY, toP.y + toP.height - 1) + 2 + increaseSize(canvas, totalW, detourY + 1) + increaseRoleCanvasSize(rc, totalW, detourY + 1) + + // Vertical down from source + for (let y = fromBY + 1; y <= detourY; y++) { + setC(fromCX, y, lineV, 'line') + } + // Horizontal + const lx = Math.min(fromCX, toCX) + const rx = Math.max(fromCX, toCX) + for (let x = lx; x <= rx; x++) { + setC(x, detourY, lineH, 'line') + } + // Vertical up to target + for (let y = detourY - 1; y >= toP.y + toP.height; y--) { + setC(toCX, y, lineV, 'line') + } + + // Draw markers - same-level routing uses vertical segments at both ends + if (marker.markerAt === 'from') { + const markerChar = getMarkerShape(marker.type, useAscii, 'down') + const my = fromBY + 1 + for (let i = 0; i < markerChar.length; i++) { + setC(fromCX - Math.floor(markerChar.length / 2) + i, my, markerChar[i]!, 'arrow') + } + } + if (marker.markerAt === 'to') { + const markerChar = getMarkerShape(marker.type, useAscii, 'up') + const my = toP.y + toP.height + for (let i = 0; i < markerChar.length; i++) { + setC(toCX - Math.floor(markerChar.length / 2) + i, my, markerChar[i]!, 'arrow') + } + } + } + + // Draw relationship label at midpoint if present (supports multi-line) + // Add padding around the label for readability + if (rel.label) { + const lines = splitLines(rel.label) + const maxLabelWidth = Math.max(...lines.map(l => displayWidth(l))) + 2 // +2 for padding + + // Calculate ideal label position based on routing direction + let baseMidY: number + let idealMidX: number + + if (fromBY < toTY) { + // Target below source: place in gap between source bottom and target top + baseMidY = Math.floor((fromBY + 1 + toTY - 1) / 2) + idealMidX = Math.floor((fromCX + toCX) / 2) + } else if (toP.y + toP.height - 1 < fromP.y) { + // Target above source: place in gap between target bottom and source top + const toBY = toP.y + toP.height - 1 + baseMidY = Math.floor((toBY + 1 + fromP.y - 1) / 2) + idealMidX = Math.floor((fromCX + toCX) / 2) + } else { + // Same level: place label at midpoint of the detour line + baseMidY = Math.max(fromBY, toP.y + toP.height - 1) + 2 + idealMidX = Math.floor((fromCX + toCX) / 2) + } + + // Find a clear vertical position for the label (not inside any box) + let labelY = baseMidY + const halfHeight = Math.floor(lines.length / 2) + + // Check if any label line would be inside a box + let labelInBox = false + for (let i = 0; i < lines.length; i++) { + const y = labelY - halfHeight + i + const idealLabelStart = idealMidX - Math.floor(maxLabelWidth / 2) + const labelStart = Math.max(0, idealLabelStart) + // Check if this line overlaps any box + for (let x = labelStart; x < labelStart + maxLabelWidth; x++) { + if (isInsideBox(x, y, excludeIds)) { + labelInBox = true + break + } + } + if (labelInBox) break + } + + // If label is inside a box, find the gap between boxes + if (labelInBox) { + // Find the gap between source and target boxes + const gapTop = fromBY + 1 + const gapBottom = toTY - 1 + + // Place label in the middle of the gap, outside any intermediate box + for (let y = gapTop; y <= gapBottom; y++) { + let clearRow = true + const idealLabelStart = idealMidX - Math.floor(maxLabelWidth / 2) + const labelStart = Math.max(0, idealLabelStart) + for (let x = labelStart; x < labelStart + maxLabelWidth; x++) { + if (isInsideBox(x, y, excludeIds)) { + clearRow = false + break + } + } + if (clearRow) { + labelY = y + break + } + } + } + + // Center lines vertically around labelY + const startY = labelY - halfHeight + + 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(cells.length / 2) + const labelStart = Math.max(0, idealLabelStart) + const y = startY + lineIdx + // Ensure canvas is wide enough for the label + 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 < cells.length; i++) { + const lx = labelStart + i + if (lx >= 0 && y >= 0) { + setC(lx, y, cells[i]!, 'text') + } + } + } + } + } + + return canvasToString(canvas, { roleCanvas: rc, colorMode, theme }) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/converter.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/converter.ts new file mode 100644 index 000000000..166b9a186 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/converter.ts @@ -0,0 +1,271 @@ +// ============================================================================ +// ASCII renderer — MermaidGraph → AsciiGraph converter +// +// Bridges the existing TypeScript parser output to the ASCII renderer's +// internal graph structure. This avoids maintaining a separate parser +// for ASCII rendering — we reuse parseMermaid() and convert its output. +// ============================================================================ + +import type { MermaidGraph, MermaidSubgraph } from '../types' +import type { + AsciiGraph, AsciiNode, AsciiEdge, AsciiSubgraph, AsciiConfig, +} from './types' +import { EMPTY_STYLE } from './types' +import { mkCanvas, mkRoleCanvas } from './canvas' + +/** + * Convert a parsed MermaidGraph into an AsciiGraph ready for grid layout. + * + * Key mappings: + * - MermaidGraph.nodes (Map) → ordered AsciiNode[] preserving insertion order + * - MermaidGraph.edges → AsciiEdge[] with resolved node references + * - MermaidGraph.subgraphs → AsciiSubgraph[] with parent/child tree + * - Node labels are used as display names (not raw IDs) + */ +export function convertToAsciiGraph(parsed: MermaidGraph, config: AsciiConfig): AsciiGraph { + // Build node list preserving Map insertion order + const nodeMap = new Map() + let index = 0 + + for (const [id, mNode] of parsed.nodes) { + const asciiNode: AsciiNode = { + // Use the parser ID as the unique identity key to avoid collisions + // when multiple nodes share the same label (e.g. A[Web Server], C[Web Server]). + name: id, + // The label is used for rendering inside the box. + displayLabel: mNode.label, + // Preserve shape from parser for shape-aware rendering + shape: mNode.shape, + index, + gridCoord: null, + drawingCoord: null, + drawing: null, + drawn: false, + styleClassName: '', + styleClass: EMPTY_STYLE, + } + nodeMap.set(id, asciiNode) + index++ + } + + const nodes = [...nodeMap.values()] + + // Build edges with resolved node references + const edges: AsciiEdge[] = [] + for (const mEdge of parsed.edges) { + const from = nodeMap.get(mEdge.source) + const to = nodeMap.get(mEdge.target) + if (!from || !to) continue + + edges.push({ + from, + to, + text: mEdge.label ?? '', + path: [], + labelLine: [], + startDir: { x: 0, y: 0 }, + endDir: { x: 0, y: 0 }, + style: mEdge.style, + hasArrowStart: mEdge.hasArrowStart, + hasArrowEnd: mEdge.hasArrowEnd, + }) + } + + // Convert subgraphs recursively + const subgraphs: AsciiSubgraph[] = [] + for (const mSg of parsed.subgraphs) { + convertSubgraph(mSg, null, nodeMap, subgraphs) + } + + // Deduplicate subgraph node membership to match Go parser behavior. + // In Go, a node belongs only to the subgraph where it was FIRST DEFINED. + // The TS parser adds referenced nodes to all subgraphs they appear in, + // which causes incorrect bounding boxes when nodes span subgraph boundaries. + deduplicateSubgraphNodes(parsed.subgraphs, subgraphs, nodeMap, parsed) + + // Apply class definitions + for (const [nodeId, className] of parsed.classAssignments) { + const node = nodeMap.get(nodeId) + const classDef = parsed.classDefs.get(className) + if (node && classDef) { + node.styleClassName = className + node.styleClass = { name: className, styles: classDef } + } + } + + return { + nodes, + edges, + canvas: mkCanvas(0, 0), + roleCanvas: mkRoleCanvas(0, 0), + grid: new Map(), + columnWidth: new Map(), + rowHeight: new Map(), + subgraphs, + config, + offsetX: 0, + offsetY: 0, + bundles: [], // Populated by analyzeEdgeBundles() during layout + } +} + +/** + * Recursively convert a MermaidSubgraph to AsciiSubgraph. + * Flattens the tree into the subgraphs array while maintaining parent/child references. + * This matches the Go implementation where all subgraphs are in a flat list + * but linked via parent/children pointers. + */ +function convertSubgraph( + mSg: MermaidSubgraph, + parent: AsciiSubgraph | null, + nodeMap: Map, + allSubgraphs: AsciiSubgraph[], +): AsciiSubgraph { + // Normalize subgraph direction: BT→TD, RL→LR (same as root graph normalization) + let normalizedDirection: 'LR' | 'TD' | undefined + if (mSg.direction) { + normalizedDirection = (mSg.direction === 'LR' || mSg.direction === 'RL') ? 'LR' : 'TD' + } + + const sg: AsciiSubgraph = { + name: mSg.label, + nodes: [], + parent, + children: [], + minX: 0, minY: 0, maxX: 0, maxY: 0, + direction: normalizedDirection, + } + + // Resolve node references + for (const nodeId of mSg.nodeIds) { + const node = nodeMap.get(nodeId) + if (node) sg.nodes.push(node) + } + + allSubgraphs.push(sg) + + // Recurse into children + for (const childMSg of mSg.children) { + const child = convertSubgraph(childMSg, sg, nodeMap, allSubgraphs) + sg.children.push(child) + + // Child nodes are also part of parent subgraphs (Go behavior). + // The Go parser adds nodes to ALL subgraphs in the stack, so a nested + // node belongs to both the inner and outer subgraph. + for (const childNode of child.nodes) { + if (!sg.nodes.includes(childNode)) { + sg.nodes.push(childNode) + } + } + } + + return sg +} + +/** + * Deduplicate subgraph node membership to match Go parser behavior. + * + * The Go parser only adds a node to the subgraph that was active when the node + * was FIRST CREATED. If a node is later referenced inside a different subgraph, + * it is NOT added to that subgraph. The TS parser is more permissive — it adds + * referenced nodes to whichever subgraph they appear in. + * + * This function fixes the discrepancy by: + * 1. Walking the edges to determine which nodes were first created inside each subgraph + * 2. Removing nodes from subgraphs where they weren't first created + */ +function deduplicateSubgraphNodes( + mermaidSubgraphs: MermaidSubgraph[], + asciiSubgraphs: AsciiSubgraph[], + nodeMap: Map, + parsed: MermaidGraph, +): void { + // Build a map from MermaidSubgraph to its corresponding AsciiSubgraph. + // The ordering matches since we convert them in the same order. + const sgMap = new Map() + buildSgMap(mermaidSubgraphs, asciiSubgraphs, sgMap) + + // Determine which subgraph each node was "first defined" in. + // A node is first defined in the subgraph where it first appears as a NEW node + // in the ordered edge/node list. We approximate this by checking the global + // node insertion order against subgraph membership. + const nodeOwner = new Map() // nodeId → owning subgraph + + // Walk all mermaid subgraphs in document order. For each subgraph, + // claim nodes that haven't been claimed yet by any previous subgraph. + function claimNodes(mSg: MermaidSubgraph): void { + const asciiSg = sgMap.get(mSg) + if (!asciiSg) return + + // Recurse into children first (they appear before parent in the Go parser stack, + // but nodes defined in children are added to parent too — this is handled by + // the convertSubgraph function which propagates child nodes to parents). + // For dedup, we process children first so their claims propagate up correctly. + for (const child of mSg.children) { + claimNodes(child) + } + + // Claim unclaimed nodes in this subgraph + for (const nodeId of mSg.nodeIds) { + if (!nodeOwner.has(nodeId)) { + nodeOwner.set(nodeId, asciiSg) + } + } + } + + for (const mSg of mermaidSubgraphs) { + claimNodes(mSg) + } + + // Now remove nodes from subgraphs that don't own them. + // A node should remain in: its owner subgraph + all ancestors of the owner. + for (const asciiSg of asciiSubgraphs) { + asciiSg.nodes = asciiSg.nodes.filter(node => { + // Find this node's ID in the nodeMap + let nodeId: string | undefined + for (const [id, n] of nodeMap) { + if (n === node) { nodeId = id; break } + } + if (!nodeId) return false + + const owner = nodeOwner.get(nodeId) + if (!owner) return true // not in any subgraph claim — keep as-is + + // Keep the node if this subgraph is the owner or an ancestor of the owner + return isAncestorOrSelf(asciiSg, owner) + }) + } +} + +/** Check if `candidate` is the same as or an ancestor of `target`. */ +function isAncestorOrSelf(candidate: AsciiSubgraph, target: AsciiSubgraph): boolean { + let current: AsciiSubgraph | null = target + while (current !== null) { + if (current === candidate) return true + current = current.parent + } + return false +} + +/** Build a mapping from MermaidSubgraph → AsciiSubgraph (matching by position). */ +function buildSgMap( + mSgs: MermaidSubgraph[], + aSgs: AsciiSubgraph[], + result: Map, +): void { + // The asciiSubgraphs array is flat (all subgraphs including nested ones), + // while mermaidSubgraphs is hierarchical. We need to flatten the mermaid tree + // in the same order the converter processes them (pre-order DFS). + const flatMermaid: MermaidSubgraph[] = [] + function flatten(sgs: MermaidSubgraph[]): void { + for (const sg of sgs) { + flatMermaid.push(sg) + flatten(sg.children) + } + } + flatten(mSgs) + + for (let i = 0; i < flatMermaid.length && i < aSgs.length; i++) { + result.set(flatMermaid[i]!, aSgs[i]!) + } +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts new file mode 100644 index 000000000..7d7fdc821 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts @@ -0,0 +1,1382 @@ +// ============================================================================ +// ASCII renderer — drawing operations +// +// Ported from AlexanderGrooff/mermaid-ascii cmd/draw.go + cmd/arrow.go. +// Contains all visual rendering: boxes, lines, arrows, corners, +// subgraphs, labels, and the top-level draw orchestrator. +// ============================================================================ + +import type { + Canvas, DrawingCoord, GridCoord, Direction, + AsciiGraph, AsciiNode, AsciiEdge, AsciiSubgraph, AsciiEdgeStyle, EdgeBundle, +} from './types' +import { + Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight, Middle, + drawingCoordEquals, +} from './types' +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' +import { splitLines } from './multiline-utils' +import { getCorners } from './shapes/corners' +import { getShapeAttachmentPoint } from './shapes/index' +import { displayWidth, toCells, WIDE_PAD } from '../text-metrics' + +// ============================================================================ +// Node drawing — renders a node using shape-aware rendering +// ============================================================================ + +/** + * Draw a node using its shape type. + * Returns a standalone canvas containing the rendered shape. + * + * For basic shapes (rectangle, rounded), uses grid-determined dimensions + * to ensure consistent sizing across nodes in the same column. + * For special shapes (diamond, circle, state pseudo-states, etc.), + * uses shape-specific dimension calculation but centers the content + * within the grid cell dimensions to ensure proper vertical alignment. + */ +export function drawNode(node: AsciiNode, graph: AsciiGraph): Canvas { + // All shapes use grid-determined dimensions to fill their allocated space. + // This ensures consistent sizing across nodes and eliminates gaps between + // nodes and subgraph borders. All shapes are rectangles with distinctive + // corner characters (defined in corners.ts) to indicate shape type. + return drawBoxWithGridDimensions(node, graph) +} + +/** + * Draw a box shape using grid-determined dimensions. + * This ensures consistent sizing when multiple nodes share a column, + * and eliminates gaps between nodes and subgraph borders by filling + * the entire allocated grid space. + * + * All shapes are rendered as rectangles with distinctive corner characters + * (defined in corners.ts) to indicate shape type. + */ +function drawBoxWithGridDimensions(node: AsciiNode, graph: AsciiGraph): Canvas { + const gc = node.gridCoord! + const useAscii = graph.config.useAscii + + // Width spans 2 columns (border + content) - matching original behavior + let w = 0 + for (let i = 0; i < 2; i++) { + w += graph.columnWidth.get(gc.x + i) ?? 0 + } + // Height spans 2 rows (border + content) + let h = 0 + for (let i = 0; i < 2; i++) { + h += graph.rowHeight.get(gc.y + i) ?? 0 + } + + const from: DrawingCoord = { x: 0, y: 0 } + const to: DrawingCoord = { x: w, y: h } + const box = mkCanvas(Math.max(from.x, to.x), Math.max(from.y, to.y)) + + // Get corner characters for this shape type + const corners = getCorners(node.shape, useAscii) + + // State-end uses double border to differentiate from state-start + const isDoubleBox = node.shape === 'state-end' + const hChar = useAscii ? (isDoubleBox ? '=' : '-') : (isDoubleBox ? '═' : '─') + const vChar = useAscii ? (isDoubleBox ? '‖' : '|') : (isDoubleBox ? '║' : '│') + + // Double-box corners (for state-end) + const doubleCorners = useAscii + ? { tl: '#', tr: '#', bl: '#', br: '#' } + : { tl: '╔', tr: '╗', bl: '╚', br: '╝' } + const effectiveCorners = isDoubleBox ? doubleCorners : corners + + // Draw box border with shape-specific corners + for (let x = from.x + 1; x < to.x; x++) box[x]![from.y] = hChar + for (let x = from.x + 1; x < to.x; x++) box[x]![to.y] = hChar + for (let y = from.y + 1; y < to.y; y++) box[from.x]![y] = vChar + for (let y = from.y + 1; y < to.y; y++) box[to.x]![y] = vChar + box[from.x]![from.y] = effectiveCorners.tl + box[to.x]![from.y] = effectiveCorners.tr + box[from.x]![to.y] = effectiveCorners.bl + box[to.x]![to.y] = effectiveCorners.br + + // Center the multi-line display label inside the box + const label = node.displayLabel + const lines = splitLines(label) + const textCenterY = from.y + Math.floor(h / 2) + const startY = textCenterY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + 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] = cells[j]! + } + } + } + + return box +} + +/** + * Draw a node box with centered label text. + * Returns a standalone canvas containing just the box. + * Box size is determined by the grid column/row sizes for the node's position. + */ +export function drawBox(node: AsciiNode, graph: AsciiGraph): Canvas { + return drawNode(node, graph) +} + +// ============================================================================ +// Multi-section box drawing — for class and ER diagram nodes +// ============================================================================ + +/** + * Draw a multi-section box with horizontal dividers between sections. + * Used by class diagrams (header | attributes | methods) and ER diagrams (header | attributes). + * Each section is an array of text lines to render left-aligned with padding. + * + * @param sections - Array of sections, each section is an array of text lines + * @param useAscii - true for ASCII chars, false for Unicode box-drawing + * @param padding - horizontal padding inside the box (default 1) + * @returns A standalone Canvas containing the multi-section box + */ +export function drawMultiBox( + sections: string[][], + useAscii: boolean, + padding: number = 1, +): Canvas { + // Compute width: widest line across all sections + 2*padding + 2 border chars + let maxTextWidth = 0 + for (const section of sections) { + for (const line of section) { + maxTextWidth = Math.max(maxTextWidth, displayWidth(line)) + } + } + const innerWidth = maxTextWidth + 2 * padding + const boxWidth = innerWidth + 2 // +2 for left/right border + + // Compute height: sum of all section line counts + dividers + 2 border rows + let totalLines = 0 + for (const section of sections) { + totalLines += Math.max(section.length, 1) // at least 1 row per section + } + const numDividers = sections.length - 1 + const boxHeight = totalLines + numDividers + 2 // +2 for top/bottom border + + // Box-drawing characters + const hLine = useAscii ? '-' : '─' + const vLine = useAscii ? '|' : '│' + const tl = useAscii ? '+' : '┌' + const tr = useAscii ? '+' : '┐' + const bl = useAscii ? '+' : '└' + const br = useAscii ? '+' : '┘' + const divL = useAscii ? '+' : '├' + const divR = useAscii ? '+' : '┤' + + const canvas = mkCanvas(boxWidth - 1, boxHeight - 1) + + // Top border + canvas[0]![0] = tl + for (let x = 1; x < boxWidth - 1; x++) canvas[x]![0] = hLine + canvas[boxWidth - 1]![0] = tr + + // Bottom border + canvas[0]![boxHeight - 1] = bl + for (let x = 1; x < boxWidth - 1; x++) canvas[x]![boxHeight - 1] = hLine + canvas[boxWidth - 1]![boxHeight - 1] = br + + // Left and right borders (full height) + for (let y = 1; y < boxHeight - 1; y++) { + canvas[0]![y] = vLine + canvas[boxWidth - 1]![y] = vLine + } + + // Render sections with dividers + let row = 1 // current y position (starts after top border) + for (let s = 0; s < sections.length; s++) { + const section = sections[s]! + const lines = section.length > 0 ? section : [''] + + // Draw section text lines + for (const line of lines) { + const startX = 1 + padding + const cells = toCells(line) + for (let i = 0; i < cells.length; i++) { + canvas[startX + i]![row] = cells[i]! + } + row++ + } + + // Draw divider after each section except the last + if (s < sections.length - 1) { + canvas[0]![row] = divL + for (let x = 1; x < boxWidth - 1; x++) canvas[x]![row] = hLine + canvas[boxWidth - 1]![row] = divR + row++ + } + } + + return canvas +} + +// ============================================================================ +// Line drawing — 8-directional lines on the canvas +// ============================================================================ + +/** + * Line character sets for different edge styles. + * Each style has horizontal, vertical, and diagonal characters for both + * Unicode (box-drawing) and ASCII (basic punctuation) modes. + * + * Unicode dotted: ┄ (horizontal), ┆ (vertical) — U+2504, U+2506 + * Unicode thick: ━ (horizontal), ┃ (vertical) — U+2501, U+2503 + */ +/** + * Line character sets for different edge styles. + * Only horizontal and vertical characters - no diagonals. + * All edges use orthogonal Manhattan routing (90° bends only). + */ +const LINE_CHARS = { + solid: { + h: { unicode: '─', ascii: '-' }, + v: { unicode: '│', ascii: '|' }, + }, + dotted: { + h: { unicode: '┄', ascii: '.' }, + v: { unicode: '┆', ascii: ':' }, + }, + thick: { + h: { unicode: '━', ascii: '=' }, + v: { unicode: '┃', ascii: '‖' }, + }, +} as const + +/** + * Draw a line between two drawing coordinates using orthogonal Manhattan routing. + * Returns the list of coordinates that were drawn on. + * offsetFrom/offsetTo control how many cells to skip at the start/end. + * + * All lines use 90° bends only - no diagonal lines are produced. + * For diagonal directions, uses horizontal-first routing (draws horizontal + * segment, then vertical segment). + */ +export function drawLine( + canvas: Canvas, + from: DrawingCoord, + to: DrawingCoord, + offsetFrom: number, + offsetTo: number, + useAscii: boolean, + style: AsciiEdgeStyle = 'solid', +): DrawingCoord[] { + const dir = determineDirection(from, to) + const drawnCoords: DrawingCoord[] = [] + + // Select character set based on style (horizontal and vertical only) + const chars = LINE_CHARS[style] + const hChar = useAscii ? chars.h.ascii : chars.h.unicode + const vChar = useAscii ? chars.v.ascii : chars.v.unicode + + // Pure vertical directions + if (dirEquals(dir, Up)) { + for (let y = from.y - offsetFrom; y >= to.y - offsetTo; y--) { + drawnCoords.push({ x: from.x, y }) + canvas[from.x]![y] = vChar + } + } else if (dirEquals(dir, Down)) { + for (let y = from.y + offsetFrom; y <= to.y + offsetTo; y++) { + drawnCoords.push({ x: from.x, y }) + canvas[from.x]![y] = vChar + } + } + // Pure horizontal directions + else if (dirEquals(dir, Left)) { + for (let x = from.x - offsetFrom; x >= to.x - offsetTo; x--) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + } else if (dirEquals(dir, Right)) { + for (let x = from.x + offsetFrom; x <= to.x + offsetTo; x++) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + } + // Diagonal directions: use Manhattan routing (horizontal-first, then vertical) + // UpperLeft: go left first, then up + else if (dirEquals(dir, UpperLeft)) { + // Horizontal segment: from.x -> to.x (going left) + for (let x = from.x - offsetFrom; x >= to.x; x--) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + // Vertical segment: from.y -> to.y (going up) + for (let y = from.y - 1; y >= to.y - offsetTo; y--) { + drawnCoords.push({ x: to.x, y }) + canvas[to.x]![y] = vChar + } + } + // UpperRight: go right first, then up + else if (dirEquals(dir, UpperRight)) { + // Horizontal segment: from.x -> to.x (going right) + for (let x = from.x + offsetFrom; x <= to.x; x++) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + // Vertical segment: from.y -> to.y (going up) + for (let y = from.y - 1; y >= to.y - offsetTo; y--) { + drawnCoords.push({ x: to.x, y }) + canvas[to.x]![y] = vChar + } + } + // LowerLeft: go left first, then down + else if (dirEquals(dir, LowerLeft)) { + // Horizontal segment: from.x -> to.x (going left) + for (let x = from.x - offsetFrom; x >= to.x; x--) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + // Vertical segment: from.y -> to.y (going down) + for (let y = from.y + 1; y <= to.y + offsetTo; y++) { + drawnCoords.push({ x: to.x, y }) + canvas[to.x]![y] = vChar + } + } + // LowerRight: go right first, then down + // Special case: if x difference is small (1), draw straight vertical at from.x + // This keeps edges visually aligned with the source node + else if (dirEquals(dir, LowerRight)) { + const dx = to.x - from.x + if (dx <= 1) { + // Draw vertical line at from.x (source's x-coordinate) + for (let y = from.y + offsetFrom; y <= to.y + offsetTo; y++) { + drawnCoords.push({ x: from.x, y }) + canvas[from.x]![y] = vChar + } + } else { + // Horizontal segment: from.x -> to.x (going right) + for (let x = from.x + offsetFrom; x <= to.x; x++) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + // Vertical segment: from.y -> to.y (going down) + for (let y = from.y + 1; y <= to.y + offsetTo; y++) { + drawnCoords.push({ x: to.x, y }) + canvas[to.x]![y] = vChar + } + } + } + + return drawnCoords +} + +// ============================================================================ +// Arrow drawing — path, corners, arrowheads, box-start junctions, labels +// ============================================================================ + +/** + * Draw a complete arrow (edge) between two nodes. + * Returns 6 separate canvases for layered compositing: + * [path, boxStart, arrowHeadEnd, arrowHeadStart, corners, label] + * + * Supports bidirectional arrows via edge.hasArrowStart and edge.hasArrowEnd. + */ +export function drawArrow( + graph: AsciiGraph, + edge: AsciiEdge, +): [Canvas, Canvas, Canvas, Canvas, Canvas, Canvas] { + if (edge.path.length === 0) { + const empty = copyCanvas(graph.canvas) + return [empty, empty, empty, empty, empty, empty] + } + + const labelCanvas = drawArrowLabel(graph, edge) + const [pathCanvas, linesDrawn, lineDirs] = drawPath(graph, edge.path, edge.style) + const boxStartCanvas = drawBoxStart(graph, edge.path, linesDrawn[0]!, edge.from.shape) + + // Draw end arrowhead only if hasArrowEnd is true (default behavior) + let arrowHeadEndCanvas: Canvas + if (edge.hasArrowEnd) { + arrowHeadEndCanvas = drawArrowHead( + graph, + linesDrawn[linesDrawn.length - 1]!, + lineDirs[lineDirs.length - 1]!, + ) + } else { + arrowHeadEndCanvas = copyCanvas(graph.canvas) + } + + // Draw start arrowhead for bidirectional edges + // The start arrowhead needs to be at the box connector position (one step back + // from the first line point), pointing into the source node. + let arrowHeadStartCanvas: Canvas + if (edge.hasArrowStart && linesDrawn.length > 0) { + const firstLine = linesDrawn[0]! + const firstPoint = firstLine[0]! + const startDir = reverseDirection(lineDirs[0]!) + + // Calculate the box connector position (one step back from first point) + const arrowPos: DrawingCoord = { x: firstPoint.x, y: firstPoint.y } + if (dirEquals(lineDirs[0]!, Right)) arrowPos.x = firstPoint.x - 1 + else if (dirEquals(lineDirs[0]!, Left)) arrowPos.x = firstPoint.x + 1 + else if (dirEquals(lineDirs[0]!, Down)) arrowPos.y = firstPoint.y - 1 + else if (dirEquals(lineDirs[0]!, Up)) arrowPos.y = firstPoint.y + 1 + + // Create a synthetic line ending at the arrow position for drawArrowHead + const syntheticLine: DrawingCoord[] = [firstPoint, arrowPos] + arrowHeadStartCanvas = drawArrowHead(graph, syntheticLine, startDir) + } else { + arrowHeadStartCanvas = copyCanvas(graph.canvas) + } + + const cornersCanvas = drawCorners(graph, edge.path) + + return [pathCanvas, boxStartCanvas, arrowHeadEndCanvas, arrowHeadStartCanvas, cornersCanvas, labelCanvas] +} + +/** + * Reverse a direction (for bidirectional arrow start heads). + */ +function reverseDirection(dir: Direction): Direction { + if (dirEquals(dir, Up)) return Down + if (dirEquals(dir, Down)) return Up + if (dirEquals(dir, Left)) return Right + if (dirEquals(dir, Right)) return Left + if (dirEquals(dir, UpperLeft)) return LowerRight + if (dirEquals(dir, UpperRight)) return LowerLeft + if (dirEquals(dir, LowerLeft)) return UpperRight + if (dirEquals(dir, LowerRight)) return UpperLeft + return Middle +} + +/** + * Draw the path lines for an edge. + * Returns the canvas, the coordinates drawn for each segment, and the direction of each segment. + */ +function drawPath( + graph: AsciiGraph, + path: GridCoord[], + style: AsciiEdgeStyle = 'solid', +): [Canvas, DrawingCoord[][], Direction[]] { + const canvas = copyCanvas(graph.canvas) + let previousCoord = path[0]! + const linesDrawn: DrawingCoord[][] = [] + const lineDirs: Direction[] = [] + + for (let i = 1; i < path.length; i++) { + const nextCoord = path[i]! + const prevDC = gridToDrawingCoord(graph, previousCoord) + const nextDC = gridToDrawingCoord(graph, nextCoord) + + if (drawingCoordEquals(prevDC, nextDC)) { + previousCoord = nextCoord + continue + } + + const dir = determineDirection(previousCoord, nextCoord) + const segment = drawLine(canvas, prevDC, nextDC, 1, -1, graph.config.useAscii, style) + if (segment.length === 0) segment.push(prevDC) + linesDrawn.push(segment) + lineDirs.push(dir) + previousCoord = nextCoord + } + + return [canvas, linesDrawn, lineDirs] +} + +/** + * Draw the junction character where an edge exits the source node's box. + * Only applies to Unicode mode (ASCII mode just uses the line characters). + * Skips drawing for state pseudo-states which have their own visual borders. + */ +function drawBoxStart( + graph: AsciiGraph, + path: GridCoord[], + firstLine: DrawingCoord[], + sourceShape: string, +): Canvas { + const canvas = copyCanvas(graph.canvas) + if (graph.config.useAscii) return canvas + + // Skip box start connectors for state pseudo-states (they have their own bordered design) + if (sourceShape === 'state-start' || sourceShape === 'state-end') { + return canvas + } + + const from = firstLine[0]! + const dir = determineDirection(path[0]!, path[1]!) + + if (dirEquals(dir, Up)) canvas[from.x]![from.y + 1] = '┴' + else if (dirEquals(dir, Down)) canvas[from.x]![from.y - 1] = '┬' + else if (dirEquals(dir, Left)) canvas[from.x + 1]![from.y] = '┤' + else if (dirEquals(dir, Right)) canvas[from.x - 1]![from.y] = '├' + + return canvas +} + +/** + * Draw the arrowhead at the end of an edge path. + * Uses triangular Unicode symbols (▲▼◄►) or ASCII symbols (^v<>). + */ +function drawArrowHead( + graph: AsciiGraph, + lastLine: DrawingCoord[], + fallbackDir: Direction, +): Canvas { + const canvas = copyCanvas(graph.canvas) + if (lastLine.length === 0) return canvas + + const from = lastLine[0]! + const lastPos = lastLine[lastLine.length - 1]! + let dir = determineDirection(from, lastPos) + if (lastLine.length === 1 || dirEquals(dir, Middle)) dir = fallbackDir + + let char: string + + if (!graph.config.useAscii) { + if (dirEquals(dir, Up)) char = '▲' + else if (dirEquals(dir, Down)) char = '▼' + else if (dirEquals(dir, Left)) char = '◄' + else if (dirEquals(dir, Right)) char = '►' + else if (dirEquals(dir, UpperRight)) char = '◥' + else if (dirEquals(dir, UpperLeft)) char = '◤' + else if (dirEquals(dir, LowerRight)) char = '◢' + else if (dirEquals(dir, LowerLeft)) char = '◣' + else { + // Fallback + if (dirEquals(fallbackDir, Up)) char = '▲' + else if (dirEquals(fallbackDir, Down)) char = '▼' + else if (dirEquals(fallbackDir, Left)) char = '◄' + else if (dirEquals(fallbackDir, Right)) char = '►' + else if (dirEquals(fallbackDir, UpperRight)) char = '◥' + else if (dirEquals(fallbackDir, UpperLeft)) char = '◤' + else if (dirEquals(fallbackDir, LowerRight)) char = '◢' + else if (dirEquals(fallbackDir, LowerLeft)) char = '◣' + else char = '●' + } + } else { + if (dirEquals(dir, Up)) char = '^' + else if (dirEquals(dir, Down)) char = 'v' + else if (dirEquals(dir, Left)) char = '<' + else if (dirEquals(dir, Right)) char = '>' + else { + if (dirEquals(fallbackDir, Up)) char = '^' + else if (dirEquals(fallbackDir, Down)) char = 'v' + else if (dirEquals(fallbackDir, Left)) char = '<' + else if (dirEquals(fallbackDir, Right)) char = '>' + else char = '*' + } + } + + canvas[lastPos.x]![lastPos.y] = char + return canvas +} + +/** + * Draw corner characters at path bends (where the direction changes). + * Uses ┌┐└┘ in Unicode mode, + in ASCII mode. + */ +function drawCorners(graph: AsciiGraph, path: GridCoord[]): Canvas { + const canvas = copyCanvas(graph.canvas) + + for (let idx = 1; idx < path.length - 1; idx++) { + const coord = path[idx]! + const dc = gridToDrawingCoord(graph, coord) + const prevDir = determineDirection(path[idx - 1]!, coord) + const nextDir = determineDirection(coord, path[idx + 1]!) + + let corner: string + if (!graph.config.useAscii) { + if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))) { + corner = '┐' + } else if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))) { + corner = '┘' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))) { + corner = '┌' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))) { + corner = '└' + } else { + corner = '+' + } + } else { + corner = '+' + } + + canvas[dc.x]![dc.y] = corner + } + + return canvas +} + +/** Draw edge label text centered on the widest path segment. */ +function drawArrowLabel(graph: AsciiGraph, edge: AsciiEdge): Canvas { + const canvas = copyCanvas(graph.canvas) + if (edge.text.length === 0) return canvas + + const drawingLine = lineToDrawing(graph, edge.labelLine) + + // Determine if this is an upward edge (target is above source in the path) + // This is used to offset labels on bidirectional edges to prevent overlap + let isUpwardEdge: boolean | undefined + if (edge.path.length >= 2) { + const startY = edge.path[0]!.y + const endY = edge.path[edge.path.length - 1]!.y + // Edge goes up if end Y is less than start Y (smaller Y = higher on screen) + if (endY < startY) { + isUpwardEdge = true + } else if (endY > startY) { + isUpwardEdge = false + } + // If endY === startY, it's horizontal, leave isUpwardEdge undefined + } + + drawTextOnLine(canvas, drawingLine, edge.text, isUpwardEdge) + return canvas +} + +/** + * Draw text centered on a line segment defined by two drawing coordinates. + * Supports multi-line labels. + * + * When isUpwardEdge is provided, offsets the label vertically to prevent + * overlapping with labels from edges going the opposite direction: + * - Upward edges: label placed in lower portion of segment + * - Downward edges (isUpwardEdge=false): label placed in upper portion + * - No direction (isUpwardEdge=undefined): label centered (default) + */ +function drawTextOnLine(canvas: Canvas, line: DrawingCoord[], label: string, isUpwardEdge?: boolean): void { + if (line.length < 2) return + const minX = Math.min(line[0]!.x, line[1]!.x) + const maxX = Math.max(line[0]!.x, line[1]!.x) + const minY = Math.min(line[0]!.y, line[1]!.y) + const maxY = Math.max(line[0]!.y, line[1]!.y) + const middleX = minX + Math.floor((maxX - minX) / 2) + let middleY = minY + Math.floor((maxY - minY) / 2) + + // Offset label vertically to prevent overlap on bidirectional edges + // For vertical segments (same X), shift based on edge direction + if (isUpwardEdge !== undefined && minX === maxX) { + const segmentHeight = maxY - minY + const offset = Math.max(1, Math.floor(segmentHeight / 4)) + if (isUpwardEdge) { + // Upward edge: place label in lower portion + middleY = middleY + offset + } else { + // Downward edge: place label in upper portion + middleY = middleY - offset + } + } + + // Support multi-line labels + const lines = splitLines(label) + 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(displayWidth(lineText) / 2) + drawText(canvas, { x: startX, y: startY + i }, lineText) + } +} + +// ============================================================================ +// Node attachment point helper +// ============================================================================ + +/** + * Get the drawing coordinate where an edge attaches to a node's border. + * Uses grid-allocated dimensions so attachment points align with the actual + * drawn box (which may be wider/taller than the intrinsic shape dimensions + * when sharing a column/row with a larger node). + */ +function getNodeAttachmentPoint( + graph: AsciiGraph, + node: AsciiNode, + dir: Direction, +): DrawingCoord { + const gc = node.gridCoord! + + // Calculate actual drawn dimensions from grid (matching drawBoxWithGridDimensions) + let w = 0 + for (let i = 0; i < 2; i++) { + w += graph.columnWidth.get(gc.x + i) ?? 0 + } + let h = 0 + for (let i = 0; i < 2; i++) { + h += graph.rowHeight.get(gc.y + i) ?? 0 + } + + // Build dimensions matching the actual drawn box size + const gridDimensions = { + width: w + 1, + height: h + 1, + labelArea: { x: 0, y: 0, width: 0, height: 0 }, + gridColumns: [0, 0, 0] as [number, number, number], + gridRows: [0, 0, 0] as [number, number, number], + } + + const baseCoord = node.drawingCoord! + return getShapeAttachmentPoint(node.shape, dir, gridDimensions, baseCoord) +} + +// ============================================================================ +// Bundled edge drawing — for parallel links (A & B --> C) +// ============================================================================ + +/** + * Draw a single edge's segment in a bundle (source → junction for fan-in, + * junction → target for fan-out). + * + * Returns the same tuple format as drawArrow for consistency. + */ +function drawBundledEdgeSegment( + graph: AsciiGraph, + edge: AsciiEdge, + bundle: EdgeBundle, +): [Canvas, Canvas, Canvas, Canvas, Canvas, Canvas] { + const empty = copyCanvas(graph.canvas) + + if (!edge.pathToJunction || edge.pathToJunction.length === 0) { + return [empty, empty, empty, empty, empty, empty] + } + + // Draw the path segment (pathToJunction) + const pathCanvas = copyCanvas(graph.canvas) + const useAscii = graph.config.useAscii + + // Convert grid coords to drawing coords + // For fan-in: first point is at source node border (use attachment point) + // For fan-out: last point is at target node border (use attachment point) + const drawingPath = edge.pathToJunction.map((gc, idx) => { + if (bundle.type === 'fan-in' && idx === 0) { + // First point: use source node's actual border position + return getNodeAttachmentPoint(graph, edge.from, edge.startDir) + } + if (bundle.type === 'fan-out' && idx === edge.pathToJunction!.length - 1) { + // Last point: use target node's actual border position + return getNodeAttachmentPoint(graph, edge.to, edge.endDir) + } + return gridToDrawingCoord(graph, gc) + }) + + // Draw line segments + for (let i = 1; i < drawingPath.length; i++) { + const from = drawingPath[i - 1]! + const to = drawingPath[i]! + if (!drawingCoordEquals(from, to)) { + // Always skip both endpoints of every segment (offset 1, -1), + // matching non-bundled drawPath behavior. This leaves endpoint + // characters to corner/junction/boxStart canvases, preventing + // line characters from corrupting them via mergeJunctions. + drawLine(pathCanvas, from, to, 1, -1, useAscii, edge.style) + } + } + + // Draw corners at path bends + const cornersCanvas = copyCanvas(graph.canvas) + for (let idx = 1; idx < edge.pathToJunction.length - 1; idx++) { + const coord = edge.pathToJunction[idx]! + const dc = gridToDrawingCoord(graph, coord) + const prevDir = determineDirection(edge.pathToJunction[idx - 1]!, coord) + const nextDir = determineDirection(coord, edge.pathToJunction[idx + 1]!) + + let corner: string + if (!useAscii) { + if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))) { + corner = '┐' + } else if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))) { + corner = '┘' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))) { + corner = '┌' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))) { + corner = '└' + } else { + corner = '+' + } + } else { + corner = '+' + } + + cornersCanvas[dc.x]![dc.y] = corner + } + + // Draw box start connector (for fan-in, from source node) + // The connector is placed at the first point coordinate (box border position) + // since we use offsets 1,-1 for drawLine, the line starts one step past this point + const boxStartCanvas = copyCanvas(graph.canvas) + if (bundle.type === 'fan-in' && edge.pathToJunction.length >= 2) { + const firstPoint = drawingPath[0]! + const dir = determineDirection(edge.pathToJunction[0]!, edge.pathToJunction[1]!) + + if (!useAscii) { + if (dirEquals(dir, Up)) boxStartCanvas[firstPoint.x]![firstPoint.y] = '┴' + else if (dirEquals(dir, Down)) boxStartCanvas[firstPoint.x]![firstPoint.y] = '┬' + else if (dirEquals(dir, Left)) boxStartCanvas[firstPoint.x]![firstPoint.y] = '┤' + else if (dirEquals(dir, Right)) boxStartCanvas[firstPoint.x]![firstPoint.y] = '├' + } + } + + // Label canvas (bundled edges typically don't have labels, but handle it) + const labelCanvas = copyCanvas(graph.canvas) + + return [pathCanvas, boxStartCanvas, empty, empty, cornersCanvas, labelCanvas] +} + +/** + * Draw the shared path segment of a bundle (junction → target for fan-in, + * source → junction for fan-out). + */ +function drawBundleSharedPath(graph: AsciiGraph, bundle: EdgeBundle): [Canvas, Canvas] { + const pathCanvas = copyCanvas(graph.canvas) + const cornersCanvas = copyCanvas(graph.canvas) + + if (bundle.sharedPath.length < 2) { + return [pathCanvas, cornersCanvas] + } + + const useAscii = graph.config.useAscii + const style = bundle.edges[0]?.style ?? 'solid' + const graphDir = graph.config.graphDirection + + // Convert grid coords to drawing coords + // For fan-in: last point is at target node border + // For fan-out: first point is at source node border + const drawingPath = bundle.sharedPath.map((gc, idx) => { + if (bundle.type === 'fan-in' && idx === bundle.sharedPath.length - 1) { + // Last point: use target node's actual border position (entry from above/left) + const entryDir = graphDir === 'TD' ? Up : Left + return getNodeAttachmentPoint(graph, bundle.sharedNode, entryDir) + } + if (bundle.type === 'fan-out' && idx === 0) { + // First point: use source node's actual border position (exit going down/right) + const exitDir = graphDir === 'TD' ? Down : Right + return getNodeAttachmentPoint(graph, bundle.sharedNode, exitDir) + } + return gridToDrawingCoord(graph, gc) + }) + + // Draw line segments with appropriate offsets + for (let i = 1; i < drawingPath.length; i++) { + const from = drawingPath[i - 1]! + const to = drawingPath[i]! + if (!drawingCoordEquals(from, to)) { + // Always skip both endpoints (offset 1, -1), matching non-bundled drawPath. + drawLine(pathCanvas, from, to, 1, -1, useAscii, style) + } + } + + // Draw corners at path bends + for (let idx = 1; idx < bundle.sharedPath.length - 1; idx++) { + const coord = bundle.sharedPath[idx]! + const dc = gridToDrawingCoord(graph, coord) + const prevDir = determineDirection(bundle.sharedPath[idx - 1]!, coord) + const nextDir = determineDirection(coord, bundle.sharedPath[idx + 1]!) + + let corner: string + if (!useAscii) { + if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))) { + corner = '┐' + } else if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))) { + corner = '┘' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))) { + corner = '┌' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))) { + corner = '└' + } else { + corner = '+' + } + } else { + corner = '+' + } + + cornersCanvas[dc.x]![dc.y] = corner + } + + return [pathCanvas, cornersCanvas] +} + +/** + * Draw the arrowhead for a fan-in bundle (single arrowhead at the shared target). + */ +function drawBundleArrowhead(graph: AsciiGraph, bundle: EdgeBundle): Canvas { + const canvas = copyCanvas(graph.canvas) + + if (bundle.sharedPath.length < 2) return canvas + + // Get the last segment direction + const lastIdx = bundle.sharedPath.length - 1 + const secondLast = bundle.sharedPath[lastIdx - 1]! + const last = bundle.sharedPath[lastIdx]! + const dir = determineDirection(secondLast, last) + + // Get drawing coord 1 char outside the target node's border (not on the border itself). + // This matches non-bundled edges where drawPath uses offsetTo=-1 and the arrowhead + // sits at the last drawn point (1 char before the border). + const graphDir = graph.config.graphDirection + const entryDir = graphDir === 'TD' ? Up : Left + const dc = getNodeAttachmentPoint(graph, bundle.sharedNode, entryDir) + // Offset 1 char away from the box border so arrowhead sits outside the box + if (graphDir === 'TD') dc.y -= 1 + else dc.x -= 1 + + // Draw arrowhead + let char: string + if (!graph.config.useAscii) { + if (dirEquals(dir, Up)) char = '▲' + else if (dirEquals(dir, Down)) char = '▼' + else if (dirEquals(dir, Left)) char = '◄' + else if (dirEquals(dir, Right)) char = '►' + else char = '▼' // default + } else { + if (dirEquals(dir, Up)) char = '^' + else if (dirEquals(dir, Down)) char = 'v' + else if (dirEquals(dir, Left)) char = '<' + else if (dirEquals(dir, Right)) char = '>' + else char = 'v' // default + } + + canvas[dc.x]![dc.y] = char + return canvas +} + +/** + * Draw the arrowhead for a single edge in a fan-out bundle. + */ +function drawBundledEdgeArrowhead(graph: AsciiGraph, edge: AsciiEdge): Canvas { + const canvas = copyCanvas(graph.canvas) + + if (!edge.pathToJunction || edge.pathToJunction.length < 2) return canvas + + // Get the last segment direction + const lastIdx = edge.pathToJunction.length - 1 + const secondLast = edge.pathToJunction[lastIdx - 1]! + const last = edge.pathToJunction[lastIdx]! + const dir = determineDirection(secondLast, last) + + // Get drawing coord 1 char outside the target node's border + const graphDir = graph.config.graphDirection + const entryDir = graphDir === 'TD' ? Up : Left + const dc = getNodeAttachmentPoint(graph, edge.to, entryDir) + // Offset 1 char away from the box border so arrowhead sits outside the box + if (graphDir === 'TD') dc.y -= 1 + else dc.x -= 1 + + // Draw arrowhead + let char: string + if (!graph.config.useAscii) { + if (dirEquals(dir, Up)) char = '▲' + else if (dirEquals(dir, Down)) char = '▼' + else if (dirEquals(dir, Left)) char = '◄' + else if (dirEquals(dir, Right)) char = '►' + else char = '▼' // default + } else { + if (dirEquals(dir, Up)) char = '^' + else if (dirEquals(dir, Down)) char = 'v' + else if (dirEquals(dir, Left)) char = '<' + else if (dirEquals(dir, Right)) char = '>' + else char = 'v' // default + } + + canvas[dc.x]![dc.y] = char + return canvas +} + +/** + * Draw the junction character where bundled edges merge/split. + * + * Analyzes actual connecting directions to choose the correct character: + * - ┼ (cross): lines from all 4 directions + * - ┬ (T down): lines from left, right, and down + * - ┴ (T up): lines from left, right, and up + * - ├ (T right): lines from up, down, and right + * - ┤ (T left): lines from up, down, and left + */ +function drawJunctionCharacter(graph: AsciiGraph, bundle: EdgeBundle): Canvas { + const canvas = copyCanvas(graph.canvas) + + if (!bundle.junctionPoint) return canvas + + const dc = gridToDrawingCoord(graph, bundle.junctionPoint) + const useAscii = graph.config.useAscii + + // Analyze what directions actually connect to the junction + let hasUp = false + let hasDown = false + let hasLeft = false + let hasRight = false + + // Check shared path direction (where the line continues to/from the shared node) + if (bundle.sharedPath.length >= 2) { + // For fan-in: shared path goes FROM junction TO target (index 0 is junction) + // For fan-out: shared path goes FROM source TO junction (last index is junction) + const junctionIdx = bundle.type === 'fan-in' ? 0 : bundle.sharedPath.length - 1 + const adjacentIdx = bundle.type === 'fan-in' ? 1 : bundle.sharedPath.length - 2 + const sharedDir = determineDirection( + bundle.sharedPath[junctionIdx]!, + bundle.sharedPath[adjacentIdx]! + ) + // This is the direction the shared path GOES from junction + if (dirEquals(sharedDir, Down)) hasDown = true + else if (dirEquals(sharedDir, Up)) hasUp = true + else if (dirEquals(sharedDir, Right)) hasRight = true + else if (dirEquals(sharedDir, Left)) hasLeft = true + } + + // Check each edge's path direction at the junction + for (const edge of bundle.edges) { + if (edge.pathToJunction && edge.pathToJunction.length >= 2) { + // For fan-in: pathToJunction goes FROM source TO junction (last is junction) + // For fan-out: pathToJunction goes FROM junction TO target (first is junction) + const junctionIdx = bundle.type === 'fan-in' + ? edge.pathToJunction.length - 1 + : 0 + const adjacentIdx = bundle.type === 'fan-in' + ? edge.pathToJunction.length - 2 + : 1 + + const arrivalDir = determineDirection( + edge.pathToJunction[adjacentIdx]!, + edge.pathToJunction[junctionIdx]! + ) + // This is the direction the edge ARRIVES at junction from + // e.g., if arrivalDir is Right, the line comes FROM the left + if (dirEquals(arrivalDir, Down)) hasUp = true // arrived going down = came from up + else if (dirEquals(arrivalDir, Up)) hasDown = true + else if (dirEquals(arrivalDir, Right)) hasLeft = true + else if (dirEquals(arrivalDir, Left)) hasRight = true + } + } + + // Select character based on connected directions + let char: string + if (!useAscii) { + if (hasUp && hasDown && hasLeft && hasRight) { + char = '┼' // cross - all 4 directions + } else if (hasDown && hasLeft && hasRight && !hasUp) { + char = '┬' // T pointing down + } else if (hasUp && hasLeft && hasRight && !hasDown) { + char = '┴' // T pointing up + } else if (hasUp && hasDown && hasRight && !hasLeft) { + char = '├' // T pointing right + } else if (hasUp && hasDown && hasLeft && !hasRight) { + char = '┤' // T pointing left + } else if (hasLeft && hasRight) { + char = '─' // horizontal only + } else if (hasUp && hasDown) { + char = '│' // vertical only + } else if (hasDown && hasRight) { + char = '┌' // corner + } else if (hasDown && hasLeft) { + char = '┐' + } else if (hasUp && hasRight) { + char = '└' + } else if (hasUp && hasLeft) { + char = '┘' + } else { + char = '┼' // fallback + } + } else { + char = '+' + } + + canvas[dc.x]![dc.y] = char + return canvas +} + +// ============================================================================ +// Subgraph drawing +// ============================================================================ + +/** Draw a subgraph border rectangle. */ +export function drawSubgraphBox(sg: AsciiSubgraph, graph: AsciiGraph): Canvas { + const width = sg.maxX - sg.minX + const height = sg.maxY - sg.minY + if (width <= 0 || height <= 0) return mkCanvas(0, 0) + + const from: DrawingCoord = { x: 0, y: 0 } + const to: DrawingCoord = { x: width, y: height } + const canvas = mkCanvas(width, height) + + if (!graph.config.useAscii) { + for (let x = from.x + 1; x < to.x; x++) canvas[x]![from.y] = '─' + for (let x = from.x + 1; x < to.x; x++) canvas[x]![to.y] = '─' + for (let y = from.y + 1; y < to.y; y++) canvas[from.x]![y] = '│' + for (let y = from.y + 1; y < to.y; y++) canvas[to.x]![y] = '│' + canvas[from.x]![from.y] = '┌' + canvas[to.x]![from.y] = '┐' + canvas[from.x]![to.y] = '└' + canvas[to.x]![to.y] = '┘' + } else { + for (let x = from.x + 1; x < to.x; x++) canvas[x]![from.y] = '-' + for (let x = from.x + 1; x < to.x; x++) canvas[x]![to.y] = '-' + for (let y = from.y + 1; y < to.y; y++) canvas[from.x]![y] = '|' + for (let y = from.y + 1; y < to.y; y++) canvas[to.x]![y] = '|' + canvas[from.x]![from.y] = '+' + canvas[to.x]![from.y] = '+' + canvas[from.x]![to.y] = '+' + canvas[to.x]![to.y] = '+' + } + + return canvas +} + +/** Draw a subgraph label centered in its header area. Supports multi-line labels. */ +export function drawSubgraphLabel(sg: AsciiSubgraph, graph: AsciiGraph): [Canvas, DrawingCoord] { + const width = sg.maxX - sg.minX + const height = sg.maxY - sg.minY + if (width <= 0 || height <= 0) return [mkCanvas(0, 0), { x: 0, y: 0 }] + + const canvas = mkCanvas(width, height) + + // Support multi-line subgraph labels + const lines = splitLines(sg.name) + + // Start at row 1 inside subgraph, expand downward for multiple lines + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const labelY = 1 + i + let labelX = Math.floor(width / 2) - Math.floor(displayWidth(line) / 2) + if (labelX < 1) labelX = 1 + + 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 + } + } + + return [canvas, { x: sg.minX, y: sg.minY }] +} + +// ============================================================================ +// Top-level draw orchestrator +// ============================================================================ + +/** Sort subgraphs by nesting depth (shallowest first) for correct layered rendering. */ +function sortSubgraphsByDepth(subgraphs: AsciiSubgraph[]): AsciiSubgraph[] { + function getDepth(sg: AsciiSubgraph): number { + return sg.parent === null ? 0 : 1 + getDepth(sg.parent) + } + const sorted = [...subgraphs] + sorted.sort((a, b) => getDepth(a) - getDepth(b)) + return sorted +} + +// ============================================================================ +// Role tracking helpers for colored output +// ============================================================================ + +/** + * Fill roles for all non-space characters in a canvas region. + * Used after drawing a layer to record what role those characters have. + */ +function fillRolesFromCanvas( + roleCanvas: RoleCanvas, + canvas: Canvas, + offset: DrawingCoord, + role: CharRole, +): void { + for (let x = 0; x < canvas.length; x++) { + for (let y = 0; y < (canvas[0]?.length ?? 0); y++) { + const char = canvas[x]?.[y] + if (char && char !== ' ') { + const rx = x + offset.x + const ry = y + offset.y + // Use setRole which auto-expands the role canvas if needed + if (rx >= 0 && ry >= 0) { + setRole(roleCanvas, rx, ry, role) + } + } + } + } +} + +/** + * Fill roles for multiple canvases with the same role. + */ +function fillRolesFromCanvases( + roleCanvas: RoleCanvas, + canvases: Canvas[], + offset: DrawingCoord, + role: CharRole, +): void { + for (const canvas of canvases) { + fillRolesFromCanvas(roleCanvas, canvas, offset, role) + } +} + +/** + * Special handling for node boxes: border chars get 'border' role, text gets 'text' role. + * Detects text by checking if character is alphanumeric or common punctuation. + */ +function fillRolesForNodeBox( + roleCanvas: RoleCanvas, + canvas: Canvas, + offset: DrawingCoord, +): void { + const isBorderChar = (c: string) => /^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|.':]$/.test(c) + + for (let x = 0; x < canvas.length; x++) { + for (let y = 0; y < (canvas[0]?.length ?? 0); y++) { + const char = canvas[x]?.[y] + if (char && char !== ' ') { + const rx = x + offset.x + const ry = y + offset.y + // Use setRole which auto-expands the role canvas if needed + if (rx >= 0 && ry >= 0) { + setRole(roleCanvas, rx, ry, isBorderChar(char) ? 'border' : 'text') + } + } + } + } +} + +/** + * Main draw function — renders the entire graph onto the canvas. + * Drawing order matters for correct layering: + * 1. Subgraph borders (bottom layer) + * 2. Node boxes + * 3. Edge paths (lines) + * 4. Edge corners + * 5. Arrowheads + * 6. Box-start junctions + * 7. Edge labels + * 8. Subgraph labels (top layer) + * + * Also fills the roleCanvas with character roles for colored output. + */ +export function drawGraph(graph: AsciiGraph): Canvas { + const useAscii = graph.config.useAscii + const zero: DrawingCoord = { x: 0, y: 0 } + + // Draw subgraph borders + const sortedSgs = sortSubgraphsByDepth(graph.subgraphs) + for (const sg of sortedSgs) { + const sgCanvas = drawSubgraphBox(sg, graph) + const offset: DrawingCoord = { x: sg.minX, y: sg.minY } + graph.canvas = mergeCanvases(graph.canvas, offset, useAscii, sgCanvas) + // Subgraph borders get 'border' role + fillRolesFromCanvas(graph.roleCanvas, sgCanvas, offset, 'border') + } + + // Draw node boxes + for (const node of graph.nodes) { + if (!node.drawn && node.drawingCoord && node.drawing) { + graph.canvas = mergeCanvases(graph.canvas, node.drawingCoord, useAscii, node.drawing) + // Node boxes: detect border vs text characters + fillRolesForNodeBox(graph.roleCanvas, node.drawing, node.drawingCoord) + node.drawn = true + } + } + + // Collect all edge drawing layers + const lineCanvases: Canvas[] = [] + const cornerCanvases: Canvas[] = [] + const arrowHeadEndCanvases: Canvas[] = [] + const arrowHeadStartCanvases: Canvas[] = [] + const boxStartCanvases: Canvas[] = [] + const labelCanvases: Canvas[] = [] + const junctionCanvases: Canvas[] = [] + + // Track which bundles have been processed (to draw shared paths only once) + const processedBundles = new Set() + + for (const edge of graph.edges) { + // Handle bundled edges specially + if (edge.bundle && edge.pathToJunction) { + const bundle = edge.bundle + + // Draw this edge's individual path (source → junction for fan-in, junction → target for fan-out) + const [pathC, boxStartC, , , cornersC, labelC] = drawBundledEdgeSegment(graph, edge, bundle) + lineCanvases.push(pathC) + cornerCanvases.push(cornersC) + boxStartCanvases.push(boxStartC) + labelCanvases.push(labelC) + + // Draw the bundle's shared path and arrowhead only once + if (!processedBundles.has(bundle)) { + processedBundles.add(bundle) + + // Draw shared path (junction → target for fan-in, source → junction for fan-out) + const [sharedPathC, sharedCornersC] = drawBundleSharedPath(graph, bundle) + lineCanvases.push(sharedPathC) + cornerCanvases.push(sharedCornersC) + + // Draw arrowhead at target for fan-in (once for all edges in bundle) + if (bundle.type === 'fan-in') { + const arrowHeadC = drawBundleArrowhead(graph, bundle) + arrowHeadEndCanvases.push(arrowHeadC) + } + + // Draw junction character + const junctionC = drawJunctionCharacter(graph, bundle) + junctionCanvases.push(junctionC) + } + + // For fan-out bundles, draw arrowhead at each target + if (bundle.type === 'fan-out' && edge.hasArrowEnd) { + const arrowHeadC = drawBundledEdgeArrowhead(graph, edge) + arrowHeadEndCanvases.push(arrowHeadC) + } + } else { + // Non-bundled edge: use standard drawing + const [pathC, boxStartC, arrowHeadEndC, arrowHeadStartC, cornersC, labelC] = drawArrow(graph, edge) + lineCanvases.push(pathC) + cornerCanvases.push(cornersC) + arrowHeadEndCanvases.push(arrowHeadEndC) + arrowHeadStartCanvases.push(arrowHeadStartC) + boxStartCanvases.push(boxStartC) + labelCanvases.push(labelC) + } + } + + // Merge edge layers in order and track roles + // Note: arrowHeadStart is merged AFTER boxStart so bidirectional arrows + // properly overwrite the box connector at the source end + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...lineCanvases) + fillRolesFromCanvases(graph.roleCanvas, lineCanvases, zero, 'line') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...cornerCanvases) + fillRolesFromCanvases(graph.roleCanvas, cornerCanvases, zero, 'corner') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...junctionCanvases) + fillRolesFromCanvases(graph.roleCanvas, junctionCanvases, zero, 'junction') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...arrowHeadEndCanvases) + fillRolesFromCanvases(graph.roleCanvas, arrowHeadEndCanvases, zero, 'arrow') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...boxStartCanvases) + fillRolesFromCanvases(graph.roleCanvas, boxStartCanvases, zero, 'junction') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...arrowHeadStartCanvases) + fillRolesFromCanvases(graph.roleCanvas, arrowHeadStartCanvases, zero, 'arrow') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...labelCanvases) + fillRolesFromCanvases(graph.roleCanvas, labelCanvases, zero, 'text') + + // Draw subgraph labels last (on top) + for (const sg of graph.subgraphs) { + if (sg.nodes.length === 0) continue + const [labelCanvas, offset] = drawSubgraphLabel(sg, graph) + graph.canvas = mergeCanvases(graph.canvas, offset, useAscii, labelCanvas) + fillRolesFromCanvas(graph.roleCanvas, labelCanvas, offset, 'text') + } + + return graph.canvas +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/edge-bundling.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/edge-bundling.ts new file mode 100644 index 000000000..e9badf65c --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/edge-bundling.ts @@ -0,0 +1,328 @@ +// ============================================================================ +// ASCII renderer — edge bundling for parallel links +// +// Analyzes edges to find parallel links (A & B --> C or A --> B & C) and +// groups them into bundles. Bundled edges share a visual junction point +// where they merge/split, creating cleaner diagrams. +// +// This module provides: +// - analyzeEdgeBundles(): Finds and creates bundles from graph edges +// - calculateJunctionPoint(): Computes optimal merge/split locations +// - routeBundledEdges(): Routes edges through junction points +// ============================================================================ + +import type { + AsciiGraph, AsciiNode, AsciiEdge, EdgeBundle, GridCoord, Direction, +} from './types' +import { Up, Down, Left, Right, Middle, gridKey, gridCoordEquals } from './types' +import { getPath, mergePath } from './pathfinder' +import { getNodeSubgraph } from './grid' + +// ============================================================================ +// Bundle analysis +// ============================================================================ + +/** + * Analyze graph edges and create bundles for parallel links. + * + * Groups edges by: + * - Fan-in: Multiple edges sharing the same target (A & B --> C) + * - Fan-out: Multiple edges sharing the same source (A --> B & C) + * + * Only creates bundles when: + * - Graph direction is TD (top-down) - LR routing handles merging naturally + * - 2+ edges share the endpoint + * - All edges have the same style (solid/dotted/thick) + * - None of the edges have labels (labels would overlap at junction) + * - Edges are not self-loops + * + * @returns Array of bundles. Each edge can belong to at most one bundle. + */ +export function analyzeEdgeBundles(graph: AsciiGraph): EdgeBundle[] { + // Only bundle in TD direction - LR routing handles merging naturally at corners + if (graph.config.graphDirection !== 'TD') { + return [] + } + const bundles: EdgeBundle[] = [] + const bundledEdges = new Set() + + // Group edges by target (fan-in candidates) + const edgesByTarget = new Map() + for (const edge of graph.edges) { + // Skip self-loops + if (edge.from === edge.to) continue + + const existing = edgesByTarget.get(edge.to) ?? [] + existing.push(edge) + edgesByTarget.set(edge.to, existing) + } + + // Create fan-in bundles + for (const [target, edges] of edgesByTarget) { + if (edges.length < 2) continue + if (!canBundle(edges, graph)) continue + + // Check if all edges are already bundled + if (edges.some(e => bundledEdges.has(e))) continue + + const bundle: EdgeBundle = { + type: 'fan-in', + edges: [...edges], + sharedNode: target, + otherNodes: edges.map(e => e.from), + junctionPoint: null, + sharedPath: [], + junctionDir: Middle, + sharedNodeDir: Middle, + } + + // Mark edges as bundled + for (const edge of edges) { + edge.bundle = bundle + bundledEdges.add(edge) + } + + bundles.push(bundle) + } + + // Group edges by source (fan-out candidates) + const edgesBySource = new Map() + for (const edge of graph.edges) { + // Skip self-loops and already bundled edges + if (edge.from === edge.to) continue + if (bundledEdges.has(edge)) continue + + const existing = edgesBySource.get(edge.from) ?? [] + existing.push(edge) + edgesBySource.set(edge.from, existing) + } + + // Create fan-out bundles + for (const [source, edges] of edgesBySource) { + if (edges.length < 2) continue + if (!canBundle(edges, graph)) continue + + const bundle: EdgeBundle = { + type: 'fan-out', + edges: [...edges], + sharedNode: source, + otherNodes: edges.map(e => e.to), + junctionPoint: null, + sharedPath: [], + junctionDir: Middle, + sharedNodeDir: Middle, + } + + // Mark edges as bundled + for (const edge of edges) { + edge.bundle = bundle + bundledEdges.add(edge) + } + + bundles.push(bundle) + } + + return bundles +} + +/** + * Check if a group of edges can be bundled together. + * Returns false if edges have different styles, any have labels, + * or if the edges span subgraph boundaries (which creates complex routing). + */ +function canBundle(edges: AsciiEdge[], graph: AsciiGraph): boolean { + if (edges.length < 2) return false + + const firstStyle = edges[0]!.style + const firstFromSg = getNodeSubgraph(graph, edges[0]!.from) + const firstToSg = getNodeSubgraph(graph, edges[0]!.to) + + for (const edge of edges) { + // Different styles can't be bundled (would look confusing) + if (edge.style !== firstStyle) return false + + // Edges with labels can't be bundled (labels would overlap at junction) + if (edge.text.length > 0) return false + + // Don't bundle if edges span different subgraph boundaries + // (creates complex routing that doesn't look good) + const fromSg = getNodeSubgraph(graph, edge.from) + const toSg = getNodeSubgraph(graph, edge.to) + if (fromSg !== firstFromSg || toSg !== firstToSg) return false + + // Don't bundle if source and target are in different subgraphs + // (cross-boundary edges have special routing needs) + if (fromSg !== toSg) return false + } + + return true +} + +// ============================================================================ +// Junction point calculation +// ============================================================================ + +/** + * Calculate the optimal junction point for a bundle. + * + * For fan-in (A & B --> C): + * - Junction is placed between the sources and the target + * - In TD: above the target, horizontally centered between sources + * - In LR: left of the target, vertically centered between sources + * + * For fan-out (A --> B & C): + * - Junction is placed between the source and the targets + * - In TD: below the source, horizontally centered between targets + * - In LR: right of the source, vertically centered between targets + */ +export function calculateJunctionPoint( + graph: AsciiGraph, + bundle: EdgeBundle, +): GridCoord { + const dir = graph.config.graphDirection + const sharedCoord = bundle.sharedNode.gridCoord! + const otherCoords = bundle.otherNodes.map(n => n.gridCoord!) + + if (bundle.type === 'fan-in') { + // Junction is BEFORE the shared target + // Calculate center of sources + const minX = Math.min(...otherCoords.map(c => c.x)) + const maxX = Math.max(...otherCoords.map(c => c.x)) + const minY = Math.min(...otherCoords.map(c => c.y)) + const maxY = Math.max(...otherCoords.map(c => c.y)) + + if (dir === 'TD') { + // Junction above target, centered between sources + // Place it one row above the target's entry point + const junctionY = sharedCoord.y - 1 + // X is centered between sources, but clamped to shared node's X for alignment + const centerX = Math.floor((minX + maxX) / 2) + 1 // +1 for center of 3x3 block + const junctionX = sharedCoord.x + 1 // Align with target's center + + return { x: junctionX, y: junctionY } + } else { + // LR: Junction left of target, centered between sources + const junctionX = sharedCoord.x - 1 + const junctionY = sharedCoord.y + 1 // Align with target's center + + return { x: junctionX, y: junctionY } + } + } else { + // fan-out: Junction is AFTER the shared source + const minX = Math.min(...otherCoords.map(c => c.x)) + const maxX = Math.max(...otherCoords.map(c => c.x)) + const minY = Math.min(...otherCoords.map(c => c.y)) + const maxY = Math.max(...otherCoords.map(c => c.y)) + + if (dir === 'TD') { + // Junction below source, will then split to targets + const junctionY = sharedCoord.y + 3 // Just below source's 3x3 block + const junctionX = sharedCoord.x + 1 // Align with source's center + + return { x: junctionX, y: junctionY } + } else { + // LR: Junction right of source + const junctionX = sharedCoord.x + 3 + const junctionY = sharedCoord.y + 1 + + return { x: junctionX, y: junctionY } + } + } +} + +// ============================================================================ +// Bundled edge routing +// ============================================================================ + +/** + * Route all edges in a bundle through the junction point. + * + * For fan-in bundles: + * 1. Route each source → junction (stored in edge.pathToJunction) + * 2. Route junction → target (stored in bundle.sharedPath) + * + * For fan-out bundles: + * 1. Route source → junction (stored in bundle.sharedPath) + * 2. Route junction → each target (stored in edge.pathToJunction) + */ +export function routeBundledEdges(graph: AsciiGraph, bundle: EdgeBundle): void { + const dir = graph.config.graphDirection + + // Calculate and store junction point + bundle.junctionPoint = calculateJunctionPoint(graph, bundle) + const junction = bundle.junctionPoint + + // Determine directions based on graph direction and bundle type + if (bundle.type === 'fan-in') { + // Sources converge to junction, then junction to target + bundle.junctionDir = dir === 'TD' ? Up : Left + bundle.sharedNodeDir = dir === 'TD' ? Down : Right + + // Route junction → target (shared path) + const targetCoord = bundle.sharedNode.gridCoord! + const targetEntry = dir === 'TD' + ? { x: targetCoord.x + 1, y: targetCoord.y } // Top center of target + : { x: targetCoord.x, y: targetCoord.y + 1 } // Left center of target + + const sharedPath = getPath(graph.grid, junction, targetEntry) + bundle.sharedPath = sharedPath ? mergePath(sharedPath) : [junction, targetEntry] + + // Route each source → junction + for (const edge of bundle.edges) { + const sourceCoord = edge.from.gridCoord! + const sourceExit = dir === 'TD' + ? { x: sourceCoord.x + 1, y: sourceCoord.y + 2 } // Bottom center of source + : { x: sourceCoord.x + 2, y: sourceCoord.y + 1 } // Right center of source + + const pathToJunction = getPath(graph.grid, sourceExit, junction) + edge.pathToJunction = pathToJunction ? mergePath(pathToJunction) : [sourceExit, junction] + + // Set edge directions for proper drawing + edge.startDir = dir === 'TD' ? Down : Right + edge.endDir = dir === 'TD' ? Up : Left + + // Build full path for grid size calculation: source → junction → target + edge.path = [...edge.pathToJunction, ...bundle.sharedPath.slice(1)] + } + } else { + // fan-out: Source to junction, then junction splits to targets + bundle.junctionDir = dir === 'TD' ? Down : Right + bundle.sharedNodeDir = dir === 'TD' ? Up : Left + + // Route source → junction (shared path) + const sourceCoord = bundle.sharedNode.gridCoord! + const sourceExit = dir === 'TD' + ? { x: sourceCoord.x + 1, y: sourceCoord.y + 2 } // Bottom center of source + : { x: sourceCoord.x + 2, y: sourceCoord.y + 1 } // Right center of source + + const sharedPath = getPath(graph.grid, sourceExit, junction) + bundle.sharedPath = sharedPath ? mergePath(sharedPath) : [sourceExit, junction] + + // Route junction → each target + for (const edge of bundle.edges) { + const targetCoord = edge.to.gridCoord! + const targetEntry = dir === 'TD' + ? { x: targetCoord.x + 1, y: targetCoord.y } // Top center of target + : { x: targetCoord.x, y: targetCoord.y + 1 } // Left center of target + + const pathToJunction = getPath(graph.grid, junction, targetEntry) + edge.pathToJunction = pathToJunction ? mergePath(pathToJunction) : [junction, targetEntry] + + // Set edge directions + edge.startDir = dir === 'TD' ? Down : Right + edge.endDir = dir === 'TD' ? Up : Left + + // Build full path for grid size calculation: source → junction → target + edge.path = [...bundle.sharedPath, ...edge.pathToJunction.slice(1)] + } + } +} + +/** + * Process all bundles in a graph: calculate junction points and route edges. + */ +export function processBundles(graph: AsciiGraph): void { + for (const bundle of graph.bundles) { + routeBundledEdges(graph, bundle) + } +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/edge-routing.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/edge-routing.ts new file mode 100644 index 000000000..33229c963 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/edge-routing.ts @@ -0,0 +1,297 @@ +// ============================================================================ +// ASCII renderer — direction system and edge path determination +// +// Ported from AlexanderGrooff/mermaid-ascii cmd/direction.go + cmd/mapping_edge.go. +// Handles direction constants, edge attachment point selection, +// and dual-path comparison for optimal edge routing. +// ============================================================================ + +import type { GridCoord, Direction, AsciiEdge, AsciiGraph } from './types' +import { + Up, Down, Left, Right, UpperRight, UpperLeft, LowerRight, LowerLeft, Middle, + gridCoordDirection, +} from './types' +import { getPath, mergePath } from './pathfinder' +import { getEffectiveDirection, getNodeSubgraph } from './grid' +import { displayWidth } from '../text-metrics' + +// ============================================================================ +// Direction utilities +// ============================================================================ + +export function getOpposite(d: Direction): Direction { + if (d === Up) return Down + if (d === Down) return Up + if (d === Left) return Right + if (d === Right) return Left + if (d === UpperRight) return LowerLeft + if (d === UpperLeft) return LowerRight + if (d === LowerRight) return UpperLeft + if (d === LowerLeft) return UpperRight + return Middle +} + +/** Compare directions by value (not reference). */ +export function dirEquals(a: Direction, b: Direction): boolean { + return a.x === b.x && a.y === b.y +} + +/** + * Determine 8-way direction from one coordinate to another. + * Uses the coordinate difference to pick one of 8 cardinal/ordinal directions. + */ +export function determineDirection(from: { x: number; y: number }, to: { x: number; y: number }): Direction { + if (from.x === to.x) { + return from.y < to.y ? Down : Up + } else if (from.y === to.y) { + return from.x < to.x ? Right : Left + } else if (from.x < to.x) { + return from.y < to.y ? LowerRight : UpperRight + } else { + return from.y < to.y ? LowerLeft : UpperLeft + } +} + +// ============================================================================ +// Start/end direction selection for edges +// ============================================================================ + +/** Self-reference routing (node points to itself). */ +function selfReferenceDirection(graphDirection: string): [Direction, Direction, Direction, Direction] { + if (graphDirection === 'LR') return [Right, Down, Down, Right] + return [Down, Right, Right, Down] +} + +/** + * Determine preferred and alternative start/end directions for an edge. + * Returns [preferredStart, preferredEnd, alternativeStart, alternativeEnd]. + * + * The edge routing tries both pairs and picks the shorter path. + * Direction selection depends on relative node positions and graph direction (LR vs TD). + */ +export function determineStartAndEndDir( + edge: AsciiEdge, + graphDirection: string, +): [Direction, Direction, Direction, Direction] { + if (edge.from === edge.to) return selfReferenceDirection(graphDirection) + + const d = determineDirection(edge.from.gridCoord!, edge.to.gridCoord!) + + let preferredDir: Direction + let preferredOppositeDir: Direction + let alternativeDir: Direction + let alternativeOppositeDir: Direction + + const isBackwards = graphDirection === 'LR' + ? (dirEquals(d, Left) || dirEquals(d, UpperLeft) || dirEquals(d, LowerLeft)) + : (dirEquals(d, Up) || dirEquals(d, UpperLeft) || dirEquals(d, UpperRight)) + + if (dirEquals(d, LowerRight)) { + if (graphDirection === 'LR') { + preferredDir = Down; preferredOppositeDir = Left + alternativeDir = Right; alternativeOppositeDir = Up + } else { + preferredDir = Right; preferredOppositeDir = Up + alternativeDir = Down; alternativeOppositeDir = Left + } + } else if (dirEquals(d, UpperRight)) { + if (graphDirection === 'LR') { + preferredDir = Up; preferredOppositeDir = Left + alternativeDir = Right; alternativeOppositeDir = Down + } else { + preferredDir = Right; preferredOppositeDir = Down + alternativeDir = Up; alternativeOppositeDir = Left + } + } else if (dirEquals(d, LowerLeft)) { + if (graphDirection === 'LR') { + preferredDir = Down; preferredOppositeDir = Down + alternativeDir = Left; alternativeOppositeDir = Up + } else { + preferredDir = Left; preferredOppositeDir = Up + alternativeDir = Down; alternativeOppositeDir = Right + } + } else if (dirEquals(d, UpperLeft)) { + if (graphDirection === 'LR') { + preferredDir = Down; preferredOppositeDir = Down + alternativeDir = Left; alternativeOppositeDir = Down + } else { + preferredDir = Right; preferredOppositeDir = Right + alternativeDir = Up; alternativeOppositeDir = Right + } + } else if (isBackwards) { + if (graphDirection === 'LR' && dirEquals(d, Left)) { + preferredDir = Down; preferredOppositeDir = Down + alternativeDir = Left; alternativeOppositeDir = Right + } else if (graphDirection === 'TD' && dirEquals(d, Up)) { + preferredDir = Right; preferredOppositeDir = Right + alternativeDir = Up; alternativeOppositeDir = Down + } else { + preferredDir = d; preferredOppositeDir = getOpposite(d) + alternativeDir = d; alternativeOppositeDir = getOpposite(d) + } + } else { + // Default: go in the natural direction + preferredDir = d; preferredOppositeDir = getOpposite(d) + alternativeDir = d; alternativeOppositeDir = getOpposite(d) + } + + return [preferredDir, preferredOppositeDir, alternativeDir, alternativeOppositeDir] +} + +// ============================================================================ +// Edge path determination +// ============================================================================ + +/** + * Determine the path for an edge by trying two candidate routes (preferred + alternative) + * and picking the shorter one. Sets edge.path, edge.startDir, edge.endDir. + * + * When both A* paths fail (common for edges crossing subgraph boundaries), falls back + * to a direct path using the start/end points. This ensures edges always have a path + * for arrowhead rendering. + * + * Uses the effective direction for edge routing, respecting subgraph direction overrides + * when both source and target are in the same subgraph. + */ +export function determinePath(graph: AsciiGraph, edge: AsciiEdge): void { + // Determine effective direction for this edge + // If both nodes are in the same subgraph with a direction override, use it + // Otherwise, use the graph's direction (not source's effective direction) + const sourceSg = getNodeSubgraph(graph, edge.from) + const targetSg = getNodeSubgraph(graph, edge.to) + const effectiveDir = (sourceSg && sourceSg === targetSg && sourceSg.direction) + ? sourceSg.direction + : graph.config.graphDirection + + const [preferredDir, preferredOppositeDir, alternativeDir, alternativeOppositeDir] = + determineStartAndEndDir(edge, effectiveDir) + + // Try preferred path + const prefFrom = gridCoordDirection(edge.from.gridCoord!, preferredDir) + const prefTo = gridCoordDirection(edge.to.gridCoord!, preferredOppositeDir) + let preferredPath = getPath(graph.grid, prefFrom, prefTo) + + // Try alternative path + const altFrom = gridCoordDirection(edge.from.gridCoord!, alternativeDir) + const altTo = gridCoordDirection(edge.to.gridCoord!, alternativeOppositeDir) + let alternativePath = getPath(graph.grid, altFrom, altTo) + + // Case 1: Both paths found — pick the shorter one + if (preferredPath !== null && alternativePath !== null) { + preferredPath = mergePath(preferredPath) + alternativePath = mergePath(alternativePath) + + if (preferredPath.length <= alternativePath.length) { + edge.startDir = preferredDir + edge.endDir = preferredOppositeDir + edge.path = preferredPath + } else { + edge.startDir = alternativeDir + edge.endDir = alternativeOppositeDir + edge.path = alternativePath + } + return + } + + // Case 2: Only preferred path found + if (preferredPath !== null) { + edge.startDir = preferredDir + edge.endDir = preferredOppositeDir + edge.path = mergePath(preferredPath) + return + } + + // Case 3: Only alternative path found + if (alternativePath !== null) { + edge.startDir = alternativeDir + edge.endDir = alternativeOppositeDir + edge.path = mergePath(alternativePath) + return + } + + // Case 4: Both paths failed — create a direct fallback path + // This happens for edges crossing subgraph boundaries where A* can't find + // a clear route. We create a direct path from source to target exit points + // so arrowheads can still be rendered correctly. + edge.startDir = preferredDir + edge.endDir = preferredOppositeDir + edge.path = [prefFrom, prefTo] +} + +/** + * Find the best line segment in an edge's path to place a label on. + * Prefers vertical segments for TD/BT graphs and horizontal for LR/RL to avoid + * label collisions when multiple edges share initial segments. + * Falls back to the widest segment if none are suitable. + * Also increases the column width at the label position to fit the text. + */ +export function determineLabelLine(graph: AsciiGraph, edge: AsciiEdge): void { + if (edge.text.length === 0) return + + const lenLabel = displayWidth(edge.text) + const pathLen = edge.path.length + const isVerticalFlow = graph.config.graphDirection === 'TD' + + // Collect all segments with their widths and orientation + const segments: { + line: [GridCoord, GridCoord] + width: number + index: number + isVertical: boolean + }[] = [] + + for (let i = 1; i < pathLen; i++) { + const p1 = edge.path[i - 1]! + const p2 = edge.path[i]! + const line: [GridCoord, GridCoord] = [p1, p2] + const width = calculateLineWidth(graph, line) + // A segment is vertical if X coords are same, horizontal if Y coords are same + const isVertical = p1.x === p2.x + segments.push({ line, width, index: i, isVertical }) + } + + // Find segments wide enough for the label, excluding the first segment + // The first segment is often shared between edges from the same source node + const suitableSegments = segments.filter(s => s.width >= lenLabel && s.index > 1) + + let largestLine: [GridCoord, GridCoord] + + if (suitableSegments.length > 0) { + // Prefer segments near the end of the path (closer to target) + // This avoids the shared initial segments from source + suitableSegments.sort((a, b) => b.index - a.index) + largestLine = suitableSegments[0]!.line + } else { + // Fall back to any suitable segment including the first + const fallbackSegments = segments.filter(s => s.width >= lenLabel) + if (fallbackSegments.length > 0) { + fallbackSegments.sort((a, b) => b.index - a.index) + largestLine = fallbackSegments[0]!.line + } else { + // No segment wide enough — use the widest one + segments.sort((a, b) => b.width - a.width) + largestLine = segments[0]?.line ?? [edge.path[0]!, edge.path[1]!] + } + } + + // Ensure column at midpoint is wide enough for the label + const minX = Math.min(largestLine[0].x, largestLine[1].x) + const maxX = Math.max(largestLine[0].x, largestLine[1].x) + const middleX = minX + Math.floor((maxX - minX) / 2) + + const current = graph.columnWidth.get(middleX) ?? 0 + graph.columnWidth.set(middleX, Math.max(current, lenLabel + 2)) + + edge.labelLine = [largestLine[0], largestLine[1]] +} + +/** Calculate the total character width of a line segment by summing column widths. */ +function calculateLineWidth(graph: AsciiGraph, line: [GridCoord, GridCoord]): number { + let total = 0 + const startX = Math.min(line[0].x, line[1].x) + const endX = Math.max(line[0].x, line[1].x) + for (let x = startX; x <= endX; x++) { + total += graph.columnWidth.get(x) ?? 0 + } + return total +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/er-diagram.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/er-diagram.ts new file mode 100644 index 000000000..7db60535f --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/er-diagram.ts @@ -0,0 +1,441 @@ +// ============================================================================ +// ASCII renderer — ER diagrams +// +// Renders erDiagram text to ASCII/Unicode art. +// Each entity is a 2-section box (header | attributes). +// Relationships are drawn as lines with crow's foot notation at endpoints. +// +// Layout: entities are placed in a grid pattern (multiple rows if needed). +// Relationship lines use Manhattan routing between entity boxes. +// ============================================================================ + +import { parseErDiagram } from '../er/parser' +import type { ErDiagram, ErEntity, ErAttribute, Cardinality } from '../er/types' +import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types' +import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas' +import { drawMultiBox } from './draw' +import { splitLines } from './multiline-utils' +import { displayWidth, toCells, WIDE_PAD } from '../text-metrics' + +/** Classify a character from a box drawing as 'border' or 'text'. */ +function classifyBoxChar(ch: string): CharRole { + if (/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|]$/.test(ch)) return 'border' + return 'text' +} + +// ============================================================================ +// Entity box content +// ============================================================================ + +/** Format an attribute line: "PK type name" or "FK type name" etc. */ +function formatAttribute(attr: ErAttribute): string { + const keyStr = attr.keys.length > 0 ? attr.keys.join(',') + ' ' : ' ' + return `${keyStr}${attr.type} ${attr.name}` +} + +/** Build sections for an entity box: [header], [attributes] */ +function buildEntitySections(entity: ErEntity): string[][] { + // Support multi-line entity names + const header = splitLines(entity.label) + const attrs = entity.attributes.map(formatAttribute) + if (attrs.length === 0) return [header] + return [header, attrs] +} + +// ============================================================================ +// Crow's foot notation +// ============================================================================ + +/** + * Returns the ASCII/Unicode characters for a crow's foot cardinality marker. + * Markers are drawn adjacent to entity boxes at relationship endpoints. + * + * Standard ER notation: + * one: ─┤├─ perpendicular line (exactly one) + * zero-one: ─○┤─ circle + perpendicular (zero or one) + * many: ─<>─ crow's foot (one or more) + * zero-many: ─○<─ circle + crow's foot (zero or more) + * + * @param card - The cardinality type + * @param useAscii - Use ASCII-only characters + * @param isRight - True if this marker is on the right side of the relationship + */ +function getCrowsFootChars(card: Cardinality, useAscii: boolean, isRight = false): string { + if (useAscii) { + switch (card) { + case 'one': return '|' + case 'zero-one': return 'o|' + case 'many': return isRight ? '<' : '>' + case 'zero-many': return isRight ? 'o<' : '>o' + } + } else { + // Use cleaner Unicode characters + switch (card) { + case 'one': return '│' + case 'zero-one': return '○│' + case 'many': return isRight ? '╟' : '╢' + case 'zero-many': return isRight ? '○╟' : '╢○' + } + } +} + +// ============================================================================ +// Positioned entity +// ============================================================================ + +interface PlacedEntity { + entity: ErEntity + sections: string[][] + x: number + y: number + width: number + height: number +} + +// ============================================================================ +// Connected Component Detection +// ============================================================================ + +/** + * Find connected components in the ER diagram using DFS. + * Treats relationships as undirected edges for connectivity. + * + * Returns an array of entity ID sets, one per connected component. + */ +function findConnectedComponents(diagram: ErDiagram): Set[] { + const visited = new Set() + const components: Set[] = [] + + // Build undirected adjacency list from relationships + const neighbors = new Map>() + for (const ent of diagram.entities) { + neighbors.set(ent.id, new Set()) + } + for (const rel of diagram.relationships) { + neighbors.get(rel.entity1)?.add(rel.entity2) + neighbors.get(rel.entity2)?.add(rel.entity1) + } + + // DFS to find each component + function dfs(startId: string, component: Set): void { + const stack = [startId] + while (stack.length > 0) { + const nodeId = stack.pop()! + if (visited.has(nodeId)) continue + + visited.add(nodeId) + component.add(nodeId) + + for (const neighbor of neighbors.get(nodeId) ?? []) { + if (!visited.has(neighbor)) { + stack.push(neighbor) + } + } + } + } + + // Find all components + for (const ent of diagram.entities) { + if (!visited.has(ent.id)) { + const component = new Set() + dfs(ent.id, component) + if (component.size > 0) { + components.push(component) + } + } + } + + return components +} + +// ============================================================================ +// Layout and rendering +// ============================================================================ + +/** + * Render a Mermaid ER diagram to ASCII/Unicode text. + * + * Pipeline: parse → build boxes → component-aware layout → draw boxes → draw relationships → string. + */ +export function renderErAscii(text: string, config: AsciiConfig, colorMode?: ColorMode, theme?: AsciiTheme): string { + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) + const diagram = parseErDiagram(lines) + + if (diagram.entities.length === 0) return '' + + const useAscii = config.useAscii + const hGap = 6 // horizontal gap between entity boxes + const vGap = 4 // vertical gap between rows (for relationship lines) + const componentGap = 6 // vertical gap between disconnected components + + // --- Build entity box dimensions --- + const entitySections = new Map() + const entityBoxW = new Map() + const entityBoxH = new Map() + const entityById = new Map() + + for (const ent of diagram.entities) { + entityById.set(ent.id, ent) + const sections = buildEntitySections(ent) + entitySections.set(ent.id, sections) + + let maxTextW = 0 + for (const section of sections) { + for (const line of section) maxTextW = Math.max(maxTextW, displayWidth(line)) + } + const boxW = maxTextW + 4 // 2 border + 2 padding + + let totalLines = 0 + for (const section of sections) totalLines += Math.max(section.length, 1) + const boxH = totalLines + (sections.length - 1) + 2 + + entityBoxW.set(ent.id, boxW) + entityBoxH.set(ent.id, boxH) + } + + // --- Find connected components --- + const components = findConnectedComponents(diagram) + + // --- Layout: place each component, then stack components vertically --- + const placed = new Map() + let currentY = 0 + + for (const component of components) { + // Get entities in this component (preserve original order for consistency) + const componentEntities = diagram.entities.filter(e => component.has(e.id)) + + // Layout entities within this component horizontally + // Use sqrt-based row limit for larger components + const maxPerRow = Math.max(2, Math.ceil(Math.sqrt(componentEntities.length))) + + let currentX = 0 + let maxRowH = 0 + let colCount = 0 + const componentStartY = currentY + + for (const ent of componentEntities) { + const w = entityBoxW.get(ent.id)! + const h = entityBoxH.get(ent.id)! + + if (colCount >= maxPerRow) { + // Wrap to next row within this component + currentY += maxRowH + vGap + currentX = 0 + maxRowH = 0 + colCount = 0 + } + + placed.set(ent.id, { + entity: ent, + sections: entitySections.get(ent.id)!, + x: currentX, + y: currentY, + width: w, + height: h, + }) + + currentX += w + hGap + maxRowH = Math.max(maxRowH, h) + colCount++ + } + + // Move to next component row (add gap between components) + currentY += maxRowH + componentGap + } + + // --- Create canvas --- + let totalW = 0 + let totalH = 0 + for (const p of placed.values()) { + totalW = Math.max(totalW, p.x + p.width) + totalH = Math.max(totalH, p.y + p.height) + } + totalW += 4 + totalH += 2 + + const canvas = mkCanvas(totalW - 1, totalH - 1) + const rc = mkRoleCanvas(totalW - 1, totalH - 1) + + /** Set a character on the canvas and track its role. */ + function setC(x: number, y: number, ch: string, role: CharRole): void { + if (x >= 0 && x < canvas.length && y >= 0 && y < (canvas[0]?.length ?? 0)) { + canvas[x]![y] = ch + setRole(rc, x, y, role) + } + } + + // --- Draw entity boxes --- + for (const p of placed.values()) { + const boxCanvas = drawMultiBox(p.sections, useAscii) + for (let bx = 0; bx < boxCanvas.length; bx++) { + for (let by = 0; by < boxCanvas[0]!.length; by++) { + const ch = boxCanvas[bx]![by]! + if (ch !== ' ') { + const cx = p.x + bx + const cy = p.y + by + if (cx < totalW && cy < totalH) { + setC(cx, cy, ch, classifyBoxChar(ch)) + } + } + } + } + } + + // --- Draw relationships --- + const H = useAscii ? '-' : '─' + const V = useAscii ? '|' : '│' + const dashH = useAscii ? '.' : '╌' + const dashV = useAscii ? ':' : '┊' + + for (const rel of diagram.relationships) { + const e1 = placed.get(rel.entity1) + const e2 = placed.get(rel.entity2) + if (!e1 || !e2) continue + + const lineH = rel.identifying ? H : dashH + const lineV = rel.identifying ? V : dashV + + // Determine connection direction based on relative position. + // Connect from right side of left entity to left side of right entity (horizontal), + // or from bottom of upper entity to top of lower entity (vertical). + const e1CX = e1.x + Math.floor(e1.width / 2) + const e1CY = e1.y + Math.floor(e1.height / 2) + const e2CX = e2.x + Math.floor(e2.width / 2) + const e2CY = e2.y + Math.floor(e2.height / 2) + + // Check if entities are on the same row (horizontal connection) + const sameRow = Math.abs(e1CY - e2CY) < Math.max(e1.height, e2.height) + + if (sameRow) { + // Horizontal connection: right side of left entity → left side of right entity + const [left, right] = e1CX < e2CX ? [e1, e2] : [e2, e1] + const [leftCard, rightCard] = e1CX < e2CX + ? [rel.cardinality1, rel.cardinality2] + : [rel.cardinality2, rel.cardinality1] + + const startX = left.x + left.width + const endX = right.x - 1 + const lineY = left.y + Math.floor(left.height / 2) + + // Draw horizontal line + for (let x = startX; x <= endX; x++) { + setC(x, lineY, lineH, 'line') + } + + // Draw crow's foot markers at endpoints + // Left marker (at left entity's right edge) - isRight=false + const leftChars = getCrowsFootChars(leftCard, useAscii, false) + for (let i = 0; i < leftChars.length; i++) { + setC(startX + i, lineY, leftChars[i]!, 'arrow') + } + + // Right marker (at right entity's left edge) - isRight=true + const rightChars = getCrowsFootChars(rightCard, useAscii, true) + for (let i = 0; i < rightChars.length; i++) { + setC(endX - rightChars.length + 1 + i, lineY, rightChars[i]!, 'arrow') + } + + // Relationship label centered in the gap between the two entities, below the line. + // Clamp label to the gap region [startX, endX] to avoid overwriting box borders. + // Supports multi-line labels. + if (rel.label) { + const lines = splitLines(rel.label) + const gapMid = Math.floor((startX + endX) / 2) + + // Place lines below the relationship line (lineY + 1, lineY + 2, ...) + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx]! + 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 + 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 + 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') + } + } + } + } else { + // Vertical connection: bottom of upper entity → top of lower entity + const [upper, lower] = e1CY < e2CY ? [e1, e2] : [e2, e1] + const [upperCard, lowerCard] = e1CY < e2CY + ? [rel.cardinality1, rel.cardinality2] + : [rel.cardinality2, rel.cardinality1] + + const startY = upper.y + upper.height + const endY = lower.y - 1 + const lineX = upper.x + Math.floor(upper.width / 2) + + // Vertical line + for (let y = startY; y <= endY; y++) { + setC(lineX, y, lineV, 'line') + } + + // If horizontal offset needed, add a horizontal segment + const lowerCX = lower.x + Math.floor(lower.width / 2) + if (lineX !== lowerCX) { + const midY = Math.floor((startY + endY) / 2) + // Horizontal segment at midY + const lx = Math.min(lineX, lowerCX) + const rx = Math.max(lineX, lowerCX) + for (let x = lx; x <= rx; x++) { + setC(x, midY, lineH, 'line') + } + // Vertical from midY to lower entity + for (let y = midY + 1; y <= endY; y++) { + setC(lowerCX, y, lineV, 'line') + } + } + + // Crow's foot markers (vertical direction) + // Upper marker (at upper entity's bottom edge) - treat as source side (isRight=false) + const upperChars = getCrowsFootChars(upperCard, useAscii, false) + for (let i = 0; i < upperChars.length; i++) { + setC(lineX - Math.floor(upperChars.length / 2) + i, startY, upperChars[i]!, 'arrow') + } + + // Lower marker (at lower entity's top edge) - treat as target side (isRight=true) + const targetX = lineX !== lowerCX ? lowerCX : lineX + const lowerChars = getCrowsFootChars(lowerCard, useAscii, true) + for (let i = 0; i < lowerChars.length; i++) { + setC(targetX - Math.floor(lowerChars.length / 2) + i, endY, lowerChars[i]!, 'arrow') + } + + // Relationship label — placed to the right of the vertical line at the midpoint. + // We expand the canvas as needed since labels can extend beyond the initial bounds. + // Supports multi-line labels. + if (rel.label) { + const lines = splitLines(rel.label) + const midY = Math.floor((startY + endY) / 2) + // Center lines vertically around midY + const startLabelY = midY - Math.floor((lines.length - 1) / 2) + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const cells = toCells(lines[lineIdx]!) + const labelX = lineX + 2 + const y = startLabelY + lineIdx + if (y >= 0) { + 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, cells[i]!, 'text') + } + } + } + } + } + } + } + + return canvasToString(canvas, { roleCanvas: rc, colorMode, theme }) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/grid.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/grid.ts new file mode 100644 index 000000000..08e2c8051 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/grid.ts @@ -0,0 +1,578 @@ +// ============================================================================ +// ASCII renderer — grid-based layout +// +// Ported from AlexanderGrooff/mermaid-ascii cmd/graph.go + cmd/mapping_node.go. +// Places nodes on a logical grid, computes column/row sizes, +// converts grid coordinates to character-level drawing coordinates, +// and handles subgraph bounding boxes. +// ============================================================================ + +import type { + GridCoord, DrawingCoord, Direction, AsciiGraph, AsciiNode, AsciiSubgraph, +} from './types' +import { gridKey } from './types' +import { mkCanvas, setCanvasSizeToGrid, setRoleCanvasSizeToGrid } from './canvas' +import { determinePath, determineLabelLine } from './edge-routing' +import { analyzeEdgeBundles, processBundles } from './edge-bundling' +import { drawBox } from './draw' +import { maxLineWidth, lineCount } from './multiline-utils' +import { getShapeDimensions } from './shapes/index' + +// ============================================================================ +// Grid coordinate → drawing coordinate conversion +// ============================================================================ + +/** + * Convert a grid coordinate to a drawing (character) coordinate. + * Sums column widths up to the target column, and row heights up to the target row, + * then centers within the cell. + */ +export function gridToDrawingCoord( + graph: AsciiGraph, + c: GridCoord, + dir?: Direction, +): DrawingCoord { + const target: GridCoord = dir + ? { x: c.x + dir.x, y: c.y + dir.y } + : c + + let x = 0 + for (let col = 0; col < target.x; col++) { + x += graph.columnWidth.get(col) ?? 0 + } + + let y = 0 + for (let row = 0; row < target.y; row++) { + y += graph.rowHeight.get(row) ?? 0 + } + + const colW = graph.columnWidth.get(target.x) ?? 0 + const rowH = graph.rowHeight.get(target.y) ?? 0 + return { + x: x + Math.floor(colW / 2) + graph.offsetX, + y: y + Math.floor(rowH / 2) + graph.offsetY, + } +} + +/** Convert a path of grid coords to drawing coords. */ +export function lineToDrawing(graph: AsciiGraph, line: GridCoord[]): DrawingCoord[] { + return line.map(c => gridToDrawingCoord(graph, c)) +} + +// ============================================================================ +// Node placement on the grid +// ============================================================================ + +/** + * Reserve a 3x3 block in the grid for a node. + * If the requested position is occupied, recursively shift by 4 grid units + * (in the perpendicular direction based on effective direction) until a free spot is found. + * + * @param effectiveDir - Optional direction override. If not provided, uses the node's + * effective direction (subgraph direction if in a subgraph with override, + * otherwise graph direction). + */ +export function reserveSpotInGrid( + graph: AsciiGraph, + node: AsciiNode, + requested: GridCoord, + effectiveDir?: 'LR' | 'TD', +): GridCoord { + // Determine direction for collision handling + const dir = effectiveDir ?? getEffectiveDirection(graph, node) + + if (graph.grid.has(gridKey(requested))) { + // Collision — shift perpendicular to main flow direction + if (dir === 'LR') { + return reserveSpotInGrid(graph, node, { x: requested.x, y: requested.y + 4 }, dir) + } else { + return reserveSpotInGrid(graph, node, { x: requested.x + 4, y: requested.y }, dir) + } + } + + // Reserve the 3x3 block + for (let dx = 0; dx < 3; dx++) { + for (let dy = 0; dy < 3; dy++) { + const reserved: GridCoord = { x: requested.x + dx, y: requested.y + dy } + graph.grid.set(gridKey(reserved), node) + } + } + + node.gridCoord = requested + return requested +} + +// ============================================================================ +// Column width / row height computation +// ============================================================================ + +/** + * Set column widths and row heights for a node's 3x3 grid block. + * Each node occupies 3 columns (border, content, border) and 3 rows. + * Uses shape-aware dimensions to properly size non-rectangular shapes. + */ +export function setColumnWidth(graph: AsciiGraph, node: AsciiNode): void { + const gc = node.gridCoord! + const padding = graph.config.boxBorderPadding + + // Get shape-aware dimensions + const shapeDims = getShapeDimensions(node.shape, node.displayLabel, { + useAscii: graph.config.useAscii, + padding, + }) + + // Use shape-provided grid dimensions + const colWidths = shapeDims.gridColumns + const rowHeights = shapeDims.gridRows + + for (let idx = 0; idx < colWidths.length; idx++) { + const xCoord = gc.x + idx + const current = graph.columnWidth.get(xCoord) ?? 0 + graph.columnWidth.set(xCoord, Math.max(current, colWidths[idx]!)) + } + + for (let idx = 0; idx < rowHeights.length; idx++) { + const yCoord = gc.y + idx + const current = graph.rowHeight.get(yCoord) ?? 0 + graph.rowHeight.set(yCoord, Math.max(current, rowHeights[idx]!)) + } + + // Padding column/row before the node (spacing between nodes) + if (gc.x > 0) { + const current = graph.columnWidth.get(gc.x - 1) ?? 0 + graph.columnWidth.set(gc.x - 1, Math.max(current, graph.config.paddingX)) + } + + if (gc.y > 0) { + let basePadding = graph.config.paddingY + // Extra vertical padding for nodes with incoming edges from outside their subgraph + if (hasIncomingEdgeFromOutsideSubgraph(graph, node)) { + const subgraphOverhead = 4 + basePadding += subgraphOverhead + } + const current = graph.rowHeight.get(gc.y - 1) ?? 0 + graph.rowHeight.set(gc.y - 1, Math.max(current, basePadding)) + } +} + +/** Ensure grid has width/height entries for all cells along an edge path. */ +export function increaseGridSizeForPath(graph: AsciiGraph, path: GridCoord[]): void { + for (const c of path) { + if (!graph.columnWidth.has(c.x)) { + graph.columnWidth.set(c.x, Math.floor(graph.config.paddingX / 2)) + } + if (!graph.rowHeight.has(c.y)) { + graph.rowHeight.set(c.y, Math.floor(graph.config.paddingY / 2)) + } + } +} + +// ============================================================================ +// Subgraph helpers +// ============================================================================ + +function isNodeInAnySubgraph(graph: AsciiGraph, node: AsciiNode): boolean { + return graph.subgraphs.some(sg => sg.nodes.includes(node)) +} + +/** + * Get the innermost subgraph that directly contains this node. + * Returns null if node is not in any subgraph. + */ +export function getNodeSubgraph(graph: AsciiGraph, node: AsciiNode): AsciiSubgraph | null { + // Find the innermost (most deeply nested) subgraph containing the node + let innermost: AsciiSubgraph | null = null + for (const sg of graph.subgraphs) { + if (sg.nodes.includes(node)) { + // Check if this subgraph is deeper (more nested) than current innermost + if (!innermost || isAncestorOrSelf(innermost, sg)) { + innermost = sg + } + } + } + return innermost +} + +/** Check if `candidate` is the same as or an ancestor of `target`. */ +function isAncestorOrSelf(candidate: AsciiSubgraph, target: AsciiSubgraph): boolean { + let current: AsciiSubgraph | null = target + while (current !== null) { + if (current === candidate) return true + current = current.parent + } + return false +} + +/** + * Get the effective direction for a node's layout. + * Returns the subgraph's direction override if the node is in a subgraph with one, + * otherwise returns the graph-level direction. + */ +export function getEffectiveDirection(graph: AsciiGraph, node: AsciiNode): 'LR' | 'TD' { + const sg = getNodeSubgraph(graph, node) + if (sg?.direction) { + return sg.direction + } + return graph.config.graphDirection +} + +/** + * Check if a node has an incoming edge from outside its subgraph + * AND is the topmost such node in its subgraph. + * Used to add extra vertical padding for subgraph borders. + */ +function hasIncomingEdgeFromOutsideSubgraph(graph: AsciiGraph, node: AsciiNode): boolean { + const nodeSg = getNodeSubgraph(graph, node) + if (!nodeSg) return false + + let hasExternalEdge = false + for (const edge of graph.edges) { + if (edge.to === node) { + const sourceSg = getNodeSubgraph(graph, edge.from) + if (sourceSg !== nodeSg) { + hasExternalEdge = true + break + } + } + } + + if (!hasExternalEdge) return false + + // Only return true for the topmost node with an external incoming edge + for (const otherNode of nodeSg.nodes) { + if (otherNode === node || !otherNode.gridCoord) continue + let otherHasExternal = false + for (const edge of graph.edges) { + if (edge.to === otherNode) { + const sourceSg = getNodeSubgraph(graph, edge.from) + if (sourceSg !== nodeSg) { + otherHasExternal = true + break + } + } + } + if (otherHasExternal && otherNode.gridCoord.y < node.gridCoord!.y) { + return false + } + } + + return true +} + +// ============================================================================ +// Subgraph bounding boxes +// ============================================================================ + +function calculateSubgraphBoundingBox(graph: AsciiGraph, sg: AsciiSubgraph): void { + if (sg.nodes.length === 0) return + + let minX = 1_000_000 + let minY = 1_000_000 + let maxX = -1_000_000 + let maxY = -1_000_000 + + // Include children's bounding boxes + for (const child of sg.children) { + calculateSubgraphBoundingBox(graph, child) + if (child.nodes.length > 0) { + minX = Math.min(minX, child.minX) + minY = Math.min(minY, child.minY) + maxX = Math.max(maxX, child.maxX) + maxY = Math.max(maxY, child.maxY) + } + } + + // Include node positions + for (const node of sg.nodes) { + if (!node.drawingCoord || !node.drawing) continue + const nodeMinX = node.drawingCoord.x + const nodeMinY = node.drawingCoord.y + const nodeMaxX = nodeMinX + node.drawing.length - 1 + const nodeMaxY = nodeMinY + node.drawing[0]!.length - 1 + minX = Math.min(minX, nodeMinX) + minY = Math.min(minY, nodeMinY) + maxX = Math.max(maxX, nodeMaxX) + maxY = Math.max(maxY, nodeMaxY) + } + + const subgraphPadding = 2 + const subgraphLabelSpace = 2 + sg.minX = minX - subgraphPadding + sg.minY = minY - subgraphPadding - subgraphLabelSpace + sg.maxX = maxX + subgraphPadding + sg.maxY = maxY + subgraphPadding +} + +/** Ensure non-overlapping root subgraphs have minimum spacing. */ +function ensureSubgraphSpacing(graph: AsciiGraph): void { + const minSpacing = 1 + const rootSubgraphs = graph.subgraphs.filter(sg => sg.parent === null && sg.nodes.length > 0) + + for (let i = 0; i < rootSubgraphs.length; i++) { + for (let j = i + 1; j < rootSubgraphs.length; j++) { + const sg1 = rootSubgraphs[i]! + const sg2 = rootSubgraphs[j]! + + // Horizontal overlap → adjust vertical + if (sg1.minX < sg2.maxX && sg1.maxX > sg2.minX) { + if (sg1.maxY >= sg2.minY - minSpacing && sg1.minY < sg2.minY) { + sg2.minY = sg1.maxY + minSpacing + 1 + } else if (sg2.maxY >= sg1.minY - minSpacing && sg2.minY < sg1.minY) { + sg1.minY = sg2.maxY + minSpacing + 1 + } + } + // Vertical overlap → adjust horizontal + if (sg1.minY < sg2.maxY && sg1.maxY > sg2.minY) { + if (sg1.maxX >= sg2.minX - minSpacing && sg1.minX < sg2.minX) { + sg2.minX = sg1.maxX + minSpacing + 1 + } else if (sg2.maxX >= sg1.minX - minSpacing && sg2.minX < sg1.minX) { + sg1.minX = sg2.maxX + minSpacing + 1 + } + } + } + } +} + +export function calculateSubgraphBoundingBoxes(graph: AsciiGraph): void { + for (const sg of graph.subgraphs) { + calculateSubgraphBoundingBox(graph, sg) + } + ensureSubgraphSpacing(graph) +} + +/** + * Offset all drawing coordinates so subgraph borders don't go negative. + * If any subgraph has negative min coordinates, shift everything positive. + */ +export function offsetDrawingForSubgraphs(graph: AsciiGraph): void { + if (graph.subgraphs.length === 0) return + + let minX = 0 + let minY = 0 + for (const sg of graph.subgraphs) { + minX = Math.min(minX, sg.minX) + minY = Math.min(minY, sg.minY) + } + + const offsetX = -minX + const offsetY = -minY + if (offsetX === 0 && offsetY === 0) return + + graph.offsetX = offsetX + graph.offsetY = offsetY + + for (const sg of graph.subgraphs) { + sg.minX += offsetX + sg.minY += offsetY + sg.maxX += offsetX + sg.maxY += offsetY + } + + for (const node of graph.nodes) { + if (node.drawingCoord) { + node.drawingCoord.x += offsetX + node.drawingCoord.y += offsetY + } + } +} + +// ============================================================================ +// Main layout orchestrator +// ============================================================================ + +/** + * createMapping performs the full grid layout: + * 1. Place root nodes on the grid + * 2. Place child nodes level by level + * 3. Compute column widths and row heights + * 4. Run A* pathfinding for all edges + * 5. Determine label placement + * 6. Convert grid coords → drawing coords + * 7. Generate node box drawings + * 8. Calculate subgraph bounding boxes + */ +export function createMapping(graph: AsciiGraph): void { + const dir = graph.config.graphDirection + const highestPositionPerLevel: number[] = new Array(100).fill(0) + + // Identify root nodes — nodes that aren't the target of any edge + const nodesFound = new Set() + const initialRoots: AsciiNode[] = [] + + for (const node of graph.nodes) { + if (!nodesFound.has(node.name)) { + initialRoots.push(node) + } + nodesFound.add(node.name) + for (const child of getChildren(graph, node)) { + nodesFound.add(child.name) + } + } + + // Filter out subgraph nodes that have incoming edges from external sources. + // This handles the case where subgraph is declared before external nodes + // (e.g., `subgraph s; A-->B; end; X-->A` - A shouldn't be a root, X should). + const rootNodes = initialRoots.filter(node => { + const nodeSg = getNodeSubgraph(graph, node) + if (!nodeSg) return true // external nodes: keep as roots + + // Check if this subgraph node has incoming edges from outside its subgraph + for (const edge of graph.edges) { + if (edge.to === node) { + const sourceSg = getNodeSubgraph(graph, edge.from) + if (sourceSg !== nodeSg) { + return false // has external incoming edge → not a root + } + } + } + return true + }) + + // In LR mode with both external and subgraph roots, separate them + // so subgraph roots are placed one level deeper + let hasExternalRoots = false + let hasSubgraphRootsWithEdges = false + for (const node of rootNodes) { + if (isNodeInAnySubgraph(graph, node)) { + if (getChildren(graph, node).length > 0) hasSubgraphRootsWithEdges = true + } else { + hasExternalRoots = true + } + } + const shouldSeparate = dir === 'LR' && hasExternalRoots && hasSubgraphRootsWithEdges + + let externalRootNodes: AsciiNode[] + let subgraphRootNodes: AsciiNode[] = [] + + if (shouldSeparate) { + externalRootNodes = rootNodes.filter(n => !isNodeInAnySubgraph(graph, n)) + subgraphRootNodes = rootNodes.filter(n => isNodeInAnySubgraph(graph, n)) + } else { + externalRootNodes = rootNodes + } + + // Place external root nodes + for (const node of externalRootNodes) { + const requested: GridCoord = dir === 'LR' + ? { x: 0, y: highestPositionPerLevel[0]! } + : { x: highestPositionPerLevel[0]!, y: 0 } + reserveSpotInGrid(graph, graph.nodes[node.index]!, requested) + highestPositionPerLevel[0] = highestPositionPerLevel[0]! + 4 + } + + // Place subgraph root nodes at level 4 (one level in from the edge) + if (shouldSeparate && subgraphRootNodes.length > 0) { + const subgraphLevel = 4 + for (const node of subgraphRootNodes) { + const requested: GridCoord = dir === 'LR' + ? { x: subgraphLevel, y: highestPositionPerLevel[subgraphLevel]! } + : { x: highestPositionPerLevel[subgraphLevel]!, y: subgraphLevel } + reserveSpotInGrid(graph, graph.nodes[node.index]!, requested) + highestPositionPerLevel[subgraphLevel] = highestPositionPerLevel[subgraphLevel]! + 4 + } + } + + // Place child nodes level by level + // Use subgraph direction only when both parent and child are in the same subgraph + // Multi-pass: iterate until all nodes are placed (handles non-topological node order) + // Note: when shouldSeparate, externalRootNodes + subgraphRootNodes = rootNodes + // otherwise, externalRootNodes = rootNodes and subgraphRootNodes is empty + let placedCount = externalRootNodes.length + subgraphRootNodes.length + while (placedCount < graph.nodes.length) { + const prevCount = placedCount + for (const node of graph.nodes) { + if (node.gridCoord === null) continue // skip unplaced nodes + const gc = node.gridCoord + + for (const child of getChildren(graph, node)) { + if (child.gridCoord !== null) continue // already placed + + // Determine direction for this edge (parent -> child) + // Use subgraph direction only if both are in the same subgraph with override + const parentSg = getNodeSubgraph(graph, node) + const childSg = getNodeSubgraph(graph, child) + const edgeDir = (parentSg && parentSg === childSg && parentSg.direction) + ? parentSg.direction + : graph.config.graphDirection + + const childLevel = edgeDir === 'LR' ? gc.x + 4 : gc.y + 4 + + // Determine position based on direction context + let highestPosition: number + if (edgeDir !== graph.config.graphDirection) { + // Cross-direction: use parent's perpendicular coordinate + // This keeps children aligned with parent when direction changes + highestPosition = edgeDir === 'LR' ? gc.y : gc.x + } else { + // Same direction: use level tracker + highestPosition = highestPositionPerLevel[childLevel]! + } + + const requested: GridCoord = edgeDir === 'LR' + ? { x: childLevel, y: highestPosition } + : { x: highestPosition, y: childLevel } + reserveSpotInGrid(graph, graph.nodes[child.index]!, requested, edgeDir) + + // Only update level tracker for same-direction placements + if (edgeDir === graph.config.graphDirection) { + highestPositionPerLevel[childLevel] = highestPosition + 4 + } + placedCount++ + } + } + // Safety: break if no progress made (handles disconnected nodes) + if (placedCount === prevCount) break + } + + // Compute column widths and row heights + for (const node of graph.nodes) { + setColumnWidth(graph, node) + } + + // Analyze edges for bundling (parallel links like A & B --> C) + // This groups edges that share sources or targets for cleaner visualization + graph.bundles = analyzeEdgeBundles(graph) + + // Route bundled edges through junction points + processBundles(graph) + + // Route non-bundled edges via A* and determine label positions + for (const edge of graph.edges) { + // Skip edges that were already routed as part of a bundle + if (edge.bundle && edge.path.length > 0) { + increaseGridSizeForPath(graph, edge.path) + determineLabelLine(graph, edge) + continue + } + + determinePath(graph, edge) + increaseGridSizeForPath(graph, edge.path) + determineLabelLine(graph, edge) + } + + // Convert grid coords → drawing coords and generate box drawings + for (const node of graph.nodes) { + node.drawingCoord = gridToDrawingCoord(graph, node.gridCoord!) + node.drawing = drawBox(node, graph) + } + + // Set canvas size and compute subgraph bounding boxes + setCanvasSizeToGrid(graph.canvas, graph.columnWidth, graph.rowHeight) + setRoleCanvasSizeToGrid(graph.roleCanvas, graph.columnWidth, graph.rowHeight) + calculateSubgraphBoundingBoxes(graph) + offsetDrawingForSubgraphs(graph) +} + +// ============================================================================ +// Graph traversal helpers +// ============================================================================ + +/** Get all edges originating from a node. */ +function getEdgesFromNode(graph: AsciiGraph, node: AsciiNode): AsciiGraph['edges'] { + return graph.edges.filter(e => e.from.name === node.name) +} + +/** Get all direct children of a node (targets of outgoing edges). */ +function getChildren(graph: AsciiGraph, node: AsciiNode): AsciiNode[] { + return getEdgesFromNode(graph, node).map(e => e.to) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/index.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/index.ts new file mode 100644 index 000000000..d208d2a1d --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/index.ts @@ -0,0 +1,187 @@ +// ============================================================================ +// beautiful-mermaid — ASCII renderer public API +// +// Renders Mermaid diagrams to ASCII or Unicode box-drawing art. +// No external dependencies — pure TypeScript. +// +// Supported diagram types: +// - Flowcharts (graph TD / flowchart LR) — grid-based layout with A* pathfinding +// - State diagrams (stateDiagram-v2) — same pipeline as flowcharts +// - Sequence diagrams (sequenceDiagram) — column-based timeline layout +// - Class diagrams (classDiagram) — level-based UML layout +// - ER diagrams (erDiagram) — grid layout with crow's foot notation +// +// Usage: +// import { renderMermaidASCII } from 'beautiful-mermaid' +// const ascii = renderMermaidASCII('graph LR\n A --> B') +// ============================================================================ + +import { parseMermaid } from '../parser' +import { convertToAsciiGraph } from './converter' +import { createMapping } from './grid' +import { drawGraph } from './draw' +import { canvasToString, flipCanvasVertically, flipRoleCanvasVertically } from './canvas' +import { renderSequenceAscii } from './sequence' +import { renderClassAscii } from './class-diagram' +import { renderErAscii } from './er-diagram' +import { renderXYChartAscii } from './xychart' +import { detectColorMode, DEFAULT_ASCII_THEME } from './ansi' +import type { AsciiConfig, AsciiTheme, ColorMode } from './types' +import type { Direction } from '../types' + +// Re-export types for external use +export type { AsciiTheme, ColorMode } +export { DEFAULT_ASCII_THEME, detectColorMode } + +export interface AsciiRenderOptions { + /** true = ASCII chars (+,-,|,>), false = Unicode box-drawing (┌,─,│,►). Default: false */ + useAscii?: boolean + /** Horizontal spacing between nodes. Default: 5 */ + paddingX?: number + /** Vertical spacing between nodes. Default: 5 */ + 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) + * - 'auto': Auto-detect (terminal ANSI capabilities, or HTML in browsers) + * - 'ansi16': 16-color ANSI + * - 'ansi256': 256-color xterm + * - 'truecolor': 24-bit RGB + * - 'html': HTML tags with inline color styles (for browser rendering) + * Default: 'auto' + */ + colorMode?: ColorMode | 'auto' + /** Theme colors for ASCII output. Uses default theme if not provided. */ + theme?: Partial +} + +/** + * Detect the diagram type from the mermaid source text. + * Mirrors the detection logic in src/index.ts for the SVG renderer. + */ +function detectDiagramType(text: string): 'flowchart' | 'sequence' | 'class' | 'er' | 'xychart' { + const firstLine = text.trim().split('\n')[0]?.trim().toLowerCase() ?? '' + + if (/^xychart(-beta)?\b/.test(firstLine)) return 'xychart' + if (/^sequencediagram\s*$/.test(firstLine)) return 'sequence' + if (/^classdiagram\s*$/.test(firstLine)) return 'class' + if (/^erdiagram\s*$/.test(firstLine)) return 'er' + + // Default: flowchart/state (handled by parseMermaid internally) + return 'flowchart' +} + +/** + * Render Mermaid diagram text to an ASCII/Unicode string. + * + * Synchronous — no async layout engine needed (unlike the SVG renderer). + * Auto-detects diagram type from the header line and dispatches to + * the appropriate renderer. + * + * @param text - Mermaid source text (any supported diagram type) + * @param options - Rendering options + * @returns Multi-line ASCII/Unicode string + * + * @example + * ```ts + * const result = renderMermaidAscii(` + * graph LR + * A --> B --> C + * `, { useAscii: true }) + * + * // Output: + * // +---+ +---+ +---+ + * // | | | | | | + * // | A |---->| B |---->| C | + * // | | | | | | + * // +---+ +---+ +---+ + * ``` + */ +export function renderMermaidASCII( + text: string, + options: AsciiRenderOptions = {}, +): string { + const config: AsciiConfig = { + useAscii: options.useAscii ?? false, + paddingX: options.paddingX ?? 5, + paddingY: options.paddingY ?? 5, + boxBorderPadding: options.boxBorderPadding ?? 1, + graphDirection: 'TD', // default, overridden for flowcharts below + } + + // Resolve color mode ('auto' or unset → detect environment, otherwise use specified mode) + const colorMode: ColorMode = options.colorMode === 'auto' || options.colorMode === undefined + ? detectColorMode() + : options.colorMode + + // Merge user theme with defaults + const theme: AsciiTheme = { ...DEFAULT_ASCII_THEME, ...options.theme } + + const diagramType = detectDiagramType(text) + + switch (diagramType) { + case 'xychart': + return renderXYChartAscii(text, config, colorMode, theme) + + case 'sequence': + return renderSequenceAscii(text, config, colorMode, theme) + + case 'class': + return renderClassAscii(text, config, colorMode, theme) + + case 'er': + return renderErAscii(text, config, colorMode, theme) + + case 'flowchart': + default: { + // 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). + if (parsed.direction === 'LR' || parsed.direction === 'RL') { + config.graphDirection = 'LR' + } else { + config.graphDirection = 'TD' + } + + const graph = convertToAsciiGraph(parsed, config) + createMapping(graph) + drawGraph(graph) + + // BT: flip the finished canvas vertically so the flow runs bottom→top. + // The grid layout ran as TD; flipping + character remapping produces BT. + if (parsed.direction === 'BT') { + flipCanvasVertically(graph.canvas) + flipRoleCanvasVertically(graph.roleCanvas) + } + + return canvasToString(graph.canvas, { + roleCanvas: graph.roleCanvas, + colorMode, + theme, + }) + } + } +} + +/** Lowercase alias kept as the public name used by the pi-utils wrapper. */ +export const renderMermaidAscii = renderMermaidASCII diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/multiline-utils.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/multiline-utils.ts new file mode 100644 index 000000000..f66791f96 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/multiline-utils.ts @@ -0,0 +1,78 @@ +// ============================================================================ +// ASCII renderer — multi-line text utilities +// +// Shared utilities for handling multi-line labels (containing \n from
tags) +// in ASCII/Unicode rendering. Provides consistent text splitting, sizing, and +// centered rendering across all diagram types. +// ============================================================================ + +import type { Canvas } from './types' +import { drawText } from './canvas' +import { displayWidth } from '../text-metrics' + +/** + * Split a label into lines. + * Labels are already normalized by parsers (br tags → \n). + */ +export function splitLines(label: string): string[] { + return label.split('\n') +} + +/** + * Get the maximum line width for sizing calculations. + * Used to determine column widths for multi-line labels. + */ +export function maxLineWidth(label: string): number { + const lines = splitLines(label) + return Math.max(...lines.map(l => displayWidth(l)), 0) +} + +/** + * Get the number of lines for height calculations. + * Used to determine row heights for multi-line labels. + */ +export function lineCount(label: string): number { + return splitLines(label).length +} + +/** + * Draw multi-line text centered at (cx, cy). + * Expands vertically from the center point. + * Each line is horizontally centered independently. + */ +export function drawMultilineTextCentered( + canvas: Canvas, + label: string, + cx: number, + cy: number +): void { + const lines = splitLines(label) + const totalHeight = lines.length + // Center vertically: start y positions lines evenly around cy + const startY = cy - Math.floor((totalHeight - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + // Center each line horizontally + 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) + } +} + +/** + * Draw multi-line text left-aligned starting at (x, y). + * Each subsequent line is placed one row below. + */ +export function drawMultilineTextLeft( + canvas: Canvas, + label: string, + x: number, + y: number +): void { + const lines = splitLines(label) + for (let i = 0; i < lines.length; i++) { + // Force overwrite for node labels (they take priority) + drawText(canvas, { x, y: y + i }, lines[i]!, true) + } +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/pathfinder.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/pathfinder.ts new file mode 100644 index 000000000..d7e40ddfc --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/pathfinder.ts @@ -0,0 +1,215 @@ +// ============================================================================ +// ASCII renderer — A* pathfinding for edge routing +// +// Ported from AlexanderGrooff/mermaid-ascii cmd/arrow.go. +// Uses A* search with a corner-penalizing heuristic to find clean +// paths between nodes on the grid. Prefers straight lines over zigzags. +// ============================================================================ + +import type { GridCoord, AsciiNode } from './types' +import { gridKey, gridCoordEquals } from './types' + +// ============================================================================ +// Priority queue (min-heap) for A* open set +// ============================================================================ + +interface PQItem { + coord: GridCoord + priority: number +} + +/** + * Simple min-heap priority queue. + * For the grid sizes we handle (~100s of cells), this is more than fast enough. + */ +class MinHeap { + private items: PQItem[] = [] + + get length(): number { + return this.items.length + } + + push(item: PQItem): void { + this.items.push(item) + this.bubbleUp(this.items.length - 1) + } + + pop(): PQItem | undefined { + if (this.items.length === 0) return undefined + const top = this.items[0]! + const last = this.items.pop()! + if (this.items.length > 0) { + this.items[0] = last + this.sinkDown(0) + } + return top + } + + private bubbleUp(i: number): void { + while (i > 0) { + const parent = (i - 1) >> 1 + if (this.items[i]!.priority < this.items[parent]!.priority) { + ;[this.items[i], this.items[parent]] = [this.items[parent]!, this.items[i]!] + i = parent + } else { + break + } + } + } + + private sinkDown(i: number): void { + const n = this.items.length + while (true) { + let smallest = i + const left = 2 * i + 1 + const right = 2 * i + 2 + if (left < n && this.items[left]!.priority < this.items[smallest]!.priority) { + smallest = left + } + if (right < n && this.items[right]!.priority < this.items[smallest]!.priority) { + smallest = right + } + if (smallest !== i) { + ;[this.items[i], this.items[smallest]] = [this.items[smallest]!, this.items[i]!] + i = smallest + } else { + break + } + } + } +} + +// ============================================================================ +// A* heuristic +// ============================================================================ + +/** + * Manhattan distance with a +1 penalty when both dx and dy are non-zero. + * This encourages the pathfinder to prefer straight lines and minimize corners. + */ +export function heuristic(a: GridCoord, b: GridCoord): number { + const absX = Math.abs(a.x - b.x) + const absY = Math.abs(a.y - b.y) + if (absX === 0 || absY === 0) { + return absX + absY + } + return absX + absY + 1 +} + +// ============================================================================ +// A* pathfinding +// ============================================================================ + +/** 4-directional movement (no diagonals in grid pathfinding). */ +const MOVE_DIRS: GridCoord[] = [ + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 }, +] + +/** Check if a grid cell is unoccupied and has non-negative coordinates. */ +function isFreeInGrid(grid: Map, c: GridCoord): boolean { + if (c.x < 0 || c.y < 0) return false + return !grid.has(gridKey(c)) +} + +/** + * Find a path from `from` to `to` on the grid using A*. + * Returns the path as an array of GridCoords, or null if no path exists. + */ +export function getPath( + grid: Map, + from: GridCoord, + to: GridCoord, +): GridCoord[] | null { + const pq = new MinHeap() + pq.push({ coord: from, priority: 0 }) + + const costSoFar = new Map() + costSoFar.set(gridKey(from), 0) + + const cameFrom = new Map() + cameFrom.set(gridKey(from), null) + + while (pq.length > 0) { + const current = pq.pop()!.coord + + if (gridCoordEquals(current, to)) { + // Reconstruct path by walking backwards through cameFrom + const path: GridCoord[] = [] + let c: GridCoord | null = current + while (c !== null) { + path.unshift(c) + c = cameFrom.get(gridKey(c)) ?? null + } + return path + } + + const currentCost = costSoFar.get(gridKey(current))! + + for (const dir of MOVE_DIRS) { + const next: GridCoord = { x: current.x + dir.x, y: current.y + dir.y } + + // Allow moving to the destination even if it's occupied (it's a node boundary) + if (!isFreeInGrid(grid, next) && !gridCoordEquals(next, to)) { + continue + } + + const newCost = currentCost + 1 + const nextKey = gridKey(next) + const existingCost = costSoFar.get(nextKey) + + if (existingCost === undefined || newCost < existingCost) { + costSoFar.set(nextKey, newCost) + const priority = newCost + heuristic(next, to) + pq.push({ coord: next, priority }) + cameFrom.set(nextKey, current) + } + } + } + + return null // No path found +} + +/** + * Simplify a path by removing intermediate waypoints on straight segments. + * E.g., [(0,0), (1,0), (2,0), (2,1)] becomes [(0,0), (2,0), (2,1)]. + * This reduces the number of line-drawing operations. + */ +export function mergePath(path: GridCoord[]): GridCoord[] { + if (path.length <= 2) return path + + const toRemove = new Set() + let step0 = path[0]! + let step1 = path[1]! + + for (let idx = 2; idx < path.length; idx++) { + const step2 = path[idx]! + const prevDx = step1.x - step0.x + const prevDy = step1.y - step0.y + const dx = step2.x - step1.x + const dy = step2.y - step1.y + + // Same direction — the middle point is redundant + if (prevDx === dx && prevDy === dy) { + // In Go: indexToRemove = append(indexToRemove, idx+1) but idx is 0-based from path[2:] + // which corresponds to index idx in the full path. Go uses idx+1 because idx iterates + // from 0 in the [2:] slice, mapping to full-array index idx+1. + // Actually re-checking Go code: the loop is `for idx, step2 := range path[2:]` + // so idx=0 → path[2], and it removes idx+1 which is index 1 in the full array. + // Wait, that doesn't look right. Let me re-read: + // step0 = path[0], step1 = path[1] + // for idx, step2 := range path[2:] { ... indexToRemove = append(indexToRemove, idx+1) ... } + // When idx=0, step2=path[2], and it removes index 1 (step1 = path[1]) if directions match + // So it removes the middle point (step1) which is at index idx+1 in the original array + // when counting from the 2-ahead loop. Let me just track which middle indices to remove. + toRemove.add(idx - 1) // Remove the middle point (step1's position) + } + + step0 = step1 + step1 = step2 + } + + return path.filter((_, i) => !toRemove.has(i)) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/sequence.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/sequence.ts new file mode 100644 index 000000000..619678b4e --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/sequence.ts @@ -0,0 +1,460 @@ +// ============================================================================ +// ASCII renderer — sequence diagrams +// +// Renders sequenceDiagram text to ASCII/Unicode art using a column-based layout. +// Each actor occupies a column with a vertical lifeline; messages are horizontal +// arrows between lifelines. Blocks (loop/alt/opt/par) wrap around message groups. +// +// Layout is fundamentally different from flowcharts — no grid or A* pathfinding. +// Instead: actors → columns, messages → rows, all positioned linearly. +// ============================================================================ + +import { parseSequenceDiagram } from '../sequence/parser' +import type { SequenceDiagram, Block } from '../sequence/types' +import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types' +import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas' +import { splitLines, maxLineWidth, lineCount } from './multiline-utils' +import { displayWidth, toCells, WIDE_PAD } from '../text-metrics' + +/** Classify a box-drawing character as 'border' or 'text'. */ +function classifyBoxChar(ch: string): CharRole { + if (/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|]$/.test(ch)) return 'border' + return 'text' +} + +/** + * Render a Mermaid sequence diagram to ASCII/Unicode text. + * + * Pipeline: parse → layout (columns + rows) → draw onto canvas → string. + */ +export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode?: ColorMode, theme?: AsciiTheme): string { + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) + const diagram = parseSequenceDiagram(lines) + + if (diagram.actors.length === 0) return '' + + const useAscii = config.useAscii + + // Box-drawing characters + const H = useAscii ? '-' : '─' + const V = useAscii ? '|' : '│' + const TL = useAscii ? '+' : '┌' + const TR = useAscii ? '+' : '┐' + const BL = useAscii ? '+' : '└' + const BR = useAscii ? '+' : '┘' + const JT = useAscii ? '+' : '┬' // top junction on lifeline + const JB = useAscii ? '+' : '┴' // bottom junction on lifeline + const JL = useAscii ? '+' : '├' // left junction + const JR = useAscii ? '+' : '┤' // right junction + + // ---- LAYOUT: compute lifeline X positions ---- + + const actorIdx = new Map() + diagram.actors.forEach((a, i) => actorIdx.set(a.id, i)) + + const boxPad = 1 + // Use max line width for multi-line actor labels + const actorBoxWidths = diagram.actors.map(a => maxLineWidth(a.label) + 2 * boxPad + 2) + const halfBox = actorBoxWidths.map(w => Math.ceil(w / 2)) + // Calculate actor box heights based on number of lines in label + const actorBoxHeights = diagram.actors.map(a => lineCount(a.label) + 2) // lines + top/bottom border + const actorBoxH = Math.max(...actorBoxHeights, 3) // Use max height for consistent lifeline positioning + + // Compute minimum gap between adjacent lifelines based on message labels. + // For messages spanning multiple actors, distribute the required width across gaps. + const adjMaxWidth: number[] = new Array(Math.max(diagram.actors.length - 1, 0)).fill(0) + + for (const msg of diagram.messages) { + const fi = actorIdx.get(msg.from)! + const ti = actorIdx.get(msg.to)! + if (fi === ti) continue // self-messages don't affect spacing + const lo = Math.min(fi, ti) + const hi = Math.max(fi, ti) + // Required gap per span = (max line width + arrow decorations) / number of gaps + const needed = maxLineWidth(msg.label) + 4 + const numGaps = hi - lo + const perGap = Math.ceil(needed / numGaps) + for (let g = lo; g < hi; g++) { + adjMaxWidth[g] = Math.max(adjMaxWidth[g]!, perGap) + } + } + + // Compute lifeline x-positions (greedy left-to-right) + const llX: number[] = [halfBox[0]!] + for (let i = 1; i < diagram.actors.length; i++) { + const gap = Math.max( + halfBox[i - 1]! + halfBox[i]! + 2, + adjMaxWidth[i - 1]! + 2, + 10, + ) + llX[i] = llX[i - 1]! + gap + } + + // ---- LAYOUT: compute vertical positions for messages ---- + + // For each message index, track the y where its arrow is drawn. + // Also track block start/end y positions and divider y positions. + const msgArrowY: number[] = [] + const msgLabelY: number[] = [] + const blockStartY = new Map() + const blockEndY = new Map() + const divYMap = new Map() // "blockIdx:divIdx" → y + const notePositions: Array<{ x: number; y: number; width: number; height: number; lines: string[] }> = [] + + let curY = actorBoxH // start right below header boxes + + for (let m = 0; m < diagram.messages.length; m++) { + // Block openings at this message + for (let b = 0; b < diagram.blocks.length; b++) { + if (diagram.blocks[b]!.startIndex === m) { + curY += 2 // 1 blank + 1 header row + blockStartY.set(b, curY - 1) + } + } + + // Dividers at this message index + for (let b = 0; b < diagram.blocks.length; b++) { + for (let d = 0; d < diagram.blocks[b]!.dividers.length; d++) { + if (diagram.blocks[b]!.dividers[d]!.index === m) { + curY += 1 + divYMap.set(`${b}:${d}`, curY) + curY += 1 + } + } + } + + curY += 1 // blank row before message + + const msg = diagram.messages[m]! + const isSelf = msg.from === msg.to + + // Calculate height needed for multi-line message labels + const msgLineCount = lineCount(msg.label) + + if (isSelf) { + // Self-message occupies 3+ rows: top-arm, label-col(s), bottom-arm + msgLabelY[m] = curY + 1 + msgArrowY[m] = curY + curY += 2 + msgLineCount // top-arm + label lines + bottom-arm + } else { + // Normal message: label row(s) then arrow row + msgLabelY[m] = curY + msgArrowY[m] = curY + msgLineCount // arrow goes after all label lines + curY += msgLineCount + 1 // label lines + arrow row + } + + // Notes after this message + for (let n = 0; n < diagram.notes.length; n++) { + if (diagram.notes[n]!.afterIndex === m) { + curY += 1 + const note = diagram.notes[n]! + const nLines = splitLines(note.text) + const nWidth = Math.max(...nLines.map(l => displayWidth(l))) + 4 + const nHeight = nLines.length + 2 + + // Determine x position based on note.position + const aIdx = actorIdx.get(note.actorIds[0]!) ?? 0 + let nx: number + if (note.position === 'left') { + nx = llX[aIdx]! - nWidth - 1 + } else if (note.position === 'right') { + nx = llX[aIdx]! + 2 + } else { + // 'over' — center over actor(s) + if (note.actorIds.length >= 2) { + const aIdx2 = actorIdx.get(note.actorIds[1]!) ?? aIdx + nx = Math.floor((llX[aIdx]! + llX[aIdx2]!) / 2) - Math.floor(nWidth / 2) + } else { + nx = llX[aIdx]! - Math.floor(nWidth / 2) + } + } + nx = Math.max(0, nx) + + notePositions.push({ x: nx, y: curY, width: nWidth, height: nHeight, lines: nLines }) + curY += nHeight + } + } + + // Block closings after this message + for (let b = 0; b < diagram.blocks.length; b++) { + if (diagram.blocks[b]!.endIndex === m) { + curY += 1 + blockEndY.set(b, curY) + curY += 1 + } + } + } + + curY += 1 // gap before footer + const footerY = curY + const totalH = footerY + actorBoxH + + // Total canvas width + const lastLL = llX[llX.length - 1] ?? 0 + const lastHalf = halfBox[halfBox.length - 1] ?? 0 + let totalW = lastLL + lastHalf + 2 + + // Ensure canvas is wide enough for self-message labels and notes + for (let m = 0; m < diagram.messages.length; m++) { + const msg = diagram.messages[m]! + if (msg.from === msg.to) { + const fi = actorIdx.get(msg.from)! + const selfRight = llX[fi]! + 6 + 2 + displayWidth(msg.label) + totalW = Math.max(totalW, selfRight + 1) + } + } + for (const np of notePositions) { + totalW = Math.max(totalW, np.x + np.width + 1) + } + + const canvas = mkCanvas(totalW, totalH - 1) + const rc = mkRoleCanvas(totalW, totalH - 1) + + /** Set a character on the canvas and track its role. */ + function setC(x: number, y: number, ch: string, role: CharRole): void { + if (x >= 0 && x < canvas.length && y >= 0 && y < (canvas[0]?.length ?? 0)) { + canvas[x]![y] = ch + setRole(rc, x, y, role) + } + } + + /** + * 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 { + const lines = splitLines(label) + const maxW = maxLineWidth(label) + const w = maxW + 2 * boxPad + 2 + const h = lines.length + 2 // lines + top/bottom border + const left = cx - Math.floor(w / 2) + + // Top border + setC(left, topY, TL, 'border') + for (let x = 1; x < w - 1; x++) setC(left + x, topY, H, 'border') + setC(left + w - 1, topY, TR, 'border') + + // Content lines (centered horizontally within the box) + for (let i = 0; i < lines.length; i++) { + const row = topY + 1 + i + setC(left, row, V, 'border') + setC(left + w - 1, row, V, 'border') + // Center this line within the box + const line = lines[i]! + const cells = toCells(line) + const ls = left + 1 + boxPad + Math.floor((maxW - cells.length) / 2) + setCells(ls, row, cells, 'text') + } + + // Bottom border + const bottomY = topY + h - 1 + setC(left, bottomY, BL, 'border') + for (let x = 1; x < w - 1; x++) setC(left + x, bottomY, H, 'border') + setC(left + w - 1, bottomY, BR, 'border') + } + + // ---- DRAW: lifelines ---- + + for (let i = 0; i < diagram.actors.length; i++) { + const x = llX[i]! + for (let y = actorBoxH; y <= footerY; y++) { + setC(x, y, V, 'line') + } + } + + // ---- DRAW: actor header + footer boxes (drawn over lifelines) ---- + + for (let i = 0; i < diagram.actors.length; i++) { + const actor = diagram.actors[i]! + drawActorBox(llX[i]!, 0, actor.label) + drawActorBox(llX[i]!, footerY, actor.label) + + // Lifeline junctions on box borders (Unicode only) + if (!useAscii) { + setC(llX[i]!, actorBoxH - 1, JT, 'junction') + setC(llX[i]!, footerY, JB, 'junction') + } + } + + // ---- DRAW: messages ---- + + for (let m = 0; m < diagram.messages.length; m++) { + const msg = diagram.messages[m]! + const fi = actorIdx.get(msg.from)! + const ti = actorIdx.get(msg.to)! + const fromX = llX[fi]! + const toX = llX[ti]! + const isSelf = fi === ti + const isDashed = msg.lineStyle === 'dashed' + const isFilled = msg.arrowHead === 'filled' + + // Arrow line character (solid vs dashed) + const lineChar = isDashed ? (useAscii ? '.' : '╌') : H + + if (isSelf) { + // Self-message: 3-row loop to the right of the lifeline + // ├──┐ (row 0 = msgArrowY) + // │ │ Label (row 1) + // │◄─┘ (row 2) + const y0 = msgArrowY[m]! + const loopW = Math.max(4, 4) + + // Row 0: start junction + horizontal + top-right corner + setC(fromX, y0, JL, 'junction') + for (let x = fromX + 1; x < fromX + loopW; x++) setC(x, y0, lineChar, 'line') + setC(fromX + loopW, y0, useAscii ? '+' : '┐', 'corner') + + // Row 1: vertical on right side + label + setC(fromX + loopW, y0 + 1, V, 'line') + const labelX = fromX + loopW + 2 + 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 ? '<' : '◁') + setC(fromX, y0 + 2, arrowChar, 'arrow') + for (let x = fromX + 1; x < fromX + loopW; x++) setC(x, y0 + 2, lineChar, 'line') + setC(fromX + loopW, y0 + 2, useAscii ? '+' : '┘', 'corner') + } else { + // Normal message: label on row above, arrow on row below + const labelY = msgLabelY[m]! + const arrowY = msgArrowY[m]! + const leftToRight = fromX < toX + + // Draw label centered between the two lifelines (supports multi-line) + const midX = Math.floor((fromX + toX) / 2) + const msgLines = splitLines(msg.label) + + for (let lineIdx = 0; lineIdx < msgLines.length; lineIdx++) { + const cells = toCells(msgLines[lineIdx]!) + const labelStart = midX - Math.floor(cells.length / 2) + const y = labelY + lineIdx + setCells(labelStart, y, cells, 'text', 0, totalW) + } + + // Draw arrow line + if (leftToRight) { + for (let x = fromX + 1; x < toX; x++) setC(x, arrowY, lineChar, 'line') + // Arrowhead at destination + const ah = isFilled ? (useAscii ? '>' : '▶') : (useAscii ? '>' : '▷') + setC(toX, arrowY, ah, 'arrow') + } else { + for (let x = toX + 1; x < fromX; x++) setC(x, arrowY, lineChar, 'line') + const ah = isFilled ? (useAscii ? '<' : '◀') : (useAscii ? '<' : '◁') + setC(toX, arrowY, ah, 'arrow') + } + } + } + + // ---- DRAW: blocks (loop, alt, opt, par, etc.) ---- + + for (let b = 0; b < diagram.blocks.length; b++) { + const block = diagram.blocks[b]! + const topY = blockStartY.get(b) + const botY = blockEndY.get(b) + if (topY === undefined || botY === undefined) continue + + // Find the leftmost/rightmost lifelines involved in this block's messages + let minLX = totalW + let maxLX = 0 + for (let m = block.startIndex; m <= block.endIndex; m++) { + if (m >= diagram.messages.length) break + const msg = diagram.messages[m]! + const f = actorIdx.get(msg.from) ?? 0 + const t = actorIdx.get(msg.to) ?? 0 + minLX = Math.min(minLX, llX[Math.min(f, t)]!) + maxLX = Math.max(maxLX, llX[Math.max(f, t)]!) + } + + const bLeft = Math.max(0, minLX - 4) + const bRight = Math.min(totalW - 1, maxLX + 4) + + // Top border with block type label + setC(bLeft, topY, TL, 'border') + for (let x = bLeft + 1; x < bRight; x++) setC(x, topY, H, 'border') + setC(bRight, topY, TR, 'border') + // Write block header label over the top border (supports multi-line) + 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 cells = toCells(hdrLines[lineIdx]!) + setCells(bLeft + 1, topY + lineIdx, cells, 'text', bLeft + 1, bRight) + } + + // Bottom border + setC(bLeft, botY, BL, 'border') + for (let x = bLeft + 1; x < bRight; x++) setC(x, botY, H, 'border') + setC(bRight, botY, BR, 'border') + + // Side borders + for (let y = topY + 1; y < botY; y++) { + setC(bLeft, y, V, 'border') + setC(bRight, y, V, 'border') + } + + // Dividers + for (let d = 0; d < block.dividers.length; d++) { + const dY = divYMap.get(`${b}:${d}`) + if (dY === undefined) continue + const dashChar = isDashedH() + setC(bLeft, dY, JL, 'junction') + for (let x = bLeft + 1; x < bRight; x++) setC(x, dY, dashChar, 'line') + setC(bRight, dY, JR, 'junction') + // Divider label + const dLabel = block.dividers[d]!.label + if (dLabel) { + const dCells = toCells(`[${dLabel}]`) + setCells(bLeft + 1, dY, dCells, 'text', bLeft + 1, bRight) + } + } + } + + // ---- DRAW: notes ---- + + for (const np of notePositions) { + // Ensure canvas is big enough + increaseSize(canvas, np.x + np.width, np.y + np.height) + increaseRoleCanvasSize(rc, np.x + np.width, np.y + np.height) + // Top border + setC(np.x, np.y, TL, 'border') + for (let x = 1; x < np.width - 1; x++) setC(np.x + x, np.y, H, 'border') + setC(np.x + np.width - 1, np.y, TR, 'border') + // Content rows + for (let l = 0; l < np.lines.length; l++) { + const ly = np.y + 1 + l + setC(np.x, ly, V, 'border') + setC(np.x + np.width - 1, ly, V, 'border') + const cells = toCells(np.lines[l]!) + setCells(np.x + 2, ly, cells, 'text') + } + // Bottom border + const by = np.y + np.height - 1 + setC(np.x, by, BL, 'border') + for (let x = 1; x < np.width - 1; x++) setC(np.x + x, by, H, 'border') + setC(np.x + np.width - 1, by, BR, 'border') + } + + return canvasToString(canvas, { roleCanvas: rc, colorMode, theme }) + + // ---- Helper: dashed horizontal character ---- + function isDashedH(): string { + return useAscii ? '-' : '╌' + } +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/circle.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/circle.ts new file mode 100644 index 000000000..ecfe6ad96 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/circle.ts @@ -0,0 +1,27 @@ +// ============================================================================ +// Circle shape renderer — uses corner decorators instead of curves +// ============================================================================ + +import type { ShapeRenderer } from './types' +import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle' +import { getCorners } from './corners' + +/** + * Circle shape renderer. + * Uses circle markers (◯) at corners to indicate circular shape semantics. + * + * Renders as: + * ◯─────────◯ + * │ Label │ + * ◯─────────◯ + */ +export const circleRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('circle', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/corners.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/corners.ts new file mode 100644 index 000000000..8bcd50f38 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/corners.ts @@ -0,0 +1,127 @@ +// ============================================================================ +// Corner character lookup table for shape rendering +// ============================================================================ +// +// All shapes are rendered as rectangles with distinctive corner characters +// to indicate shape type. This eliminates diagonal characters while keeping +// shapes visually distinguishable. + +import type { AsciiNodeShape } from '../types' + +/** + * Corner characters for a shape in both Unicode and ASCII modes. + */ +export interface CornerChars { + /** Top-left corner */ + tl: string + /** Top-right corner */ + tr: string + /** Bottom-left corner */ + bl: string + /** Bottom-right corner */ + br: string +} + +/** + * Shape corner configuration with both Unicode and ASCII variants. + */ +export interface ShapeCorners { + unicode: CornerChars + ascii: CornerChars +} + +/** + * Corner character lookup table for all shape types. + * + * Design principles: + * - All shapes use orthogonal box structure (no diagonals) + * - Corner characters indicate shape semantics + * - ASCII fallbacks use available punctuation + */ +export const SHAPE_CORNERS: Record = { + // Standard rectangular shapes + rectangle: { + unicode: { tl: '┌', tr: '┐', bl: '└', br: '┘' }, + ascii: { tl: '+', tr: '+', bl: '+', br: '+' }, + }, + rounded: { + unicode: { tl: '╭', tr: '╮', bl: '╰', br: '╯' }, + ascii: { tl: '.', tr: '.', bl: "'", br: "'" }, + }, + + // Circular shapes - use circle markers at corners + circle: { + unicode: { tl: '◯', tr: '◯', bl: '◯', br: '◯' }, + ascii: { tl: 'o', tr: 'o', bl: 'o', br: 'o' }, + }, + doublecircle: { + unicode: { tl: '◎', tr: '◎', bl: '◎', br: '◎' }, + ascii: { tl: '@', tr: '@', bl: '@', br: '@' }, + }, + + // Diamond - decision nodes + diamond: { + unicode: { tl: '◇', tr: '◇', bl: '◇', br: '◇' }, + ascii: { tl: '<', tr: '>', bl: '<', br: '>' }, + }, + + // Hexagon - process nodes (crop corners — monospace-safe, distinct from rectangle) + hexagon: { + unicode: { tl: '⌜', tr: '⌝', bl: '⌞', br: '⌟' }, + ascii: { tl: '*', tr: '*', bl: '*', br: '*' }, + }, + + // Stadium/pill shape + stadium: { + unicode: { tl: '(', tr: ')', bl: '(', br: ')' }, + ascii: { tl: '(', tr: ')', bl: '(', br: ')' }, + }, + + // Subroutine - double vertical bars + subroutine: { + unicode: { tl: '╟', tr: '╢', bl: '╟', br: '╢' }, + ascii: { tl: '|', tr: '|', bl: '|', br: '|' }, + }, + + // Cylinder/database + cylinder: { + unicode: { tl: '╭', tr: '╮', bl: '╰', br: '╯' }, + ascii: { tl: '.', tr: '.', bl: "'", br: "'" }, + }, + + // Asymmetric/flag - pointer on left side + asymmetric: { + unicode: { tl: '▷', tr: '┐', bl: '▷', br: '┘' }, + ascii: { tl: '>', tr: '+', bl: '>', br: '+' }, + }, + + // Trapezoid - wider at bottom (top corners slope inward) + trapezoid: { + unicode: { tl: '/', tr: '\\', bl: '└', br: '┘' }, + ascii: { tl: '/', tr: '\\', bl: '+', br: '+' }, + }, + + // Trapezoid-alt - wider at top (bottom corners slope inward) + 'trapezoid-alt': { + unicode: { tl: '┌', tr: '┐', bl: '\\', br: '/' }, + ascii: { tl: '+', tr: '+', bl: '\\', br: '/' }, + }, + + // State diagram pseudostates (special handling, not corner-based) + 'state-start': { + unicode: { tl: '●', tr: '●', bl: '●', br: '●' }, + ascii: { tl: '*', tr: '*', bl: '*', br: '*' }, + }, + 'state-end': { + unicode: { tl: '◉', tr: '◉', bl: '◉', br: '◉' }, + ascii: { tl: '@', tr: '@', bl: '@', br: '@' }, + }, +} + +/** + * Get corner characters for a shape type. + */ +export function getCorners(shape: AsciiNodeShape, useAscii: boolean): CornerChars { + const corners = SHAPE_CORNERS[shape] ?? SHAPE_CORNERS.rectangle + return useAscii ? corners.ascii : corners.unicode +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts new file mode 100644 index 000000000..7c54c7787 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts @@ -0,0 +1,27 @@ +// ============================================================================ +// Diamond shape renderer — uses corner decorators instead of diagonals +// ============================================================================ + +import type { ShapeRenderer } from './types' +import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle' +import { getCorners } from './corners' + +/** + * Diamond shape renderer. + * Uses diamond markers (◇) at corners to indicate decision node semantics. + * + * Renders as: + * ◇─────────◇ + * │ Label │ + * ◇─────────◇ + */ +export const diamondRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('diamond', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts new file mode 100644 index 000000000..fe22b9bf5 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts @@ -0,0 +1,27 @@ +// ============================================================================ +// Hexagon shape renderer — uses corner decorators instead of diagonals +// ============================================================================ + +import type { ShapeRenderer } from './types' +import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle' +import { getCorners } from './corners' + +/** + * Hexagon shape renderer. + * Uses hexagon markers (⬡) at corners to indicate process node semantics. + * + * Renders as: + * ⬡─────────⬡ + * │ Label │ + * ⬡─────────⬡ + */ +export const hexagonRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('hexagon', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/index.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/index.ts new file mode 100644 index 000000000..c505c0347 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/index.ts @@ -0,0 +1,101 @@ +// ============================================================================ +// Shape registry — pluggable ASCII shape renderers +// ============================================================================ + +import type { AsciiNodeShape, Canvas, DrawingCoord, Direction } from '../types' +import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions, ShapeRegistry } from './types' + +// Import all shape renderers +import { rectangleRenderer } from './rectangle' +import { diamondRenderer } from './diamond' +import { circleRenderer } from './circle' +import { stateStartRenderer, stateEndRenderer } from './state' +import { roundedRenderer } from './rounded' +import { stadiumRenderer } from './stadium' +import { hexagonRenderer } from './hexagon' +import { + subroutineRenderer, + doublecircleRenderer, + cylinderRenderer, + asymmetricRenderer, + trapezoidRenderer, + trapezoidAltRenderer, +} from './special' + +// Re-export types +export type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions, ShapeRegistry } + +/** + * Global shape registry — maps shape types to their renderers. + * Rectangle is the default fallback for unregistered shapes. + */ +export const shapeRegistry: ShapeRegistry = new Map([ + // Core shapes + ['rectangle', rectangleRenderer], + ['rounded', roundedRenderer], + ['diamond', diamondRenderer], + ['stadium', stadiumRenderer], + ['circle', circleRenderer], + + // Batch 1 additions + ['subroutine', subroutineRenderer], + ['doublecircle', doublecircleRenderer], + ['hexagon', hexagonRenderer], + + // Batch 2 additions + ['cylinder', cylinderRenderer], + ['asymmetric', asymmetricRenderer], + ['trapezoid', trapezoidRenderer], + ['trapezoid-alt', trapezoidAltRenderer], + + // State diagram pseudo-states + ['state-start', stateStartRenderer], + ['state-end', stateEndRenderer], +]) + +/** + * Get the renderer for a shape type, falling back to rectangle. + */ +export function getShapeRenderer(shape: AsciiNodeShape): ShapeRenderer { + return shapeRegistry.get(shape) ?? rectangleRenderer +} + +/** + * Render a node shape to a canvas. + * This is the main entry point for shape rendering. + */ +export function renderShape( + shape: AsciiNodeShape, + label: string, + options: ShapeRenderOptions +): Canvas { + const renderer = getShapeRenderer(shape) + const dimensions = renderer.getDimensions(label, options) + return renderer.render(label, dimensions, options) +} + +/** + * Get dimensions for a shape given a label. + * Used during layout to determine node size. + */ +export function getShapeDimensions( + shape: AsciiNodeShape, + label: string, + options: ShapeRenderOptions +): ShapeDimensions { + const renderer = getShapeRenderer(shape) + return renderer.getDimensions(label, options) +} + +/** + * Get edge attachment point for a shape. + */ +export function getShapeAttachmentPoint( + shape: AsciiNodeShape, + dir: Direction, + dimensions: ShapeDimensions, + baseCoord: DrawingCoord +): DrawingCoord { + const renderer = getShapeRenderer(shape) + return renderer.getAttachmentPoint(dir, dimensions, baseCoord) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts new file mode 100644 index 000000000..118607b54 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts @@ -0,0 +1,175 @@ +// ============================================================================ +// Rectangle shape renderer — standard box with corners +// ============================================================================ +// +// This module provides the base box rendering used by all rectangular shapes. +// The renderBox() function accepts custom corner characters, allowing different +// shapes to reuse the same rendering logic with different visual markers. + +import type { Canvas, DrawingCoord, Direction } from '../types' +import { Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight, Middle } from '../types' +import { mkCanvas } from '../canvas' +import { splitLines } from '../multiline-utils' +import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types' +import { dirEquals } from '../edge-routing' +import { type CornerChars, getCorners } from './corners' +import { displayWidth, toCells } from '../../text-metrics' + +// ============================================================================ +// Shared dimension calculation +// ============================================================================ + +/** + * Calculate standard box dimensions for any rectangular shape. + * Used by rectangle, circle, diamond, hexagon, etc. + */ +export function getBoxDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { + const lines = splitLines(label) + const maxLineWidth = Math.max(...lines.map(l => displayWidth(l)), 0) + const lineCount = lines.length + + // Width: 2*padding + maxLineWidth + 2 border chars + const innerWidth = 2 * options.padding + maxLineWidth + const width = innerWidth + 2 + + // Height: lineCount + 2*padding + 2 border chars + // Ensure innerHeight is odd for symmetric vertical centering + const rawInnerHeight = lineCount + 2 * options.padding + const innerHeight = rawInnerHeight % 2 === 0 ? rawInnerHeight + 1 : rawInnerHeight + const height = innerHeight + 2 + + return { + width, + height, + labelArea: { + x: 1 + options.padding, + y: 1 + options.padding, + width: maxLineWidth, + height: lineCount, + }, + // Grid layout: [border=1, content, border=1] + gridColumns: [1, innerWidth, 1], + gridRows: [1, innerHeight, 1], + } +} + +// ============================================================================ +// Shared box rendering +// ============================================================================ + +/** + * Render a box with custom corner characters. + * This is the core rendering function used by all rectangular shapes. + * + * @param label - Text to display in the box + * @param dimensions - Pre-calculated dimensions + * @param corners - Corner characters (tl, tr, bl, br) + * @param useAscii - Whether to use ASCII or Unicode for lines + */ +export function renderBox( + label: string, + dimensions: ShapeDimensions, + corners: CornerChars, + useAscii: boolean +): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const from = { x: 0, y: 0 } + const to = { x: width - 1, y: height - 1 } + + // Line characters + const hLine = useAscii ? '-' : '─' + const vLine = useAscii ? '|' : '│' + + // Draw horizontal lines (top and bottom) + for (let x = from.x + 1; x < to.x; x++) { + canvas[x]![from.y] = hLine + canvas[x]![to.y] = hLine + } + + // Draw vertical lines (left and right) + for (let y = from.y + 1; y < to.y; y++) { + canvas[from.x]![y] = vLine + canvas[to.x]![y] = vLine + } + + // Draw corners + canvas[from.x]![from.y] = corners.tl + canvas[to.x]![from.y] = corners.tr + canvas[from.x]![to.y] = corners.bl + canvas[to.x]![to.y] = corners.br + + // Center the multi-line label + const lines = splitLines(label) + const w = width - 1 // Match original grid-based width calculation + const h = height - 1 + const centerY = Math.floor(h / 2) + const startY = centerY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + 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] = cells[j]! + } + } + } + + return canvas +} + +// ============================================================================ +// Shared attachment point calculation +// ============================================================================ + +/** + * Calculate edge attachment point for rectangular shapes. + * All box-based shapes use the same attachment logic. + */ +export function getBoxAttachmentPoint( + dir: Direction, + dimensions: ShapeDimensions, + baseCoord: DrawingCoord +): DrawingCoord { + const { width, height } = dimensions + const centerX = baseCoord.x + Math.floor(width / 2) + const centerY = baseCoord.y + Math.floor(height / 2) + + if (dirEquals(dir, Up)) return { x: centerX, y: baseCoord.y } + if (dirEquals(dir, Down)) return { x: centerX, y: baseCoord.y + height - 1 } + if (dirEquals(dir, Left)) return { x: baseCoord.x, y: centerY } + if (dirEquals(dir, Right)) return { x: baseCoord.x + width - 1, y: centerY } + if (dirEquals(dir, UpperLeft)) return { x: baseCoord.x, y: baseCoord.y } + if (dirEquals(dir, UpperRight)) return { x: baseCoord.x + width - 1, y: baseCoord.y } + if (dirEquals(dir, LowerLeft)) return { x: baseCoord.x, y: baseCoord.y + height - 1 } + if (dirEquals(dir, LowerRight)) return { x: baseCoord.x + width - 1, y: baseCoord.y + height - 1 } + // Middle + return { x: centerX, y: centerY } +} + +// ============================================================================ +// Rectangle renderer +// ============================================================================ + +/** + * Rectangle shape renderer — the default box shape. + * Renders as: + * ┌─────────┐ + * │ Label │ + * └─────────┘ + */ +export const rectangleRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const corners = getCorners('rectangle', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts new file mode 100644 index 000000000..ab996ef84 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts @@ -0,0 +1,27 @@ +// ============================================================================ +// Rounded rectangle shape renderer — uses rounded corner decorators +// ============================================================================ + +import type { ShapeRenderer } from './types' +import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle' +import { getCorners } from './corners' + +/** + * Rounded rectangle shape renderer. + * Uses rounded corner markers (╭╮╰╯) to indicate soft edges. + * + * Renders as: + * ╭─────────╮ + * │ Label │ + * ╰─────────╯ + */ +export const roundedRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('rounded', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/special.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/special.ts new file mode 100644 index 000000000..af08c7795 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/special.ts @@ -0,0 +1,296 @@ +// ============================================================================ +// Special shape renderers — subroutine, doublecircle, cylinder, etc. +// ============================================================================ +// +// Some shapes have unique internal structure (subroutine, cylinder) and keep +// custom rendering. Others use the corner decorator pattern for simplicity. + +import type { Canvas, DrawingCoord, Direction } from '../types' +import { Up, Down, Left, Right } from '../types' +import { mkCanvas } from '../canvas' +import { splitLines } from '../multiline-utils' +import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types' +import { dirEquals } from '../edge-routing' +import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle' +import { getCorners } from './corners' +import { displayWidth, toCells } from '../../text-metrics' + +// ============================================================================ +// Subroutine — keeps custom double-border rendering +// ============================================================================ + +/** + * Subroutine shape renderer — double-bordered rectangle. + * Renders as: + * ┌┬─────────┬┐ + * ││ Label ││ + * └┴─────────┴┘ + */ +export const subroutineRenderer: ShapeRenderer = { + getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { + const lines = splitLines(label) + const maxLineWidth = Math.max(...lines.map(l => displayWidth(l)), 0) + const lineCount = lines.length + + const innerWidth = 2 * options.padding + maxLineWidth + const width = innerWidth + 4 // Double borders on each side + const innerHeight = lineCount + 2 * options.padding + const height = innerHeight + 2 + + return { + width, + height, + labelArea: { + x: 2 + options.padding, + y: 1 + options.padding, + width: maxLineWidth, + height: lineCount, + }, + gridColumns: [2, innerWidth, 2], + gridRows: [1, innerHeight, 1], + } + }, + + render(label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const hChar = options.useAscii ? '-' : '─' + const vChar = options.useAscii ? '|' : '│' + + // Top border + canvas[0]![0] = options.useAscii ? '+' : '┌' + canvas[1]![0] = options.useAscii ? '+' : '┬' + for (let x = 2; x < width - 2; x++) canvas[x]![0] = hChar + canvas[width - 2]![0] = options.useAscii ? '+' : '┬' + canvas[width - 1]![0] = options.useAscii ? '+' : '┐' + + // Sides with double border + for (let y = 1; y < height - 1; y++) { + canvas[0]![y] = vChar + canvas[1]![y] = vChar + canvas[width - 2]![y] = vChar + canvas[width - 1]![y] = vChar + } + + // Bottom border + canvas[0]![height - 1] = options.useAscii ? '+' : '└' + canvas[1]![height - 1] = options.useAscii ? '+' : '┴' + for (let x = 2; x < width - 2; x++) canvas[x]![height - 1] = hChar + canvas[width - 2]![height - 1] = options.useAscii ? '+' : '┴' + canvas[width - 1]![height - 1] = options.useAscii ? '+' : '┘' + + // Center the label + const lines = splitLines(label) + const centerY = Math.floor(height / 2) + const startY = centerY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + 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] = cells[j]! + } + } + } + + return canvas + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} + +// ============================================================================ +// Double circle — uses corner decorators +// ============================================================================ + +/** + * Double circle shape renderer. + * Uses double circle markers (◎) at corners. + * + * Renders as: + * ◎─────────◎ + * │ Label │ + * ◎─────────◎ + */ +export const doublecircleRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('doublecircle', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} + +// ============================================================================ +// Cylinder — keeps custom rendering for database appearance +// ============================================================================ + +/** + * Cylinder shape renderer — database symbol. + * Renders as: + * ╭─────╮ + * │─────│ + * │ DB │ + * │─────│ + * ╰─────╯ + */ +export const cylinderRenderer: ShapeRenderer = { + getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { + const lines = splitLines(label) + const maxLineWidth = Math.max(...lines.map(l => displayWidth(l)), 0) + const lineCount = lines.length + + const innerWidth = 2 * options.padding + maxLineWidth + const width = innerWidth + 2 + const innerHeight = lineCount + 2 * options.padding + 2 // Extra for curved top/bottom + const height = innerHeight + 2 + + return { + width, + height, + labelArea: { + x: 1 + options.padding, + y: 2 + options.padding, + width: maxLineWidth, + height: lineCount, + }, + gridColumns: [1, innerWidth, 1], + gridRows: [2, innerHeight - 2, 2], + } + }, + + render(label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const hChar = options.useAscii ? '-' : '─' + const vChar = options.useAscii ? '|' : '│' + + // Top ellipse + canvas[0]![0] = options.useAscii ? '.' : '╭' + for (let x = 1; x < width - 1; x++) canvas[x]![0] = hChar + canvas[width - 1]![0] = options.useAscii ? '.' : '╮' + + // Second row - bottom of top ellipse + canvas[0]![1] = vChar + for (let x = 1; x < width - 1; x++) canvas[x]![1] = hChar + canvas[width - 1]![1] = vChar + + // Middle section + for (let y = 2; y < height - 2; y++) { + canvas[0]![y] = vChar + canvas[width - 1]![y] = vChar + } + + // Second to last row - top of bottom ellipse + canvas[0]![height - 2] = vChar + for (let x = 1; x < width - 1; x++) canvas[x]![height - 2] = hChar + canvas[width - 1]![height - 2] = vChar + + // Bottom ellipse + canvas[0]![height - 1] = options.useAscii ? '\'' : '╰' + for (let x = 1; x < width - 1; x++) canvas[x]![height - 1] = hChar + canvas[width - 1]![height - 1] = options.useAscii ? '\'' : '╯' + + // Center the label + const lines = splitLines(label) + const centerY = Math.floor(height / 2) + const startY = centerY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + 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] = cells[j]! + } + } + } + + return canvas + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} + +// ============================================================================ +// Asymmetric (flag) — uses corner decorators +// ============================================================================ + +/** + * Asymmetric (flag/banner) shape renderer. + * Uses arrow markers (▷) on left corners. + * + * Renders as: + * ▷─────────┐ + * │ Label │ + * ▷─────────┘ + */ +export const asymmetricRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('asymmetric', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} + +// ============================================================================ +// Trapezoid — uses corner decorators instead of diagonal sides +// ============================================================================ + +/** + * Trapezoid shape renderer — wider at bottom. + * Uses slope markers (◸◹) on top corners. + * + * Renders as: + * ◸─────────◹ + * │ Label │ + * └─────────┘ + */ +export const trapezoidRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('trapezoid', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} + +// ============================================================================ +// Trapezoid-alt — uses corner decorators instead of diagonal sides +// ============================================================================ + +/** + * Trapezoid-alt shape renderer — wider at top. + * Uses slope markers (◺◿) on bottom corners. + * + * Renders as: + * ┌─────────┐ + * │ Label │ + * ◺─────────◿ + */ +export const trapezoidAltRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('trapezoid-alt', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts new file mode 100644 index 000000000..f7a1a09de --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts @@ -0,0 +1,114 @@ +// ============================================================================ +// Stadium (pill) shape renderer — special parentheses-based rendering +// ============================================================================ +// +// Stadium has unique rendering: single-line is inline `(Label)`, multi-line +// uses parentheses or rounded corners. This differs from other shapes that +// use corner decorators with box lines. + +import type { Canvas, DrawingCoord, Direction } from '../types' +import { mkCanvas } from '../canvas' +import { splitLines } from '../multiline-utils' +import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types' +import { getBoxAttachmentPoint } from './rectangle' +import { displayWidth, toCells } from '../../text-metrics' + +/** + * Stadium (pill) shape renderer. + * + * Single-line: ( Label ) + * + * Multi-line unicode: + * ╭──────────╮ + * │ Label │ + * ╰──────────╯ + * + * Multi-line ASCII: + * (----------) + * ( Label ) + * (----------) + */ +export const stadiumRenderer: ShapeRenderer = { + getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { + const lines = splitLines(label) + const maxLineWidth = Math.max(...lines.map(l => displayWidth(l)), 0) + const lineCount = lines.length + + const innerWidth = 2 * options.padding + maxLineWidth + const width = innerWidth + 4 // Extra for rounded ends + const innerHeight = lineCount + 2 * options.padding + const height = Math.max(innerHeight + 2, 3) + + return { + width, + height, + labelArea: { + x: 2 + options.padding, + y: 1 + options.padding, + width: maxLineWidth, + height: lineCount, + }, + gridColumns: [2, innerWidth, 2], + gridRows: [1, innerHeight, 1], + } + }, + + render(label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const centerY = Math.floor(height / 2) + const hChar = options.useAscii ? '-' : '─' + + if (height === 3) { + // Single row pill: ( Label ) + canvas[0]![centerY] = '(' + canvas[width - 1]![centerY] = ')' + } else if (!options.useAscii) { + // Multi-row stadium with rounded corners (unicode) + canvas[0]![0] = '╭' + for (let x = 1; x < width - 1; x++) canvas[x]![0] = hChar + canvas[width - 1]![0] = '╮' + + for (let y = 1; y < height - 1; y++) { + canvas[0]![y] = '│' + canvas[width - 1]![y] = '│' + } + + canvas[0]![height - 1] = '╰' + for (let x = 1; x < width - 1; x++) canvas[x]![height - 1] = hChar + canvas[width - 1]![height - 1] = '╯' + } else { + // Multi-row stadium ASCII — parentheses on all sides + for (let y = 0; y < height; y++) { + canvas[0]![y] = '(' + canvas[width - 1]![y] = ')' + } + for (let x = 1; x < width - 1; x++) { + canvas[x]![0] = hChar + canvas[x]![height - 1] = hChar + } + } + + // Center the label + const lines = splitLines(label) + const startY = centerY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + 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] = cells[j]! + } + } + } + + return canvas + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/state.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/state.ts new file mode 100644 index 000000000..0e7cc7d59 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/state.ts @@ -0,0 +1,192 @@ +// ============================================================================ +// State pseudo-state renderers — UML start and end states +// ============================================================================ + +import type { Canvas, DrawingCoord, Direction } from '../types' +import { Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight } from '../types' +import { mkCanvas } from '../canvas' +import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types' +import { dirEquals } from '../edge-routing' + +/** + * State start pseudo-state renderer — filled circle in rounded box. + * Renders as: + * ╭───╮ + * │ ● │ (Unicode) + * ╰───╯ + * + * .---. + * | * | (ASCII) + * '---' + * + * This represents the UML initial pseudo-state. + */ +export const stateStartRenderer: ShapeRenderer = { + getDimensions(_label: string, _options: ShapeRenderOptions): ShapeDimensions { + // Start state is a 5x3 rounded box with centered symbol + const width = 5 + const height = 3 + + return { + width, + height, + labelArea: { x: 2, y: 1, width: 1, height: 1 }, + gridColumns: [1, 3, 1], + gridRows: [1, 1, 1], + } + }, + + render(_label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const centerX = Math.floor(width / 2) // = 2 + + if (!options.useAscii) { + // Unicode rounded box with filled circle: ╭───╮ │ ● │ ╰───╯ + canvas[0]![0] = '╭' + canvas[1]![0] = '─' + canvas[2]![0] = '─' + canvas[3]![0] = '─' + canvas[4]![0] = '╮' + + canvas[0]![1] = '│' + canvas[centerX]![1] = '●' + canvas[4]![1] = '│' + + canvas[0]![2] = '╰' + canvas[1]![2] = '─' + canvas[2]![2] = '─' + canvas[3]![2] = '─' + canvas[4]![2] = '╯' + } else { + // ASCII rounded box: .---. | * | '---' + canvas[0]![0] = '.' + canvas[1]![0] = '-' + canvas[2]![0] = '-' + canvas[3]![0] = '-' + canvas[4]![0] = '.' + + canvas[0]![1] = '|' + canvas[centerX]![1] = '*' + canvas[4]![1] = '|' + + canvas[0]![2] = '\'' + canvas[1]![2] = '-' + canvas[2]![2] = '-' + canvas[3]![2] = '-' + canvas[4]![2] = '\'' + } + + return canvas + }, + + getAttachmentPoint( + dir: Direction, + dimensions: ShapeDimensions, + baseCoord: DrawingCoord + ): DrawingCoord { + const { width, height } = dimensions + const centerX = baseCoord.x + Math.floor(width / 2) + const centerY = baseCoord.y + Math.floor(height / 2) + + if (dirEquals(dir, Up)) return { x: centerX, y: baseCoord.y } + if (dirEquals(dir, Down)) return { x: centerX, y: baseCoord.y + height - 1 } + if (dirEquals(dir, Left)) return { x: baseCoord.x, y: centerY } + if (dirEquals(dir, Right)) return { x: baseCoord.x + width - 1, y: centerY } + // All diagonals and middle point to center + return { x: centerX, y: centerY } + }, +} + +/** + * State end pseudo-state renderer — bullseye in double-bordered box. + * Renders as: + * ╔═══╗ + * ║ ◎ ║ (Unicode) + * ╚═══╝ + * + * #===# + * # * # (ASCII) + * #===# + * + * This represents the UML final state. The double border distinguishes it + * from the start state's single rounded border. + */ +export const stateEndRenderer: ShapeRenderer = { + getDimensions(_label: string, _options: ShapeRenderOptions): ShapeDimensions { + // End state is a 5x3 double-bordered box with centered symbol + const width = 5 + const height = 3 + + return { + width, + height, + labelArea: { x: 2, y: 1, width: 1, height: 1 }, + gridColumns: [1, 3, 1], + gridRows: [1, 1, 1], + } + }, + + render(_label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const centerX = Math.floor(width / 2) // = 2 + + if (!options.useAscii) { + // Unicode double-bordered box with bullseye: ╔═══╗ ║ ◎ ║ ╚═══╝ + canvas[0]![0] = '╔' + canvas[1]![0] = '═' + canvas[2]![0] = '═' + canvas[3]![0] = '═' + canvas[4]![0] = '╗' + + canvas[0]![1] = '║' + canvas[centerX]![1] = '◎' + canvas[4]![1] = '║' + + canvas[0]![2] = '╚' + canvas[1]![2] = '═' + canvas[2]![2] = '═' + canvas[3]![2] = '═' + canvas[4]![2] = '╝' + } else { + // ASCII double-bordered box: #===# # * # #===# + canvas[0]![0] = '#' + canvas[1]![0] = '=' + canvas[2]![0] = '=' + canvas[3]![0] = '=' + canvas[4]![0] = '#' + + canvas[0]![1] = '#' + canvas[centerX]![1] = '*' + canvas[4]![1] = '#' + + canvas[0]![2] = '#' + canvas[1]![2] = '=' + canvas[2]![2] = '=' + canvas[3]![2] = '=' + canvas[4]![2] = '#' + } + + return canvas + }, + + getAttachmentPoint( + dir: Direction, + dimensions: ShapeDimensions, + baseCoord: DrawingCoord + ): DrawingCoord { + const { width, height } = dimensions + const centerX = baseCoord.x + Math.floor(width / 2) + const centerY = baseCoord.y + Math.floor(height / 2) + + if (dirEquals(dir, Up)) return { x: centerX, y: baseCoord.y } + if (dirEquals(dir, Down)) return { x: centerX, y: baseCoord.y + height - 1 } + if (dirEquals(dir, Left)) return { x: baseCoord.x, y: centerY } + if (dirEquals(dir, Right)) return { x: baseCoord.x + width - 1, y: centerY } + // All diagonals and middle point to center + return { x: centerX, y: centerY } + }, +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/types.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/types.ts new file mode 100644 index 000000000..31fe93faa --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/types.ts @@ -0,0 +1,73 @@ +// ============================================================================ +// Shape renderer types — interface for pluggable ASCII shape renderers +// ============================================================================ + +import type { Canvas, DrawingCoord, Direction, AsciiNodeShape } from '../types' + +/** + * Dimensions calculated for a shape, used by layout and rendering. + */ +export interface ShapeDimensions { + /** Total width in characters including borders */ + width: number + /** Total height in characters including borders */ + height: number + /** Label area bounds (where text can be placed) */ + labelArea: { + x: number + y: number + width: number + height: number + } + /** Grid column widths for the 3-column layout [left, center, right] */ + gridColumns: [number, number, number] + /** Grid row heights for the 3-row layout [top, middle, bottom] */ + gridRows: [number, number, number] +} + +/** + * Options passed to shape renderers. + */ +export interface ShapeRenderOptions { + /** Use ASCII chars (+,-,|) vs Unicode box-drawing (┌,─,│) */ + useAscii: boolean + /** Padding inside the shape */ + padding: number +} + +/** + * Interface for pluggable shape renderers. + * Each shape type implements this interface. + */ +export interface ShapeRenderer { + /** + * Calculate dimensions for this shape given a label. + * Used during layout to determine node size. + */ + getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions + + /** + * Render the shape to a canvas. + * Returns a standalone canvas containing just the shape. + */ + render( + label: string, + dimensions: ShapeDimensions, + options: ShapeRenderOptions + ): Canvas + + /** + * Get the edge attachment point for a given direction. + * Used by edge routing to determine where edges connect. + */ + getAttachmentPoint( + dir: Direction, + dimensions: ShapeDimensions, + baseCoord: DrawingCoord + ): DrawingCoord +} + +/** + * Registry of shape renderers keyed by shape type. + */ +export type ShapeRegistry = Map diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/types.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/types.ts new file mode 100644 index 000000000..9adfde734 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/types.ts @@ -0,0 +1,273 @@ +// ============================================================================ +// ASCII renderer — type definitions +// +// Ported from AlexanderGrooff/mermaid-ascii (Go). +// These types model the grid-based coordinate system, 2D text canvas, +// and graph structures used by the ASCII/Unicode renderer. +// ============================================================================ + +import type { NodeShape } from '../types' + +// Re-export NodeShape for convenience +export type { NodeShape } + +/** + * Shape type for ASCII rendering — maps parser shapes to ASCII renderers. + * Most shapes from the parser are supported, with fallback to 'rectangle'. + */ +export type AsciiNodeShape = NodeShape + +/** Logical grid coordinate — nodes occupy 3x3 blocks on this grid. */ +export interface GridCoord { + x: number + y: number +} + +/** Character-level coordinate on the 2D text canvas. */ +export interface DrawingCoord { + x: number + y: number +} + +/** + * Direction constants model positions on a node's 3x3 grid block. + * Each node occupies grid cells [x..x+2, y..y+2]. + * Directions are offsets into that block, used for edge attachment points. + * + * (0,0) UL (1,0) Up (2,0) UR + * (0,1) Left (1,1) Mid (2,1) Right + * (0,2) LL (1,2) Down (2,2) LR + */ +export interface Direction { + readonly x: number + readonly y: number +} + +export const Up: Direction = { x: 1, y: 0 } +export const Down: Direction = { x: 1, y: 2 } +export const Left: Direction = { x: 0, y: 1 } +export const Right: Direction = { x: 2, y: 1 } +export const UpperRight: Direction = { x: 2, y: 0 } +export const UpperLeft: Direction = { x: 0, y: 0 } +export const LowerRight: Direction = { x: 2, y: 2 } +export const LowerLeft: Direction = { x: 0, y: 2 } +export const Middle: Direction = { x: 1, y: 1 } + +/** All named directions for iteration. */ +export const ALL_DIRECTIONS: readonly Direction[] = [ + Up, Down, Left, Right, UpperRight, UpperLeft, LowerRight, LowerLeft, Middle, +] + +/** + * 2D text canvas — column-major (canvas[x][y]). + * Each cell holds a single character (or space). + */ +export type Canvas = string[][] + +/** A node in the ASCII graph, positioned on the grid. */ +export interface AsciiNode { + /** Unique identity key — the original node ID from the parser (e.g. "A", "B"). */ + name: string + /** Human-readable label for rendering inside the box (e.g. "Web Server"). */ + displayLabel: string + /** Node shape from the parser (e.g. "rectangle", "diamond", "circle"). */ + shape: AsciiNodeShape + index: number + gridCoord: GridCoord | null + drawingCoord: DrawingCoord | null + drawing: Canvas | null + drawn: boolean + styleClassName: string + styleClass: AsciiStyleClass +} + +/** Style class for colored node text (ported from Go's classDef). */ +export interface AsciiStyleClass { + name: string + styles: Record +} + +/** Edge line style for ASCII rendering. */ +export type AsciiEdgeStyle = 'solid' | 'dotted' | 'thick' + +/** An edge in the ASCII graph, with a routed path. */ +export interface AsciiEdge { + from: AsciiNode + to: AsciiNode + text: string + path: GridCoord[] + labelLine: GridCoord[] + startDir: Direction + endDir: Direction + /** Line style: solid (default), dotted (-.->) or thick (==>) */ + style: AsciiEdgeStyle + /** Whether to render an arrowhead at the start (source end) of the edge */ + hasArrowStart: boolean + /** Whether to render an arrowhead at the end (target end) of the edge */ + hasArrowEnd: boolean + /** Bundle this edge belongs to (if any). Set during bundling analysis. */ + bundle?: EdgeBundle + /** + * For bundled edges: path from source/target to the junction point. + * The full visual path is: pathToJunction + bundle.sharedPath (for fan-in) + * or bundle.sharedPath + pathToJunction (for fan-out). + */ + pathToJunction?: GridCoord[] +} + +/** A subgraph container with bounding box for rendering. */ +export interface AsciiSubgraph { + name: string + nodes: AsciiNode[] + parent: AsciiSubgraph | null + children: AsciiSubgraph[] + minX: number + minY: number + maxX: number + maxY: number + /** Optional direction override for layout within this subgraph (LR or TD). */ + direction?: 'LR' | 'TD' +} + +/** Configuration for ASCII rendering. */ +export interface AsciiConfig { + /** true = ASCII chars (+,-,|), false = Unicode box-drawing (┌,─,│). Default: false */ + useAscii: boolean + /** Horizontal spacing between nodes. Default: 5 */ + paddingX: number + /** Vertical spacing between nodes. Default: 5 */ + paddingY: number + /** Padding inside node boxes. Default: 1 */ + boxBorderPadding: number + /** Graph direction: "LR" or "TD". */ + graphDirection: 'LR' | 'TD' +} + +/** Full ASCII graph state used during layout and rendering. */ +export interface AsciiGraph { + nodes: AsciiNode[] + edges: AsciiEdge[] + canvas: Canvas + /** Role canvas — tracks the role of each character for colored output. */ + roleCanvas: RoleCanvas + /** Grid occupancy map — maps "x,y" keys to node references. */ + grid: Map + columnWidth: Map + rowHeight: Map + subgraphs: AsciiSubgraph[] + config: AsciiConfig + /** Offset applied to all drawing coords to accommodate subgraph borders. */ + offsetX: number + offsetY: number + /** Edge bundles for parallel link visualization. Set during bundling analysis. */ + bundles: EdgeBundle[] +} + +// ============================================================================ +// Coordinate helpers +// ============================================================================ + +export function gridCoordEquals(a: GridCoord, b: GridCoord): boolean { + return a.x === b.x && a.y === b.y +} + +export function drawingCoordEquals(a: DrawingCoord, b: DrawingCoord): boolean { + return a.x === b.x && a.y === b.y +} + +/** Apply a direction offset to a grid coordinate (move into the 3x3 block). */ +export function gridCoordDirection(c: GridCoord, dir: Direction): GridCoord { + return { x: c.x + dir.x, y: c.y + dir.y } +} + +/** Key for storing GridCoord in a Map. */ +export function gridKey(c: GridCoord): string { + return `${c.x},${c.y}` +} + +/** Default empty style class. */ +export const EMPTY_STYLE: AsciiStyleClass = { name: '', styles: {} } + +// ============================================================================ +// Character role types for colored output +// ============================================================================ + +/** + * Role of a character in the ASCII diagram, used for theming. + * Each role maps to a different color when colors are enabled. + */ +export type CharRole = + | 'text' // Node labels, edge labels + | 'border' // Node box borders, subgraph borders + | 'line' // Edge lines (paths between nodes) + | 'arrow' // Arrowheads (▲▼◄► or ^v<>) + | 'corner' // Corner characters at path bends + | 'junction' // Junction characters (┬┴├┤ where edges meet boxes) + +/** + * Role canvas — parallel to Canvas, tracks the role of each character. + * Same column-major structure: roleCanvas[x][y] gives the role at (x, y). + * null means the character has no role (whitespace). + */ +export type RoleCanvas = (CharRole | null)[][] + +/** + * Theme colors for ASCII output — hex color strings. + * Derived from the SVG theme system for visual consistency. + */ +export interface AsciiTheme { + /** Text color (node labels, edge labels) */ + fg: string + /** Box border color (node borders, subgraph borders) */ + border: string + /** Edge line color (paths between nodes) */ + line: string + /** Arrowhead color (▲▼◄► or ^v<>) */ + arrow: string + /** Theme accent color (optional, used by xycharts for series 0) */ + accent?: string + /** Background color (optional, used by xycharts for dark-mode-aware shading) */ + bg?: string + /** Corner character color (optional, defaults to line) */ + corner?: string + /** Junction character color (optional, defaults to border) */ + junction?: string +} + +/** Color mode for output. */ +export type ColorMode = + | 'none' // No colors (plain text) + | 'ansi16' // 16-color ANSI (basic terminals) + | 'ansi256' // 256-color ANSI (xterm) + | 'truecolor' // 24-bit RGB (modern terminals) + | 'html' // HTML tags with inline color styles (browsers) + +// ============================================================================ +// Edge bundling types +// ============================================================================ + +/** + * Edge bundle — groups edges that share a common source or target. + * Used to visually merge parallel links before they reach the shared node. + * + * For fan-in (A & B --> C): multiple sources converge to one target. + * For fan-out (A --> B & C): one source diverges to multiple targets. + */ +export interface EdgeBundle { + /** Bundle type: fan-in = many→one, fan-out = one→many */ + type: 'fan-in' | 'fan-out' + /** Edges in this bundle */ + edges: AsciiEdge[] + /** The common node (target for fan-in, source for fan-out) */ + sharedNode: AsciiNode + /** The non-shared nodes (sources for fan-in, targets for fan-out) */ + otherNodes: AsciiNode[] + /** Junction point where edges merge/split — set during routing */ + junctionPoint: GridCoord | null + /** Path from junction to shared node (drawn once for all edges) */ + sharedPath: GridCoord[] + /** Direction when entering/exiting the junction */ + junctionDir: Direction + /** Direction when entering/exiting the shared node */ + sharedNodeDir: Direction +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/validate.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/validate.ts new file mode 100644 index 000000000..dcbb25867 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/validate.ts @@ -0,0 +1,120 @@ +/** + * ASCII Rendering Validation Utilities + * + * Provides validation functions for ASCII diagram output, + * including diagonal line detection to ensure orthogonal-only routing. + */ + +/** + * Characters that represent diagonal lines in ASCII and Unicode modes. + * These should never appear in properly rendered diagrams. + */ +export const DIAGONAL_CHARS = { + ascii: ['/', '\\'], + unicode: ['\u2571', '\u2572'], // ╱ ╲ + all: ['/', '\\', '\u2571', '\u2572'], +} as const + +/** + * Position of a diagonal character in ASCII output. + */ +export interface DiagonalPosition { + line: number + col: number + char: string +} + +/** + * Check if ASCII output contains any diagonal line characters. + * Returns true if diagonals are found (which is an error condition). + * + * @param asciiOutput - The rendered ASCII diagram string + * @returns true if diagonal characters are present, false otherwise + */ +export function hasDiagonalLines(asciiOutput: string): boolean { + return DIAGONAL_CHARS.all.some((char) => asciiOutput.includes(char)) +} + +/** + * Find all diagonal line character positions in ASCII output. + * Useful for debugging when diagonals are detected. + * + * Skips diagonal characters that appear inside node labels (between box borders). + * This prevents false positives from labels like "feature/auth" or "release/1.0". + * + * @param asciiOutput - The rendered ASCII diagram string + * @returns Array of positions where diagonal characters were found + */ +export function findDiagonalLines(asciiOutput: string): DiagonalPosition[] { + const positions: DiagonalPosition[] = [] + const lines = asciiOutput.split('\n') + + // Box-drawing characters that indicate node boundaries + const boxBorders = new Set(['│', '┤', '├', '║', '┃', '|']) + + for (let lineNum = 0; lineNum < lines.length; lineNum++) { + const line = lines[lineNum]! + + // Find all box border positions in this line + const borderPositions: number[] = [] + for (let col = 0; col < line.length; col++) { + if (boxBorders.has(line[col]!)) { + borderPositions.push(col) + } + } + + for (let col = 0; col < line.length; col++) { + const char = line[col]! + if (DIAGONAL_CHARS.all.includes(char as '/' | '\\' | '╱' | '╲')) { + // Check if this position is inside a node (between two box borders) + // Find the nearest borders before and after this position + let insideNode = false + for (let i = 0; i < borderPositions.length - 1; i++) { + const leftBorder = borderPositions[i]! + const rightBorder = borderPositions[i + 1]! + if (col > leftBorder && col < rightBorder) { + // This diagonal char is between two borders - likely inside a node label + insideNode = true + break + } + } + + if (!insideNode) { + positions.push({ + line: lineNum + 1, // 1-indexed for human readability + col: col + 1, + char, + }) + } + } + } + } + + return positions +} + +/** + * Assert that ASCII output contains no diagonal lines. + * Throws an error with detailed position information if diagonals are found. + * + * @param asciiOutput - The rendered ASCII diagram string + * @param context - Optional context string for error message (e.g., diagram name) + * @throws Error if diagonal characters are present + */ +export function assertNoDiagonals(asciiOutput: string, context?: string): void { + if (!hasDiagonalLines(asciiOutput)) { + return + } + + const positions = findDiagonalLines(asciiOutput) + const contextStr = context ? ` in "${context}"` : '' + const positionStr = positions + .map((p) => ` Line ${p.line}, Col ${p.col}: '${p.char}'`) + .join('\n') + + throw new Error( + `Diagonal lines detected${contextStr}. ` + + `Edges must use orthogonal Manhattan routing (90° bends only).\n` + + `Found ${positions.length} diagonal character(s):\n${positionStr}` + ) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/xychart.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/xychart.ts new file mode 100644 index 000000000..08088d4f4 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/xychart.ts @@ -0,0 +1,875 @@ +// ============================================================================ +// ASCII renderer — XY Chart +// +// Renders xychart-beta diagrams to ASCII/Unicode text art. +// Uses the parsed XYChart type directly (not PositionedXYChart) since +// pixel coordinates don't map to character grids. +// +// Bar charts: █ (Unicode) or # (ASCII) block characters. +// Line charts: continuous staircase routing with rounded corners (╭╮╰╯│─). +// +// Multi-series support: each series gets a distinct color from a palette. +// ============================================================================ + +import { parseXYChart } from '../xychart/parser' +import type { XYChart } from '../xychart/types' +import type { AsciiConfig, AsciiTheme, ColorMode, CharRole, Canvas, RoleCanvas } from './types' +import { colorizeText } from './ansi' +import { getSeriesColor, CHART_ACCENT_FALLBACK } from '../xychart/colors' +import { displayWidth, toCells, WIDE_PAD } from '../text-metrics' + +// ============================================================================ +// Constants +// ============================================================================ + +const PLOT_WIDTH = 60 +const PLOT_HEIGHT = 20 + +// Unicode box-drawing characters +const UNI = { + hLine: '─', + vLine: '│', + origin: '┼', + yTick: '┤', + xTick: '┬', + bar: '█', + grid: '·', + cornerTL: '╭', // top-left: down+right + cornerTR: '╮', // top-right: down+left + cornerBL: '╰', // bottom-left: up+right + cornerBR: '╯', // bottom-right: up+left +} as const + +// ASCII fallback characters +const ASC = { + hLine: '-', + vLine: '|', + origin: '+', + yTick: '+', + xTick: '+', + bar: '#', + grid: '.', + cornerTL: '+', + cornerTR: '+', + cornerBL: '+', + cornerBR: '+', +} as const + +// ============================================================================ +// Multi-series color support +// ============================================================================ + +/** Per-cell hex color override canvas. Parallel to RoleCanvas. */ +type HexCanvas = (string | null)[][] + +/** Generate an array of hex colors, one per series. */ +function getSeriesColors(total: number, theme: AsciiTheme): string[] { + const accent = theme.accent ?? CHART_ACCENT_FALLBACK + if (total <= 1) return [accent] + return Array.from({ length: total }, (_, i) => getSeriesColor(i, accent, theme.bg)) +} + +/** Map a CharRole to its hex color from the theme (for canvasToString fallback). */ +function roleToHex(role: CharRole, theme: AsciiTheme): string { + switch (role) { + case 'text': return theme.fg + case 'border': return theme.border + case 'line': return theme.line + case 'arrow': return theme.arrow + case 'corner': return theme.corner ?? theme.line + case 'junction': return theme.junction ?? theme.border + default: return theme.fg + } +} + +// ============================================================================ +// Public API +// ============================================================================ + +export function renderXYChartAscii( + text: string, + config: AsciiConfig, + colorMode: ColorMode, + theme: AsciiTheme, +): string { + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) + const chart = parseXYChart(lines) + const ch = config.useAscii ? ASC : UNI + + if (chart.horizontal) { + return renderHorizontal(chart, ch, colorMode, theme) + } + return renderVertical(chart, ch, colorMode, theme) +} + +// ============================================================================ +// Vertical chart layout + rendering +// ============================================================================ + +function renderVertical( + chart: XYChart, + ch: typeof UNI | typeof ASC, + colorMode: ColorMode, + theme: AsciiTheme, +): string { + const dataCount = getDataCount(chart) + if (dataCount === 0) return '' + + 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 => displayWidth(l))) + 1 + + const plotW = Math.max(PLOT_WIDTH, dataCount * 6) + const plotH = PLOT_HEIGHT + const bandW = Math.floor(plotW / dataCount) + const catLabels = getCategoryLabels(chart, dataCount) + + // Canvas dimensions + const hasTitle = !!chart.title + const hasXTitle = !!chart.xAxis.title + const hasLegend = chart.series.length > 1 + const titleRow = hasTitle ? 0 : -1 + const plotTop = (hasTitle ? 2 : 0) + (hasLegend ? 1 : 0) + const plotLeft = yGutter + 1 // +1 for axis character + const totalW = plotLeft + bandW * dataCount + 2 + const xAxisRow = plotTop + plotH + const xLabelRow = xAxisRow + 1 + const xTitleRow = hasXTitle ? xLabelRow + 1 : -1 + const totalH = xLabelRow + 1 + (hasXTitle ? 1 : 0) + (hasLegend && !hasTitle ? 0 : 0) + + // Create canvas + const canvas = createCanvas(totalW, totalH) + const roles = createRoleCanvas(totalW, totalH) + const hexColors = createHexCanvas(totalW, totalH) + + // Series colors + const seriesColors = getSeriesColors(chart.series.length, theme) + + // Scales + const valueToRow = (v: number): number => { + const t = (v - yRange.min) / (yRange.max - yRange.min || 1) + return Math.round(t * (plotH - 1)) + } + const bandCenter = (i: number): number => plotLeft + Math.floor(bandW * (i + 0.5)) + + // 1. Title + if (hasTitle && titleRow >= 0) { + writeText(canvas, roles, titleRow, Math.floor(totalW / 2 - displayWidth(chart.title!) / 2), chart.title!, 'text') + } + + // 2. Legend + if (hasLegend) { + const legendRow = hasTitle ? 1 : 0 + drawLegend(canvas, roles, hexColors, chart, legendRow, totalW, ch, seriesColors) + } + + // 3. Y-axis line + ticks + labels + for (let row = 0; row < plotH; row++) { + const displayRow = plotTop + (plotH - 1 - row) + set(canvas, roles, displayRow, plotLeft - 1, ch.vLine, 'border') + } + // Origin + set(canvas, roles, xAxisRow, plotLeft - 1, ch.origin, 'border') + + for (const tick of yTicks) { + const row = valueToRow(tick) + if (row < 0 || row >= plotH) continue + const displayRow = plotTop + (plotH - 1 - row) + const label = formatTickValue(tick) + // Tick mark on axis + set(canvas, roles, displayRow, plotLeft - 1, row === 0 ? ch.origin : ch.yTick, 'border') + // Label + const labelStart = yGutter - displayWidth(label) + writeText(canvas, roles, displayRow, Math.max(0, labelStart), label, 'text') + } + + // 4. X-axis line + ticks + labels + for (let c = plotLeft; c < plotLeft + bandW * dataCount; c++) { + set(canvas, roles, xAxisRow, c, ch.hLine, 'border') + } + for (let i = 0; i < dataCount; i++) { + const cx = bandCenter(i) + set(canvas, roles, xAxisRow, cx, ch.xTick, 'border') + // Label below + const label = catLabels[i]! + 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 - displayWidth(title) / 2), title, 'text') + } + + // 6. Grid lines (subtle horizontal dots at y-tick positions) + for (const tick of yTicks) { + const row = valueToRow(tick) + if (row < 0 || row >= plotH) continue + const displayRow = plotTop + (plotH - 1 - row) + for (let c = plotLeft; c < plotLeft + bandW * dataCount; c++) { + if (get(canvas, displayRow, c) === ' ') { + set(canvas, roles, displayRow, c, ch.grid, 'line') + } + } + } + + // 7. Bars — track global series index for per-series colors + const barEntries: { data: number[]; globalIdx: number }[] = [] + for (let si = 0; si < chart.series.length; si++) { + if (chart.series[si]!.type === 'bar') barEntries.push({ data: chart.series[si]!.data, globalIdx: si }) + } + + if (barEntries.length > 0) { + const barCount = barEntries.length + const usable = Math.max(1, bandW - 2) + const singleBarW = Math.max(1, Math.min(Math.floor(usable / barCount), 8)) + const groupW = singleBarW * barCount + (barCount - 1) + const baseRow = valueToRow(Math.max(0, yRange.min)) + + for (let bIdx = 0; bIdx < barEntries.length; bIdx++) { + const entry = barEntries[bIdx]! + const hexColor = seriesColors[entry.globalIdx]! + for (let i = 0; i < entry.data.length; i++) { + const cx = bandCenter(i) + const groupLeft = cx - Math.floor(groupW / 2) + const bx = groupLeft + bIdx * (singleBarW + 1) + const valRow = valueToRow(entry.data[i]!) + const fromRow = Math.min(baseRow, valRow) + const toRow = Math.max(baseRow, valRow) + + for (let row = fromRow; row <= toRow; row++) { + const displayRow = plotTop + (plotH - 1 - row) + for (let c = bx; c < bx + singleBarW; c++) { + set(canvas, roles, displayRow, c, ch.bar, 'arrow', hexColors, hexColor) + } + } + } + } + } + + // 8. Lines (staircase routing with rounded corners) + const lineEntries: { data: number[]; globalIdx: number }[] = [] + for (let si = 0; si < chart.series.length; si++) { + if (chart.series[si]!.type === 'line') lineEntries.push({ data: chart.series[si]!.data, globalIdx: si }) + } + + for (const entry of lineEntries) { + if (entry.data.length === 0) continue + const hexColor = seriesColors[entry.globalIdx]! + drawStaircaseLine(canvas, roles, entry.data, bandCenter, valueToRow, plotTop, plotH, plotLeft, bandW * dataCount, ch, hexColors, hexColor) + } + + return canvasToString(canvas, roles, hexColors, colorMode, theme) +} + +// ============================================================================ +// Horizontal chart layout + rendering +// ============================================================================ + +function renderHorizontal( + chart: XYChart, + ch: typeof UNI | typeof ASC, + colorMode: ColorMode, + theme: AsciiTheme, +): string { + const dataCount = getDataCount(chart) + if (dataCount === 0) return '' + + const yRange = chart.yAxis.range! + const valueTicks = niceTickValues(yRange.min, yRange.max) + const catLabels = getCategoryLabels(chart, dataCount) + 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 + + const hasTitle = !!chart.title + const hasYTitle = !!chart.yAxis.title + const hasLegend = chart.series.length > 1 + const plotTop = (hasTitle ? 2 : 0) + (hasLegend ? 1 : 0) + const plotLeft = catGutter + 1 + const totalW = plotLeft + plotW + 2 + const totalH = plotTop + plotH + 2 + (hasYTitle ? 1 : 0) + const xAxisRow = plotTop + plotH + + const canvas = createCanvas(totalW, totalH) + const roles = createRoleCanvas(totalW, totalH) + const hexColors = createHexCanvas(totalW, totalH) + + // Series colors + const seriesColors = getSeriesColors(chart.series.length, theme) + + // Value scale (horizontal) + const valueToCol = (v: number): number => { + const t = (v - yRange.min) / (yRange.max - yRange.min || 1) + return plotLeft + Math.round(t * (plotW - 1)) + } + const bandMid = (i: number): number => plotTop + Math.floor(bandH * (i + 0.5)) + + // Title + if (hasTitle) { + writeText(canvas, roles, 0, Math.floor(totalW / 2 - displayWidth(chart.title!) / 2), chart.title!, 'text') + } + + // Legend + if (hasLegend) { + const legendRow = hasTitle ? 1 : 0 + drawLegend(canvas, roles, hexColors, chart, legendRow, totalW, ch, seriesColors) + } + + // Y-axis (category axis on left) + for (let r = plotTop; r < plotTop + plotH; r++) { + set(canvas, roles, r, plotLeft - 1, ch.vLine, 'border') + } + set(canvas, roles, xAxisRow, plotLeft - 1, ch.origin, 'border') + + for (let i = 0; i < dataCount; i++) { + const my = bandMid(i) + const label = catLabels[i]! + const labelStart = catGutter - displayWidth(label) + writeText(canvas, roles, my, Math.max(0, labelStart), label, 'text') + } + + // X-axis (value axis on bottom) + for (let c = plotLeft; c < plotLeft + plotW; c++) { + set(canvas, roles, xAxisRow, c, ch.hLine, 'border') + } + for (const tick of valueTicks) { + const cx = valueToCol(tick) + 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(displayWidth(label) / 2), label, 'text') + } + + // Y-axis title + if (hasYTitle) { + const title = chart.yAxis.title! + writeText(canvas, roles, totalH - 1, Math.floor(totalW / 2 - displayWidth(title) / 2), title, 'text') + } + + // Grid lines (vertical at value tick positions) + for (const tick of valueTicks) { + const cx = valueToCol(tick) + if (cx < plotLeft || cx >= plotLeft + plotW) continue + for (let r = plotTop; r < plotTop + plotH; r++) { + if (get(canvas, r, cx) === ' ') { + set(canvas, roles, r, cx, ch.grid, 'line') + } + } + } + + // Bars (horizontal) — with per-series colors + const barEntries: { data: number[]; globalIdx: number }[] = [] + for (let si = 0; si < chart.series.length; si++) { + if (chart.series[si]!.type === 'bar') barEntries.push({ data: chart.series[si]!.data, globalIdx: si }) + } + + if (barEntries.length > 0) { + const barCount = barEntries.length + const singleBarH = 1 + const groupH = singleBarH * barCount + (barCount - 1) + const baseCol = valueToCol(Math.max(0, yRange.min)) + + for (let bIdx = 0; bIdx < barEntries.length; bIdx++) { + const entry = barEntries[bIdx]! + const hexColor = seriesColors[entry.globalIdx]! + for (let i = 0; i < entry.data.length; i++) { + const my = bandMid(i) + const groupTop = my - Math.floor(groupH / 2) + const by = groupTop + bIdx * (singleBarH + 1) + const valCol = valueToCol(entry.data[i]!) + const fromCol = Math.min(baseCol, valCol) + const toCol = Math.max(baseCol, valCol) + + for (let r = by; r < by + singleBarH; r++) { + for (let c = fromCol; c <= toCol; c++) { + set(canvas, roles, r, c, ch.bar, 'arrow', hexColors, hexColor) + } + } + } + } + } + + // Lines (horizontal staircase: value on x, category on y) — with per-series colors + const lineEntries: { data: number[]; globalIdx: number }[] = [] + for (let si = 0; si < chart.series.length; si++) { + if (chart.series[si]!.type === 'line') lineEntries.push({ data: chart.series[si]!.data, globalIdx: si }) + } + + for (const entry of lineEntries) { + if (entry.data.length === 0) continue + const hexColor = seriesColors[entry.globalIdx]! + drawHorizontalStaircaseLine(canvas, roles, entry.data, bandMid, valueToCol, plotTop, plotH, plotLeft, plotW, ch, hexColors, hexColor) + } + + return canvasToString(canvas, roles, hexColors, colorMode, theme) +} + +// ============================================================================ +// Staircase line drawing — vertical charts +// +// Connects data points with flat segments (─) at each value's row, +// vertical segments (│) between rows, and rounded corners (╭╮╰╯) +// at transitions. The vertical step happens at the midpoint column +// between adjacent data points. +// ============================================================================ + +function drawStaircaseLine( + canvas: Canvas, + roles: RoleCanvas, + data: number[], + bandCenter: (i: number) => number, + valueToRow: (v: number) => number, + plotTop: number, + plotH: number, + plotLeft: number, + plotTotalW: number, + ch: typeof UNI | typeof ASC, + hexCanvas?: HexCanvas, + hexColor?: string | null, +): void { + if (data.length === 0) return + + const points = data.map((v, i) => ({ + col: bandCenter(i), + row: valueToRow(v), + })) + + // Helper to draw on the canvas (row 0 = bottom, displayed inverted) + const drawAt = (col: number, row: number, char: string) => { + const displayRow = plotTop + (plotH - 1 - row) + if (displayRow >= 0 && col >= plotLeft && col < plotLeft + plotTotalW) { + set(canvas, roles, displayRow, col, char, 'arrow', hexCanvas, hexColor) + } + } + + // Single point: just draw a flat segment + if (points.length === 1) { + drawAt(points[0]!.col, points[0]!.row, ch.hLine) + return + } + + for (let i = 0; i < points.length - 1; i++) { + const p1 = points[i]! + const p2 = points[i + 1]! + + if (p1.row === p2.row) { + // Flat: draw ─ across + for (let c = p1.col; c <= p2.col; c++) { + drawAt(c, p1.row, ch.hLine) + } + continue + } + + const midCol = Math.round((p1.col + p2.col) / 2) + const goingUp = p2.row > p1.row + + // 1. Flat at p1's row from p1.col to midCol-1 + for (let c = p1.col; c < midCol; c++) { + drawAt(c, p1.row, ch.hLine) + } + + // 2. Corner at (midCol, p1.row) + // goingUp: ─ from LEFT, │ going UP → LEFT+TOP = ╯ (cornerBR) + // goingDown: ─ from LEFT, │ going DOWN → LEFT+BOT = ╮ (cornerTR) + if (goingUp) { + drawAt(midCol, p1.row, ch.cornerBR) // ╯ + } else { + drawAt(midCol, p1.row, ch.cornerTR) // ╮ + } + + // 3. Vertical from p1.row to p2.row (exclusive of endpoints) + const minRow = Math.min(p1.row, p2.row) + const maxRow = Math.max(p1.row, p2.row) + for (let row = minRow + 1; row < maxRow; row++) { + drawAt(midCol, row, ch.vLine) + } + + // 4. Corner at (midCol, p2.row) + // goingUp: │ from BOTTOM, ─ going RIGHT → BOT+RIGHT = ╭ (cornerTL) + // goingDown: │ from TOP, ─ going RIGHT → TOP+RIGHT = ╰ (cornerBL) + if (goingUp) { + drawAt(midCol, p2.row, ch.cornerTL) // ╭ + } else { + drawAt(midCol, p2.row, ch.cornerBL) // ╰ + } + + // 5. Flat at p2's row from midCol+1 to p2.col + for (let c = midCol + 1; c <= p2.col; c++) { + drawAt(c, p2.row, ch.hLine) + } + + // Leading flat for first segment (before p1.col) + if (i === 0) { + const leadStart = Math.max(plotLeft, p1.col - Math.floor((p2.col - p1.col) / 4)) + for (let c = leadStart; c < p1.col; c++) { + drawAt(c, p1.row, ch.hLine) + } + } + + // Trailing flat for last segment (after p2.col) + if (i === points.length - 2) { + const trailEnd = Math.min(plotLeft + plotTotalW - 1, p2.col + Math.floor((p2.col - p1.col) / 4)) + for (let c = p2.col + 1; c <= trailEnd; c++) { + drawAt(c, p2.row, ch.hLine) + } + } + } +} + +// ============================================================================ +// Staircase line drawing — horizontal charts +// +// Same staircase approach but with axes swapped: +// data values map to columns (horizontal position) and categories map to +// rows (vertical position). Flat segments are vertical (│), transitions +// are horizontal (─), with the same rounded corners. +// ============================================================================ + +function drawHorizontalStaircaseLine( + canvas: Canvas, + roles: RoleCanvas, + data: number[], + bandMid: (i: number) => number, + valueToCol: (v: number) => number, + plotTop: number, + plotH: number, + plotLeft: number, + plotW: number, + ch: typeof UNI | typeof ASC, + hexCanvas?: HexCanvas, + hexColor?: string | null, +): void { + if (data.length === 0) return + + const points = data.map((v, i) => ({ + row: bandMid(i), + col: valueToCol(v), + })) + + const drawAt = (row: number, col: number, char: string) => { + if (row >= plotTop && row < plotTop + plotH && col >= plotLeft && col < plotLeft + plotW) { + set(canvas, roles, row, col, char, 'arrow', hexCanvas, hexColor) + } + } + + if (points.length === 1) { + drawAt(points[0]!.row, points[0]!.col, ch.vLine) + return + } + + for (let i = 0; i < points.length - 1; i++) { + const p1 = points[i]! + const p2 = points[i + 1]! + + if (p1.col === p2.col) { + // Same value: draw │ down + for (let r = p1.row; r <= p2.row; r++) { + drawAt(r, p1.col, ch.vLine) + } + continue + } + + const midRow = Math.round((p1.row + p2.row) / 2) + const goingRight = p2.col > p1.col + + // 1. Vertical at p1's col from p1.row to midRow-1 + for (let r = p1.row; r < midRow; r++) { + drawAt(r, p1.col, ch.vLine) + } + + // 2. Corner at (midRow, p1.col) + // goingRight: │ from TOP, ─ going RIGHT → TOP+RIGHT = ╰ (cornerBL) + // goingLeft: │ from TOP, ─ going LEFT → TOP+LEFT = ╯ (cornerBR) + if (goingRight) { + drawAt(midRow, p1.col, ch.cornerBL) // ╰ + } else { + drawAt(midRow, p1.col, ch.cornerBR) // ╯ + } + + // 3. Horizontal from p1.col to p2.col (exclusive) + const minCol = Math.min(p1.col, p2.col) + const maxCol = Math.max(p1.col, p2.col) + for (let c = minCol + 1; c < maxCol; c++) { + drawAt(midRow, c, ch.hLine) + } + + // 4. Corner at (midRow, p2.col) + // goingRight: ─ from LEFT, │ going DOWN → LEFT+BOT = ╮ (cornerTR) + // goingLeft: ─ from RIGHT, │ going DOWN → RIGHT+BOT = ╭ (cornerTL) + if (goingRight) { + drawAt(midRow, p2.col, ch.cornerTR) // ╮ + } else { + drawAt(midRow, p2.col, ch.cornerTL) // ╭ + } + + // 5. Vertical at p2's col from midRow+1 to p2.row + for (let r = midRow + 1; r <= p2.row; r++) { + drawAt(r, p2.col, ch.vLine) + } + } +} + +// ============================================================================ +// Legend — shows series symbols with per-series colors +// ============================================================================ + +function drawLegend( + canvas: Canvas, + roles: RoleCanvas, + hexCanvas: HexCanvas, + chart: XYChart, + row: number, + totalW: number, + ch: typeof UNI | typeof ASC, + seriesColors: string[], +): void { + // Build legend items with global series indices + type LegendItem = { symbol: string; label: string; globalIdx: number } + const items: LegendItem[] = [] + let barIdx = 0, lineIdx = 0 + for (let si = 0; si < chart.series.length; si++) { + const s = chart.series[si]! + if (s.type === 'bar') { + items.push({ symbol: ch.bar, label: `Bar ${barIdx + 1}`, globalIdx: si }) + barIdx++ + } else { + items.push({ symbol: ch.hLine, label: `Line ${lineIdx + 1}`, globalIdx: si }) + lineIdx++ + } + } + + // Calculate total legend width: "symbol space label symbol space label ..." + let totalLen = 0 + for (let i = 0; i < items.length; i++) { + if (i > 0) totalLen += 2 // gap between items + totalLen += 1 + 1 + displayWidth(items[i]!.label) // symbol + space + label + } + + const startCol = Math.max(0, Math.floor(totalW / 2 - totalLen / 2)) + let col = startCol + + for (let i = 0; i < items.length; i++) { + if (i > 0) col += 2 // gap + const item = items[i]! + // Symbol with series-specific color + set(canvas, roles, row, col, item.symbol, 'arrow', hexCanvas, seriesColors[item.globalIdx]) + col += 1 + // Space (already ' ' from canvas init) + col += 1 + // Label text + writeText(canvas, roles, row, col, item.label, 'text') + col += displayWidth(item.label) + } +} + +// ============================================================================ +// Canvas utilities +// ============================================================================ + +function createCanvas(width: number, height: number): Canvas { + return Array.from({ length: width }, () => Array.from({ length: height }, () => ' ')) +} + +function createRoleCanvas(width: number, height: number): RoleCanvas { + return Array.from({ length: width }, () => Array.from({ length: height }).fill(null)) +} + +function createHexCanvas(width: number, height: number): HexCanvas { + return Array.from({ length: width }, () => Array.from({ length: height }).fill(null)) +} + +function set( + canvas: Canvas, roles: RoleCanvas, row: number, col: number, + char: string, role: CharRole, + hexCanvas?: HexCanvas, hex?: string | null, +): void { + if (col >= 0 && col < canvas.length && row >= 0 && row < canvas[0]!.length) { + canvas[col]![row] = char + roles[col]![row] = role + if (hexCanvas && hex) hexCanvas[col]![row] = hex + } +} + +function get(canvas: Canvas, row: number, col: number): string { + if (col >= 0 && col < canvas.length && row >= 0 && row < canvas[0]!.length) { + return canvas[col]![row]! + } + return ' ' +} + +function writeText(canvas: Canvas, roles: RoleCanvas, row: number, startCol: number, text: string, role: CharRole): void { + 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) + } +} + +// ============================================================================ +// Canvas → string (with per-cell hex color support) +// ============================================================================ + +function canvasToString( + canvas: Canvas, + roles: RoleCanvas, + hexCanvas: HexCanvas, + colorMode: ColorMode, + theme: AsciiTheme, +): string { + if (canvas.length === 0) return '' + const height = canvas[0]!.length + const width = canvas.length + const lines: string[] = [] + + for (let row = 0; row < height; row++) { + const chars: string[] = [] + const rowRoles: (CharRole | null)[] = [] + const rowHex: (string | null)[] = [] + for (let col = 0; col < width; col++) { + 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]!) + } + // Trim trailing spaces + let end = chars.length - 1 + while (end >= 0 && chars[end] === ' ') end-- + if (end < 0) { + lines.push('') + } else { + lines.push(colorizeRow( + chars.slice(0, end + 1), + rowRoles.slice(0, end + 1), + rowHex.slice(0, end + 1), + theme, + colorMode, + )) + } + } + + // Trim trailing empty lines + while (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop() + } + + return lines.join('\n') +} + +/** + * Colorize a row of characters, using hex color overrides where available + * and falling back to role-based theme colors otherwise. + * Groups consecutive same-color characters for efficient escape sequences. + */ +function colorizeRow( + chars: string[], + roles: (CharRole | null)[], + hexOverrides: (string | null)[], + theme: AsciiTheme, + mode: ColorMode, +): string { + if (mode === 'none') return chars.join('') + + let result = '' + let currentColor: string | null = null + let buffer = '' + + for (let i = 0; i < chars.length; i++) { + const char = chars[i]! + + if (char === ' ') { + // Flush buffer before whitespace + if (buffer.length > 0) { + result += currentColor ? colorizeText(buffer, currentColor, mode) : buffer + buffer = '' + currentColor = null + } + result += ' ' + continue + } + + // Effective color: hex override > role-based > null + const hexOvr = hexOverrides[i] ?? null + const roleVal = roles[i] ?? null + const color = hexOvr ?? (roleVal ? roleToHex(roleVal, theme) : null) + + if (color === currentColor) { + buffer += char + } else { + // Flush previous group + if (buffer.length > 0) { + result += currentColor ? colorizeText(buffer, currentColor, mode) : buffer + } + buffer = char + currentColor = color + } + } + + // Flush remaining + if (buffer.length > 0) { + result += currentColor ? colorizeText(buffer, currentColor, mode) : buffer + } + + return result +} + +// ============================================================================ +// Helpers (chart-level) +// ============================================================================ + +function getDataCount(chart: XYChart): number { + if (chart.xAxis.categories) return chart.xAxis.categories.length + for (const s of chart.series) { + if (s.data.length > 0) return s.data.length + } + return 0 +} + +function getCategoryLabels(chart: XYChart, count: number): string[] { + if (chart.xAxis.categories) return chart.xAxis.categories + if (chart.xAxis.range) { + const { min, max } = chart.xAxis.range + const step = count > 1 ? (max - min) / (count - 1) : 0 + return Array.from({ length: count }, (_, i) => formatTickValue(min + step * i)) + } + return Array.from({ length: count }, (_, i) => String(i + 1)) +} + +/** Generate nice tick values for a numeric range. */ +function niceTickValues(min: number, max: number): number[] { + const range = max - min + if (range <= 0) return [min] + + const rawInterval = range / 6 + const magnitude = Math.pow(10, Math.floor(Math.log10(rawInterval))) + const residual = rawInterval / magnitude + let niceInterval: number + if (residual <= 1.5) niceInterval = magnitude + else if (residual <= 3) niceInterval = 2 * magnitude + else if (residual <= 7) niceInterval = 5 * magnitude + else niceInterval = 10 * magnitude + + const start = Math.ceil(min / niceInterval) * niceInterval + const ticks: number[] = [] + for (let v = start; v <= max + niceInterval * 0.001; v += niceInterval) { + ticks.push(Math.round(v * 1e10) / 1e10) + } + return ticks +} + +function formatTickValue(v: number): string { + if (Number.isInteger(v)) return String(v) + return v.toFixed(Math.abs(v) < 10 ? 1 : 0) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/class/parser.ts b/packages/utils/src/vendor/mermaid-ascii/class/parser.ts new file mode 100644 index 000000000..ad8e28ec0 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/class/parser.ts @@ -0,0 +1,290 @@ +import type { ClassDiagram, ClassNode, ClassRelationship, ClassMember, RelationshipType, ClassNamespace } from './types' +import { normalizeBrTags } from '../multiline-utils' + +// ============================================================================ +// Class diagram parser +// +// Parses Mermaid classDiagram syntax into a ClassDiagram structure. +// +// Supported syntax: +// class Animal { +String name; +eat() void } +// class Shape { <> } +// Animal <|-- Dog (inheritance) +// Car *-- Engine (composition) +// Car o-- Wheel (aggregation) +// A --> B (association) +// A ..> B (dependency) +// A ..|> B (realization) +// A "1" --> "*" B : label (with cardinality + label) +// Animal : +String name (inline attribute) +// namespace MyNamespace { class A { } } +// ============================================================================ + +/** + * Parse a Mermaid class diagram. + * Expects the first line to be "classDiagram". + */ +export function parseClassDiagram(lines: string[]): ClassDiagram { + const diagram: ClassDiagram = { + classes: [], + relationships: [], + namespaces: [], + } + + // Track classes by ID for deduplication + const classMap = new Map() + // Track namespace nesting + let currentNamespace: ClassNamespace | null = null + // Track class body parsing + let currentClass: ClassNode | null = null + let braceDepth = 0 + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]! + + // --- Inside a class body block --- + if (currentClass && braceDepth > 0) { + if (line === '}') { + braceDepth-- + if (braceDepth === 0) { + currentClass = null + } + continue + } + + // Check for annotation like <> + const annotMatch = line.match(/^<<(\w+)>>$/) + if (annotMatch) { + currentClass.annotation = annotMatch[1]! + continue + } + + // Parse member: visibility, name, type, optional parens for method + const member = parseMember(line) + if (member) { + if (member.isMethod) { + currentClass.methods.push(member.member) + } else { + currentClass.attributes.push(member.member) + } + } + continue + } + + // --- Namespace block start --- + const nsMatch = line.match(/^namespace\s+(\S+)\s*\{$/) + if (nsMatch) { + currentNamespace = { name: nsMatch[1]!, classIds: [] } + continue + } + + // --- Namespace end --- + if (line === '}' && currentNamespace) { + diagram.namespaces.push(currentNamespace) + currentNamespace = null + continue + } + + // --- Class block start: `class ClassName {` or `class ClassName` --- + const classBlockMatch = line.match(/^class\s+(\S+?)(?:\s*~(\w+)~)?\s*\{$/) + if (classBlockMatch) { + const id = classBlockMatch[1]! + const generic = classBlockMatch[2] + const cls = ensureClass(classMap, id) + if (generic) { + cls.label = `${id}<${generic}>` + } + currentClass = cls + braceDepth = 1 + if (currentNamespace) { + currentNamespace.classIds.push(id) + } + continue + } + + // --- Standalone class declaration (no body): `class ClassName` --- + const classOnlyMatch = line.match(/^class\s+(\S+?)(?:\s*~(\w+)~)?\s*$/) + if (classOnlyMatch) { + const id = classOnlyMatch[1]! + const generic = classOnlyMatch[2] + const cls = ensureClass(classMap, id) + if (generic) { + cls.label = `${id}<${generic}>` + } + if (currentNamespace) { + currentNamespace.classIds.push(id) + } + continue + } + + // --- Inline annotation: `class ClassName { <> }` (single line) --- + const inlineAnnotMatch = line.match(/^class\s+(\S+?)\s*\{\s*<<(\w+)>>\s*\}$/) + if (inlineAnnotMatch) { + const cls = ensureClass(classMap, inlineAnnotMatch[1]!) + cls.annotation = inlineAnnotMatch[2]! + continue + } + + // --- Inline attribute: `ClassName : +String name` --- + const inlineAttrMatch = line.match(/^(\S+?)\s*:\s*(.+)$/) + if (inlineAttrMatch) { + // Make sure this isn't a relationship line (those have arrows) + const rest = inlineAttrMatch[2]! + if (!rest.match(/<\|--|--|\*--|o--|-->|\.\.>|\.\.\|>/)) { + const cls = ensureClass(classMap, inlineAttrMatch[1]!) + const member = parseMember(rest) + if (member) { + if (member.isMethod) { + cls.methods.push(member.member) + } else { + cls.attributes.push(member.member) + } + } + continue + } + } + + // --- Relationship --- + // Pattern: [FROM] ["card"] ARROW ["card"] [TO] [: label] + // Arrows: <|--, *--, o--, -->, ..|>, ..> + // Can also be reversed: --o, --*, --|> + const rel = parseRelationship(line) + if (rel) { + // Ensure both classes exist + ensureClass(classMap, rel.from) + ensureClass(classMap, rel.to) + diagram.relationships.push(rel) + continue + } + } + + diagram.classes = [...classMap.values()] + return diagram +} + +/** Ensure a class exists in the map, creating a default if needed */ +function ensureClass(classMap: Map, id: string): ClassNode { + let cls = classMap.get(id) + if (!cls) { + cls = { id, label: id, attributes: [], methods: [] } + classMap.set(id, cls) + } + return cls +} + +/** Parse a class member line (attribute or method) */ +function parseMember(line: string): { member: ClassMember; isMethod: boolean } | null { + const trimmed = line.trim().replace(/;$/, '') + if (!trimmed) return null + + // Extract visibility prefix + let visibility: ClassMember['visibility'] = '' + let rest = trimmed + if (/^[+\-#~]/.test(rest)) { + visibility = rest[0] as ClassMember['visibility'] + rest = rest.slice(1).trim() + } + + // Check if it's a method (has parentheses) + const methodMatch = rest.match(/^(.+?)\(([^)]*)\)(?:\s*(.+))?$/) + if (methodMatch) { + const name = methodMatch[1]!.trim() + const params = methodMatch[2]?.trim() || undefined // Store the parameter string + const type = methodMatch[3]?.trim() + // Check for static ($) or abstract (*) markers + const isStatic = name.endsWith('$') || rest.includes('$') + const isAbstract = name.endsWith('*') || rest.includes('*') + return { + member: { + visibility, + name: name.replace(/[$*]$/, ''), + type: type || undefined, + isStatic, + isAbstract, + isMethod: true, + params, + }, + isMethod: true, + } + } + + // It's an attribute: [Type] name or name Type + // Common patterns: "String name", "+int age", "name" + const parts = rest.split(/\s+/) + let name: string + let type: string | undefined + + if (parts.length >= 2) { + // "Type name" pattern + type = parts[0] + name = parts.slice(1).join(' ') + } else { + name = parts[0] ?? rest + } + + const isStatic = name.endsWith('$') + const isAbstract = name.endsWith('*') + + return { + member: { + visibility, + name: name.replace(/[$*]$/, ''), + type: type || undefined, + isStatic, + isAbstract, + isMethod: false, + }, + isMethod: false, + } +} + +/** Parse a relationship line into a ClassRelationship */ +function parseRelationship(line: string): ClassRelationship | null { + // Relationship regex — handles all arrow types with optional cardinality and labels + // Pattern: FROM ["card"] ARROW ["card"] TO [: label] + const match = line.match( + /^(\S+?)\s+(?:"([^"]*?)"\s+)?(<\|--|<\|\.\.|\*--|o--|-->|--\*|--o|--\|>|\.\.>|\.\.\|>|<--|<\.\.?|--)\s+(?:"([^"]*?)"\s+)?(\S+?)(?:\s*:\s*(.+))?$/ + ) + if (!match) return null + + const from = match[1]! + const rawFromCardinality = match[2] + const fromCardinality = rawFromCardinality ? normalizeBrTags(rawFromCardinality) : undefined + const arrow = match[3]!.trim() + const rawToCardinality = match[4] + const toCardinality = rawToCardinality ? normalizeBrTags(rawToCardinality) : undefined + const to = match[5]! + const rawLabel = match[6]?.trim() + const label = rawLabel ? normalizeBrTags(rawLabel) : undefined + + const parsed = parseArrow(arrow) + if (!parsed) return null + + return { from, to, type: parsed.type, markerAt: parsed.markerAt, label, fromCardinality, toCardinality } +} + +/** + * Map arrow syntax to relationship type and marker placement side. + * Prefix markers (`<|--`, `*--`, `o--`) place the UML shape at the 'from' end. + * Suffix markers (`..|>`, `-->`, `..>`, `--*`, `--o`) place it at the 'to' end. + */ +function parseArrow(arrow: string): { type: RelationshipType; markerAt: 'from' | 'to' } | null { + // Trim whitespace that might be captured by the regex + const a = arrow.trim() + switch (a) { + case '<|--': return { type: 'inheritance', markerAt: 'from' } + case '--|>': return { type: 'inheritance', markerAt: 'to' } + case '<|..': return { type: 'realization', markerAt: 'from' } + case '..|>': return { type: 'realization', markerAt: 'to' } + case '*--': return { type: 'composition', markerAt: 'from' } + case '--*': return { type: 'composition', markerAt: 'to' } + case 'o--': return { type: 'aggregation', markerAt: 'from' } + case '--o': return { type: 'aggregation', markerAt: 'to' } + case '-->': return { type: 'association', markerAt: 'to' } + case '<--': return { type: 'association', markerAt: 'from' } + case '..>': return { type: 'dependency', markerAt: 'to' } + case '<..': return { type: 'dependency', markerAt: 'from' } + case '--': return { type: 'association', markerAt: 'to' } + default: return null + } +} diff --git a/packages/utils/src/vendor/mermaid-ascii/class/types.ts b/packages/utils/src/vendor/mermaid-ascii/class/types.ts new file mode 100644 index 000000000..b4f96b104 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/class/types.ts @@ -0,0 +1,121 @@ +// ============================================================================ +// Class diagram types +// +// Models the parsed and positioned representations of a Mermaid class diagram. +// Class diagrams show UML class relationships, inheritance, composition, etc. +// ============================================================================ + +/** Parsed class diagram — logical structure from mermaid text */ +export interface ClassDiagram { + /** All class definitions */ + classes: ClassNode[] + /** Relationships between classes */ + relationships: ClassRelationship[] + /** Optional namespace groupings */ + namespaces: ClassNamespace[] +} + +export interface ClassNode { + id: string + label: string + /** Annotation like <>, <>, <>, <> */ + annotation?: string + /** Class attributes (fields/properties) */ + attributes: ClassMember[] + /** Class methods (functions) */ + methods: ClassMember[] +} + +export interface ClassMember { + /** Visibility: + public, - private, # protected, ~ package */ + visibility: '+' | '-' | '#' | '~' | '' + /** Member name */ + name: string + /** Type annotation (e.g., "String", "int", "void") */ + type?: string + /** Whether the member is static (underlined in UML) */ + isStatic?: boolean + /** Whether the member is abstract (italic in UML) */ + isAbstract?: boolean + /** Whether the member is a method (renders with parentheses) */ + isMethod?: boolean + /** Method parameters (e.g., "data", "key, val") — only for methods */ + params?: string +} + +/** Relationship types following UML conventions */ +export type RelationshipType = + | 'inheritance' // A <|-- B (solid line, hollow triangle) + | 'composition' // A *-- B (solid line, filled diamond) + | 'aggregation' // A o-- B (solid line, hollow diamond) + | 'association' // A --> B (solid line, open arrow) + | 'dependency' // A ..> B (dashed line, open arrow) + | 'realization' // A ..|> B (dashed line, hollow triangle) + +export interface ClassRelationship { + from: string + to: string + type: RelationshipType + /** + * Which end of the relationship line has the UML marker (triangle, diamond, arrow). + * Determined by the arrow syntax direction: + * - Prefix markers like `<|--`, `*--`, `o--` → 'from' (marker on left/from side) + * - Suffix markers like `..|>`, `-->`, `..>`, `--*`, `--o` → 'to' (marker on right/to side) + */ + markerAt: 'from' | 'to' + /** Label on the relationship line */ + label?: string + /** Cardinality at the "from" end (e.g., "1", "*", "0..1") */ + fromCardinality?: string + /** Cardinality at the "to" end */ + toCardinality?: string +} + +export interface ClassNamespace { + name: string + classIds: string[] +} + +// ============================================================================ +// Positioned class diagram — ready for SVG rendering +// ============================================================================ + +export interface PositionedClassDiagram { + width: number + height: number + classes: PositionedClassNode[] + relationships: PositionedClassRelationship[] +} + +export interface PositionedClassNode { + id: string + label: string + annotation?: string + attributes: ClassMember[] + methods: ClassMember[] + x: number + y: number + width: number + height: number + /** Height of the header section (name + annotation) */ + headerHeight: number + /** Height of the attributes section */ + attrHeight: number + /** Height of the methods section */ + methodHeight: number +} + +export interface PositionedClassRelationship { + from: string + to: string + type: RelationshipType + /** Which end of the line has the UML marker — propagated from ClassRelationship */ + markerAt: 'from' | 'to' + label?: string + fromCardinality?: string + toCardinality?: string + /** Path points from source to target */ + points: Array<{ x: number; y: number }> + /** Dagre-computed label center position (avoids overlaps between nearby edges) */ + labelPosition?: { x: number; y: number } +} diff --git a/packages/utils/src/vendor/mermaid-ascii/er/parser.ts b/packages/utils/src/vendor/mermaid-ascii/er/parser.ts new file mode 100644 index 000000000..100685832 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/er/parser.ts @@ -0,0 +1,181 @@ +import type { ErDiagram, ErEntity, ErAttribute, ErRelationship, Cardinality } from './types' +import { normalizeBrTags } from '../multiline-utils' + +// ============================================================================ +// ER diagram parser +// +// Parses Mermaid erDiagram syntax into an ErDiagram structure. +// +// Supported syntax: +// CUSTOMER ||--o{ ORDER : places +// CUSTOMER { +// string name PK +// int age +// string email UK "user email" +// } +// +// Cardinality notation: +// || exactly one +// o| zero or one (also |o) +// }| one or more (also |{) +// o{ zero or more (also {o) +// +// Line style: +// -- identifying (solid line) +// .. non-identifying (dashed line) +// ============================================================================ + +/** + * Parse a Mermaid ER diagram. + * Expects the first line to be "erDiagram". + */ +export function parseErDiagram(lines: string[]): ErDiagram { + const diagram: ErDiagram = { + entities: [], + relationships: [], + } + + // Track entities by ID for deduplication + const entityMap = new Map() + // Track entity body parsing + let currentEntity: ErEntity | null = null + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]! + + // --- Inside entity body --- + if (currentEntity) { + if (line === '}') { + currentEntity = null + continue + } + + // Attribute line: type name [PK|FK|UK] ["comment"] + const attr = parseAttribute(line) + if (attr) { + currentEntity.attributes.push(attr) + } + continue + } + + // --- Entity block start: `ENTITY_NAME {` --- + const entityBlockMatch = line.match(/^(\S+)\s*\{$/) + if (entityBlockMatch) { + const id = entityBlockMatch[1]! + const entity = ensureEntity(entityMap, id) + currentEntity = entity + continue + } + + // --- Relationship: `ENTITY1 cardinality1--cardinality2 ENTITY2 : label` --- + const rel = parseRelationshipLine(line) + if (rel) { + // Ensure both entities exist + ensureEntity(entityMap, rel.entity1) + ensureEntity(entityMap, rel.entity2) + diagram.relationships.push(rel) + continue + } + } + + diagram.entities = [...entityMap.values()] + return diagram +} + +/** Ensure an entity exists in the map */ +function ensureEntity(entityMap: Map, id: string): ErEntity { + let entity = entityMap.get(id) + if (!entity) { + entity = { id, label: id, attributes: [] } + entityMap.set(id, entity) + } + return entity +} + +/** Parse an attribute line inside an entity block */ +function parseAttribute(line: string): ErAttribute | null { + // Format: type name [PK|FK|UK [...]] ["comment"] + const match = line.match(/^(\S+)\s+(\S+)(?:\s+(.+))?$/) + if (!match) return null + + const type = match[1]! + const name = match[2]! + const rest = match[3]?.trim() ?? '' + + // Extract key constraints (PK, FK, UK) and optional comment + const keys: ErAttribute['keys'] = [] + let comment: string | undefined + + // Extract quoted comment first (supports
tags) + const commentMatch = rest.match(/"([^"]*)"/) + if (commentMatch) { + comment = normalizeBrTags(commentMatch[1]!) + } + + // Extract key constraints + const restWithoutComment = rest.replace(/"[^"]*"/, '').trim() + for (const part of restWithoutComment.split(/\s+/)) { + const upper = part.toUpperCase() + if (upper === 'PK' || upper === 'FK' || upper === 'UK') { + keys.push(upper as 'PK' | 'FK' | 'UK') + } + } + + return { type, name, keys, comment } +} + +/** + * Parse a relationship line. + * + * Cardinality symbols on each side of the line style: + * Left side (entity1): || |o o| }| |{ o{ {o + * Line: -- (identifying) or .. (non-identifying) + * Right side (entity2): || o| |o |{ }| {o o{ + * + * Full pattern example: CUSTOMER ||--o{ ORDER : places + */ +function parseRelationshipLine(line: string): ErRelationship | null { + // Match: ENTITY1 ENTITY2 : label + const match = line.match(/^(\S+)\s+([|o}{]+(?:--|\.\.)[|o}{]+)\s+(\S+)\s*:\s*(.+)$/) + if (!match) return null + + const entity1 = match[1]! + const cardinalityStr = match[2]! + const entity2 = match[3]! + // Strip surrounding quotes if present, then normalize br tags + const rawLabel = match[4]!.trim().replace(/^["']|["']$/g, '') + const label = normalizeBrTags(rawLabel) + + // Split the cardinality string into left side, line style, right side + const lineMatch = cardinalityStr.match(/^([|o}{]+)(--|\.\.?)([|o}{]+)$/) + if (!lineMatch) return null + + const leftStr = lineMatch[1]! + const lineStyle = lineMatch[2]! + const rightStr = lineMatch[3]! + + const cardinality1 = parseCardinality(leftStr) + const cardinality2 = parseCardinality(rightStr) + const identifying = lineStyle === '--' + + if (!cardinality1 || !cardinality2) return null + + return { entity1, entity2, cardinality1, cardinality2, label, identifying } +} + +/** Parse a cardinality notation string into a Cardinality type */ +function parseCardinality(str: string): Cardinality | null { + // Normalize: sort the characters to handle both orders (e.g., |o and o|) + const sorted = str.split('').sort().join('') + + // Exact one: || → sorted "||" + if (sorted === '||') return 'one' + // Zero or one: o| or |o → sorted "o|" (o=111 < |=124 in char codes) + if (sorted === 'o|') return 'zero-one' + // One or more: }| or |{ → sorted "|}" or "{|" + if (sorted === '|}' || sorted === '{|') return 'many' + // Zero or more: o{ or {o → sorted "{o" or "o{" + if (sorted === '{o' || sorted === 'o{') return 'zero-many' + + return null +} diff --git a/packages/utils/src/vendor/mermaid-ascii/er/types.ts b/packages/utils/src/vendor/mermaid-ascii/er/types.ts new file mode 100644 index 000000000..702f3c8c8 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/er/types.ts @@ -0,0 +1,91 @@ +// ============================================================================ +// ER diagram types +// +// Models the parsed and positioned representations of a Mermaid ER diagram. +// ER diagrams show database entities, their attributes, and relationships. +// ============================================================================ + +/** Parsed ER diagram — logical structure from mermaid text */ +export interface ErDiagram { + /** All entity definitions */ + entities: ErEntity[] + /** Relationships between entities */ + relationships: ErRelationship[] +} + +export interface ErEntity { + id: string + /** Display name (same as id unless aliased) */ + label: string + /** Entity attributes (columns) */ + attributes: ErAttribute[] +} + +export interface ErAttribute { + /** Data type (string, int, varchar, etc.) */ + type: string + /** Attribute name */ + name: string + /** Key constraints: PK, FK, UK */ + keys: Array<'PK' | 'FK' | 'UK'> + /** Optional comment */ + comment?: string +} + +/** + * Cardinality notation (crow's foot): + * 'one' || exactly one + * 'zero-one' |o zero or one + * 'many' }| one or more + * 'zero-many' o{ zero or more + */ +export type Cardinality = 'one' | 'zero-one' | 'many' | 'zero-many' + +export interface ErRelationship { + entity1: string + entity2: string + /** Cardinality at entity1's end */ + cardinality1: Cardinality + /** Cardinality at entity2's end */ + cardinality2: Cardinality + /** Relationship verb/label (e.g., "places", "contains") */ + label: string + /** Whether the relationship is identifying (solid line) or non-identifying (dashed) */ + identifying: boolean +} + +// ============================================================================ +// Positioned ER diagram — ready for SVG rendering +// ============================================================================ + +export interface PositionedErDiagram { + width: number + height: number + entities: PositionedErEntity[] + relationships: PositionedErRelationship[] +} + +export interface PositionedErEntity { + id: string + label: string + attributes: ErAttribute[] + x: number + y: number + width: number + height: number + /** Height of the header row */ + headerHeight: number + /** Height per attribute row */ + rowHeight: number +} + +export interface PositionedErRelationship { + entity1: string + entity2: string + cardinality1: Cardinality + cardinality2: Cardinality + label: string + identifying: boolean + /** Path points from entity1 to entity2 */ + points: Array<{ x: number; y: number }> +} diff --git a/packages/utils/src/vendor/mermaid-ascii/index.ts b/packages/utils/src/vendor/mermaid-ascii/index.ts new file mode 100644 index 000000000..f668b735d --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/index.ts @@ -0,0 +1,14 @@ +// ============================================================================ +// Mermaid → ASCII renderer (vendored) +// +// First-party copy of the ASCII rendering pipeline from `beautiful-mermaid` +// (MIT, Copyright (c) 2026 Craft Docs — see ./NOTICE). The SVG pipeline and +// its `elkjs` graph-layout dependency are omitted; the ASCII renderers use +// their own grid layout + A* edge routing and need no external deps. Terminal +// display-width math is delegated to `Bun.stringWidth` (see ./text-metrics). +// +// Public surface: renderMermaidASCII + AsciiRenderOptions (incl. the +// `direction` override) and the theme/color-mode types. +// ============================================================================ + +export * from './ascii/index' diff --git a/packages/utils/src/vendor/mermaid-ascii/multiline-utils.ts b/packages/utils/src/vendor/mermaid-ascii/multiline-utils.ts new file mode 100644 index 000000000..743efc9e7 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/multiline-utils.ts @@ -0,0 +1,30 @@ +// ============================================================================ +// Label normalization +// +// Shared by the diagram parsers (flowchart/state, class, ER, sequence) to +// normalize raw Mermaid label text before it reaches the ASCII renderers. +// The SVG-only multi-line tspan renderers from upstream are not vendored. +// ============================================================================ + +/** + * Normalize label text for terminal ASCII output: strip surrounding quotes, + * convert
tags and literal newline escapes to newlines, and reduce + * inline formatting (HTML bold/italic/underline/strike tags and the markdown + * bold, italic, and strikethrough markers) to plain text. The ASCII renderer + * has no styled spans, so preserving the markup would print raw tags and + * markers inside node boxes. + */ +export function normalizeBrTags(label: string): string { + // Strip surrounding double quotes (Mermaid uses them for special chars in labels) + const unquoted = label.startsWith('"') && label.endsWith('"') ? label.slice(1, -1) : label + return unquoted + .replace(//gi, '\n') + .replace(/\\n/g, '\n') + .replace(/<\/?(?:sub|sup|small|mark)\s*>/gi, '') + // Drop inline HTML formatting tags — ASCII output has no styled spans + .replace(/<\/?(?:b|strong|i|em|u|s|del)\s*>/gi, '') + // Reduce markdown emphasis to its inner text (order matters: ** before *) + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/(? l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) + + if (lines.length === 0) { + throw new Error('Empty mermaid diagram') + } + + // Detect diagram type from header + const header = lines[0]! + + // State diagram: "stateDiagram-v2" or "stateDiagram" + if (/^stateDiagram(-v2)?\s*$/i.test(header)) { + return parseStateDiagram(lines) + } + + // Flowchart: "graph TD" or "flowchart LR" + return parseFlowchart(lines) +} + +// ============================================================================ +// Flowchart parser +// ============================================================================ + +function parseFlowchart(lines: string[]): MermaidGraph { + const headerMatch = lines[0]!.match(/^(?:graph|flowchart)\s+(TD|TB|LR|BT|RL)\s*$/i) + if (!headerMatch) { + throw new Error(`Invalid mermaid header: "${lines[0]}". Expected "graph TD", "flowchart LR", "stateDiagram-v2", etc.`) + } + + const direction = headerMatch[1]!.toUpperCase() as Direction + + const graph: MermaidGraph = { + direction, + nodes: new Map(), + edges: [], + subgraphs: [], + classDefs: new Map(), + classAssignments: new Map(), + nodeStyles: new Map(), + linkStyles: new Map(), + } + + // Subgraph stack for nested subgraphs. + const subgraphStack: MermaidSubgraph[] = [] + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]! + + // --- classDef: `classDef name prop:val,prop:val` --- + const classDefMatch = line.match(/^classDef\s+(\w+)\s+(.+)$/) + if (classDefMatch) { + const name = classDefMatch[1]! + const propsStr = classDefMatch[2]! + const props = parseStyleProps(propsStr) + graph.classDefs.set(name, props) + continue + } + + // --- class assignment: `class A,B className` --- + const classAssignMatch = line.match(/^class\s+([\w,-]+)\s+(\w+)$/) + if (classAssignMatch) { + const nodeIds = classAssignMatch[1]!.split(',').map(s => s.trim()) + const className = classAssignMatch[2]! + for (const id of nodeIds) { + graph.classAssignments.set(id, className) + } + continue + } + + // --- style statement: `style A,B fill:#f00,stroke:#333` --- + const styleMatch = line.match(/^style\s+([\w,-]+)\s+(.+)$/) + if (styleMatch) { + const nodeIds = styleMatch[1]!.split(',').map(s => s.trim()) + const props = parseStyleProps(styleMatch[2]!) + for (const id of nodeIds) { + graph.nodeStyles.set(id, { ...graph.nodeStyles.get(id), ...props }) + } + continue + } + + // --- linkStyle: `linkStyle 0 stroke:#f00` or `linkStyle default stroke:#f00` --- + const linkStyleMatch = line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/) + if (linkStyleMatch) { + const target = linkStyleMatch[1]!.trim() + const props = parseStyleProps(linkStyleMatch[2]!) + if (target === 'default') { + graph.linkStyles.set('default', { ...graph.linkStyles.get('default'), ...props }) + } else { + const indices = target.split(',').map(s => parseInt(s.trim(), 10)) + for (const idx of indices) { + if (!isNaN(idx)) { + graph.linkStyles.set(idx, { ...graph.linkStyles.get(idx), ...props }) + } + } + } + continue + } + + // --- direction override inside subgraph: `direction LR` --- + const dirMatch = line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i) + if (dirMatch && subgraphStack.length > 0) { + subgraphStack[subgraphStack.length - 1]!.direction = dirMatch[1]!.toUpperCase() as Direction + continue + } + + // --- subgraph start: `subgraph Label` or `subgraph id [Label]` --- + const subgraphMatch = line.match(/^subgraph\s+(.+)$/) + if (subgraphMatch) { + const rest = subgraphMatch[1]!.trim() + // Check for "subgraph id [Label]" form + // ID can contain hyphens (e.g. "us-east"), so use [\w-]+ not \w+ + const bracketMatch = rest.match(/^([\w-]+)\s*\[(.+)\]$/) + let id: string + let label: string + if (bracketMatch) { + id = bracketMatch[1]! + label = normalizeBrTags(bracketMatch[2]!) + } else { + // Use the label text as id (slugified) + label = normalizeBrTags(rest) + id = rest.replace(/\s+/g, '_').replace(/[^\w]/g, '') + } + const sg: MermaidSubgraph = { id, label, nodeIds: [], children: [] } + subgraphStack.push(sg) + continue + } + + // --- subgraph end --- + if (line === 'end') { + const completed = subgraphStack.pop() + if (completed) { + if (subgraphStack.length > 0) { + subgraphStack[subgraphStack.length - 1]!.children.push(completed) + } else { + graph.subgraphs.push(completed) + } + } + continue + } + + // --- Edge/node definitions --- + parseEdgeLine(line, graph, subgraphStack) + } + + return graph +} + +// ============================================================================ +// State diagram parser +// +// Supported syntax: +// stateDiagram-v2 +// s1 : Description +// state "Description" as s1 +// s1 --> s2 : label +// [*] --> s1 (start pseudostate) +// s1 --> [*] (end pseudostate) +// state CompositeState { +// inner1 --> inner2 +// } +// ============================================================================ + +function parseStateDiagram(lines: string[]): MermaidGraph { + const graph: MermaidGraph = { + direction: 'TD', + nodes: new Map(), + edges: [], + subgraphs: [], + classDefs: new Map(), + classAssignments: new Map(), + nodeStyles: new Map(), + linkStyles: new Map(), + } + + // Track composite state nesting (like subgraphs) + const compositeStack: MermaidSubgraph[] = [] + // Track all composite state IDs to avoid creating duplicate nodes + const compositeStateIds = new Set() + // Counter for unique [*] pseudostate IDs + let startCount = 0 + let endCount = 0 + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]! + + // --- direction override --- + const dirMatch = line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i) + if (dirMatch) { + if (compositeStack.length > 0) { + compositeStack[compositeStack.length - 1]!.direction = dirMatch[1]!.toUpperCase() as Direction + } else { + graph.direction = dirMatch[1]!.toUpperCase() as Direction + } + continue + } + + // --- linkStyle: `linkStyle 0 stroke:#f00` or `linkStyle default stroke:#f00` --- + const linkStyleMatch = line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/) + if (linkStyleMatch) { + const target = linkStyleMatch[1]!.trim() + const props = parseStyleProps(linkStyleMatch[2]!) + if (target === 'default') { + graph.linkStyles.set('default', { ...graph.linkStyles.get('default'), ...props }) + } else { + const indices = target.split(',').map(s => parseInt(s.trim(), 10)) + for (const idx of indices) { + if (!isNaN(idx)) { + graph.linkStyles.set(idx, { ...graph.linkStyles.get(idx), ...props }) + } + } + } + continue + } + + // --- composite state start: `state CompositeState {` --- + const compositeMatch = line.match(/^state\s+(?:"([^"]+)"\s+as\s+)?([\w\p{L}]+)\s*\{$/u) + if (compositeMatch) { + const label = compositeMatch[1] ?? compositeMatch[2]! + const id = compositeMatch[2]! + const sg: MermaidSubgraph = { id, label, nodeIds: [], children: [] } + compositeStack.push(sg) + // Track this ID to avoid creating a duplicate node for the composite state + compositeStateIds.add(id) + // Remove any existing node that was created when parsing transitions before + // this composite state definition (e.g., "A --> Processing" before "state Processing {") + graph.nodes.delete(id) + continue + } + + // --- composite state end --- + if (line === '}') { + const completed = compositeStack.pop() + if (completed) { + if (compositeStack.length > 0) { + compositeStack[compositeStack.length - 1]!.children.push(completed) + } else { + graph.subgraphs.push(completed) + } + } + continue + } + + // --- state alias: `state "Description" as s1` (without brace) --- + const stateAliasMatch = line.match(/^state\s+"([^"]+)"\s+as\s+([\w\p{L}]+)\s*$/u) + if (stateAliasMatch) { + const label = normalizeBrTags(stateAliasMatch[1]!) + const id = stateAliasMatch[2]! + registerStateNode(graph, compositeStack, { id, label, shape: 'rounded' }) + continue + } + + // --- transition: `s1 --> s2` or `s1 --> s2 : label` or `[*] --> s1` --- + const transitionMatch = line.match(/^(\[\*\]|[\w\p{L}-]+)\s*(-->)\s*(\[\*\]|[\w\p{L}-]+)(?:\s*:\s*(.+))?$/u) + if (transitionMatch) { + let sourceId = transitionMatch[1]! + let targetId = transitionMatch[3]! + const rawTransitionLabel = transitionMatch[4]?.trim() + const edgeLabel = rawTransitionLabel ? normalizeBrTags(rawTransitionLabel) : undefined + + // Handle [*] pseudostates — each occurrence gets a unique ID + if (sourceId === '[*]') { + startCount++ + sourceId = `_start${startCount > 1 ? startCount : ''}` + registerStateNode(graph, compositeStack, { id: sourceId, label: '', shape: 'state-start' }) + } else if (!compositeStateIds.has(sourceId)) { + // Only create a node if this isn't a composite state + ensureStateNode(graph, compositeStack, sourceId) + } + + if (targetId === '[*]') { + endCount++ + targetId = `_end${endCount > 1 ? endCount : ''}` + registerStateNode(graph, compositeStack, { id: targetId, label: '', shape: 'state-end' }) + } else if (!compositeStateIds.has(targetId)) { + // Only create a node if this isn't a composite state + ensureStateNode(graph, compositeStack, targetId) + } + + graph.edges.push({ + source: sourceId, + target: targetId, + label: edgeLabel, + style: 'solid', + hasArrowStart: false, + hasArrowEnd: true, + }) + continue + } + + // --- state description: `s1 : Description` --- + const stateDescMatch = line.match(/^([\w\p{L}-]+)\s*:\s*(.+)$/u) + if (stateDescMatch) { + const id = stateDescMatch[1]! + const label = normalizeBrTags(stateDescMatch[2]!.trim()) + registerStateNode(graph, compositeStack, { id, label, shape: 'rounded' }) + continue + } + } + + return graph +} + +/** Register a state node and track in composite state if applicable */ +function registerStateNode( + graph: MermaidGraph, + compositeStack: MermaidSubgraph[], + node: MermaidNode +): void { + const isNew = !graph.nodes.has(node.id) + if (isNew) { + graph.nodes.set(node.id, node) + } + if (compositeStack.length > 0) { + const current = compositeStack[compositeStack.length - 1]! + if (!current.nodeIds.includes(node.id)) { + current.nodeIds.push(node.id) + } + } +} + +/** Ensure a state node exists with default rounded shape */ +function ensureStateNode( + graph: MermaidGraph, + compositeStack: MermaidSubgraph[], + id: string +): void { + if (!graph.nodes.has(id)) { + registerStateNode(graph, compositeStack, { id, label: id, shape: 'rounded' }) + } else { + // Track in composite if applicable + if (compositeStack.length > 0) { + const current = compositeStack[compositeStack.length - 1]! + if (!current.nodeIds.includes(id)) { + current.nodeIds.push(id) + } + } + } +} + +// ============================================================================ +// Shared utilities +// ============================================================================ + +/** Parse "fill:#f00,stroke:#333" style property strings into a Record */ +function parseStyleProps(propsStr: string): Record { + // Strip trailing semicolons — Mermaid tolerates them (e.g. `stroke:#f00;`) + const cleaned = propsStr.replace(/;\s*$/, '') + const props: Record = {} + for (const pair of cleaned.split(',')) { + const colonIdx = pair.indexOf(':') + if (colonIdx > 0) { + const key = pair.slice(0, colonIdx).trim() + const val = pair.slice(colonIdx + 1).trim() + if (key && val) { + props[key] = val + } + } + } + return props +} + +// ============================================================================ +// Flowchart edge line parser +// +// Handles chained edges like: A[Label] --> B(Label) -.-> C{Label} +// Also handles & parallel links: A & B --> C & D +// ============================================================================ + +/** + * Arrow regex — matches all arrow operators with optional labels. + * + * Supported operators: + * --> --- solid arrow / solid line + * -.-> -.- dotted arrow / dotted line + * ==> === thick arrow / thick line + * <--> <-.-> <==> bidirectional variants + * + * Optional label: -->|label text| + */ +const ARROW_REGEX = /^(<)?(-->|-.->|==>|---|-\.-|===)(?:\|([^|]*)\|)?/ + +/** + * Text-embedded label regex — matches "-- label -->", "-. label .->", "== label ==>" syntax. + * Tried as fallback when ARROW_REGEX doesn't match. + * + * Based on PR #36 by @liuxiaopai-ai (https://github.com/lukilabs/beautiful-mermaid/pull/36) + */ +const TEXT_ARROW_REGEX = /^(<)?(--|-\.|==)\s+(.+?)\s+(-->|---|\.\->|-\.\-|==>|===)/ + +/** + * Node shape patterns — ordered from most specific delimiters to least. + * Multi-char delimiters must be tried before single-char to avoid false matches. + */ +const NODE_PATTERNS: Array<{ regex: RegExp; shape: NodeShape }> = [ + // Triple delimiters (must be first) + { regex: /^([\w-]+)\(\(\((.+?)\)\)\)/, shape: 'doublecircle' }, // A(((text))) + + // Double delimiters with mixed brackets + { regex: /^([\w-]+)\(\[(.+?)\]\)/, shape: 'stadium' }, // A([text]) + { regex: /^([\w-]+)\(\((.+?)\)\)/, shape: 'circle' }, // A((text)) + { regex: /^([\w-]+)\[\[(.+?)\]\]/, shape: 'subroutine' }, // A[[text]] + { regex: /^([\w-]+)\[\((.+?)\)\]/, shape: 'cylinder' }, // A[(text)] + + // Trapezoid variants — must come before plain [text] + { regex: /^([\w-]+)\[\/(.+?)\\\]/, shape: 'trapezoid' }, // A[/text\] + { regex: /^([\w-]+)\[\\(.+?)\/\]/, shape: 'trapezoid-alt' }, // A[\text/] + + // Asymmetric flag shape + { regex: /^([\w-]+)>(.+?)\]/, shape: 'asymmetric' }, // A>text] + + // Double curly braces (hexagon) — must come before single {text} + { regex: /^([\w-]+)\{\{(.+?)\}\}/, shape: 'hexagon' }, // A{{text}} + + // Single-char delimiters (last — most common, least specific) + { regex: /^([\w-]+)\[(.+?)\]/, shape: 'rectangle' }, // A[text] + { regex: /^([\w-]+)\((.+?)\)/, shape: 'rounded' }, // A(text) + { regex: /^([\w-]+)\{(.+?)\}/, shape: 'diamond' }, // A{text} +] + +/** Regex for a bare node reference (just an ID, no shape brackets) */ +const BARE_NODE_REGEX = /^([\w-]+)/ + +/** Regex for ::: class shorthand suffix — matches :::className immediately after a node */ +const CLASS_SHORTHAND_REGEX = /^:::([\w][\w-]*)/ + +/** + * Parse a line that contains node definitions and edges. + * Handles chaining: A --> B --> C produces edges A→B and B→C. + * Handles parallel links: A & B --> C & D produces 4 edges. + */ +function parseEdgeLine( + line: string, + graph: MermaidGraph, + subgraphStack: MermaidSubgraph[] +): void { + let remaining = line.trim() + + // Parse the first node group (possibly with & separators) + const firstGroup = consumeNodeGroup(remaining, graph, subgraphStack) + if (!firstGroup || firstGroup.ids.length === 0) return + + remaining = firstGroup.remaining.trim() + let prevGroupIds = firstGroup.ids + + // Parse arrow + node-group pairs until the line is exhausted + while (remaining.length > 0) { + let hasArrowStart: boolean + let style: EdgeStyle + let hasArrowEnd: boolean + let edgeLabel: string | undefined + + const arrowMatch = remaining.match(ARROW_REGEX) + if (arrowMatch) { + hasArrowStart = Boolean(arrowMatch[1]) + const arrowOp = arrowMatch[2]! + const rawEdgeLabel = arrowMatch[3]?.trim() + edgeLabel = rawEdgeLabel ? normalizeBrTags(rawEdgeLabel) : undefined + remaining = remaining.slice(arrowMatch[0].length).trim() + style = arrowStyleFromOp(arrowOp) + hasArrowEnd = arrowOp.endsWith('>') + } else { + // Fallback: text-embedded label syntax (-- Yes -->, -. Maybe .->, == Sure ==>) + const textMatch = remaining.match(TEXT_ARROW_REGEX) + if (!textMatch) break + hasArrowStart = Boolean(textMatch[1]) + const rawLabel = textMatch[3]!.trim() + edgeLabel = rawLabel ? normalizeBrTags(rawLabel) : undefined + const openOp = textMatch[2]! + const closeOp = textMatch[4]! + remaining = remaining.slice(textMatch[0].length).trim() + style = textArrowStyleFromOps(openOp, closeOp) + hasArrowEnd = closeOp.endsWith('>') + } + + // Parse the next node group + const nextGroup = consumeNodeGroup(remaining, graph, subgraphStack) + if (!nextGroup || nextGroup.ids.length === 0) break + + remaining = nextGroup.remaining.trim() + + // Emit Cartesian product of edges: every source × every target + for (const sourceId of prevGroupIds) { + for (const targetId of nextGroup.ids) { + graph.edges.push({ + source: sourceId, + target: targetId, + label: edgeLabel, + style, + hasArrowStart, + hasArrowEnd, + }) + } + } + + prevGroupIds = nextGroup.ids + } +} + +interface ConsumedNodeGroup { + ids: string[] + remaining: string +} + +/** + * Consume one or more nodes separated by `&`. + * E.g. "A & B & C --> ..." returns ids: ['A', 'B', 'C'] + */ +function consumeNodeGroup( + text: string, + graph: MermaidGraph, + subgraphStack: MermaidSubgraph[] +): ConsumedNodeGroup | null { + const first = consumeNode(text, graph, subgraphStack) + if (!first) return null + + const ids = [first.id] + let remaining = first.remaining.trim() + + // Check for & separators + while (remaining.startsWith('&')) { + remaining = remaining.slice(1).trim() + const next = consumeNode(remaining, graph, subgraphStack) + if (!next) break + ids.push(next.id) + remaining = next.remaining.trim() + } + + return { ids, remaining } +} + +interface ConsumedNode { + id: string + remaining: string +} + +/** + * Try to consume a node definition from the start of `text`. + * If the node has a shape+label (e.g. A[Text]), it's registered in the graph. + * If it's a bare reference (e.g. A), we look it up or create a default. + * Also handles ::: class shorthand suffix. + */ +function consumeNode( + text: string, + graph: MermaidGraph, + subgraphStack: MermaidSubgraph[] +): ConsumedNode | null { + let id: string | null = null + let remaining: string = text + + // Try each node pattern (shape-qualified) + for (const { regex, shape } of NODE_PATTERNS) { + const match = text.match(regex) + if (match) { + id = match[1]! + const label = normalizeBrTags(match[2]!) + registerNode(graph, subgraphStack, { id, label, shape }) + remaining = text.slice(match[0].length) + break + } + } + + // Bare node reference — only register if node doesn't exist yet. + // If it already exists, do NOT track it in the current subgraph; + // nodes belong to the subgraph where they're first defined. + if (id === null) { + const bareMatch = text.match(BARE_NODE_REGEX) + if (bareMatch) { + id = bareMatch[1]! + if (!graph.nodes.has(id)) { + registerNode(graph, subgraphStack, { id, label: id, shape: 'rectangle' }) + } + remaining = text.slice(bareMatch[0].length) + } + } + + if (id === null) return null + + // Check for ::: class shorthand suffix immediately after the node + const classMatch = remaining.match(CLASS_SHORTHAND_REGEX) + if (classMatch) { + graph.classAssignments.set(id, classMatch[1]!) + remaining = remaining.slice(classMatch[0].length) + } + + return { id, remaining } +} + +/** Register a node in the graph and track it in the current subgraph */ +function registerNode( + graph: MermaidGraph, + subgraphStack: MermaidSubgraph[], + node: MermaidNode +): void { + const isNew = !graph.nodes.has(node.id) + if (isNew) { + graph.nodes.set(node.id, node) + } + trackInSubgraph(subgraphStack, node.id) +} + +/** Add node ID to the innermost subgraph if we're inside one */ +function trackInSubgraph(subgraphStack: MermaidSubgraph[], nodeId: string): void { + if (subgraphStack.length > 0) { + const current = subgraphStack[subgraphStack.length - 1]! + if (!current.nodeIds.includes(nodeId)) { + current.nodeIds.push(nodeId) + } + } +} + +/** Map arrow operator string to edge style (ignoring direction) */ +function arrowStyleFromOp(op: string): EdgeStyle { + if (op === '-.->') return 'dotted' + if (op === '-.-') return 'dotted' + if (op === '==>') return 'thick' + if (op === '===') return 'thick' + // '-->'' and '---' are both solid + return 'solid' +} + +/** Map text-embedded arrow open/close operators to edge style */ +function textArrowStyleFromOps(openOp: string, closeOp: string): EdgeStyle { + if (openOp === '-.' || closeOp === '.->' || closeOp === '-.-') return 'dotted' + if (openOp === '==' || closeOp === '==>' || closeOp === '===') return 'thick' + return 'solid' +} diff --git a/packages/utils/src/vendor/mermaid-ascii/sequence/parser.ts b/packages/utils/src/vendor/mermaid-ascii/sequence/parser.ts new file mode 100644 index 000000000..11262f368 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/sequence/parser.ts @@ -0,0 +1,207 @@ +import type { SequenceDiagram, Actor, Message, Block, Note } from './types' +import { normalizeBrTags } from '../multiline-utils' + +// ============================================================================ +// Sequence diagram parser +// +// Parses Mermaid sequenceDiagram syntax into a SequenceDiagram structure. +// +// Supported syntax: +// participant A as Alice +// actor B as Bob +// A->>B: Solid arrow +// A-->>B: Dashed arrow +// A-)B: Open arrow +// A--)B: Dashed open arrow +// A->>+B: Activate target +// A-->>-B: Deactivate source +// loop Label ... end +// alt Label ... else Label ... end +// opt Label ... end +// par Label ... and Label ... end +// Note left of A: Text +// Note right of A: Text +// Note over A,B: Text +// ============================================================================ + +/** + * Parse a Mermaid sequence diagram. + * Expects the first line to be "sequenceDiagram". + */ +export function parseSequenceDiagram(lines: string[]): SequenceDiagram { + const diagram: SequenceDiagram = { + actors: [], + messages: [], + blocks: [], + notes: [], + } + + // Track actor IDs to auto-create actors referenced in messages + const actorIds = new Set() + // Track block nesting with a stack + const blockStack: Array<{ type: Block['type']; label: string; startIndex: number; dividers: Block['dividers'] }> = [] + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]! + + // --- Participant / Actor declaration --- + // "participant A as Alice" or "participant Alice" + // "actor B as Bob" or "actor Bob" + const actorMatch = line.match(/^(participant|actor)\s+(\S+?)(?:\s+as\s+(.+))?$/) + if (actorMatch) { + const type = actorMatch[1] as 'participant' | 'actor' + const id = actorMatch[2]! + const rawLabel = actorMatch[3]?.trim() ?? id + const label = normalizeBrTags(rawLabel) + if (!actorIds.has(id)) { + actorIds.add(id) + diagram.actors.push({ id, label, type }) + } + continue + } + + // --- Note --- + // "Note left of A: text" / "Note right of A: text" / "Note over A,B: text" + const noteMatch = line.match(/^Note\s+(left of|right of|over)\s+([^:]+):\s*(.+)$/i) + if (noteMatch) { + const posStr = noteMatch[1]!.toLowerCase() + const actorsStr = noteMatch[2]!.trim() + const text = normalizeBrTags(noteMatch[3]!.trim()) + const noteActorIds = actorsStr.split(',').map(s => s.trim()) + + // Ensure actors exist + for (const aid of noteActorIds) { + ensureActor(diagram, actorIds, aid) + } + + let position: 'left' | 'right' | 'over' = 'over' + if (posStr === 'left of') position = 'left' + else if (posStr === 'right of') position = 'right' + + diagram.notes.push({ + actorIds: noteActorIds, + text, + position, + afterIndex: diagram.messages.length - 1, + }) + continue + } + + // --- Block start: loop, alt, opt, par, critical, break, rect --- + const blockMatch = line.match(/^(loop|alt|opt|par|critical|break|rect)\s*(.*)$/) + if (blockMatch) { + const blockType = blockMatch[1] as Block['type'] + const rawBlockLabel = blockMatch[2]?.trim() ?? '' + const label = normalizeBrTags(rawBlockLabel) + blockStack.push({ + type: blockType, + label, + startIndex: diagram.messages.length, + dividers: [], + }) + continue + } + + // --- Block divider: else, and --- + const dividerMatch = line.match(/^(else|and)\s*(.*)$/) + if (dividerMatch && blockStack.length > 0) { + const rawDividerLabel = dividerMatch[2]?.trim() ?? '' + const label = normalizeBrTags(rawDividerLabel) + blockStack[blockStack.length - 1]!.dividers.push({ + index: diagram.messages.length, + label, + }) + continue + } + + // --- Block end --- + if (line === 'end' && blockStack.length > 0) { + const completed = blockStack.pop()! + diagram.blocks.push({ + type: completed.type, + label: completed.label, + startIndex: completed.startIndex, + endIndex: Math.max(diagram.messages.length - 1, completed.startIndex), + dividers: completed.dividers, + }) + continue + } + + // --- Message --- + // Patterns: A->>B, A-->>B, A-)B, A--)B, with optional +/- activation + // Format: FROM ARROW TO: LABEL + const msgMatch = line.match( + /^(\S+?)\s*(--?>?>|--?[)x]|--?>>|--?>)\s*([+-]?)(\S+?)\s*:\s*(.+)$/ + ) + if (msgMatch) { + const from = msgMatch[1]! + const arrow = msgMatch[2]! + const activationMark = msgMatch[3] + const to = msgMatch[4]! + const label = normalizeBrTags(msgMatch[5]!.trim()) + + // Ensure both actors exist + ensureActor(diagram, actorIds, from) + ensureActor(diagram, actorIds, to) + + // Determine line style and arrow head from the arrow operator + const lineStyle = arrow.startsWith('--') ? 'dashed' : 'solid' + // ">>" = filled arrow, ")" or ">" alone = open arrow, "x" = cross (treat as filled) + const arrowHead = arrow.includes('>>') || arrow.includes('x') ? 'filled' : 'open' + + const msg: Message = { + from, + to, + label, + lineStyle, + arrowHead, + } + + // Activation/deactivation via +/- prefix on target + if (activationMark === '+') msg.activate = true + if (activationMark === '-') msg.deactivate = true + + diagram.messages.push(msg) + continue + } + + // --- Simplified message format: A->>B: Label (fallback with more relaxed regex) --- + const simpleMsgMatch = line.match( + /^(\S+?)\s*(->>|-->>|-\)|--\)|-x|--x|->|-->)\s*([+-]?)(\S+?)\s*:\s*(.+)$/ + ) + if (simpleMsgMatch) { + const from = simpleMsgMatch[1]! + const arrow = simpleMsgMatch[2]! + const activationMark = simpleMsgMatch[3] + const to = simpleMsgMatch[4]! + const label = normalizeBrTags(simpleMsgMatch[5]!.trim()) + + ensureActor(diagram, actorIds, from) + ensureActor(diagram, actorIds, to) + + const lineStyle = arrow.startsWith('--') ? 'dashed' : 'solid' + const arrowHead = arrow.includes('>>') || arrow.includes('x') ? 'filled' : 'open' + + const msg: Message = { from, to, label, lineStyle, arrowHead } + if (activationMark === '+') msg.activate = true + if (activationMark === '-') msg.deactivate = true + + diagram.messages.push(msg) + continue + } + + // --- activate / deactivate explicit commands --- + // These are handled implicitly via +/- on messages but can also appear standalone + // For now, we skip explicit activate/deactivate lines (they affect rendering only) + } + + return diagram +} + +/** Ensure an actor exists, creating a default participant if not */ +function ensureActor(diagram: SequenceDiagram, actorIds: Set, id: string): void { + if (!actorIds.has(id)) { + actorIds.add(id) + diagram.actors.push({ id, label: id, type: 'participant' }) + } +} diff --git a/packages/utils/src/vendor/mermaid-ascii/sequence/types.ts b/packages/utils/src/vendor/mermaid-ascii/sequence/types.ts new file mode 100644 index 000000000..af8a8fc23 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/sequence/types.ts @@ -0,0 +1,146 @@ +// ============================================================================ +// Sequence diagram types +// +// Models the parsed and positioned representations of a Mermaid sequence diagram. +// Sequence diagrams show actor interactions over time (vertical timeline). +// ============================================================================ + +/** Parsed sequence diagram — logical structure from mermaid text */ +export interface SequenceDiagram { + /** Ordered list of actors/participants */ + actors: Actor[] + /** Messages between actors in chronological order */ + messages: Message[] + /** Structural blocks (loop, alt, opt, par, critical) */ + blocks: Block[] + /** Notes attached to actors */ + notes: Note[] +} + +export interface Actor { + id: string + label: string + /** 'participant' renders as a box, 'actor' renders as a stick figure */ + type: 'participant' | 'actor' +} + +export interface Message { + from: string + to: string + label: string + /** Arrow style: solid line or dashed line */ + lineStyle: 'solid' | 'dashed' + /** Arrow head: filled (closed) or open */ + arrowHead: 'filled' | 'open' + /** Activate the target lifeline (+) */ + activate?: boolean + /** Deactivate the source lifeline (-) */ + deactivate?: boolean +} + +export interface Block { + /** Block type keyword */ + type: 'loop' | 'alt' | 'opt' | 'par' | 'critical' | 'break' | 'rect' + /** Label for the block header */ + label: string + /** Index of the first message inside this block */ + startIndex: number + /** Index of the last message inside this block (inclusive) */ + endIndex: number + /** For alt/par blocks: indices where "else"/"and" dividers appear (message indices) */ + dividers: Array<{ index: number; label: string }> +} + +export interface Note { + /** Which actor(s) the note is attached to */ + actorIds: string[] + /** Note text content */ + text: string + /** Position relative to the actor(s) */ + position: 'left' | 'right' | 'over' + /** Message index after which this note appears */ + afterIndex: number +} + +// ============================================================================ +// Positioned sequence diagram — ready for SVG rendering +// ============================================================================ + +export interface PositionedSequenceDiagram { + width: number + height: number + actors: PositionedActor[] + lifelines: Lifeline[] + messages: PositionedMessage[] + activations: Activation[] + blocks: PositionedBlock[] + notes: PositionedNote[] +} + +export interface PositionedActor { + id: string + label: string + type: 'participant' | 'actor' + /** Center x of the actor box */ + x: number + /** Top y of the actor box */ + y: number + width: number + height: number +} + +/** Vertical dashed line from actor to bottom of diagram */ +export interface Lifeline { + actorId: string + x: number + topY: number + bottomY: number +} + +export interface PositionedMessage { + from: string + to: string + label: string + lineStyle: 'solid' | 'dashed' + arrowHead: 'filled' | 'open' + /** Start point (from actor's lifeline) */ + x1: number + /** End point (to actor's lifeline) */ + x2: number + /** Vertical position */ + y: number + /** Whether this is a self-message (same actor) */ + isSelf: boolean +} + +/** Narrow rectangle on a lifeline showing active processing */ +export interface Activation { + actorId: string + x: number + topY: number + bottomY: number + width: number +} + +export interface PositionedBlock { + type: Block['type'] + label: string + x: number + y: number + width: number + height: number + /** Divider lines within the block (for alt/par) */ + dividers: Array<{ y: number; label: string }> +} + +export interface PositionedNote { + text: string + x: number + y: number + width: number + height: number + /** Actor IDs this note is attached to (for SVG attribution) */ + actors?: string[] + /** Note position relative to actors (for SVG attribution) */ + position?: 'left' | 'right' | 'over' +} diff --git a/packages/utils/src/vendor/mermaid-ascii/text-metrics.ts b/packages/utils/src/vendor/mermaid-ascii/text-metrics.ts new file mode 100644 index 000000000..77c89e7b0 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/text-metrics.ts @@ -0,0 +1,71 @@ +// ============================================================================ +// 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. +// +// Width is delegated to Bun.stringWidth (wcwidth-style, locale-insensitive): +// East Asian Wide/Fullwidth and emoji-presentation graphemes (incl. ZWJ +// sequences and regional-indicator flags) measure 2; box-drawing, the +// renderer's narrow arrowheads (◀ ▶ ►), and East Asian Ambiguous glyphs +// measure 1. This matches the renderer's structural glyph set exactly. +// +// 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. +// ============================================================================ + +/** + * 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' + +const graphemeSegmenter = new Intl.Segmenter() + +/** + * Display width of a string in terminal columns, summed over grapheme + * clusters so it always equals `toCells(text).length`. 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 += Bun.stringWidth(seg.segment) >= 2 ? 2 : 1 + } + 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 (Bun.stringWidth(seg.segment) >= 2) { + cells.push(WIDE_PAD) + } + } + return cells +} diff --git a/packages/utils/src/vendor/mermaid-ascii/types.ts b/packages/utils/src/vendor/mermaid-ascii/types.ts new file mode 100644 index 000000000..4e424621c --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/types.ts @@ -0,0 +1,164 @@ +// ============================================================================ +// Parsed graph — logical structure extracted from Mermaid text +// ============================================================================ + +export interface MermaidGraph { + direction: Direction + nodes: Map + edges: MermaidEdge[] + subgraphs: MermaidSubgraph[] + classDefs: Map> + /** Maps node IDs to their class names (from `class X className` or `:::className` shorthand) */ + classAssignments: Map + /** Maps node IDs to inline styles (from `style X fill:#f00,stroke:#333`) */ + nodeStyles: Map> + /** Maps edge indices (or 'default') to inline styles from `linkStyle` directives */ + linkStyles: Map> +} + +export type Direction = 'TD' | 'TB' | 'LR' | 'BT' | 'RL' + +export interface MermaidNode { + id: string + label: string + shape: NodeShape +} + +export type NodeShape = + | 'rectangle' + | 'rounded' + | 'diamond' + | 'stadium' + | 'circle' + // Batch 1 additions + | 'subroutine' // [[text]] — double-bordered rectangle + | 'doublecircle' // (((text))) — concentric circles + | 'hexagon' // {{text}} — six-sided polygon + // Batch 2 additions + | 'cylinder' // [(text)] — database cylinder + | 'asymmetric' // >text] — flag/banner shape + | 'trapezoid' // [/text\] — wider bottom + | 'trapezoid-alt' // [\text/] — wider top + // Batch 3 state diagram pseudostates + | 'state-start' // filled circle (start pseudostate) + | 'state-end' // bullseye circle (end pseudostate) + +export interface MermaidEdge { + source: string + target: string + label?: string + style: EdgeStyle + /** Whether to render an arrowhead at the start (source end) of the edge */ + hasArrowStart: boolean + /** Whether to render an arrowhead at the end (target end) of the edge */ + hasArrowEnd: boolean +} + +export type EdgeStyle = 'solid' | 'dotted' | 'thick' + +export interface MermaidSubgraph { + id: string + label: string + nodeIds: string[] + children: MermaidSubgraph[] + /** Optional direction override for this subgraph's internal layout */ + direction?: Direction +} + +// ============================================================================ +// Positioned graph — after ELK layout, ready for SVG rendering +// ============================================================================ + +export interface PositionedGraph { + width: number + height: number + nodes: PositionedNode[] + edges: PositionedEdge[] + groups: PositionedGroup[] +} + +export interface PositionedNode { + id: string + label: string + shape: NodeShape + x: number + y: number + width: number + height: number + /** Inline styles resolved from classDef + explicit `style` statements — override theme defaults */ + inlineStyle?: Record +} + +export interface PositionedEdge { + source: string + target: string + label?: string + style: EdgeStyle + hasArrowStart: boolean + hasArrowEnd: boolean + /** Full path including bends — array of {x, y} points */ + points: Point[] + /** Layout-computed label center position (avoids label-label collisions) */ + labelPosition?: Point + /** Inline styles resolved from `linkStyle` directives — override theme defaults */ + inlineStyle?: Record +} + +export interface Point { + x: number + y: number +} + +export interface PositionedGroup { + id: string + label: string + x: number + y: number + width: number + height: number + children: PositionedGroup[] +} + +// ============================================================================ +// Render options — user-facing configuration +// +// Color theming uses CSS custom properties: --bg and --fg are required, +// optional enrichment variables (--line, --accent, --muted, --surface, +// --border) add richer color from Shiki themes or custom palettes. +// See src/theme.ts for the full variable system. +// ============================================================================ + +export interface RenderOptions { + /** Background color → CSS variable --bg. Default: '#FFFFFF' */ + bg?: string + /** Foreground / primary text color → CSS variable --fg. Default: '#27272A' */ + fg?: string + + // -- Optional enrichment colors (fall back to color-mix from bg/fg) -- + + /** Edge/connector color → CSS variable --line */ + line?: string + /** Arrow heads, highlights → CSS variable --accent */ + accent?: string + /** Secondary text, edge labels → CSS variable --muted */ + muted?: string + /** Node/box fill tint → CSS variable --surface */ + surface?: string + /** Node/group stroke color → CSS variable --border */ + border?: string + + /** Font family for all text. Default: 'Inter' */ + font?: string + /** Canvas padding in px. Default: 40 */ + padding?: number + /** Horizontal spacing between sibling nodes. Default: 24 */ + nodeSpacing?: number + /** Vertical spacing between layers. Default: 40 */ + layerSpacing?: number + /** Spacing between disconnected components. Default: nodeSpacing (24) */ + componentSpacing?: number + /** Render with transparent background (no background style on SVG). Default: false */ + transparent?: boolean + /** Enable hover tooltips on chart data points (xychart only). Default: false */ + interactive?: boolean +} diff --git a/packages/utils/src/vendor/mermaid-ascii/xychart/colors.ts b/packages/utils/src/vendor/mermaid-ascii/xychart/colors.ts new file mode 100644 index 000000000..7cc053d04 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/xychart/colors.ts @@ -0,0 +1,140 @@ +// ============================================================================ +// XY Chart — shared color palette +// +// Generates monochromatic shades from the theme accent color. +// Series 0 = accent (or blue fallback). Series 1+ are darker/lighter +// shades of the same hue with subtle hue drift to stay in the same +// color family (like navy ↔ cyan from blue). +// +// Used by both the SVG and ASCII renderers. +// ============================================================================ + +/** Default accent for charts when the theme doesn't provide one. */ +export const CHART_ACCENT_FALLBACK = '#3b82f6' // blue-500 + +// --------------------------------------------------------------------------- +// HSL ↔ Hex conversion +// --------------------------------------------------------------------------- + +function hexToHsl(hex: string): [number, number, number] { + const h = hex.replace('#', '') + const ri = parseInt(h.substring(0, 2), 16) / 255 + const gi = parseInt(h.substring(2, 4), 16) / 255 + const bi = parseInt(h.substring(4, 6), 16) / 255 + + const max = Math.max(ri, gi, bi) + const min = Math.min(ri, gi, bi) + const l = (max + min) / 2 + + if (max === min) return [0, 0, l * 100] + + const d = max - min + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + + let hue: number + if (max === ri) hue = ((gi - bi) / d + (gi < bi ? 6 : 0)) / 6 + else if (max === gi) hue = ((bi - ri) / d + 2) / 6 + else hue = ((ri - gi) / d + 4) / 6 + + return [hue * 360, s * 100, l * 100] +} + +function hslToHex(h: number, s: number, l: number): string { + const si = s / 100 + const li = l / 100 + + const c = (1 - Math.abs(2 * li - 1)) * si + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = li - c / 2 + + let r: number, g: number, b: number + if (h < 60) { r = c; g = x; b = 0 } + else if (h < 120) { r = x; g = c; b = 0 } + else if (h < 180) { r = 0; g = c; b = x } + else if (h < 240) { r = 0; g = x; b = c } + else if (h < 300) { r = x; g = 0; b = c } + else { r = c; g = 0; b = x } + + const toHex = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, '0') + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +// --------------------------------------------------------------------------- +// Hex ↔ RGB conversion +// --------------------------------------------------------------------------- + +function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace('#', '') + return [ + parseInt(h.substring(0, 2), 16), + parseInt(h.substring(2, 4), 16), + parseInt(h.substring(4, 6), 16), + ] +} + +function rgbToHex(r: number, g: number, b: number): string { + const toHex = (v: number) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0') + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Check whether a string is a valid 6-digit hex color (e.g. "#3b82f6"). */ +export function isValidHex(color: string): boolean { + return /^#[0-9a-fA-F]{6}$/.test(color) +} + +/** + * Detect whether a background color is dark (lightness < 50%). + */ +export function isDarkBackground(bgHex: string): boolean { + return hexToHsl(bgHex)[2] < 50 +} + +/** + * Mix two hex colors in RGB space. + * `ratio` controls how much of `fgHex` shows: 0 = pure bg, 1 = pure fg. + * Equivalent to alpha-compositing fg over bg at the given opacity. + */ +export function mixHexColors(bgHex: string, fgHex: string, ratio: number): string { + const [br, bg, bb] = hexToRgb(bgHex) + const [fr, fg, fb] = hexToRgb(fgHex) + const inv = 1 - ratio + return rgbToHex(br * inv + fr * ratio, bg * inv + fg * ratio, bb * inv + fb * ratio) +} + +/** + * Get the hex color for a series index. + * Index 0 returns the accent color as-is. + * Index 1+ alternate between darker and lighter shades of the same hue + * with subtle hue drift (±8-12° per tier) to stay in the same family. + * + * When `bgColor` is provided, shade direction adapts to the background: + * - Light bg: odd = darker, even = lighter (default) + * - Dark bg: odd = lighter, even = darker (so shades stay visible) + */ +export function getSeriesColor(index: number, accentColor: string, bgColor?: string): string { + if (index === 0) return accentColor + // Fall back to defaults when inputs aren't valid hex (e.g. CSS variable refs like "var(--accent)") + const safeAccent = isValidHex(accentColor) ? accentColor : CHART_ACCENT_FALLBACK + const safeBg = bgColor && isValidHex(bgColor) ? bgColor : undefined + const [h, s] = hexToHsl(safeAccent) + const chartS = Math.max(55, Math.min(85, s)) + + const tier = Math.ceil(index / 2) + const oddIndex = index % 2 === 1 + + // On dark backgrounds, flip: odd = lighter, even = darker + const dark = safeBg && isDarkBackground(safeBg) ? !oddIndex : oddIndex + const l = dark + ? Math.max(25, 48 - tier * 13) + : Math.min(78, 55 + tier * 11) + + // Subtle hue drift: darker shades shift slightly negative, lighter shift positive + const hShift = (dark ? -8 : 12) * tier + const newH = ((h + hShift) % 360 + 360) % 360 + + return hslToHex(newH, chartS, l) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/xychart/parser.ts b/packages/utils/src/vendor/mermaid-ascii/xychart/parser.ts new file mode 100644 index 000000000..29f8595dc --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/xychart/parser.ts @@ -0,0 +1,115 @@ +import type { XYChart, XYAxis, XYChartSeries } from './types' + +// ============================================================================ +// XY Chart parser +// +// Parses Mermaid xychart-beta syntax into a typed XYChart structure. +// +// Supported directives: +// xychart-beta [horizontal] +// title "Chart Title" +// x-axis [label1, label2, ...] — categorical +// x-axis min --> max — numeric range +// x-axis "Axis Title" [label1, ...] — with title +// x-axis "Axis Title" min --> max — with title +// y-axis (same patterns) +// bar [val1, val2, ...] +// line [val1, val2, ...] +// ============================================================================ + +/** + * Parse a Mermaid xychart-beta diagram from preprocessed lines. + * Lines should already be trimmed and comment-stripped. + */ +export function parseXYChart(lines: string[]): XYChart { + const xAxis: XYAxis = {} + const yAxis: XYAxis = {} + const series: XYChartSeries[] = [] + let title: string | undefined + let horizontal = false + + for (const line of lines) { + // Header line — detect horizontal + if (/^xychart(-beta)?\b/i.test(line)) { + if (/\bhorizontal\b/i.test(line)) horizontal = true + continue + } + + // Title + const titleMatch = line.match(/^title\s+"([^"]+)"/) + if (titleMatch) { + title = titleMatch[1] + continue + } + + // x-axis with categories: x-axis "Title" [a, b, c] or x-axis [a, b, c] + const xCatMatch = line.match(/^x-axis\s+(?:"([^"]*)"\s*)?\[([^\]]+)\]/) + if (xCatMatch) { + if (xCatMatch[1]) xAxis.title = xCatMatch[1] + xAxis.categories = xCatMatch[2]!.split(',').map(s => s.trim()) + continue + } + + // x-axis with range: x-axis "Title" min --> max or x-axis min --> max + const xRangeMatch = line.match(/^x-axis\s+(?:"([^"]*)"\s+)?(-?\d+(?:\.\d+)?)\s*-->\s*(-?\d+(?:\.\d+)?)/) + if (xRangeMatch) { + if (xRangeMatch[1]) xAxis.title = xRangeMatch[1] + xAxis.range = { min: parseFloat(xRangeMatch[2]!), max: parseFloat(xRangeMatch[3]!) } + continue + } + + // y-axis with range: y-axis "Title" min --> max or y-axis min --> max + const yRangeMatch = line.match(/^y-axis\s+(?:"([^"]*)"\s+)?(-?\d+(?:\.\d+)?)\s*-->\s*(-?\d+(?:\.\d+)?)/) + if (yRangeMatch) { + if (yRangeMatch[1]) yAxis.title = yRangeMatch[1] + yAxis.range = { min: parseFloat(yRangeMatch[2]!), max: parseFloat(yRangeMatch[3]!) } + continue + } + + // y-axis with just title (no range) + const yTitleOnly = line.match(/^y-axis\s+"([^"]+)"\s*$/) + if (yTitleOnly) { + yAxis.title = yTitleOnly[1] + continue + } + + // bar [...] + const barMatch = line.match(/^bar\s+\[([^\]]+)\]/) + if (barMatch) { + series.push({ type: 'bar', data: parseNumericArray(barMatch[1]!) }) + continue + } + + // line [...] + const lineMatch = line.match(/^line\s+\[([^\]]+)\]/) + if (lineMatch) { + series.push({ type: 'line', data: parseNumericArray(lineMatch[1]!) }) + continue + } + } + + // Auto-derive y-axis range from data if not specified + if (!yAxis.range && series.length > 0) { + const allValues = series.flatMap(s => s.data) + let min = Math.min(...allValues) + let max = Math.max(...allValues) + const span = max - min || 1 + // Add 10% padding + min = min - span * 0.1 + max = max + span * 0.1 + // Floor to 0 if all values are positive and min is close to 0 + if (min > 0 && min < span * 0.5) min = 0 + yAxis.range = { min, max } + } + + // Fallback y-axis range + if (!yAxis.range) { + yAxis.range = { min: 0, max: 100 } + } + + return { title, horizontal, xAxis, yAxis, series } +} + +function parseNumericArray(str: string): number[] { + return str.split(',').map(s => parseFloat(s.trim())) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/xychart/types.ts b/packages/utils/src/vendor/mermaid-ascii/xychart/types.ts new file mode 100644 index 000000000..920c98132 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/xychart/types.ts @@ -0,0 +1,150 @@ +// ============================================================================ +// XY Chart types +// +// Models the parsed and positioned representations of a Mermaid xychart-beta +// diagram. Supports bar charts, line charts, and combinations with categorical +// or numeric x-axes. +// ============================================================================ + +/** Parsed XY chart — logical structure from mermaid text */ +export interface XYChart { + /** Optional chart title */ + title?: string + /** Chart orientation: vertical (default) or horizontal */ + horizontal: boolean + /** X-axis configuration */ + xAxis: XYAxis + /** Y-axis configuration */ + yAxis: XYAxis + /** Data series (bar and/or line) */ + series: XYChartSeries[] +} + +/** Axis configuration — categorical (labels) or numeric (range) */ +export interface XYAxis { + /** Optional axis title/label */ + title?: string + /** Categorical labels (e.g., ["jan", "feb", "mar"]) — mutually exclusive with range */ + categories?: string[] + /** Numeric range — mutually exclusive with categories */ + range?: { min: number; max: number } +} + +/** A single data series (bar or line) */ +export interface XYChartSeries { + /** Series type */ + type: 'bar' | 'line' + /** Data values — one per category, or evenly spaced across numeric range */ + data: number[] +} + +// ============================================================================ +// Positioned XY chart — ready for SVG rendering +// ============================================================================ + +export interface PositionedXYChart { + width: number + height: number + /** Whether this is a horizontal (rotated) chart */ + horizontal?: boolean + /** Title text and position (if present) */ + title?: PositionedTitle + /** Positioned x-axis with tick marks and labels */ + xAxis: PositionedAxis + /** Positioned y-axis with tick marks and labels */ + yAxis: PositionedAxis + /** The plot area bounds (inside axes) */ + plotArea: PlotArea + /** Positioned bar groups */ + bars: PositionedBar[] + /** Positioned line polylines */ + lines: PositionedLine[] + /** Horizontal grid lines for readability */ + gridLines: GridLine[] + /** Legend items (shown when multiple series) */ + legend: LegendItem[] +} + +export interface LegendItem { + /** Display label */ + label: string + /** Position of the swatch/icon */ + x: number + y: number + /** Series type determines swatch shape (rect for bar, line+dot for line) */ + type: 'bar' | 'line' + /** Series index within its type (for layout grouping) */ + seriesIndex: number + /** Global color index across all series (for unified color assignment) */ + colorIndex: number +} + +export interface PositionedTitle { + text: string + x: number + y: number +} + +export interface PositionedAxis { + /** Optional axis title text and position */ + title?: { text: string; x: number; y: number; rotate?: number } + /** Tick positions along the axis */ + ticks: AxisTick[] + /** Axis line: start and end coordinates */ + line: { x1: number; y1: number; x2: number; y2: number } +} + +export interface AxisTick { + /** Label text for this tick */ + label: string + /** Position of the tick mark on the axis */ + x: number + y: number + /** End of the tick mark (short perpendicular line) */ + tx: number + ty: number + /** Label anchor position */ + labelX: number + labelY: number + /** Text anchor for label */ + textAnchor: 'start' | 'middle' | 'end' +} + +export interface PlotArea { + x: number + y: number + width: number + height: number +} + +export interface PositionedBar { + /** Bar rectangle in SVG coordinates */ + x: number + y: number + width: number + height: number + /** Original data value */ + value: number + /** Category label for this bar (e.g. "Jan") */ + label?: string + /** Series index within bar type (for layout grouping) */ + seriesIndex: number + /** Global color index across all series */ + colorIndex: number +} + +export interface PositionedLine { + /** Polyline points */ + points: Array<{ x: number; y: number; value: number; label?: string }> + /** Series index within line type (for layout grouping) */ + seriesIndex: number + /** Global color index across all series */ + colorIndex: number +} + +export interface GridLine { + x1: number + y1: number + x2: number + y2: number +} diff --git a/packages/utils/test/mermaid/class-arrows.test.ts b/packages/utils/test/mermaid/class-arrows.test.ts new file mode 100644 index 000000000..b979b6e5b --- /dev/null +++ b/packages/utils/test/mermaid/class-arrows.test.ts @@ -0,0 +1,413 @@ +/** + * Comprehensive tests for class diagram arrow directions. + * + * Ensures all relationship types have correctly oriented arrows: + * - Inheritance/Realization: hollow triangles point toward parent/interface + * - Association/Dependency: filled arrows point from source to target + * - Composition/Aggregation: diamonds are omnidirectional + */ + +import { describe, expect, test } from "bun:test"; +import { renderMermaidAscii } from "../../src/vendor/mermaid-ascii/ascii/index"; + +describe("Class Diagram Arrow Directions", () => { + // ============================================================================ + // INHERITANCE (<|--) + // ============================================================================ + + describe("Inheritance (<|--)", () => { + test("parent above child - triangle points UP toward parent", () => { + const diagram = `classDiagram + Animal <|-- Dog`; + const result = renderMermaidAscii(diagram); + + // Should contain upward triangle + expect(result).toContain("△"); + expect(result).not.toContain("▽"); + + // Parent should be above child + const lines = result.split("\n"); + const animalLine = lines.findIndex(l => l.includes("Animal")); + const dogLine = lines.findIndex(l => l.includes("Dog")); + expect(animalLine).toBeLessThan(dogLine); + }); + + test("multiple inheritance creates separate arrows", () => { + const diagram = `classDiagram + Animal <|-- Dog + Animal <|-- Cat + Dog <|-- Puppy`; + const result = renderMermaidAscii(diagram); + + // Animal should be at top, then Dog/Cat, then Puppy + const lines = result.split("\n"); + const animalLine = lines.findIndex(l => l.includes("Animal")); + const dogLine = lines.findIndex(l => l.includes("Dog")); + const catLine = lines.findIndex(l => l.includes("Cat")); + const puppyLine = lines.findIndex(l => l.includes("Puppy")); + + expect(animalLine).toBeLessThan(dogLine); + expect(animalLine).toBeLessThan(catLine); + expect(dogLine).toBeLessThan(puppyLine); + }); + + test("multi-level inheritance - all triangles point UP", () => { + const diagram = `classDiagram + Animal <|-- Mammal + Mammal <|-- Dog`; + const result = renderMermaidAscii(diagram); + + // Verify ordering: Animal > Mammal > Dog (top to bottom) + const lines = result.split("\n"); + const animalLine = lines.findIndex(l => l.includes("Animal")); + const mammalLine = lines.findIndex(l => l.includes("Mammal")); + const dogLine = lines.findIndex(l => l.includes("Dog")); + + expect(animalLine).toBeLessThan(mammalLine); + expect(mammalLine).toBeLessThan(dogLine); + + // All triangles should point up + expect(result.match(/△/g)?.length).toBe(2); + }); + + test("multiple inheritance from same parent", () => { + const diagram = `classDiagram + Animal <|-- Dog + Animal <|-- Cat`; + const result = renderMermaidAscii(diagram); + + // Animal should be above both children + const lines = result.split("\n"); + const animalLine = lines.findIndex(l => l.includes("Animal")); + const dogLine = lines.findIndex(l => l.includes("Dog")); + const catLine = lines.findIndex(l => l.includes("Cat")); + + expect(animalLine).toBeLessThan(dogLine); + expect(animalLine).toBeLessThan(catLine); + + // Should have at least one triangle pointing up (may merge visually) + expect(result).toContain("△"); + }); + + test("ASCII mode uses ^ for upward triangle", () => { + const diagram = `classDiagram + Animal <|-- Dog`; + const result = renderMermaidAscii(diagram, { useAscii: true }); + + expect(result).toContain("^"); + expect(result).not.toContain("v"); + }); + }); + + // ============================================================================ + // ASSOCIATION (-->) + // ============================================================================ + + describe("Association (-->)", () => { + test("source above target - arrow points DOWN", () => { + const diagram = `classDiagram + Person --> Address`; + const result = renderMermaidAscii(diagram); + + // Should contain downward arrow + expect(result).toContain("▼"); + expect(result).not.toContain("▲"); + + // Person should be above Address + const lines = result.split("\n"); + const personLine = lines.findIndex(l => l.includes("Person")); + const addressLine = lines.findIndex(l => l.includes("Address")); + expect(personLine).toBeLessThan(addressLine); + }); + + test("multiple associations from same source", () => { + const diagram = `classDiagram + Person --> Address + Person --> Phone`; + const result = renderMermaidAscii(diagram); + + // Person should be above both targets + const lines = result.split("\n"); + const personLine = lines.findIndex(l => l.includes("Person")); + const addressLine = lines.findIndex(l => l.includes("Address")); + const phoneLine = lines.findIndex(l => l.includes("Phone")); + + expect(personLine).toBeLessThan(addressLine); + expect(personLine).toBeLessThan(phoneLine); + }); + + test("chain of associations", () => { + const diagram = `classDiagram + A --> B + B --> C`; + const result = renderMermaidAscii(diagram); + + // A > B > C ordering + const lines = result.split("\n"); + const aLine = lines.findIndex(l => l.includes("│ A │")); + const bLine = lines.findIndex(l => l.includes("│ B │")); + const cLine = lines.findIndex(l => l.includes("│ C │")); + + expect(aLine).toBeLessThan(bLine); + expect(bLine).toBeLessThan(cLine); + + // Both arrows point down + expect(result.match(/▼/g)?.length).toBe(2); + }); + + test("ASCII mode uses v for downward arrow", () => { + const diagram = `classDiagram + Person --> Address`; + const result = renderMermaidAscii(diagram, { useAscii: true }); + + expect(result).toContain("v"); + expect(result).not.toContain("^"); + }); + }); + + // ============================================================================ + // DEPENDENCY (..>) + // ============================================================================ + + describe("Dependency (..>)", () => { + test("source above target - arrow points DOWN", () => { + const diagram = `classDiagram + Client ..> Server`; + const result = renderMermaidAscii(diagram); + + expect(result).toContain("▼"); + expect(result).not.toContain("▲"); + + const lines = result.split("\n"); + const clientLine = lines.findIndex(l => l.includes("Client")); + const serverLine = lines.findIndex(l => l.includes("Server")); + expect(clientLine).toBeLessThan(serverLine); + }); + + test("multiple dependencies", () => { + const diagram = `classDiagram + Client ..> Server + Client ..> Database`; + const result = renderMermaidAscii(diagram); + + const lines = result.split("\n"); + const clientLine = lines.findIndex(l => l.includes("Client")); + const serverLine = lines.findIndex(l => l.includes("Server")); + const dbLine = lines.findIndex(l => l.includes("Database")); + + expect(clientLine).toBeLessThan(serverLine); + expect(clientLine).toBeLessThan(dbLine); + }); + + test("ASCII mode uses v for downward arrow", () => { + const diagram = `classDiagram + Client ..> Server`; + const result = renderMermaidAscii(diagram, { useAscii: true }); + + expect(result).toContain("v"); + }); + }); + + // ============================================================================ + // REALIZATION (..|>) + // ============================================================================ + + describe("Realization (..|>)", () => { + test("interface above implementation - triangle points UP", () => { + // Circle ..|> Shape means "Circle implements Shape" + // Shape (interface/parent) should be placed ABOVE Circle (implementation/child) + const diagram = `classDiagram + Circle ..|> Shape`; + const result = renderMermaidAscii(diagram); + + // Shape (interface) should be above Circle (implementation) + const lines = result.split("\n"); + const shapeLine = lines.findIndex(l => l.includes("Shape")); + const circleLine = lines.findIndex(l => l.includes("Circle")); + expect(shapeLine).toBeLessThan(circleLine); + expect(result).toContain("△"); + }); + + test("realization with <|.. syntax (marker at from end)", () => { + // Shape <|.. Circle means "Circle implements Shape" (same as Circle ..|> Shape) + const diagram = `classDiagram + Shape <|.. Circle`; + const result = renderMermaidAscii(diagram); + + // Shape (interface) should be above Circle (implementation) + const lines = result.split("\n"); + const shapeLine = lines.findIndex(l => l.includes("Shape")); + const circleLine = lines.findIndex(l => l.includes("Circle")); + expect(shapeLine).toBeLessThan(circleLine); + expect(result).toContain("△"); + }); + + test("multiple implementations", () => { + // Circle and Square both implement Shape + const diagram = `classDiagram + Circle ..|> Shape + Square ..|> Shape`; + const result = renderMermaidAscii(diagram); + + // Shape (interface) above both implementations + const lines = result.split("\n"); + const shapeLine = lines.findIndex(l => l.includes("Shape")); + const circleLine = lines.findIndex(l => l.includes("Circle")); + const squareLine = lines.findIndex(l => l.includes("Square")); + + expect(shapeLine).toBeLessThan(circleLine); + expect(shapeLine).toBeLessThan(squareLine); + // At least one triangle (may merge visually if same connection point) + expect(result).toContain("△"); + }); + }); + + // ============================================================================ + // COMPOSITION & AGGREGATION (omnidirectional diamonds) + // ============================================================================ + + describe("Composition (*--) and Aggregation (o--)", () => { + test("composition - diamond is omnidirectional", () => { + const diagram = `classDiagram + Car *-- Engine`; + const result = renderMermaidAscii(diagram); + + // Should contain filled diamond + expect(result).toContain("◆"); + }); + + test("aggregation - hollow diamond is omnidirectional", () => { + const diagram = `classDiagram + Team o-- Player`; + const result = renderMermaidAscii(diagram); + + // Should contain hollow diamond + expect(result).toContain("◇"); + }); + }); + + // ============================================================================ + // MIXED SCENARIOS + // ============================================================================ + + describe("Mixed Relationship Scenarios", () => { + test("all 6 relationship types together", () => { + const diagram = `classDiagram + A <|-- B : inheritance + C *-- D : composition + E o-- F : aggregation + G --> H : association + I ..> J : dependency + K ..|> L : realization`; + const result = renderMermaidAscii(diagram); + + // Upward triangles for inheritance and realization + expect(result.match(/△/g)?.length).toBe(2); + + // Downward arrows for association and dependency + expect(result.match(/▼/g)?.length).toBe(2); + + // Diamonds for composition and aggregation + expect(result).toContain("◆"); + expect(result).toContain("◇"); + }); + + test("inheritance with association - different arrow directions", () => { + const diagram = `classDiagram + Animal <|-- Dog + Dog --> Food`; + const result = renderMermaidAscii(diagram); + + // Should have both up triangle (inheritance) and down arrow (association) + expect(result).toContain("△"); + expect(result).toContain("▼"); + }); + + test("circular reference creates valid layout", () => { + const diagram = `classDiagram + A --> B + B --> C + C ..> A`; + const result = renderMermaidAscii(diagram); + + // Cycles may create mixed arrow directions (up and down) to avoid overlaps + // Just verify arrows are present and classes are rendered + const hasUpArrow = result.includes("▲"); + const hasDownArrow = result.includes("▼"); + expect(hasUpArrow || hasDownArrow).toBe(true); + expect(result).toContain("│ A │"); + expect(result).toContain("│ B │"); + expect(result).toContain("│ C │"); + }); + }); + + // ============================================================================ + // ASCII vs UNICODE CONSISTENCY + // ============================================================================ + + describe("ASCII and Unicode Mode Consistency", () => { + test("same diagram produces consistent layouts in both modes", () => { + const diagram = `classDiagram + Animal <|-- Dog + Person --> Address`; + + const unicode = renderMermaidAscii(diagram); + const ascii = renderMermaidAscii(diagram, { useAscii: true }); + + // Both should have same node ordering + const unicodeLines = unicode.split("\n"); + const asciiLines = ascii.split("\n"); + + const uAnimal = unicodeLines.findIndex(l => l.includes("Animal")); + const uDog = unicodeLines.findIndex(l => l.includes("Dog")); + const aPerson = asciiLines.findIndex(l => l.includes("Person")); + const aAddress = asciiLines.findIndex(l => l.includes("Address")); + + expect(uAnimal).toBeLessThan(uDog); + expect(aPerson).toBeLessThan(aAddress); + + // Unicode has △ and ▼, ASCII has ^ and v + expect(unicode).toContain("△"); + expect(unicode).toContain("▼"); + expect(ascii).toContain("^"); + expect(ascii).toContain("v"); + }); + }); + + // ============================================================================ + // EDGE CASES + // ============================================================================ + + describe("Edge Cases", () => { + test("single inheritance relationship", () => { + const diagram = `classDiagram + A <|-- B`; + const result = renderMermaidAscii(diagram); + + expect(result).toContain("△"); + const lines = result.split("\n"); + const aLine = lines.findIndex(l => l.includes("│ A │")); + const bLine = lines.findIndex(l => l.includes("│ B │")); + expect(aLine).toBeLessThan(bLine); + }); + + test("classes with members maintain arrow directions", () => { + const diagram = `classDiagram + class Animal { + +String name + +eat() void + } + class Dog { + +bark() void + } + Animal <|-- Dog`; + const result = renderMermaidAscii(diagram); + + expect(result).toContain("△"); + const lines = result.split("\n"); + const animalLine = lines.findIndex(l => l.includes("Animal")); + const dogLine = lines.findIndex(l => l.includes("Dog")); + expect(animalLine).toBeLessThan(dogLine); + }); + }); +}); diff --git a/packages/utils/test/mermaid/edge-styles.test.ts b/packages/utils/test/mermaid/edge-styles.test.ts new file mode 100644 index 000000000..d86f9ea0a --- /dev/null +++ b/packages/utils/test/mermaid/edge-styles.test.ts @@ -0,0 +1,149 @@ +// ============================================================================ +// ASCII edge style tests — dotted and thick line rendering +// ============================================================================ + +import { describe, expect, it } from "bun:test"; +import { renderMermaidAscii } from "../../src/vendor/mermaid-ascii/ascii/index"; + +describe("ASCII edge styles", () => { + describe("solid edges (default)", () => { + it("renders solid edges with ─ in unicode mode", () => { + const result = renderMermaidAscii(` + graph LR + A --> B + `); + expect(result).toContain("─"); + expect(result).not.toContain("┄"); + expect(result).not.toContain("━"); + }); + + it("renders solid edges with - in ascii mode", () => { + const result = renderMermaidAscii( + ` + graph LR + A --> B + `, + { useAscii: true }, + ); + expect(result).toContain("-"); + }); + }); + + describe("dotted edges (-.->)", () => { + it("renders dotted edges with ┄ in unicode mode", () => { + const result = renderMermaidAscii(` + graph LR + A -.-> B + `); + // Should contain dotted horizontal line character + expect(result).toContain("┄"); + }); + + it("renders dotted edges with . in ascii mode", () => { + const result = renderMermaidAscii( + ` + graph LR + A -.-> B + `, + { useAscii: true }, + ); + // Should contain dots for dotted lines + expect(result).toContain("."); + }); + + it("renders dotted vertical edges with ┆ in unicode mode", () => { + const result = renderMermaidAscii(` + graph TD + A -.-> B + `); + // Should contain dotted vertical line character + expect(result).toContain("┆"); + }); + + it("renders dotted vertical edges with : in ascii mode", () => { + const result = renderMermaidAscii( + ` + graph TD + A -.-> B + `, + { useAscii: true }, + ); + // Should contain colons for dotted vertical lines + expect(result).toContain(":"); + }); + + it("renders dotted edges with labels", () => { + const result = renderMermaidAscii(` + graph LR + A -.->|optional| B + `); + expect(result).toContain("┄"); + expect(result).toContain("optional"); + }); + }); + + describe("thick edges (==>)", () => { + it("renders thick edges with ━ in unicode mode", () => { + const result = renderMermaidAscii(` + graph LR + A ==> B + `); + // Should contain thick horizontal line character + expect(result).toContain("━"); + }); + + it("renders thick edges with = in ascii mode", () => { + const result = renderMermaidAscii( + ` + graph LR + A ==> B + `, + { useAscii: true }, + ); + // Should contain equals for thick lines + expect(result).toContain("="); + }); + + it("renders thick vertical edges with ┃ in unicode mode", () => { + const result = renderMermaidAscii(` + graph TD + A ==> B + `); + // Should contain thick vertical line character + expect(result).toContain("┃"); + }); + }); + + describe("mixed edge styles", () => { + it("renders different styles in the same diagram", () => { + const result = renderMermaidAscii(` + graph LR + A --> B + B -.-> C + C ==> D + `); + // Should have all three line types + expect(result).toContain("─"); // solid + expect(result).toContain("┄"); // dotted + expect(result).toContain("━"); // thick + }); + + it("renders mixed styles in ascii mode", () => { + const result = renderMermaidAscii( + ` + graph LR + A --> B + B -.-> C + C ==> D + `, + { useAscii: true }, + ); + // Note: ASCII mode uses - for solid, . for dotted, = for thick + // We just check that the diagram renders without error + expect(result).toContain("A"); + expect(result).toContain("B"); + expect(result).toContain("C"); + expect(result).toContain("D"); + }); + }); +}); diff --git a/packages/utils/test/mermaid/formatting.test.ts b/packages/utils/test/mermaid/formatting.test.ts new file mode 100644 index 000000000..e5765a4a9 --- /dev/null +++ b/packages/utils/test/mermaid/formatting.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; +import { renderMermaidAscii } from "../../src/mermaid-ascii"; + +// The vendored renderer is ASCII-only, so inline formatting (HTML tags and +// markdown emphasis) is reduced to plain text rather than preserved — otherwise +// the raw tags/markers would print inside the node box. Exercised through the +// public `@oh-my-pi/pi-utils` wrapper so the dependency-removal path stays covered. +describe("mermaid ASCII inline-formatting stripping", () => { + const render = (label: string): string => renderMermaidAscii(`flowchart TD\n A[${label}]`, { colorMode: "none" }); + + it("strips markdown bold/italic/strikethrough markers, keeping the text", () => { + const out = render("**bold** *em* ~~gone~~"); + expect(out).toContain("bold"); + expect(out).toContain("em"); + expect(out).toContain("gone"); + expect(out).not.toContain("**"); + expect(out).not.toContain("~~"); + expect(out).not.toContain("*em*"); + }); + + it("strips inline HTML formatting tags, keeping the text", () => { + const out = render("strong and slanted"); + expect(out).toContain("strong"); + expect(out).toContain("slanted"); + expect(out).not.toContain(""); + expect(out).not.toContain(""); + expect(out).not.toContain(""); + }); +}); diff --git a/packages/utils/test/mermaid/golden.test.ts b/packages/utils/test/mermaid/golden.test.ts new file mode 100644 index 000000000..5729217a1 --- /dev/null +++ b/packages/utils/test/mermaid/golden.test.ts @@ -0,0 +1,253 @@ +/** + * Golden-file tests for the ASCII/Unicode renderer. + * + * Ported from AlexanderGrooff/mermaid-ascii cmd/graph_test.go. + * Each .txt file contains mermaid input above a `---` separator + * and the expected ASCII/Unicode output below it. + * + * Test data: 44 ASCII files + 22 Unicode files = 66 total. + */ +import { describe, expect, it } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { renderMermaidAscii } from "../../src/vendor/mermaid-ascii/ascii/index"; +import { DIAGONAL_CHARS, hasDiagonalLines } from "../../src/vendor/mermaid-ascii/ascii/validate"; + +// ============================================================================ +// Test case parser — matches Go's testutil.ReadTestCase format +// ============================================================================ + +interface TestCase { + mermaid: string; + expected: string; + paddingX: number; + paddingY: number; +} + +/** + * Parse a golden test file into its components. + * Format: + * [paddingX=N] (optional) + * [paddingY=N] (optional) + * + * --- + * + */ +function parseTestCase(content: string): TestCase { + const tc: TestCase = { mermaid: "", expected: "", paddingX: 5, paddingY: 5 }; + const lines = content.split("\n"); + const paddingRegex = /^(?:padding([xy]))\s*=\s*(\d+)\s*$/i; + + let inMermaid = true; + let mermaidStarted = false; + const mermaidLines: string[] = []; + const expectedLines: string[] = []; + + for (const line of lines) { + if (line === "---") { + inMermaid = false; + continue; + } + + if (inMermaid) { + const trimmed = line.trim(); + + // Before mermaid code starts, parse padding directives and skip blanks + if (!mermaidStarted) { + if (trimmed === "") continue; + const match = trimmed.match(paddingRegex); + if (match) { + const value = parseInt(match[2]!, 10); + if (match[1]!.toLowerCase() === "x") { + tc.paddingX = value; + } else { + tc.paddingY = value; + } + continue; + } + } + + mermaidStarted = true; + mermaidLines.push(line); + } else { + expectedLines.push(line); + } + } + + tc.mermaid = `${mermaidLines.join("\n")}\n`; + + // Strip final trailing newline (matches Go's strings.TrimSuffix(expected, "\n")) + let expected = expectedLines.join("\n"); + if (expected.endsWith("\n")) { + expected = expected.slice(0, -1); + } + tc.expected = expected; + + return tc; +} + +// ============================================================================ +// Whitespace normalization — matches Go's testutil.NormalizeWhitespace +// ============================================================================ + +/** + * Normalize whitespace for comparison: + * - Trim trailing spaces from each line + * - Remove leading/trailing blank lines + */ +function normalizeWhitespace(s: string): string { + const lines = s.split("\n"); + const normalized = lines.map(l => l.trimEnd()); + + // Remove leading blank lines + while (normalized.length > 0 && normalized[0] === "") { + normalized.shift(); + } + // Remove trailing blank lines + while (normalized.length > 0 && normalized[normalized.length - 1] === "") { + normalized.pop(); + } + + return normalized.join("\n"); +} + +/** Replace spaces with middle dots for clearer diff output. */ +function visualizeWhitespace(s: string): string { + return s.replaceAll(" ", "·"); +} + +// ============================================================================ +// Test runner — dynamically loads all golden files from testdata directories +// ============================================================================ + +function runGoldenTests(dir: string, useAscii: boolean): void { + const files = readdirSync(dir) + .filter(f => f.endsWith(".txt")) + .sort(); + + for (const file of files) { + const testName = file.replace(".txt", ""); + + it(testName, () => { + const content = readFileSync(join(dir, file), "utf-8"); + const tc = parseTestCase(content); + + const actual = renderMermaidAscii(tc.mermaid, { + useAscii, + paddingX: tc.paddingX, + paddingY: tc.paddingY, + }); + + const normalizedExpected = normalizeWhitespace(tc.expected); + const normalizedActual = normalizeWhitespace(actual); + + if (normalizedExpected !== normalizedActual) { + const expectedVis = visualizeWhitespace(normalizedExpected); + const actualVis = visualizeWhitespace(normalizedActual); + expect(actualVis).toBe(expectedVis); + } + }); + } +} + +// ============================================================================ +// Test suites +// ============================================================================ + +const testdataDir = join(import.meta.dir, "testdata"); + +describe("ASCII rendering", () => { + runGoldenTests(join(testdataDir, "ascii"), true); +}); + +describe("Unicode rendering", () => { + runGoldenTests(join(testdataDir, "unicode"), false); +}); + +// ============================================================================ +// Config behavior tests — ported from Go's TestGraphUseAsciiConfig +// ============================================================================ + +describe("Config behavior", () => { + const mermaidInput = "graph LR\nA --> B"; + + it("ASCII and Unicode outputs should differ", () => { + const asciiOutput = renderMermaidAscii(mermaidInput, { useAscii: true }); + const unicodeOutput = renderMermaidAscii(mermaidInput, { useAscii: false }); + expect(asciiOutput).not.toBe(unicodeOutput); + }); + + it("ASCII output should not contain Unicode box-drawing characters", () => { + const output = renderMermaidAscii(mermaidInput, { useAscii: true }); + expect(output).not.toContain("┌"); + expect(output).not.toContain("─"); + expect(output).not.toContain("│"); + }); + + it("Unicode output should contain Unicode box-drawing characters", () => { + const output = renderMermaidAscii(mermaidInput, { useAscii: false }); + const hasUnicode = output.includes("┌") || output.includes("─") || output.includes("│"); + expect(hasUnicode).toBe(true); + }); +}); + +// ============================================================================ +// Diagonal validation — ensures all edges use orthogonal Manhattan routing +// ============================================================================ + +describe("Diagonal validation", () => { + const asciiDir = join(testdataDir, "ascii"); + const unicodeDir = join(testdataDir, "unicode"); + + it("ASCII output should never contain diagonal characters", () => { + // Test all ASCII golden files + const files = readdirSync(asciiDir).filter(f => f.endsWith(".txt")); + for (const file of files) { + const content = readFileSync(join(asciiDir, file), "utf-8"); + const { mermaid, paddingX, paddingY } = parseTestCase(content); + const output = renderMermaidAscii(mermaid, { + useAscii: true, + boxBorderPadding: paddingX, + paddingY: paddingY, + }); + + // Check for diagonal characters + for (const char of DIAGONAL_CHARS.ascii) { + expect(output).not.toContain(char); + } + } + }); + + it("Unicode output should never contain diagonal characters", () => { + // Test all Unicode golden files + const files = readdirSync(unicodeDir).filter(f => f.endsWith(".txt")); + for (const file of files) { + const content = readFileSync(join(unicodeDir, file), "utf-8"); + const { mermaid, paddingX, paddingY } = parseTestCase(content); + const output = renderMermaidAscii(mermaid, { + useAscii: false, + boxBorderPadding: paddingX, + paddingY: paddingY, + }); + + // Check for diagonal characters + for (const char of DIAGONAL_CHARS.unicode) { + expect(output).not.toContain(char); + } + } + }); + + it("hasDiagonalLines utility correctly detects diagonal characters", () => { + // Should detect ASCII diagonals + expect(hasDiagonalLines("A / B")).toBe(true); + expect(hasDiagonalLines("A \\ B")).toBe(true); + + // Should detect Unicode diagonals + expect(hasDiagonalLines("A ╱ B")).toBe(true); + expect(hasDiagonalLines("A ╲ B")).toBe(true); + + // Should not flag clean output + expect(hasDiagonalLines("┌───┐\n│ A │\n└───┘")).toBe(false); + expect(hasDiagonalLines("+---+\n| A |\n+---+")).toBe(false); + }); +}); diff --git a/packages/utils/test/mermaid/multiline.test.ts b/packages/utils/test/mermaid/multiline.test.ts new file mode 100644 index 000000000..3c26acf1a --- /dev/null +++ b/packages/utils/test/mermaid/multiline.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "bun:test"; +import { renderMermaidAscii } from "../../src/vendor/mermaid-ascii/ascii/index"; + +describe("ASCII multi-line labels", () => { + describe("flowchart nodes", () => { + it("renders multi-line node labels", () => { + const ascii = renderMermaidAscii("graph TD\n A[Line1
Line2]", { useAscii: false }); + expect(ascii).toContain("Line1"); + expect(ascii).toContain("Line2"); + // Lines should be on different rows + const lines = ascii.split("\n"); + const line1Row = lines.findIndex(l => l.includes("Line1")); + const line2Row = lines.findIndex(l => l.includes("Line2")); + expect(line2Row).toBeGreaterThan(line1Row); + }); + + it("handles 3+ line labels", () => { + const ascii = renderMermaidAscii("graph TD\n A[A
B
C]", { useAscii: false }); + expect(ascii).toContain("A"); + expect(ascii).toContain("B"); + expect(ascii).toContain("C"); + // Verify vertical ordering + const lines = ascii.split("\n"); + const aRow = lines.findIndex(l => l.includes("A") && !l.includes("─") && !l.includes("-")); + const bRow = lines.findIndex(l => l.includes("B")); + const cRow = lines.findIndex(l => l.includes("C")); + expect(bRow).toBeGreaterThan(aRow); + expect(cRow).toBeGreaterThan(bRow); + }); + + it("renders in ASCII mode (not Unicode)", () => { + const ascii = renderMermaidAscii("graph TD\n A[Line1
Line2]", { useAscii: true }); + expect(ascii).toContain("Line1"); + expect(ascii).toContain("Line2"); + // Should use ASCII box characters + expect(ascii).toContain("+"); + expect(ascii).toContain("-"); + }); + }); + + describe("flowchart edge labels", () => { + it("renders multi-line edge labels", () => { + const ascii = renderMermaidAscii("graph TD\n A --> B\n A -->|Line1
Line2| C", { useAscii: false }); + expect(ascii).toContain("Line1"); + expect(ascii).toContain("Line2"); + }); + }); + + describe("flowchart subgraph labels", () => { + it("renders multi-line subgraph labels", () => { + const ascii = renderMermaidAscii( + `graph TD + subgraph sg [Group
Header] + A[Node] + end + `, + { useAscii: false }, + ); + expect(ascii).toContain("Group"); + expect(ascii).toContain("Header"); + }); + }); + + describe("sequence diagram", () => { + it("renders multi-line actor labels", () => { + const ascii = renderMermaidAscii( + `sequenceDiagram + participant A as Actor
One + A->>A: msg + `, + { useAscii: false }, + ); + expect(ascii).toContain("Actor"); + expect(ascii).toContain("One"); + }); + + it("renders multi-line message labels", () => { + const ascii = renderMermaidAscii( + `sequenceDiagram + participant A + participant B + A->>B: Line1
Line2 + `, + { useAscii: false }, + ); + expect(ascii).toContain("Line1"); + expect(ascii).toContain("Line2"); + }); + + it("preserves existing note multi-line support", () => { + const ascii = renderMermaidAscii( + `sequenceDiagram + participant A + A->>A: self + Note over A: Note line 1
Note line 2 + `, + { useAscii: false }, + ); + expect(ascii).toContain("Note line 1"); + expect(ascii).toContain("Note line 2"); + }); + }); + + describe("class diagram", () => { + it("renders multi-line class names", () => { + const ascii = renderMermaidAscii( + `classDiagram + class MyClass["Long
Name"] + `, + { useAscii: false }, + ); + expect(ascii).toContain("Long"); + expect(ascii).toContain("Name"); + }); + + it("renders multi-line relationship labels", () => { + const ascii = renderMermaidAscii( + `classDiagram + A --> B : uses
implements + `, + { useAscii: false }, + ); + expect(ascii).toContain("uses"); + expect(ascii).toContain("implements"); + }); + }); + + describe("ER diagram", () => { + it("renders multi-line entity names", () => { + const ascii = renderMermaidAscii( + `erDiagram + "Entity
Name" { + string id + } + `, + { useAscii: false }, + ); + expect(ascii).toContain("Entity"); + expect(ascii).toContain("Name"); + }); + + it("renders multi-line relationship labels", () => { + const ascii = renderMermaidAscii( + `erDiagram + A ||--o{ B : "has
many" + `, + { useAscii: false }, + ); + expect(ascii).toContain("has"); + expect(ascii).toContain("many"); + }); + }); + + describe("edge cases", () => { + it("handles empty lines from consecutive
", () => { + const ascii = renderMermaidAscii("graph TD\n A[Line1

Line3]", { useAscii: false }); + expect(ascii).toContain("Line1"); + expect(ascii).toContain("Line3"); + }); + + it("handles single-line labels (no
)", () => { + const ascii = renderMermaidAscii("graph TD\n A[SingleLine]", { useAscii: false }); + expect(ascii).toContain("SingleLine"); + }); + + it("handles very long lines", () => { + const long = "A".repeat(30); + const ascii = renderMermaidAscii(`graph TD\n A[${long}
Short]`, { useAscii: false }); + expect(ascii).toContain(long); + expect(ascii).toContain("Short"); + }); + + it("handles mixed short and long lines", () => { + const ascii = renderMermaidAscii("graph TD\n A[Short
VeryLongSecondLine
Med]", { useAscii: false }); + expect(ascii).toContain("Short"); + expect(ascii).toContain("VeryLongSecondLine"); + expect(ascii).toContain("Med"); + }); + }); + + describe("multiline-utils functions", () => { + it("splitLines splits on newlines", () => { + // Test through the rendering pipeline + const ascii = renderMermaidAscii("graph TD\n A[One
Two
Three]", { useAscii: false }); + const lines = ascii.split("\n"); + // All three words should appear on separate lines + expect(lines.some(l => l.includes("One"))).toBe(true); + expect(lines.some(l => l.includes("Two"))).toBe(true); + expect(lines.some(l => l.includes("Three"))).toBe(true); + }); + + it("maxLineWidth uses longest line for box sizing", () => { + // Box should be wide enough for the longest line + const ascii = renderMermaidAscii("graph TD\n A[X
LongLine
Y]", { useAscii: false }); + // The box should contain LongLine without truncation + expect(ascii).toContain("LongLine"); + }); + }); +}); diff --git a/packages/utils/test/mermaid/testdata/ascii/ampersand_lhs.txt b/packages/utils/test/mermaid/testdata/ascii/ampersand_lhs.txt new file mode 100644 index 000000000..3e42697ae --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/ampersand_lhs.txt @@ -0,0 +1,18 @@ +graph LR +A --> B & C +--- ++---+ +---+ +| | | | +| A |---->| B | +| | | | ++---+ +---+ + | + | + | + | + | + | +---+ + | | | + +------>| C | + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/ampersand_lhs_and_rhs.txt b/packages/utils/test/mermaid/testdata/ascii/ampersand_lhs_and_rhs.txt new file mode 100644 index 000000000..b5b3ee281 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/ampersand_lhs_and_rhs.txt @@ -0,0 +1,18 @@ +graph LR +A & B --> C & D +--- ++---+ +---+ +| | | | +| A |--+->| C | +| | | | | ++---+ | +---+ + | | + | | + +----+ + | | + | | ++---+ | +---+ +| | | | | +| B |--+->| D | +| | | | ++---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/ampersand_rhs.txt b/packages/utils/test/mermaid/testdata/ascii/ampersand_rhs.txt new file mode 100644 index 000000000..257488a7f --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/ampersand_rhs.txt @@ -0,0 +1,18 @@ +graph LR +A & B --> C +--- ++---+ +---+ +| | | | +| A |---->| C | +| | | | ++---+ +---+ + ^ + | + | + | + | ++---+ | +| | | +| B |-------+ +| | ++---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/ampersand_td_fanin.txt b/packages/utils/test/mermaid/testdata/ascii/ampersand_td_fanin.txt new file mode 100644 index 000000000..3b0da2f96 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/ampersand_td_fanin.txt @@ -0,0 +1,18 @@ +graph TD +A & B --> C +--- ++---+ +---+ +| | | | +| A | | B | +| | | | ++---+ +---+ + | | + | | + +---------+ + | + v ++---+ +| | +| C | +| | ++---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/ampersand_td_fanout.txt b/packages/utils/test/mermaid/testdata/ascii/ampersand_td_fanout.txt new file mode 100644 index 000000000..8f2527d36 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/ampersand_td_fanout.txt @@ -0,0 +1,18 @@ +graph TD +A --> B & C +--- ++---+ +| | +| A | +| | ++---+ + | + | + +---------+ + | | + v v ++---+ +---+ +| | | | +| B | | C | +| | | | ++---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/ampersand_without_edge.txt b/packages/utils/test/mermaid/testdata/ascii/ampersand_without_edge.txt new file mode 100644 index 000000000..1fa800d9e --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/ampersand_without_edge.txt @@ -0,0 +1,18 @@ +graph LR +A & B +--- ++---+ +| | +| A | +| | ++---+ + + + + + ++---+ +| | +| B | +| | ++---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/back_reference_from_child.txt b/packages/utils/test/mermaid/testdata/ascii/back_reference_from_child.txt new file mode 100644 index 000000000..bb2b1443c --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/back_reference_from_child.txt @@ -0,0 +1,10 @@ +graph LR +A --> B --> C --> A +--- ++---+ +---+ +---+ +| | | | | | +| A |---->| B |---->| C | +| | | | | | ++---+ +---+ +---+ + ^ | + +-------------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/backlink_from_bottom.txt b/packages/utils/test/mermaid/testdata/ascii/backlink_from_bottom.txt new file mode 100644 index 000000000..38078a9f2 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/backlink_from_bottom.txt @@ -0,0 +1,22 @@ +graph LR +A --> B +B --> C +A --> C +B --> D +C --> D +--- ++---+ +---+ +---+ +| | | | | | +| A |---->| B |---->| D | +| | | | | | ++---+ +---+ +---+ + | | ^ + | | | + | | | + | | | + | v | + | +---+ | + | | | | + +------>| C |-------+ + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/backlink_from_top.txt b/packages/utils/test/mermaid/testdata/ascii/backlink_from_top.txt new file mode 100644 index 000000000..1ebe82d7f --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/backlink_from_top.txt @@ -0,0 +1,22 @@ +graph LR +A --> B +B --> C +A --> C +B --> D +D --> C +--- ++---+ +---+ +---+ +| | | | | | +| A |---->| B |--+->| D | +| | | | | | | ++---+ +---+ | +---+ + | | | + | | | + | +----+ + | | + | v + | +---+ + | | | + +------>| C | + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/backlink_with_short_y_padding.txt b/packages/utils/test/mermaid/testdata/ascii/backlink_with_short_y_padding.txt new file mode 100644 index 000000000..2ae51627d --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/backlink_with_short_y_padding.txt @@ -0,0 +1,20 @@ +paddingX=5 +paddingY=3 +graph LR +A --> B & C +B --> C & D +D --> C +--- ++---+ +---+ +---+ +| | | | | | +| A |---->| B |--+->| D | +| | | | | | | ++---+ +---+ | +---+ + | | | + | +----+ + | v + | +---+ + | | | + +------>| C | + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/cls_all_relationships.txt b/packages/utils/test/mermaid/testdata/ascii/cls_all_relationships.txt new file mode 100644 index 000000000..2d2c85bb3 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/cls_all_relationships.txt @@ -0,0 +1,17 @@ +classDiagram + A <|-- B : inherits + C *-- D : owns + E o-- F : has + G --> H : uses + I ..> J : depends + K ..|> L : implements +--- ++---+ +---+ +---+ +---+ +---+ +---+ +| A | | C | | E | | G | | I | | L | ++---+ +---+ +---+ +---+ +---+ +---+ + ^ * o | : ^ + inherit owns has uses depend implements + | | | v v : ++---+ +---+ +---+ +---+ +---+ +---+ +| B | | D | | F | | H | | J | | K | ++---+ +---+ +---+ +---+ +---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/cls_annotation.txt b/packages/utils/test/mermaid/testdata/ascii/cls_annotation.txt new file mode 100644 index 000000000..ce0295b8f --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/cls_annotation.txt @@ -0,0 +1,31 @@ +classDiagram + class Shape { + <> + +draw() void + } + class Circle { + +radius int + +draw() void + } + Shape <|-- Circle +--- ++--------------+ +| <> | +| Shape | ++--------------+ +| | ++--------------+ +| +draw: void | ++--------------+ + ^ + | + | ++--------------+ +| Circle | ++--------------+ +| +int: radius | ++--------------+ +| +draw: void | ++--------------+ + + diff --git a/packages/utils/test/mermaid/testdata/ascii/cls_association.txt b/packages/utils/test/mermaid/testdata/ascii/cls_association.txt new file mode 100644 index 000000000..41df0fa34 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/cls_association.txt @@ -0,0 +1,14 @@ +classDiagram + Student --> Course : enrolls +--- ++---------+ +| Student | ++---------+ + | + enrolls + v ++--------+ +| Course | ++--------+ + + diff --git a/packages/utils/test/mermaid/testdata/ascii/cls_basic.txt b/packages/utils/test/mermaid/testdata/ascii/cls_basic.txt new file mode 100644 index 000000000..b8267cc55 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/cls_basic.txt @@ -0,0 +1,15 @@ +classDiagram + class Animal { + +String name + +eat() void + } +--- ++---------------+ +| Animal | ++---------------+ +| +name: String | ++---------------+ +| +eat: void | ++---------------+ + + diff --git a/packages/utils/test/mermaid/testdata/ascii/cls_dependency.txt b/packages/utils/test/mermaid/testdata/ascii/cls_dependency.txt new file mode 100644 index 000000000..ff3ec1c3d --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/cls_dependency.txt @@ -0,0 +1,14 @@ +classDiagram + Client ..> Server : uses +--- ++--------+ +| Client | ++--------+ + : + uses + v ++--------+ +| Server | ++--------+ + + diff --git a/packages/utils/test/mermaid/testdata/ascii/cls_inheritance.txt b/packages/utils/test/mermaid/testdata/ascii/cls_inheritance.txt new file mode 100644 index 000000000..362f272d1 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/cls_inheritance.txt @@ -0,0 +1,22 @@ +classDiagram + Animal <|-- Dog + Animal : +String name + Dog : +bark() void +--- ++---------------+ +| Animal | ++---------------+ +| +name: String | ++---------------+ + ^ + -- + | ++-------------+ +| Dog | ++-------------+ +| | ++-------------+ +| +bark: void | ++-------------+ + + diff --git a/packages/utils/test/mermaid/testdata/ascii/cls_methods.txt b/packages/utils/test/mermaid/testdata/ascii/cls_methods.txt new file mode 100644 index 000000000..c6a13ec18 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/cls_methods.txt @@ -0,0 +1,21 @@ +classDiagram + class BankAccount { + +String owner + -int balance + +deposit(int amount) void + +withdraw(int amount) bool + -validate() bool + } +--- ++-----------------+ +| BankAccount | ++-----------------+ +| +owner: String | +| -balance: int | ++-----------------+ +| +deposit: void | +| +withdraw: bool | +| -validate: bool | ++-----------------+ + + diff --git a/packages/utils/test/mermaid/testdata/ascii/comments.txt b/packages/utils/test/mermaid/testdata/ascii/comments.txt new file mode 100644 index 000000000..8df423e32 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/comments.txt @@ -0,0 +1,23 @@ +graph LR +%% This is a comment +A --> B +%% Another comment +B --> C +A --> C +%% Final comment +--- ++---+ +---+ +| | | | +| A |---->| B | +| | | | ++---+ +---+ + | | + | | + | | + | | + | v + | +---+ + | | | + +------>| C | + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/custom_padding.txt b/packages/utils/test/mermaid/testdata/ascii/custom_padding.txt new file mode 100644 index 000000000..254a9c008 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/custom_padding.txt @@ -0,0 +1,10 @@ +paddingX=2 +paddingY=1 +graph LR +A --> B +--- ++---+ +---+ +| | | | +| A |->| B | +| | | | ++---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/duplicate_labels.txt b/packages/utils/test/mermaid/testdata/ascii/duplicate_labels.txt new file mode 100644 index 000000000..388f0806b --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/duplicate_labels.txt @@ -0,0 +1,19 @@ +graph TD +A[Server] --> B[Client] +C[Server] --> D[Client] +--- ++--------+ +--------+ +| | | | +| Server | | Server | +| | | | ++--------+ +--------+ + | | + | | + | | + | | + v v ++--------+ +--------+ +| | | | +| Client | | Client | +| | | | ++--------+ +--------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/er_attributes.txt b/packages/utils/test/mermaid/testdata/ascii/er_attributes.txt new file mode 100644 index 000000000..f043b74af --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/er_attributes.txt @@ -0,0 +1,19 @@ +erDiagram + CUSTOMER { + string name PK + string email UK + int age + } + ORDER { + int id PK + string status + } + CUSTOMER ||--o{ ORDER : places +--- ++-----------------+ +------------------+ +| CUSTOMER | | ORDER | ++-----------------+ +------------------+ +| PK string name ||---o<| PK int id | +| UK string email |places| string status | +| int age | +------------------+ ++-----------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/er_basic.txt b/packages/utils/test/mermaid/testdata/ascii/er_basic.txt new file mode 100644 index 000000000..de0ef4179 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/er_basic.txt @@ -0,0 +1,8 @@ +erDiagram + CUSTOMER ||--o{ ORDER : places +--- ++----------+ +-------+ +| CUSTOMER ||---o<| ORDER | ++----------+places+-------+ + + diff --git a/packages/utils/test/mermaid/testdata/ascii/er_identifying.txt b/packages/utils/test/mermaid/testdata/ascii/er_identifying.txt new file mode 100644 index 000000000..4ec1a4768 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/er_identifying.txt @@ -0,0 +1,16 @@ +erDiagram + PERSON ||--o{ ADDRESS : lives_at + PERSON { + string name PK + } + ADDRESS { + string street + string city + } +--- ++----------------+ +------------------+ +| PERSON | | ADDRESS | ++----------------+|---o<+------------------+ +| PK string name |lives_| string street | ++----------------+ | string city | + +------------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/flowchart_tb_simple.txt b/packages/utils/test/mermaid/testdata/ascii/flowchart_tb_simple.txt new file mode 100644 index 000000000..3c6c342a4 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/flowchart_tb_simple.txt @@ -0,0 +1,29 @@ +flowchart TB + A --> B + B --> C +--- ++---+ +| | +| A | +| | ++---+ + | + | + | + | + v ++---+ +| | +| B | +| | ++---+ + | + | + | + | + v ++---+ +| | +| C | +| | ++---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/graph_bt_direction.txt b/packages/utils/test/mermaid/testdata/ascii/graph_bt_direction.txt new file mode 100644 index 000000000..0049fa40b --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/graph_bt_direction.txt @@ -0,0 +1,28 @@ +graph BT + A --> B --> C +--- ++---+ +| | +| C | +| | ++---+ + ^ + | + | + | + | ++---+ +| | +| B | +| | ++---+ + ^ + | + | + | + | ++---+ +| | +| A | +| | ++---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/graph_tb_direction.txt b/packages/utils/test/mermaid/testdata/ascii/graph_tb_direction.txt new file mode 100644 index 000000000..9ced6ab77 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/graph_tb_direction.txt @@ -0,0 +1,26 @@ +graph TB +subgraph one + A --> B +end +--- ++-------+ +| one | +| | +| | +| +---+ | +| | | | +| | A | | +| | | | +| +---+ | +| | | +| | | +| | | +| | | +| v | +| +---+ | +| | | | +| | B | | +| | | | +| +---+ | +| | ++-------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/nested_subgraphs_with_labels.txt b/packages/utils/test/mermaid/testdata/ascii/nested_subgraphs_with_labels.txt new file mode 100644 index 000000000..36d076b49 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/nested_subgraphs_with_labels.txt @@ -0,0 +1,36 @@ +graph TD +A[Web Server] --> B[API Gateway] +B --> C[Web Server] +subgraph Frontend +A +end +subgraph Backend +B +C +end +--- ++-------------+ +| | +| Web Server | +| | ++-------------+ + | + | + | + | + v ++-------------+ +| | +| API Gateway | +| | ++-------------+ + | + | + | + | + v ++-------------+ +| | +| Web Server | +| | ++-------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/preserve_order_of_definition.txt b/packages/utils/test/mermaid/testdata/ascii/preserve_order_of_definition.txt new file mode 100644 index 000000000..8cf5df8ea --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/preserve_order_of_definition.txt @@ -0,0 +1,23 @@ +graph LR +A +B +B --> A +A --> A +B --> C +C --> A +--- ++---+ +---+ +| | | | +| A |<-+--| C | +| | | | | ++---+ | +---+ + ^ | ^ + | | | + +----+ | + | | + | | ++---+ | +| | | +| B |-------+ +| | ++---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/self_reference.txt b/packages/utils/test/mermaid/testdata/ascii/self_reference.txt new file mode 100644 index 000000000..1b968c118 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/self_reference.txt @@ -0,0 +1,10 @@ +graph LR +A --> A +--- ++---+ +| | +| A |-+ +| | | ++---+ | + ^ | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/self_reference_with_edge.txt b/packages/utils/test/mermaid/testdata/ascii/self_reference_with_edge.txt new file mode 100644 index 000000000..303e7d76e --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/self_reference_with_edge.txt @@ -0,0 +1,10 @@ +graph LR +A --> A & B +--- ++---+ +---+ +| | | | +| A |--+->| B | +| | | | | ++---+ | +---+ + ^ | + +----+ diff --git a/packages/utils/test/mermaid/testdata/ascii/seq_basic.txt b/packages/utils/test/mermaid/testdata/ascii/seq_basic.txt new file mode 100644 index 000000000..d42a724e7 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/seq_basic.txt @@ -0,0 +1,17 @@ +sequenceDiagram + Alice->>Bob: Hello Bob + Bob-->>Alice: Hi Alice +--- + +-------+ +-----+ + | Alice | | Bob | + +-------+ +-----+ + | | + | Hello Bob | + |--------------> + | | + | Hi Alice | + <..............| + | | + +-------+ +-----+ + | Alice | | Bob | + +-------+ +-----+ diff --git a/packages/utils/test/mermaid/testdata/ascii/seq_multiple_messages.txt b/packages/utils/test/mermaid/testdata/ascii/seq_multiple_messages.txt new file mode 100644 index 000000000..c2918984b --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/seq_multiple_messages.txt @@ -0,0 +1,25 @@ +sequenceDiagram + Alice->>Bob: Hello + Bob->>Charlie: Forward + Charlie-->>Bob: Reply + Bob-->>Alice: Done +--- + +-------+ +-----+ +---------+ + | Alice | | Bob | | Charlie | + +-------+ +-----+ +---------+ + | | | + | Hello | | + |----------> | + | | | + | | Forward | + | |------------> + | | | + | | Reply | + | <............| + | | | + | Done | | + <..........| | + | | | + +-------+ +-----+ +---------+ + | Alice | | Bob | | Charlie | + +-------+ +-----+ +---------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/seq_self_message.txt b/packages/utils/test/mermaid/testdata/ascii/seq_self_message.txt new file mode 100644 index 000000000..107807563 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/seq_self_message.txt @@ -0,0 +1,18 @@ +sequenceDiagram + Alice->>Alice: Think + Alice->>Bob: Result +--- + +-------+ +-----+ + | Alice | | Bob | + +-------+ +-----+ + | | + +---+ | + | | Think | + <---+ | + | | + | Result | + |-----------> + | | + +-------+ +-----+ + | Alice | | Bob | + +-------+ +-----+ diff --git a/packages/utils/test/mermaid/testdata/ascii/single_node.txt b/packages/utils/test/mermaid/testdata/ascii/single_node.txt new file mode 100644 index 000000000..fc71ff15a --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/single_node.txt @@ -0,0 +1,8 @@ +graph LR +A +--- ++---+ +| | +| A | +| | ++---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/single_node_longer_name.txt b/packages/utils/test/mermaid/testdata/ascii/single_node_longer_name.txt new file mode 100644 index 000000000..5d8970cf7 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/single_node_longer_name.txt @@ -0,0 +1,8 @@ +graph LR +LongerName +--- ++------------+ +| | +| LongerName | +| | ++------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_complex_mixed.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_complex_mixed.txt new file mode 100644 index 000000000..cd7ecdc96 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_complex_mixed.txt @@ -0,0 +1,38 @@ +graph LR +Start +subgraph Processing + A --> B + B --> C +end +subgraph Storage + D + E +end +Start --> A +C --> D +C --> E +D --> End +E --> End +End +--- + +---------------------------+ +-------+ + | Processing | |Storage| + | | | | + | | | | ++-------+ | +---+ +---+ +---+ | | +---+ | +-----+ +| | | | | | | | | | | | | | | | +| Start |---->| A |---->| B |---->| C |---->| D |---->| End | +| | | | | | | | | | | | | | | | ++-------+ | +---+ +---+ +---+ | | +---+ | +-----+ + | | | | | ^ + +-----------------------|---+ | | | + | | | | + | | | | + | | | | + | | +---+ | | + | | | | | | + +------>| E |--------+ + | | | | + | +---+ | + | | + +-------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_complex_nested.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_complex_nested.txt new file mode 100644 index 000000000..8355e830a --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_complex_nested.txt @@ -0,0 +1,49 @@ +graph LR +subgraph outer + A + subgraph inner + B + end + C +end +D +--- ++-----------+ +| outer | +| | +| | +| +---+ | +| | | | +| | A | | +| | | | +| +---+ | +| | +| +-------+ | +| | inner | | +| | | | +| | | | +| | +---+ | | +| | | | | | +| | | B | | | +| | | | | | +| | +---+ | | +| | | | +| +-------+ | +| | +| | +| | +| +---+ | +| | | | +| | C | | +| | | | +| +---+ | +| | ++-----------+ + + + + +---+ + | | + | D | + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_direction_override.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_direction_override.txt new file mode 100644 index 000000000..9fd668643 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_direction_override.txt @@ -0,0 +1,37 @@ +graph TD +subgraph one [LR Group] + direction LR + A --> B +end +X --> A +B --> Y +--- + +---+ + | | + | X | + | | + +---+ + | + | + | + | + | ++---|-------------+ +| |LR Group | +| | | +| v | +| +---+ +---+ | +| | | | | | +| | A |---->| B | | +| | | | | | +| +---+ +---+ | +| | | ++-------------|---+ + | + | + | + +---+ | + | | | + | Y |<------+ + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_empty.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_empty.txt new file mode 100644 index 000000000..1c0d54e75 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_empty.txt @@ -0,0 +1,10 @@ +graph LR +subgraph one +end +A --> B +--- ++---+ +---+ +| | | | +| A |---->| B | +| | | | ++---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_mixed_nodes.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_mixed_nodes.txt new file mode 100644 index 000000000..0d288a4f2 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_mixed_nodes.txt @@ -0,0 +1,20 @@ +graph LR +X +subgraph one + A --> B +end +Y +X --> A +B --> Y +--- + +-----------------+ + | one | + | | + | | ++---+ | +---+ +---+ | +---+ +| | | | | | | | | | +| X |---->| A |---->| B |---->| Y | +| | | | | | | | | | ++---+ | +---+ +---+ | +---+ + | | + +-----------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_mixed_nodes_td.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_mixed_nodes_td.txt new file mode 100644 index 000000000..19af6be0f --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_mixed_nodes_td.txt @@ -0,0 +1,48 @@ +graph TD +X +subgraph one + A --> B +end +Y +X --> A +B --> Y +--- + +---+ + | | + | X | + | | + +---+ + | + | + | + | + | ++---|---+ +| one | +| | | +| v | +| +---+ | +| | | | +| | A | | +| | | | +| +---+ | +| | | +| | | +| | | +| | | +| v | +| +---+ | +| | | | +| | B | | +| | | | +| +---+ | +| | | ++---|---+ + | + | + v + +---+ + | | + | Y | + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_multiple_edges.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_multiple_edges.txt new file mode 100644 index 000000000..cebdccc31 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_multiple_edges.txt @@ -0,0 +1,32 @@ +graph LR +subgraph one + A --> B + A --> C +end +subgraph two + D --> E +end +B --> D +C --> E +--- ++-----------------+ +-------+ +| one | | two | +| | | | +| | | | +| +---+ +---+ | | +---+ | +| | | | | | | | | | +| | A |---->| B |---->| D | | +| | | | | | | | | | +| +---+ +---+ | | +---+ | +| | | | | | +| | | | | | +| | | | | | +| | | | | | +| | | | v | +| | +---+ | | +---+ | +| | | | | | | | | +| +------>| C |---->| E | | +| | | | | | | | +| +---+ | | +---+ | +| | | | ++-----------------+ +-------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_multiple_nodes.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_multiple_nodes.txt new file mode 100644 index 000000000..e018e4469 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_multiple_nodes.txt @@ -0,0 +1,16 @@ +graph LR +subgraph one + A --> B +end +--- ++-----------------+ +| one | +| | +| | +| +---+ +---+ | +| | | | | | +| | A |---->| B | | +| | | | | | +| +---+ +---+ | +| | ++-----------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_nested.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_nested.txt new file mode 100644 index 000000000..9c698fbec --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_nested.txt @@ -0,0 +1,24 @@ +graph LR +subgraph outer + subgraph inner + A + end +end +--- ++-----------+ +| outer | +| | +| | +| +-------+ | +| | inner | | +| | | | +| | | | +| | +---+ | | +| | | | | | +| | | A | | | +| | | | | | +| | +---+ | | +| | | | +| +-------+ | +| | ++-----------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_nested_with_external.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_nested_with_external.txt new file mode 100644 index 000000000..56de3c97d --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_nested_with_external.txt @@ -0,0 +1,30 @@ +graph LR +X +subgraph outer + A + subgraph inner + B --> C + end + A --> B +end +X --> A +C --> Y +Y +--- + +-----------------------------+ + | outer | + | | + | | + | +-----------------+ | + | | inner | | + | | | | + | | | | ++---+ | +---+ | +---+ +---+ | | +---+ +| | | | | | | | | | | | | | +| X |---->| A |---->| B |---->| C |---->| Y | +| | | | | | | | | | | | | | ++---+ | +---+ | +---+ +---+ | | +---+ + | | | | + | +-----------------+ | + | | + +-----------------------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_node_outside_lr.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_node_outside_lr.txt new file mode 100644 index 000000000..fc4804684 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_node_outside_lr.txt @@ -0,0 +1,17 @@ +graph LR +X +subgraph one + A --> B +end +--- + +-----------------+ + | one | + | | + | | ++---+ | +---+ +---+ | +| | | | | | | | +| X | | | A |---->| B | | +| | | | | | | | ++---+ | +---+ +---+ | + | | + +-----------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_single_node.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_single_node.txt new file mode 100644 index 000000000..155f96b6e --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_single_node.txt @@ -0,0 +1,16 @@ +graph LR +subgraph one + A +end +--- ++-------+ +| one | +| | +| | +| +---+ | +| | | | +| | A | | +| | | | +| +---+ | +| | ++-------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_td_direction.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_td_direction.txt new file mode 100644 index 000000000..d1b254019 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_td_direction.txt @@ -0,0 +1,26 @@ +graph TD +subgraph one + A --> B +end +--- ++-------+ +| one | +| | +| | +| +---+ | +| | | | +| | A | | +| | | | +| +---+ | +| | | +| | | +| | | +| | | +| v | +| +---+ | +| | | | +| | B | | +| | | | +| +---+ | +| | ++-------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_td_multiple.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_td_multiple.txt new file mode 100644 index 000000000..d34dc1e98 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_td_multiple.txt @@ -0,0 +1,44 @@ +graph TD +subgraph Frontend + UI --> API +end +subgraph Backend + API --> Database + API --> Cache +end +--- ++--------------+ +| Frontend | +| | +| | +| +----------+ | +| | | | +| | UI | | +| | | | +| +----------+ | +| | | +| | | +| | | +| | | +| v | +| +----------+ | +| | | | +| | API |---------+ +| | | | | +| +----------+ | | +| | | | ++-------|------+ | + | | + | | + | | ++-------|--------------|-----+ +| | Backend | | +| | | | +| v v | +| +----------+ +-------+ | +| | | | | | +| | Database | | Cache | | +| | | | | | +| +----------+ +-------+ | +| | ++----------------------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_td_multiple_paddingy.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_td_multiple_paddingy.txt new file mode 100644 index 000000000..f45c8ea9b --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_td_multiple_paddingy.txt @@ -0,0 +1,42 @@ +paddingX=3 +paddingY=3 +graph TD +subgraph Frontend + UI --> API +end +subgraph Backend + API --> Database + API --> Cache +end +--- ++--------------+ +| Frontend | +| | +| | +| +----------+ | +| | | | +| | UI | | +| | | | +| +----------+ | +| | | +| | | +| v | +| +----------+ | +| | | | +| | API |-------+ +| | | | | +| +----------+ | | +| | | | ++-------|------+ | + | | ++-------|------------|-----+ +| | Backend | | +| | | | +| v v | +| +----------+ +-------+ | +| | | | | | +| | Database | | Cache | | +| | | | | | +| +----------+ +-------+ | +| | ++--------------------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_three_levels_nested.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_three_levels_nested.txt new file mode 100644 index 000000000..fa2a694e0 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_three_levels_nested.txt @@ -0,0 +1,32 @@ +graph LR +subgraph level1 + subgraph level2 + subgraph level3 + A + end + end +end +--- ++---------------+ +| level1 | +| | +| | +| +-----------+ | +| | level2 | | +| | | | +| | | | +| | +-------+ | | +| | |level3 | | | +| | | | | | +| | | | | | +| | | +---+ | | | +| | | | | | | | +| | | | A | | | | +| | | | | | | | +| | | +---+ | | | +| | | | | | +| | +-------+ | | +| | | | +| +-----------+ | +| | ++---------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_three_separate.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_three_separate.txt new file mode 100644 index 000000000..ed9eb2a9b --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_three_separate.txt @@ -0,0 +1,24 @@ +graph LR +subgraph Frontend + UI +end +subgraph Backend + API +end +subgraph Database + DB +end +UI --> API +API --> DB +--- ++--------+ +---------+ +--------+ +|Frontend| | Backend | |Database| +| | | | | | +| | | | | | +| +----+ | | +-----+ | | +----+ | +| | | | | | | | | | | | +| | UI |---->| API |---->| DB | | +| | | | | | | | | | | | +| +----+ | | +-----+ | | +----+ | +| | | | | | ++--------+ +---------+ +--------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_two_separate.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_two_separate.txt new file mode 100644 index 000000000..c58582a0b --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_two_separate.txt @@ -0,0 +1,20 @@ +graph LR +subgraph one + A +end +subgraph two + B +end +A --> B +--- ++-------+ +-------+ +| one | | two | +| | | | +| | | | +| +---+ | | +---+ | +| | | | | | | | +| | A |---->| B | | +| | | | | | | | +| +---+ | | +---+ | +| | | | ++-------+ +-------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_with_labels.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_with_labels.txt new file mode 100644 index 000000000..3168748f3 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_with_labels.txt @@ -0,0 +1,20 @@ +graph LR +subgraph one + A -->|sends| B +end +subgraph two + C -->|receives| D +end +B -->|data| C +--- ++-------------------+ +----------------------+ +| one | | two | +| | | | +| | | | +| +---+ +---+ | | +---+ +---+ | +| | | | | | | | | | | | +| | A |-sends>| B |data->| C |receives->| D | | +| | | | | | | | | | | | +| +---+ +---+ | | +---+ +---+ | +| | | | ++-------------------+ +----------------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/three_nodes.txt b/packages/utils/test/mermaid/testdata/ascii/three_nodes.txt new file mode 100644 index 000000000..7ec4a6991 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/three_nodes.txt @@ -0,0 +1,9 @@ +graph LR +A --> B +B --> C +--- ++---+ +---+ +---+ +| | | | | | +| A |---->| B |---->| C | +| | | | | | ++---+ +---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/three_nodes_single_line.txt b/packages/utils/test/mermaid/testdata/ascii/three_nodes_single_line.txt new file mode 100644 index 000000000..cc83b6bc0 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/three_nodes_single_line.txt @@ -0,0 +1,8 @@ +graph LR +A --> B --> C +--- ++---+ +---+ +---+ +| | | | | | +| A |---->| B |---->| C | +| | | | | | ++---+ +---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/two_layer_single_graph.txt b/packages/utils/test/mermaid/testdata/ascii/two_layer_single_graph.txt new file mode 100644 index 000000000..c9f375a52 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/two_layer_single_graph.txt @@ -0,0 +1,19 @@ +graph LR +A --> B +A --> C +--- ++---+ +---+ +| | | | +| A |---->| B | +| | | | ++---+ +---+ + | + | + | + | + | + | +---+ + | | | + +------>| C | + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/two_layer_single_graph_longer_names.txt b/packages/utils/test/mermaid/testdata/ascii/two_layer_single_graph_longer_names.txt new file mode 100644 index 000000000..efe45a812 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/two_layer_single_graph_longer_names.txt @@ -0,0 +1,19 @@ +graph LR +ABC --> BCDEFG +ABC --> CDEFGHI +--- ++-----+ +---------+ +| | | | +| ABC |---->| BCDEFG | +| | | | ++-----+ +---------+ + | + | + | + | + | + | +---------+ + | | | + +------->| CDEFGHI | + | | + +---------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/two_nodes_linked.txt b/packages/utils/test/mermaid/testdata/ascii/two_nodes_linked.txt new file mode 100644 index 000000000..0d263eb3c --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/two_nodes_linked.txt @@ -0,0 +1,8 @@ +graph LR +A --> B +--- ++---+ +---+ +| | | | +| A |---->| B | +| | | | ++---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/two_nodes_longer_names.txt b/packages/utils/test/mermaid/testdata/ascii/two_nodes_longer_names.txt new file mode 100644 index 000000000..1c8fcb090 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/two_nodes_longer_names.txt @@ -0,0 +1,8 @@ +graph LR +LongerName1 --> LongerName2 +--- ++-------------+ +-------------+ +| | | | +| LongerName1 |---->| LongerName2 | +| | | | ++-------------+ +-------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/two_root_nodes.txt b/packages/utils/test/mermaid/testdata/ascii/two_root_nodes.txt new file mode 100644 index 000000000..896c5fca4 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/two_root_nodes.txt @@ -0,0 +1,19 @@ +graph LR +A --> B +C --> D +--- ++---+ +---+ +| | | | +| A |---->| B | +| | | | ++---+ +---+ + + + + + ++---+ +---+ +| | | | +| C |---->| D | +| | | | ++---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/two_root_nodes_longer_names.txt b/packages/utils/test/mermaid/testdata/ascii/two_root_nodes_longer_names.txt new file mode 100644 index 000000000..9627f644c --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/two_root_nodes_longer_names.txt @@ -0,0 +1,19 @@ +graph LR +ABC --> BCDEFG +CDEFGH --> DEF +--- ++--------+ +--------+ +| | | | +| ABC |---->| BCDEFG | +| | | | ++--------+ +--------+ + + + + + ++--------+ +--------+ +| | | | +| CDEFGH |---->| DEF | +| | | | ++--------+ +--------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/two_single_root_nodes.txt b/packages/utils/test/mermaid/testdata/ascii/two_single_root_nodes.txt new file mode 100644 index 000000000..8fec1dffa --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/two_single_root_nodes.txt @@ -0,0 +1,19 @@ +graph LR +A +B +--- ++---+ +| | +| A | +| | ++---+ + + + + + ++---+ +| | +| B | +| | ++---+ diff --git a/packages/utils/test/mermaid/testdata/unicode/ampersand_lhs.txt b/packages/utils/test/mermaid/testdata/unicode/ampersand_lhs.txt new file mode 100644 index 000000000..50fefa56c --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/ampersand_lhs.txt @@ -0,0 +1,18 @@ +graph LR +A --> B & C +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├────►│ B │ +│ │ │ │ +└─┬─┘ └───┘ + │ + │ + │ + │ + │ + │ ┌───┐ + │ │ │ + └──────►│ C │ + │ │ + └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/ampersand_lhs_and_rhs.txt b/packages/utils/test/mermaid/testdata/unicode/ampersand_lhs_and_rhs.txt new file mode 100644 index 000000000..f56d03a45 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/ampersand_lhs_and_rhs.txt @@ -0,0 +1,18 @@ +graph LR +A & B --> C & D +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├──┬─►│ C │ +│ │ │ │ │ +└─┬─┘ │ └───┘ + │ │ + │ │ + ├────┤ + │ │ + │ │ +┌─┴─┐ │ ┌───┐ +│ │ │ │ │ +│ B ├──┴─►│ D │ +│ │ │ │ +└───┘ └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/ampersand_rhs.txt b/packages/utils/test/mermaid/testdata/unicode/ampersand_rhs.txt new file mode 100644 index 000000000..b33572596 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/ampersand_rhs.txt @@ -0,0 +1,18 @@ +graph LR +A & B --> C +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├────►│ C │ +│ │ │ │ +└───┘ └───┘ + ▲ + │ + │ + │ + │ +┌───┐ │ +│ │ │ +│ B ├───────┘ +│ │ +└───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/ampersand_without_edge.txt b/packages/utils/test/mermaid/testdata/unicode/ampersand_without_edge.txt new file mode 100644 index 000000000..ed5860a5d --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/ampersand_without_edge.txt @@ -0,0 +1,18 @@ +graph LR +A & B +--- +┌───┐ +│ │ +│ A │ +│ │ +└───┘ + + + + + +┌───┐ +│ │ +│ B │ +│ │ +└───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/back_reference_from_child.txt b/packages/utils/test/mermaid/testdata/unicode/back_reference_from_child.txt new file mode 100644 index 000000000..a40debd42 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/back_reference_from_child.txt @@ -0,0 +1,10 @@ +graph LR +A --> B --> C --> A +--- +┌───┐ ┌───┐ ┌───┐ +│ │ │ │ │ │ +│ A ├────►│ B ├────►│ C │ +│ │ │ │ │ │ +└───┘ └───┘ └─┬─┘ + ▲ │ + └───────────────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/backlink_from_bottom.txt b/packages/utils/test/mermaid/testdata/unicode/backlink_from_bottom.txt new file mode 100644 index 000000000..885314ca8 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/backlink_from_bottom.txt @@ -0,0 +1,22 @@ +graph LR +A --> B +B --> C +A --> C +B --> D +C --> D +--- +┌───┐ ┌───┐ ┌───┐ +│ │ │ │ │ │ +│ A ├────►│ B ├────►│ D │ +│ │ │ │ │ │ +└─┬─┘ └─┬─┘ └───┘ + │ │ ▲ + │ │ │ + │ │ │ + │ │ │ + │ ▼ │ + │ ┌───┐ │ + │ │ │ │ + └──────►│ C ├───────┘ + │ │ + └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/backlink_from_top.txt b/packages/utils/test/mermaid/testdata/unicode/backlink_from_top.txt new file mode 100644 index 000000000..fa4b90ea9 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/backlink_from_top.txt @@ -0,0 +1,22 @@ +graph LR +A --> B +B --> C +A --> C +B --> D +D --> C +--- +┌───┐ ┌───┐ ┌───┐ +│ │ │ │ │ │ +│ A ├────►│ B ├──┬─►┤ D │ +│ │ │ │ │ │ │ +└─┬─┘ └─┬─┘ │ └───┘ + │ │ │ + │ │ │ + │ ├────┘ + │ │ + │ ▼ + │ ┌───┐ + │ │ │ + └──────►│ C │ + │ │ + └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/cls_all_relationships.txt b/packages/utils/test/mermaid/testdata/unicode/cls_all_relationships.txt new file mode 100644 index 000000000..208843c6e --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/cls_all_relationships.txt @@ -0,0 +1,17 @@ +classDiagram + A <|-- B : inherits + C *-- D : owns + E o-- F : has + G --> H : uses + I ..> J : depends + K ..|> L : implements +--- +┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ +│ A │ │ C │ │ E │ │ G │ │ I │ │ L │ +└───┘ └───┘ └───┘ └───┘ └───┘ └───┘ + △ ◆ ◇ │ ┊ △ + inherit owns has uses depend implements + │ │ │ ▼ ▼ ┊ +┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ +│ B │ │ D │ │ F │ │ H │ │ J │ │ K │ +└───┘ └───┘ └───┘ └───┘ └───┘ └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/cls_annotation.txt b/packages/utils/test/mermaid/testdata/unicode/cls_annotation.txt new file mode 100644 index 000000000..b9e929ffd --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/cls_annotation.txt @@ -0,0 +1,31 @@ +classDiagram + class Shape { + <> + +draw() void + } + class Circle { + +radius int + +draw() void + } + Shape <|-- Circle +--- +┌──────────────┐ +│ <> │ +│ Shape │ +├──────────────┤ +│ │ +├──────────────┤ +│ +draw: void │ +└──────────────┘ + △ + │ + │ +┌──────────────┐ +│ Circle │ +├──────────────┤ +│ +int: radius │ +├──────────────┤ +│ +draw: void │ +└──────────────┘ + + diff --git a/packages/utils/test/mermaid/testdata/unicode/cls_association.txt b/packages/utils/test/mermaid/testdata/unicode/cls_association.txt new file mode 100644 index 000000000..9729bf2e3 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/cls_association.txt @@ -0,0 +1,14 @@ +classDiagram + Student --> Course : enrolls +--- +┌─────────┐ +│ Student │ +└─────────┘ + │ + enrolls + ▼ +┌────────┐ +│ Course │ +└────────┘ + + diff --git a/packages/utils/test/mermaid/testdata/unicode/cls_basic.txt b/packages/utils/test/mermaid/testdata/unicode/cls_basic.txt new file mode 100644 index 000000000..fab4b1861 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/cls_basic.txt @@ -0,0 +1,15 @@ +classDiagram + class Animal { + +String name + +eat() void + } +--- +┌───────────────┐ +│ Animal │ +├───────────────┤ +│ +name: String │ +├───────────────┤ +│ +eat: void │ +└───────────────┘ + + diff --git a/packages/utils/test/mermaid/testdata/unicode/cls_dependency.txt b/packages/utils/test/mermaid/testdata/unicode/cls_dependency.txt new file mode 100644 index 000000000..5196a5409 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/cls_dependency.txt @@ -0,0 +1,14 @@ +classDiagram + Client ..> Server : uses +--- +┌────────┐ +│ Client │ +└────────┘ + ┊ + uses + ▼ +┌────────┐ +│ Server │ +└────────┘ + + diff --git a/packages/utils/test/mermaid/testdata/unicode/cls_inheritance.txt b/packages/utils/test/mermaid/testdata/unicode/cls_inheritance.txt new file mode 100644 index 000000000..f7a3c320e --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/cls_inheritance.txt @@ -0,0 +1,22 @@ +classDiagram + Animal <|-- Dog + Animal : +String name + Dog : +bark() void +--- +┌───────────────┐ +│ Animal │ +├───────────────┤ +│ +name: String │ +└───────────────┘ + △ + ┌┘ + │ +┌─────────────┐ +│ Dog │ +├─────────────┤ +│ │ +├─────────────┤ +│ +bark: void │ +└─────────────┘ + + diff --git a/packages/utils/test/mermaid/testdata/unicode/cls_methods.txt b/packages/utils/test/mermaid/testdata/unicode/cls_methods.txt new file mode 100644 index 000000000..a87265a1f --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/cls_methods.txt @@ -0,0 +1,21 @@ +classDiagram + class BankAccount { + +String owner + -int balance + +deposit(int amount) void + +withdraw(int amount) bool + -validate() bool + } +--- +┌─────────────────┐ +│ BankAccount │ +├─────────────────┤ +│ +owner: String │ +│ -balance: int │ +├─────────────────┤ +│ +deposit: void │ +│ +withdraw: bool │ +│ -validate: bool │ +└─────────────────┘ + + diff --git a/packages/utils/test/mermaid/testdata/unicode/comments.txt b/packages/utils/test/mermaid/testdata/unicode/comments.txt new file mode 100644 index 000000000..6a7ff3c8e --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/comments.txt @@ -0,0 +1,23 @@ +graph LR +%% This is a comment +A --> B +%% Another comment +B --> C +A --> C +%% Final comment +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├────►│ B │ +│ │ │ │ +└─┬─┘ └─┬─┘ + │ │ + │ │ + │ │ + │ │ + │ ▼ + │ ┌───┐ + │ │ │ + └──────►│ C │ + │ │ + └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/duplicate_labels.txt b/packages/utils/test/mermaid/testdata/unicode/duplicate_labels.txt new file mode 100644 index 000000000..949a72268 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/duplicate_labels.txt @@ -0,0 +1,19 @@ +graph TD +A[Server] --> B[Client] +C[Server] --> D[Client] +--- +┌────────┐ ┌────────┐ +│ │ │ │ +│ Server │ │ Server │ +│ │ │ │ +└────┬───┘ └────┬───┘ + │ │ + │ │ + │ │ + │ │ + ▼ ▼ +┌────────┐ ┌────────┐ +│ │ │ │ +│ Client │ │ Client │ +│ │ │ │ +└────────┘ └────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/er_attributes.txt b/packages/utils/test/mermaid/testdata/unicode/er_attributes.txt new file mode 100644 index 000000000..860dc18f8 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/er_attributes.txt @@ -0,0 +1,19 @@ +erDiagram + CUSTOMER { + string name PK + string email UK + int age + } + ORDER { + int id PK + string status + } + CUSTOMER ||--o{ ORDER : places +--- +┌─────────────────┐ ┌──────────────────┐ +│ CUSTOMER │ │ ORDER │ +├─────────────────┤ ├──────────────────┤ +│ PK string name ││───○╟│ PK int id │ +│ UK string email │places│ string status │ +│ int age │ └──────────────────┘ +└─────────────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/er_basic.txt b/packages/utils/test/mermaid/testdata/unicode/er_basic.txt new file mode 100644 index 000000000..9c9f3a8a5 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/er_basic.txt @@ -0,0 +1,8 @@ +erDiagram + CUSTOMER ||--o{ ORDER : places +--- +┌──────────┐ ┌───────┐ +│ CUSTOMER ││───○╟│ ORDER │ +└──────────┘places└───────┘ + + diff --git a/packages/utils/test/mermaid/testdata/unicode/er_identifying.txt b/packages/utils/test/mermaid/testdata/unicode/er_identifying.txt new file mode 100644 index 000000000..9e8aa961f --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/er_identifying.txt @@ -0,0 +1,16 @@ +erDiagram + PERSON ||--o{ ADDRESS : lives_at + PERSON { + string name PK + } + ADDRESS { + string street + string city + } +--- +┌────────────────┐ ┌──────────────────┐ +│ PERSON │ │ ADDRESS │ +├────────────────┤│───○╟├──────────────────┤ +│ PK string name │lives_│ string street │ +└────────────────┘ │ string city │ + └──────────────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/graph_bt_direction.txt b/packages/utils/test/mermaid/testdata/unicode/graph_bt_direction.txt new file mode 100644 index 000000000..54aa19ae9 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/graph_bt_direction.txt @@ -0,0 +1,28 @@ +graph BT + A --> B --> C +--- +┌───┐ +│ │ +│ C │ +│ │ +└───┘ + ▲ + │ + │ + │ + │ +┌─┴─┐ +│ │ +│ B │ +│ │ +└───┘ + ▲ + │ + │ + │ + │ +┌─┴─┐ +│ │ +│ A │ +│ │ +└───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/preserve_order_of_definition.txt b/packages/utils/test/mermaid/testdata/unicode/preserve_order_of_definition.txt new file mode 100644 index 000000000..6ed6857ce --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/preserve_order_of_definition.txt @@ -0,0 +1,23 @@ +graph LR +A +B +B --> A +A --> A +B --> C +C --> A +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├◄─┬──┤ C │ +│ │ │ │ │ +└───┘ │ └───┘ + ▲ │ ▲ + │ │ │ + ├────┘ │ + │ │ + │ │ +┌─┴─┐ │ +│ │ │ +│ B ├───────┘ +│ │ +└───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/self_reference.txt b/packages/utils/test/mermaid/testdata/unicode/self_reference.txt new file mode 100644 index 000000000..c11269d34 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/self_reference.txt @@ -0,0 +1,10 @@ +graph LR +A --> A +--- +┌───┐ +│ │ +│ A ├─┐ +│ │ │ +└───┘ │ + ▲ │ + └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/self_reference_with_edge.txt b/packages/utils/test/mermaid/testdata/unicode/self_reference_with_edge.txt new file mode 100644 index 000000000..8eb0f6a9d --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/self_reference_with_edge.txt @@ -0,0 +1,10 @@ +graph LR +A --> A & B +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├──┬─►│ B │ +│ │ │ │ │ +└───┘ │ └───┘ + ▲ │ + └────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/seq_basic.txt b/packages/utils/test/mermaid/testdata/unicode/seq_basic.txt new file mode 100644 index 000000000..6106fd620 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/seq_basic.txt @@ -0,0 +1,17 @@ +sequenceDiagram + Alice->>Bob: Hello Bob + Bob-->>Alice: Hi Alice +--- + ┌───────┐ ┌─────┐ + │ Alice │ │ Bob │ + └───┬───┘ └──┬──┘ + │ │ + │ Hello Bob │ + │──────────────▶ + │ │ + │ Hi Alice │ + ◀╌╌╌╌╌╌╌╌╌╌╌╌╌╌│ + │ │ + ┌───┴───┐ ┌──┴──┐ + │ Alice │ │ Bob │ + └───────┘ └─────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/seq_multiple_messages.txt b/packages/utils/test/mermaid/testdata/unicode/seq_multiple_messages.txt new file mode 100644 index 000000000..6f2a99e62 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/seq_multiple_messages.txt @@ -0,0 +1,25 @@ +sequenceDiagram + Alice->>Bob: Hello + Bob->>Charlie: Forward + Charlie-->>Bob: Reply + Bob-->>Alice: Done +--- + ┌───────┐ ┌─────┐ ┌─────────┐ + │ Alice │ │ Bob │ │ Charlie │ + └───┬───┘ └──┬──┘ └────┬────┘ + │ │ │ + │ Hello │ │ + │──────────▶ │ + │ │ │ + │ │ Forward │ + │ │────────────▶ + │ │ │ + │ │ Reply │ + │ ◀╌╌╌╌╌╌╌╌╌╌╌╌│ + │ │ │ + │ Done │ │ + ◀╌╌╌╌╌╌╌╌╌╌│ │ + │ │ │ + ┌───┴───┐ ┌──┴──┐ ┌────┴────┐ + │ Alice │ │ Bob │ │ Charlie │ + └───────┘ └─────┘ └─────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/seq_self_message.txt b/packages/utils/test/mermaid/testdata/unicode/seq_self_message.txt new file mode 100644 index 000000000..f3652dab2 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/seq_self_message.txt @@ -0,0 +1,18 @@ +sequenceDiagram + Alice->>Alice: Think + Alice->>Bob: Result +--- + ┌───────┐ ┌─────┐ + │ Alice │ │ Bob │ + └───┬───┘ └──┬──┘ + │ │ + ├───┐ │ + │ │ Think │ + ◀───┘ │ + │ │ + │ Result │ + │───────────▶ + │ │ + ┌───┴───┐ ┌──┴──┐ + │ Alice │ │ Bob │ + └───────┘ └─────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/single_node.txt b/packages/utils/test/mermaid/testdata/unicode/single_node.txt new file mode 100644 index 000000000..d6824d9b2 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/single_node.txt @@ -0,0 +1,8 @@ +graph LR +A +--- +┌───┐ +│ │ +│ A │ +│ │ +└───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/single_node_longer_name.txt b/packages/utils/test/mermaid/testdata/unicode/single_node_longer_name.txt new file mode 100644 index 000000000..85be6a5ea --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/single_node_longer_name.txt @@ -0,0 +1,8 @@ +graph LR +LongerName +--- +┌────────────┐ +│ │ +│ LongerName │ +│ │ +└────────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/three_nodes.txt b/packages/utils/test/mermaid/testdata/unicode/three_nodes.txt new file mode 100644 index 000000000..10e17e9fb --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/three_nodes.txt @@ -0,0 +1,9 @@ +graph LR +A --> B +B --> C +--- +┌───┐ ┌───┐ ┌───┐ +│ │ │ │ │ │ +│ A ├────►│ B ├────►│ C │ +│ │ │ │ │ │ +└───┘ └───┘ └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/three_nodes_single_line.txt b/packages/utils/test/mermaid/testdata/unicode/three_nodes_single_line.txt new file mode 100644 index 000000000..b4d9f2a8b --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/three_nodes_single_line.txt @@ -0,0 +1,8 @@ +graph LR +A --> B --> C +--- +┌───┐ ┌───┐ ┌───┐ +│ │ │ │ │ │ +│ A ├────►│ B ├────►│ C │ +│ │ │ │ │ │ +└───┘ └───┘ └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/two_layer_single_graph.txt b/packages/utils/test/mermaid/testdata/unicode/two_layer_single_graph.txt new file mode 100644 index 000000000..72e27fdfa --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/two_layer_single_graph.txt @@ -0,0 +1,19 @@ +graph LR +A --> B +A --> C +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├────►│ B │ +│ │ │ │ +└─┬─┘ └───┘ + │ + │ + │ + │ + │ + │ ┌───┐ + │ │ │ + └──────►│ C │ + │ │ + └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/two_layer_single_graph_longer_names.txt b/packages/utils/test/mermaid/testdata/unicode/two_layer_single_graph_longer_names.txt new file mode 100644 index 000000000..5d112d528 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/two_layer_single_graph_longer_names.txt @@ -0,0 +1,19 @@ +graph LR +ABC --> BCDEFG +ABC --> CDEFGHI +--- +┌─────┐ ┌─────────┐ +│ │ │ │ +│ ABC ├────►│ BCDEFG │ +│ │ │ │ +└──┬──┘ └─────────┘ + │ + │ + │ + │ + │ + │ ┌─────────┐ + │ │ │ + └───────►│ CDEFGHI │ + │ │ + └─────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/two_nodes_linked.txt b/packages/utils/test/mermaid/testdata/unicode/two_nodes_linked.txt new file mode 100644 index 000000000..84560f209 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/two_nodes_linked.txt @@ -0,0 +1,8 @@ +graph LR +A --> B +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├────►│ B │ +│ │ │ │ +└───┘ └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/two_nodes_longer_names.txt b/packages/utils/test/mermaid/testdata/unicode/two_nodes_longer_names.txt new file mode 100644 index 000000000..f53e3bbda --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/two_nodes_longer_names.txt @@ -0,0 +1,8 @@ +graph LR +LongerName1 --> LongerName2 +--- +┌─────────────┐ ┌─────────────┐ +│ │ │ │ +│ LongerName1 ├────►│ LongerName2 │ +│ │ │ │ +└─────────────┘ └─────────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/two_root_nodes.txt b/packages/utils/test/mermaid/testdata/unicode/two_root_nodes.txt new file mode 100644 index 000000000..cd89e1d8a --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/two_root_nodes.txt @@ -0,0 +1,19 @@ +graph LR +A --> B +C --> D +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├────►│ B │ +│ │ │ │ +└───┘ └───┘ + + + + + +┌───┐ ┌───┐ +│ │ │ │ +│ C ├────►│ D │ +│ │ │ │ +└───┘ └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/two_root_nodes_longer_names.txt b/packages/utils/test/mermaid/testdata/unicode/two_root_nodes_longer_names.txt new file mode 100644 index 000000000..1c2ea32c1 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/two_root_nodes_longer_names.txt @@ -0,0 +1,19 @@ +graph LR +ABC --> BCDEFG +CDEFGH --> DEF +--- +┌────────┐ ┌────────┐ +│ │ │ │ +│ ABC ├────►│ BCDEFG │ +│ │ │ │ +└────────┘ └────────┘ + + + + + +┌────────┐ ┌────────┐ +│ │ │ │ +│ CDEFGH ├────►│ DEF │ +│ │ │ │ +└────────┘ └────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/two_single_root_nodes.txt b/packages/utils/test/mermaid/testdata/unicode/two_single_root_nodes.txt new file mode 100644 index 000000000..25dab9054 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/two_single_root_nodes.txt @@ -0,0 +1,19 @@ +graph LR +A +B +--- +┌───┐ +│ │ +│ A │ +│ │ +└───┘ + + + + + +┌───┐ +│ │ +│ B │ +│ │ +└───┘ diff --git a/packages/utils/test/mermaid/xychart.test.ts b/packages/utils/test/mermaid/xychart.test.ts new file mode 100644 index 000000000..ca705e116 --- /dev/null +++ b/packages/utils/test/mermaid/xychart.test.ts @@ -0,0 +1,319 @@ +/** + * Tests for xychart-beta ASCII rendering. + * + * Tests bar charts, line charts, mixed charts, horizontal orientation, + * multi-series support, staircase line routing, and edge cases. + */ +import { describe, expect, it } from "bun:test"; +import { renderMermaidASCII } from "../../src/vendor/mermaid-ascii/ascii/index"; + +// ============================================================================ +// Helper — render with no colors for easy string matching +// ============================================================================ + +function render(text: string, useAscii = false): string { + return renderMermaidASCII(text, { colorMode: "none", useAscii }); +} + +// ============================================================================ +// Bar charts +// ============================================================================ + +describe("xychart ASCII – bar charts", () => { + it("renders a basic bar chart with block characters", () => { + const result = render(`xychart-beta + x-axis [A, B, C] + bar [10, 20, 30]`); + expect(result).toContain("█"); + expect(result).toContain("A"); + expect(result).toContain("B"); + expect(result).toContain("C"); + }); + + it("renders bars with # in ASCII mode", () => { + const result = render( + `xychart-beta + x-axis [A, B, C] + bar [10, 20, 30]`, + true, + ); + expect(result).toContain("#"); + expect(result).not.toContain("█"); + }); + + it("renders a bar chart with title", () => { + const result = render(`xychart-beta + title "Sales Report" + x-axis [Q1, Q2, Q3, Q4] + bar [100, 200, 150, 250]`); + expect(result).toContain("Sales Report"); + expect(result).toContain("Q1"); + expect(result).toContain("Q4"); + }); + + it("renders y-axis tick labels", () => { + const result = render(`xychart-beta + x-axis [A, B] + y-axis 0 --> 100 + bar [25, 75]`); + // Should have at least some tick values visible + expect(result).toContain("┤"); + expect(result).toContain("┼"); + }); + + it("renders multi-series bars", () => { + const result = render(`xychart-beta + x-axis [A, B, C] + bar [10, 20, 30] + bar [15, 25, 35]`); + // Should contain legend for multi-series + expect(result).toContain("Bar 1"); + expect(result).toContain("Bar 2"); + }); +}); + +// ============================================================================ +// Line charts +// ============================================================================ + +describe("xychart ASCII – line charts", () => { + it("renders a line chart with staircase routing", () => { + const result = render(`xychart-beta + x-axis [A, B, C, D] + line [10, 30, 20, 40]`); + // Should use box-drawing corners for staircase + expect(result).toContain("╭"); + expect(result).toContain("─"); + }); + + it("does NOT use dot markers on line charts", () => { + const result = render(`xychart-beta + x-axis [A, B, C, D] + line [10, 30, 20, 40]`); + expect(result).not.toContain("●"); + expect(result).not.toContain("*"); + }); + + it("uses vertical segments for row transitions", () => { + const result = render(`xychart-beta + x-axis [A, B, C] + line [10, 50, 20]`); + // Vertical segments connect rows + expect(result).toContain("│"); + }); + + it("renders ascending line with ╯ and ╭ corners", () => { + const result = render(`xychart-beta + x-axis [A, B] + line [10, 50]`); + // Ascending: ╯ at bottom (left+up), ╭ at top (bottom+right) + expect(result).toContain("╯"); + expect(result).toContain("╭"); + }); + + it("renders descending line with ╮ and ╰ corners", () => { + const result = render(`xychart-beta + x-axis [A, B] + line [50, 10]`); + // Descending: ╮ at top (left+down), ╰ at bottom (top+right) + expect(result).toContain("╮"); + expect(result).toContain("╰"); + }); + + it("renders flat line with only ─", () => { + const result = render(`xychart-beta + x-axis [A, B, C] + line [50, 50, 50]`); + expect(result).toContain("─"); + // No corners needed for flat lines + expect(result).not.toContain("╭"); + expect(result).not.toContain("╮"); + }); + + it("uses + for corners in ASCII mode", () => { + const result = render( + `xychart-beta + x-axis [A, B] + line [10, 50]`, + true, + ); + expect(result).toContain("+"); + expect(result).not.toContain("╭"); + expect(result).not.toContain("╰"); + }); +}); + +// ============================================================================ +// Mixed bar + line +// ============================================================================ + +describe("xychart ASCII – mixed charts", () => { + it("renders bars and lines together", () => { + const result = render(`xychart-beta + x-axis [A, B, C, D] + bar [20, 40, 30, 50] + line [25, 35, 40, 45]`); + // Should have both bar blocks and line corners + expect(result).toContain("█"); + expect(result).toContain("─"); + // Legend for multi-series + expect(result).toContain("Bar 1"); + expect(result).toContain("Line 1"); + }); +}); + +// ============================================================================ +// Horizontal orientation +// ============================================================================ + +describe("xychart ASCII – horizontal", () => { + it("renders horizontal bar chart", () => { + const result = render(`xychart-beta horizontal + x-axis [Python, JavaScript, Go] + bar [30, 25, 12]`); + // Category labels on left side + expect(result).toContain("Python"); + expect(result).toContain("JavaScript"); + expect(result).toContain("Go"); + expect(result).toContain("█"); + }); + + it("renders horizontal line chart with staircase", () => { + const result = render(`xychart-beta horizontal + x-axis [A, B, C] + line [10, 30, 20]`); + // Should have horizontal staircase routing + expect(result).toContain("─"); + expect(result).toContain("│"); + }); +}); + +// ============================================================================ +// Titles and axis labels +// ============================================================================ + +describe("xychart ASCII – titles and axes", () => { + it("renders chart title centered", () => { + const result = render(`xychart-beta + title "My Chart" + x-axis [A, B] + bar [10, 20]`); + const titleLine = result.split("\n").find(l => l.includes("My Chart")); + expect(titleLine).toBeDefined(); + }); + + it("renders x-axis title", () => { + const result = render(`xychart-beta + x-axis "Category" [A, B, C] + bar [10, 20, 30]`); + expect(result).toContain("Category"); + }); + + it("renders y-axis with explicit range", () => { + const result = render(`xychart-beta + x-axis [A, B] + y-axis "Score" 0 --> 100 + bar [25, 75]`); + // Y-axis ticks should appear + expect(result).toContain("0"); + expect(result).toContain("100"); + }); + + it("renders without title when not specified", () => { + const result = render(`xychart-beta + x-axis [A, B] + bar [10, 20]`); + // First non-empty line should be chart content, not a title + const lines = result.split("\n").filter(l => l.trim().length > 0); + expect(lines.length).toBeGreaterThan(0); + }); +}); + +// ============================================================================ +// Edge cases +// ============================================================================ + +describe("xychart ASCII – edge cases", () => { + it("handles single data point", () => { + const result = render(`xychart-beta + x-axis [Only] + bar [42]`); + expect(result).toContain("Only"); + expect(result).toContain("█"); + }); + + it("handles two data points", () => { + const result = render(`xychart-beta + x-axis [A, B] + bar [10, 20]`); + expect(result).toContain("A"); + expect(result).toContain("B"); + }); + + it("handles all zeros", () => { + const result = render(`xychart-beta + x-axis [A, B, C] + bar [0, 0, 0]`); + expect(result).toContain("A"); + // Should still render axes + expect(result).toContain("┼"); + }); + + it("handles large values", () => { + const result = render(`xychart-beta + x-axis [A, B] + bar [1000000, 2000000]`); + expect(result).toContain("A"); + expect(result).toContain("B"); + }); + + it("renders line chart with single data point", () => { + const result = render(`xychart-beta + x-axis [A] + line [50]`); + expect(result).toContain("A"); + expect(result).toContain("─"); + }); +}); + +// ============================================================================ +// Axis structure +// ============================================================================ + +describe("xychart ASCII – axis structure", () => { + it("has y-axis ticks (┤) at value positions", () => { + const result = render(`xychart-beta + x-axis [A, B, C] + y-axis 0 --> 100 + bar [25, 50, 75]`); + expect(result).toContain("┤"); + }); + + it("has x-axis ticks (┬) at category positions", () => { + const result = render(`xychart-beta + x-axis [A, B, C] + bar [25, 50, 75]`); + expect(result).toContain("┬"); + }); + + it("has origin marker (┼)", () => { + const result = render(`xychart-beta + x-axis [A, B] + bar [10, 20]`); + expect(result).toContain("┼"); + }); + + it("uses + for axis characters in ASCII mode", () => { + const result = render( + `xychart-beta + x-axis [A, B, C] + y-axis 0 --> 100 + bar [25, 50, 75]`, + true, + ); + expect(result).toContain("+"); + expect(result).not.toContain("┤"); + expect(result).not.toContain("┬"); + expect(result).not.toContain("┼"); + }); +}); diff --git a/patches/beautiful-mermaid@1.1.3.patch b/patches/beautiful-mermaid@1.1.3.patch deleted file mode 100644 index c41d4e55e..000000000 --- a/patches/beautiful-mermaid@1.1.3.patch +++ /dev/null @@ -1,1682 +0,0 @@ -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. - *