@publisle/research 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +31 -0
  3. package/README.md +14 -2
  4. package/compiler/Dockerfile +18 -0
  5. package/compiler/README.md +80 -0
  6. package/compiler/cacert.pem +2950 -0
  7. package/compiler/install-texlive.sh +38 -0
  8. package/compiler/release.json +13 -0
  9. package/dist/article.d.ts +10 -0
  10. package/dist/article.d.ts.map +1 -0
  11. package/dist/article.js +425 -0
  12. package/dist/article.js.map +1 -0
  13. package/dist/bibtex.d.ts +2 -0
  14. package/dist/bibtex.d.ts.map +1 -0
  15. package/dist/bibtex.js +2 -0
  16. package/dist/bibtex.js.map +1 -0
  17. package/dist/citeproc.d.ts +28 -0
  18. package/dist/csl.d.ts +35 -0
  19. package/dist/csl.d.ts.map +1 -0
  20. package/dist/csl.js +315 -0
  21. package/dist/csl.js.map +1 -0
  22. package/dist/index.d.ts +13 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +12 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/jats.d.ts +14 -0
  27. package/dist/jats.d.ts.map +1 -0
  28. package/dist/jats.js +197 -0
  29. package/dist/jats.js.map +1 -0
  30. package/dist/latex.d.ts +5 -0
  31. package/dist/latex.d.ts.map +1 -0
  32. package/dist/latex.js +19 -0
  33. package/dist/latex.js.map +1 -0
  34. package/dist/locales.d.ts +2 -0
  35. package/dist/locales.d.ts.map +1 -0
  36. package/dist/locales.js +67 -0
  37. package/dist/locales.js.map +1 -0
  38. package/dist/node.d.ts +66 -0
  39. package/dist/node.d.ts.map +1 -0
  40. package/dist/node.js +542 -0
  41. package/dist/node.js.map +1 -0
  42. package/dist/package.d.ts +19 -0
  43. package/dist/package.d.ts.map +1 -0
  44. package/dist/package.js +48 -0
  45. package/dist/package.js.map +1 -0
  46. package/dist/pdf.d.ts +10 -0
  47. package/dist/pdf.d.ts.map +1 -0
  48. package/dist/pdf.js +207 -0
  49. package/dist/pdf.js.map +1 -0
  50. package/dist/portable-citations.d.ts +27 -0
  51. package/dist/portable-citations.d.ts.map +1 -0
  52. package/dist/portable-citations.js +185 -0
  53. package/dist/portable-citations.js.map +1 -0
  54. package/dist/profiles.d.ts +10 -0
  55. package/dist/profiles.d.ts.map +1 -0
  56. package/dist/profiles.js +166 -0
  57. package/dist/profiles.js.map +1 -0
  58. package/dist/remote.d.ts +24 -0
  59. package/dist/remote.d.ts.map +1 -0
  60. package/dist/remote.js +139 -0
  61. package/dist/remote.js.map +1 -0
  62. package/dist/resolve.d.ts +10 -0
  63. package/dist/resolve.d.ts.map +1 -0
  64. package/dist/resolve.js +62 -0
  65. package/dist/resolve.js.map +1 -0
  66. package/package.json +53 -3
  67. package/src/article.ts +478 -0
  68. package/src/bibtex.ts +1 -0
  69. package/src/citeproc.d.ts +28 -0
  70. package/src/csl.ts +367 -0
  71. package/src/index.ts +29 -0
  72. package/src/jats.ts +242 -0
  73. package/src/latex.ts +21 -0
  74. package/src/locales.ts +126 -0
  75. package/src/node.ts +752 -0
  76. package/src/package.ts +83 -0
  77. package/src/pdf.ts +225 -0
  78. package/src/portable-citations.ts +250 -0
  79. package/src/profiles.ts +188 -0
  80. package/src/remote.ts +163 -0
  81. package/src/resolve.ts +91 -0
  82. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/BITS-embedded-index2.ent +308 -0
  83. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/BITS-question-answer2.ent +531 -0
  84. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-XHTMLtablesetup1-3.ent +629 -0
  85. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-ali-namespace1-3.ent +293 -0
  86. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-archivearticle1-3.dtd +1085 -0
  87. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-archivecustom-classes1-3.ent +409 -0
  88. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-archivecustom-mixes1-3.ent +601 -0
  89. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-archivecustom-models1-3.ent +1613 -0
  90. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-archivecustom-modules1-3.ent +404 -0
  91. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-articlemeta1-3.ent +2552 -0
  92. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-backmatter1-3.ent +540 -0
  93. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-chars1-3.ent +664 -0
  94. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-common-atts1-3.ent +360 -0
  95. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-common1-3.ent +4659 -0
  96. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-default-classes1-3.ent +1595 -0
  97. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-default-mixes1-3.ent +630 -0
  98. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-display1-3.ent +1299 -0
  99. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-format1-3.ent +997 -0
  100. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-funding1-3.ent +1100 -0
  101. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-journalmeta1-3.ent +593 -0
  102. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-link1-3.ent +604 -0
  103. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-list1-3.ent +663 -0
  104. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-math1-3.ent +563 -0
  105. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-mathmlsetup1-3.ent +491 -0
  106. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-modules1-3.ent +769 -0
  107. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-nlmcitation1-3.ent +428 -0
  108. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-notat1-3.ent +412 -0
  109. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-para1-3.ent +648 -0
  110. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-phrase1-3.ent +664 -0
  111. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-references1-3.ent +1428 -0
  112. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-related-object1-3.ent +435 -0
  113. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-section1-3.ent +463 -0
  114. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-xmlspecchars1-3.ent +528 -0
  115. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/catalog-jats-v1-3-no-base.xml +999 -0
  116. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/catalog-jats-v1-3-with-base.xml +986 -0
  117. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso8879/isobox.ent +61 -0
  118. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso8879/isocyr1.ent +88 -0
  119. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso8879/isocyr2.ent +47 -0
  120. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso8879/isodia.ent +35 -0
  121. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso8879/isolat1.ent +83 -0
  122. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso8879/isolat2.ent +142 -0
  123. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso8879/isonum.ent +97 -0
  124. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso8879/isopub.ent +105 -0
  125. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso9573-13/isoamsa.ent +167 -0
  126. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso9573-13/isoamsb.ent +143 -0
  127. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso9573-13/isoamsc.ent +43 -0
  128. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso9573-13/isoamsn.ent +114 -0
  129. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso9573-13/isoamso.ent +73 -0
  130. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso9573-13/isoamsr.ent +204 -0
  131. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso9573-13/isogrk3.ent +64 -0
  132. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso9573-13/isomfrk.ent +75 -0
  133. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso9573-13/isomopf.ent +49 -0
  134. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso9573-13/isomscr.ent +75 -0
  135. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/iso9573-13/isotech.ent +182 -0
  136. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/mathml/mmlalias.ent +564 -0
  137. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/mathml/mmlextra.ent +122 -0
  138. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/mathml2-qname-1.mod +286 -0
  139. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/mathml2.dtd +2206 -0
  140. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/xhtml-inlstyle-1.mod +34 -0
  141. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/xhtml-table-1.mod +333 -0
  142. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/xmlchars/isogrk1.ent +70 -0
  143. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/xmlchars/isogrk2.ent +41 -0
  144. package/vendor/jats/JATS-Archiving-1-3-MathML2-DTD/xmlchars/isogrk4.ent +66 -0
  145. package/vendor/jats/PROVENANCE.json +6 -0
  146. package/vendor/locales/PROVENANCE.json +5 -0
  147. package/vendor/locales/README.md +23 -0
