opentui-markdown-render 0.0.0-stage → 0.1.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 +173 -2
- 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 +62 -4
- package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
- package/src/app.tsx +180 -0
- package/src/config/index.ts +45 -0
- package/src/config/load.ts +308 -0
- package/src/config/resolve.ts +37 -0
- package/src/config/theme.ts +88 -0
- package/src/config/types.ts +102 -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 +98 -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 +5 -0
- package/src/theme/resolve.ts +166 -0
- package/src/theme/syntax.ts +96 -0
- package/src/theme/types.ts +150 -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
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { Marked, type Token, type Tokens } from "marked"
|
|
2
|
+
import { createMathDisplayExtension, createMathInlineExtension, type MathDisplayToken, type MathInlineToken } from "./math"
|
|
3
|
+
import type { Block, Document, Inline, SourceRange, TableCell } from "./document"
|
|
4
|
+
|
|
5
|
+
const parser = new Marked({
|
|
6
|
+
gfm: true,
|
|
7
|
+
breaks: false,
|
|
8
|
+
async: false,
|
|
9
|
+
extensions: [createMathDisplayExtension(), createMathInlineExtension()],
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
type ParserToken = Token | MathDisplayToken | MathInlineToken
|
|
13
|
+
|
|
14
|
+
function isDisplay(token: ParserToken): token is MathDisplayToken {
|
|
15
|
+
return token.type === "mathDisplay"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isInlineMath(token: ParserToken): token is MathInlineToken {
|
|
19
|
+
return token.type === "mathInline"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function childrenOf(token: ParserToken): ParserToken[] {
|
|
23
|
+
return "tokens" in token && Array.isArray(token.tokens) ? token.tokens : []
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function inlineToken(token: ParserToken): Inline[] {
|
|
27
|
+
if (isInlineMath(token)) {
|
|
28
|
+
return [{ type: "math", content: token.content, raw: token.raw, open: token.open, close: token.close }]
|
|
29
|
+
}
|
|
30
|
+
if (isDisplay(token)) return [{ type: "text", text: token.raw }]
|
|
31
|
+
switch (token.type) {
|
|
32
|
+
case "text":
|
|
33
|
+
return childrenOf(token).length ? convertInline(childrenOf(token)) : [{ type: "text", text: token.text }]
|
|
34
|
+
case "escape":
|
|
35
|
+
return [{ type: "text", text: token.text }]
|
|
36
|
+
case "codespan":
|
|
37
|
+
return [{ type: "code", text: token.text }]
|
|
38
|
+
case "strong":
|
|
39
|
+
case "em":
|
|
40
|
+
case "del":
|
|
41
|
+
return [{ type: token.type, children: convertInline(token.tokens ?? []) }]
|
|
42
|
+
case "link":
|
|
43
|
+
return [{ type: "link", href: token.href, title: token.title ?? null, children: convertInline(token.tokens ?? []) }]
|
|
44
|
+
case "image":
|
|
45
|
+
return [{ type: "inline-image", href: token.href, title: token.title ?? null, alt: token.text }]
|
|
46
|
+
case "br":
|
|
47
|
+
return [{ type: "break", hard: true }]
|
|
48
|
+
case "checkbox":
|
|
49
|
+
return [] // ListItem.task/checked already carry the marker.
|
|
50
|
+
default:
|
|
51
|
+
return token.raw ? [{ type: "text", text: token.raw }] : []
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function convertInline(tokens: readonly ParserToken[]): Inline[] {
|
|
56
|
+
return tokens.flatMap(inlineToken)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function directInline(tokens: readonly ParserToken[]): ParserToken[] {
|
|
60
|
+
// Marked wraps the inline tokens of tight list items in a `text` block.
|
|
61
|
+
return tokens.flatMap((token) => token.type === "text" && childrenOf(token).length ? childrenOf(token) : [token])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function convertParagraph(token: ParserToken, source: SourceRange | null): Block[] {
|
|
65
|
+
const tokens = directInline(childrenOf(token))
|
|
66
|
+
const visible = convertInline(tokens).filter((node) => node.type !== "text" || node.text.trim().length > 0)
|
|
67
|
+
if (visible.length === 1 && visible[0].type === "inline-image") {
|
|
68
|
+
const image = visible[0]
|
|
69
|
+
return [{ type: "image", href: image.href, alt: image.alt, title: image.title, raw: token.raw, source }]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!tokens.some(isDisplay)) return [{ type: "paragraph", children: convertInline(tokens), raw: token.raw, source }]
|
|
73
|
+
|
|
74
|
+
const blocks: Block[] = []
|
|
75
|
+
let pending: ParserToken[] = []
|
|
76
|
+
// Only direct top-level children with exact raw alignment receive a range;
|
|
77
|
+
// Marked normalizes tokens inside nested quotes and list items.
|
|
78
|
+
let offset = source?.start ?? 0
|
|
79
|
+
let aligned = source !== null
|
|
80
|
+
let pendingStart = offset
|
|
81
|
+
const flush = () => {
|
|
82
|
+
if (!pending.length) return
|
|
83
|
+
const raw = pending.map((child) => child.raw).join("")
|
|
84
|
+
const children = convertInline(pending)
|
|
85
|
+
if (children.length) blocks.push({ type: "paragraph", raw, source: aligned ? { start: pendingStart, end: offset } : null, children })
|
|
86
|
+
pending = []
|
|
87
|
+
pendingStart = offset
|
|
88
|
+
}
|
|
89
|
+
for (const child of tokens) {
|
|
90
|
+
if (aligned && token.raw.slice(offset - source!.start, offset - source!.start + child.raw.length) !== child.raw) aligned = false
|
|
91
|
+
if (isDisplay(child)) {
|
|
92
|
+
flush()
|
|
93
|
+
offset += child.raw.length
|
|
94
|
+
blocks.push({
|
|
95
|
+
type: "display-math", content: child.content, open: child.open, close: child.close,
|
|
96
|
+
raw: child.raw, source: aligned ? { start: offset - child.raw.length, end: offset } : null,
|
|
97
|
+
})
|
|
98
|
+
pendingStart = offset
|
|
99
|
+
} else {
|
|
100
|
+
pending.push(child)
|
|
101
|
+
offset += child.raw.length
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
flush()
|
|
105
|
+
return blocks
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function convertBlocks(tokens: readonly ParserToken[], source: string | null, start = 0): Block[] {
|
|
109
|
+
const blocks: Block[] = []
|
|
110
|
+
let cursor = start
|
|
111
|
+
for (const token of tokens) {
|
|
112
|
+
const range: SourceRange | null = source !== null && source.slice(cursor, cursor + token.raw.length) === token.raw
|
|
113
|
+
? { start: cursor, end: cursor + token.raw.length }
|
|
114
|
+
: null
|
|
115
|
+
// `space` and `def` are invisible, but still occupy source positions.
|
|
116
|
+
cursor += token.raw.length
|
|
117
|
+
if (token.type === "space" || token.type === "def" || token.type === "checkbox") continue
|
|
118
|
+
if (isDisplay(token)) {
|
|
119
|
+
blocks.push({ type: "display-math", content: token.content, open: token.open, close: token.close, raw: token.raw, source: range })
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
switch (token.type) {
|
|
123
|
+
case "paragraph":
|
|
124
|
+
case "text":
|
|
125
|
+
blocks.push(...convertParagraph(token, range))
|
|
126
|
+
break
|
|
127
|
+
case "heading":
|
|
128
|
+
blocks.push({ type: "heading", level: token.depth as 1 | 2 | 3 | 4 | 5 | 6, children: convertInline(childrenOf(token)), raw: token.raw, source: range })
|
|
129
|
+
break
|
|
130
|
+
case "blockquote":
|
|
131
|
+
blocks.push({ type: "quote", blocks: convertBlocks(childrenOf(token), null), raw: token.raw, source: range })
|
|
132
|
+
break
|
|
133
|
+
case "list": {
|
|
134
|
+
const list = token as Tokens.List
|
|
135
|
+
let cursor = 0
|
|
136
|
+
const items = list.items.map((item) => {
|
|
137
|
+
let itemSource: SourceRange | null = null
|
|
138
|
+
if (range !== null && list.raw.slice(cursor, cursor + item.raw.length) === item.raw) {
|
|
139
|
+
itemSource = { start: range.start + cursor, end: range.start + cursor + item.raw.length }
|
|
140
|
+
}
|
|
141
|
+
cursor += item.raw.length
|
|
142
|
+
return {
|
|
143
|
+
task: item.task, checked: item.task ? (item.checked ?? false) : null, loose: item.loose,
|
|
144
|
+
blocks: convertBlocks(item.tokens, null),
|
|
145
|
+
source: itemSource,
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
blocks.push({
|
|
149
|
+
type: "list", raw: list.raw, source: range, ordered: list.ordered,
|
|
150
|
+
start: list.start === "" ? 1 : list.start, loose: list.loose,
|
|
151
|
+
items,
|
|
152
|
+
})
|
|
153
|
+
break
|
|
154
|
+
}
|
|
155
|
+
case "table": {
|
|
156
|
+
const table = token as Tokens.Table
|
|
157
|
+
const convertCell = (cell: Tokens.TableCell | undefined, header: boolean, align: TableCell["align"]): TableCell => ({
|
|
158
|
+
children: cell ? convertInline(cell.tokens) : [], header, align,
|
|
159
|
+
})
|
|
160
|
+
const align = table.align
|
|
161
|
+
const header = table.header.map((cell, i) => convertCell(cell, true, align[i] ?? null))
|
|
162
|
+
const rows = table.rows.map((row) => header.map((_, i) => convertCell(row[i], false, align[i] ?? null)))
|
|
163
|
+
blocks.push({ type: "table", align, header, rows, raw: table.raw, source: range })
|
|
164
|
+
break
|
|
165
|
+
}
|
|
166
|
+
case "code": {
|
|
167
|
+
const language = token.lang ?? null
|
|
168
|
+
if (language?.trim().split(/\s+/)[0]?.toLowerCase() === "mermaid") {
|
|
169
|
+
blocks.push({ type: "mermaid", content: token.text, language: "mermaid", raw: token.raw, source: range })
|
|
170
|
+
} else {
|
|
171
|
+
blocks.push({ type: "code", content: token.text, language, fenced: /^ {0,3}(?:`{3,}|~{3,})/.test(token.raw), raw: token.raw, source: range })
|
|
172
|
+
}
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
case "hr":
|
|
176
|
+
blocks.push({ type: "rule", raw: token.raw, source: range })
|
|
177
|
+
break
|
|
178
|
+
default:
|
|
179
|
+
// HTML and unknown extensions are literal, never executed or silently lost.
|
|
180
|
+
if (token.raw) blocks.push({ type: "literal", text: token.raw, raw: token.raw, source: range })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return blocks
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function parseDocument(source: string): Document {
|
|
187
|
+
// Marked token raw text uses LF even for CRLF input. Parse against the same
|
|
188
|
+
// normalized text, then translate proven offsets back into the host's source.
|
|
189
|
+
const normalized = source.replace(/\r\n?/g, "\n")
|
|
190
|
+
const blocks = convertBlocks(parser.lexer(normalized), normalized)
|
|
191
|
+
if (normalized === source) return { source, blocks }
|
|
192
|
+
|
|
193
|
+
const offsets: number[] = [0]
|
|
194
|
+
for (let index = 0; index < source.length; index++) {
|
|
195
|
+
if (source[index] === "\r" && source[index + 1] === "\n") index++
|
|
196
|
+
offsets.push(index + 1)
|
|
197
|
+
}
|
|
198
|
+
const originalRange = (range: SourceRange | null): SourceRange | null => range === null ? null
|
|
199
|
+
: { start: offsets[range.start]!, end: offsets[range.end]! }
|
|
200
|
+
const originalBlock = (block: Block): Block => {
|
|
201
|
+
if (block.type === "list") return {
|
|
202
|
+
...block, source: originalRange(block.source),
|
|
203
|
+
items: block.items.map((item) => ({ ...item, source: originalRange(item.source) })),
|
|
204
|
+
}
|
|
205
|
+
return { ...block, source: originalRange(block.source) }
|
|
206
|
+
}
|
|
207
|
+
return { source, blocks: blocks.map(originalBlock) }
|
|
208
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Table sizing is a layout decision, not a renderer one: the answer is a set of
|
|
2
|
+
// options OpenTUI understands, and it can be computed without a render context.
|
|
3
|
+
export const tableWidthModes = ["content", "full"] as const
|
|
4
|
+
|
|
5
|
+
// "content" sizes the columns to the cells, which keeps a two-column table from
|
|
6
|
+
// stretching over the whole window. "full" is OpenTUI's grid default.
|
|
7
|
+
export type TableWidthMode = (typeof tableWidthModes)[number]
|
|
8
|
+
|
|
9
|
+
export function isTableWidthMode(value: unknown): value is TableWidthMode {
|
|
10
|
+
return typeof value === "string" && (tableWidthModes as readonly string[]).includes(value)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const tableOverflows = ["scroll", "wrap"] as const
|
|
14
|
+
|
|
15
|
+
// A table wider than the document either scrolls sideways (like the Mermaid
|
|
16
|
+
// diagrams) or wraps its cells to fit.
|
|
17
|
+
export type TableOverflow = (typeof tableOverflows)[number]
|
|
18
|
+
|
|
19
|
+
export function isTableOverflow(value: unknown): value is TableOverflow {
|
|
20
|
+
return typeof value === "string" && (tableOverflows as readonly string[]).includes(value)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const tableColumnFitters = ["proportional", "balanced"] as const
|
|
24
|
+
|
|
25
|
+
export type TableColumnFitter = (typeof tableColumnFitters)[number]
|
|
26
|
+
|
|
27
|
+
export function isTableColumnFitter(value: unknown): value is TableColumnFitter {
|
|
28
|
+
return typeof value === "string" && (tableColumnFitters as readonly string[]).includes(value)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type TablesConfig = {
|
|
32
|
+
readonly widthMode?: TableWidthMode
|
|
33
|
+
readonly overflow?: TableOverflow
|
|
34
|
+
readonly columnFitter?: TableColumnFitter
|
|
35
|
+
readonly cellPaddingX?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type ResolvedTableOptions = {
|
|
39
|
+
readonly widthMode: TableWidthMode
|
|
40
|
+
readonly overflow: TableOverflow
|
|
41
|
+
readonly columnFitter: TableColumnFitter
|
|
42
|
+
readonly cellPaddingX: number
|
|
43
|
+
// A table that may scroll keeps its intrinsic width. A table that has to fit
|
|
44
|
+
// wraps its cells instead of being clipped at the window edge.
|
|
45
|
+
readonly wrapMode: "none" | "word"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveTableOptions(config?: TablesConfig): ResolvedTableOptions {
|
|
49
|
+
const widthMode = config?.widthMode ?? "content"
|
|
50
|
+
const overflow = config?.overflow ?? "scroll"
|
|
51
|
+
return {
|
|
52
|
+
widthMode,
|
|
53
|
+
overflow,
|
|
54
|
+
columnFitter: config?.columnFitter ?? "balanced",
|
|
55
|
+
cellPaddingX: config?.cellPaddingX ?? 1,
|
|
56
|
+
wrapMode: overflow === "scroll" && widthMode === "content" ? "none" : "word",
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/graphics.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { CliRenderer, PixelResolution, TerminalCapabilities } from "@opentui/core"
|
|
2
|
+
import { createMemo, createSignal, onCleanup } from "solid-js"
|
|
3
|
+
|
|
4
|
+
export type CellSize = { readonly width: number; readonly height: number }
|
|
5
|
+
|
|
6
|
+
// OpenTUI does not export its `resolveImageRenderProtocol`, so this mirrors it:
|
|
7
|
+
// the package resolves the protocol from detected capabilities inside a renderer
|
|
8
|
+
// child process whose environment is filtered down to a fixed allowlist (`TERM`,
|
|
9
|
+
// `OPENTUI_*`, ...), which means `KITTY_WINDOW_ID` and friends never reach it.
|
|
10
|
+
// Sniffing our own environment therefore cannot be trusted to agree with what
|
|
11
|
+
// images will actually be drawn with.
|
|
12
|
+
function resolveGraphicsProtocol(capabilities: TerminalCapabilities | null, hasResolution: boolean) {
|
|
13
|
+
const configured = capabilities?.image_protocol
|
|
14
|
+
if (configured && configured !== "auto") return configured === "sixel" && !hasResolution ? "blocks" : configured
|
|
15
|
+
if (!capabilities || capabilities.multiplexer === "tmux") return "blocks"
|
|
16
|
+
if (capabilities.kitty_graphics) return "kitty"
|
|
17
|
+
if (capabilities.sixel && hasResolution) return "sixel"
|
|
18
|
+
return "blocks"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Terminals without an image protocol fall back to a block-character mosaic where
|
|
22
|
+
// one cell is one coarse pixel, so a formula there needs a cell box twice as big
|
|
23
|
+
// to stay legible.
|
|
24
|
+
export const mosaicScale = (protocol: string) => (protocol === "blocks" ? 2 : 1)
|
|
25
|
+
|
|
26
|
+
export function useCanvasGraphics(renderer: CliRenderer) {
|
|
27
|
+
// Capabilities and pixel size are resolved by terminal queries after startup,
|
|
28
|
+
// so the first frame can still be missing them.
|
|
29
|
+
const [queried, setQueried] = createSignal(0)
|
|
30
|
+
const onCapabilities = () => setQueried((value) => value + 1)
|
|
31
|
+
renderer.on("capabilities", onCapabilities)
|
|
32
|
+
let refresh: ReturnType<typeof setTimeout> | undefined
|
|
33
|
+
const onResize = () => {
|
|
34
|
+
if (refresh) clearTimeout(refresh)
|
|
35
|
+
refresh = setTimeout(() => {
|
|
36
|
+
refresh = undefined
|
|
37
|
+
setQueried((value) => value + 1)
|
|
38
|
+
}, 250)
|
|
39
|
+
}
|
|
40
|
+
renderer.on("resize", onResize)
|
|
41
|
+
// OpenTUI requests a render after receiving pixel resolution but does not emit
|
|
42
|
+
// a capabilities event for it. A short probe is enough to make the first raster
|
|
43
|
+
// use the returned cell size without keeping a background timer alive.
|
|
44
|
+
const probe = setInterval(() => {
|
|
45
|
+
if (!renderer.resolution) return
|
|
46
|
+
clearInterval(probe)
|
|
47
|
+
setQueried((value) => value + 1)
|
|
48
|
+
}, 50)
|
|
49
|
+
const stopProbe = setTimeout(() => clearInterval(probe), 1000)
|
|
50
|
+
onCleanup(() => {
|
|
51
|
+
renderer.off("capabilities", onCapabilities)
|
|
52
|
+
renderer.off("resize", onResize)
|
|
53
|
+
clearInterval(probe)
|
|
54
|
+
clearTimeout(stopProbe)
|
|
55
|
+
if (refresh) clearTimeout(refresh)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const resolution = createMemo<PixelResolution | null>(() => {
|
|
59
|
+
queried()
|
|
60
|
+
const value = renderer.resolution
|
|
61
|
+
return value && value.width > 0 && value.height > 0 ? value : null
|
|
62
|
+
})
|
|
63
|
+
const cell = createMemo<CellSize | null>(() => {
|
|
64
|
+
const value = resolution()
|
|
65
|
+
const width = renderer.terminalWidth
|
|
66
|
+
const height = renderer.terminalHeight
|
|
67
|
+
if (!value || width <= 0 || height <= 0) return null
|
|
68
|
+
const cellWidth = value.width / width
|
|
69
|
+
const cellHeight = value.height / height
|
|
70
|
+
if (cellWidth <= 0 || cellHeight <= 0) return null
|
|
71
|
+
return { width: Math.max(1, Math.round(cellWidth)), height: Math.max(1, Math.round(cellHeight)) }
|
|
72
|
+
})
|
|
73
|
+
const protocol = createMemo(() => {
|
|
74
|
+
queried()
|
|
75
|
+
return resolveGraphicsProtocol(renderer.capabilities, resolution() !== null)
|
|
76
|
+
})
|
|
77
|
+
const hyperlinks = createMemo(() => {
|
|
78
|
+
queried()
|
|
79
|
+
return renderer.capabilities?.hyperlinks === true
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return { cell, protocol, hyperlinks }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type CanvasGraphics = ReturnType<typeof useCanvasGraphics>
|
package/src/headings.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { TextAttributes, type RGBA } from "@opentui/core"
|
|
2
|
+
import type { HeadingLevelConfig, HeadingsConfig } from "./config/types"
|
|
3
|
+
import type { HeadingLevel, Theme } from "./theme/types"
|
|
4
|
+
|
|
5
|
+
// A terminal has a single text size, so a heading level is expressed through
|
|
6
|
+
// attributes, colour and spacing instead of a larger font.
|
|
7
|
+
export type HeadingStyle = {
|
|
8
|
+
readonly bold: boolean
|
|
9
|
+
readonly italic: boolean
|
|
10
|
+
readonly underline: boolean
|
|
11
|
+
readonly dim: boolean
|
|
12
|
+
readonly uppercase: boolean
|
|
13
|
+
// Empty rows above the heading.
|
|
14
|
+
readonly marginTop: number
|
|
15
|
+
// Empty rows below the heading.
|
|
16
|
+
readonly marginBottom: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type HeadingStyles = Record<HeadingLevel, HeadingStyle>
|
|
20
|
+
|
|
21
|
+
export const headingLevels: readonly HeadingLevel[] = [1, 2, 3, 4, 5, 6]
|
|
22
|
+
|
|
23
|
+
// The bundled look: the top level is the loudest one, the lowest levels fade
|
|
24
|
+
// into the prose around them.
|
|
25
|
+
export const defaultHeadingStyles: HeadingStyles = {
|
|
26
|
+
1: { bold: true, italic: false, underline: true, dim: false, uppercase: false, marginTop: 2, marginBottom: 1 },
|
|
27
|
+
2: { bold: true, italic: false, underline: false, dim: false, uppercase: false, marginTop: 1, marginBottom: 0 },
|
|
28
|
+
3: { bold: true, italic: false, underline: false, dim: false, uppercase: false, marginTop: 1, marginBottom: 0 },
|
|
29
|
+
4: { bold: false, italic: true, underline: false, dim: false, uppercase: false, marginTop: 1, marginBottom: 0 },
|
|
30
|
+
5: { bold: false, italic: true, underline: false, dim: true, uppercase: false, marginTop: 1, marginBottom: 0 },
|
|
31
|
+
6: { bold: false, italic: false, underline: false, dim: true, uppercase: false, marginTop: 1, marginBottom: 0 },
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The configuration only carries the options the author set, so the rest of the
|
|
35
|
+
// style keeps the bundled default of that level.
|
|
36
|
+
function mergeHeadingStyle(base: HeadingStyle, override: HeadingLevelConfig | undefined): HeadingStyle {
|
|
37
|
+
if (!override) return base
|
|
38
|
+
return {
|
|
39
|
+
bold: override.bold ?? base.bold,
|
|
40
|
+
italic: override.italic ?? base.italic,
|
|
41
|
+
underline: override.underline ?? base.underline,
|
|
42
|
+
dim: override.dim ?? base.dim,
|
|
43
|
+
uppercase: override.uppercase ?? base.uppercase,
|
|
44
|
+
marginTop: override.marginTop ?? base.marginTop,
|
|
45
|
+
marginBottom: override.marginBottom ?? base.marginBottom,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveHeadingStyles(config?: HeadingsConfig): HeadingStyles {
|
|
50
|
+
const styles = {} as HeadingStyles
|
|
51
|
+
for (const level of headingLevels) {
|
|
52
|
+
styles[level] = mergeHeadingStyle(defaultHeadingStyles[level], config?.levels?.[`${level}` as "1"])
|
|
53
|
+
}
|
|
54
|
+
return styles
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function headingAttributes(style: HeadingStyle): number {
|
|
58
|
+
let attributes = TextAttributes.NONE
|
|
59
|
+
if (style.bold) attributes |= TextAttributes.BOLD
|
|
60
|
+
if (style.italic) attributes |= TextAttributes.ITALIC
|
|
61
|
+
if (style.underline) attributes |= TextAttributes.UNDERLINE
|
|
62
|
+
if (style.dim) attributes |= TextAttributes.DIM
|
|
63
|
+
return attributes
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function headingColor(theme: Theme, level: HeadingLevel): RGBA {
|
|
67
|
+
// A theme that was built by hand may not carry the ramp; the base heading
|
|
68
|
+
// colour is the fallback then.
|
|
69
|
+
return theme.markdown.headingLevels?.[level - 1] ?? theme.markdown.heading
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The level of a heading that came from a document is always one of the six,
|
|
73
|
+
// but a host that builds a block by hand may pass any number.
|
|
74
|
+
export function headingLevel(level: number): HeadingLevel {
|
|
75
|
+
const value = Math.min(6, Math.max(1, Math.round(level)))
|
|
76
|
+
return value as HeadingLevel
|
|
77
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export { MarkdownRenderer, resolveRendererTheme, loadRendererConfig, type MarkdownRendererProps, type MarkdownRendererCommonProps, type MarkdownDiagnostic, type ThemeModeSetting } from "./app"
|
|
2
|
+
export type { RenderUnit } from "./view/document"
|
|
3
|
+
export { parseDocument, type Document, type Block, type Inline, type ListItem, type TableCell, type SourceRange, type UnitInfo } from "./core"
|
|
4
|
+
export {
|
|
5
|
+
resolveFrame,
|
|
6
|
+
isFrameAlign,
|
|
7
|
+
isFrameStyle,
|
|
8
|
+
frameAligns,
|
|
9
|
+
frameStyles,
|
|
10
|
+
type FrameAlign,
|
|
11
|
+
type FrameConfig,
|
|
12
|
+
type FrameInsets,
|
|
13
|
+
type FrameStyle,
|
|
14
|
+
type ResolvedFrame,
|
|
15
|
+
} from "./core/frame"
|
|
16
|
+
export { MarkdownFrame, frameBorderColor, frameTitleColor } from "./view/frame"
|
|
17
|
+
export {
|
|
18
|
+
resolveTableOptions,
|
|
19
|
+
isTableWidthMode,
|
|
20
|
+
isTableOverflow,
|
|
21
|
+
isTableColumnFitter,
|
|
22
|
+
tableWidthModes,
|
|
23
|
+
tableOverflows,
|
|
24
|
+
tableColumnFitters,
|
|
25
|
+
type ResolvedTableOptions,
|
|
26
|
+
type TableColumnFitter,
|
|
27
|
+
type TableOverflow,
|
|
28
|
+
type TableWidthMode,
|
|
29
|
+
type TablesConfig,
|
|
30
|
+
} from "./core/tables"
|
|
31
|
+
export { createDocumentLayout, type DocumentLayout, type DocumentLayoutInput } from "./core"
|
|
32
|
+
export { LocalImage, imageBox, resolveLocalImage, resolveBackground, rgbToHex, standaloneImage, maxColumns, type ImageLimits, type MarkdownImage } from "./media"
|
|
33
|
+
export { useCanvasGraphics, mosaicScale, type CanvasGraphics, type CellSize } from "./graphics"
|
|
34
|
+
export {
|
|
35
|
+
defaultHeadingStyles,
|
|
36
|
+
resolveHeadingStyles,
|
|
37
|
+
headingAttributes,
|
|
38
|
+
headingColor,
|
|
39
|
+
headingLevels,
|
|
40
|
+
type HeadingStyle,
|
|
41
|
+
type HeadingStyles,
|
|
42
|
+
} from "./headings"
|
|
43
|
+
export { detectMermaidDiagram, MermaidSyntaxError, type MermaidDiagramKind } from "./merman"
|
|
44
|
+
export { rgbaToHex, mathImageID, renderMathImage, type MathImage } from "./math"
|
|
45
|
+
export { renderMath, clearMathCache, type MathRenderRequest, type RenderedMath } from "./math/renderer"
|
|
46
|
+
export {
|
|
47
|
+
StatusLine,
|
|
48
|
+
statusLineSegments,
|
|
49
|
+
formatStatusLine,
|
|
50
|
+
statusLinePalette,
|
|
51
|
+
defaultStatusLineFields,
|
|
52
|
+
type StatusLineState,
|
|
53
|
+
type StatusLineSegment,
|
|
54
|
+
type StatusLinePalette,
|
|
55
|
+
} from "./statusline"
|
|
56
|
+
export {
|
|
57
|
+
loadConfig,
|
|
58
|
+
discoverConfig,
|
|
59
|
+
parseConfig,
|
|
60
|
+
configFilenames,
|
|
61
|
+
resolveConfiguredTheme,
|
|
62
|
+
resolveConfiguredLayout,
|
|
63
|
+
statusLineFields,
|
|
64
|
+
isStatusLineField,
|
|
65
|
+
statusLineStyles,
|
|
66
|
+
isStatusLineStyle,
|
|
67
|
+
type MarkdownConfig,
|
|
68
|
+
type StatusLineConfig,
|
|
69
|
+
type StatusLineField,
|
|
70
|
+
type StatusLineBorder,
|
|
71
|
+
type StatusLineStyle,
|
|
72
|
+
type ImagesConfig,
|
|
73
|
+
type MermaidConfig,
|
|
74
|
+
type HeadingsConfig,
|
|
75
|
+
type HeadingLevelConfig,
|
|
76
|
+
type HeadingLevelKey,
|
|
77
|
+
type ConfigReporter,
|
|
78
|
+
type ThemeRuntime,
|
|
79
|
+
type LayoutRuntime,
|
|
80
|
+
} from "./config"
|
|
81
|
+
export {
|
|
82
|
+
defaultTheme,
|
|
83
|
+
generateSyntax,
|
|
84
|
+
resolveTheme,
|
|
85
|
+
mergeTheme,
|
|
86
|
+
deriveStatusLineTheme,
|
|
87
|
+
mixColors,
|
|
88
|
+
type Theme,
|
|
89
|
+
type ThemeMode,
|
|
90
|
+
type ThemePreset,
|
|
91
|
+
type ThemeOverride,
|
|
92
|
+
type StatusLineTheme,
|
|
93
|
+
type StatusLineThemeOverride,
|
|
94
|
+
type MarkdownThemeOverride,
|
|
95
|
+
type FrameTheme,
|
|
96
|
+
type FrameThemeOverride,
|
|
97
|
+
type HeadingLevel,
|
|
98
|
+
} from "./theme"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import type { RGBA } from "@opentui/core"
|
|
3
|
+
import type { CellSize } from "../graphics"
|
|
4
|
+
import { renderMath } from "./renderer"
|
|
5
|
+
import { isRenderableLatex } from "./validate"
|
|
6
|
+
|
|
7
|
+
export type { CellSize } from "../graphics"
|
|
8
|
+
|
|
9
|
+
export type MathImage = {
|
|
10
|
+
readonly png: Uint8Array
|
|
11
|
+
readonly columns: number
|
|
12
|
+
readonly rows: number
|
|
13
|
+
readonly baseline: number
|
|
14
|
+
// Whether the raster was scaled below the standard text size to fit a single
|
|
15
|
+
// text row. A formula that was not scaled can keep that size.
|
|
16
|
+
readonly clamped: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function rgbaToHex(color: RGBA) {
|
|
20
|
+
const channel = (value: number) =>
|
|
21
|
+
Math.round(Math.max(0, Math.min(1, value)) * 255)
|
|
22
|
+
.toString(16)
|
|
23
|
+
.padStart(2, "0")
|
|
24
|
+
return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function mathImageID(
|
|
28
|
+
content: string,
|
|
29
|
+
display: boolean,
|
|
30
|
+
color: string,
|
|
31
|
+
cell: CellSize | null,
|
|
32
|
+
scale = 1,
|
|
33
|
+
compact = false,
|
|
34
|
+
standard = false,
|
|
35
|
+
) {
|
|
36
|
+
return createHash("sha256")
|
|
37
|
+
.update(JSON.stringify({ content, display, color, cell, scale, compact, standard, renderer: "mitex-typst-png-v2" }))
|
|
38
|
+
.digest("hex")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The Typst page is laid out at 12pt, so a scale of `px / 12` puts one em at one
|
|
42
|
+
// terminal cell height: the same text size the port reference targets with its
|
|
43
|
+
// `textColumns = 2` / cell-aspect-ratio model. `scale` is only the caller's own
|
|
44
|
+
// enlargement (`+`/`-`) on top of that text-relative base.
|
|
45
|
+
const fontSizePoints = 12
|
|
46
|
+
|
|
47
|
+
// The native renderer produces a tight page whose frame baseline is known, so a
|
|
48
|
+
// formula maps onto a cell box without any of the padding the SVG path needed.
|
|
49
|
+
export async function renderMathImage(
|
|
50
|
+
content: string,
|
|
51
|
+
display: boolean,
|
|
52
|
+
color: string,
|
|
53
|
+
background: string,
|
|
54
|
+
cell: CellSize | null,
|
|
55
|
+
scale = 1,
|
|
56
|
+
compact = false,
|
|
57
|
+
standard = false,
|
|
58
|
+
): Promise<MathImage | undefined> {
|
|
59
|
+
// A formula that Typst would happily accept as a truncated expression (a
|
|
60
|
+
// trailing `\frac{1}{`, say) is not a formula an author meant to typeset, so
|
|
61
|
+
// it falls back to its source rather than rendering a mis-shaped image.
|
|
62
|
+
const latex = content.trim()
|
|
63
|
+
if (!isRenderableLatex(latex)) return undefined
|
|
64
|
+
const cellWidth = cell?.width ?? 8
|
|
65
|
+
const cellHeight = cell?.height ?? 16
|
|
66
|
+
const base = cellHeight / fontSizePoints
|
|
67
|
+
// The native raster is padded to an exact whole-cell box, so the source and the
|
|
68
|
+
// cell box share an aspect ratio and `fit` draws it 1:1 instead of stretching
|
|
69
|
+
// the ink to fill the box.
|
|
70
|
+
const rendered = await renderMath({
|
|
71
|
+
latex,
|
|
72
|
+
foreground: color,
|
|
73
|
+
background,
|
|
74
|
+
scale: base * scale,
|
|
75
|
+
display,
|
|
76
|
+
cellWidth,
|
|
77
|
+
cellHeight,
|
|
78
|
+
compact,
|
|
79
|
+
standard,
|
|
80
|
+
})
|
|
81
|
+
if (!rendered) return undefined
|
|
82
|
+
const columns = Math.max(1, Math.round(rendered.pixelWidth / cellWidth))
|
|
83
|
+
const rows = Math.max(1, Math.round(rendered.pixelHeight / cellHeight))
|
|
84
|
+
const baseline = rendered.baseline / cellHeight
|
|
85
|
+
return { png: rendered.png, columns, rows, baseline, clamped: rendered.clamped }
|
|
86
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { createRequire } from "node:module"
|
|
3
|
+
|
|
4
|
+
type NativeModule = {
|
|
5
|
+
renderLatex(request: {
|
|
6
|
+
latex: string
|
|
7
|
+
scale?: number
|
|
8
|
+
foreground?: string
|
|
9
|
+
background?: string
|
|
10
|
+
display?: boolean
|
|
11
|
+
cellWidth?: number
|
|
12
|
+
cellHeight?: number
|
|
13
|
+
compact?: boolean
|
|
14
|
+
standard?: boolean
|
|
15
|
+
}): Promise<{
|
|
16
|
+
png: Uint8Array
|
|
17
|
+
pixelWidth: number
|
|
18
|
+
pixelHeight: number
|
|
19
|
+
baseline: number
|
|
20
|
+
clamped: boolean
|
|
21
|
+
}>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let loaded: Promise<NativeModule | undefined> | undefined
|
|
25
|
+
|
|
26
|
+
export function loadNativeMath() {
|
|
27
|
+
loaded ??= importNativeMath()
|
|
28
|
+
return loaded
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function importNativeMath(): Promise<NativeModule | undefined> {
|
|
32
|
+
if (process.platform !== "linux" || process.arch !== "x64") return undefined
|
|
33
|
+
try {
|
|
34
|
+
const file = path.join(import.meta.dir, "..", "..", "prebuilds", "linux-x64-gnu", "opentui_markdown_render.node")
|
|
35
|
+
return createRequire(import.meta.url)(file) as NativeModule
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
}
|