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