Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/command/render/output-typst.ts
12925 views
1
/*
2
* output-typst.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import {
8
dirname,
9
isAbsolute,
10
join,
11
normalize,
12
relative,
13
resolve,
14
} from "../../deno_ral/path.ts";
15
import {
16
copySync,
17
ensureDirSync,
18
existsSync,
19
safeRemoveSync,
20
} from "../../deno_ral/fs.ts";
21
import {
22
builtinSubtreeExtensions,
23
inputExtensionDirs,
24
readExtensions,
25
readSubtreeExtensions,
26
} from "../../extension/extension.ts";
27
import { projectScratchPath } from "../../project/project-scratch.ts";
28
import { resourcePath } from "../../core/resources.ts";
29
30
import {
31
kFontPaths,
32
kKeepTyp,
33
kOutputExt,
34
kOutputFile,
35
kPdfStandard,
36
kVariant,
37
pdfStandardEnv,
38
} from "../../config/constants.ts";
39
import { error, warning } from "../../deno_ral/log.ts";
40
import { ErrorEx } from "../../core/lib/error.ts";
41
import { Format } from "../../config/types.ts";
42
import { writeFileToStdout } from "../../core/console.ts";
43
import { dirAndStem, expandPath } from "../../core/path.ts";
44
import { kStdOut, replacePandocOutputArg } from "./flags.ts";
45
import { OutputRecipe, RenderOptions } from "./types.ts";
46
import { normalizeOutputPath } from "./output-shared.ts";
47
import {
48
typstCompile,
49
TypstCompileOptions,
50
validateRequiredTypstVersion,
51
} from "../../core/typst.ts";
52
import { runAnalyze, toTomlPath } from "../../core/typst-gather.ts";
53
import { asArray } from "../../core/array.ts";
54
import { ProjectContext } from "../../project/types.ts";
55
import { validatePdfStandards } from "../../core/verapdf.ts";
56
57
export interface NeededPackage {
58
namespace: string;
59
name: string;
60
version: string;
61
}
62
63
// Collect all package source directories (built-in + extensions)
64
async function collectPackageSources(
65
input: string,
66
projectDir: string,
67
): Promise<string[]> {
68
const sources: string[] = [];
69
70
// 1. Built-in packages
71
const builtinPackages = resourcePath("formats/typst/packages");
72
if (existsSync(builtinPackages)) {
73
sources.push(builtinPackages);
74
}
75
76
// 2. Extension packages
77
const extensionDirs = inputExtensionDirs(input, projectDir);
78
const subtreePath = builtinSubtreeExtensions();
79
for (const extDir of extensionDirs) {
80
const extensions = extDir === subtreePath
81
? await readSubtreeExtensions(extDir)
82
: await readExtensions(extDir);
83
for (const ext of extensions) {
84
const packagesDir = join(ext.path, "typst/packages");
85
if (existsSync(packagesDir)) {
86
sources.push(packagesDir);
87
}
88
}
89
}
90
91
return sources;
92
}
93
94
// Build the TOML config string for typst-gather analyze
95
export function buildAnalyzeToml(
96
typstInput: string,
97
packageSources: string[],
98
): string {
99
const discoverPath = toTomlPath(typstInput);
100
const cachePaths = packageSources.map((p) => `"${toTomlPath(p)}"`).join(", ");
101
102
return [
103
`discover = ["${discoverPath}"]`,
104
`package-cache = [${cachePaths}]`,
105
].join("\n");
106
}
107
108
// Run typst-gather analyze on the .typ file to determine needed packages
109
async function analyzeNeededPackages(
110
typstInput: string,
111
packageSources: string[],
112
): Promise<NeededPackage[] | null> {
113
const tomlConfig = buildAnalyzeToml(typstInput, packageSources);
114
115
try {
116
const result = await runAnalyze(tomlConfig);
117
return result.imports.map(({ namespace, name, version }) => ({
118
namespace,
119
name,
120
version,
121
}));
122
} catch {
123
// Fallback: if analyze fails, stage everything (current behavior)
124
warning("typst-gather analyze failed; staging all packages as fallback");
125
return null;
126
}
127
}
128
129
// Stage only the needed packages from source dirs into the cache dir.
130
// Last write wins — extensions (listed after built-in) override built-in packages.
131
export function stageSelectedPackages(
132
sources: string[],
133
cacheDir: string,
134
needed: NeededPackage[] | null,
135
): void {
136
if (needed === null) {
137
stageAllPackages(sources, cacheDir);
138
return;
139
}
140
141
for (const pkg of needed) {
142
const relPath = join(pkg.namespace, pkg.name, pkg.version);
143
const destPath = join(cacheDir, relPath);
144
145
for (const source of sources) {
146
const srcPath = join(source, relPath);
147
if (existsSync(srcPath)) {
148
ensureDirSync(dirname(destPath));
149
copySync(srcPath, destPath, { overwrite: true });
150
}
151
}
152
}
153
}
154
155
// Fallback: copy all packages from all sources. Last write wins at the
156
// package directory level. Built-in listed first, extensions after.
157
export function stageAllPackages(sources: string[], cacheDir: string): void {
158
for (const source of sources) {
159
for (const nsEntry of Deno.readDirSync(source)) {
160
if (!nsEntry.isDirectory) continue;
161
const nsSrc = join(source, nsEntry.name);
162
const nsDest = join(cacheDir, nsEntry.name);
163
ensureDirSync(nsDest);
164
for (const pkgEntry of Deno.readDirSync(nsSrc)) {
165
const pkgSrc = join(nsSrc, pkgEntry.name);
166
const pkgDest = join(nsDest, pkgEntry.name);
167
copySync(pkgSrc, pkgDest, { overwrite: true });
168
}
169
}
170
}
171
}
172
173
// Stage typst packages to .quarto/typst-packages/
174
// First stages built-in packages, then extension packages (which can override)
175
async function stageTypstPackages(
176
input: string,
177
typstInput: string,
178
projectDir?: string,
179
): Promise<string | undefined> {
180
if (!projectDir) {
181
return undefined;
182
}
183
184
const packageSources = await collectPackageSources(input, projectDir);
185
if (packageSources.length === 0) {
186
return undefined;
187
}
188
189
const neededPackages = await analyzeNeededPackages(
190
typstInput,
191
packageSources,
192
);
193
194
const cacheDir = projectScratchPath(projectDir, "typst/packages");
195
stageSelectedPackages(packageSources, cacheDir, neededPackages);
196
197
return cacheDir;
198
}
199
200
export function useTypstPdfOutputRecipe(
201
format: Format,
202
) {
203
return format.pandoc.to === "typst" &&
204
format.render[kOutputExt] === "pdf";
205
}
206
207
export function typstPdfOutputRecipe(
208
input: string,
209
finalOutput: string,
210
options: RenderOptions,
211
format: Format,
212
project?: ProjectContext,
213
): OutputRecipe {
214
// calculate output and args for pandoc (this is an intermediate file
215
// which we will then compile to a pdf and rename to .typ)
216
const [inputDir, inputStem] = dirAndStem(input);
217
const output = inputStem + ".typ";
218
let args = options.pandocArgs || [];
219
const pandoc = { ...format.pandoc };
220
if (options.flags?.output) {
221
args = replacePandocOutputArg(args, output);
222
} else {
223
pandoc[kOutputFile] = output;
224
}
225
226
// when pandoc is done, we need to run the pdf generator and then copy the
227
// output to the user's requested destination
228
const complete = async () => {
229
// input file is pandoc's output
230
const typstInput = join(inputDir, output);
231
232
// run typst
233
await validateRequiredTypstVersion();
234
const pdfOutput = join(inputDir, inputStem + ".pdf");
235
const typstOptions: TypstCompileOptions = {
236
quiet: options.flags?.quiet,
237
fontPaths: (asArray(format.metadata?.[kFontPaths]) as string[]).map(
238
(p) => isAbsolute(p) ? p : resolve(inputDir, p),
239
),
240
pdfStandard: normalizePdfStandardForTypst(
241
asArray(
242
format.render?.[kPdfStandard] ?? format.metadata?.[kPdfStandard] ??
243
pdfStandardEnv(),
244
),
245
),
246
};
247
if (project?.dir) {
248
typstOptions.rootDir = project.dir;
249
250
// Stage extension typst packages
251
const packagePath = await stageTypstPackages(
252
input,
253
typstInput,
254
project.dir,
255
);
256
if (packagePath) {
257
typstOptions.packagePath = packagePath;
258
}
259
}
260
const result = await typstCompile(
261
typstInput,
262
pdfOutput,
263
typstOptions,
264
);
265
if (!result.success) {
266
if (result.stderr) {
267
error(result.stderr);
268
}
269
throw new ErrorEx("Error", "Typst compilation failed", false, false);
270
}
271
272
// Validate PDF against specified standards using verapdf (if available)
273
const pdfStandards = asArray(
274
format.render?.[kPdfStandard] ?? format.metadata?.[kPdfStandard] ??
275
pdfStandardEnv(),
276
) as string[];
277
if (pdfStandards.length > 0) {
278
await validatePdfStandards(pdfOutput, pdfStandards, {
279
quiet: options.flags?.quiet,
280
});
281
}
282
283
// keep typ if requested
284
if (!format.render[kKeepTyp]) {
285
safeRemoveSync(typstInput);
286
}
287
288
// copy (or write for stdout) compiled pdf to final output location
289
if (finalOutput) {
290
if (finalOutput === kStdOut) {
291
writeFileToStdout(pdfOutput);
292
safeRemoveSync(pdfOutput);
293
} else {
294
const outputPdf = expandPath(finalOutput);
295
296
if (normalize(pdfOutput) !== normalize(outputPdf)) {
297
// ensure the target directory exists
298
ensureDirSync(dirname(outputPdf));
299
Deno.renameSync(pdfOutput, outputPdf);
300
}
301
}
302
303
// final output needs to either absolute or input dir relative
304
// (however it may be working dir relative when it is passed in)
305
return normalizeOutputPath(typstInput, finalOutput);
306
} else {
307
return normalizeOutputPath(typstInput, pdfOutput);
308
}
309
};
310
311
const pdfOutput = finalOutput
312
? finalOutput === kStdOut
313
? undefined
314
: normalizeOutputPath(input, finalOutput)
315
: normalizeOutputPath(input, join(inputDir, inputStem + ".pdf"));
316
317
// return recipe
318
const recipe: OutputRecipe = {
319
output,
320
keepYaml: false,
321
args,
322
format: { ...format, pandoc },
323
complete,
324
finalOutput: pdfOutput ? relative(inputDir, pdfOutput) : undefined,
325
};
326
327
// if we have some variant declared, resolve it
328
// (use for opt-out citations extension)
329
if (format.render?.[kVariant]) {
330
const to = format.pandoc.to;
331
const variant = format.render[kVariant];
332
333
recipe.format = {
334
...recipe.format,
335
pandoc: {
336
...recipe.format.pandoc,
337
to: `${to}${variant}`,
338
},
339
};
340
}
341
342
return recipe;
343
}
344
345
// Typst-supported PDF standards
346
const kTypstSupportedStandards = new Set([
347
"1.4",
348
"1.5",
349
"1.6",
350
"1.7",
351
"2.0",
352
"a-1b",
353
"a-1a",
354
"a-2b",
355
"a-2u",
356
"a-2a",
357
"a-3b",
358
"a-3u",
359
"a-3a",
360
"a-4",
361
"a-4f",
362
"a-4e",
363
"ua-1",
364
]);
365
366
function normalizePdfStandardForTypst(standards: unknown[]): string[] {
367
const result: string[] = [];
368
for (const s of standards) {
369
// Convert to string - YAML may parse versions like 2.0 as integer 2
370
let str: string;
371
if (typeof s === "number") {
372
// Handle YAML numeric parsing: integer 2 -> "2.0", float 1.4 -> "1.4"
373
str = Number.isInteger(s) ? `${s}.0` : String(s);
374
} else if (typeof s === "string") {
375
str = s;
376
} else {
377
continue;
378
}
379
// Normalize: lowercase, remove any "pdf" prefix
380
const normalized = str.toLowerCase().replace(/^pdf[/-]?/, "");
381
if (kTypstSupportedStandards.has(normalized)) {
382
result.push(normalized);
383
} else {
384
warning(
385
`PDF standard '${s}' is not supported by Typst and will be ignored`,
386
);
387
}
388
}
389
return result;
390
}
391
392