@vutex/vutex 0.0.14 → 0.0.16

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/src/runtime.ts CHANGED
@@ -1,4 +1,8 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname, extname, resolve } from "node:path";
3
+
1
4
  import type {
5
+ ActivatedPackage,
2
6
  CreateVuTeXOptions,
3
7
  FormulaRender,
4
8
  FormulaRenderOptions,
@@ -6,12 +10,22 @@ import type {
6
10
  InspectDocumentResult,
7
11
  RenderDocumentOptions,
8
12
  RenderDocumentResult,
13
+ TemplateGeometry,
9
14
  VuTeXBindings,
10
15
  VuTeXDiagnostic,
11
16
  VuTeXRuntime,
12
17
  } from "./types.js";
13
18
  import { PackageManager } from "./packages/manager.js";
14
19
  import { readManifest } from "./packages/manifest.js";
20
+ import { readMathFontFace } from "./packages/math_font.js";
21
+ import { readTemplateGeometry } from "./packages/template.js";
22
+
23
+ type ParsedPayload = {
24
+ ok: boolean;
25
+ format: string;
26
+ graph?: unknown;
27
+ diagnostics?: VuTeXDiagnostic[];
28
+ };
15
29
 
16
30
  function encodeFormulaOptions(options?: FormulaRenderOptions): string | undefined {
17
31
  if (!options) {
@@ -32,58 +46,394 @@ function normalizeOutputFormat(value: string | undefined): string {
32
46
  return typeof value === "string" && value.length > 0 ? value : "svg";
33
47
  }
34
48
 
35
- function diagnosticsFromReport(reportJson: string): VuTeXDiagnostic[] {
36
- try {
37
- const report = JSON.parse(reportJson) as {
38
- diagnostics?: VuTeXDiagnostic[];
39
- };
40
- return report.diagnostics ?? [];
41
- } catch {
42
- return [];
49
+ function formatFromPath(inputPath: string): string {
50
+ const extension = extname(inputPath).slice(1).toLowerCase();
51
+ switch (extension) {
52
+ case "md":
53
+ case "markdown":
54
+ return "markdown";
55
+ case "nd":
56
+ case "notedown":
57
+ return "notedown";
58
+ case "html":
59
+ case "htm":
60
+ return "html";
61
+ case "tex":
62
+ case "latex":
63
+ case "ltx":
64
+ return "tex";
65
+ case "typ":
66
+ case "typst":
67
+ return "typst";
68
+ case "docx":
69
+ return "docx";
70
+ case "epub":
71
+ return "epub";
72
+ default:
73
+ return "unknown";
43
74
  }
44
75
  }
45
76
 
77
+ function diagnostic(code: string, message: string, stage: string, path?: string): VuTeXDiagnostic {
78
+ return { code, severity: "error", message, stage, path };
79
+ }
80
+
81
+ function byteLength(value: string): number {
82
+ return new TextEncoder().encode(value).length;
83
+ }
84
+
85
+ function readBytes(inputPath: string): Promise<Uint8Array> {
86
+ return readFile(resolve(inputPath)).then((buffer) => new Uint8Array(buffer));
87
+ }
88
+
89
+ /** Theme presets the native scene knows how to lay out. */
90
+ const NATIVE_THEMES = new Set(["baseline", "traditional", "editorial", "classic"]);
91
+
92
+ /**
93
+ * Built-in math packages that install one OpenType MATH family.
94
+ *
95
+ * The native binding owns the same mapping, so an id outside this set fails
96
+ * closed instead of painting a face the document never declared.
97
+ */
98
+ const NATIVE_MATH_FONTS = new Set(["latin-modern-math", "xits-math"]);
99
+
100
+ function activatedSlot(activation: ActivatedPackage[], slot: string): ActivatedPackage | undefined {
101
+ return activation.find((item) => item.slot === slot);
102
+ }
103
+
104
+ /**
105
+ * Compose the native primitives into one render call.
106
+ *
107
+ * The runtime owns orchestration only: it reads the input bytes, selects the
108
+ * format, chains parse -> lower -> build -> emit, and assembles the report.
109
+ * Font embedding happens inside the emission primitives, so this file never
110
+ * names a face.
111
+ */
46
112
  export function createVuTeXRuntime(
47
113
  bindings: VuTeXBindings,
48
114
  options: CreateVuTeXOptions = {},
49
115
  ): VuTeXRuntime {
50
116
  const projectRoot = options.projectRoot ?? process.cwd();
51
117
  const packages = new PackageManager(projectRoot);
118
+ const defaults = options.defaults ?? {};
52
119
 
53
- return {
54
- version: () => bindings.vutexVersion(),
55
- async render(inputPath, renderOptions = {}): Promise<RenderDocumentResult> {
56
- const manifest = await readManifest(projectRoot);
57
- const payload = JSON.stringify({
58
- output: normalizeOutputFormat(renderOptions.output ?? manifest?.document?.output),
59
- profile: renderOptions.profile ?? manifest?.profile?.name,
60
- input: renderOptions.input ?? manifest?.document?.input,
61
- });
62
- const response = bindings.renderDocument(inputPath, payload);
120
+ async function render(inputPath: string, renderOptions: RenderDocumentOptions = {}): Promise<RenderDocumentResult> {
121
+ const manifest = await readManifest(projectRoot);
122
+ const outputFormat = normalizeOutputFormat(renderOptions.output ?? manifest?.document?.output);
123
+ const inputFormat = renderOptions.input ?? manifest?.document?.input ?? formatFromPath(inputPath);
124
+ const explicitTheme = typeof renderOptions.theme === "string" && renderOptions.theme.length > 0
125
+ ? renderOptions.theme
126
+ : undefined;
127
+ const inputs = JSON.stringify([{ path: inputPath, format: inputFormat }]);
128
+ const activation = await packages.activation();
129
+ const activationErrors = activation.diagnostics.filter((item) => item.severity === "error");
130
+ if (activationErrors.length > 0) {
63
131
  return {
64
- exitCode: response.exitCode,
65
- outputFormat: response.outputFormat,
66
- svg: response.svg,
67
- html: response.html,
68
- pdf: response.pdf,
69
- reportJson: response.reportJson,
70
- diagnostics: diagnosticsFromReport(response.reportJson),
132
+ outputFormat,
133
+ reportJson: bindings.assembleReport("render", inputs, JSON.stringify(activation.diagnostics)),
134
+ diagnostics: activation.diagnostics,
135
+ packages: [],
71
136
  };
72
- },
73
- async inspect(inputPath, _options: InspectDocumentOptions = {}): Promise<InspectDocumentResult> {
74
- const response = bindings.inspectDocument(inputPath);
137
+ }
138
+
139
+ const fail = (item: VuTeXDiagnostic): RenderDocumentResult => {
140
+ const items = [...activation.diagnostics, item];
75
141
  return {
76
- inputFormat: response.inputFormat,
77
- path: response.path,
78
- reportJson: response.reportJson,
79
- diagnostics: diagnosticsFromReport(response.reportJson),
142
+ outputFormat,
143
+ reportJson: bindings.assembleReport("render", inputs, JSON.stringify(items)),
144
+ diagnostics: items,
145
+ packages: activation.packages,
80
146
  };
147
+ };
148
+
149
+ // A declared slot must change what the renderer does. `theme` and
150
+ // `math-font` have native consumers, so an unusable value fails the
151
+ // render instead of silently painting the built-in baseline.
152
+ const themePackage = activatedSlot(activation.packages, "theme");
153
+ const theme = explicitTheme ?? themePackage?.name ?? defaults.theme;
154
+ if (theme !== undefined && !NATIVE_THEMES.has(theme)) {
155
+ return fail(
156
+ diagnostic(
157
+ "VUTEX_PACKAGE_THEME_UNSUPPORTED",
158
+ `theme package has no native preset: ${theme}`,
159
+ "package",
160
+ inputPath,
161
+ ),
162
+ );
163
+ }
164
+
165
+ const mathPackage = activatedSlot(activation.packages, "math-font");
166
+ const bundledMath = mathPackage !== undefined
167
+ && mathPackage.source === "builtin"
168
+ && NATIVE_MATH_FONTS.has(mathPackage.name);
169
+ if (mathPackage !== undefined && !bundledMath) {
170
+ if (mathPackage.path === undefined) {
171
+ return fail(
172
+ diagnostic(
173
+ "VUTEX_PACKAGE_MATH_FONT_UNSUPPORTED",
174
+ `math font package has no resource directory: ${mathPackage.name}`,
175
+ "package",
176
+ inputPath,
177
+ ),
178
+ );
179
+ }
180
+ // Reflowed html keeps formulas as markup, so the warning below is
181
+ // the whole record for that format and the face is never installed.
182
+ if (outputFormat !== "html") {
183
+ const face = await readMathFontFace(mathPackage.path);
184
+ if (face.diagnostic !== undefined) {
185
+ return fail(face.diagnostic);
186
+ }
187
+ if (face.file === undefined) {
188
+ return fail(
189
+ diagnostic(
190
+ "VUTEX_PACKAGE_MATH_FONT_INVALID",
191
+ `math font package ${mathPackage.name} names no font file`,
192
+ "package",
193
+ inputPath,
194
+ ),
195
+ );
196
+ }
197
+ let faceBytes: Uint8Array;
198
+ try {
199
+ faceBytes = await readBytes(face.file);
200
+ } catch {
201
+ return fail(
202
+ diagnostic(
203
+ "VUTEX_PACKAGE_MATH_FONT_INVALID",
204
+ `math font package ${mathPackage.name} has no readable font file`,
205
+ "package",
206
+ inputPath,
207
+ ),
208
+ );
209
+ }
210
+ if (
211
+ face.texParameters !== undefined
212
+ && !bindings.mathFontTexParameterFamilies().includes(face.texParameters)
213
+ ) {
214
+ return fail(
215
+ diagnostic(
216
+ "VUTEX_PACKAGE_MATH_FONT_SEMANTICS_UNKNOWN",
217
+ `math font package ${mathPackage.name} declares unknown TeX parameter family: ${face.texParameters}`,
218
+ "package",
219
+ inputPath,
220
+ ),
221
+ );
222
+ }
223
+ try {
224
+ bindings.installMathFontFace(
225
+ mathPackage.name,
226
+ mathPackage.artifactHash ?? "",
227
+ faceBytes,
228
+ face.texParameters,
229
+ );
230
+ } catch (failure) {
231
+ return fail(
232
+ diagnostic(
233
+ "VUTEX_PACKAGE_MATH_FONT_INVALID",
234
+ failure instanceof Error ? failure.message : String(failure),
235
+ "package",
236
+ inputPath,
237
+ ),
238
+ );
239
+ }
240
+ }
241
+ }
242
+ const mathFont = mathPackage?.name ?? defaults.mathFont;
243
+
244
+ let bytes: Uint8Array;
245
+ try {
246
+ bytes = await readBytes(inputPath);
247
+ } catch {
248
+ return fail(diagnostic("VUTEX_INPUT_NOT_FOUND", `input path does not exist: ${inputPath}`, "read", inputPath));
249
+ }
250
+
251
+ let parsed: ParsedPayload;
252
+ try {
253
+ parsed = JSON.parse(bindings.parseDocument(inputPath, inputFormat, bytes)) as ParsedPayload;
254
+ } catch (failure) {
255
+ const message = failure instanceof Error ? failure.message : String(failure);
256
+ return fail(diagnostic("VUTEX_IMPORT_FAILED", message, "import", inputPath));
257
+ }
258
+ if (!parsed.ok || parsed.graph === undefined) {
259
+ const items = parsed.diagnostics ?? [];
260
+ const first = items[0] ?? diagnostic("VUTEX_IMPORT_FAILED", "import produced no graph", "import", inputPath);
261
+ return {
262
+ outputFormat,
263
+ reportJson: bindings.assembleReport("render", inputs, JSON.stringify(items)),
264
+ diagnostics: items.length > 0 ? items : [first],
265
+ packages: activation.packages,
266
+ };
267
+ }
268
+
269
+ if (!["svg", "html", "snapshot", "pdf"].includes(outputFormat)) {
270
+ return fail(
271
+ diagnostic("VUTEX_OUTPUT_FORMAT_UNSUPPORTED", `unsupported output format: ${outputFormat}`, "render", inputPath),
272
+ );
273
+ }
274
+
275
+ const graphJson = JSON.stringify(parsed.graph);
276
+ const diagnostics: VuTeXDiagnostic[] = [
277
+ ...(parsed.diagnostics ?? []),
278
+ ...activation.diagnostics,
279
+ ];
280
+ const templatePackage = activatedSlot(activation.packages, "template");
281
+ let templateApplied: string | undefined;
282
+ let templateGeometry: TemplateGeometry | undefined;
283
+ if (templatePackage !== undefined) {
284
+ // Reflowed html has no fixed pages, so a page template has nothing
285
+ // to act on and only the warning records the gap.
286
+ if (outputFormat === "html") {
287
+ diagnostics.push({
288
+ code: "VUTEX_PACKAGE_TEMPLATE_INACTIVE",
289
+ severity: "warning",
290
+ message: `reflowed html has no fixed pages, so template ${templatePackage.name} does not apply`,
291
+ stage: "package",
292
+ args: { slot: "template", package: templatePackage.name },
293
+ });
294
+ } else {
295
+ if (templatePackage.path === undefined) {
296
+ return fail(
297
+ diagnostic(
298
+ "VUTEX_PACKAGE_TEMPLATE_UNSUPPORTED",
299
+ `template package has no resource directory: ${templatePackage.name}`,
300
+ "package",
301
+ inputPath,
302
+ ),
303
+ );
304
+ }
305
+ const read = await readTemplateGeometry(templatePackage.path);
306
+ if (read.diagnostic !== undefined) {
307
+ return fail(read.diagnostic);
308
+ }
309
+ templateGeometry = read.geometry;
310
+ templateApplied = templatePackage.name;
311
+ }
312
+ }
313
+ let mathFontApplied: string | undefined;
314
+ try {
315
+ const lowered = bindings.lowerDocument(graphJson, inputPath);
316
+ const footnotes = JSON.parse(lowered.footnotesJson()) as Record<string, number>;
317
+ diagnostics.push(...(JSON.parse(lowered.diagnosticsJson()) as VuTeXDiagnostic[]));
318
+
319
+ let svg: string | undefined;
320
+ let html: string | undefined;
321
+ let pdf: Uint8Array | undefined;
322
+ let outputFormatLabel = outputFormat;
323
+ let pages: number | undefined;
324
+ let items: number | undefined;
325
+
326
+ if (outputFormat === "html") {
327
+ const flow = bindings.buildFlow(lowered.graphJson(), theme);
328
+ html = flow.html();
329
+ if (mathPackage !== undefined && mathFont !== undefined) {
330
+ diagnostics.push({
331
+ code: "VUTEX_PACKAGE_MATH_FONT_INACTIVE",
332
+ severity: "warning",
333
+ message: `reflowed html keeps formulas as markup, so ${mathFont} does not apply`,
334
+ stage: "package",
335
+ args: { slot: "math-font", package: mathFont },
336
+ });
337
+ }
338
+ } else {
339
+ const scene = bindings.buildDisplay(lowered, theme, mathFont, templateGeometry);
340
+ mathFontApplied = scene.mathFontId();
341
+ pages = scene.pageCount();
342
+ items = scene.itemCount();
343
+ diagnostics.push(...(JSON.parse(scene.diagnosticsJson()) as VuTeXDiagnostic[]));
344
+ if (outputFormat === "svg") {
345
+ svg = scene.svg();
346
+ } else if (outputFormat === "snapshot") {
347
+ html = scene.snapshotHtml();
348
+ } else {
349
+ const rendered = scene.pdf(JSON.stringify({
350
+ base_dir: renderOptions.baseDir ?? dirname(resolve(inputPath)),
351
+ resources: renderOptions.resources,
352
+ }));
353
+ pdf = new Uint8Array(rendered.pdf);
354
+ diagnostics.push(...(JSON.parse(rendered.diagnosticsJson) as VuTeXDiagnostic[]));
355
+ }
356
+ }
357
+
358
+ const bodyBytes = outputFormat === "pdf" ? (pdf?.byteLength ?? 0) : byteLength(svg ?? html ?? "");
359
+ const reportJson = bindings.assembleReport(
360
+ "render",
361
+ inputs,
362
+ JSON.stringify(diagnostics),
363
+ JSON.stringify({
364
+ format: outputFormatLabel,
365
+ bytes: bodyBytes,
366
+ ...(pages === undefined ? {} : { pages, items }),
367
+ }),
368
+ JSON.stringify({
369
+ footnotes,
370
+ theme: theme ?? "baseline",
371
+ mathFont: mathFontApplied,
372
+ template: templateApplied,
373
+ packages: activation.packages,
374
+ }),
375
+ );
376
+
377
+ return {
378
+ outputFormat: outputFormatLabel,
379
+ svg,
380
+ html,
381
+ pdf,
382
+ reportJson,
383
+ diagnostics,
384
+ packages: activation.packages,
385
+ };
386
+ } catch (failure) {
387
+ const message = failure instanceof Error ? failure.message : String(failure);
388
+ const item = diagnostic("VUTEX_RENDER_FAILED", message, "render", inputPath);
389
+ diagnostics.push(item);
390
+ return {
391
+ outputFormat,
392
+ reportJson: bindings.assembleReport("render", inputs, JSON.stringify(diagnostics)),
393
+ diagnostics,
394
+ packages: activation.packages,
395
+ };
396
+ }
397
+ }
398
+
399
+ async function inspect(inputPath: string, _options: InspectDocumentOptions = {}): Promise<InspectDocumentResult> {
400
+ const inputFormat = formatFromPath(inputPath);
401
+ const inputs = JSON.stringify([{ path: inputPath, format: inputFormat }]);
402
+ let bytes: Uint8Array;
403
+ try {
404
+ bytes = await readBytes(inputPath);
405
+ } catch {
406
+ const item = diagnostic("VUTEX_INPUT_NOT_FOUND", `input path does not exist: ${inputPath}`, "inspect", inputPath);
407
+ return {
408
+ inputFormat,
409
+ path: inputPath,
410
+ reportJson: bindings.assembleReport("inspect", inputs, JSON.stringify([item])),
411
+ diagnostics: [item],
412
+ };
413
+ }
414
+
415
+ const parsed = JSON.parse(bindings.parseDocument(inputPath, inputFormat, bytes)) as ParsedPayload;
416
+ const diagnostics = parsed.diagnostics ?? [];
417
+ const extra = parsed.ok && parsed.graph !== undefined
418
+ ? bindings.summarizeDocument(JSON.stringify(parsed.graph))
419
+ : undefined;
420
+ return {
421
+ inputFormat: parsed.format,
422
+ path: inputPath,
423
+ reportJson: bindings.assembleReport("inspect", inputs, JSON.stringify(diagnostics), undefined, extra),
424
+ diagnostics,
425
+ };
426
+ }
427
+
428
+ return {
429
+ version: () => bindings.vutexVersion(),
430
+ render,
431
+ inspect,
432
+ measureFormula(source, formulaOptions) {
433
+ return decodeFormulaRender(bindings.measureFormula(source, encodeFormulaOptions(formulaOptions)));
81
434
  },
82
- measureFormula(source, options) {
83
- return decodeFormulaRender(bindings.measureFormula(source, encodeFormulaOptions(options)));
84
- },
85
- renderFormula(source, options) {
86
- return decodeFormulaRender(bindings.renderFormula(source, encodeFormulaOptions(options)));
435
+ renderFormula(source, formulaOptions) {
436
+ return decodeFormulaRender(bindings.renderFormula(source, encodeFormulaOptions(formulaOptions)));
87
437
  },
88
438
  packages: {
89
439
  list: () => packages.list(),