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