@publisle/research 0.1.1 → 0.3.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/src/node.ts CHANGED
@@ -13,14 +13,67 @@ import {
13
13
  writeFile,
14
14
  } from "node:fs/promises";
15
15
  import { homedir, tmpdir } from "node:os";
16
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
16
+ import {
17
+ basename,
18
+ dirname,
19
+ isAbsolute,
20
+ join,
21
+ relative,
22
+ resolve,
23
+ } from "node:path";
17
24
  import { promisify } from "node:util";
25
+ import {
26
+ compileNativePdf,
27
+ type NativeCompileOptions,
28
+ type NativeCompileResult,
29
+ } from "./native-node.ts";
30
+ export {
31
+ compileNativePdf,
32
+ type NativeCompileOptions,
33
+ type NativeCompileResult,
34
+ };
18
35
  import type { ResearchLoss } from "./article.ts";
19
- import type { Block, BlockType, Document } from "@publisle/schema";
36
+ import {
37
+ parseDocument,
38
+ type Block,
39
+ type BlockType,
40
+ type Document,
41
+ } from "@publisle/schema";
20
42
  import { createLatexPackage, type LatexPackageOptions } from "./package.ts";
21
43
  import type { LatexPackage } from "./package.ts";
22
44
 
23
45
  const execute = promisify(execFile);
46
+
47
+ /** One kpsewhich invocation resolves every requirement at once. kpsewhich
48
+ * exits non-zero when a file is missing but still lists the found paths on
49
+ * stdout, so partial results survive; output basenames match the arguments. */
50
+ async function kpsewhichAll(
51
+ names: readonly string[],
52
+ ): Promise<Map<string, string>> {
53
+ const paths = new Map<string, string>();
54
+ if (!names.length) return paths;
55
+ let stdout: string;
56
+ try {
57
+ stdout = (await execute("kpsewhich", [...names], { timeout: 10_000 }))
58
+ .stdout;
59
+ } catch (error) {
60
+ stdout = (error as { stdout?: string }).stdout ?? "";
61
+ }
62
+ for (const line of stdout.split("\n")) {
63
+ const path = line.trim();
64
+ if (path) paths.set(basename(path), path);
65
+ }
66
+ return paths;
67
+ }
68
+
69
+ /** In-process probe caches. Native TeX installations do not change while the
70
+ * process runs, so successful probes are reused across compilations. Failed
71
+ * probes are retried so an installation completed mid-process is picked up. */
72
+ const taggingFormatCache = new Map<string, string | undefined>();
73
+ const doctorCache = new Map<string, CompilerStatus>();
74
+ const engineVersionCache = new Map<string, string>();
75
+ const dependencyDigestCache = new Map<string, string>();
76
+
24
77
  export interface CompileLatexOptions {
25
78
  /** Base directory for local manuscript assets. */
26
79
  readonly sourceDirectory: string;
@@ -118,31 +171,35 @@ export async function compileLatexPackage(
118
171
  ...source.resources,
119
172
  };
120
173
  try {
121
- for (const [name, value] of Object.entries({
122
- ...source.files,
123
- ...source.resources,
124
- })) {
125
- const path = packagePath(directory, name);
126
- await mkdir(dirname(path), { recursive: true });
127
- await writeFile(path, value, { flag: "wx" });
128
- }
174
+ await Promise.all(
175
+ Object.entries({
176
+ ...source.files,
177
+ ...source.resources,
178
+ }).map(async ([name, value]) => {
179
+ const path = packagePath(directory, name);
180
+ await mkdir(dirname(path), { recursive: true });
181
+ await writeFile(path, value, { flag: "wx" });
182
+ }),
183
+ );
129
184
  const base = await realpath(options.sourceDirectory);
130
- for (const asset of source.assets) {
131
- const path = await realpath(resolve(base, asset.source));
132
- const local = relative(base, path);
133
- if (
134
- isAbsolute(local) ||
135
- local === ".." ||
136
- local.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)
137
- )
138
- throw new Error(
139
- `Figure asset escapes manuscript directory: ${asset.source}`,
140
- );
141
- const destination = packagePath(directory, asset.destination);
142
- await mkdir(dirname(destination), { recursive: true });
143
- await copyFile(path, destination, constants.COPYFILE_EXCL);
144
- files[asset.destination] = new Uint8Array(await readFile(destination));
145
- }
185
+ await Promise.all(
186
+ source.assets.map(async (asset) => {
187
+ const path = await realpath(resolve(base, asset.source));
188
+ const local = relative(base, path);
189
+ if (
190
+ isAbsolute(local) ||
191
+ local === ".." ||
192
+ local.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)
193
+ )
194
+ throw new Error(
195
+ `Figure asset escapes manuscript directory: ${asset.source}`,
196
+ );
197
+ const destination = packagePath(directory, asset.destination);
198
+ await mkdir(dirname(destination), { recursive: true });
199
+ await copyFile(path, destination, constants.COPYFILE_EXCL);
200
+ files[asset.destination] = new Uint8Array(await readFile(destination));
201
+ }),
202
+ );
146
203
  let stdout = "";
