@publisle/research 0.1.0 → 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/src/native.ts ADDED
@@ -0,0 +1,179 @@
1
+ import { isPlainObject, type Document } from "@publisle/schema";
2
+ import { citationClusters, projectArticle } from "./article.ts";
3
+ import { resolveDocument, type ResolvedDocument } from "./resolve.ts";
4
+ import type { ArticleBlock, ArticleInline } from "./article.ts";
5
+
6
+ export const NATIVE_PDF_INPUT_VERSION = 1 as const;
7
+ export const NATIVE_PDF_TEMPLATES = [
8
+ "article",
9
+ "article-arabic",
10
+ "ieee-journal",
11
+ "acm-journal",
12
+ "springer-journal",
13
+ "elsevier-numeric",
14
+ "elsevier-author-date",
15
+ ] as const;
16
+ export type NativePdfTemplate = (typeof NATIVE_PDF_TEMPLATES)[number];
17
+ export interface NativeFont {
18
+ readonly path: string;
19
+ readonly role: "regular" | "bold" | "italic" | "mono" | "fallback";
20
+ readonly index?: number;
21
+ }
22
+ export interface NativePdfInput {
23
+ readonly schemaVersion: typeof NATIVE_PDF_INPUT_VERSION;
24
+ readonly template: NativePdfTemplate;
25
+ readonly templateVersion: string;
26
+ readonly pdfStandard: "ua-2" | null;
27
+ readonly article: ResolvedDocument["article"] & { readonly title: string };
28
+ readonly citations: readonly string[];
29
+ readonly bibliography: ResolvedDocument["bibliography"];
30
+ readonly assets: readonly {
31
+ readonly source: string;
32
+ readonly path: string;
33
+ }[];
34
+ readonly fonts: readonly NativeFont[];
35
+ readonly templateData: { readonly country?: string };
36
+ }
37
+ export interface NativePdfOptions {
38
+ readonly template?: NativePdfTemplate;
39
+ readonly style?: string;
40
+ readonly pdfUa?: "ua-2" | false;
41
+ readonly fonts: readonly NativeFont[];
42
+ readonly assets?: NativePdfInput["assets"];
43
+ readonly data?: { readonly country?: string };
44
+ }
45
+ export interface NativePdfPackage {
46
+ readonly input: NativePdfInput;
47
+ /** Maps bounded compiler block indices back to authored source IDs. */
48
+ readonly blockIds: readonly string[];
49
+ readonly diagnostics: ResolvedDocument["losses"];
50
+ }
51
+
52
+ /** Pure lowering shared by the website worker and Research CLI. No filesystem or network access. */
53
+ export function createNativePdfPackage(
54
+ document: Document,
55
+ options: NativePdfOptions,
56
+ ): NativePdfPackage {
57
+ const template = options.template ?? "article";
58
+ if (!NATIVE_PDF_TEMPLATES.includes(template))
59
+ throw new Error(`Unsupported native PDF template: ${template}`);
60
+ const resolved = resolveDocument(
61
+ document,
62
+ options.style ??
63
+ (template === "elsevier-author-date" ? "author-date" : "numeric"),
64
+ );
65
+ if (resolved.unresolved.length)
66
+ throw new Error(`Unresolved citations: ${resolved.unresolved.join(", ")}`);
67
+ if (
68
+ ["ieee-journal", "springer-journal"].includes(template) &&
69
+ resolved.form !== "numeric"
70
+ )
71
+ throw new Error("This journal profile requires numeric citations.");
72
+ if (template === "elsevier-author-date" && resolved.form !== "author-date")
73
+ throw new Error("This journal profile requires author-date citations.");
74
+ if (
75
+ options.data !== undefined &&
76
+ (!isPlainObject(options.data) ||
77
+ Object.keys(options.data).some((key) => key !== "country") ||
78
+ (options.data.country !== undefined &&
79
+ (typeof options.data.country !== "string" ||
80
+ options.data.country.length > 100)))
81
+ )
82
+ throw new Error("Unsupported native template metadata.");
83
+ if (
84
+ template === "acm-journal" &&
85
+ resolved.article.authorDetails?.some((author) => author.affiliation) &&
86
+ !options.data?.country?.trim()
87
+ )
88
+ throw new Error("ACM affiliations require template data: country.");
89
+ if (!resolved.article.title?.trim() || !resolved.article.authors.length)
90
+ throw new Error("PDF export requires a title and at least one author.");
91
+ if (options.fonts.length === 0 || options.fonts.length > 16)
92
+ throw new Error("Native PDF export requires one to sixteen pinned fonts.");
93
+ const article = structuredClone(resolved.article);
94
+ // CSL resolves cited footnotes at their first reference, rather than at the
95
+ // definition's physical position. Bind results before the native layout runs.
96
+ const notes = new Map(
97
+ article.blocks
98
+ .filter((block) => block.kind === "footnote")
99
+ .map((block) => [block.id, block]),
100
+ );
101
+ const visited = new Set<string>();
102
+ let cursor = 0;
103
+ const bind = (inlines: readonly ArticleInline[]): void => {
104
+ for (const inline of inlines) {
105
+ if (inline.type === "citation") {
106
+ const value = resolved.citations[cursor++];
107
+ if (value === undefined)
108
+ throw new Error(
109
+ "Resolved citation count does not match the article.",
110
+ );
111
+ Object.assign(inline, { text: value });
112
+ } else if (
113
+ inline.type === "text" &&
114
+ inline.reference?.kind === "footnote" &&
115
+ !visited.has(inline.reference.target)
116
+ ) {
117
+ visited.add(inline.reference.target);
118
+ notes.get(inline.reference.target)?.paragraphs.forEach(bind);
119
+ }
120
+ }
121
+ };
122
+ for (const block of article.blocks) visitInlines(block, bind);
123
+ if (cursor !== resolved.citations.length)
124
+ throw new Error("Resolved citation count does not match the article.");
125
+ // Each projection may emit multiple printable blocks (for example callouts).
126
+ const blockIds = document.blocks.flatMap((block) =>
127
+ projectArticle({ ...document, blocks: [block] }).blocks.map(() => block.id),
128
+ );
129
+ if (blockIds.length !== article.blocks.length)
130
+ throw new Error("Native projection source mapping is inconsistent.");
131
+ const clusters = citationClusters(resolved.article);
132
+ if (clusters.length !== resolved.citations.length)
133
+ throw new Error("Resolved citation clusters are inconsistent.");
134
+ return {
135
+ input: {
136
+ schemaVersion: NATIVE_PDF_INPUT_VERSION,
137
+ template,
138
+ templateVersion: "0.1.0-experimental",
139
+ pdfStandard: options.pdfUa === false ? null : "ua-2",
140
+ article: { ...article, title: resolved.article.title },
141
+ citations: resolved.citations,
142
+ bibliography: resolved.bibliography,
143
+ assets: options.assets ?? [],
144
+ fonts: options.fonts,
145
+ templateData: options.data ?? {},
146
+ },
147
+ blockIds,
148
+ diagnostics: resolved.losses,
149
+ };
150
+ }
151
+
152
+ function visitInlines(
153
+ block: ArticleBlock,
154
+ visit: (inlines: readonly ArticleInline[]) => void,
155
+ ): void {
156
+ switch (block.kind) {
157
+ case "heading":
158
+ case "paragraph":
159
+ visit(block.inlines);
160
+ break;
161
+ case "list":
162
+ block.items.forEach(visit);
163
+ break;
164
+ case "quote":
165
+ block.paragraphs.forEach(visit);
166
+ break;
167
+ case "figure":
168
+ visit(block.caption);
169
+ break;
170
+ case "table":
171
+ if (block.caption) visit(block.caption);
172
+ block.rows.forEach((row) => {
173
+ row.forEach(visit);
174
+ });
175
+ break;
176
+ default:
177
+ break;
178
+ }
179
+ }
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 {
@@ -383,7 +464,11 @@ async function taggingFormat(image?: string): Promise<string | undefined> {
383
464
  maxBuffer: 1024 * 1024,
384
465
  },
385
466
  );
