@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.
@@ -0,0 +1,96 @@
1
+ /** Locate a pinned, already installed native artifact. Never download at runtime. */
2
+ import { createHash } from "node:crypto";
3
+ import researchPackage from "../package.json" with { type: "json" };
4
+ import { createRequire } from "node:module";
5
+ import { readFile, realpath, stat } from "node:fs/promises";
6
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
7
+ import { parseJson, isPlainObject } from "@publisle/schema";
8
+ const require = createRequire(import.meta.url);
9
+ export const NATIVE_ARTIFACT_VERSION = researchPackage.version;
10
+ export const NATIVE_PLATFORM_PACKAGES: Readonly<Record<string, string>> = {
11
+ "linux-x64": "@publisle/pdf-native-linux-x64",
12
+ "linux-arm64": "@publisle/pdf-native-linux-arm64",
13
+ "darwin-x64": "@publisle/pdf-native-darwin-x64",
14
+ "darwin-arm64": "@publisle/pdf-native-darwin-arm64",
15
+ "win32-x64": "@publisle/pdf-native-win32-x64",
16
+ };
17
+ export interface InstalledNativeCompiler {
18
+ readonly executable: string;
19
+ readonly fontManifest: string;
20
+ readonly compilerVersion: string;
21
+ }
22
+ export async function installedNativeCompiler(): Promise<InstalledNativeCompiler> {
23
+ const name = NATIVE_PLATFORM_PACKAGES[`${process.platform}-${process.arch}`];
24
+ if (!name)
25
+ throw new Error(
26
+ `Native PDF does not support ${process.platform}/${process.arch}. Configure an explicit compatible compiler and fonts.`,
27
+ );
28
+ let root: string;
29
+ try {
30
+ root = dirname(require.resolve(`${name}/package.json`));
31
+ } catch {
32
+ throw new Error(
33
+ `Missing ${name}@${NATIVE_ARTIFACT_VERSION}. Install that native package; optional dependencies may have been omitted. PDF compilation never downloads binaries.`,
34
+ );
35
+ }
36
+ const manifest = parseJson(await readFile(resolve(root, "package.json")));
37
+ if (
38
+ !isPlainObject(manifest) ||
39
+ manifest["name"] !== name ||
40
+ manifest["version"] !== NATIVE_ARTIFACT_VERSION
41
+ )
42
+ throw new Error(
43
+ "Installed native PDF package version does not match Research.",
44
+ );
45
+ const artifact = parseJson(await readFile(resolve(root, "artifact.json")), {
46
+ maxBytes: 65536,
47
+ maxDepth: 8,
48
+ maxNodes: 512,
49
+ });
50
+ if (
51
+ !isPlainObject(artifact) ||
52
+ artifact["schemaVersion"] !== 1 ||
53
+ artifact["inputSchemaVersion"] !== 1 ||
54
+ artifact["compilerVersion"] !== NATIVE_ARTIFACT_VERSION ||
55
+ artifact["platform"] !== process.platform ||
56
+ artifact["architecture"] !== process.arch ||
57
+ !isPlainObject(artifact["files"])
58
+ )
59
+ throw new Error("Native artifact metadata is invalid or incompatible.");
60
+ const executable =
61
+ process.platform === "win32" ? "bin/publisle-pdf.exe" : "bin/publisle-pdf";
62
+ const paths = Object.keys(artifact["files"]);
63
+ if (
64
+ !paths.includes(executable) ||
65
+ !paths.includes("fonts.json") ||
66
+ paths.length > 64
67
+ )
68
+ throw new Error("Native artifact inventory is incomplete.");
69
+ const base = await realpath(root);
70
+ for (const [file, digest] of Object.entries(artifact["files"])) {
71
+ if (
72
+ typeof digest !== "string" ||
73
+ !/^[a-f0-9]{64}$/u.test(digest) ||
74
+ isAbsolute(file) ||
75
+ file.split(/[\\/]/u).includes("..")
76
+ )
77
+ throw new Error("Invalid native artifact path or checksum.");
78
+ const path = await realpath(resolve(base, file));
79
+ const local = relative(base, path);
80
+ if (
81
+ isAbsolute(local) ||
82
+ local === ".." ||
83
+ local.startsWith(`..${sep}`) ||
84
+ !(await stat(path)).isFile()
85
+ )
86
+ throw new Error("Native artifact escapes its package.");
87
+ const bytes = await readFile(path);
88
+ if (createHash("sha256").update(bytes).digest("hex") !== digest)
89
+ throw new Error(`Native artifact checksum mismatch: ${file}`);
90
+ }
91
+ return {
92
+ executable: resolve(base, executable),
93
+ fontManifest: resolve(base, "fonts.json"),
94
+ compilerVersion: NATIVE_ARTIFACT_VERSION,
95
+ };
96
+ }
@@ -0,0 +1,236 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import {
4
+ mkdtemp,
5
+ readFile,
6
+ realpath,
7
+ rm,
8
+ stat,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
13
+ import {
14
+ parseJson,
15
+ isPlainObject,
16
+ type Block,
17
+ type BlockType,
18
+ type Document,
19
+ } from "@publisle/schema";
20
+ import {
21
+ createNativePdfPackage,
22
+ type NativeFont,
23
+ type NativePdfOptions,
24
+ } from "./native.ts";
25
+ import { projectArticle, type ResearchLoss } from "./article.ts";
26
+
27
+ import { installedNativeCompiler } from "./native-install.ts";
28
+ const execute = promisify(execFile);
29
+ export interface NativeCompileOptions extends Omit<
30
+ NativePdfOptions,
31
+ "fonts" | "assets"
32
+ > {
33
+ readonly sourceDirectory: string;
34
+ readonly fonts?: readonly NativeFont[];
35
+ readonly executable?: string;
36
+ readonly fontManifest?: string;
37
+ readonly timeoutMs?: number;
38
+ readonly signal?: AbortSignal;
39
+ }
40
+ export interface NativeCompilerDiagnostic extends ResearchLoss {
41
+ readonly blockId?: string;
42
+ }
43
+ export interface NativeCompileResult {
44
+ readonly pdf: Uint8Array;
45
+ readonly diagnostics: readonly NativeCompilerDiagnostic[];
46
+ readonly timings: Readonly<Record<string, number>>;
47
+ readonly pages: number;
48
+ readonly compilerVersion: string;
49
+ }
50
+
51
+ /** Explicit native compiler invocation. Automatic selection remains gated on parity. */
52
+ export async function compileNativePdf(
53
+ document: Document<Block<BlockType, unknown>>,
54
+ options: NativeCompileOptions,
55
+ ): Promise<NativeCompileResult> {
56
+ const timeout = options.timeoutMs ?? 180_000;
57
+ if (!Number.isSafeInteger(timeout) || timeout <= 0)
58
+ throw new Error("Compilation timeout must be a positive integer.");
59
+ const explicitExecutable =
60
+ options.executable ?? process.env["PUBLISLE_NATIVE_COMPILER"];
61
+ const explicitManifest =
62
+ options.fontManifest ?? process.env["PUBLISLE_NATIVE_FONTS"];
63
+ const installed =
64
+ !explicitExecutable || (!options.fonts && !explicitManifest)
65
+ ? await installedNativeCompiler()
66
+ : undefined;
67
+ const executable = explicitExecutable ?? installed?.executable;
68
+ if (!executable) throw new Error("A pinned native PDF compiler is required.");
69
+ let fonts = options.fonts;
70
+ if (!fonts) {
71
+ const manifest = explicitManifest ?? installed?.fontManifest;
72
+ if (!manifest)
73
+ throw new Error(
74
+ "Set PUBLISLE_NATIVE_FONTS to the pinned font manifest JSON file.",
75
+ );
76
+ const bytes = await readFile(manifest);
77
+ const raw = parseJson(bytes, {
78
+ maxBytes: 65536,
79
+ maxDepth: 8,
80
+ maxNodes: 256,
81
+ });
82
+ if (!Array.isArray(raw))
83
+ throw new Error("Native font manifest must be an array.");
84
+ fonts = raw.map((entry: unknown) => {
85
+ if (
86
+ !isPlainObject(entry) ||
87
+ typeof entry["path"] !== "string" ||
88
+ !["regular", "bold", "italic", "mono", "fallback"].includes(
89
+ String(entry["role"]),
90
+ ) ||
91
+ (entry["index"] !== undefined &&
92
+ (!Number.isSafeInteger(entry["index"]) || Number(entry["index"]) < 0))
93
+ )
94
+ throw new Error("Invalid native font manifest entry.");
95
+ return {
96
+ path: resolve(dirname(manifest), entry["path"]),
97
+ role: entry["role"] as NativeFont["role"],
98
+ ...(entry["index"] === undefined
99
+ ? {}
100
+ : { index: Number(entry["index"]) }),
101
+ };
102
+ });
103
+ }
104
+ const base = await realpath(options.sourceDirectory);
105
+ const assets = await Promise.all(
106
+ [
107
+ ...new Set(
108
+ projectArticle(document).blocks.flatMap((block) =>
109
+ block.kind === "figure" ? [block.src] : [],
110
+ ),
111
+ ),
112
+ ].map(async (source) => {
113
+ if (/^[a-z][a-z0-9+.-]*:/iu.test(source))
114
+ throw new Error("Native CLI figures must be validated local files.");
115
+ const path = await realpath(resolve(base, source));
116
+ const local = relative(base, path);
117
+ if (isAbsolute(local) || local === ".." || local.startsWith(`..${sep}`))
118
+ throw new Error(`Figure asset escapes manuscript directory: ${source}`);
119
+ if ((await stat(path)).size > 16 * 1024 * 1024)
120
+ throw new Error(`Figure asset exceeds 16 MiB: ${source}`);
121
+ return { source, path };
122
+ }),
123
+ );
124
+ const source = createNativePdfPackage(document, {
125
+ ...options,
126
+ fonts,
127
+ assets,
128
+ });
129
+ const directory = await mkdtemp(join(tmpdir(), "publisle-native-"));
130
+ try {
131
+ const input = join(directory, "input.json");
132
+ const output = join(directory, "manuscript.pdf");
133
+ await writeFile(input, JSON.stringify(source.input), { flag: "wx" });
134
+ let stdout: string;
135
+ try {
136
+ stdout = (
137
+ await execute(executable, [input, output], {
138
+ timeout,
139
+ signal: options.signal,
140
+ maxBuffer: 65536,
141
+ })
142
+ ).stdout;
143
+ } catch (cause) {
144
+ const stdout =
145
+ cause && typeof cause === "object" && "stdout" in cause
146
+ ? String(cause.stdout)
147
+ : "";
148
+ let message = "Native PDF compilation failed.";
149
+ try {
150
+ const error = JSON.parse(stdout) as {
151
+ diagnostics?: {
152
+ code: string;
153
+ message: string;
154
+ blockIndex?: number;
155
+ }[];
156
+ };
157
+ const detail = error.diagnostics
158
+ ?.map(
159
+ (entry) =>
160
+ `${entry.code}${entry.blockIndex === undefined ? "" : ` (${source.blockIds[entry.blockIndex] ?? "unknown block"})`}: ${entry.message}`,
161
+ )
162
+ .join("\n");
163
+ if (detail) message = detail;
164
+ } catch {
165
+ /* Preserve the bounded process failure when no structured result exists. */
166
+ }
167
+ throw new Error(message, { cause });
168
+ }
169
+ const report = JSON.parse(stdout) as {
170
+ ok?: boolean;
171
+ compilerVersion?: string;
172
+ pages?: number;
173
+ diagnostics?: unknown;
174
+ timings?: unknown;
175
+ };
176
+ if (
177
+ report.ok !== true ||
178
+ typeof report.pages !== "number" ||
179
+ !Number.isSafeInteger(report.pages) ||
180
+ report.pages < 1 ||
181
+ report.pages > 300 ||
182
+ typeof report.compilerVersion !== "string" ||
183
+ report.compilerVersion.length === 0 ||
184
+ report.compilerVersion.length > 64 ||
185
+ (installed !== undefined &&
186
+ report.compilerVersion !== installed.compilerVersion) ||
187
+ (report.diagnostics !== undefined &&
188
+ (!Array.isArray(report.diagnostics) ||
189
+ report.diagnostics.length > 100 ||
190
+ report.diagnostics.some(
191
+ (entry) =>
192
+ !isPlainObject(entry) ||
193
+ typeof entry["code"] !== "string" ||
194
+ typeof entry["message"] !== "string",
195
+ ))) ||
196
+ (report.timings !== undefined &&
197
+ (!isPlainObject(report.timings) ||
198
+ Object.values(report.timings).some(
199
+ (value) =>
200
+ typeof value !== "number" || !Number.isFinite(value) || value < 0,
201
+ )))
202
+ )
203
+ throw new Error("Invalid native compiler report.");
204
+ const outputInfo = await stat(output);
205
+ if (outputInfo.size > 16 * 1024 * 1024)
206
+ throw new Error("Native PDF exceeds 16 MiB.");
207
+ const pdf = new Uint8Array(await readFile(output));
208
+ if (new TextDecoder().decode(pdf.subarray(0, 5)) !== "%PDF-")
209
+ throw new Error("Native compiler returned an invalid PDF.");
210
+ const expectedHeader =
211
+ source.input.pdfStandard === "ua-1"
212
+ ? "%PDF-1.7"
213
+ : source.input.pdfStandard === "ua-2"
214
+ ? "%PDF-2.0"
215
+ : undefined;
216
+ if (
217
+ expectedHeader &&
218
+ new TextDecoder().decode(pdf.subarray(0, 8)) !== expectedHeader
219
+ )
220
+ throw new Error(
221
+ "Native compiler returned the wrong PDF version for the requested standard.",
222
+ );
223
+ return {
224
+ pdf,
225
+ diagnostics: [
226
+ ...source.diagnostics,
227
+ ...((report.diagnostics ?? []) as ResearchLoss[]),
228
+ ],
229
+ timings: (report.timings ?? {}) as Record<string, number>,
230
+ pages: report.pages,
231
+ compilerVersion: report.compilerVersion,
232
+ };
233
+ } finally {
234
+ await rm(directory, { recursive: true, force: true });
235
+ }
236
+ }
package/src/native.ts ADDED
@@ -0,0 +1,184 @@
1
+ import {
2
+ isPlainObject,
3
+ type Block,
4
+ type BlockType,
5
+ type Document,
6
+ } from "@publisle/schema";
7
+ import { citationClusters, projectArticle } from "./article.ts";
8
+ import { resolveDocument, type ResolvedDocument } from "./resolve.ts";
9
+ import type { ArticleBlock, ArticleInline } from "./article.ts";
10
+
11
+ export const NATIVE_PDF_INPUT_VERSION = 1 as const;
12
+ export const NATIVE_PDF_TEMPLATES = [
13
+ "article",
14
+ "article-arabic",
15
+ "ieee-journal",
16
+ "acm-journal",
17
+ "springer-journal",
18
+ "elsevier-numeric",
19
+ "elsevier-author-date",
20
+ ] as const;
21
+ export type NativePdfTemplate = (typeof NATIVE_PDF_TEMPLATES)[number];
22
+ export interface NativeFont {
23
+ readonly path: string;
24
+ readonly role: "regular" | "bold" | "italic" | "mono" | "fallback";
25
+ readonly index?: number;
26
+ }
27
+ export interface NativePdfInput {
28
+ readonly schemaVersion: typeof NATIVE_PDF_INPUT_VERSION;
29
+ readonly template: NativePdfTemplate;
30
+ readonly templateVersion: string;
31
+ readonly pdfStandard: "ua-1" | "ua-2" | null;
32
+ readonly article: ResolvedDocument["article"] & { readonly title: string };
33
+ readonly citations: readonly string[];
34
+ readonly bibliography: ResolvedDocument["bibliography"];
35
+ readonly assets: readonly {
36
+ readonly source: string;
37
+ readonly path: string;
38
+ }[];
39
+ readonly fonts: readonly NativeFont[];
40
+ readonly templateData: { readonly country?: string };
41
+ }
42
+ export interface NativePdfOptions {
43
+ readonly template?: NativePdfTemplate;
44
+ readonly style?: string;
45
+ readonly pdfUa?: "ua-1" | "ua-2" | false;
46
+ readonly fonts: readonly NativeFont[];
47
+ readonly assets?: NativePdfInput["assets"];
48
+ readonly data?: { readonly country?: string };
49
+ }
50
+ export interface NativePdfPackage {
51
+ readonly input: NativePdfInput;
52
+ /** Maps bounded compiler block indices back to authored source IDs. */
53
+ readonly blockIds: readonly string[];
54
+ readonly diagnostics: ResolvedDocument["losses"];
55
+ }
56
+
57
+ /** Pure lowering shared by the website worker and Research CLI. No filesystem or network access. */
58
+ export function createNativePdfPackage(
59
+ document: Document<Block<BlockType, unknown>>,
60
+ options: NativePdfOptions,
61
+ ): NativePdfPackage {
62
+ const template = options.template ?? "article";
63
+ if (!NATIVE_PDF_TEMPLATES.includes(template))
64
+ throw new Error(`Unsupported native PDF template: ${template}`);
65
+ const resolved = resolveDocument(
66
+ document,
67
+ options.style ??
68
+ (template === "elsevier-author-date" ? "author-date" : "numeric"),
69
+ );
70
+ if (resolved.unresolved.length)
71
+ throw new Error(`Unresolved citations: ${resolved.unresolved.join(", ")}`);
72
+ if (
73
+ ["ieee-journal", "springer-journal"].includes(template) &&
74
+ resolved.form !== "numeric"
75
+ )
76
+ throw new Error("This journal profile requires numeric citations.");
77
+ if (template === "elsevier-author-date" && resolved.form !== "author-date")
78
+ throw new Error("This journal profile requires author-date citations.");
79
+ if (
80
+ options.data !== undefined &&
81
+ (!isPlainObject(options.data) ||
82
+ Object.keys(options.data).some((key) => key !== "country") ||
83
+ (options.data.country !== undefined &&
84
+ (typeof options.data.country !== "string" ||
85
+ options.data.country.length > 100)))
86
+ )
87
+ throw new Error("Unsupported native template metadata.");
88
+ if (
89
+ template === "acm-journal" &&
90
+ resolved.article.authorDetails?.some((author) => author.affiliation) &&
91
+ !options.data?.country?.trim()
92
+ )
93
+ throw new Error("ACM affiliations require template data: country.");
94
+ if (!resolved.article.title?.trim() || !resolved.article.authors.length)
95
+ throw new Error("PDF export requires a title and at least one author.");
96
+ if (options.fonts.length === 0 || options.fonts.length > 16)
97
+ throw new Error("Native PDF export requires one to sixteen pinned fonts.");
98
+ const article = structuredClone(resolved.article);
99
+ // CSL resolves cited footnotes at their first reference, rather than at the
100
+ // definition's physical position. Bind results before the native layout runs.
101
+ const notes = new Map(
102
+ article.blocks
103
+ .filter((block) => block.kind === "footnote")
104
+ .map((block) => [block.id, block]),
105
+ );
106
+ const visited = new Set<string>();
107
+ let cursor = 0;
108
+ const bind = (inlines: readonly ArticleInline[]): void => {
109
+ for (const inline of inlines) {
110
+ if (inline.type === "citation") {
111
+ const value = resolved.citations[cursor++];
112
+ if (value === undefined)
113
+ throw new Error(
114
+ "Resolved citation count does not match the article.",
115
+ );
116
+ Object.assign(inline, { text: value });
117
+ } else if (
118
+ inline.type === "text" &&
119
+ inline.reference?.kind === "footnote" &&
120
+ !visited.has(inline.reference.target)
121
+ ) {
122
+ visited.add(inline.reference.target);
123
+ notes.get(inline.reference.target)?.paragraphs.forEach(bind);
124
+ }
125
+ }
126
+ };
127
+ for (const block of article.blocks) visitInlines(block, bind);
128
+ if (cursor !== resolved.citations.length)
129
+ throw new Error("Resolved citation count does not match the article.");
130
+ // Each projection may emit multiple printable blocks (for example callouts).
131
+ const blockIds = document.blocks.flatMap((block) =>
132
+ projectArticle({ ...document, blocks: [block] }).blocks.map(() => block.id),
133
+ );
134
+ if (blockIds.length !== article.blocks.length)
135
+ throw new Error("Native projection source mapping is inconsistent.");
136
+ const clusters = citationClusters(resolved.article);
137
+ if (clusters.length !== resolved.citations.length)
138
+ throw new Error("Resolved citation clusters are inconsistent.");
139
+ return {
140
+ input: {
141
+ schemaVersion: NATIVE_PDF_INPUT_VERSION,
142
+ template,
143
+ templateVersion: "0.1.0-experimental",
144
+ pdfStandard: options.pdfUa === false ? null : (options.pdfUa ?? "ua-2"),
145
+ article: { ...article, title: resolved.article.title },
146
+ citations: resolved.citations,
147
+ bibliography: resolved.bibliography,
148
+ assets: options.assets ?? [],
149
+ fonts: options.fonts,
150
+ templateData: options.data ?? {},
151
+ },
152
+ blockIds,
153
+ diagnostics: resolved.losses,
154
+ };
155
+ }
156
+
157
+ function visitInlines(
158
+ block: ArticleBlock,
159
+ visit: (inlines: readonly ArticleInline[]) => void,
160
+ ): void {
161
+ switch (block.kind) {
162
+ case "heading":
163
+ case "paragraph":
164
+ visit(block.inlines);
165
+ break;
166
+ case "list":
167
+ block.items.forEach(visit);
168
+ break;
169
+ case "quote":
170
+ block.paragraphs.forEach(visit);
171
+ break;
172
+ case "figure":
173
+ visit(block.caption);
174
+ break;
175
+ case "table":
176
+ if (block.caption) visit(block.caption);
177
+ block.rows.forEach((row) => {
178
+ row.forEach(visit);
179
+ });
180
+ break;
181
+ default:
182
+ break;
183
+ }
184
+ }