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
@@ -0,0 +1,83 @@
1
+ import type { BorderCharacters, BorderStyle } from "@opentui/core"
2
+ import type { JSX } from "solid-js"
3
+ import type { FrameAlign, FrameStyle, ResolvedFrame } from "../core/frame"
4
+ import type { Theme } from "../theme/types"
5
+
6
+ // OpenTUI only ships the Unicode border sets, so the ASCII frame is expressed as
7
+ // its own character table. Every corner is the same `+`, which is the look the
8
+ // plain-text sketches ask for.
9
+ const asciiBorder: BorderCharacters = {
10
+ topLeft: "+",
11
+ topRight: "+",
12
+ bottomLeft: "+",
13
+ bottomRight: "+",
14
+ horizontal: "-",
15
+ vertical: "|",
16
+ topT: "+",
17
+ bottomT: "+",
18
+ leftT: "+",
19
+ rightT: "+",
20
+ cross: "+",
21
+ }
22
+
23
+ function borderStyle(style: FrameStyle): BorderStyle {
24
+ if (style === "rounded" || style === "heavy" || style === "double") return style
25
+ return "single"
26
+ }
27
+
28
+ function alignSelf(align: FrameAlign) {
29
+ if (align === "left") return "flex-start" as const
30
+ if (align === "right") return "flex-end" as const
31
+ return "center" as const
32
+ }
33
+
34
+ export function frameBorderColor(theme: Theme) {
35
+ return theme.frame?.border ?? theme.border.base
36
+ }
37
+
38
+ export function frameTitleColor(theme: Theme) {
39
+ return theme.frame?.title ?? theme.text.muted
40
+ }
41
+
42
+ // The border props are only handed to the box when the frame is on. OpenTUI's
43
+ // `borderStyle` and `borderColor` setters enable the border as a side effect, so
44
+ // passing them while the frame is off would draw a window the host never asked
45
+ // for.
46
+ function borderProps(frame: ResolvedFrame, theme: Theme) {
47
+ if (!frame.enabled) return {}
48
+ return {
49
+ border: true,
50
+ borderStyle: borderStyle(frame.style),
51
+ customBorderChars: frame.style === "ascii" ? asciiBorder : undefined,
52
+ borderColor: frameBorderColor(theme),
53
+ title: frame.title,
54
+ titleColor: frameTitleColor(theme),
55
+ titleAlignment: frame.titleAlign,
56
+ }
57
+ }
58
+
59
+ // The frame is deliberately just a box around whatever the host puts inside it.
60
+ // It owns no document state, so a host can wrap its own scroll box and keep the
61
+ // border fixed while the document scrolls, or let the document renderer draw it.
62
+ export function MarkdownFrame(props: {
63
+ readonly frame: ResolvedFrame
64
+ readonly theme: Theme
65
+ readonly children: JSX.Element
66
+ }) {
67
+ return (
68
+ <box
69
+ width={props.frame.width}
70
+ alignSelf={alignSelf(props.frame.align)}
71
+ flexDirection="column"
72
+ flexShrink={0}
73
+ backgroundColor={props.theme.background.base}
74
+ paddingLeft={props.frame.paddingX}
75
+ paddingRight={props.frame.paddingX}
76
+ paddingTop={props.frame.paddingY}
77
+ paddingBottom={props.frame.paddingY}
78
+ {...borderProps(props.frame, props.theme)}
79
+ >
80
+ {props.children}
81
+ </box>
82
+ )
83
+ }
@@ -0,0 +1,113 @@
1
+ import { createEffect, createMemo, For, Show } from "solid-js"
2
+ import { StyledText, type RGBA, type TextChunk, type TextRenderable } from "@opentui/core"
3
+ import { mosaicScale, type CanvasGraphics } from "../graphics"
4
+ import { mathImageID, rgbaToHex } from "../math"
5
+ import { layoutAtoms, type LayoutRun, type MathVariant } from "./inline-layout"
6
+ import { flattenInline, type InlineStyleContext } from "./inline-style"
7
+ import { useInlineMathImages } from "./inline-math"
8
+ import type { Inline } from "../core/document"
9
+ import type { Theme, HeadingLevel } from "../theme/types"
10
+ import { headingColor, type HeadingStyles } from "../headings"
11
+
12
+ export type InlineFlowProps = {
13
+ inlines: readonly Inline[]
14
+ columns: number
15
+ theme: Theme
16
+ headings: HeadingStyles
17
+ graphics: CanvasGraphics
18
+ surface: () => string
19
+ quote?: boolean
20
+ heading?: { level: HeadingLevel }
21
+ prefix?: (index: number) => string
22
+ prefixColor?: () => RGBA
23
+ prefixChunks?: (index: number) => TextChunk[]
24
+ }
25
+
26
+ function PrefixSegment(props: { chunks: () => TextChunk[]; width: number }) {
27
+ let ref: TextRenderable | undefined
28
+ createEffect(() => {
29
+ if (ref) ref.content = new StyledText(props.chunks())
30
+ })
31
+ return <text ref={ref} wrapMode="none" width={props.width} flexShrink={0} />
32
+ }
33
+
34
+ function StyledSegment(props: { entry: LayoutRun; textRow: number }) {
35
+ let ref: TextRenderable | undefined
36
+ createEffect(() => {
37
+ const atom = props.entry.atom
38
+ if (ref && atom.type === "text") {
39
+ ref.content = new StyledText([{
40
+ __isChunk: true, text: atom.text, fg: atom.fg, attributes: atom.attributes,
41
+ ...(atom.href ? { link: { url: atom.href } } : {}),
42
+ }])
43
+ }
44
+ })
45
+ return <text ref={ref} width={props.entry.columns} flexShrink={0} wrapMode="none" marginTop={props.textRow} />
46
+ }
47
+
48
+ export function InlineFlow(props: InlineFlowProps) {
49
+ const atoms = createMemo(() => {
50
+ const ctx: InlineStyleContext = { theme: props.theme, headings: props.headings, quote: props.quote, heading: props.heading,
51
+ hyperlinks: props.graphics.hyperlinks?.() ?? true }
52
+ return flattenInline(props.inlines, ctx)
53
+ })
54
+ const contents = createMemo(() => [...new Set(atoms().flatMap((atom) => atom.type === "math" ? [atom.content] : []))])
55
+ const foreground = () => rgbaToHex(props.heading ? headingColor(props.theme, props.heading.level) : props.theme.text.base)
56
+ const images = useInlineMathImages(contents, foreground, props.surface, props.graphics.cell)
57
+ const scale = createMemo(() => mosaicScale(props.graphics.protocol()))
58
+ const prefixWidth = () => props.prefix?.(0).length ?? 0
59
+ const columns = () => Math.max(1, props.columns - prefixWidth())
60
+ const lines = createMemo(() => layoutAtoms(atoms(), columns(), (content) => {
61
+ const image = images().get(content)
62
+ if (!image) return undefined
63
+ return {
64
+ normal: { columns: Math.max(1, Math.round(image.normal.columns * scale())), rows: Math.max(1, Math.round(image.normal.rows * scale())), baseline: image.normal.baseline * scale() },
65
+ compact: { columns: Math.max(1, Math.round(image.compact.columns * scale())), rows: image.compact.rows === 1 ? 1 : Math.max(1, Math.round(image.compact.rows * scale())), baseline: image.compact.baseline * scale() },
66
+ standard: { columns: Math.max(1, Math.round(image.standard.columns * scale())), rows: Math.max(1, Math.round(image.standard.rows * scale())), baseline: image.standard.baseline * scale() },
67
+ }
68
+ }, props.graphics.protocol() !== "blocks"))
69
+
70
+ return (
71
+ <box width="100%" flexDirection="column" flexShrink={0}>
72
+ <For each={lines()}>
73
+ {(line, index) => (
74
+ <box flexDirection="row" alignItems="flex-start" width="100%" height={line.height} flexShrink={0} backgroundColor={props.theme.background.base}>
75
+ <Show when={props.prefix}>
76
+ <Show when={props.prefixChunks} fallback={<text fg={props.prefixColor?.() ?? props.theme.markdown.text}
77
+ wrapMode="none" width={prefixWidth()} flexShrink={0}>{props.prefix?.(index()) ?? ""}</text>}>
78
+ {(chunks) => <PrefixSegment chunks={() => chunks()(index())} width={prefixWidth()} />}
79
+ </Show>
80
+ </Show>
81
+ <For each={line.runs}>
82
+ {(entry, position) => {
83
+ const atom = entry.atom
84
+ if (atom.type === "text") {
85
+ const visible = position() === line.runs.length - 1 ? atom.text.trimEnd() : atom.text
86
+ return <StyledSegment entry={{ ...entry, atom: { ...atom, text: visible } }} textRow={line.textRow} />
87
+ }
88
+ const variant: MathVariant = entry.variant ?? "normal"
89
+ return (
90
+ <Show when={images().get(atom.content)?.[variant]} fallback={
91
+ <text fg={props.theme.text.muted} wrapMode="none" width={entry.columns} flexShrink={0}>{atom.raw}</text>
92
+ }>
93
+ {(image) => (
94
+ <image
95
+ id={`opentui-markdown-math-${mathImageID(atom.content, false, foreground(), props.graphics.cell(), 1, variant === "compact", variant === "standard")}-${props.surface()}`}
96
+ source={image().png}
97
+ fit="fit"
98
+ protocol="auto"
99
+ width={entry.columns}
100
+ height={entry.rows}
101
+ flexShrink={0}
102
+ />
103
+ )}
104
+ </Show>
105
+ )
106
+ }}
107
+ </For>
108
+ </box>
109
+ )}
110
+ </For>
111
+ </box>
112
+ )
113
+ }
@@ -0,0 +1,149 @@
1
+ import stringWidth from "string-width"
2
+ import type { StyledAtom } from "./inline-style"
3
+
4
+ export type MathMeasure = { columns: number; rows: number; baseline: number }
5
+ export type MathVariant = "normal" | "compact" | "standard"
6
+ export type LayoutRun = { atom: Extract<StyledAtom, { type: "text" | "math" }>; columns: number; rows: number; variant?: MathVariant }
7
+ export type LayoutLine = { runs: LayoutRun[]; columns: number; height: number; textRow: number }
8
+
9
+ function hardChunks(content: string, columns: number) {
10
+ const chunks: string[] = []
11
+ let current = ""
12
+ for (const character of content) {
13
+ if (current && stringWidth(current + character) > columns) {
14
+ chunks.push(current)
15
+ current = character
16
+ continue
17
+ }
18
+ current += character
19
+ }
20
+ if (current) chunks.push(current)
21
+ return chunks
22
+ }
23
+
24
+ // Measurements, compact collision checks, and standard-size choices use the
25
+ // shared styled AST.
26
+ export function layoutAtoms(
27
+ atoms: readonly StyledAtom[],
28
+ columns: number,
29
+ measureOf: (content: string) => { normal: MathMeasure; compact: MathMeasure; standard: MathMeasure } | undefined,
30
+ compactOverflow: boolean,
31
+ ): LayoutLine[] {
32
+ columns = Math.max(1, columns)
33
+ const lines: LayoutLine[] = []
34
+ let current: LayoutLine = { runs: [], columns: 0, height: 1, textRow: 0 }
35
+ const flush = (force = false) => {
36
+ if (!current.runs.length && !force) return
37
+ lines.push(current)
38
+ current = { runs: [], columns: 0, height: 1, textRow: 0 }
39
+ }
40
+ const place = (run: LayoutRun, textRow = 0) => {
41
+ const add = (entry: LayoutRun) => {
42
+ current.runs.push(entry)
43
+ current.columns += entry.columns
44
+ current.height = Math.max(current.height, entry.rows, textRow + 1)
45
+ current.textRow = Math.max(current.textRow, textRow)
46
+ }
47
+ if (current.columns + run.columns <= columns || !current.runs.length) {
48
+ add(run)
49
+ return
50
+ }
51
+ if (run.atom.type === "text") {
52
+ const text = run.atom.text.trimStart()
53
+ const trimmed = { ...run, atom: { ...run.atom, text }, columns: stringWidth(text) }
54
+ if (current.columns + trimmed.columns <= columns) {
55
+ add(trimmed)
56
+ return
57
+ }
58
+ flush()
59
+ add(trimmed)
60
+ return
61
+ }
62
+ flush()
63
+ add(run)
64
+ }
65
+
66
+ for (const atom of atoms) {
67
+ if (atom.type === "break") {
68
+ flush(true)
69
+ continue
70
+ }
71
+ if (atom.type === "math") {
72
+ const natural = measureOf(atom.content)?.normal
73
+ const squeezed = natural ? natural.columns > columns : false
74
+ const entry: LayoutRun = natural
75
+ ? { atom, columns: squeezed ? columns : natural.columns, rows: Math.max(1, Math.round(squeezed ? natural.rows * columns / natural.columns : natural.rows)) }
76
+ : { atom, columns: Math.min(columns, Math.max(3, atom.raw.length)), rows: 1 }
77
+ place(entry, natural ? Math.max(0, Math.ceil(natural.baseline) - 1) : 0)
78
+ continue
79
+ }
80
+ // Soft newlines in Markdown prose are spaces. A hard break is its own atom.
81
+ const normalized = atom.text.replace(/\r?\n/g, " ")
82
+ const words = normalized.match(/\s*\S+\s*/g) ?? (normalized ? [normalized] : [])
83
+ for (const word of words) {
84
+ if (stringWidth(word) > columns) {
85
+ for (const chunk of hardChunks(word.trimEnd(), columns)) {
86
+ place({ atom: { ...atom, text: chunk }, columns: stringWidth(chunk), rows: 1 })
87
+ }
88
+ } else {
89
+ place({ atom: { ...atom, text: word }, columns: stringWidth(word), rows: 1 })
90
+ }
91
+ }
92
+ }
93
+ flush()
94
+
95
+ if (compactOverflow) {
96
+ let changed = true
97
+ while (changed) {
98
+ changed = false
99
+ const occupied = lines.flatMap((line, row) => {
100
+ let column = 0
101
+ return line.runs.map((entry) => {
102
+ const area = {
103
+ entry, column,
104
+ row: row + (entry.atom.type === "text" ? line.textRow : 0),
105
+ rows: entry.atom.type === "math" ? entry.rows : 1,
106
+ }
107
+ column += entry.columns
108
+ return area
109
+ })
110
+ })
111
+ for (const area of occupied) {
112
+ if (area.entry.atom.type !== "math" || area.entry.variant === "compact" || area.rows <= 1) continue
113
+ const collision = occupied.some((other) =>
114
+ other.entry !== area.entry &&
115
+ other.column < area.column + area.entry.columns &&
116
+ area.column < other.column + other.entry.columns &&
117
+ other.row < area.row + area.rows &&
118
+ area.row + 1 < other.row + other.rows,
119
+ )
120
+ if (!collision) continue
121
+ const compact = measureOf(area.entry.atom.content)?.compact
122
+ if (!compact) continue
123
+ area.entry.variant = "compact"
124
+ area.entry.columns = compact.columns
125
+ area.entry.rows = compact.rows
126
+ changed = true
127
+ }
128
+ }
129
+ }
130
+ for (const line of lines) {
131
+ const entry = line.runs.at(-1)
132
+ if (!entry || entry.atom.type !== "math" || entry.variant === "compact") continue
133
+ const standard = measureOf(entry.atom.content)?.standard
134
+ if (!standard || line.columns - entry.columns + standard.columns > columns) continue
135
+ entry.variant = "standard"
136
+ entry.columns = standard.columns
137
+ entry.rows = standard.rows
138
+ }
139
+ for (const line of lines) {
140
+ line.columns = line.runs.reduce((total, entry) => total + entry.columns, 0)
141
+ line.textRow = Math.max(0, ...line.runs.flatMap((entry) => {
142
+ if (entry.atom.type !== "math") return []
143
+ const measured = measureOf(entry.atom.content)
144
+ return measured ? [Math.max(0, Math.ceil(measured[entry.variant ?? "normal"].baseline) - 1)] : []
145
+ }))
146
+ line.height = Math.max(1, line.textRow + 1, ...line.runs.map((entry) => entry.rows))
147
+ }
148
+ return lines
149
+ }
@@ -0,0 +1,42 @@
1
+ import { createMemo, createResource, type Accessor } from "solid-js"
2
+ import { renderMathImage, type MathImage } from "../math"
3
+ import type { CellSize } from "../graphics"
4
+
5
+ export type InlineMathImages = Map<string, { normal: MathImage; compact: MathImage; standard: MathImage }>
6
+
7
+ export function useInlineMathImages(
8
+ contents: Accessor<readonly string[]>,
9
+ foreground: Accessor<string>,
10
+ background: Accessor<string>,
11
+ cell: Accessor<CellSize | null>,
12
+ ): Accessor<InlineMathImages> {
13
+ const [rendered] = createResource(
14
+ () => JSON.stringify({ contents: contents(), color: foreground(), surface: background(), cell: cell() }),
15
+ async (key) => {
16
+ const input = JSON.parse(key) as { contents: string[]; color: string; surface: string; cell: CellSize | null }
17
+ return Promise.all(input.contents.map(async (content) => {
18
+ try {
19
+ const normal = await renderMathImage(content, false, input.color, input.surface, input.cell)
20
+ if (!normal) return undefined
21
+ const compact = normal.rows > 1
22
+ ? await renderMathImage(content, false, input.color, input.surface, input.cell, 1, true)
23
+ : normal
24
+ const standard = normal.clamped
25
+ ? await renderMathImage(content, false, input.color, input.surface, input.cell, 1, false, true)
26
+ : normal
27
+ return { normal, compact: compact ?? normal, standard: standard ?? normal }
28
+ } catch {
29
+ return undefined
30
+ }
31
+ }))
32
+ },
33
+ )
34
+ return createMemo(() => {
35
+ const values = rendered()
36
+ if (!values) return new Map()
37
+ return new Map(contents().flatMap((content, index) => {
38
+ const image = values[index]
39
+ return image ? [[content, image] as const] : []
40
+ }))
41
+ })
42
+ }
@@ -0,0 +1,103 @@
1
+ import { TextAttributes, type RGBA, type TextChunk } from "@opentui/core"
2
+ import type { HeadingLevel, Theme } from "../theme/types"
3
+ import type { HeadingStyles } from "../headings"
4
+ import { headingAttributes, headingColor } from "../headings"
5
+ import type { Inline } from "../core/document"
6
+
7
+ export type StyledAtom =
8
+ | { type: "text"; text: string; fg: RGBA; attributes: number; href?: string }
9
+ | { type: "math"; content: string; raw: string; open: string; close: string }
10
+ | { type: "break"; hard: true }
11
+
12
+ export type InlineStyleContext = {
13
+ theme: Theme
14
+ headings: HeadingStyles
15
+ heading?: { level: HeadingLevel }
16
+ quote?: boolean
17
+ link?: { href: string; title?: string | null }
18
+ attributes?: number
19
+ hyperlinks?: boolean
20
+ }
21
+
22
+ export function flattenInline(inlines: readonly Inline[], ctx: InlineStyleContext): StyledAtom[] {
23
+ const out: StyledAtom[] = []
24
+ const baseAttr = (ctx.attributes ?? 0) |
25
+ (ctx.heading ? headingAttributes(ctx.headings[ctx.heading.level]) : 0) |
26
+ (ctx.quote ? TextAttributes.ITALIC : 0)
27
+ const quote = !!ctx.quote
28
+ const linkCtx = ctx.link
29
+
30
+ function walk(list: readonly Inline[], attr: number, link: { href: string; title?: string | null } | undefined) {
31
+ for (const node of list) {
32
+ if (node.type === "text") {
33
+ let fg = ctx.theme.markdown.text
34
+ if (quote && !link && !ctx.heading) fg = ctx.theme.markdown.blockQuote
35
+ if (ctx.heading) fg = headingColor(ctx.theme, ctx.heading.level)
36
+ if (link) fg = ctx.theme.markdown.linkText
37
+ else if (attr & TextAttributes.BOLD && !ctx.heading) fg = ctx.theme.markdown.strong
38
+ else if (attr & TextAttributes.ITALIC && !ctx.heading && !quote) fg = ctx.theme.markdown.emphasis
39
+ if (node.text) out.push({ type: "text", text: node.text, fg, attributes: baseAttr | attr | (link ? TextAttributes.UNDERLINE : 0), href: link?.href })
40
+ continue
41
+ }
42
+ if (node.type === "code") {
43
+ // code has priority styling
44
+ const fg = ctx.theme.markdown.code
45
+ out.push({ type: "text", text: node.text, fg, attributes: TextAttributes.NONE, href: link?.href })
46
+ continue
47
+ }
48
+ if (node.type === "math") {
49
+ out.push({ type: "math", content: node.content, raw: node.raw, open: node.open, close: node.close })
50
+ continue
51
+ }
52
+ if (node.type === "break") {
53
+ out.push({ type: "break", hard: true })
54
+ continue
55
+ }
56
+ if (node.type === "strong") {
57
+ walk(node.children, attr | TextAttributes.BOLD, link)
58
+ continue
59
+ }
60
+ if (node.type === "em") {
61
+ walk(node.children, attr | TextAttributes.ITALIC, link)
62
+ continue
63
+ }
64
+ if (node.type === "del") {
65
+ walk(node.children, attr | TextAttributes.STRIKETHROUGH, link)
66
+ continue
67
+ }
68
+ if (node.type === "link") {
69
+ const l = { href: node.href, title: node.title }
70
+ walk(node.children, attr, l)
71
+ const label = node.children.map((child) => child.type === "text" || child.type === "code" ? child.text : "").join("")
72
+ if (ctx.hyperlinks === false && label !== node.href) {
73
+ out.push({ type: "text", text: ` (${node.href})`, fg: ctx.theme.markdown.link, attributes: baseAttr, href: node.href })
74
+ }
75
+ continue
76
+ }
77
+ if (node.type === "inline-image") {
78
+ const l = link
79
+ // render alt as text with link style
80
+ out.push({ type: "text", text: node.alt || node.href || "image", fg: ctx.theme.markdown.linkText, attributes: baseAttr | attr, href: l?.href || node.href })
81
+ continue
82
+ }
83
+ }
84
+ }
85
+
86
+ walk(inlines, baseAttr, linkCtx)
87
+ return out
88
+ }
89
+
90
+ export function toTableChunks(inlines: readonly Inline[], theme: Theme, headings: HeadingStyles, hyperlinks = true): TextChunk[] {
91
+ const chunks: TextChunk[] = []
92
+ for (const atom of flattenInline(inlines, { theme, headings, hyperlinks })) {
93
+ if (atom.type === "text") {
94
+ chunks.push({ __isChunk: true, text: atom.text, fg: atom.fg, attributes: atom.attributes,
95
+ ...(atom.href ? { link: { url: atom.href } } : {}) })
96
+ } else if (atom.type === "math") {
97
+ chunks.push({ __isChunk: true, text: atom.raw, fg: theme.markdown.text })
98
+ } else {
99
+ chunks.push({ __isChunk: true, text: "\n", fg: theme.markdown.text })
100
+ }
101
+ }
102
+ return chunks.length ? chunks : [{ __isChunk: true, text: " ", fg: theme.markdown.text }]
103
+ }
@@ -0,0 +1,85 @@
1
+ import { createResource, createSignal, Match, Switch } from "solid-js"
2
+ import { mosaicScale, type CanvasGraphics } from "../graphics"
3
+ import { mathImageID, renderMathImage, rgbaToHex, type CellSize, type MathImage } from "../math"
4
+ import type { Theme } from "../theme/types"
5
+
6
+ // Display math owns its own block. Inline math flows with the text around it.
7
+ export function DisplayMath(props: {
8
+ content: string
9
+ open: string
10
+ close: string
11
+ maxWidth: () => number
12
+ surface: () => string
13
+ theme: () => Theme
14
+ graphics: CanvasGraphics
15
+ }) {
16
+ const [failed, setFailed] = createSignal(false)
17
+ const color = () => rgbaToHex(props.theme().text.base)
18
+ // The block-character mosaic packs two vertical pixels per cell, so its
19
+ // cell box (not its raster) is scaled separately from font rasterization.
20
+ const mosaic = () => mosaicScale(props.graphics.protocol())
21
+ // Unrenderable TeX is a value here, not a rejected resource: reading a rejected
22
+ // resource throws, which would abort the tree on the next update.
23
+ const [rendered] = createResource(
24
+ () =>
25
+ JSON.stringify({
26
+ content: props.content,
27
+ color: color(),
28
+ background: props.surface(),
29
+ cell: props.graphics.cell(),
30
+ }),
31
+ async (key): Promise<{ raster: MathImage } | { error: string }> => {
32
+ const input = JSON.parse(key) as { content: string; color: string; background: string; cell: CellSize | null }
33
+ try {
34
+ const raster = await renderMathImage(input.content, true, input.color, input.background, input.cell)
35
+ return raster ? { raster } : { error: "Renderer returned no image" }
36
+ } catch (error) {
37
+ return { error: error instanceof Error ? error.message : String(error) }
38
+ }
39
+ },
40
+ )
41
+ const raster = () => {
42
+ const value = rendered()
43
+ return value && "raster" in value ? value.raster : undefined
44
+ }
45
+ const source = () => `${props.open}${props.content}${props.close}`
46
+ const box = () => {
47
+ const image = raster()
48
+ const columns = (image ? image.columns : Math.min(24, props.maxWidth())) * mosaic()
49
+ const rows = (image ? image.rows : 3) * mosaic()
50
+ const max = props.maxWidth()
51
+ if (columns <= max) return { columns, rows: Math.max(1, rows) }
52
+ return { columns: max, rows: Math.max(1, Math.round((rows * max) / columns)) }
53
+ }
54
+
55
+ return (
56
+ <box width="100%" flexShrink={0} flexDirection="column" marginTop={1} marginBottom={1}>
57
+ <Switch>
58
+ <Match when={rendered.loading}>
59
+ <text fg={props.theme().text.muted} alignSelf="center">Rendering math...</text>
60
+ </Match>
61
+ <Match when={failed() || (rendered() && "error" in rendered()!)}>
62
+ <box flexDirection="column" border={["left"]} borderColor={props.theme().text.feedback.error} paddingLeft={1} alignSelf="center">
63
+ <text fg={props.theme().text.feedback.error}>Could not render TeX; showing source</text>
64
+ <text fg={props.theme().text.base} wrapMode="word">
65
+ {source()}
66
+ </text>
67
+ </box>
68
+ </Match>
69
+ <Match when={raster()}>
70
+ <box width="100%" height={box().rows} flexShrink={0} alignItems="center" justifyContent="center">
71
+ <image
72
+ id={`opentui-markdown-math-${mathImageID(props.content, true, color(), props.graphics.cell())}-${props.surface()}`}
73
+ source={raster()!.png}
74
+ fit="fit"
75
+ protocol="auto"
76
+ width={box().columns}
77
+ height={box().rows}
78
+ onError={() => setFailed(true)}
79
+ />
80
+ </box>
81
+ </Match>
82
+ </Switch>
83
+ </box>
84
+ )
85
+ }