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
6428 views
1
/*
2
* output-typst.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { dirname, join, normalize, relative } from "../../deno_ral/path.ts";
8
import {
9
copySync,
10
ensureDirSync,
11
existsSync,
12
safeRemoveSync,
13
} from "../../deno_ral/fs.ts";
14
import {
15
builtinSubtreeExtensions,
16
inputExtensionDirs,
17
readExtensions,
18
readSubtreeExtensions,
19
} from "../../extension/extension.ts";
20
import { projectScratchPath } from "../../project/project-scratch.ts";
21
import { resourcePath } from "../../core/resources.ts";
22
23
import {
24
kFontPaths,
25
kKeepTyp,
26
kOutputExt,
27
kOutputFile,
28
kPdfStandard,
29
kVariant,
30
} from "../../config/constants.ts";
31
import { error, warning } from "../../deno_ral/log.ts";
32
import { Format } from "../../config/types.ts";
33
import { writeFileToStdout } from "../../core/console.ts";
34
import { dirAndStem, expandPath } from "../../core/path.ts";
35
import { kStdOut, replacePandocOutputArg } from "./flags.ts";
36
import { OutputRecipe, RenderOptions } from "./types.ts";
37
import { normalizeOutputPath } from "./output-shared.ts";
38
import {
39
typstCompile,
40
TypstCompileOptions,
41
validateRequiredTypstVersion,
42
} from "../../core/typst.ts";
43
import { asArray } from "../../core/array.ts";
44
import { ProjectContext } from "../../project/types.ts";
45
import { validatePdfStandards } from "../../core/verapdf.ts";
46
47
// Stage typst packages to .quarto/typst-packages/
48
// First stages built-in packages, then extension packages (which can override)
49
async function stageTypstPackages(
50
input: string,
51
projectDir?: string,
52
): Promise<string | undefined> {
53
if (!projectDir) {
54
return undefined;
55
}
56
57
const packageSources: string[] = [];
58
59
// 1. Add built-in packages from quarto resources
60
const builtinPackages = resourcePath("formats/typst/packages");
61
if (existsSync(builtinPackages)) {
62
packageSources.push(builtinPackages);
63
}
64
65
// 2. Add packages from extensions (can override built-in)
66
const extensionDirs = inputExtensionDirs(input, projectDir);
67
const subtreePath = builtinSubtreeExtensions();
68
for (const extDir of extensionDirs) {
69
// Use readSubtreeExtensions for subtree directory, readExtensions for others
70
const extensions = extDir === subtreePath
71
? await readSubtreeExtensions(extDir)
72
: await readExtensions(extDir);
73
for (const ext of extensions) {
74
const packagesDir = join(ext.path, "typst/packages");
75
if (existsSync(packagesDir)) {
76
packageSources.push(packagesDir);
77
}
78
}
79
}
80
81
if (packageSources.length === 0) {
82
return undefined;
83
}
84
85
// Stage to .quarto/typst/packages/
86
const cacheDir = projectScratchPath(projectDir, "typst/packages");
87
88
// Copy contents of each source directory (merging namespaces like "preview", "local")
89
for (const source of packageSources) {
90
for (const entry of Deno.readDirSync(source)) {
91
const srcPath = join(source, entry.name);
92
const destPath = join(cacheDir, entry.name);
93
if (!existsSync(destPath)) {
94
copySync(srcPath, destPath);
95
} else if (entry.isDirectory) {
96
// Merge directory contents (e.g., merge packages within "preview" namespace)
97
for (const subEntry of Deno.readDirSync(srcPath)) {
98
const subSrcPath = join(srcPath, subEntry.name);
99
const subDestPath = join(destPath, subEntry.name);
100
if (!existsSync(subDestPath)) {
101
copySync(subSrcPath, subDestPath);
102
}
103
}
104
}
105
}
106
}
107
108
return cacheDir;
109
}
110
111
export function useTypstPdfOutputRecipe(
112
format: Format,
113
) {
114
return format.pandoc.to === "typst" &&
115
format.render[kOutputExt] === "pdf";
116
}
117
118
export function typstPdfOutputRecipe(
119
input: string,
120
finalOutput: string,
121
options: RenderOptions,
122
format: Format,
123
project?: ProjectContext,
124
): OutputRecipe {
125
// calculate output and args for pandoc (this is an intermediate file
126
// which we will then compile to a pdf and rename to .typ)
127
const [inputDir, inputStem] = dirAndStem(input);
128
const output = inputStem + ".typ";
129
let args = options.pandocArgs || [];
130
const pandoc = { ...format.pandoc };
131
if (options.flags?.output) {
132
args = replacePandocOutputArg(args, output);
133
} else {
134
pandoc[kOutputFile] = output;
135
}
136
137
// when pandoc is done, we need to run the pdf generator and then copy the
138
// output to the user's requested destination
139
const complete = async () => {
140
// input file is pandoc's output
141
const typstInput = join(inputDir, output);
142
143
// run typst
144
await validateRequiredTypstVersion();
145
const pdfOutput = join(inputDir, inputStem + ".pdf");
146
const typstOptions: TypstCompileOptions = {
147
quiet: options.flags?.quiet,
148
fontPaths: asArray(format.metadata?.[kFontPaths]) as string[],
149
pdfStandard: normalizePdfStandardForTypst(
150
asArray(
151
format.render?.[kPdfStandard] ?? format.metadata?.[kPdfStandard],
152
),
153
),
154
};
155
if (project?.dir) {
156
typstOptions.rootDir = project.dir;
157
158
// Stage extension typst packages
159
const packagePath = await stageTypstPackages(input, project.dir);
160
if (packagePath) {
161
typstOptions.packagePath = packagePath;
162
}
163
}
164
const result = await typstCompile(
165
typstInput,
166
pdfOutput,
167
typstOptions,
168
);
169
if (!result.success) {
170
// Log the error so test framework can detect it via shouldError
171
if (result.stderr) {
172
error(result.stderr);
173
}
174
throw new Error("Typst compilation failed");
175
}
176
177
// Validate PDF against specified standards using verapdf (if available)
178
const pdfStandards = asArray(
179
format.render?.[kPdfStandard] ?? format.metadata?.[kPdfStandard],
180
) as string[];
181
if (pdfStandards.length > 0) {
182
await validatePdfStandards(pdfOutput, pdfStandards, {
183
quiet: options.flags?.quiet,
184
});
185
}
186
187
// keep typ if requested
188
if (!format.render[kKeepTyp]) {
189
safeRemoveSync(typstInput);
190
}
191
192
// copy (or write for stdout) compiled pdf to final output location
193
if (finalOutput) {
194
if (finalOutput === kStdOut) {
195
writeFileToStdout(pdfOutput);
196
safeRemoveSync(pdfOutput);
197
} else {
198
const outputPdf = expandPath(finalOutput);
199
200
if (normalize(pdfOutput) !== normalize(outputPdf)) {
201
// ensure the target directory exists
202
ensureDirSync(dirname(outputPdf));
203
Deno.renameSync(pdfOutput, outputPdf);
204
}
205
}
206
207
// final output needs to either absolute or input dir relative
208
// (however it may be working dir relative when it is passed in)
209
return normalizeOutputPath(typstInput, finalOutput);
210
} else {
211
return normalizeOutputPath(typstInput, pdfOutput);
212
}
213
};
214
215
const pdfOutput = finalOutput
216
? finalOutput === kStdOut
217
? undefined
218
: normalizeOutputPath(input, finalOutput)
219
: normalizeOutputPath(input, join(inputDir, inputStem + ".pdf"));
220
221
// return recipe
222
const recipe: OutputRecipe = {
223
output,
224
keepYaml: false,
225
args,
226
format: { ...format, pandoc },
227
complete,
228
finalOutput: pdfOutput ? relative(inputDir, pdfOutput) : undefined,
229
};
230
231
// if we have some variant declared, resolve it
232
// (use for opt-out citations extension)
233
if (format.render?.[kVariant]) {
234
const to = format.pandoc.to;
235
const variant = format.render[kVariant];
236
237
recipe.format = {
238
...recipe.format,
239
pandoc: {
240
...recipe.format.pandoc,
241
to: `${to}${variant}`,
242
},
243
};
244
}
245
246
return recipe;
247
}
248
249
// Typst-supported PDF standards
250
const kTypstSupportedStandards = new Set([
251
"1.4",
252
"1.5",
253
"1.6",
254
"1.7",
255
"2.0",
256
"a-1b",
257
"a-1a",
258
"a-2b",
259
"a-2u",
260
"a-2a",
261
"a-3b",
262
"a-3u",
263
"a-3a",
264
"a-4",
265
"a-4f",
266
"a-4e",
267
"ua-1",
268
]);
269
270
function normalizePdfStandardForTypst(standards: unknown[]): string[] {
271
const result: string[] = [];
272
for (const s of standards) {
273
// Convert to string - YAML may parse versions like 2.0 as integer 2
274
let str: string;
275
if (typeof s === "number") {
276
// Handle YAML numeric parsing: integer 2 -> "2.0", float 1.4 -> "1.4"
277
str = Number.isInteger(s) ? `${s}.0` : String(s);
278
} else if (typeof s === "string") {
279
str = s;
280
} else {
281
continue;
282
}
283
// Normalize: lowercase, remove any "pdf" prefix
284
const normalized = str.toLowerCase().replace(/^pdf[/-]?/, "");
285
if (kTypstSupportedStandards.has(normalized)) {
286
result.push(normalized);
287
} else {
288
warning(
289
`PDF standard '${s}' is not supported by Typst and will be ignored`,
290
);
291
}
292
}
293
return result;
294
}
295
296