386
- return /PUBLISLE-FORMAT:(\d{4}-\d{2}-\d{2})/u.exec(output.stdout)?.[1];
467
+ const detected = /PUBLISLE-FORMAT:(\d{4}-\d{2}-\d{2})/u.exec(
468
+ output.stdout,
469
+ )?.[1];
470
+ taggingFormatCache.set(key, detected);
471
+ return detected;
387
472
  } finally {
388
473
  if (image)
389
474
  await execute("docker", ["rm", "--force", containerName], {
@@ -395,6 +480,18 @@ async function taggingFormat(image?: string): Promise<string | undefined> {
395
480
  export async function doctorCompiler(
396
481
  source: LatexPackage,
397
482
  ): Promise<CompilerStatus> {
483
+ const cacheKey = [
484
+ source.engine,
485
+ source.pdfStandard ?? "",
486
+ Object.values(source.fonts).join(","),
487
+ ...source.requirements.map((name) =>
488
+ source.files[name] === undefined && source.resources?.[name] === undefined
489
+ ? name
490
+ : `${name}:bundled`,
491
+ ),
492
+ ].join("|");
493
+ const cached = doctorCache.get(cacheKey);
494
+ if (cached) return cached;
398
495
  const missing: string[] = [];
399
496
  let latexFormat: string | undefined;
400
497
  if (source.engine === "lualatex") {
@@ -404,44 +501,45 @@ export async function doctorCompiler(
404
501
  else if (source.pdfStandard && latexFormat < "2025-11-01")
405
502
  missing.push("LaTeX format >= 2025-11-01 (PDF/UA tagging)");
406
503
  }
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()))
504
+ await Promise.all(
505
+ ["latexmk", source.engine, "bibtex", "kpsewhich"].map(async (command) => {
506
+ try {
507
+ await execute(command, ["--version"], { timeout: 5000 });
508
+ } catch {
509
+ missing.push(command);
510
+ }
511
+ }),
512
+ );
513
+ const external = source.requirements.filter(
514
+ (dependency) =>
515
+ source.files[dependency] === undefined &&
516
+ source.resources?.[dependency] === undefined,
517
+ );
518
+ const resolved = await kpsewhichAll(external);
519
+ for (const dependency of external)
520
+ if (!resolved.has(dependency)) missing.push(dependency);
521
+ await Promise.all(
522
+ Object.values(source.fonts).map(async (font) => {
523
+ try {
524
+ const matched = (
525
+ await execute("fc-match", ["-f", "%{family}", font], {
526
+ timeout: 5000,
527
+ })
528
+ ).stdout;
529
+ if (!matched.toLowerCase().includes(font.toLowerCase()))
530
+ missing.push(`font:${font}`);
531
+ } catch {
436
532
  missing.push(`font:${font}`);
437
- } catch {
438
- missing.push(`font:${font}`);
439
- }
440
- return {
533
+ }
534
+ }),
535
+ );
536
+ const status: CompilerStatus = {
441
537
  available: missing.length === 0,
442
538
  missing,
443
539
  ...(latexFormat ? { latexFormat } : {}),
444
540
  };
541
+ if (status.available) doctorCache.set(cacheKey, status);
542
+ return status;
445
543
  }
446
544
  async function selectCompiler(
447
545
  source: LatexPackage,
@@ -589,10 +687,22 @@ export async function setupCompiler(
589
687
  return details.Id;
590
688
  }
591
689
  /** Unicode-safe default PDF API. Never falls back to the legacy text exporter. */
592
- export async function exportPdf(
690
+ export function exportPdf(
691
+ document: Document<Block<BlockType, unknown>>,
692
+ options: NativeCompileOptions & { readonly engine: "rust" },
693
+ ): Promise<NativeCompileResult>;
694
+ export function exportPdf(
593
695
  document: Document<Block<BlockType, unknown>>,
594
696
  options: LatexPackageOptions & CompileLatexOptions,
595
- ): Promise<CompiledLatexPackage> {
697
+ ): Promise<CompiledLatexPackage>;
698
+ export async function exportPdf(
699
+ document: Document<Block<BlockType, unknown>>,
700
+ options:
701
+ | (LatexPackageOptions & CompileLatexOptions)
702
+ | (NativeCompileOptions & { readonly engine: "rust" }),
703
+ ): Promise<CompiledLatexPackage | NativeCompileResult> {
704
+ if (options.engine === "rust")
705
+ return compileNativePdf(parseDocument(document), options);
596
706
  return compileLatexPackage(createLatexPackage(document, options), options);
597
707
  }
598
708