147
204
  const containerName = `publisle-${randomUUID()}`;
148
205
  try {
@@ -238,43 +295,65 @@ export async function compileLatexPackage(
238
295
  files["build.log"] = stdout;
239
296
  const engineVersion =
240
297
  backend.kind === "native"
241
- ? (
242
- await execute(source.engine, ["--version"], { timeout: 5000 })
243
- ).stdout.split("\n")[0]
298
+ ? await (async () => {
299
+ const cached = engineVersionCache.get(source.engine);
300
+ if (cached) return cached;
301
+ const version = (
302
+ await execute(source.engine, ["--version"], { timeout: 5000 })
303
+ ).stdout.split("\n")[0];
304
+ engineVersionCache.set(source.engine, version ?? "");
305
+ return version;
306
+ })()
244
307
  : backend.image;
245
308
  const fontVersions: Record<string, string> = {};
246
309
  if (backend.kind === "native")
247
- for (const [script, font] of Object.entries(source.fonts))
248
- fontVersions[script] = (
249
- await execute(
250
- "fc-match",
251
- ["-f", "%{family}: %{fontversion}\n", font],
252
- { timeout: 5000 },
253
- )
254
- ).stdout.trim();
310
+ await Promise.all(
311
+ Object.entries(source.fonts).map(async ([script, font]) => {
312
+ fontVersions[script] = (
313
+ await execute(
314
+ "fc-match",
315
+ ["-f", "%{family}: %{fontversion}\n", font],
316
+ { timeout: 5000 },
317
+ )
318
+ ).stdout.trim();
319
+ }),
320
+ );
255
321
  const dependencyDigests: Record<string, string> = {};
256
322
  const dependencyVersions: Record<string, string> = {};
257
- if (backend.kind === "native")
258
- for (const name of source.requirements) {
259
- const provided = source.resources?.[name] ?? source.files[name];
260
- const bytes =
261
- provided === undefined
262
- ? await readFile(
263
- (
264
- await execute("kpsewhich", [name], { timeout: 5000 })
265
- ).stdout.trim(),
266
- )
267
- : typeof provided === "string"
268
- ? Buffer.from(provided)
269
- : provided;
270
- dependencyDigests[name] =
271
- "sha256:" + createHash("sha256").update(bytes).digest("hex");
272
- const version =
273
- /\\Provides(?:Package|Class)\{[^}]+\}\s*\[([^\]]+)\]/u.exec(
274
- new TextDecoder().decode(bytes),
275
- )?.[1];
276
- if (version) dependencyVersions[name] = version;
277
- }
323
+ if (backend.kind === "native") {
324
+ const resolvedPaths = await kpsewhichAll(
325
+ source.requirements.filter(
326
+ (name) =>
327
+ source.resources?.[name] === undefined &&
328
+ source.files[name] === undefined,
329
+ ),
330
+ );
331
+ await Promise.all(
332
+ source.requirements.map(async (name) => {
333
+ const provided = source.resources?.[name] ?? source.files[name];
334
+ const path = resolvedPaths.get(name);
335
+ const bytes =
336
+ provided === undefined
337
+ ? await readFile(path ?? "")
338
+ : typeof provided === "string"
339
+ ? Buffer.from(provided)
340
+ : provided;
341
+ const digest = dependencyDigestCache.get(path ?? name);
342
+ if (digest) {
343
+ dependencyDigests[name] = digest;
344
+ } else {
345
+ dependencyDigests[name] =
346
+ "sha256:" + createHash("sha256").update(bytes).digest("hex");
347
+ dependencyDigestCache.set(path ?? name, dependencyDigests[name]);
348
+ }
349
+ const version =
350
+ /\\Provides(?:Package|Class)\{[^}]+\}\s*\[([^\]]+)\]/u.exec(
351
+ new TextDecoder().decode(bytes),
352
+ )?.[1];
353
+ if (version) dependencyVersions[name] = version;
354
+ }),
355
+ );
356
+ }
278
357
  files["manifest.json"] =
