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
|
@@ -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
|
+
}
|