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,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>
@@ -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,107 @@
1
+ export { MarkdownRenderer, resolveRendererTheme, loadRendererConfig, type MarkdownRendererProps, type MarkdownRendererCommonProps, type MarkdownDiagnostic, type ThemeModeSetting } from "./app"
2
+ export { cliUsage, parseCliArgs, type CliCommand } from "./cli"
3
+ export type { RenderUnit } from "./view/document"
4
+ export { parseDocument, type Document, type Block, type Inline, type ListItem, type TableCell, type SourceRange, type UnitInfo } from "./core"
5
+ export {
6
+ resolveFrame,
7
+ isFrameAlign,
8
+ isFrameStyle,
9
+ frameAligns,
10
+ frameStyles,
11
+ type FrameAlign,
12
+ type FrameConfig,
13
+ type FrameInsets,
14
+ type FrameStyle,
15
+ type ResolvedFrame,
16
+ } from "./core/frame"
17
+ export { MarkdownFrame, frameBorderColor, frameTitleColor } from "./view/frame"
18
+ export {
19
+ resolveTableOptions,
20
+ isTableWidthMode,
21
+ isTableOverflow,
22
+ isTableColumnFitter,
23
+ tableWidthModes,
24
+ tableOverflows,
25
+ tableColumnFitters,
26
+ type ResolvedTableOptions,
27
+ type TableColumnFitter,
28
+ type TableOverflow,
29
+ type TableWidthMode,
30
+ type TablesConfig,
31
+ } from "./core/tables"
32
+ export { createDocumentLayout, type DocumentLayout, type DocumentLayoutInput } from "./core"
33
+ export { LocalImage, imageBox, resolveLocalImage, resolveBackground, rgbToHex, standaloneImage, maxColumns, type ImageLimits, type MarkdownImage } from "./media"
34
+ export { useCanvasGraphics, mosaicScale, type CanvasGraphics, type CellSize } from "./graphics"
35
+ export {
36
+ defaultHeadingStyles,
37
+ resolveHeadingStyles,
38
+ headingAttributes,
39
+ headingColor,
40
+ headingLevels,
41
+ type HeadingStyle,
42
+ type HeadingStyles,
43
+ } from "./headings"
44
+ export { detectMermaidDiagram, MermaidSyntaxError, type MermaidDiagramKind } from "./merman"
45
+ export { rgbaToHex, mathImageID, renderMathImage, type MathImage } from "./math"
46
+ export { renderMath, clearMathCache, type MathRenderRequest, type RenderedMath } from "./math/renderer"
47
+ export {
48
+ StatusLine,
49
+ statusLineSegments,
50
+ formatStatusLine,
51
+ statusLinePalette,
52
+ defaultStatusLineFields,
53
+ type StatusLineState,
54
+ type StatusLineSegment,
55
+ type StatusLinePalette,
56
+ } from "./statusline"
57
+ export {
58
+ loadConfig,
59
+ discoverConfig,
60
+ parseConfig,
61
+ configFilenames,
62
+ xdgConfigFilenames,
63
+ xdgConfigDirectory,
64
+ resolveConfiguredTheme,
65
+ resolveConfiguredLayout,
66
+ statusLineFields,
67
+ isStatusLineField,
68
+ statusLineStyles,
69
+ isStatusLineStyle,
70
+ type MarkdownConfig,
71
+ type StatusLineConfig,
72
+ type StatusLineField,
73
+ type StatusLineBorder,
74
+ type StatusLineStyle,
75
+ type ImagesConfig,
76
+ type MermaidConfig,
77
+ type ThemeConfig,
78
+ type HeadingsConfig,
79
+ type HeadingLevelConfig,
80
+ type HeadingLevelKey,
81
+ type ConfigReporter,
82
+ type ConfigEnvironment,
83
+ type ThemeRuntime,
84
+ type LayoutRuntime,
85
+ } from "./config"
86
+ export {
87
+ defaultTheme,
88
+ loadTheme,
89
+ bundledThemeNames,
90
+ themeFileNames,
91
+ generateSyntax,
92
+ resolveTheme,
93
+ mergeTheme,
94
+ deriveStatusLineTheme,
95
+ mixColors,
96
+ type Theme,
97
+ type ThemeMode,
98
+ type ThemePreset,
99
+ type ThemeOverride,
100
+ type ThemeEnvironment,
101
+ type StatusLineTheme,
102
+ type StatusLineThemeOverride,
103
+ type MarkdownThemeOverride,
104
+ type FrameTheme,
105
+ type FrameThemeOverride,
106
+ type HeadingLevel,
107
+ } 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
+ }
@@ -0,0 +1,84 @@
1
+ import { loadNativeMath } from "./native"
2
+
3
+ export type RenderedMath = {
4
+ readonly png: Uint8Array
5
+ readonly pixelWidth: number
6
+ readonly pixelHeight: number
7
+ readonly baseline: number
8
+ // Whether the raster was scaled below the standard text size to fit a single
9
+ // text row. A formula that was not scaled can keep that size.
10
+ readonly clamped: boolean
11
+ }
12
+
13
+ export type MathRenderRequest = {
14
+ readonly latex: string
15
+ readonly scale?: number
16
+ readonly enabled?: boolean
17
+ readonly foreground?: string
18
+ readonly background?: string
19
+ readonly display?: boolean
20
+ readonly cellWidth?: number
21
+ readonly cellHeight?: number
22
+ readonly compact?: boolean
23
+ // Keep the standard text size instead of scaling the formula down to the
24
+ // single text row it shares with the prose around it.
25
+ readonly standard?: boolean
26
+ }
27
+
28
+ const limit = 128
29
+ const cache = new Map<string, Promise<RenderedMath | undefined>>()
30
+
31
+ // A rejected render is intentionally a hole: callers keep the exact LaTeX
32
+ // source in the layout instead of replacing it with an approximation.
33
+ export function renderMath(request: MathRenderRequest) {
34
+ if (request.enabled === false) return Promise.resolve(undefined)
35
+ const scale = request.scale ?? 2
36
+ const display = request.display ?? false
37
+ const cellWidth = request.cellWidth ?? 8
38
+ const cellHeight = request.cellHeight ?? 16
39
+ const compact = request.compact ?? false
40
+ const standard = request.standard ?? false
41
+ const key = JSON.stringify({
42
+ latex: request.latex,
43
+ scale,
44
+ foreground: request.foreground,
45
+ background: request.background,
46
+ display,
47
+ cellWidth,
48
+ cellHeight,
49
+ compact,
50
+ standard,
51
+ })
52
+ const cached = cache.get(key)
53
+ if (cached) return cached
54
+ const result = loadNativeMath()
55
+ .then(async (native) => {
56
+ if (!native) return undefined
57
+ const image = await native.renderLatex({
58
+ latex: request.latex,
59
+ scale,
60
+ foreground: request.foreground,
61
+ background: request.background,
62
+ display,
63
+ cellWidth,
64
+ cellHeight,
65
+ compact,
66
+ standard,
67
+ })
68
+ return {
69
+ png: image.png,
70
+ pixelWidth: image.pixelWidth,
71
+ pixelHeight: image.pixelHeight,
72
+ baseline: image.baseline,
73
+ clamped: image.clamped,
74
+ }
75
+ })
76
+ .catch(() => undefined)
77
+ cache.set(key, result)
78
+ if (cache.size > limit) cache.delete(cache.keys().next().value!)
79
+ return result
80
+ }
81
+
82
+ export function clearMathCache() {
83
+ cache.clear()
84
+ }
@@ -0,0 +1,44 @@
1
+ // MiTeX and Typst both accept a truncated formula and lay out whatever they can
2
+ // parse, so an author error such as `\frac{1}{` becomes a small, wrong image
3
+ // instead of an error. There is no structured diagnostic to key off, so this is
4
+ // a deliberately conservative structural check: it only rejects input that is
5
+ // unambiguously incomplete and can never be valid math.
6
+ //
7
+ // Only `{}` and `\left`/`\right` are treated as structural. `[]` and `()` are
8
+ // ordinary math characters (interval notation such as `[0, 1)` is unbalanced on
9
+ // purpose), so they are left alone.
10
+ export function isRenderableLatex(content: string) {
11
+ let depth = 0
12
+ let left = 0
13
+ let right = 0
14
+ for (let index = 0; index < content.length; index++) {
15
+ const character = content[index]!
16
+ if (character === "\\") {
17
+ if (content[index + 1] === undefined) return false
18
+ // A backslash protects the next character (`\{`, `\}`), so it is consumed
19
+ // here rather than counted as a brace.
20
+ if (!/[A-Za-z]/.test(content[index + 1]!)) {
21
+ index++
22
+ continue
23
+ }
24
+ let cursor = index + 1
25
+ while (cursor < content.length && /[A-Za-z]/.test(content[cursor]!)) cursor++
26
+ const name = content.slice(index, cursor)
27
+ if (name === "\\left") left++
28
+ else if (name === "\\right") right++
29
+ index = cursor - 1
30
+ continue
31
+ }
32
+ if (character === "{") {
33
+ depth++
34
+ continue
35
+ }
36
+ if (character === "}") {
37
+ if (depth === 0) return false
38
+ depth--
39
+ }
40
+ }
41
+ // `\frac{1}{` leaves an open group; an unmatched `\left` or `\right` is the
42
+ // same class of error.
43
+ return depth === 0 && left === right
44
+ }