opentui-markdown-render 0.0.0-stage → 0.2.0

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.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +27 -0
  3. package/README.md +223 -2
  4. package/bin/otmd.tsx +99 -0
  5. package/native/Cargo.lock +3708 -0
  6. package/native/Cargo.toml +28 -0
  7. package/native/build.rs +3 -0
  8. package/native/src/lib.rs +880 -0
  9. package/package.json +67 -4
  10. package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
  11. package/src/app.tsx +180 -0
  12. package/src/cli.ts +63 -0
  13. package/src/config/index.ts +46 -0
  14. package/src/config/load.ts +337 -0
  15. package/src/config/resolve.ts +36 -0
  16. package/src/config/theme.ts +110 -0
  17. package/src/config/types.ts +105 -0
  18. package/src/core/document.ts +44 -0
  19. package/src/core/frame.ts +144 -0
  20. package/src/core/image.ts +9 -0
  21. package/src/core/index.ts +6 -0
  22. package/src/core/layout.ts +27 -0
  23. package/src/core/math.ts +170 -0
  24. package/src/core/parse.ts +208 -0
  25. package/src/core/tables.ts +58 -0
  26. package/src/graphics.ts +85 -0
  27. package/src/headings.ts +77 -0
  28. package/src/index.ts +107 -0
  29. package/src/math/index.ts +86 -0
  30. package/src/math/native.ts +39 -0
  31. package/src/math/renderer.ts +84 -0
  32. package/src/math/validate.ts +44 -0
  33. package/src/media.tsx +267 -0
  34. package/src/merman/class/dagre-layout.ts +171 -0
  35. package/src/merman/class/diagram.ts +8 -0
  36. package/src/merman/class/drawing.ts +343 -0
  37. package/src/merman/class/labels.ts +38 -0
  38. package/src/merman/class/layout.ts +531 -0
  39. package/src/merman/class/parser.ts +308 -0
  40. package/src/merman/class/render-grid.ts +18 -0
  41. package/src/merman/class/style.ts +27 -0
  42. package/src/merman/class/types.ts +57 -0
  43. package/src/merman/core/canvas.ts +228 -0
  44. package/src/merman/core/color/style.ts +56 -0
  45. package/src/merman/core/drawing.ts +307 -0
  46. package/src/merman/core/geometry.ts +347 -0
  47. package/src/merman/core/mermaid.ts +38 -0
  48. package/src/merman/core/render-grid.ts +51 -0
  49. package/src/merman/core/spatial.ts +243 -0
  50. package/src/merman/core/text-lines.ts +32 -0
  51. package/src/merman/core/text.ts +44 -0
  52. package/src/merman/detect.ts +19 -0
  53. package/src/merman/diagnostics.ts +16 -0
  54. package/src/merman/diagram.ts +268 -0
  55. package/src/merman/flowchart/drawing.ts +433 -0
  56. package/src/merman/flowchart/labels.ts +144 -0
  57. package/src/merman/flowchart/layout.ts +1057 -0
  58. package/src/merman/flowchart/options.ts +12 -0
  59. package/src/merman/flowchart/parser.ts +477 -0
  60. package/src/merman/flowchart/render.ts +10 -0
  61. package/src/merman/flowchart/routing.ts +1429 -0
  62. package/src/merman/flowchart/style.ts +79 -0
  63. package/src/merman/flowchart/types.ts +63 -0
  64. package/src/merman/gantt/diagram.ts +8 -0
  65. package/src/merman/gantt/drawing.ts +193 -0
  66. package/src/merman/gantt/parser.ts +198 -0
  67. package/src/merman/gantt/render-grid.ts +17 -0
  68. package/src/merman/gantt/style.ts +36 -0
  69. package/src/merman/gantt/types.ts +45 -0
  70. package/src/merman/gitgraph/diagram.ts +8 -0
  71. package/src/merman/gitgraph/drawing.ts +234 -0
  72. package/src/merman/gitgraph/parser.ts +234 -0
  73. package/src/merman/gitgraph/render-grid.ts +17 -0
  74. package/src/merman/gitgraph/style.ts +37 -0
  75. package/src/merman/gitgraph/types.ts +36 -0
  76. package/src/merman/palette.ts +60 -0
  77. package/src/merman/sequence/diagram.ts +8 -0
  78. package/src/merman/sequence/drawing.ts +288 -0
  79. package/src/merman/sequence/options.ts +8 -0
  80. package/src/merman/sequence/parser.ts +303 -0
  81. package/src/merman/sequence/placement.ts +692 -0
  82. package/src/merman/sequence/render-grid.ts +22 -0
  83. package/src/merman/sequence/style.ts +81 -0
  84. package/src/merman/sequence/types.ts +74 -0
  85. package/src/merman/state/diagram.ts +8 -0
  86. package/src/merman/state/drawing.ts +363 -0
  87. package/src/merman/state/endpoint.ts +15 -0
  88. package/src/merman/state/layout.ts +1134 -0
  89. package/src/merman/state/note.ts +59 -0
  90. package/src/merman/state/options.ts +11 -0
  91. package/src/merman/state/parser.ts +245 -0
  92. package/src/merman/state/render-grid.ts +22 -0
  93. package/src/merman/state/routing.ts +1522 -0
  94. package/src/merman/state/search.ts +103 -0
  95. package/src/merman/state/style.ts +52 -0
  96. package/src/merman/state/types.ts +63 -0
  97. package/src/merman/state/visible-model.ts +77 -0
  98. package/src/merman/timeline/diagram.ts +8 -0
  99. package/src/merman/timeline/drawing.ts +109 -0
  100. package/src/merman/timeline/parser.ts +119 -0
  101. package/src/merman/timeline/render-grid.ts +17 -0
  102. package/src/merman/timeline/style.ts +36 -0
  103. package/src/merman/timeline/types.ts +31 -0
  104. package/src/merman.ts +3 -0
  105. package/src/statusline.tsx +183 -0
  106. package/src/theme/colors.ts +11 -0
  107. package/src/theme/defaults.ts +190 -0
  108. package/src/theme/index.ts +7 -0
  109. package/src/theme/load.ts +39 -0
  110. package/src/theme/resolve.ts +167 -0
  111. package/src/theme/syntax.ts +96 -0
  112. package/src/theme/types.ts +151 -0
  113. package/src/view/code.tsx +27 -0
  114. package/src/view/document-slots.ts +111 -0
  115. package/src/view/document-table.tsx +98 -0
  116. package/src/view/document.tsx +195 -0
  117. package/src/view/frame.tsx +83 -0
  118. package/src/view/inline-flow.tsx +113 -0
  119. package/src/view/inline-layout.ts +149 -0
  120. package/src/view/inline-math.ts +42 -0
  121. package/src/view/inline-style.ts +103 -0
  122. package/src/view/math.tsx +85 -0
  123. package/src/view/mermaid.tsx +128 -0
  124. package/src/view/table.ts +100 -0
  125. package/themes/dark.json +32 -0
  126. package/themes/example.jsonc +98 -0
  127. package/themes/light.json +32 -0
  128. package/themes/solarized-dark.json +32 -0
  129. package/themes/solarized-light.json +32 -0