279
358
  JSON.stringify(
280
359
  {
@@ -333,6 +412,8 @@ export interface CompilerStatus {
333
412
  readonly latexFormat?: string;
334
413
  }
335
414
  async function taggingFormat(image?: string): Promise<string | undefined> {
415
+ const key = image ?? "native";
416
+ if (taggingFormatCache.has(key)) return taggingFormatCache.get(key);
336
417
  const directory = await mkdtemp(join(tmpdir(), "publisle-format-"));
337
418
  const containerName = `publisle-format-${randomUUID()}`;
338
419
  try {
@@ -379,11 +460,16 @@ async function taggingFormat(image?: string): Promise<string | undefined> {
379
460
  openin_any: "p",
380
461
  openout_any: "p",
381
462
  },
382
- timeout: 15_000,
463
+ // Cold container startup can initialize LuaLaTeX caches.
464
+ timeout: image ? 60_000 : 15_000,
383
465
  maxBuffer: 1024 * 1024,
384
466
  },
385
467
  );
386
- return /PUBLISLE-FORMAT:(\d{4}-\d{2}-\d{2})/u.exec(output.stdout)?.[1];
468
+ const detected = /PUBLISLE-FORMAT:(\d{4}-\d{2}-\d{2})/u.exec(
469
+ output.stdout,
470
+ )?.[1];
471
+ taggingFormatCache.set(key, detected);
472
+ return detected;
387
473
  } finally {
388
474
  if (image)
389
475
  await execute("docker", ["rm", "--force", containerName], {
@@ -395,6 +481,18 @@ async function taggingFormat(image?: string): Promise<string | undefined> {
395
481
  export async function doctorCompiler(
396
482
  source: LatexPackage,
397
483
  ): Promise<CompilerStatus> {
484
+ const cacheKey = [
485
+ source.engine,
486
+ source.pdfStandard ?? "",
487
+ Object.values(source.fonts).join(","),
488
+ ...source.requirements.map((name) =>
489
+ source.files[name] === undefined && source.resources?.[name] === undefined
490
+ ? name
491
+ : `${name}:bundled`,
492
+ ),
493
+ ].join("|");
494
+ const cached = doctorCache.get(cacheKey);
495
+ if (cached) return cached;
398
496
  const missing: string[] = [];
399
497
  let latexFormat: string | undefined;
400
498
  if (source.engine === "lualatex") {
@@ -404,44 +502,45 @@ export async function doctorCompiler(
404
502
  else if (source.pdfStandard && latexFormat < "2025-11-01")
405
503
  missing.push("LaTeX format >= 2025-11-01 (PDF/UA tagging)");
406
504
  }
407
- for (const command of ["latexmk", source.engine, "bibtex", "kpsewhich"])
408
- try {
409
- await execute(command, ["--version"], { timeout: 5000 });
410
- } catch {
411
- missing.push(command);
412
- }
413
- for (const dependency of source.requirements) {
414
- if (
415
- source.files[dependency] !== undefined ||
416
- source.resources?.[dependency] !== undefined
417
- )
418
- continue;
419
- try {
420
- if (
421
- !(
422
- await execute("kpsewhich", [dependency], { timeout: 5000 })
423
- ).stdout.trim()
424
- )
425
- missing.push(dependency);
426
- } catch {
427
- missing.push(dependency);
428
- }
429
- }
430
- for (const font of Object.values(source.fonts))
431
- try {
432
- const matched = (
433
- await execute("fc-match", ["-f", "%{family}", font], { timeout: 5000 })
434
- ).stdout;
435
- if (!matched.toLowerCase().includes(font.toLowerCase()))
505
+ await Promise.all(
506
+ ["latexmk", source.engine, "bibtex", "kpsewhich"].map(async (command) => {
507
+ try {
508
+ await execute(command, ["--version"], { timeout: 5000 });
509
+ } catch {
510
+ missing.push(command);
511
+ }
512
+ }),
513
+ );
514
+ const external = source.requirements.filter(
515
+ (dependency) =>
516
+ source.files[dependency] === undefined &&
517
+ source.resources?.[dependency] === undefined,
518
+ );
519
+ const resolved = await kpsewhichAll(external);
520
+ for (const dependency of external)
521
+ if (!resolved.has(dependency)) missing.push(dependency);
522
+ await Promise.all(
523
+ Object.values(source.fonts).map(async (font) => {
524
+ try {
525
+ const matched = (
526
+ await execute("fc-match", ["-f", "%{family}", font], {
527
+ timeout: 5000,
528
+ })
529
+ ).stdout;
530
+ if (!matched.toLowerCase().includes(font.toLowerCase()))
531
+ missing.push(`font:${font}`);
532
+ } catch {
436
533
  missing.push(`font:${font}`);
437
- } catch {
438
- missing.push(`font:${font}`);
439
- }
440
- return {
534
+ }
535
+ }),
536
+ );
537
+ const status: CompilerStatus = {
441
538
  available: missing.length === 0,
442
539
  missing,
443
540
  ...(latexFormat ? { latexFormat } : {}),
444
541
  };
542
+ if (status.available) doctorCache.set(cacheKey, status);
543
+ return status;
445
544
  }
446
545
  async function selectCompiler(
447
546
  source: LatexPackage,
@@ -589,10 +688,22 @@ export async function setupCompiler(
589
688
  return details.Id;
590
689
  }
591
690
  /** Unicode-safe default PDF API. Never falls back to the legacy text exporter. */
592
- export async function exportPdf(
691
+ export function exportPdf(
692
+ document: Document<Block<BlockType, unknown>>,
693
+ options: NativeCompileOptions & { readonly engine: "rust" },
694
+ ): Promise<NativeCompileResult>;
695
+ export function exportPdf(
593
696
  document: Document<Block<BlockType, unknown>>,
594
697
  options: LatexPackageOptions & CompileLatexOptions,
595
- ): Promise<CompiledLatexPackage> {
698
+ ): Promise<CompiledLatexPackage>;
699
+ export async function exportPdf(
700
+ document: Document<Block<BlockType, unknown>>,
701
+ options:
702
+ | (LatexPackageOptions & CompileLatexOptions)
703
+ | (NativeCompileOptions & { readonly engine: "rust" }),
704
+ ): Promise<CompiledLatexPackage | NativeCompileResult> {
705
+ if (options.engine === "rust")
706
+ return compileNativePdf(parseDocument(document), options);
596
707
  return compileLatexPackage(createLatexPackage(document, options), options);
597
708
  }
598
709