@publisle/template-sdk 0.0.0-stage → 0.1.1

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/render.ts ADDED
@@ -0,0 +1,382 @@
1
+ import type { Article, ArticleInline, CitationEntry } from "./article.ts";
2
+ import type { LatexEngine, SourcePackage, TemplateContext } from "./index.ts";
3
+ import { toBibtex } from "./bibtex.ts";
4
+ import { latexKey, latexText } from "./escape.ts";
5
+ type WritingDirection = "ltr" | "rtl" | "auto";
6
+ export interface LatexProfile {
7
+ /** Only enable after validating the class against the pinned compiler. */
8
+ readonly tagging?: boolean;
9
+ readonly id: string;
10
+ readonly version: string;
11
+ readonly className: string;
12
+ readonly classOptions?: string;
13
+ readonly bibliographyStyle?: string;
14
+ readonly direction?: "ltr" | "rtl";
15
+ readonly defaultEngine?: LatexEngine;
16
+ readonly preamble?: string;
17
+ readonly automaticBibliographyStyle?: boolean;
18
+ readonly numericCitationPackage?: boolean;
19
+ readonly pdfSymbols?: boolean;
20
+ readonly keywords?: (
21
+ subjects: readonly string[],
22
+ text: (value: string) => string,
23
+ ) => string;
24
+ readonly citationCommand?: "cite" | "citep";
25
+ readonly author?: (
26
+ article: Article,
27
+ text: (value: string) => string,
28
+ ) => string;
29
+ readonly frontmatter?: (
30
+ abstract: string,
31
+ article: Article,
32
+ text: (value: string) => string,
33
+ ) => string;
34
+ }
35
+ export function renderLatexArticle(
36
+ article: Article,
37
+ context: TemplateContext,
38
+ profile: LatexProfile,
39
+ ): SourcePackage {
40
+ const engine = context.engine ?? profile.defaultEngine ?? "pdflatex";
41
+ const requested =
42
+ context.pdfUa ??
43
+ (context.data && Object.hasOwn(context.data, "pdfUa")
44
+ ? context.data["pdfUa"]
45
+ : "ua-2");
46
+ if (requested !== false && requested !== "ua-1" && requested !== "ua-2")
47
+ throw new Error("pdfUa must be ua-1, ua-2 or false.");
48
+ const pdfStandard =
49
+ profile.tagging && engine === "lualatex" && requested !== false
50
+ ? requested
51
+ : undefined;
52
+ const language =
53
+ article.language ?? (profile.direction === "rtl" ? "ar" : "en");
54
+ if (pdfStandard && !/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u.test(language))
55
+ throw new Error("Tagged PDF requires a BCP 47 document language.");
56
+ const fonts = {
57
+ latin: context.fonts?.["latin"] ?? "TeX Gyre Termes",
58
+ arabic: context.fonts?.["arabic"] ?? "PakType Naskh Basic",
59
+ cjk: context.fonts?.["cjk"] ?? "FandolSong",
60
+ math: context.fonts?.["math"] ?? "TeX Gyre Termes Math",
61
+ };
62
+ for (const font of Object.values(fonts))
63
+ if (!/^[\p{L}\p{N} .-]+$/u.test(font))
64
+ throw new Error(
65
+ "Font names must contain letters, digits, spaces, dots or hyphens.",
66
+ );
67
+ const text = (value: string, direction?: WritingDirection): string => {
68
+ if (engine === "pdflatex" && /[^\u0020-\u007e\t\n\r]/u.test(value))
69
+ throw new Error(
70
+ "Non-ASCII journal text requires --engine lualatex (no glyph substitution is performed).",
71
+ );
72
+ // Script runs select shaping fonts while the surrounding article stays LTR.
73
+ const escaped = value
74
+ .split(
75
+ /([\p{Script=Arabic}\p{M}\u060c\u061b\u061f]+(?:[ \t]+[\p{Script=Arabic}\p{M}\u060c\u061b\u061f]+)*|[\p{Script=Han}\u3000-\u303f\uff00-\uffef]+)/u,
76
+ )
77
+ .map((run) => {
78
+ const safe = latexText(run);
79
+ if (
80
+ engine === "lualatex" &&
81
+ /[\p{Script=Arabic}\u060c\u061b\u061f]/u.test(run)
82
+ )
83
+ return `\\foreignlanguage{arabic}{${safe}}`;
84
+ if (
85
+ engine === "lualatex" &&
86
+ /[\p{Script=Han}\u3000-\u303f\uff00-\uffef]/u.test(run)
87
+ )
88
+ return `{\\cjkfont ${safe}}`;
89
+ const symbols: Readonly<Record<string, string>> = {
90
+ α: "\\alpha",
91
+ β: "\\beta",
92
+ γ: "\\gamma",
93
+ δ: "\\delta",
94
+ π: "\\pi",
95
+ "∑": "\\sum",
96
+ "∞": "\\infty",
97
+ "≤": "\\leq",
98
+ "≥": "\\geq",
99
+ "±": "\\pm",
100
+ "∈": "\\in",
101
+ "∫": "\\int",
102
+ "√": "\\surd",
103
+ };
104
+ return safe.replace(
105
+ /[\p{Sm}αβγδπ]/gu,
106
+ (symbol) => `\\(${symbols[symbol] ?? symbol}\\)`,
107
+ );
108
+ })
109
+ .join("");
110
+ return direction === "rtl" && engine === "lualatex"
111
+ ? `\\foreignlanguage{arabic}{${escaped}}`
112
+ : escaped;
113
+ };
114
+ const labels = new Set<string>();
115
+ const footnotes = new Map<string, readonly (readonly ArticleInline[])[]>();
116
+ const footnoteNumbers = new Map<string, number>();
117
+ const assets: { source: string; destination: string }[] = [];
118
+ for (const block of article.blocks) {
119
+ if (block.label) {
120
+ if (labels.has(block.label))
121
+ throw new Error(`Duplicate label: ${block.label}`);
122
+ labels.add(block.label);
123
+ }
124
+ if (block.kind === "footnote") {
125
+ if (footnotes.has(block.id))
126
+ throw new Error(`Duplicate footnote: ${block.id}`);
127
+ footnotes.set(block.id, block.paragraphs);
128
+ }
129
+ }
130
+ const activeNotes = new Set<string>();
131
+ let citationIndex = 0;
132
+ let emittedNotes = 0;
133
+ const take = (inlines: readonly ArticleInline[]): string =>
134
+ inlines
135
+ .map((inline) => {
136
+ if (inline.type === "break") return " ";
137
+ if (inline.type === "math") return `\\(${inline.value}\\)`;
138
+ if (inline.type === "citation") {
139
+ const formatted =
140
+ context.citationText?.(inline.cluster) ??
141
+ context.citations[citationIndex++] ??
142
+ "";
143
+ if (!profile.bibliographyStyle) {
144
+ if (context.citationForm === "note" && activeNotes.size === 0) {
145
+ emittedNotes++;
146
+ return `\\footnote{${text(formatted)}}`;
147
+ }
148
+ return text(formatted);
149
+ }
150
+ const locators = inline.cluster.items.filter(
151
+ (item) => item.locator !== undefined,
152
+ );
153
+ if (locators.length > 1)
154
+ throw new Error(
155
+ "BibTeX citation clusters support one locator; split this cluster.",
156
+ );
157
+ if (inline.cluster.items.some((item) => item.suppressAuthor))
158
+ throw new Error(
159
+ "Author suppression is not supported by this numeric BibTeX profile.",
160
+ );
161
+ const locator = locators[0];
162
+ return `${text(inline.cluster.prefix ?? "")}\\${profile.citationCommand ?? "cite"}${locator ? `[${text(`${locator.label ?? "p."} ${locator.locator ?? ""}`)}]` : ""}{${inline.cluster.items.map((item) => latexKey(item.id)).join(",")}}${text(inline.cluster.suffix ?? "")}`;
163
+ }
164
+ if (inline.type === "link")
165
+ return `\\href{${latexText(inline.url)}}{${text(inline.value)}}`;
166
+ if (inline.reference?.kind === "cross") {
167
+ if (!labels.has(inline.reference.target))
168
+ throw new Error(
169
+ `Unresolved cross-reference: ${inline.reference.target}`,
170
+ );
171
+ return inline.value === inline.reference.target
172
+ ? `\\ref{${latexKey(inline.reference.target)}}`
173
+ : `\\hyperref[${latexKey(inline.reference.target)}]{${text(inline.value)}}`;
174
+ }
175
+ if (inline.reference?.kind === "footnote") {
176
+ const id = inline.reference.target;
177
+ const paragraphs = footnotes.get(id);
178
+ if (!paragraphs) throw new Error(`Unresolved footnote: ${id}`);
179
+ if (activeNotes.has(id)) throw new Error(`Recursive footnote: ${id}`);
180
+ const number = footnoteNumbers.get(id);
181
+ if (number !== undefined) return `\\footnotemark[${String(number)}]`;
182
+ footnoteNumbers.set(id, ++emittedNotes);
183
+ activeNotes.add(id);
184
+ const value = paragraphs.map(take).join(" ");
185
+ activeNotes.delete(id);
186
+ return `\\footnote{${value}}`;
187
+ }
188
+ const value = text(inline.value, inline.direction);
189
+ const command =
190
+ inline.mark === "strong"
191
+ ? "textbf"
192
+ : inline.mark === "emphasis"
193
+ ? "emph"
194
+ : inline.mark === "code"
195
+ ? "texttt"
196
+ : inline.mark === "strike"
197
+ ? "sout"
198
+ : undefined;
199
+ return command ? `\\${command}{${value}}` : value;
200
+ })
201
+ .join("");
202
+ const abstract: string[] = [];
203
+ const body: string[] = [];
204
+ for (const block of article.blocks) {
205
+ const target = "abstract" in block && block.abstract ? abstract : body;
206
+ const label = block.label ? `\\label{${latexKey(block.label)}}` : "";
207
+ if (block.kind === "heading") {
208
+ if (!block.abstract)
209
+ target.push(
210
+ `\\${block.level <= 1 ? "section" : block.level === 2 ? "subsection" : "subsubsection"}{${take(block.inlines)}}${label}`,
211
+ );
212
+ } else if (block.kind === "paragraph") target.push(take(block.inlines));
213
+ else if (block.kind === "math")
214
+ target.push(
215
+ block.display
216
+ ? `\\begin{equation}${block.value}${label}\\end{equation}`
217
+ : `\\(${block.value}\\)`,
218
+ );
219
+ else if (block.kind === "figure") {
220
+ if (pdfStandard && !block.alt.trim())
221
+ throw new Error("Tagged PDF figures require alternative text.");
222
+ if (/^(?:[a-z]+:|\/\/)/iu.test(block.src))
223
+ throw new Error(`Remote figure assets are unsupported: ${block.src}`);
224
+ const extension = /\.(pdf|png|jpe?g)$/iu
225
+ .exec(block.src)?.[1]
226
+ ?.toLowerCase();
227
+ if (!extension)
228
+ throw new Error(
229
+ `Unsupported figure asset (use PDF, PNG or JPEG): ${block.src}`,
230
+ );
231
+ const destination = `assets/figure-${String(assets.length + 1)}.${extension}`;
232
+ assets.push({ source: block.src, destination });
233
+ target.push(
234
+ `\\begin{figure}[!t]\n\\centering\n\\includegraphics[width=\\columnwidth${pdfStandard ? `,alt={${latexText(block.alt)}}` : ""}]{${destination}}\n\\caption{${take(block.caption)}}${label}\n\\end{figure}`,
235
+ );
236
+ } else if (block.kind === "table") {
237
+ const width = block.rows[0]?.length ?? 0;
238
+ if (width === 0 || block.rows.some((row) => row.length !== width))
239
+ throw new Error("Journal tables must be nonempty and rectangular.");
240
+ if (
241
+ pdfStandard &&
242
+ (!Number.isInteger(block.headerRows) ||
243
+ (block.headerRows ?? 0) < 1 ||
244
+ (block.headerRows ?? 0) > block.rows.length)
245
+ )
246
+ throw new Error("Tagged PDF tables require valid headerRows.");
247
+ const rows = block.rows
248
+ .map(
249
+ (row, index) =>
250
+ row
251
+ .map((cell) =>
252
+ index < (block.headerRows ?? 0)
253
+ ? `\\textbf{${take(cell)}}`
254
+ : take(cell),
255
+ )
256
+ .join(" & ") + " \\\\",
257
+ )
258
+ .join("\n");
259
+ target.push(
260
+ `\\begin{table}[!t]\n\\caption{${take(block.caption ?? [])}}${label}\n\\centering\n${pdfStandard ? `\\tagpdfsetup{table/header-rows={${Array.from({ length: block.headerRows ?? 0 }, (_, index) => String(index + 1)).join(",")}}}\n` : ""}\\begin{tabularx}{\\columnwidth}{${"X".repeat(width)}}\n\\hline\n${rows}\n\\hline\n\\end{tabularx}\n\\end{table}`,
261
+ );
262
+ } else if (block.kind === "list")
263
+ target.push(
264
+ `\\begin{${block.ordered ? "enumerate" : "itemize"}}\n${block.items.map((item) => `\\item ${take(item)}`).join("\n")}\n\\end{${block.ordered ? "enumerate" : "itemize"}}`,
265
+ );
266
+ else if (block.kind === "quote")
267
+ target.push(
268
+ `\\begin{quote}${block.paragraphs.map(take).join("\n\n")}\\end{quote}`,
269
+ );
270
+ else if (block.kind === "code") {
271
+ if (block.value.includes("\\end{verbatim}"))
272
+ throw new Error("Code contains a verbatim terminator.");
273
+ if (engine === "pdflatex" && /[^\u0020-\u007e\t\n\r]/u.test(block.value))
274
+ throw new Error("Unicode code requires LuaLaTeX.");
275
+ target.push(`\\begin{verbatim}\n${block.value}\n\\end{verbatim}`);
276
+ }
277
+ }
278
+ const unicodePreamble =
279
+ engine === "lualatex"
280
+ ? `\\usepackage{fontspec}
281
+ \\setmainfont{${fonts.latin}}
282
+ \\usepackage{unicode-math}
283
+ \\setmathfont{${fonts.math}}
284
+ \\usepackage[bidi=basic]{babel}
285
+ ${profile.direction === "rtl" ? "\\babelprovide[import]{english}\n\\babelprovide[import,main]{arabic}" : "\\babelprovide[import,main]{english}\n\\babelprovide[import]{arabic}"}
286
+ \\babelfont[arabic]{rm}{${fonts.arabic}}
287
+ \\newfontfamily\\cjkfont{${fonts.cjk}}
288
+ `
289
+ : "\\usepackage[utf8]{inputenc}\n\\usepackage[T1]{fontenc}\n";
290
+ const authors = article.authors.map((name) => text(name)).join(", ");
291
+ const affiliations = (article.authorDetails ?? [])
292
+ .filter((author) => author.affiliation)
293
+ .map(
294
+ (author) =>
295
+ `\\thanks{${text(author.name)} is with ${text(author.affiliation ?? "")}.}`,
296
+ )
297
+ .join("");
298
+ // BibTeX values are data, not authored TeX. Escape them before writing fields.
299
+ const entries = article.entries.map((entry) => {
300
+ const mapped: CitationEntry = {
301
+ id: latexKey(entry.id),
302
+ authors: entry.authors.map((author) => text(author)),
303
+ };
304
+ return {
305
+ ...mapped,
306
+ ...(entry.type === undefined ? {} : { type: entry.type }),
307
+ ...(entry.raw && !entry.title && !entry.authors.length && !entry.issued
308
+ ? { title: text(entry.raw) }
309
+ : {}),
310
+ ...Object.fromEntries(
311
+ Object.entries(entry)
312
+ .filter(
313
+ ([key, value]) =>
314
+ key !== "id" &&
315
+ key !== "authors" &&
316
+ key !== "type" &&
317
+ key !== "raw" &&
318
+ typeof value === "string",
319
+ )
320
+ .map(([key, value]) => [key, text(String(value))]),
321
+ ),
322
+ };
323
+ });
324
+ const manuscript = `${pdfStandard ? `\\DocumentMetadata{lang=${language},pdfstandard=${pdfStandard},${pdfStandard === "ua-1" ? "pdfversion=1.7," : ""}tagging=on,tagging-setup={math/setup={mathml-AF${pdfStandard === "ua-2" ? ",mathml-SE" : ""}}}}\n` : ""}\\documentclass${profile.classOptions ? `[${profile.classOptions}]` : ""}{${profile.className}}
325
+ ${unicodePreamble}\\usepackage{amsmath,${engine === "pdflatex" && profile.pdfSymbols === true ? "amssymb," : ""}graphicx,tabularx${profile.numericCitationPackage ? ",cite" : ""}}
326
+ \\usepackage[normalem]{ulem}
327
+ \\usepackage{hyperref}
328
+ \\hypersetup{hidelinks}
329
+ \\hypersetup{pdftitle={${latexText(article.title ?? "Untitled")}},pdfauthor={${latexText(article.authors.join(", "))}}}
330
+ ${profile.author ? profile.author(article, text) : `\\title{${text(article.title ?? "Untitled")}}\n\\author{${authors}${affiliations}}`}
331
+ ${profile.preamble ?? ""}
332
+ \\begin{document}
333
+ ${profile.frontmatter ? profile.frontmatter(abstract.join("\n\n"), article, text) : `\\maketitle\n\\begin{abstract}\n${abstract.join("\n\n")}\n\\end{abstract}`}
334
+ ${profile.keywords?.(article.subjects ?? [], text) ?? ""}
335
+ ${body.join("\n\n")}
336
+ ${profile.bibliographyStyle ? `${profile.automaticBibliographyStyle ? "" : `\\bibliographystyle{${profile.bibliographyStyle}}\n`}\\bibliography{references}` : `\\section*{${profile.direction === "rtl" ? text("المراجع") : "References"}}\n${context.bibliography.map((item) => text(item.text)).join("\n\n")}`}
337
+ \\end{document}
338
+ `;
339
+ return {
340
+ template: profile.id,
341
+ templateVersion: profile.version,
342
+ format: "latex",
343
+ engine,
344
+ ...(pdfStandard ? { pdfStandard } : {}),
345
+ requirements: [
346
+ "amsmath.sty",
347
+ "graphicx.sty",
348
+ "tabularx.sty",
349
+ "ulem.sty",
350
+ "hyperref.sty",
351
+ ...(pdfStandard ? ["tagpdf.sty", "luamml.sty"] : []),
352
+ ...(engine === "lualatex"
353
+ ? ["fontspec.sty", "unicode-math.sty", "lualatex-math.sty", "babel.sty"]
354
+ : []),
355
+ `${profile.className}.cls`,
356
+ ...(profile.bibliographyStyle
357
+ ? [`${profile.bibliographyStyle}.bst`]
358
+ : []),
359
+ ],
360
+ fonts: engine === "lualatex" ? fonts : {},
361
+ files: {
362
+ "manuscript.tex": manuscript,
363
+ "references.bib": toBibtex(entries),
364
+ "BUILD.md": `# Rebuild\n\nRequires latexmk, ${engine}, and ${profile.className}.${pdfStandard ? ` LaTeX format >= 2025-11-01 is required for ${pdfStandard} tagging. Compiling does not run PDF/UA validation.` : ""}\n\nRun: latexmk -${engine === "pdflatex" ? "pdf" : "lualatex"} -norc -interaction=nonstopmode -halt-on-error -no-shell-escape manuscript.tex\n`,
365
+ },
366
+ assets,
367
+ diagnostics: [
368
+ ...article.losses,
369
+ ...(pdfStandard
370
+ ? []
371
+ : [
372
+ {
373
+ code: "pdf-ua-unavailable",
374
+ message:
375
+ requested === false
376
+ ? "PDF tagging was explicitly disabled; this output does not claim PDF/UA conformance."
377
+ : `PDF/UA tagging is unavailable for ${profile.id} with ${engine}; use a LuaLaTeX article template.`,
378
+ },
379
+ ]),
380
+ ],
381
+ };
382
+ }