package/src/media.tsx ADDED
@@ -0,0 +1,267 @@
1
+ import { NativeImage, imageInfo, parseColor, type ColorInput, type RGBA } from "@opentui/core"
2
+ import { decompressFrames, parseGIF } from "gifuct-js"
3
+ import path from "node:path"
4
+ import { createEffect, createResource, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
5
+
6
+ const maxRows = 32
7
+ // Allow larger images for better resolution in kitty/modern terminals
8
+ export const maxColumns = 80
9
+
10
+ const TRANSPARENT_BACKGROUND = "#000000"
11
+
12
+ export type ImageLimits = {
13
+ readonly maxColumns?: number
14
+ readonly maxRows?: number
15
+ }
16
+
17
+ type Rgb = readonly [number, number, number]
18
+
19
+ function isRgba(value: unknown): value is RGBA {
20
+ return typeof value === "object" && value !== null && "r" in value && "g" in value && "b" in value
21
+ }
22
+
23
+ // `parseColor` cannot read a plain object, and an OpenTUI `RGBA` reaches us both
24
+ // as a live instance and as a JSON round-trip inside the resource key, so the
25
+ // channel values are normalized here instead of trusted to `parseColor`.
26
+ export function resolveBackground(input: ColorInput | RGBA | undefined): Rgb {
27
+ if (input === undefined) return [0, 0, 0]
28
+ if (isRgba(input)) {
29
+ const channel = (value: number) => Math.round(Math.max(0, Math.min(1, Number(value) || 0)) * 255)
30
+ return [channel(input.r), channel(input.g), channel(input.b)]
31
+ }
32
+ try {
33
+ const color = parseColor(input)
34
+ const channel = (value: number) => Math.round(Math.max(0, Math.min(1, value)) * 255)
35
+ return [channel(color.r), channel(color.g), channel(color.b)]
36
+ } catch {
37
+ const color = parseColor(TRANSPARENT_BACKGROUND)
38
+ return [Math.round(color.r * 255), Math.round(color.g * 255), Math.round(color.b * 255)]
39
+ }
40
+ }
41
+
42
+ export function rgbToHex(color: Rgb) {
43
+ const channel = (value: number) => Math.round(value).toString(16).padStart(2, "0")
44
+ return `#${channel(color[0])}${channel(color[1])}${channel(color[2])}`
45
+ }
46
+
47
+ type AnimatedFrame = { readonly image: NativeImage; readonly delay: number }
48
+ type Media = { readonly box: { columns: number; rows: number }; readonly image?: NativeImage; readonly frames?: readonly AnimatedFrame[] }
49
+
50
+ export { standaloneImage, type MarkdownImage } from "./core/image"
51
+
52
+ export function resolveLocalImage(base: string | undefined, href: string) {
53
+ if (!base) return { error: "No asset base is configured" }
54
+ if (/^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith("//")) return { error: "Only local image paths are supported" }
55
+ const root = path.resolve(base)
56
+ const target = path.resolve(root, href)
57
+ if (target !== root && !target.startsWith(`${root}${path.sep}`)) return { error: "Image path escapes the asset root" }
58
+ return { path: target }
59
+ }
60
+
61
+ export function imageBox(
62
+ width: number,
63
+ height: number,
64
+ columns: number,
65
+ cellAspectRatio: number,
66
+ limits?: ImageLimits,
67
+ ) {
68
+ const ratio = width / height
69
+ // A 0 (or non-finite) cell aspect means the terminal never reported pixel
70
+ // dimensions; the common 2:1 cell is the safe default.
71
+ const aspect = Number.isFinite(cellAspectRatio) && cellAspectRatio > 0 ? cellAspectRatio : 2
72
+ const columnLimit = Math.max(1, limits?.maxColumns ?? maxColumns)
73
+ const rowLimit = Math.max(1, limits?.maxRows ?? maxRows)
74
+ const clamped = Math.max(1, Math.min(Math.max(1, columns), columnLimit))
75
+ const naturalRows = Math.max(1, Math.ceil(clamped / (ratio * aspect)))
76
+ if (naturalRows <= rowLimit) return { columns: clamped, rows: naturalRows }
77
+ return { columns: Math.max(1, Math.round(ratio * rowLimit * aspect)), rows: rowLimit }
78
+ }
79
+
80
+ export function LocalImage(props: {
81
+ readonly href: string
82
+ readonly alt: string
83
+ readonly base: () => string | undefined
84
+ readonly columns: () => number
85
+ readonly cell: () => { width: number; height: number } | null | undefined
86
+ readonly background: () => ColorInput | RGBA | undefined
87
+ readonly labelColor?: () => RGBA
88
+ readonly textColor?: () => RGBA
89
+ readonly limits?: ImageLimits
90
+ }) {
91
+ const [failure, setFailure] = createSignal<string>()
92
+ // The cell size is part of the key so the image is reloaded and re-fitted once
93
+ // the terminal reports its pixel resolution (kitty queries it after startup).
94
+ const [loaded] = createResource(
95
+ () =>
96
+ JSON.stringify({
97
+ href: props.href,
98
+ base: props.base(),
99
+ columns: props.columns(),
100
+ cell: props.cell() ?? null,
101
+ background: rgbToHex(resolveBackground(props.background())),
102
+ limits: props.limits ?? null,
103
+ }),
104
+ async (key): Promise<Media | { error: string }> => {
105
+ const input = JSON.parse(key) as {
106
+ href: string
107
+ base?: string
108
+ columns: number
109
+ cell?: { width: number; height: number } | null
110
+ background?: string
111
+ limits?: ImageLimits | null
112
+ }
113
+ const resolved = resolveLocalImage(input.base, input.href)
114
+ if (resolved.path === undefined) return { error: resolved.error ?? "Image path is invalid" }
115
+ const file = Bun.file(resolved.path)
116
+ if (!(await file.exists())) return { error: `Image does not exist: ${input.href}` }
117
+ try {
118
+ const bytes = new Uint8Array(await file.arrayBuffer())
119
+ const info = imageInfo(bytes)
120
+ const box = imageBox(
121
+ info.width,
122
+ info.height,
123
+ Math.max(1, input.columns),
124
+ input.cell ? input.cell.height / input.cell.width : 2,
125
+ input.limits ?? undefined,
126
+ )
127
+ const target = { width: Math.max(1, Math.round(box.columns * (input.cell?.width ?? 8))), height: Math.max(1, Math.round(box.rows * (input.cell?.height ?? 16))) }
128
+ const background = resolveBackground(input.background)
129
+ if (info.format === "gif") return loadGif(bytes, box, target, background)
130
+ return { box, image: prepareImage(NativeImage.decode(bytes), target, background) }
131
+ } catch (error) {
132
+ return { error: error instanceof Error ? error.message : String(error) }
133
+ }
134
+ },
135
+ )
136
+ const media = () => {
137
+ const value = loaded()
138
+ return value && !("error" in value) ? value : undefined
139
+ }
140
+ const error = () => {
141
+ const value = loaded()
142
+ return failure() ?? (value && "error" in value ? value.error : undefined)
143
+ }
144
+ const [frame, setFrame] = createSignal<NativeImage>()
145
+
146
+ createEffect(() => {
147
+ const value = media()
148
+ if (!value?.frames?.length) {
149
+ setFrame(value?.image)
150
+ return
151
+ }
152
+ let index = 0
153
+ let timer: ReturnType<typeof setTimeout> | undefined
154
+ const next = () => {
155
+ const current = value.frames![index]!
156
+ setFrame(current.image)
157
+ index = (index + 1) % value.frames!.length
158
+ timer = setTimeout(next, current.delay)
159
+ }
160
+ next()
161
+ onCleanup(() => {
162
+ if (timer) clearTimeout(timer)
163
+ })
164
+ })
165
+ createEffect(() => {
166
+ const value = media()
167
+ onCleanup(() => disposeMedia(value))
168
+ })
169
+
170
+ return (
171
+ <box width="100%" flexDirection="column" flexShrink={0} overflow="hidden">
172
+ <Switch>
173
+ <Match when={loaded.loading}>
174
+ <box width="100%" flexDirection="row">
175
+ <text fg={props.labelColor?.()} flexShrink={0}>Loading image: </text>
176
+ <text fg={props.textColor?.()} wrapMode="word" flexGrow={1}>{props.alt || props.href}</text>
177
+ </box>
178
+ </Match>
179
+ <Match when={error()}>
180
+ <box width="100%" flexDirection="row">
181
+ <text fg={props.labelColor?.()} flexShrink={0}>Could not render image </text>
182
+ <text fg={props.textColor?.()} wrapMode="word" flexGrow={1}>{`${props.alt || props.href}: ${error()}`}</text>
183
+ </box>
184
+ </Match>
185
+ <Match when={media() && frame()}>
186
+ <image
187
+ id={`opentui-markdown-image-${props.href}`}
188
+ source={frame()!}
189
+ fit="fit"
190
+ protocol="auto"
191
+ width={media()!.box.columns}
192
+ height={media()!.box.rows}
193
+ onError={(error) => setFailure(error instanceof Error ? error.message : String(error))}
194
+ />
195
+ </Match> </Switch>
196
+ </box>
197
+ )
198
+ }
199
+
200
+ function loadGif(bytes: Uint8Array, box: { columns: number; rows: number }, target: { width: number; height: number }, background: Rgb) {
201
+ const gif = parseGIF(bytes.slice().buffer as ArrayBuffer)
202
+ const width = gif.lsd.width
203
+ const height = gif.lsd.height
204
+ const canvas = new Uint8Array(width * height * 4)
205
+ const frames = decompressFrames(gif, true).map((frame) => {
206
+ const before = canvas.slice()
207
+ drawPatch(canvas, width, height, frame.patch, frame.dims.left, frame.dims.top, frame.dims.width, frame.dims.height)
208
+ const image = prepareImage(NativeImage.fromRgba(canvas.slice(), width, height), target, background)
209
+ if (frame.disposalType === 2) clearPatch(canvas, width, frame.dims.left, frame.dims.top, frame.dims.width, frame.dims.height)
210
+ if (frame.disposalType === 3) canvas.set(before)
211
+ return { image, delay: Math.max(50, frame.delay || 100) }
212
+ })
213
+ if (!frames.length) throw new Error("GIF contains no frames")
214
+ return { box, frames }
215
+ }
216
+
217
+ function drawPatch(canvas: Uint8Array, width: number, height: number, patch: Uint8ClampedArray, left: number, top: number, patchWidth: number, patchHeight: number) {
218
+ for (let y = 0; y < patchHeight; y++) {
219
+ if (top + y < 0 || top + y >= height) continue
220
+ for (let x = 0; x < patchWidth; x++) {
221
+ if (left + x < 0 || left + x >= width) continue
222
+ const source = (y * patchWidth + x) * 4
223
+ const target = ((top + y) * width + left + x) * 4
224
+ const alpha = patch[source + 3]! / 255
225
+ const inverse = 1 - alpha
226
+ canvas[target] = Math.round(patch[source]! * alpha + canvas[target]! * inverse)
227
+ canvas[target + 1] = Math.round(patch[source + 1]! * alpha + canvas[target + 1]! * inverse)
228
+ canvas[target + 2] = Math.round(patch[source + 2]! * alpha + canvas[target + 2]! * inverse)
229
+ canvas[target + 3] = Math.round((alpha + canvas[target + 3]! / 255 * inverse) * 255)
230
+ }
231
+ }
232
+ }
233
+
234
+ function clearPatch(canvas: Uint8Array, width: number, left: number, top: number, patchWidth: number, patchHeight: number) {
235
+ for (let y = 0; y < patchHeight; y++) {
236
+ for (let x = 0; x < patchWidth; x++) canvas.fill(0, ((top + y) * width + left + x) * 4, ((top + y) * width + left + x + 1) * 4)
237
+ }
238
+ }
239
+
240
+ function prepareImage(image: NativeImage, target: { width: number; height: number }, background: Rgb) {
241
+ const targetWidth = Math.max(1, Math.round(target.width))
242
+ const targetHeight = Math.max(1, Math.round(target.height))
243
+ const scale = Math.min(targetWidth / image.width, targetHeight / image.height)
244
+ const newWidth = Math.max(1, Math.round(image.width * scale))
245
+ const newHeight = Math.max(1, Math.round(image.height * scale))
246
+ const resized = scale !== 1 ? image.resize({ width: newWidth, height: newHeight }) : image
247
+ if (resized !== image) image.dispose()
248
+ if (!resized.info().hasAlpha) return resized
249
+ const raw = resized.raw()
250
+ const pixels = new Uint8Array(raw.width * raw.height * 4)
251
+ const color = background
252
+ for (let index = 0; index < pixels.length; index += 4) {
253
+ const alpha = raw.data[index + 3]! / 255
254
+ pixels[index] = Math.round(raw.data[index]! * alpha + color[0]! * (1 - alpha))
255
+ pixels[index + 1] = Math.round(raw.data[index + 1]! * alpha + color[1]! * (1 - alpha))
256
+ pixels[index + 2] = Math.round(raw.data[index + 2]! * alpha + color[2]! * (1 - alpha))
257
+ pixels[index + 3] = 255
258
+ }
259
+ const composited = NativeImage.fromRgba(pixels, raw.width, raw.height)
260
+ resized.dispose()
261
+ return composited
262
+ }
263
+
264
+ function disposeMedia(media: Media | undefined) {
265
+ media?.image?.dispose()
266
+ media?.frames?.forEach((frame) => frame.image.dispose())
267
+ }
@@ -0,0 +1,171 @@
1
+ import { Graph, layout, type EdgeLabel, type GraphLabel, type NodeLabel, type Point } from "@dagrejs/dagre"
2
+ import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
3
+ import { translateDiagramBounds, type DiagramBounds, type DiagramPoint } from "../core/geometry.js"
4
+ import { classLabelAnchor } from "./labels.js"
5
+ import {
6
+ finalizeClassDiagramLayout,
7
+ makeClassBox,
8
+ measureClassBox,
9
+ type ClassBox,
10
+ type ClassDiagramLayout,
11
+ type ClassEdge,
12
+ } from "./layout.js"
13
+ import type { ClassDiagram, ClassDiagramDirection } from "./types.js"
14
+
15
+ type Side = "top" | "bottom" | "left" | "right"
16
+
17
+ function attachment(box: DiagramBounds, center: Point, size: { width: number; height: number }, point: Point): DiagramPoint {
18
+ const sides: Array<[Side, number]> = [
19
+ ["top", Math.abs(point.y - (center.y - size.height / 2))],
20
+ ["bottom", Math.abs(point.y - (center.y + size.height / 2))],
21
+ ["left", Math.abs(point.x - (center.x - size.width / 2))],
22
+ ["right", Math.abs(point.x - (center.x + size.width / 2))],
23
+ ]
24
+ const side = sides.reduce((best, entry) => entry[1] < best[1] ? entry : best)[0]
25
+ const x = Math.max(box.left + 1, Math.min(box.left + box.width - 2, Math.round(point.x)))
26
+ const y = Math.max(box.top + 1, Math.min(box.top + box.height - 2, Math.round(point.y)))
27
+ if (side === "top") return { x, y: box.top - 1 }
28
+ if (side === "bottom") return { x, y: box.top + box.height }
29
+ if (side === "left") return { x: box.left - 1, y }
30
+ return { x: box.left + box.width, y }
31
+ }
32
+
33
+ function intersectsBox(from: DiagramPoint, to: DiagramPoint, box: DiagramBounds): boolean {
34
+ const right = box.left + box.width - 1
35
+ const bottom = box.top + box.height - 1
36
+ if (from.y === to.y) return from.y >= box.top && from.y <= bottom &&
37
+ Math.max(from.x, to.x) >= box.left && Math.min(from.x, to.x) <= right
38
+ return from.x >= box.left && from.x <= right &&
39
+ Math.max(from.y, to.y) >= box.top && Math.min(from.y, to.y) <= bottom
40
+ }
41
+
42
+ function orthogonalize(
43
+ points: readonly DiagramPoint[],
44
+ boxes: readonly ClassBox[],
45
+ vertical: boolean,
46
+ ): DiagramPoint[] {
47
+ const result: DiagramPoint[] = [points[0]!]
48
+ for (const to of points.slice(1)) {
49
+ const from = result[result.length - 1]!
50
+ if (from.x !== to.x && from.y !== to.y) {
51
+ const candidates = vertical
52
+ ? [{ x: from.x, y: to.y }, { x: to.x, y: from.y }]
53
+ : [{ x: to.x, y: from.y }, { x: from.x, y: to.y }]
54
+ const cost = (bend: DiagramPoint): number => boxes.reduce((total, box) =>
55
+ total + Number(intersectsBox(from, bend, box.bounds)) + Number(intersectsBox(bend, to, box.bounds)), 0)
56
+ const bend = cost(candidates[1]!) < cost(candidates[0]!) ? candidates[1]! : candidates[0]!
57
+ if (from.x !== bend.x || from.y !== bend.y) result.push(bend)
58
+ }
59
+ if (result.at(-1)!.x !== to.x || result.at(-1)!.y !== to.y) result.push(to)
60
+ }
61
+ return result
62
+ }
63
+
64
+ function selfLoop(box: DiagramBounds, vertical: boolean, offset: number, labelWidth: number): DiagramPoint[] {
65
+ const left = box.left
66
+ const right = box.left + box.width - 1
67
+ const top = box.top
68
+ const bottom = box.top + box.height - 1
69
+ if (vertical) {
70
+ const portX = right - 1
71
+ const outsideX = right + Math.max(3, labelWidth + 3) + offset * 2
72
+ return [
73
+ { x: portX, y: bottom + 1 },
74
+ { x: portX, y: bottom + 3 + offset },
75
+ { x: outsideX, y: bottom + 3 + offset },
76
+ { x: outsideX, y: top - 3 - offset },
77
+ { x: portX, y: top - 3 - offset },
78
+ { x: portX, y: top - 1 },
79
+ ]
80
+ }
81
+ const portY = bottom - 1
82
+ const outsideY = bottom + 3 + offset * 2
83
+ return [
84
+ { x: right + 1, y: portY },
85
+ { x: right + 3 + offset, y: portY },
86
+ { x: right + 3 + offset, y: outsideY },
87
+ { x: left - 3 - offset, y: outsideY },
88
+ { x: left - 3 - offset, y: portY },
89
+ { x: left - 1, y: portY },
90
+ ]
91
+ }
92
+
93
+ export function layoutDagreClassDiagram(diagram: ClassDiagram, direction: ClassDiagramDirection): ClassDiagramLayout {
94
+ if (diagram.classes.length === 0) return { direction, boxes: [], edges: [], width: 0, height: 0 }
95
+ const graph = new Graph<GraphLabel, NodeLabel, EdgeLabel>({ multigraph: true })
96
+ graph.setGraph({ rankdir: direction, nodesep: 8, ranksep: 10, edgesep: 4 })
97
+ graph.setDefaultEdgeLabel(() => ({}))
98
+ const metrics = new Map(diagram.classes.map((definition) => [definition.id, measureClassBox(definition)]))
99
+ for (const definition of diagram.classes) {
100
+ const size = metrics.get(definition.id)!
101
+ graph.setNode(definition.id, { width: size.width, height: size.height })
102
+ }
103
+ diagram.relationships.forEach((edge, index) => {
104
+ const width = edge.label ? Math.max(...splitDiagramLines(edge.label).map(diagramTextWidth)) + 2 : 0
105
+ const height = edge.label ? splitDiagramLines(edge.label).length + 1 : 0
106
+ graph.setEdge(edge.from, edge.to, { width, height, weight: edge.kind === "inheritance" ? 2 : 1 }, String(index))
107
+ })
108
+ layout(graph)
109
+
110
+ const boxes = diagram.classes.map((definition) => {
111
+ const node = graph.node(definition.id)
112
+ const size = metrics.get(definition.id)!
113
+ return makeClassBox(definition, Math.round(node.x! - size.width / 2), Math.round(node.y! - size.height / 2), size)
114
+ })
115
+ const boxById = new Map(boxes.map((box) => [box.definition.id, box]))
116
+ const vertical = direction === "TB" || direction === "BT"
117
+ const loopCounts = new Map<string, number>()
118
+ const edges: ClassEdge[] = diagram.relationships.map((relationship, index) => {
119
+ const from = boxById.get(relationship.from)!.bounds
120
+ const to = boxById.get(relationship.to)!.bounds
121
+ if (relationship.from === relationship.to) {
122
+ const offset = loopCounts.get(relationship.from) ?? 0
123
+ loopCounts.set(relationship.from, offset + 1)
124
+ return { relationship, fromId: relationship.from, toId: relationship.to, from, to,
125
+ path: selfLoop(from, vertical, offset,
126
+ relationship.label ? Math.max(...splitDiagramLines(relationship.label).map(diagramTextWidth)) : 0) }
127
+ }
128
+ const waypoints = graph.edge({ v: relationship.from, w: relationship.to, name: String(index) })?.points ?? []
129
+ const source = graph.node(relationship.from)
130
+ const target = graph.node(relationship.to)
131
+ const first = waypoints[0] ?? { x: source.x!, y: source.y! }
132
+ const last = waypoints.at(-1) ?? { x: target.x!, y: target.y! }
133
+ const start = attachment(from, { x: source.x!, y: source.y! }, metrics.get(relationship.from)!, first)
134
+ const end = attachment(to, { x: target.x!, y: target.y! }, metrics.get(relationship.to)!, last)
135
+ const direct = (start.x === end.x || start.y === end.y) &&
136
+ boxes.every((box) => !intersectsBox(start, end, box.bounds))
137
+ const path = direct ? [start, end] : orthogonalize([
138
+ start,
139
+ ...waypoints.slice(1, -1).map((point) => ({ x: Math.round(point.x), y: Math.round(point.y) })),
140
+ end,
141
+ ], boxes, vertical)
142
+ return { relationship, fromId: relationship.from, toId: relationship.to, from, to, path }
143
+ })
144
+ const result = finalizeClassDiagramLayout(boxes, edges, direction)
145
+ let minX = 0
146
+ let minY = 0
147
+ let maxX = result.width
148
+ let maxY = result.height
149
+ for (const edge of result.edges) {
150
+ if (!edge.relationship.label) continue
151
+ const lines = splitDiagramLines(edge.relationship.label)
152
+ const width = Math.max(...lines.map(diagramTextWidth))
153
+ const anchor = classLabelAnchor(edge.path, lines.length, width, vertical)
154
+ minX = Math.min(minX, anchor.x - 1)
155
+ minY = Math.min(minY, anchor.y)
156
+ maxX = Math.max(maxX, anchor.x + width + 2)
157
+ maxY = Math.max(maxY, anchor.y + lines.length + 1)
158
+ }
159
+ const dx = -minX
160
+ const dy = -minY
161
+ if (dx || dy) {
162
+ for (const box of result.boxes) translateDiagramBounds(box.bounds, dx, dy)
163
+ for (const edge of result.edges) for (const point of edge.path) {
164
+ point.x += dx
165
+ point.y += dy
166
+ }
167
+ }
168
+ result.width = maxX + dx
169
+ result.height = maxY + dy
170
+ return result
171
+ }
@@ -0,0 +1,8 @@
1
+ import { drawClassDiagramGrid } from "./drawing.js"
2
+ import { parseMermaidClassDiagram } from "./parser.js"
3
+ import { renderClassGridText } from "./render-grid.js"
4
+ import type { ClassDiagramRenderOptions } from "./types.js"
5
+
6
+ export function renderClassDiagram(content: string, options: ClassDiagramRenderOptions = {}): string {
7
+ return renderClassGridText(drawClassDiagramGrid(parseMermaidClassDiagram(content), options))
8
+ }