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,880 @@
1
+ use napi::bindgen_prelude::{Buffer, Error, Result, Status};
2
+ use napi_derive::napi;
3
+ use typst::{
4
+ diag::{FileError, FileResult},
5
+ foundations::{Bytes, Datetime, Duration},
6
+ syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot},
7
+ text::{Font, FontBook},
8
+ utils::{LazyHash, Scalar},
9
+ Library, LibraryExt, World,
10
+ };
11
+ use typst_layout::{Page, PagedDocument};
12
+
13
+ #[napi(object)]
14
+ pub struct RenderRequest {
15
+ pub latex: String,
16
+ pub scale: Option<f64>,
17
+ pub foreground: Option<String>,
18
+ pub background: Option<String>,
19
+ pub display: Option<bool>,
20
+ pub cell_width: Option<f64>,
21
+ pub cell_height: Option<f64>,
22
+ pub compact: Option<bool>,
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
+ pub standard: Option<bool>,
26
+ }
27
+
28
+ #[napi(object)]
29
+ pub struct RenderedMath {
30
+ pub png: Buffer,
31
+ pub pixel_width: u32,
32
+ pub pixel_height: u32,
33
+ pub baseline: f64,
34
+ /// Whether the raster was scaled below the standard text size to fit a text
35
+ /// row. A formula that was not scaled can keep that size.
36
+ pub clamped: bool,
37
+ }
38
+
39
+ struct MemoryWorld {
40
+ library: LazyHash<Library>,
41
+ book: LazyHash<FontBook>,
42
+ main: FileId,
43
+ source: Source,
44
+ fonts: Vec<Font>,
45
+ }
46
+
47
+ impl MemoryWorld {
48
+ fn new(source: String) -> Self {
49
+ let mut book = FontBook::new();
50
+ let mut fonts = vec![];
51
+
52
+ for data in typst_assets::fonts() {
53
+ for font in Font::iter(Bytes::new(data)) {
54
+ book.push(font.info().clone());
55
+ fonts.push(font);
56
+ }
57
+ }
58
+
59
+ let path = VirtualPath::new("math.typ").expect("the math path is valid");
60
+ let main = FileId::new(RootedPath::new(VirtualRoot::Project, path));
61
+ Self {
62
+ library: LazyHash::new(Library::default()),
63
+ book: LazyHash::new(book),
64
+ main,
65
+ source: Source::new(main, source),
66
+ fonts,
67
+ }
68
+ }
69
+ }
70
+
71
+ impl World for MemoryWorld {
72
+ fn library(&self) -> &LazyHash<Library> {
73
+ &self.library
74
+ }
75
+
76
+ fn book(&self) -> &LazyHash<FontBook> {
77
+ &self.book
78
+ }
79
+
80
+ fn main(&self) -> FileId {
81
+ self.main
82
+ }
83
+
84
+ // The renderer handles one expression and deliberately has no filesystem or
85
+ // package access. That makes every render deterministic and in-process.
86
+ fn source(&self, id: FileId) -> FileResult<Source> {
87
+ if id == self.main {
88
+ Ok(self.source.clone())
89
+ } else {
90
+ Err(FileError::AccessDenied)
91
+ }
92
+ }
93
+
94
+ fn file(&self, _: FileId) -> FileResult<Bytes> {
95
+ Err(FileError::AccessDenied)
96
+ }
97
+
98
+ fn font(&self, index: usize) -> Option<Font> {
99
+ self.fonts.get(index).cloned()
100
+ }
101
+
102
+ fn today(&self, _: Option<Duration>) -> Option<Datetime> {
103
+ None
104
+ }
105
+ }
106
+
107
+ // The page is laid out at this size in points. A render scale of
108
+ // `cell_height / FONT_SIZE_PT` then puts one em at one terminal cell height.
109
+ const FONT_SIZE_PT: f64 = 12.0;
110
+
111
+ // Geometry shared with the SVG reference implementation. A terminal cell is about
112
+ // twice as tall as it is wide, so one text em spans two columns and one row.
113
+ const TEXT_COLUMNS: f64 = 2.0;
114
+ const TEXT_BASELINE: f64 = 0.75;
115
+ // Empty rows charged above the ink so the anti-aliased edge of the tallest glyph
116
+ // is never clipped by the box. Compact math, which only ever occupies one row,
117
+ // can afford less.
118
+ const BLEED: f64 = 0.08;
119
+ const COMPACT_PADDING: f64 = 0.04;
120
+ const INLINE_GLYPH_SCALE: f64 = 1.02;
121
+
122
+ // A tight page (margin 0) is exactly the text line box, so an inline formula whose
123
+ // ink overflows that box — an integral, a summation with limits — is clipped at
124
+ // the page edge. A generous margin lets the whole ink rasterize; the ink is then
125
+ // measured back out of the raster, so the margin never reaches the terminal box.
126
+ const PAGE_MARGIN_PT: f64 = FONT_SIZE_PT * 6.0;
127
+
128
+ // A page laid out from inline math flattens the line into direct glyph items, so
129
+ // the line's baseline is not recoverable from the frame tree. Instead the formula
130
+ // shares its line with a small sentinel glyph (a Latin `x`, which has no
131
+ // descender and rests on the baseline) drawn in a color distinct from both the
132
+ // ink and the fill. Scanning for that color locates the true text baseline.
133
+ //
134
+ // MiTeX converts LaTeX into Typst *code*, and that code calls helper functions
135
+ // for every LaTeX command Typst has no one-to-one equivalent for (`mitexsqrt`,
136
+ // `mitexdisplay`, `pmatrix`, ...). Those helpers are the host's job: the scope
137
+ // below is the official MiTeX scope (mitex-rs/mitex, `packages/mitex/specs/
138
+ // latex/standard.typ`, Apache-2.0), reduced to the handlers Typst 0.15 does not
139
+ // provide on its own.
140
+ const MITEX_SCOPE: &str = r#"
141
+ #let mitexsqrt(..args) = {
142
+ let p = args.pos()
143
+ if p.len() == 1 { math.sqrt(p.at(0)) }
144
+ else { math.root(p.at(0).children.filter(it => it != [\[] and it != [\]]).sum(), p.at(1)) }
145
+ }
146
+ #let mitexdisplay(body) = math.display(body)
147
+ #let mitexinline(body) = math.inline(body)
148
+ #let mitexscript(body) = math.script(body)
149
+ #let mitexsscript(body) = math.sscript(body)
150
+ #let mitexbold(body) = math.bold(math.upright(body))
151
+ #let mitexupright(body) = math.upright(body)
152
+ #let mitexitalic(body) = math.italic(body)
153
+ #let mitexsans(body) = math.sans(body)
154
+ #let mitexfrak(body) = math.frak(body)
155
+ #let mitexmono(body) = math.mono(body)
156
+ #let mitexcal(body) = math.cal(body)
157
+ #let mitexmathbf(body) = math.bold(math.upright(body))
158
+ #let textmath(body) = body
159
+ #let textmd(body) = body
160
+ #let textnormal(body) = body
161
+ #let textbf(body) = math.bold(body)
162
+ #let textit(body) = math.italic(body)
163
+ #let textrm(body) = math.upright(body)
164
+ #let textup(body) = math.upright(body)
165
+ #let textsf(body) = math.sans(body)
166
+ #let texttt(body) = math.mono(body)
167
+ #let operatorname(body) = math.op(math.upright(body))
168
+ #let operatornamewithlimits(body) = math.op(limits: true, math.upright(body))
169
+ #let middle(body) = math.mid(body)
170
+ #let big(body) = math.lr(size: 1.2em, body)
171
+ #let Big(body) = math.lr(size: 1.8em, body)
172
+ #let bigg(body) = math.lr(size: 2.4em, body)
173
+ #let Bigg(body) = math.lr(size: 3em, body)
174
+ #let phantom(body) = hide(body)
175
+ #let hphantom(body) = box(height: 0pt, hide(body))
176
+ #let vphantom(body) = box(width: 0pt, hide(body))
177
+ #let dfrac(a, b) = math.display(math.frac(a, b))
178
+ #let tfrac(a, b) = math.inline(math.frac(a, b))
179
+ #let cfrac(a, b) = math.display(math.frac(a, b))
180
+ #let dbinom(a, b) = math.display(math.binom(a, b))
181
+ #let tbinom(a, b) = math.inline(math.binom(a, b))
182
+ #let substack(body) = body
183
+ #let overset(sup, base) = $limits(#base)^(#sup)$
184
+ #let underset(sub, base) = $limits(#base)_(#sub)$
185
+ #let stackrel(sup, base) = $limits(#base)^(#sup)$
186
+ #let boxed(body) = box(stroke: 0.5pt, inset: 6pt, $body$)
187
+ #let pmatrix(..args) = math.mat(delim: "(", ..args.pos())
188
+ #let bmatrix(..args) = math.mat(delim: "[", ..args.pos())
189
+ #let Bmatrix(..args) = math.mat(delim: "{", ..args.pos())
190
+ #let vmatrix(..args) = math.mat(delim: "|", ..args.pos())
191
+ #let Vmatrix(..args) = math.mat(delim: "||", ..args.pos())
192
+ #let matrix(..args) = math.mat(delim: none, ..args.pos())
193
+ #let smallmatrix(..args) = math.inline(math.mat(delim: none, ..args.pos()))
194
+ #let aligned(body) = math.op(body)
195
+ #let alignedat(body) = math.op(body)
196
+ #let mitexarray(arg0: none, ..args) = {
197
+ if args.pos().len() == 0 { return [] }
198
+ let columns = if type(arg0) == str {
199
+ (arg0,)
200
+ } else if arg0.has("children") {
201
+ arg0.children.filter(it => it != [ ] and it != [#math.zws]).map(it => it.text).filter(it => it == "l" or it == "c" or it == "r")
202
+ } else {
203
+ (arg0.text,)
204
+ }
205
+ // The converter writes the cells of one row as separate arguments and
206
+ // separates the rows with `;`, which arrives as a single array of rows.
207
+ let rows = if args.pos().len() == 1 and type(args.pos().at(0)) == array {
208
+ args.pos().at(0)
209
+ } else {
210
+ (args.pos(),)
211
+ }
212
+ let width = calc.max(..rows.map(row => row.len()))
213
+ let cells = rows.map(row => row + (width - row.len()) * (none,)).flatten()
214
+ let align-map = ("l": left, "c": center, "r": right)
215
+ set align(if columns.len() == 0 { center } else { align-map.at(columns.at(0)) })
216
+ grid(
217
+ columns: width,
218
+ column-gutter: 0.5em,
219
+ row-gutter: 0.5em,
220
+ ..cells.map(cell => $cell$),
221
+ )
222
+ }
223
+ #let mitexunderbrace(body) = math.underbrace(body)
224
+ #let mitexoverbrace(body) = math.overbrace(body)
225
+ #let mitexunderbracket(body) = math.underbracket(body)
226
+ #let mitexoverbracket(body) = math.overbracket(body)
227
+ #let xrightarrow(body) = $limits(stretch(#math.arrow.r)^#body)$
228
+ #let xleftarrow(body) = $limits(stretch(#math.arrow.l)^#body)$
229
+ #let xleftrightarrow(body) = $limits(stretch(#math.arrow.l.r)^#body)$
230
+ #let xRightarrow(body) = $limits(stretch(#math.arrow.r.double)^#body)$
231
+ #let xLeftarrow(body) = $limits(stretch(#math.arrow.l.double)^#body)$
232
+ #let xLeftrightarrow(body) = $limits(stretch(#math.arrow.l.r.double)^#body)$
233
+ #let mitexlabel(..args) = []
234
+ #let mitexcite(..args) = []
235
+ #let mitexref(..args) = []
236
+ "#;
237
+
238
+ // `display` math is typeset in Typst's display style, where large operators
239
+ // carry their limits above and below instead of beside them.
240
+ fn math_source(math: &str, foreground: &str, background: &str, sentinel: &str, display: bool) -> String {
241
+ let body = if display { format!("display({math})") } else { math.to_string() };
242
+ format!(
243
+ "#set page(width: auto, height: auto, margin: {PAGE_MARGIN_PT}pt, fill: rgb(\"{background}\"))\n#set text(size: {FONT_SIZE_PT}pt, fill: rgb(\"{foreground}\"))\n{MITEX_SCOPE}\n#text(fill: rgb(\"{sentinel}\"))[x]#h(6em)${body}$"
244
+ )
245
+ }
246
+
247
+ fn compile_page(source: String) -> std::result::Result<Page, String> {
248
+ let world = MemoryWorld::new(source);
249
+ let warned = typst::compile::<PagedDocument>(&world);
250
+ for warning in warned.warnings.iter() {
251
+ eprintln!("[opentui-markdown-render] {warning:?}");
252
+ }
253
+ let document = warned.output.map_err(|errors| format!("{errors:?}"))?;
254
+ document.pages().first().cloned().ok_or_else(|| "Typst produced no page".into())
255
+ }
256
+
257
+ // A formula the display-style wrapper cannot parse - a stray top-level comma,
258
+ // say - still renders in text style instead of falling back to the source.
259
+ fn compile_math_page(
260
+ math: &str,
261
+ foreground: &str,
262
+ background: &str,
263
+ display: bool,
264
+ ) -> std::result::Result<Page, String> {
265
+ let source = math_source(math, foreground, background, SENTINEL_HEX, display);
266
+ match compile_page(source) {
267
+ Ok(page) => Ok(page),
268
+ Err(error) if display => {
269
+ eprintln!("[opentui-markdown-render] display style failed, using text style: {error}");
270
+ compile_page(math_source(math, foreground, background, SENTINEL_HEX, false))
271
+ }
272
+ Err(error) => Err(error),
273
+ }
274
+ }
275
+
276
+ // The page carries the fill that the terminal background is baked into, so the
277
+ // raster is opaque and matches the theme it is drawn on.
278
+ fn render_page(page: &Page, scale: f64) -> tiny_skia::Pixmap {
279
+ typst_render::render(
280
+ page,
281
+ &typst_render::RenderOptions {
282
+ pixel_per_pt: Scalar::new(scale),
283
+ render_bleed: false,
284
+ },
285
+ )
286
+ }
287
+
288
+ fn hex_rgb(hex: &str) -> (u8, u8, u8) {
289
+ let pair = |start: usize| u8::from_str_radix(&hex[start..start + 2], 16).unwrap_or(0);
290
+ if hex.len() == 3 {
291
+ let mut iter = hex.bytes();
292
+ let digit = |byte: u8| {
293
+ let value = (byte as char).to_digit(16).unwrap_or(0) as u8;
294
+ value * 16 + value
295
+ };
296
+ (digit(iter.next().unwrap()), digit(iter.next().unwrap()), digit(iter.next().unwrap()))
297
+ } else {
298
+ (pair(0), pair(2), pair(4))
299
+ }
300
+ }
301
+
302
+ fn channel_distance(a: (u8, u8, u8), b: (u8, u8, u8)) -> i32 {
303
+ (a.0 as i32 - b.0 as i32).abs() + (a.1 as i32 - b.1 as i32).abs() + (a.2 as i32 - b.2 as i32).abs()
304
+ }
305
+
306
+ fn is_ink(pixel: (u8, u8, u8), background: (u8, u8, u8)) -> bool {
307
+ channel_distance(pixel, background) > 8
308
+ }
309
+
310
+ // The sentinel glyph is only a positional marker: it is sliced off spatially, so
311
+ // its color never affects the result and any value distinct from the fill works.
312
+ const SENTINEL_HEX: &str = "ff00ff";
313
+
314
+ struct InkBox {
315
+ left: u32,
316
+ top: u32,
317
+ right: u32,
318
+ bottom: u32,
319
+ }
320
+
321
+ struct Regions {
322
+ math: Option<InkBox>,
323
+ sentinel: Option<InkBox>,
324
+ split: u32,
325
+ }
326
+
327
+ fn extend(bounds: &mut InkBox, x: u32, y: u32) {
328
+ bounds.left = bounds.left.min(x);
329
+ bounds.right = bounds.right.max(x);
330
+ bounds.top = bounds.top.min(y);
331
+ bounds.bottom = bounds.bottom.max(y);
332
+ }
333
+
334
+ // The page lays the sentinel glyph and the formula on one line, separated by a
335
+ // wide `#h` gap, so the ink splits at the single largest empty column run:
336
+ // everything to its left is the sentinel, everything to its right is the formula.
337
+ // This needs no color model, so it survives any theme palette, and it recovers
338
+ // the formula's true bounding box however far its ink overflows the text line box
339
+ // it was laid out in. `min_gap` is the separator width in pixels; a longest run
340
+ // shorter than that means the two are not distinguishable and the whole raster is
341
+ // treated as the formula.
342
+ fn measure_regions(image: &tiny_skia::Pixmap, background: (u8, u8, u8), min_gap: u32) -> Regions {
343
+ let width = image.width();
344
+ let height = image.height();
345
+ let data = image.data();
346
+ let mut column_has_ink = vec![false; width as usize];
347
+ let mut any_ink = false;
348
+ for y in 0..height {
349
+ for x in 0..width {
350
+ let index = ((y * width + x) * 4) as usize;
351
+ if is_ink((data[index], data[index + 1], data[index + 2]), background) {
352
+ column_has_ink[x as usize] = true;
353
+ any_ink = true;
354
+ }
355
+ }
356
+ }
357
+ if !any_ink {
358
+ return Regions { math: None, sentinel: None, split: 0 };
359
+ }
360
+ let first = column_has_ink.iter().position(|value| *value).unwrap_or(0) as u32;
361
+ let last = column_has_ink.iter().rposition(|value| *value).unwrap_or(0) as u32;
362
+
363
+ let mut best_gap = 0;
364
+ let mut best_start = 0;
365
+ let mut run_start = 0;
366
+ let mut in_gap = false;
367
+ for x in first..=last {
368
+ if column_has_ink[x as usize] {
369
+ in_gap = false;
370
+ continue;
371
+ }
372
+ if !in_gap {
373
+ in_gap = true;
374
+ run_start = x;
375
+ }
376
+ let run = x - run_start + 1;
377
+ if run > best_gap {
378
+ best_gap = run;
379
+ best_start = run_start;
380
+ }
381
+ }
382
+ let split = if best_gap >= min_gap { best_start + best_gap / 2 } else { first };
383
+
384
+ let mut math = InkBox { left: width, top: height, right: 0, bottom: 0 };
385
+ let mut glyph = InkBox { left: width, top: height, right: 0, bottom: 0 };
386
+ let mut math_found = false;
387
+ let mut glyph_found = false;
388
+ for y in 0..height {
389
+ for x in 0..width {
390
+ let index = ((y * width + x) * 4) as usize;
391
+ if !is_ink((data[index], data[index + 1], data[index + 2]), background) {
392
+ continue;
393
+ }
394
+ if x < split {
395
+ glyph_found = true;
396
+ extend(&mut glyph, x, y);
397
+ } else {
398
+ math_found = true;
399
+ extend(&mut math, x, y);
400
+ }
401
+ }
402
+ }
403
+ Regions { math: math_found.then_some(math), sentinel: glyph_found.then_some(glyph), split }
404
+ }
405
+
406
+ // The sentinel `x` has no descender and rests on the baseline, so the row below
407
+ // its last inked row is the text baseline. A faint anti-aliased row can push it
408
+ // down by at most a pixel.
409
+ fn region_baseline(sentinel: &InkBox) -> f64 {
410
+ sentinel.bottom as f64 + 1.0
411
+ }
412
+
413
+ // Copy the margin-padded source raster into the box at the given offset, clipping
414
+ // anything outside it. Fill pixels are a no-op (they match the box) and the
415
+ // sentinel glyph (everything left of `split`) is dropped.
416
+ fn blit(
417
+ source: &tiny_skia::Pixmap,
418
+ destination: &mut tiny_skia::Pixmap,
419
+ offset_x: i64,
420
+ offset_y: i64,
421
+ background: (u8, u8, u8),
422
+ split: u32,
423
+ ) {
424
+ let source_width = source.width() as i64;
425
+ let source_height = source.height() as i64;
426
+ let destination_width = destination.width() as i64;
427
+ let destination_height = destination.height() as i64;
428
+ let source_data = source.data();
429
+ let destination_data = destination.data_mut();
430
+ for source_y in 0..source_height {
431
+ let destination_y = source_y + offset_y;
432
+ if destination_y < 0 || destination_y >= destination_height {
433
+ continue;
434
+ }
435
+ for source_x in 0..source_width {
436
+ if source_x < split as i64 {
437
+ continue;
438
+ }
439
+ let destination_x = source_x + offset_x;
440
+ if destination_x < 0 || destination_x >= destination_width {
441
+ continue;
442
+ }
443
+ let from = ((source_y * source_width + source_x) * 4) as usize;
444
+ if !is_ink((source_data[from], source_data[from + 1], source_data[from + 2]), background) {
445
+ continue;
446
+ }
447
+ let to = ((destination_y * destination_width + destination_x) * 4) as usize;
448
+ destination_data[to..to + 4].copy_from_slice(&source_data[from..from + 4]);
449
+ }
450
+ }
451
+ }
452
+
453
+ // The `#h(6em)` separator between the sentinel and the formula, in pixels. Real
454
+ // formulas contain no empty run anywhere near this wide, so it marks the slice.
455
+ fn sentinel_gap_px(scale: f64) -> u32 {
456
+ (FONT_SIZE_PT * 3.0 * scale).round().max(4.0) as u32
457
+ }
458
+
459
+ fn blank_box(cell_width: u32, cell_height: u32, min_rows: u32, background: (u8, u8, u8)) -> Option<tiny_skia::Pixmap> {
460
+ let box_width = cell_width.max(1);
461
+ let box_height = cell_height.max(1) * min_rows.max(1);
462
+ let mut box_image = tiny_skia::Pixmap::new(box_width, box_height)?;
463
+ let (red, green, blue) = background;
464
+ for pixel in box_image.data_mut().chunks_exact_mut(4) {
465
+ pixel.copy_from_slice(&[red, green, blue, 255]);
466
+ }
467
+ Some(box_image)
468
+ }
469
+
470
+ // The terminal's `fit` maps the raster onto the cell box 1:1 only when the two
471
+ // share an aspect ratio, so the raster is padded to exactly `columns * cell_width`
472
+ // by `rows * cell_height`. The formula's true ink metrics are measured from a
473
+ // margin-padded page, then the same rows/columns/baseline arithmetic the SVG
474
+ // reference uses places the ink on the shared text baseline. Inline formulas that
475
+ // would otherwise overflow one text row are scaled down to fit it; genuinely tall
476
+ // ones keep a multi-row box; compact math is scaled into a single text row.
477
+ struct Rendered {
478
+ png: Vec<u8>,
479
+ width: u32,
480
+ height: u32,
481
+ baseline: f64,
482
+ clamped: bool,
483
+ }
484
+
485
+ fn render_math_png(
486
+ latex: String,
487
+ scale: f32,
488
+ foreground: String,
489
+ background: String,
490
+ display: bool,
491
+ cell_width: f32,
492
+ cell_height: f32,
493
+ compact: bool,
494
+ standard: bool,
495
+ ) -> std::result::Result<Rendered, String> {
496
+ // Display-math delimiters own the surrounding newlines, and a formula whose
497
+ // raw text still carries them is a multiline math expression inside the
498
+ // sentinel template. That changes the line box enough to defeat the widest-gap
499
+ // split (the sentinel leaks beside the formula), so the math is normalized to
500
+ // a single expression before layout.
501
+ let math = mitex::convert_math(latex.trim(), None).map_err(|error| error.to_string())?;
502
+ let foreground = normalize_color(&foreground)?;
503
+ let background = normalize_color(&background)?;
504
+
505
+ let cell_width = cell_width.round().max(1.0) as f64;
506
+ let cell_height = cell_height.round().max(1.0) as f64;
507
+ let background_rgb = hex_rgb(background);
508
+ let min_rows: u32 = if display { 3 } else { 1 };
509
+
510
+ // `scale` is the caller's points-to-pixels factor; separate the text-relative
511
+ // base (one em per cell height) from the caller's own enlargement so the two
512
+ // can be reasoned about independently.
513
+ let base_scale = cell_height / FONT_SIZE_PT;
514
+ let user_scale = (scale as f64) * FONT_SIZE_PT / cell_height;
515
+ let em_px = FONT_SIZE_PT * base_scale;
516
+
517
+ // Pass 1: measure the ink at text size on the margin-padded page.
518
+ let measuring = compile_math_page(&math, foreground, background, display)?;
519
+ let measured = render_page(&measuring, base_scale);
520
+ let regions = measure_regions(&measured, background_rgb, sentinel_gap_px(base_scale));
521
+ let Some(ink) = regions.math else {
522
+ // An empty formula draws nothing; reserve one text row so the flow stays
523
+ // stable, with the baseline where text beside it would sit.
524
+ let box_image = blank_box(cell_width as u32, cell_height as u32, min_rows, background_rgb)
525
+ .ok_or("Typst produced an empty page")?;
526
+ let baseline = if display {
527
+ (box_image.height() as f64) / 2.0
528
+ } else {
529
+ TEXT_BASELINE * cell_height
530
+ };
531
+ let png = box_image.encode_png().map_err(|error| error.to_string())?;
532
+ return Ok(Rendered { png, width: box_image.width(), height: box_image.height(), baseline, clamped: false });
533
+ };
534
+ let measured_baseline = regions
535
+ .sentinel
536
+ .as_ref()
537
+ .map(region_baseline)
538
+ .unwrap_or_else(|| ink.top as f64 + TEXT_BASELINE * em_px);
539
+ let em_above = ((measured_baseline - ink.top as f64) / em_px).max(0.0);
540
+ let em_below = (((ink.bottom + 1) as f64 - measured_baseline) / em_px).max(0.0);
541
+ let em_width = ((ink.right - ink.left + 1) as f64 / em_px).max(0.0);
542
+
543
+
544
+ // One em is two columns and (because a cell is twice as tall as wide) two rows
545
+ // tall, so `em_columns / aspect` converts em measurements into text rows.
546
+ let aspect = cell_height / cell_width;
547
+ let em_height = em_above + em_below;
548
+ // Near-text-height formulas are rescaled just enough to land inside the single
549
+ // text row they share with the surrounding prose; taller ones keep their box.
550
+ // A formula that stands alone in its line shares the row with nothing, so it
551
+ // keeps the standard text size instead.
552
+ let inline_columns = if standard || em_height > 1.1 {
553
+ TEXT_COLUMNS
554
+ } else {
555
+ TEXT_COLUMNS.min(((1.0 - BLEED - 0.01) * aspect) / (TEXT_BASELINE.max(em_above) + em_below))
556
+ };
557
+ let clamped = inline_columns < TEXT_COLUMNS;
558
+ let em_columns = if display { TEXT_COLUMNS * user_scale } else { inline_columns };
559
+ let ink_above_rows = em_above * (em_columns / aspect);
560
+ let ink_below_rows = em_below * (em_columns / aspect);
561
+ let padding = if compact && !display { COMPACT_PADDING } else { BLEED };
562
+ let glyph_scale = if compact && !display {
563
+ INLINE_GLYPH_SCALE
564
+ .min((TEXT_BASELINE - padding) / ink_above_rows)
565
+ .min((1.0 - TEXT_BASELINE - padding) / ink_below_rows)
566
+ } else {
567
+ INLINE_GLYPH_SCALE
568
+ };
569
+ let baseline_rows = if compact && !display {
570
+ TEXT_BASELINE
571
+ } else {
572
+ let above = if display { em_above } else { TEXT_BASELINE.max(em_above) };
573
+ above * (em_columns / aspect)
574
+ };
575
+ let ink_bottom = baseline_rows + ink_below_rows * glyph_scale;
576
+ let rows = if compact && !display {
577
+ 1.0
578
+ } else {
579
+ (ink_bottom + padding).ceil().max(min_rows as f64)
580
+ };
581
+ let columns = (em_width * em_columns * if compact && !display { glyph_scale } else { 1.0 }).ceil().max(1.0);
582
+ let returned_baseline_rows = baseline_rows + padding;
583
+ // The raster draws the glyphs at `em_columns * cell_width` pixels per em, and
584
+ // compact/the inline clamp shrink them further by `glyph_scale`. Display math
585
+ // is never glyph-scaled in the reference, so it draws at its true size.
586
+ let glyph_effective = if display { 1.0 } else { glyph_scale };
587
+ let render_scale = em_columns * cell_width * glyph_effective / FONT_SIZE_PT;
588
+
589
+ // Pass 2: rasterize at the computed size and place the ink on the box baseline.
590
+ let drawing = compile_math_page(&math, foreground, background, display)?;
591
+ let image = render_page(&drawing, render_scale);
592
+ let drawn = measure_regions(&image, background_rgb, sentinel_gap_px(render_scale));
593
+ let drawn_em_px = FONT_SIZE_PT * render_scale;
594
+ let drawn_baseline = drawn
595
+ .sentinel
596
+ .as_ref()
597
+ .map(region_baseline)
598
+ .unwrap_or_else(|| drawn.math.as_ref().map(|value| value.top as f64 + TEXT_BASELINE * drawn_em_px).unwrap_or(0.0));
599
+
600
+ let box_width = (columns as u32) * (cell_width as u32);
601
+ let box_height = (rows as u32) * (cell_height as u32);
602
+ let box_width = box_width.max(1);
603
+ let box_height = box_height.max(1);
604
+
605
+ // Centre the visible ink horizontally and drop the formula baseline onto
606
+ // `returned_baseline_rows`, where the surrounding text's own baseline sits.
607
+ let ink_center = drawn
608
+ .math
609
+ .as_ref()
610
+ .map(|value| (value.left as f64 + value.right as f64 + 1.0) / 2.0)
611
+ .unwrap_or(image.width() as f64 / 2.0);
612
+ let offset_x = (box_width as f64 / 2.0 - ink_center).round() as i64;
613
+ let offset_y = (returned_baseline_rows * cell_height).round() as i64 - drawn_baseline.round() as i64;
614
+
615
+ let (red, green, blue) = background_rgb;
616
+ let mut padded = tiny_skia::Pixmap::new(box_width, box_height).ok_or("Typst produced an empty page")?;
617
+ for pixel in padded.data_mut().chunks_exact_mut(4) {
618
+ pixel.copy_from_slice(&[red, green, blue, 255]);
619
+ }
620
+ blit(&image, &mut padded, offset_x, offset_y, background_rgb, drawn.split);
621
+
622
+ let baseline = offset_y as f64 + drawn_baseline;
623
+ let png = padded.encode_png().map_err(|error| error.to_string())?;
624
+ Ok(Rendered { png, width: box_width, height: box_height, baseline, clamped })
625
+ }
626
+
627
+ fn normalize_color(value: &str) -> std::result::Result<&str, String> {
628
+ let hex = value.strip_prefix('#').unwrap_or(value);
629
+ if (hex.len() == 3 || hex.len() == 6) && hex.bytes().all(|character| character.is_ascii_hexdigit()) {
630
+ return Ok(hex);
631
+ }
632
+ Err("Math colors must be three- or six-digit hexadecimal values".into())
633
+ }
634
+
635
+ #[napi]
636
+ pub async fn render_latex(request: RenderRequest) -> Result<RenderedMath> {
637
+ let scale = request.scale.unwrap_or(2.0).clamp(0.5, 8.0) as f32;
638
+ let foreground = request.foreground.unwrap_or_else(|| "000000".into());
639
+ let background = request.background.unwrap_or_else(|| "ffffff".into());
640
+ let display = request.display.unwrap_or(false);
641
+ let cell_width = request.cell_width.unwrap_or(8.0) as f32;
642
+ let cell_height = request.cell_height.unwrap_or(16.0) as f32;
643
+ let compact = request.compact.unwrap_or(false);
644
+ let standard = request.standard.unwrap_or(false);
645
+ let rendered = napi::tokio::task::spawn_blocking(move || {
646
+ render_math_png(
647
+ request.latex,
648
+ scale,
649
+ foreground,
650
+ background,
651
+ display,
652
+ cell_width,
653
+ cell_height,
654
+ compact,
655
+ standard,
656
+ )
657
+ })
658
+ .await
659
+ .map_err(|error| Error::new(Status::GenericFailure, error.to_string()))?
660
+ .map_err(|error| Error::new(Status::InvalidArg, error))?;
661
+ Ok(RenderedMath {
662
+ png: rendered.png.into(),
663
+ pixel_width: rendered.width,
664
+ pixel_height: rendered.height,
665
+ baseline: rendered.baseline,
666
+ clamped: rendered.clamped,
667
+ })
668
+ }
669
+
670
+ #[cfg(test)]
671
+ mod tests {
672
+ use super::render_math_png;
673
+ use super::{hex_rgb, math_source, measure_regions, sentinel_gap_px, Regions, SENTINEL_HEX};
674
+
675
+ #[test]
676
+ fn renders_mitex_math_to_a_png() {
677
+ let rendered =
678
+ render_math_png(r"\frac{a}{b}".into(), 16.0 / 12.0, "000000".into(), "ffffff".into(), false, 8.0, 16.0, false, false)
679
+ .expect("formula renders");
680
+ assert!(rendered.png.starts_with(b"\x89PNG\r\n\x1a\n"));
681
+ assert!(rendered.width > 0);
682
+ assert!(rendered.height > 0);
683
+ assert!(
684
+ rendered.baseline > 0.0 && rendered.baseline <= rendered.height as f64,
685
+ "baseline={} height={} width={}",
686
+ rendered.baseline,
687
+ rendered.height,
688
+ rendered.width
689
+ );
690
+ }
691
+
692
+ #[test]
693
+ fn a_lone_formula_keeps_the_standard_text_size() {
694
+ // A formula that shares its row with prose is scaled down to fit that
695
+ // row. One that stands alone keeps the standard size, so it is wider and
696
+ // reports that it was not clamped.
697
+ let clamped =
698
+ render_math_png(r"x^2".into(), 16.0 / 12.0, "000000".into(), "ffffff".into(), false, 8.0, 16.0, false, false)
699
+ .expect("formula renders");
700
+ let standard =
701
+ render_math_png(r"x^2".into(), 16.0 / 12.0, "000000".into(), "ffffff".into(), false, 8.0, 16.0, false, true)
702
+ .expect("formula renders");
703
+ assert!(clamped.clamped, "the shared variant is scaled into the text row");
704
+ assert!(!standard.clamped, "the standard variant is never clamped");
705
+ assert!(
706
+ standard.height > clamped.height,
707
+ "standard {} clamped {}",
708
+ standard.height,
709
+ clamped.height
710
+ );
711
+ }
712
+
713
+ #[test]
714
+ fn trims_display_math_whitespace_before_layout() {
715
+ // `$$` display blocks hand the renderer the raw inner text including the
716
+ // delimiter-adjacent newlines. Left alone, a multiline expression inside the
717
+ // sentinel template defeats the widest-gap split and the sentinel leaks next
718
+ // to the formula; trimming it must produce a clean single-expression raster.
719
+ let rendered = render_math_png(
720
+ "\n\\int_0^\\infty e^{-x^2}\\,dx = \\frac{\\sqrt{\\pi}}{2}\n".into(),
721
+ 16.0 / 12.0,
722
+ "000000".into(),
723
+ "ffffff".into(),
724
+ true,
725
+ 8.0,
726
+ 16.0,
727
+ false,
728
+ false,
729
+ )
730
+ .expect("formula renders");
731
+ assert!(rendered.width > 0);
732
+ }
733
+
734
+ #[test]
735
+ fn widest_gap_split_separates_sentinel_from_formula() {
736
+ let math = mitex::convert_math(r"\frac{a}{b}", None).expect("mitex conversion");
737
+ let source = math_source(&math, "000000", "ffffff", SENTINEL_HEX, false);
738
+ let page = super::compile_page(source).expect("compile");
739
+ let image = super::render_page(&page, 16.0 / 12.0);
740
+ let regions = measure_regions(&image, hex_rgb("ffffff"), sentinel_gap_px(16.0 / 12.0));
741
+ assert!(matches!(regions, Regions { math: Some(_), sentinel: Some(_), .. }), "both ink regions found");
742
+ assert!(regions.math.is_some());
743
+ }
744
+
745
+ // Every LaTeX command the converter can emit has to reach Typst as a
746
+ // rendered formula. A command without a handler in the scope fails to
747
+ // compile and silently falls back to the LaTeX source.
748
+ const COVERAGE: &[&str] = &[
749
+ r"\frac{a}{b}",
750
+ r"\dfrac{a}{b}",
751
+ r"\tfrac{a}{b}",
752
+ r"\sqrt[3]{x}",
753
+ r"\sqrt{x}",
754
+ r"\displaystyle\frac{1}{2}",
755
+ r"\text{hello}",
756
+ r"\operatorname{sgn}(x)",
757
+ r"\binom{n}{k}",
758
+ r"\mathbb{R}",
759
+ r"\int_0^1 f",
760
+ r"\iint f",
761
+ r"\oint f",
762
+ r"\left| x \right|",
763
+ r"\begin{pmatrix} a & b \end{pmatrix}",
764
+ r"\begin{bmatrix} a \end{bmatrix}",
765
+ r"\begin{matrix} a \end{matrix}",
766
+ r"\begin{cases} a \end{cases}",
767
+ r"\begin{aligned} a &= b \end{aligned}",
768
+ r"\begin{array}{cc} a & b \end{array}",
769
+ r"\underbrace{a}_{b}",
770
+ r"\overbrace{a}^{b}",
771
+ r"\vec{v}",
772
+ r"\hat{x}",
773
+ r"\overline{a}",
774
+ r"\underline{a}",
775
+ r"\boxed{a}",
776
+ r"\phantom{a}",
777
+ r"\xrightarrow{A}",
778
+ r"\middle|",
779
+ r"\big|",
780
+ r"\lim_{x \to 0}",
781
+ r"\max_{x}",
782
+ r"\substack{a \\ b}",
783
+ r"\begin{equation} a \end{equation}",
784
+ r"\sum_{i=1}^{n} i",
785
+ r"\prod_{i} x",
786
+ r"\lim_{x \to 0} f(x)",
787
+ r"\int_a^b f",
788
+ ];
789
+
790
+ #[test]
791
+ fn geometry_stays_stable() {
792
+ // The raster box is part of the contract with the terminal: it decides
793
+ // how many cells the image occupies and where the baseline sits.
794
+ let cases: &[(&str, u32, u32)] = &[
795
+ ("x", 2, 1),
796
+ ("x^2", 2, 1),
797
+ (r"\frac{a}{b}", 1, 2),
798
+ (r"\frac{n(n+1)(2n+1)}{6}", 10, 2),
799
+ (r"\sum_{i=1}^{n} i^2", 7, 2),
800
+ (r"\int_0^\infty e^{-x^2}\,dx", 10, 2),
801
+ (r"\sqrt{\pi}", 3, 1),
802
+ (r"\left( \frac{1}{2} \right]", 3, 2),
803
+ (r"P(A \mid B) = \frac{P(B \mid A) P(A)}{P(B)}", 20, 2),
804
+ (r"v = \sqrt{GM\left(\frac{2}{r} - \frac{1}{a}\right)}", 17, 2),
805
+ ("E = mc^2", 8, 1),
806
+ (r"\lim_{x \to 0} \frac{\sin x}{x} = 1", 14, 2),
807
+ (r"\theta = 28.4°", 8, 1),
808
+ (r"\varepsilon = -\frac{GM}{2a}", 9, 2),
809
+ ];
810
+ for (formula, columns, rows) in cases {
811
+ let rendered = render_math_png(
812
+ (*formula).into(),
813
+ 16.0 / 12.0,
814
+ "000000".into(),
815
+ "ffffff".into(),
816
+ false,
817
+ 8.0,
818
+ 16.0,
819
+ false,
820
+ false,
821
+ )
822
+ .unwrap_or_else(|error| panic!("{formula} renders: {error}"));
823
+ assert_eq!(rendered.width / 8, *columns, "{formula} columns");
824
+ assert_eq!(rendered.height / 16, *rows, "{formula} rows");
825
+ assert!(
826
+ rendered.baseline > 0.0 && rendered.baseline <= rendered.height as f64,
827
+ "{formula} baseline {}",
828
+ rendered.baseline
829
+ );
830
+ }
831
+ }
832
+
833
+ #[test]
834
+ fn display_math_uses_the_display_style() {
835
+ // In text style a large operator carries its limits beside it, so the
836
+ // formula is as tall as a single line. In display style the limits move
837
+ // above and below the operator, which makes the ink taller.
838
+ let measure = |display: bool| {
839
+ let math = mitex::convert_math(r"\sum_{i=1}^{n} i^2", None).expect("mitex conversion");
840
+ let source = math_source(&math, "000000", "ffffff", SENTINEL_HEX, display);
841
+ let page = super::compile_page(source).expect("compile");
842
+ let image = super::render_page(&page, 16.0 / 12.0);
843
+ let regions = measure_regions(&image, hex_rgb("ffffff"), sentinel_gap_px(16.0 / 12.0));
844
+ let ink = regions.math.expect("formula ink");
845
+ (ink.bottom - ink.top) as f64
846
+ };
847
+
848
+ let inline = measure(false);
849
+ let display = measure(true);
850
+ assert!(display > inline * 1.25, "display ink {display} should be much taller than inline {inline}");
851
+ }
852
+
853
+ #[test]
854
+ fn every_converted_command_reaches_typst() {
855
+ let mut failures = Vec::new();
856
+ for formula in COVERAGE {
857
+ let rendered = render_math_png(
858
+ (*formula).into(),
859
+ 16.0 / 12.0,
860
+ "000000".into(),
861
+ "ffffff".into(),
862
+ false,
863
+ 8.0,
864
+ 16.0,
865
+ false,
866
+ false,
867
+ );
868
+ if let Err(error) = rendered {
869
+ // Keep the first diagnostic message: it names the missing helper.
870
+ let message = error
871
+ .split("message: \"")
872
+ .nth(1)
873
+ .and_then(|rest| rest.split('"').next())
874
+ .unwrap_or(&error);
875
+ failures.push(format!("{formula}: {message}"));
876
+ }
877
+ }
878
+ assert!(failures.is_empty(), "commands that failed to render:\n{}", failures.join("\n"));
879
+ }
880
+ }