package/src/node.ts ADDED
@@ -0,0 +1,752 @@
1
+ import { createHash } from "node:crypto";
2
+ import { randomUUID } from "node:crypto";
3
+ import { execFile, spawn } from "node:child_process";
4
+ import { constants } from "node:fs";
5
+ import {
6
+ copyFile,
7
+ mkdir,
8
+ mkdtemp,
9
+ readFile,
10
+ realpath,
11
+ readdir,
12
+ rm,
13
+ writeFile,
14
+ } from "node:fs/promises";
15
+ import { homedir, tmpdir } from "node:os";
16
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
17
+ import { promisify } from "node:util";
18
+ import type { ResearchLoss } from "./article.ts";
19
+ import type { Block, BlockType, Document } from "@publisle/schema";
20
+ import { createLatexPackage, type LatexPackageOptions } from "./package.ts";
21
+ import type { LatexPackage } from "./package.ts";
22
+
23
+ const execute = promisify(execFile);
24
+ export interface CompileLatexOptions {
25
+ /** Base directory for local manuscript assets. */
26
+ readonly sourceDirectory: string;
27
+ /** Total latexmk process timeout, default 120 seconds. */
28
+ readonly timeoutMs?: number;
29
+ readonly compiler?: "auto" | "native" | "container";
30
+ readonly containerImage?: string;
31
+ }
32
+ export interface CompiledLatexPackage {
33
+ readonly pdf: Uint8Array;
34
+ readonly files: Readonly<Record<string, string | Uint8Array>>;
35
+ readonly diagnostics: readonly ResearchLoss[];
36
+ }
37
+ function packagePath(directory: string, name: string): string {
38
+ if (isAbsolute(name) || name.split(/[\\/]/u).includes(".."))
39
+ throw new Error(`Unsafe package path: ${name}`);
40
+ return join(directory, name);
41
+ }
42
+
43
+ // Killing the process group also terminates TeX/BibTeX children on POSIX.
44
+ function runLatexmk(
45
+ args: readonly string[],
46
+ directory: string,
47
+ timeout: number,
48
+ backend: { command: string; prefix: readonly string[] },
49
+ ): Promise<string> {
50
+ return new Promise((resolveRun, rejectRun) => {
51
+ const child = spawn(backend.command, [...backend.prefix, ...args], {
52
+ cwd: directory,
53
+ detached: process.platform !== "win32",
54
+ stdio: ["ignore", "pipe", "pipe"],
55
+ env: {
56
+ ...process.env,
57
+ TEXMFOUTPUT: directory,
58
+ openin_any: "p",
59
+ openout_any: "p",
60
+ },
61
+ });
62
+ const chunks: Buffer[] = [];
63
+ let bytes = 0;
64
+ let failure: Error | undefined;
65
+ const stop = (error: Error) => {
66
+ failure ??= error;
67
+ try {
68
+ if (process.platform !== "win32" && child.pid !== undefined)
69
+ process.kill(-child.pid, "SIGKILL");
70
+ else child.kill("SIGKILL");
71
+ } catch {
72
+ child.kill("SIGKILL");
73
+ }
74
+ };
75
+ const timer = setTimeout(() => {
76
+ stop(
77
+ new Error(`LaTeX compilation timed out after ${String(timeout)} ms.`),
78
+ );
79
+ }, timeout);
80
+ const collect = (chunk: Buffer) => {
81
+ bytes += chunk.length;
82
+ if (bytes > 4 * 1024 * 1024)
83
+ stop(new Error("LaTeX compilation exceeded the 4 MiB output limit."));
84
+ else chunks.push(chunk);
85
+ };
86
+ child.stdout.on("data", collect);
87
+ child.stderr.on("data", collect);
88
+ child.on("error", (error) => {
89
+ failure ??= error;
90
+ });
91
+ child.on("close", (code) => {
92
+ clearTimeout(timer);
93
+ const output = Buffer.concat(chunks).toString("utf8");
94
+ if (failure) rejectRun(failure);
95
+ else if (code !== 0)
96
+ rejectRun(
97
+ new Error(
98
+ `latexmk exited with code ${String(code)}.\n${output.slice(-6000)}`,
99
+ ),
100
+ );
101
+ else resolveRun(output);
102
+ });
103
+ });
104
+ }
105
+
106
+ /** Node-only local TeX compilation. Does not publish or overwrite output paths. */
107
+ export async function compileLatexPackage(
108
+ source: LatexPackage,
109
+ options: CompileLatexOptions,
110
+ ): Promise<CompiledLatexPackage> {
111
+ const timeout = options.timeoutMs ?? 120_000;
112
+ if (!Number.isSafeInteger(timeout) || timeout <= 0)
113
+ throw new Error("Compilation timeout must be a positive integer.");
114
+ const backend = await selectCompiler(source, options);
115
+ const directory = await mkdtemp(join(tmpdir(), "publisle-tex-"));
116
+ const files: Record<string, string | Uint8Array> = {
117
+ ...source.files,
118
+ ...source.resources,
119
+ };
120
+ 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
+ }
129
+ 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
+ }
146
+ let stdout = "";
147
+ const containerName = `publisle-${randomUUID()}`;
148
+ try {
149
+ stdout = await runLatexmk(
150
+ [
151
+ source.engine === "pdflatex" ? "-pdf" : "-lualatex",
152
+ "-norc",
153
+ "-interaction=nonstopmode",
154
+ "-halt-on-error",
155
+ "-file-line-error",
156
+ "-no-shell-escape",
157
+ "manuscript.tex",
158
+ ],
159
+ directory,
160
+ timeout,
161
+ backend.kind === "native"
162
+ ? { command: "latexmk", prefix: [] }
163
+ : {
164
+ command: "docker",
165
+ prefix: [
166
+ "run",
167
+ "--rm",
168
+ "--pull=never",
169
+ "--name",
170
+ containerName,
171
+ "--read-only",
172
+ "--tmpfs",
173
+ "/tmp:rw,nosuid,size=512m",
174
+ "--network=none",
175
+ "--cap-drop=ALL",
176
+ "--security-opt=no-new-privileges",
177
+ "--user",
178
+ backend.user ?? "1000:1000",
179
+ "--mount",
180
+ `type=bind,source=${directory},target=/work`,
181
+ "--workdir",
182
+ "/work",
183
+ "--env",
184
+ "HOME=/tmp",
185
+ "--env",
186
+ "TEXMFVAR=/tmp/texmf-var",
187
+ "--env",
188
+ // LuaTeX 2025 reads Unicode data via absolute paths. The
189
+ // container exposes only the compiler and the package mount.
190
+ "openin_any=r",
191
+ "--env",
192
+ "openout_any=p",
193
+ backend.image ?? "",
194
+ "latexmk",
195
+ ],
196
+ },
197
+ );
198
+ } catch (error) {
199
+ const log = await readFile(
200
+ join(directory, "manuscript.log"),
201
+ "utf8",
202
+ ).catch(() => "");
203
+ throw new Error(
204
+ `Local LaTeX compilation failed (requires latexmk, ${source.engine}, template dependencies): ${error instanceof Error ? error.message : String(error)}\n${log.slice(-6000)}`,
205
+ { cause: error },
206
+ );
207
+ } finally {
208
+ if (backend.kind === "container")
209
+ await execute("docker", ["rm", "--force", containerName], {
210
+ timeout: 10_000,
211
+ }).catch(() => undefined);
212
+ }
213
+ const log = await readFile(join(directory, "manuscript.log"), "utf8");
214
+ if (
215
+ /Missing character:|undefined references|Citation .* undefined|Reference .* undefined/iu.test(
216
+ log,
217
+ )
218
+ )
219
+ throw new Error(
220
+ `LaTeX output has missing glyphs or unresolved references:\n${log
221
+ .split("\n")
222
+ .filter((line) => /Missing character:|undefined/iu.test(line))
223
+ .join("\n")}`,
224
+ );
225
+ const diagnostics = [...source.diagnostics];
226
+ for (const line of log.split("\n"))
227
+ if (/Overfull \\[hv]box/u.test(line))
228
+ diagnostics.push({ code: "latex-layout-overflow", message: line });
229
+ const pdf = new Uint8Array(
230
+ await readFile(join(directory, "manuscript.pdf")),
231
+ );
232
+ files["manuscript.pdf"] = pdf;
233
+ files["manuscript.bbl"] = await readFile(
234
+ join(directory, "manuscript.bbl"),
235
+ "utf8",
236
+ ).catch(() => "");
237
+ files["manuscript.log"] = log;
238
+ files["build.log"] = stdout;
239
+ const engineVersion =
240
+ backend.kind === "native"
241
+ ? (
242
+ await execute(source.engine, ["--version"], { timeout: 5000 })
243
+ ).stdout.split("\n")[0]
244
+ : backend.image;
245
+ const fontVersions: Record<string, string> = {};
246
+ 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();
255
+ const dependencyDigests: Record<string, string> = {};
256
+ 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
+ }
278
+ files["manifest.json"] =
279
+ JSON.stringify(
280
+ {
281
+ template: source.template,
282
+ pdfStandard: source.pdfStandard,
283
+ pdfUaValidation: source.pdfStandard ? "not-run" : "unavailable",
284
+ engine: source.engine,
285
+ engineVersion,
286
+ compiler: backend.kind,
287
+ containerImage: backend.image,
288
+ templateVersion: source.templateVersion,
289
+ requirements: source.requirements,
290
+ dependencyDigests,
291
+ dependencyVersions,
292
+
293
+ fonts: source.fonts,
294
+ fontVersions,
295
+ diagnostics,
296
+ files: [...Object.keys(files), "manifest.json"].sort(),
297
+ },
298
+ null,
299
+ 2,
300
+ ) + "\n";
301
+ return { pdf, files, diagnostics };
302
+ } finally {
303
+ await rm(directory, { recursive: true, force: true });
304
+ }
305
+ }
306
+
307
+ /** Atomically claims a new output directory; compilation must already have succeeded. */
308
+ export async function writeLatexPackage(
309
+ compiled: {
310
+ readonly files: CompiledLatexPackage["files"];
311
+ readonly pdf?: Uint8Array;
312
+ readonly diagnostics?: readonly ResearchLoss[];
313
+ },
314
+ outputDirectory: string,
315
+ ): Promise<void> {
316
+ await mkdir(outputDirectory);
317
+ try {
318
+ for (const [name, value] of Object.entries(compiled.files)) {
319
+ const path = packagePath(outputDirectory, name);
320
+ await mkdir(dirname(path), { recursive: true });
321
+ await writeFile(path, value, { flag: "wx" });
322
+ }
323
+ } catch (error) {
324
+ await rm(outputDirectory, { recursive: true, force: true });
325
+ throw error;
326
+ }
327
+ }
328
+
329
+ const compilerLock = join(homedir(), ".cache", "publisle", "compiler.json");
330
+ export interface CompilerStatus {
331
+ readonly available: boolean;
332
+ readonly missing: readonly string[];
333
+ readonly latexFormat?: string;
334
+ }
335
+ async function taggingFormat(image?: string): Promise<string | undefined> {
336
+ const directory = await mkdtemp(join(tmpdir(), "publisle-format-"));
337
+ const containerName = `publisle-format-${randomUUID()}`;
338
+ try {
339
+ const args = [
340
+ "-interaction=nonstopmode",
341
+ "-no-shell-escape",
342
+ "-draftmode",
343
+ "\\typeout{PUBLISLE-FORMAT:\\fmtversion}\\stop",
344
+ ];
345
+ const output = await execute(
346
+ image ? "docker" : "lualatex",
347
+ image
348
+ ? [
349
+ "run",
350
+ "--rm",
351
+ "--pull=never",
352
+ "--name",
353
+ containerName,
354
+ "--network=none",
355
+ "--cap-drop=ALL",
356
+ "--security-opt=no-new-privileges",
357
+ "--read-only",
358
+ "--tmpfs",
359
+ "/tmp:rw,nosuid,size=128m",
360
+ "--workdir",
361
+ "/tmp",
362
+ "--env",
363
+ "HOME=/tmp",
364
+ "--env",
365
+ "TEXMFVAR=/tmp/texmf-var",
366
+ "--env",
367
+ "openin_any=r",
368
+ image,
369
+ "lualatex",
370
+ ...args,
371
+ ]
372
+ : args,
373
+ {
374
+ cwd: directory,
375
+ env: {
376
+ ...process.env,
377
+ TEXMFVAR: directory,
378
+ TEXMFOUTPUT: directory,
379
+ openin_any: "p",
380
+ openout_any: "p",
381
+ },
382
+ timeout: 15_000,
383
+ maxBuffer: 1024 * 1024,
384
+ },
385
+ );
386
+ return /PUBLISLE-FORMAT:(\d{4}-\d{2}-\d{2})/u.exec(output.stdout)?.[1];
387
+ } finally {
388
+ if (image)
389
+ await execute("docker", ["rm", "--force", containerName], {
390
+ timeout: 10_000,
391
+ }).catch(() => undefined);
392
+ await rm(directory, { recursive: true, force: true });
393
+ }
394
+ }
395
+ export async function doctorCompiler(
396
+ source: LatexPackage,
397
+ ): Promise<CompilerStatus> {
398
+ const missing: string[] = [];
399
+ let latexFormat: string | undefined;
400
+ if (source.engine === "lualatex") {
401
+ latexFormat = await taggingFormat().catch(() => undefined);
402
+ if (!latexFormat)
403
+ missing.push("LuaLaTeX initialization under restricted native input");
404
+ else if (source.pdfStandard && latexFormat < "2025-11-01")
405
+ missing.push("LaTeX format >= 2025-11-01 (PDF/UA tagging)");
406
+ }
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()))
436
+ missing.push(`font:${font}`);
437
+ } catch {
438
+ missing.push(`font:${font}`);
439
+ }
440
+ return {
441
+ available: missing.length === 0,
442
+ missing,
443
+ ...(latexFormat ? { latexFormat } : {}),
444
+ };
445
+ }
446
+ async function selectCompiler(
447
+ source: LatexPackage,
448
+ options: CompileLatexOptions,
449
+ ): Promise<{ kind: "native" | "container"; image?: string; user?: string }> {
450
+ const mode = options.compiler ?? "auto";
451
+ const native =
452
+ mode === "container" ? undefined : await doctorCompiler(source);
453
+ if (native?.available) return { kind: "native" };
454
+ if (mode === "native")
455
+ throw new Error(
456
+ `Native compiler is incomplete: ${native?.missing.join(", ") ?? "unknown"}`,
457
+ );
458
+ let image = options.containerImage;
459
+ if (!image)
460
+ try {
461
+ const lock = JSON.parse(await readFile(compilerLock, "utf8")) as {
462
+ image: string;
463
+ };
464
+ image = lock.image;
465
+ } catch {
466
+ /* No installed compiler. */
467
+ }
468
+ if (image && /^sha256:[a-f0-9]{64}$/u.test(image))
469
+ try {
470
+ await execute("docker", ["image", "inspect", image], { timeout: 5000 });
471
+ if (source.pdfStandard) {
472
+ const format = await taggingFormat(image);
473
+ if (!format || format < "2025-11-01")
474
+ throw new Error(
475
+ "Installed compiler predates PDF/UA tagging; run publisle setup compiler.",
476
+ );
477
+ }
478
+ const security = (
479
+ await execute(
480
+ "docker",
481
+ ["info", "--format", "{{json .SecurityOptions}}"],
482
+ { timeout: 5000 },
483
+ )
484
+ ).stdout;
485
+ return {
486
+ kind: "container",
487
+ image,
488
+ user: security.includes("rootless")
489
+ ? "0:0"
490
+ : `${String(process.getuid?.() ?? 1000)}:${String(process.getgid?.() ?? 1000)}`,
491
+ };
492
+ } catch {
493
+ /* Never pull implicitly. */
494
+ }
495
+ throw new Error(
496
+ `No complete compiler is installed${native ? ` (missing: ${native.missing.join(", ")})` : ""}. Run publisle setup compiler explicitly, or install the native dependencies.`,
497
+ );
498
+ }
499
+ export interface SetupCompilerOptions {
500
+ /** Published immutable registry reference, including @sha256: digest. */
501
+ readonly image?: string;
502
+ /** Build the bundled frozen recipe instead of pulling a published image. */
503
+ readonly localBuild?: boolean;
504
+ }
505
+
506
+ /** Explicit setup is the only operation that downloads or builds a compiler. */
507
+ export async function setupCompiler(
508
+ options: SetupCompilerOptions = {},
509
+ ): Promise<string> {
510
+ if (options.localBuild && options.image)
511
+ throw new Error("Choose a published compiler image or a local build.");
512
+ const release = JSON.parse(
513
+ await readFile(
514
+ new URL("../compiler/release.json", import.meta.url),
515
+ "utf8",
516
+ ),
517
+ ) as { recipeVersion: number; publishedImage: string | null };
518
+ const publishedImage = options.localBuild
519
+ ? undefined
520
+ : (options.image ?? release.publishedImage ?? undefined);
521
+ if (
522
+ publishedImage &&
523
+ !/^[a-z0-9][a-z0-9.:/-]*@sha256:[a-f0-9]{64}$/u.test(publishedImage)
524
+ )
525
+ throw new Error(
526
+ "Compiler image must be an immutable registry@sha256: reference.",
527
+ );
528
+ const tag = `publisle-compiler:${String(release.recipeVersion)}`;
529
+ if (publishedImage) {
530
+ try {
531
+ await execute("docker", ["pull", publishedImage], {
532
+ timeout: 1_800_000,
533
+ maxBuffer: 8 * 1024 * 1024,
534
+ });
535
+ } catch (error) {
536
+ throw new Error(
537
+ "Could not pull the pinned compiler. Retry setup, or use publisle setup compiler --build for the bundled local recipe.",
538
+ { cause: error },
539
+ );
540
+ }
541
+ } else {
542
+ await execute(
543
+ "docker",
544
+ [
545
+ "build",
546
+ "--tag",
547
+ tag,
548
+ "--file",
549
+ new URL("../compiler/Dockerfile", import.meta.url).pathname,
550
+ new URL("../compiler/", import.meta.url).pathname,
551
+ ],
552
+ { timeout: 1_800_000, maxBuffer: 8 * 1024 * 1024 },
553
+ );
554
+ }
555
+ const inspected = JSON.parse(
556
+ (
557
+ await execute("docker", ["image", "inspect", publishedImage ?? tag], {
558
+ timeout: 5000,
559
+ })
560
+ ).stdout,
561
+ ) as {
562
+ Id: string;
563
+ RepoDigests?: string[];
564
+ Os: string;
565
+ Architecture: string;
566
+ }[];
567
+ const details = inspected[0];
568
+ if (!details || !/^sha256:[a-f0-9]{64}$/u.test(details.Id))
569
+ throw new Error("Compiler did not return a pinned image digest.");
570
+ if (publishedImage && !details.RepoDigests?.includes(publishedImage))
571
+ throw new Error(
572
+ "Pulled compiler does not match the requested registry digest.",
573
+ );
574
+ if (
575
+ details.Os !== "linux" ||
576
+ !["amd64", "arm64"].includes(details.Architecture)
577
+ )
578
+ throw new Error("Compiler must be a linux/amd64 or linux/arm64 image.");
579
+ await mkdir(dirname(compilerLock), { recursive: true });
580
+ await writeFile(
581
+ compilerLock,
582
+ JSON.stringify({
583
+ image: details.Id,
584
+ recipeVersion: release.recipeVersion,
585
+ platform: `${details.Os}/${details.Architecture}`,
586
+ ...(publishedImage ? { publishedImage } : {}),
587
+ }) + "\n",
588
+ );
589
+ return details.Id;
590
+ }
591
+ /** Unicode-safe default PDF API. Never falls back to the legacy text exporter. */
592
+ export async function exportPdf(
593
+ document: Document<Block<BlockType, unknown>>,
594
+ options: LatexPackageOptions & CompileLatexOptions,
595
+ ): Promise<CompiledLatexPackage> {
596
+ return compileLatexPackage(createLatexPackage(document, options), options);
597
+ }
598
+
599
+ export { lookupDoi, fetchBibliography, importCslJson } from "./remote.ts";
600
+
601
+ /** In-memory ZIP construction, followed by exclusive creation of the destination. */
602
+ export async function writePackageArchive(
603
+ compiled: Pick<CompiledLatexPackage, "files">,
604
+ outputFile: string,
605
+ ): Promise<void> {
606
+ const { zipSync, strToU8 } = await import("fflate");
607
+ const entries: Record<string, Uint8Array> = {};
608
+ for (const [name, value] of Object.entries(compiled.files)) {
609
+ packagePath("/", name);
610
+ entries[name] = typeof value === "string" ? strToU8(value) : value;
611
+ }
612
+ await writeFile(outputFile, zipSync(entries), { flag: "wx" });
613
+ }
614
+
615
+ /** Collect and validate an article/assets JATS package without network access. */
616
+ export async function collectJatsPackage(
617
+ source: import("./jats.ts").JatsPackage,
618
+ sourceDirectory: string,
619
+ ): Promise<{
620
+ files: Readonly<Record<string, string | Uint8Array>>;
621
+ diagnostics: readonly ResearchLoss[];
622
+ }> {
623
+ const directory = await mkdtemp(join(tmpdir(), "publisle-jats-"));
624
+ const files: Record<string, string | Uint8Array> = { ...source.files };
625
+ try {
626
+ const base = await realpath(sourceDirectory);
627
+ for (const asset of source.assets) {
628
+ const path = await realpath(resolve(base, asset.source));
629
+ const local = relative(base, path);
630
+ if (
631
+ isAbsolute(local) ||
632
+ local === ".." ||
633
+ local.startsWith("../") ||
634
+ local.startsWith("..\\")
635
+ )
636
+ throw new Error("JATS asset escapes manuscript directory.");
637
+ files[asset.destination] = new Uint8Array(await readFile(path));
638
+ }
639
+ for (const [name, value] of Object.entries(files)) {
640
+ const path = packagePath(directory, name);
641
+ await mkdir(dirname(path), { recursive: true });
642
+ await writeFile(path, value, { flag: "wx" });
643
+ }
644
+ const dtd = new URL(
645
+ "../vendor/jats/JATS-Archiving-1-3-MathML2-DTD/JATS-archivearticle1-3.dtd",
646
+ import.meta.url,
647
+ ).pathname;
648
+ await execute(
649
+ "xmllint",
650
+ ["--nonet", "--noout", "--dtdvalid", dtd, join(directory, "article.xml")],
651
+ { timeout: 10_000, maxBuffer: 1024 * 1024 },
652
+ );
653
+ const schemaDirectory = dirname(dtd);
654
+ for (const name of await readdir(schemaDirectory)) {
655
+ const path = join(schemaDirectory, name);
656
+ if (/\.(?:dtd|ent|mod)$/u.test(name))
657
+ files[`schema/${name}`] = new Uint8Array(await readFile(path));
658
+ }
659
+ files["BUILD.md"] =
660
+ "# Validate offline\n\nxmllint --nonet --noout --dtdvalid schema/JATS-archivearticle1-3.dtd article.xml\n";
661
+ files["manifest.json"] =
662
+ JSON.stringify(
663
+ {
664
+ format: "jats",
665
+ version: "1.3",
666
+ validation: "offline-dtd",
667
+ files: Object.keys(files).sort(),
668
+ diagnostics: source.diagnostics,
669
+ },
670
+ null,
671
+ 2,
672
+ ) + "\n";
673
+ return { files, diagnostics: source.diagnostics };
674
+ } finally {
675
+ await rm(directory, { recursive: true, force: true });
676
+ }
677
+ }
678
+
679
+ export async function doctorCompilers(
680
+ source: LatexPackage,
681
+ options: { readonly veraPdf?: boolean } = {},
682
+ ): Promise<{
683
+ native: CompilerStatus;
684
+ container: {
685
+ available: boolean;
686
+ runtimeAvailable: boolean;
687
+ image?: string;
688
+ publishedImage?: string;
689
+ platform?: string;
690
+ };
691
+ veraPdf?: { available: boolean; version?: string };
692
+ }> {
693
+ const native = await doctorCompiler(source);
694
+ const runtimeAvailable = await execute("docker", ["info"], { timeout: 5000 })
695
+ .then(() => true)
696
+ .catch(() => false);
697
+ const installed = await readFile(compilerLock, "utf8")
698
+ .then(
699
+ (value) =>
700
+ JSON.parse(value) as {
701
+ image?: string;
702
+ publishedImage?: string;
703
+ platform?: string;
704
+ },
705
+ )
706
+ .catch(
707
+ (): {
708
+ image?: string;
709
+ publishedImage?: string;
710
+ platform?: string;
711
+ } => ({}),
712
+ );
713
+ const imageStatus = {
714
+ runtimeAvailable,
715
+ ...(installed.image ? { image: installed.image } : {}),
716
+ ...(installed.publishedImage
717
+ ? { publishedImage: installed.publishedImage }
718
+ : {}),
719
+ ...(installed.platform ? { platform: installed.platform } : {}),
720
+ };
721
+ const validator = options.veraPdf
722
+ ? {
723
+ veraPdf: await execute("verapdf", ["--version"], { timeout: 10_000 })
724
+ .then(({ stdout }) => ({
725
+ available: true,
726
+ version: stdout.split("\n")[0] ?? "",
727
+ }))
728
+ .catch(() => ({ available: false })),
729
+ }
730
+ : {};
731
+ try {
732
+ const backend = await selectCompiler(source, {
733
+ sourceDirectory: ".",
734
+ compiler: "container",
735
+ });
736
+ return {
737
+ native,
738
+ ...validator,
739
+ container: {
740
+ ...imageStatus,
741
+ available: true,
742
+ ...(backend.image ? { image: backend.image } : {}),
743
+ },
744
+ };
745
+ } catch {
746
+ return {
747
+ native,
748
+ container: { ...imageStatus, available: false },
749
+ ...validator,
750
+ };
751
+ }
752
+ }