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.
- package/LICENSE +21 -0
- package/NOTICE +27 -0
- package/README.md +223 -2
- package/bin/otmd.tsx +99 -0
- package/native/Cargo.lock +3708 -0
- package/native/Cargo.toml +28 -0
- package/native/build.rs +3 -0
- package/native/src/lib.rs +880 -0
- package/package.json +67 -4
- package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
- package/src/app.tsx +180 -0
- package/src/cli.ts +63 -0
- package/src/config/index.ts +46 -0
- package/src/config/load.ts +337 -0
- package/src/config/resolve.ts +36 -0
- package/src/config/theme.ts +110 -0
- package/src/config/types.ts +105 -0
- package/src/core/document.ts +44 -0
- package/src/core/frame.ts +144 -0
- package/src/core/image.ts +9 -0
- package/src/core/index.ts +6 -0
- package/src/core/layout.ts +27 -0
- package/src/core/math.ts +170 -0
- package/src/core/parse.ts +208 -0
- package/src/core/tables.ts +58 -0
- package/src/graphics.ts +85 -0
- package/src/headings.ts +77 -0
- package/src/index.ts +107 -0
- package/src/math/index.ts +86 -0
- package/src/math/native.ts +39 -0
- package/src/math/renderer.ts +84 -0
- package/src/math/validate.ts +44 -0
- package/src/media.tsx +267 -0
- package/src/merman/class/dagre-layout.ts +171 -0
- package/src/merman/class/diagram.ts +8 -0
- package/src/merman/class/drawing.ts +343 -0
- package/src/merman/class/labels.ts +38 -0
- package/src/merman/class/layout.ts +531 -0
- package/src/merman/class/parser.ts +308 -0
- package/src/merman/class/render-grid.ts +18 -0
- package/src/merman/class/style.ts +27 -0
- package/src/merman/class/types.ts +57 -0
- package/src/merman/core/canvas.ts +228 -0
- package/src/merman/core/color/style.ts +56 -0
- package/src/merman/core/drawing.ts +307 -0
- package/src/merman/core/geometry.ts +347 -0
- package/src/merman/core/mermaid.ts +38 -0
- package/src/merman/core/render-grid.ts +51 -0
- package/src/merman/core/spatial.ts +243 -0
- package/src/merman/core/text-lines.ts +32 -0
- package/src/merman/core/text.ts +44 -0
- package/src/merman/detect.ts +19 -0
- package/src/merman/diagnostics.ts +16 -0
- package/src/merman/diagram.ts +268 -0
- package/src/merman/flowchart/drawing.ts +433 -0
- package/src/merman/flowchart/labels.ts +144 -0
- package/src/merman/flowchart/layout.ts +1057 -0
- package/src/merman/flowchart/options.ts +12 -0
- package/src/merman/flowchart/parser.ts +477 -0
- package/src/merman/flowchart/render.ts +10 -0
- package/src/merman/flowchart/routing.ts +1429 -0
- package/src/merman/flowchart/style.ts +79 -0
- package/src/merman/flowchart/types.ts +63 -0
- package/src/merman/gantt/diagram.ts +8 -0
- package/src/merman/gantt/drawing.ts +193 -0
- package/src/merman/gantt/parser.ts +198 -0
- package/src/merman/gantt/render-grid.ts +17 -0
- package/src/merman/gantt/style.ts +36 -0
- package/src/merman/gantt/types.ts +45 -0
- package/src/merman/gitgraph/diagram.ts +8 -0
- package/src/merman/gitgraph/drawing.ts +234 -0
- package/src/merman/gitgraph/parser.ts +234 -0
- package/src/merman/gitgraph/render-grid.ts +17 -0
- package/src/merman/gitgraph/style.ts +37 -0
- package/src/merman/gitgraph/types.ts +36 -0
- package/src/merman/palette.ts +60 -0
- package/src/merman/sequence/diagram.ts +8 -0
- package/src/merman/sequence/drawing.ts +288 -0
- package/src/merman/sequence/options.ts +8 -0
- package/src/merman/sequence/parser.ts +303 -0
- package/src/merman/sequence/placement.ts +692 -0
- package/src/merman/sequence/render-grid.ts +22 -0
- package/src/merman/sequence/style.ts +81 -0
- package/src/merman/sequence/types.ts +74 -0
- package/src/merman/state/diagram.ts +8 -0
- package/src/merman/state/drawing.ts +363 -0
- package/src/merman/state/endpoint.ts +15 -0
- package/src/merman/state/layout.ts +1134 -0
- package/src/merman/state/note.ts +59 -0
- package/src/merman/state/options.ts +11 -0
- package/src/merman/state/parser.ts +245 -0
- package/src/merman/state/render-grid.ts +22 -0
- package/src/merman/state/routing.ts +1522 -0
- package/src/merman/state/search.ts +103 -0
- package/src/merman/state/style.ts +52 -0
- package/src/merman/state/types.ts +63 -0
- package/src/merman/state/visible-model.ts +77 -0
- package/src/merman/timeline/diagram.ts +8 -0
- package/src/merman/timeline/drawing.ts +109 -0
- package/src/merman/timeline/parser.ts +119 -0
- package/src/merman/timeline/render-grid.ts +17 -0
- package/src/merman/timeline/style.ts +36 -0
- package/src/merman/timeline/types.ts +31 -0
- package/src/merman.ts +3 -0
- package/src/statusline.tsx +183 -0
- package/src/theme/colors.ts +11 -0
- package/src/theme/defaults.ts +190 -0
- package/src/theme/index.ts +7 -0
- package/src/theme/load.ts +39 -0
- package/src/theme/resolve.ts +167 -0
- package/src/theme/syntax.ts +96 -0
- package/src/theme/types.ts +151 -0
- package/src/view/code.tsx +27 -0
- package/src/view/document-slots.ts +111 -0
- package/src/view/document-table.tsx +98 -0
- package/src/view/document.tsx +195 -0
- package/src/view/frame.tsx +83 -0
- package/src/view/inline-flow.tsx +113 -0
- package/src/view/inline-layout.ts +149 -0
- package/src/view/inline-math.ts +42 -0
- package/src/view/inline-style.ts +103 -0
- package/src/view/math.tsx +85 -0
- package/src/view/mermaid.tsx +128 -0
- package/src/view/table.ts +100 -0
- package/themes/dark.json +32 -0
- package/themes/example.jsonc +98 -0
- package/themes/light.json +32 -0
- package/themes/solarized-dark.json +32 -0
- 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
|
+
}
|