@zombie-mermaid/core 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/dist/index.cjs +29 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1236 -0
- package/dist/index.d.ts +1236 -0
- package/dist/index.js +888 -0
- package/dist/index.js.map +1 -0
- package/package.json +35 -0
- package/src/__tests__/click-directive.test.ts +63 -0
- package/src/__tests__/color-utils.test.ts +186 -0
- package/src/__tests__/diagram-type.test.ts +28 -0
- package/src/__tests__/text-metrics.test.ts +486 -0
- package/src/__tests__/theme-palette-contrast.test.ts +84 -0
- package/src/__tests__/theme.test.ts +162 -0
- package/src/click-directive.ts +106 -0
- package/src/color-utils.ts +205 -0
- package/src/diagram-type.ts +35 -0
- package/src/direction-override.ts +46 -0
- package/src/direction.ts +56 -0
- package/src/generated/mono-font-subset.ts +32 -0
- package/src/index.ts +34 -0
- package/src/init-directive.ts +248 -0
- package/src/multiline-utils.ts +275 -0
- package/src/statements.ts +197 -0
- package/src/style-directives.ts +214 -0
- package/src/text-metrics.ts +520 -0
- package/src/theme.ts +712 -0
- package/src/types.ts +670 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @zombie-mermaid/core — the module set both renderers import
|
|
3
|
+
//
|
|
4
|
+
// Every file re-exported below was grep-verified (zombie-mermaid#625,
|
|
5
|
+
// umbrella #620) as reachable from BOTH front doors — `src/index.ts` (SVG)
|
|
6
|
+
// and `src/ascii/index.ts` (ASCII) — either directly or through
|
|
7
|
+
// `src/parser.ts`, which both call. Nothing here imports anything outside
|
|
8
|
+
// this package, so `core` is a sink in the workspace graph: the ASCII
|
|
9
|
+
// renderer can depend on it without dragging in `elkjs` or any SVG
|
|
10
|
+
// emission code.
|
|
11
|
+
//
|
|
12
|
+
// The one external reference is a type-only `import type { ElkNode }` in
|
|
13
|
+
// `types.ts` (for `RenderOptions.layoutCache`), erased before bundling.
|
|
14
|
+
//
|
|
15
|
+
// A barrel rather than per-file subpath exports: `#620`'s recommendation 4
|
|
16
|
+
// has the umbrella's three entries eventually become thin re-exports of
|
|
17
|
+
// real packages, which needs a real package API. `sideEffects: false` plus
|
|
18
|
+
// this package's total absence of module-level side effects is what keeps
|
|
19
|
+
// the barrel from widening any bundle — verified against the pre-split
|
|
20
|
+
// `dist/` byte-for-byte.
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
export * from './click-directive.ts'
|
|
24
|
+
export * from './color-utils.ts'
|
|
25
|
+
export * from './diagram-type.ts'
|
|
26
|
+
export * from './direction.ts'
|
|
27
|
+
export * from './direction-override.ts'
|
|
28
|
+
export * from './init-directive.ts'
|
|
29
|
+
export * from './multiline-utils.ts'
|
|
30
|
+
export * from './statements.ts'
|
|
31
|
+
export * from './style-directives.ts'
|
|
32
|
+
export * from './text-metrics.ts'
|
|
33
|
+
export * from './theme.ts'
|
|
34
|
+
export * from './types.ts'
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// zombie-mermaid — %%{init: ...}%% configuration directives
|
|
3
|
+
//
|
|
4
|
+
// Mermaid lets a diagram carry its own configuration inline:
|
|
5
|
+
//
|
|
6
|
+
// %%{init: {"theme": "dark", "flowchart": {"curve": "basis"}}}%%
|
|
7
|
+
// flowchart TD
|
|
8
|
+
// A --> B
|
|
9
|
+
//
|
|
10
|
+
// The payload is JSON-ish: Mermaid accepts unquoted keys and single quotes,
|
|
11
|
+
// which strict JSON.parse rejects, so it is normalized before parsing.
|
|
12
|
+
//
|
|
13
|
+
// A directive only ever *supplies defaults*. Explicit render options passed
|
|
14
|
+
// by the caller win, because the caller is closer to the user's intent than
|
|
15
|
+
// text embedded in a diagram — and because a diagram from an untrusted source
|
|
16
|
+
// should not be able to override the host application's rendering choices.
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
import type { RenderOptions } from './types.ts'
|
|
20
|
+
|
|
21
|
+
/** How an edge path is interpolated between its routed points. */
|
|
22
|
+
export type CurveStyle =
|
|
23
|
+
'linear' | 'basis' | 'natural' | 'step' | 'stepBefore' | 'stepAfter'
|
|
24
|
+
|
|
25
|
+
const CURVE_STYLES = new Set<CurveStyle>([
|
|
26
|
+
'linear',
|
|
27
|
+
'basis',
|
|
28
|
+
'natural',
|
|
29
|
+
'step',
|
|
30
|
+
'stepBefore',
|
|
31
|
+
'stepAfter',
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
/** Configuration a diagram can set for itself via `%%{init: ...}%%`. */
|
|
35
|
+
export interface InitConfig {
|
|
36
|
+
theme?: string
|
|
37
|
+
/** Flowchart edge interpolation. */
|
|
38
|
+
curve?: CurveStyle
|
|
39
|
+
/** Recognized but not acted on — see `IGNORED_KEYS`. */
|
|
40
|
+
ignored: string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Directive keys that are parsed and deliberately not acted on, with the
|
|
45
|
+
* reason. Reported on the result so a caller can surface them rather than
|
|
46
|
+
* having the setting vanish silently.
|
|
47
|
+
*/
|
|
48
|
+
const IGNORED_KEYS: Record<string, string> = {
|
|
49
|
+
theme:
|
|
50
|
+
"colors come from the caller's bg/fg render options, which are usually CSS variables so a diagram inherits the host page's light/dark. A diagram-supplied theme would hard-code colors that fight it. Mermaid's theme names (default/dark/forest/neutral) also have no equivalent in this renderer's palettes — pass `bg`/`fg`, or a THEMES entry, instead",
|
|
51
|
+
securitylevel:
|
|
52
|
+
'this renderer emits static SVG and never executes diagram-supplied script, so there is no sandbox to configure',
|
|
53
|
+
defaultrenderer:
|
|
54
|
+
'ELK is the only layout engine; dagre/elk selection has no effect',
|
|
55
|
+
fontfamily: 'use the `font` render option instead',
|
|
56
|
+
htmllabels:
|
|
57
|
+
'labels are always rendered as SVG text; there is no HTML label mode',
|
|
58
|
+
maxtextsize: 'no text-size limit is enforced',
|
|
59
|
+
startonload: 'not a browser auto-render integration',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Matches a `%%{init: ... }%%` or `%%{initialize: ... }%%` directive.
|
|
64
|
+
*
|
|
65
|
+
* The body is captured lazily up to the closing `}%%` so a directive sharing
|
|
66
|
+
* a line with other content does not swallow it.
|
|
67
|
+
*/
|
|
68
|
+
const INIT_DIRECTIVE = /^\s*%%\{\s*(?:init|initialize)\s*:\s*([\s\S]*?)\}%%/i
|
|
69
|
+
|
|
70
|
+
/** True if `line` is an init directive. */
|
|
71
|
+
export function isInitDirective(line: string): boolean {
|
|
72
|
+
return INIT_DIRECTIVE.test(line)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Turn Mermaid's relaxed JSON into strict JSON.
|
|
77
|
+
*
|
|
78
|
+
* Mermaid accepts `{theme: 'dark'}`; JSON.parse does not. Quoting is applied
|
|
79
|
+
* only outside string literals so a value containing a colon, a brace, or an
|
|
80
|
+
* apostrophe survives intact.
|
|
81
|
+
*/
|
|
82
|
+
function normalizeRelaxedJson(text: string): string {
|
|
83
|
+
let out = ''
|
|
84
|
+
let quote: '"' | "'" | null = null
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < text.length; i++) {
|
|
87
|
+
const ch = text[i]!
|
|
88
|
+
|
|
89
|
+
if (quote !== null) {
|
|
90
|
+
if (ch === '\\' && i + 1 < text.length) {
|
|
91
|
+
/*
|
|
92
|
+
* Consume the escape and its target together. Without this, the
|
|
93
|
+
* backslash is emitted bare and the quote it protects reads as the
|
|
94
|
+
* end of the string — so strict, valid JSON like {"theme": "a\"b"}
|
|
95
|
+
* re-emits unbalanced and the whole directive is dropped.
|
|
96
|
+
*
|
|
97
|
+
* JSON has no \' escape, so a single-quoted string's \' becomes a
|
|
98
|
+
* bare apostrophe. Every other escape passes through untouched.
|
|
99
|
+
*/
|
|
100
|
+
const next = text[i + 1]!
|
|
101
|
+
out += next === "'" ? "'" : `\\${next}`
|
|
102
|
+
i++
|
|
103
|
+
} else if (ch === quote) {
|
|
104
|
+
quote = null
|
|
105
|
+
out += '"'
|
|
106
|
+
} else if (ch === '"') {
|
|
107
|
+
// A double quote inside a single-quoted string must be escaped once
|
|
108
|
+
// the string is re-emitted with double quotes.
|
|
109
|
+
out += '\\"'
|
|
110
|
+
} else {
|
|
111
|
+
out += ch
|
|
112
|
+
}
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (ch === '"' || ch === "'") {
|
|
117
|
+
quote = ch
|
|
118
|
+
out += '"'
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Bare key: an identifier run followed (after spaces) by a colon.
|
|
123
|
+
if (/[A-Za-z_$]/.test(ch)) {
|
|
124
|
+
let j = i
|
|
125
|
+
while (j < text.length && /[\w$-]/.test(text[j]!)) j++
|
|
126
|
+
const word = text.slice(i, j)
|
|
127
|
+
let k = j
|
|
128
|
+
while (k < text.length && /\s/.test(text[k]!)) k++
|
|
129
|
+
|
|
130
|
+
if (text[k] === ':') {
|
|
131
|
+
out += `"${word}"`
|
|
132
|
+
i = j - 1
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A bare word in value position — `true`, `false`, `null` are already
|
|
137
|
+
// valid JSON; anything else is quoted so it parses as a string.
|
|
138
|
+
out += /^(true|false|null)$/i.test(word)
|
|
139
|
+
? word.toLowerCase()
|
|
140
|
+
: `"${word}"`
|
|
141
|
+
i = j - 1
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
out += ch
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return out
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Parse an init directive's payload.
|
|
153
|
+
*
|
|
154
|
+
* Returns `undefined` when the line is not a directive or its payload cannot
|
|
155
|
+
* be parsed. A malformed directive is ignored rather than fatal: Mermaid
|
|
156
|
+
* treats config as advisory, and failing an entire diagram over a stray brace
|
|
157
|
+
* in a comment-like construct would be a poor trade.
|
|
158
|
+
*/
|
|
159
|
+
export function parseInitDirective(line: string): InitConfig | undefined {
|
|
160
|
+
const match = line.match(INIT_DIRECTIVE)
|
|
161
|
+
if (!match) return undefined
|
|
162
|
+
|
|
163
|
+
let payload: unknown
|
|
164
|
+
try {
|
|
165
|
+
payload = JSON.parse(normalizeRelaxedJson(match[1]!))
|
|
166
|
+
} catch {
|
|
167
|
+
return undefined
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (typeof payload !== 'object' || payload === null) return undefined
|
|
171
|
+
|
|
172
|
+
const config: InitConfig = { ignored: [] }
|
|
173
|
+
const record: Record<string, unknown> = { ...payload }
|
|
174
|
+
|
|
175
|
+
for (const [key, value] of Object.entries(record)) {
|
|
176
|
+
const lower = key.toLowerCase()
|
|
177
|
+
|
|
178
|
+
if (lower === 'theme' && typeof value === 'string') {
|
|
179
|
+
/*
|
|
180
|
+
* Kept on the result as metadata for a caller that wants to act on it,
|
|
181
|
+
* but listed as ignored: it is parsed and deliberately not applied. See
|
|
182
|
+
* IGNORED_KEYS for why.
|
|
183
|
+
*/
|
|
184
|
+
config.theme = value
|
|
185
|
+
config.ignored.push(key)
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (lower === 'flowchart' && typeof value === 'object' && value !== null) {
|
|
190
|
+
const flowchart: Record<string, unknown> = { ...value }
|
|
191
|
+
for (const [fk, fv] of Object.entries(flowchart)) {
|
|
192
|
+
if (fk.toLowerCase() === 'curve' && typeof fv === 'string') {
|
|
193
|
+
if (CURVE_STYLES.has(fv as CurveStyle)) {
|
|
194
|
+
config.curve = fv as CurveStyle
|
|
195
|
+
}
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
if (fk.toLowerCase() in IGNORED_KEYS) config.ignored.push(fk)
|
|
199
|
+
}
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (lower in IGNORED_KEYS) config.ignored.push(key)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return config
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Scan a whole diagram for init directives and merge them.
|
|
211
|
+
*
|
|
212
|
+
* Later directives win over earlier ones, matching Mermaid, where a second
|
|
213
|
+
* directive overrides the first.
|
|
214
|
+
*/
|
|
215
|
+
export function extractInitConfig(lines: string[]): InitConfig {
|
|
216
|
+
const merged: InitConfig = { ignored: [] }
|
|
217
|
+
|
|
218
|
+
for (const line of lines) {
|
|
219
|
+
const config = parseInitDirective(line)
|
|
220
|
+
if (!config) continue
|
|
221
|
+
if (config.theme !== undefined) merged.theme = config.theme
|
|
222
|
+
if (config.curve !== undefined) merged.curve = config.curve
|
|
223
|
+
merged.ignored.push(...config.ignored)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return merged
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Fold an init config into render options.
|
|
231
|
+
*
|
|
232
|
+
* Caller-supplied options always win: a directive supplies a default, never
|
|
233
|
+
* an override. See the module header for why.
|
|
234
|
+
*/
|
|
235
|
+
export function applyInitConfig(
|
|
236
|
+
options: RenderOptions,
|
|
237
|
+
config: InitConfig,
|
|
238
|
+
): RenderOptions {
|
|
239
|
+
if (config.curve === undefined || options.curve !== undefined) return options
|
|
240
|
+
return { ...options, curve: config.curve }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Human-readable note about directive keys that were parsed but not applied. */
|
|
244
|
+
export function describeIgnored(config: InitConfig): string[] {
|
|
245
|
+
return [...new Set(config.ignored)].map(
|
|
246
|
+
(key) => `${key}: ${IGNORED_KEYS[key.toLowerCase()] ?? 'not supported'}`,
|
|
247
|
+
)
|
|
248
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Multi-line Text Rendering Utilities
|
|
3
|
+
//
|
|
4
|
+
// Shared utilities for rendering multi-line text in SVG using <tspan> elements.
|
|
5
|
+
// Supports inline formatting: <b>, <i>, <u>, <s> mapped to SVG attributes.
|
|
6
|
+
// Used across all diagram types (flowcharts, state, sequence, class, ER).
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
import { LINE_HEIGHT_RATIO } from './text-metrics.ts'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Normalize label text: strip surrounding quotes, convert <br> tags and
|
|
13
|
+
* literal \n sequences to newline characters. Strips unsupported HTML tags
|
|
14
|
+
* but preserves formatting tags (<b>, <i>, <u>, <s>) for SVG rendering.
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeBrTags(label: string): string {
|
|
17
|
+
// Strip surrounding double quotes (Mermaid uses them for special chars in labels)
|
|
18
|
+
const unquoted =
|
|
19
|
+
label.startsWith('"') && label.endsWith('"') ? label.slice(1, -1) : label
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* Mermaid's markdown-string form wraps the label in backticks —
|
|
23
|
+
* `A["` + '`**bold**`' + `"]`. The markdown conversion below already runs
|
|
24
|
+
* unconditionally, so the backticks only need removing; left in place they
|
|
25
|
+
* rendered as literal characters in the label.
|
|
26
|
+
*/
|
|
27
|
+
const unfenced =
|
|
28
|
+
unquoted.length >= 2 && unquoted.startsWith('`') && unquoted.endsWith('`')
|
|
29
|
+
? unquoted.slice(1, -1)
|
|
30
|
+
: unquoted
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
unfenced
|
|
34
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
35
|
+
.replace(/\\n/g, '\n')
|
|
36
|
+
.replace(/<\/?(?:sub|sup|small|mark)\s*>/gi, '')
|
|
37
|
+
// Markdown formatting → HTML tags (order matters: ** before *)
|
|
38
|
+
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
|
|
39
|
+
.replace(/(?<!\*)\*([^\s*](?:[^*]*[^\s*])?)\*(?!\*)/g, '<i>$1</i>')
|
|
40
|
+
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Strip all inline formatting tags from text, keeping only plain text.
|
|
46
|
+
* Used for text measurement where tag characters shouldn't affect width.
|
|
47
|
+
*/
|
|
48
|
+
export function stripFormattingTags(text: string): string {
|
|
49
|
+
return text.replace(/<\/?(?:b|strong|i|em|u|s|del)\s*>/gi, '')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Escape special XML characters in text content.
|
|
54
|
+
*/
|
|
55
|
+
export function escapeXml(text: string): string {
|
|
56
|
+
return text
|
|
57
|
+
.replace(/&/g, '&')
|
|
58
|
+
.replace(/</g, '<')
|
|
59
|
+
.replace(/>/g, '>')
|
|
60
|
+
.replace(/"/g, '"')
|
|
61
|
+
.replace(/'/g, ''')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Escape a string for use as an XML/HTML attribute value.
|
|
66
|
+
* Escapes quotes and ampersands to prevent attribute injection.
|
|
67
|
+
*
|
|
68
|
+
* Deliberately distinct from `escapeXml()` above: attribute values in this
|
|
69
|
+
* codebase are always double-quoted, so single quotes don't need escaping —
|
|
70
|
+
* `escapeXml()` escapes them too (for safe embedding in either quote style),
|
|
71
|
+
* which would change output if reused here.
|
|
72
|
+
*/
|
|
73
|
+
export function escapeAttr(value: string): string {
|
|
74
|
+
return value
|
|
75
|
+
.replace(/&/g, '&')
|
|
76
|
+
.replace(/"/g, '"')
|
|
77
|
+
.replace(/</g, '<')
|
|
78
|
+
.replace(/>/g, '>')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ============================================================================
|
|
82
|
+
// Inline formatting: <b>, <i>, <u>, <s> → SVG tspan attributes
|
|
83
|
+
// ============================================================================
|
|
84
|
+
|
|
85
|
+
interface StyledSegment {
|
|
86
|
+
text: string
|
|
87
|
+
bold: boolean
|
|
88
|
+
italic: boolean
|
|
89
|
+
underline: boolean
|
|
90
|
+
strikethrough: boolean
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Regex to match opening/closing formatting tags */
|
|
94
|
+
const FORMAT_TAG_REGEX = /<(\/)?(?:(b|strong)|(i|em)|(u)|(s|del))\s*>/gi
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Parse a line of text into styled segments based on inline formatting tags.
|
|
98
|
+
* Supports nesting: `<b>bold <i>both</i> bold</b>`.
|
|
99
|
+
*/
|
|
100
|
+
function parseInlineFormatting(line: string): StyledSegment[] {
|
|
101
|
+
const segments: StyledSegment[] = []
|
|
102
|
+
let bold = false,
|
|
103
|
+
italic = false,
|
|
104
|
+
underline = false,
|
|
105
|
+
strikethrough = false
|
|
106
|
+
let lastIndex = 0
|
|
107
|
+
|
|
108
|
+
// Reset lastIndex for global regex
|
|
109
|
+
FORMAT_TAG_REGEX.lastIndex = 0
|
|
110
|
+
|
|
111
|
+
let match: RegExpExecArray | null
|
|
112
|
+
while ((match = FORMAT_TAG_REGEX.exec(line)) !== null) {
|
|
113
|
+
// Capture text before this tag
|
|
114
|
+
if (match.index > lastIndex) {
|
|
115
|
+
segments.push({
|
|
116
|
+
text: line.slice(lastIndex, match.index),
|
|
117
|
+
bold,
|
|
118
|
+
italic,
|
|
119
|
+
underline,
|
|
120
|
+
strikethrough,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
lastIndex = match.index + match[0].length
|
|
124
|
+
|
|
125
|
+
const isClosing = Boolean(match[1])
|
|
126
|
+
// match[2] = b|strong, match[3] = i|em, match[4] = u, match[5] = s|del
|
|
127
|
+
if (match[2]) bold = !isClosing
|
|
128
|
+
else if (match[3]) italic = !isClosing
|
|
129
|
+
else if (match[4]) underline = !isClosing
|
|
130
|
+
else if (match[5]) strikethrough = !isClosing
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Remaining text after last tag
|
|
134
|
+
if (lastIndex < line.length) {
|
|
135
|
+
segments.push({
|
|
136
|
+
text: line.slice(lastIndex),
|
|
137
|
+
bold,
|
|
138
|
+
italic,
|
|
139
|
+
underline,
|
|
140
|
+
strikethrough,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return segments
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Check if a line contains any formatting tags */
|
|
148
|
+
const HAS_FORMAT_TAGS = /<\/?(?:b|strong|i|em|u|s|del)\s*>/i
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Render a line's content as SVG, with inline formatting applied as tspan attributes.
|
|
152
|
+
* Returns raw SVG content (no wrapping tspan — caller provides positioning).
|
|
153
|
+
*/
|
|
154
|
+
function renderLineContent(line: string): string {
|
|
155
|
+
// Fast path: no formatting tags
|
|
156
|
+
if (!HAS_FORMAT_TAGS.test(line)) return escapeXml(line)
|
|
157
|
+
|
|
158
|
+
const segments = parseInlineFormatting(line)
|
|
159
|
+
if (segments.length === 0) return ''
|
|
160
|
+
|
|
161
|
+
// If all segments are unstyled, just escape
|
|
162
|
+
const allPlain = segments.every(
|
|
163
|
+
(s) => !s.bold && !s.italic && !s.underline && !s.strikethrough,
|
|
164
|
+
)
|
|
165
|
+
if (allPlain) return segments.map((s) => escapeXml(s.text)).join('')
|
|
166
|
+
|
|
167
|
+
return segments
|
|
168
|
+
.map((seg) => {
|
|
169
|
+
const escaped = escapeXml(seg.text)
|
|
170
|
+
if (!seg.bold && !seg.italic && !seg.underline && !seg.strikethrough)
|
|
171
|
+
return escaped
|
|
172
|
+
|
|
173
|
+
const attrs: string[] = []
|
|
174
|
+
if (seg.bold) attrs.push('font-weight="bold"')
|
|
175
|
+
if (seg.italic) attrs.push('font-style="italic"')
|
|
176
|
+
// SVG text-decoration can combine values
|
|
177
|
+
const deco: string[] = []
|
|
178
|
+
if (seg.underline) deco.push('underline')
|
|
179
|
+
if (seg.strikethrough) deco.push('line-through')
|
|
180
|
+
if (deco.length) attrs.push(`text-decoration="${deco.join(' ')}"`)
|
|
181
|
+
|
|
182
|
+
return `<tspan ${attrs.join(' ')}>${escaped}</tspan>`
|
|
183
|
+
})
|
|
184
|
+
.join('')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ============================================================================
|
|
188
|
+
// Multi-line text rendering
|
|
189
|
+
// ============================================================================
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Render a multi-line text element with proper vertical centering.
|
|
193
|
+
*
|
|
194
|
+
* For single-line text, returns a simple <text> element.
|
|
195
|
+
* For multi-line text (containing \n), returns <text> with <tspan> children.
|
|
196
|
+
* Inline formatting tags (<b>, <i>, <u>, <s>) are rendered as SVG attributes.
|
|
197
|
+
*
|
|
198
|
+
* @param text - The text to render (may contain \n and formatting tags)
|
|
199
|
+
* @param cx - Center x coordinate
|
|
200
|
+
* @param cy - Center y coordinate
|
|
201
|
+
* @param fontSize - Font size in pixels
|
|
202
|
+
* @param attrs - Additional SVG attributes (e.g., 'text-anchor="middle" fill="var(--_text)"')
|
|
203
|
+
* @param baselineShift - Baseline shift for vertical alignment (default 0.35)
|
|
204
|
+
* @returns SVG text element string
|
|
205
|
+
*/
|
|
206
|
+
export function renderMultilineText(
|
|
207
|
+
text: string,
|
|
208
|
+
cx: number,
|
|
209
|
+
cy: number,
|
|
210
|
+
fontSize: number,
|
|
211
|
+
attrs: string,
|
|
212
|
+
baselineShift: number = 0.35,
|
|
213
|
+
): string {
|
|
214
|
+
const lines = text.split('\n')
|
|
215
|
+
|
|
216
|
+
// Single line — simple text element
|
|
217
|
+
if (lines.length === 1) {
|
|
218
|
+
const dy = fontSize * baselineShift
|
|
219
|
+
return `<text x="${cx}" y="${cy}" ${attrs} dy="${dy}">${renderLineContent(text)}</text>`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Multi-line — use tspan elements with vertical centering
|
|
223
|
+
const lineHeight = fontSize * LINE_HEIGHT_RATIO
|
|
224
|
+
// First line dy: shift up by (n-1)/2 line heights, then add baseline shift
|
|
225
|
+
const firstDy =
|
|
226
|
+
-((lines.length - 1) / 2) * lineHeight + fontSize * baselineShift
|
|
227
|
+
|
|
228
|
+
const tspans = lines
|
|
229
|
+
.map((line, i) => {
|
|
230
|
+
const dy = i === 0 ? firstDy : lineHeight
|
|
231
|
+
return `<tspan x="${cx}" dy="${dy}">${renderLineContent(line)}</tspan>`
|
|
232
|
+
})
|
|
233
|
+
.join('')
|
|
234
|
+
|
|
235
|
+
return `<text x="${cx}" y="${cy}" ${attrs}>${tspans}</text>`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Render a multi-line text element with a background rectangle (pill).
|
|
240
|
+
*
|
|
241
|
+
* Used for edge labels that need a background for readability.
|
|
242
|
+
*
|
|
243
|
+
* @param text - The text to render (may contain \n)
|
|
244
|
+
* @param cx - Center x coordinate
|
|
245
|
+
* @param cy - Center y coordinate
|
|
246
|
+
* @param textWidth - Pre-calculated text width (max line width)
|
|
247
|
+
* @param textHeight - Pre-calculated text height (lines × lineHeight)
|
|
248
|
+
* @param fontSize - Font size in pixels
|
|
249
|
+
* @param padding - Padding around text
|
|
250
|
+
* @param textAttrs - SVG attributes for the text element
|
|
251
|
+
* @param bgAttrs - SVG attributes for the background rect
|
|
252
|
+
* @returns SVG elements string (rect + text)
|
|
253
|
+
*/
|
|
254
|
+
export function renderMultilineTextWithBackground(
|
|
255
|
+
text: string,
|
|
256
|
+
cx: number,
|
|
257
|
+
cy: number,
|
|
258
|
+
textWidth: number,
|
|
259
|
+
textHeight: number,
|
|
260
|
+
fontSize: number,
|
|
261
|
+
padding: number,
|
|
262
|
+
textAttrs: string,
|
|
263
|
+
bgAttrs: string,
|
|
264
|
+
): string {
|
|
265
|
+
const bgWidth = textWidth + padding * 2
|
|
266
|
+
const bgHeight = textHeight + padding * 2
|
|
267
|
+
|
|
268
|
+
const rect =
|
|
269
|
+
`<rect x="${cx - bgWidth / 2}" y="${cy - bgHeight / 2}" ` +
|
|
270
|
+
`width="${bgWidth}" height="${bgHeight}" ${bgAttrs} />`
|
|
271
|
+
|
|
272
|
+
const textEl = renderMultilineText(text, cx, cy, fontSize, textAttrs)
|
|
273
|
+
|
|
274
|
+
return `${rect}\n${textEl}`
|
|
275
|
+
}
|