Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/command/render/pandoc.ts
6446 views
1
/*
2
* pandoc.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { basename, dirname, isAbsolute, join } from "../../deno_ral/path.ts";
8
9
import { info } from "../../deno_ral/log.ts";
10
11
import { ensureDir, existsSync, expandGlobSync } from "../../deno_ral/fs.ts";
12
13
import { parse as parseYml, stringify } from "../../core/yaml.ts";
14
import { copyTo } from "../../core/copy.ts";
15
import { decodeBase64, encodeBase64 } from "encoding/base64";
16
17
import * as ld from "../../core/lodash.ts";
18
19
import { Document } from "../../core/deno-dom.ts";
20
21
import { execProcess } from "../../core/process.ts";
22
import { dirAndStem, normalizePath } from "../../core/path.ts";
23
import { mergeConfigs } from "../../core/config.ts";
24
25
import {
26
Format,
27
FormatExtras,
28
FormatPandoc,
29
kBodyEnvelope,
30
kDependencies,
31
kHtmlFinalizers,
32
kHtmlPostprocessors,
33
kMarkdownAfterBody,
34
kTextHighlightingMode,
35
} from "../../config/types.ts";
36
import {
37
isAstOutput,
38
isBeamerOutput,
39
isEpubOutput,
40
isHtmlDocOutput,
41
isHtmlFileOutput,
42
isHtmlOutput,
43
isIpynbOutput,
44
isLatexOutput,
45
isMarkdownOutput,
46
isRevealjsOutput,
47
isTypstOutput,
48
} from "../../config/format.ts";
49
import {
50
isIncludeMetadata,
51
isQuartoMetadata,
52
metadataGetDeep,
53
} from "../../config/metadata.ts";
54
import { pandocBinaryPath, resourcePath } from "../../core/resources.ts";
55
import { pandocAutoIdentifier } from "../../core/pandoc/pandoc-id.ts";
56
import {
57
partitionYamlFrontMatter,
58
readYamlFromMarkdown,
59
} from "../../core/yaml.ts";
60
61
import { ProjectContext } from "../../project/types.ts";
62
63
import {
64
deleteProjectMetadata,
65
projectIsBook,
66
projectIsWebsite,
67
} from "../../project/project-shared.ts";
68
import { deleteCrossrefMetadata } from "../../project/project-crossrefs.ts";
69
import { migrateProjectScratchPath } from "../../project/project-scratch.ts";
70
71
import {
72
getPandocArg,
73
havePandocArg,
74
kQuartoForwardedMetadataFields,
75
removePandocArgs,
76
} from "./flags.ts";
77
import {
78
generateDefaults,
79
pandocDefaultsMessage,
80
writeDefaultsFile,
81
} from "./defaults.ts";
82
import { filterParamsJson, removeFilterParams } from "./filters.ts";
83
import {
84
kAbstract,
85
kAbstractTitle,
86
kAuthor,
87
kAuthors,
88
kClassOption,
89
kColorLinks,
90
kColumns,
91
kDate,
92
kDateFormat,
93
kDateModified,
94
kDocumentClass,
95
kEmbedResources,
96
kFigResponsive,
97
kFilterParams,
98
kFontPaths,
99
kFormatResources,
100
kFrom,
101
kHighlightStyle,
102
kHtmlMathMethod,
103
kIncludeAfterBody,
104
kIncludeBeforeBody,
105
kIncludeInHeader,
106
kInstitute,
107
kInstitutes,
108
kKeepSource,
109
kLatexAutoMk,
110
kLinkColor,
111
kMath,
112
kMetadataFormat,
113
kNotebooks,
114
kNotebookView,
115
kNumberOffset,
116
kNumberSections,
117
kPageTitle,
118
kQuartoInternal,
119
kQuartoTemplateParams,
120
kQuartoVarsKey,
121
kQuartoVersion,
122
kResources,
123
kRevealJsScripts,
124
kSectionTitleAbstract,
125
kSelfContained,
126
kSyntaxDefinitions,
127
kTemplate,
128
kTheme,
129
kTitle,
130
kTitlePrefix,
131
kTocLocation,
132
kTocTitle,
133
kTocTitleDocument,
134
kTocTitleWebsite,
135
kVariables,
136
} from "../../config/constants.ts";
137
import { TempContext } from "../../core/temp.ts";
138
import { discoverResourceRefs, fixEmptyHrefs } from "../../core/html.ts";
139
140
import { kDefaultHighlightStyle } from "./constants.ts";
141
import {
142
HtmlPostProcessor,
143
HtmlPostProcessResult,
144
PandocOptions,
145
RunPandocResult,
146
} from "./types.ts";
147
import { crossrefFilterActive } from "./crossref.ts";
148
import { overflowXPostprocessor } from "./layout.ts";
149
import {
150
codeToolsPostprocessor,
151
formatHasCodeTools,
152
keepSourceBlock,
153
} from "./codetools.ts";
154
import { pandocMetadataPath } from "./render-paths.ts";
155
import { Metadata } from "../../config/types.ts";
156
import { resourcesFromMetadata } from "./resources.ts";
157
import { resolveSassBundles } from "./pandoc-html.ts";
158
import {
159
cleanTemplatePartialMetadata,
160
kTemplatePartials,
161
readPartials,
162
resolveTemplatePartialPaths,
163
stageTemplate,
164
} from "./template.ts";
165
import {
166
kYamlMetadataBlock,
167
pandocFormatWith,
168
parseFormatString,
169
splitPandocFormatString,
170
} from "../../core/pandoc/pandoc-formats.ts";
171
import { cslNameToString, parseAuthor } from "../../core/author.ts";
172
import { logLevel } from "../../core/log.ts";
173
174
import { cacheCodePage, clearCodePageCache } from "../../core/windows.ts";
175
import { textHighlightThemePath } from "../../quarto-core/text-highlighting.ts";
176
import { resolveAndFormatDate, resolveDate } from "../../core/date.ts";
177
import { katexPostProcessor } from "../../format/html/format-html-math.ts";
178
import {
179
readAndInjectDependencies,
180
writeDependencies,
181
} from "./pandoc-dependencies-html.ts";
182
import {
183
processFormatResources,
184
writeFormatResources,
185
} from "./pandoc-dependencies-resources.ts";
186
import { withTiming } from "../../core/timing.ts";
187
188
import {
189
requiresShortcodeUnescapePostprocessor,
190
shortcodeUnescapePostprocessor,
191
} from "../../format/markdown/format-markdown.ts";
192
193
import { kRevealJSPlugins } from "../../extension/constants.ts";
194
import { kCitation } from "../../format/html/format-html-shared.ts";
195
import { cslDate } from "../../core/csl.ts";
196
import {
197
createMarkdownPipeline,
198
MarkdownPipelineHandler,
199
} from "../../core/markdown-pipeline.ts";
200
import { getenv } from "../../core/env.ts";
201
import { Zod } from "../../resources/types/zod/schema-types.ts";
202
import { kFieldCategories } from "../../project/types/website/listing/website-listing-shared.ts";
203
import { isWindows } from "../../deno_ral/platform.ts";
204
import { appendToCombinedLuaProfile } from "../../core/performance/perfetto-utils.ts";
205
import { makeTimedFunctionAsync } from "../../core/performance/function-times.ts";
206
import { walkJson } from "../../core/json.ts";
207
import { safeCloneDeep } from "../../core/safe-clone-deep.ts";
208
import { assert } from "testing/asserts";
209
import { call } from "../../deno_ral/process.ts";
210
211
// in case we are running multiple pandoc processes
212
// we need to make sure we capture all of the trace files
213
let traceCount = 0;
214
215
const handleCombinedLuaProfiles = (
216
source: string,
217
paramsJson: Record<string, unknown>,
218
temp: TempContext,
219
) => {
220
const beforePandocHooks: (() => unknown)[] = [];
221
const afterPandocHooks: (() => unknown)[] = [];
222
const tmp = temp.createFile();
223
224
const combinedProfile = Deno.env.get("QUARTO_COMBINED_LUA_PROFILE");
225
if (combinedProfile) {
226
beforePandocHooks.push(() => {
227
paramsJson["lua-profiler-output"] = tmp;
228
});
229
afterPandocHooks.push(() => {
230
appendToCombinedLuaProfile(
231
source,
232
tmp,
233
combinedProfile,
234
);
235
});
236
}
237
return {
238
before: beforePandocHooks,
239
after: afterPandocHooks,
240
};
241
};
242
243
function captureRenderCommand(
244
args: Deno.CommandOptions,
245
temp: TempContext,
246
outputDir: string,
247
) {
248
Deno.mkdirSync(outputDir, { recursive: true });
249
const newArgs: typeof args.args = (args.args ?? []).map((_arg) => {
250
const arg = _arg as string; // we know it's a string, TypeScript doesn't somehow
251
if (!arg.startsWith(temp.baseDir)) {
252
return arg;
253
}
254
const newArg = join(outputDir, basename(arg));
255
if (arg.match(/^.*quarto\-defaults.*.yml$/)) {
256
// we need to correct the defaults YML because it contains a reference to a template in a temp directory
257
const ymlDefaults = Deno.readTextFileSync(arg);
258
const defaults = parseYml(ymlDefaults);
259
const templateDirectory = dirname(defaults.template);
260
const newTemplateDirectory = join(
261
outputDir,
262
basename(templateDirectory),
263
);
264
copyTo(templateDirectory, newTemplateDirectory);
265
defaults.template = join(
266
newTemplateDirectory,
267
basename(defaults.template),
268
);
269
const defaultsOutputFile = join(outputDir, basename(arg));
270
Deno.writeTextFileSync(defaultsOutputFile, stringify(defaults));
271
return defaultsOutputFile;
272
}
273
Deno.copyFileSync(arg, newArg);
274
return newArg;
275
});
276
277
// now we need to correct entries in filterParams
278
const filterParams = JSON.parse(
279
new TextDecoder().decode(decodeBase64(args.env!["QUARTO_FILTER_PARAMS"])),
280
);
281
walkJson(
282
filterParams,
283
(v: unknown) => typeof v === "string" && v.startsWith(temp.baseDir),
284
(_v: unknown) => {
285
const v = _v as string;
286
const newV = join(outputDir, basename(v));
287
Deno.copyFileSync(v, newV);
288
return newV;
289
},
290
);
291
292
Deno.writeTextFileSync(
293
join(outputDir, "render-command.json"),
294
JSON.stringify(
295
{
296
...args,
297
args: newArgs,
298
env: {
299
...args.env,
300
"QUARTO_FILTER_PARAMS": encodeBase64(JSON.stringify(filterParams)),
301
},
302
},
303
undefined,
304
2,
305
),
306
);
307
}
308
309
export async function runPandoc(
310
options: PandocOptions,
311
sysFilters: string[],
312
): Promise<RunPandocResult | null> {
313
const beforePandocHooks: (() => unknown)[] = [];
314
const afterPandocHooks: (() => unknown)[] = [];
315
const setupPandocHooks = (
316
hooks: { before: (() => unknown)[]; after: (() => unknown)[] },
317
) => {
318
beforePandocHooks.push(...hooks.before);
319
afterPandocHooks.push(...hooks.after);
320
};
321
322
const pandocEnv: { [key: string]: string } = {};
323
324
const setupPandocEnv = () => {
325
pandocEnv["QUARTO_FILTER_PARAMS"] = encodeBase64(
326
JSON.stringify(paramsJson),
327
);
328
329
const traceFilters =
330
// deno-lint-ignore no-explicit-any
331
(pandocMetadata as any)?.["_quarto"]?.["trace-filters"] ||
332
Deno.env.get("QUARTO_TRACE_FILTERS");
333
334
if (traceFilters) {
335
// in case we are running multiple pandoc processes
336
// we need to make sure we capture all of the trace files
337
let traceCountSuffix = "";
338
if (traceCount > 0) {
339
traceCountSuffix = `-${traceCount}`;
340
}
341
++traceCount;
342
if (traceFilters === true) {
343
pandocEnv["QUARTO_TRACE_FILTERS"] = "quarto-filter-trace.json" +
344
traceCountSuffix;
345
} else {
346
pandocEnv["QUARTO_TRACE_FILTERS"] = traceFilters + traceCountSuffix;
347
}
348
}
349
350
// https://github.com/quarto-dev/quarto-cli/issues/8274
351
// do not use the default LUA_CPATH, as it will cause pandoc to
352
// load the system lua libraries, which may not be compatible with
353
// the lua version we are using
354
if (Deno.env.get("QUARTO_LUA_CPATH") !== undefined) {
355
pandocEnv["LUA_CPATH"] = getenv("QUARTO_LUA_CPATH");
356
} else {
357
pandocEnv["LUA_CPATH"] = "";
358
}
359
};
360
361
// compute cwd for render
362
const cwd = dirname(options.source);
363
364
// build the pandoc command (we'll feed it the input on stdin)
365
const cmd = [pandocBinaryPath(), "+RTS", "-K512m", "-RTS"];
366
367
// build command line args
368
const args = [...options.args];
369
370
// propagate debug
371
if (logLevel() === "DEBUG") {
372
args.push("--verbose");
373
args.push("--trace");
374
}
375
376
// propagate quiet
377
if (options.flags?.quiet || logLevel() === "ERROR") {
378
args.push("--quiet");
379
}
380
381
// merge in any extra metadata
382
if (options.metadata) {
383
options.format.metadata = mergeConfigs(
384
options.format.metadata,
385
options.metadata,
386
);
387
}
388
389
// save args and metadata so we can print them (we may subsequently edit them)
390
const printArgs = [...args];
391
let printMetadata = {
392
...options.format.metadata,
393
crossref: {
394
...(options.format.metadata.crossref || {}),
395
},
396
...options.flags?.metadata,
397
} as Metadata;
398
399
const cleanQuartoTestsMetadata = (metadata: Metadata) => {
400
// remove any metadata that is only used for testing
401
if (metadata["_quarto"] && typeof metadata["_quarto"] === "object") {
402
delete (metadata._quarto as { [key: string]: unknown })?.tests;
403
if (Object.keys(metadata._quarto).length === 0) {
404
delete metadata._quarto;
405
}
406
}
407
};
408
409
// remove some metadata that are used as parameters to our lua filters
410
const cleanMetadataForPrinting = (metadata: Metadata) => {
411
delete metadata.params;
412
delete metadata[kQuartoInternal];
413
delete metadata[kQuartoVarsKey];
414
delete metadata[kQuartoVersion];
415
delete metadata[kFigResponsive];
416
delete metadata[kQuartoTemplateParams];
417
delete metadata[kRevealJsScripts];
418
deleteProjectMetadata(metadata);
419
deleteCrossrefMetadata(metadata);
420
removeFilterParams(metadata);
421
422
// Don't print empty reveal-js plugins
423
if (
424
metadata[kRevealJSPlugins] &&
425
(metadata[kRevealJSPlugins] as Array<unknown>).length === 0
426
) {
427
delete metadata[kRevealJSPlugins];
428
}
429
430
// Don't print _quarto.tests
431
// This can cause issue on regex test for printed output
432
cleanQuartoTestsMetadata(metadata);
433
434
// Filter out bundled engines from the engines array
435
if (Array.isArray(metadata.engines)) {
436
const filteredEngines = metadata.engines.filter((engine) => {
437
const enginePath = typeof engine === "string" ? engine : engine.path;
438
// Keep user engines, filter out bundled ones
439
return !enginePath?.replace(/\\/g, "/").includes(
440
"resources/extension-subtrees/",
441
);
442
});
443
444
// Remove the engines key entirely if empty, otherwise assign filtered array
445
if (filteredEngines.length === 0) {
446
delete metadata.engines;
447
} else {
448
metadata.engines = filteredEngines;
449
}
450
}
451
};
452
453
cleanMetadataForPrinting(printMetadata);
454
455
// Forward flags metadata into the format
456
kQuartoForwardedMetadataFields.forEach((field) => {
457
if (options.flags?.pandocMetadata?.[field]) {
458
options.format.metadata[field] = options.flags.pandocMetadata[field];
459
}
460
});
461
462
// generate defaults and capture defaults to be printed
463
let allDefaults = (await generateDefaults(options)) || {};
464
let printAllDefaults = safeCloneDeep(allDefaults);
465
466
// capture any filterParams in the FormatExtras
467
const formatFilterParams = {} as Record<string, unknown>;
468
469
// Note whether we should be forcing math on for this render
470
471
const forceMath = options.format.metadata[kMath];
472
delete options.format.metadata[kMath];
473
474
// the "ojs" filter is a special value that results in us
475
// just signaling our standard filter chain that the ojs
476
// filter should be active
477
const kOJSFilter = "ojs";
478
if (sysFilters.includes(kOJSFilter)) {
479
formatFilterParams[kOJSFilter] = true;
480
sysFilters = sysFilters.filter((filter) => filter !== kOJSFilter);
481
}
482
483
// pass the format language along to filter params
484
formatFilterParams["language"] = options.format.language;
485
486
// if there is no toc title then provide the appropirate default
487
if (
488
!options.format.metadata[kTocTitle] && !isAstOutput(options.format.pandoc)
489
) {
490
options.format.metadata[kTocTitle] = options.format.language[
491
(projectIsWebsite(options.project) && !projectIsBook(options.project) &&
492
isHtmlOutput(options.format.pandoc, true))
493
? kTocTitleWebsite
494
: kTocTitleDocument
495
];
496
}
497
498
// if toc-location is set, enable the TOC as well
499
if (
500
options.format.metadata[kTocLocation] &&
501
options.format.pandoc.toc === undefined
502
) {
503
options.format.pandoc.toc = true;
504
}
505
506
// if there is an abtract then forward abtract-title
507
if (
508
options.format.metadata[kAbstract] &&
509
(isHtmlDocOutput(options.format.pandoc) ||
510
isEpubOutput(options.format.pandoc))
511
) {
512
options.format.metadata[kAbstractTitle] =
513
options.format.metadata[kAbstractTitle] ||
514
options.format.language[kSectionTitleAbstract];
515
}
516
517
// see if there are extras
518
const postprocessors: Array<
519
(
520
output: string,
521
) => Promise<{ supporting?: string[]; resources?: string[] } | void>
522
> = [];
523
const htmlPostprocessors: Array<HtmlPostProcessor> = [];
524
const htmlFinalizers: Array<(doc: Document) => Promise<void>> = [];
525
const htmlRenderAfterBody: string[] = [];
526
const dependenciesFile = options.services.temp.createFile();
527
528
if (
529
sysFilters.length > 0 || options.format.formatExtras ||
530
options.project?.formatExtras
531
) {
532
const projectExtras = options.project?.formatExtras
533
? (await options.project.formatExtras(
534
options.source,
535
options.flags || {},
536
options.format,
537
options.services,
538
))
539
: {};
540
541
const formatExtras = options.format.formatExtras
542
? (await options.format.formatExtras(
543
options.source,
544
options.markdown,
545
options.flags || {},
546
options.format,
547
options.libDir,
548
options.services,
549
options.offset,
550
options.project,
551
options.quiet,
552
))
553
: {};
554
555
// start with the merge
556
const inputExtras = mergeConfigs(
557
projectExtras,
558
formatExtras,
559
{
560
metadata: projectExtras.metadata?.[kDocumentClass]
561
? {
562
[kDocumentClass]: projectExtras.metadata?.[kDocumentClass],
563
}
564
: undefined,
565
},
566
);
567
568
const extras = await resolveExtras(
569
options.source,
570
inputExtras,
571
options.format,
572
cwd,
573
options.libDir,
574
dependenciesFile,
575
options.project,
576
);
577
578
// record postprocessors
579
postprocessors.push(...(extras.postprocessors || []));
580
581
// add a keep-source post processor if we need one
582
if (
583
options.format?.render[kKeepSource] || formatHasCodeTools(options.format)
584
) {
585
htmlPostprocessors.push(codeToolsPostprocessor(options.format));
586
}
587
588
// save post-processors
589
htmlPostprocessors.push(...(extras.html?.[kHtmlPostprocessors] || []));
590
591
// Save finalizers
592
htmlFinalizers.push(...(extras.html?.[kHtmlFinalizers] || []));
593
594
if (isHtmlFileOutput(options.format.pandoc)) {
595
// add a post-processor for fixing overflow-x in cell output display
596
htmlPostprocessors.push(overflowXPostprocessor);
597
598
// katex post-processor
599
if (
600
options.flags?.katex ||
601
options.format.pandoc[kHtmlMathMethod] === "katex"
602
) {
603
htmlPostprocessors.push(katexPostProcessor());
604
}
605
606
if (!projectIsWebsite(options.project)) {
607
// add a resource discovery postProcessor if we are not in a website project
608
htmlPostprocessors.push(discoverResourceRefs);
609
610
// in order for tabsets etc to show the right mouse cursor,
611
// we need hrefs in anchor elements to be "empty" instead of missing.
612
// Existing href attributes trigger the any-link pseudo-selector that
613
// browsers set to `cursor: pointer`.
614
//
615
// In project websites, quarto-nav.js does the same thing so this step
616
// isn't necessary.
617
618
htmlPostprocessors.push(fixEmptyHrefs);
619
}
620
621
// Include Math, if explicitly requested (this will result
622
// in math dependencies being injected into the page)
623
if (forceMath) {
624
const htmlMarkdownHandlers: MarkdownPipelineHandler[] = [];
625
htmlMarkdownHandlers.push({
626
getUnrendered: () => {
627
return {
628
inlines: {
629
"quarto-enable-math-inline": "$e = mC^2$",
630
},
631
};
632
},
633
processRendered: (
634
_rendered: unknown,
635
_doc: Document,
636
) => {
637
},
638
});
639
640
const htmlMarkdownPipeline = createMarkdownPipeline(
641
"quarto-book-math",
642
htmlMarkdownHandlers,
643
);
644
645
const htmlPipelinePostProcessor = (
646
doc: Document,
647
): Promise<HtmlPostProcessResult> => {
648
htmlMarkdownPipeline.processRenderedMarkdown(doc);
649
return Promise.resolve({
650
resources: [],
651
supporting: [],
652
});
653
};
654
655
htmlRenderAfterBody.push(htmlMarkdownPipeline.markdownAfterBody());
656
htmlPostprocessors.push(htmlPipelinePostProcessor);
657
}
658
}
659
660
// Capture markdown that should be appended post body
661
htmlRenderAfterBody.push(...(extras.html?.[kMarkdownAfterBody] || []));
662
663
// merge sysFilters if we have them
664
if (sysFilters.length > 0) {
665
extras.filters = extras.filters || {};
666
extras.filters.post = extras.filters.post || [];
667
extras.filters.post.unshift(
668
...(sysFilters.map((filter) => resourcePath(join("filters", filter)))),
669
);
670
}
671
672
// merge args
673
if (extras.args) {
674
args.push(...extras.args);
675
printArgs.push(...extras.args);
676
}
677
678
// merge pandoc
679
if (extras.pandoc) {
680
// Special case - we need to more intelligently merge pandoc from
681
// by breaking apart the from string
682
if (
683
typeof (allDefaults[kFrom]) === "string" &&
684
typeof (extras.pandoc[kFrom]) === "string"
685
) {
686
const userFrom = splitPandocFormatString(allDefaults[kFrom] as string);
687
const extrasFrom = splitPandocFormatString(
688
extras.pandoc[kFrom] as string,
689
);
690
allDefaults[kFrom] = pandocFormatWith(
691
userFrom.format,
692
"",
693
extrasFrom.options + userFrom.options,
694
);
695
printAllDefaults[kFrom] = allDefaults[kFrom];
696
}
697
698
allDefaults = mergeConfigs(extras.pandoc, allDefaults);
699
printAllDefaults = mergeConfigs(extras.pandoc, printAllDefaults);
700
701
// Special case - theme is resolved on extras and should override allDefaults
702
if (extras.pandoc[kHighlightStyle] === null) {
703
delete printAllDefaults[kHighlightStyle];
704
allDefaults[kHighlightStyle] = null;
705
} else if (extras.pandoc[kHighlightStyle]) {
706
delete printAllDefaults[kHighlightStyle];
707
allDefaults[kHighlightStyle] = extras.pandoc[kHighlightStyle];
708
} else {
709
delete printAllDefaults[kHighlightStyle];
710
delete allDefaults[kHighlightStyle];
711
}
712
}
713
714
// merge metadata
715
if (extras.metadata || extras.metadataOverride) {
716
// before we merge metadata, ensure that partials are proper paths
717
resolveTemplatePartialPaths(
718
options.format.metadata,
719
cwd,
720
options.project,
721
);
722
options.format.metadata = {
723
...mergeConfigs(
724
extras.metadata || {},
725
options.format.metadata,
726
),
727
...extras.metadataOverride || {},
728
};
729
printMetadata = mergeConfigs(extras.metadata || {}, printMetadata);
730
cleanMetadataForPrinting(printMetadata);
731
}
732
733
// merge notebooks that have been provided by the document / user
734
// or by the project as format extras
735
if (extras[kNotebooks]) {
736
const documentNotebooks = options.format.render[kNotebookView];
737
// False means that the user has explicitely disabled notebooks
738
if (documentNotebooks !== false) {
739
const userNotebooks = documentNotebooks === true
740
? []
741
: Array.isArray(documentNotebooks)
742
? documentNotebooks
743
: documentNotebooks !== undefined
744
? [documentNotebooks]
745
: [];
746
747
// Only add notebooks that aren't already present
748
const uniqExtraNotebooks = extras[kNotebooks].filter((nb) => {
749
return !userNotebooks.find((userNb) => {
750
return userNb.notebook === nb.notebook;
751
});
752
});
753
754
options.format.render[kNotebookView] = [
755
...userNotebooks,
756
...uniqExtraNotebooks,
757
];
758
}
759
}
760
761
// clean 'columns' from pandoc defaults to typst
762
if (isTypstOutput(options.format.pandoc)) {
763
delete allDefaults[kColumns];
764
delete printAllDefaults[kColumns];
765
}
766
767
// The user template (if any)
768
const userTemplate = getPandocArg(args, "--template") ||
769
allDefaults[kTemplate];
770
771
// The user partials (if any)
772
const userPartials = readPartials(options.format.metadata, cwd);
773
const inputDir = normalizePath(cwd);
774
const resolvePath = (path: string) => {
775
if (isAbsolute(path)) {
776
return path;
777
} else {
778
return join(inputDir, path);
779
}
780
};
781
782
const templateContext = extras.templateContext;
783
if (templateContext) {
784
// Clean the template partial output
785
cleanTemplatePartialMetadata(
786
printMetadata,
787
templateContext.partials || [],
788
);
789
790
// The format is providing a more robust local template
791
// to use, stage the template and pass it on to pandoc
792
const template = userTemplate
793
? resolvePath(userTemplate)
794
: templateContext.template;
795
796
// Validate any user partials
797
if (!userTemplate && userPartials.length > 0) {
798
const templateNames = templateContext.partials?.map((temp) =>
799
basename(temp)
800
);
801
802
if (templateNames) {
803
const userPartialNames = userPartials.map((userPartial) =>
804
basename(userPartial)
805
);
806
807
const hasAtLeastOnePartial = userPartialNames.find((userPartial) => {
808
return templateNames.includes(userPartial);
809
});
810
811
if (!hasAtLeastOnePartial) {
812
const errorMsg =
813
`The format '${allDefaults.to}' only supports the following partials:\n${
814
templateNames.join("\n")
815
}\n\nPlease provide one or more of these partials.`;
816
throw new Error(errorMsg);
817
}
818
} else {
819
throw new Error(
820
`The format ${allDefaults.to} does not support providing any template partials.`,
821
);
822
}
823
}
824
825
// Place any user partials at the end of the list of partials
826
const partials: string[] = templateContext.partials || [];
827
partials.push(...userPartials);
828
829
// Stage the template and partials
830
const stagedTemplate = await stageTemplate(
831
options,
832
extras,
833
{
834
template,
835
partials,
836
},
837
);
838
839
// Clean out partials from metadata, they are not needed downstream
840
delete options.format.metadata[kTemplatePartials];
841
842
allDefaults[kTemplate] = stagedTemplate;
843
} else {
844
// ipynb is allowed to have templates without warning
845
if (userPartials.length > 0 && !isIpynbOutput(options.format.pandoc)) {
846
// The user passed partials to a format that doesn't support
847
// staging and partials.
848
throw new Error(
849
`The format ${allDefaults.to} does not support providing any template partials.`,
850
);
851
} else if (userTemplate) {
852
// Use the template provided by the user
853
allDefaults[kTemplate] = userTemplate;
854
}
855
}
856
857
// more cleanup
858
options.format.metadata = cleanupPandocMetadata({
859
...options.format.metadata,
860
});
861
printMetadata = cleanupPandocMetadata(printMetadata);
862
863
if (extras[kIncludeInHeader]) {
864
if (
865
allDefaults[kIncludeInHeader] !== undefined &&
866
!ld.isArray(allDefaults[kIncludeInHeader])
867
) {
868
// FIXME we need to fix the type up in FormatExtras..
869
allDefaults[kIncludeInHeader] = [
870
allDefaults[kIncludeInHeader],
871
] as unknown as string[];
872
}
873
allDefaults = {
874
...allDefaults,
875
[kIncludeInHeader]: [
876
...extras[kIncludeInHeader] || [],
877
...allDefaults[kIncludeInHeader] || [],
878
],
879
};
880
}
881
if (
882
extras[kIncludeBeforeBody]
883
) {
884
if (
885
allDefaults[kIncludeBeforeBody] !== undefined &&
886
!ld.isArray(allDefaults[kIncludeBeforeBody])
887
) {
888
// FIXME we need to fix the type up in FormatExtras..
889
allDefaults[kIncludeBeforeBody] = [
890
allDefaults[kIncludeBeforeBody],
891
] as unknown as string[];
892
}
893
allDefaults = {
894
...allDefaults,
895
[kIncludeBeforeBody]: [
896
...extras[kIncludeBeforeBody] || [],
897
...allDefaults[kIncludeBeforeBody] || [],
898
],
899
};
900
}
901
if (extras[kIncludeAfterBody]) {
902
if (
903
allDefaults[kIncludeAfterBody] !== undefined &&
904
!ld.isArray(allDefaults[kIncludeAfterBody])
905
) {
906
// FIXME we need to fix the type up in FormatExtras..
907
allDefaults[kIncludeAfterBody] = [
908
allDefaults[kIncludeAfterBody],
909
] as unknown as string[];
910
}
911
allDefaults = {
912
...allDefaults,
913
[kIncludeAfterBody]: [
914
...allDefaults[kIncludeAfterBody] || [],
915
...extras[kIncludeAfterBody] || [],
916
],
917
};
918
}
919
920
// Resolve the body envelope here
921
// body envelope to includes (project body envelope always wins)
922
if (extras.html?.[kBodyEnvelope] && projectExtras.html?.[kBodyEnvelope]) {
923
extras.html[kBodyEnvelope] = projectExtras.html[kBodyEnvelope];
924
}
925
resolveBodyEnvelope(allDefaults, extras, options.services.temp);
926
927
// add any filters
928
allDefaults.filters = [
929
...extras.filters?.pre || [],
930
...allDefaults.filters || [],
931
...extras.filters?.post || [],
932
];
933
934
// make the filter paths windows safe
935
allDefaults.filters = allDefaults.filters.map((filter) => {
936
if (typeof filter === "string") {
937
return pandocMetadataPath(filter);
938
} else {
939
return {
940
type: filter.type,
941
path: pandocMetadataPath(filter.path),
942
};
943
}
944
});
945
946
// Capture any format filter params
947
const filterParams = extras[kFilterParams];
948
if (filterParams) {
949
Object.keys(filterParams).forEach((key) => {
950
formatFilterParams[key] = filterParams[key];
951
});
952
}
953
}
954
955
// add a shortcode escaping post-processor if we need one
956
if (
957
isMarkdownOutput(options.format) &&
958
requiresShortcodeUnescapePostprocessor(options.markdown)
959
) {
960
postprocessors.push(shortcodeUnescapePostprocessor);
961
}
962
963
// resolve some title variables
964
const title = allDefaults?.[kVariables]?.[kTitle] ||
965
options.format.metadata[kTitle];
966
const pageTitle = allDefaults?.[kVariables]?.[kPageTitle] ||
967
options.format.metadata[kPageTitle];
968
const titlePrefix = allDefaults?.[kTitlePrefix];
969
970
// provide default page title if necessary
971
if (!title && !pageTitle && isHtmlFileOutput(options.format.pandoc)) {
972
const [_dir, stem] = dirAndStem(options.source);
973
args.push(
974
"--metadata",
975
`pagetitle:${pandocAutoIdentifier(stem, false)}`,
976
);
977
}
978
979
// don't ever duplicate pagetite/title and title-prefix
980
if (
981
(pageTitle !== undefined && pageTitle === titlePrefix) ||
982
(pageTitle === undefined && title === titlePrefix)
983
) {
984
delete allDefaults[kTitlePrefix];
985
}
986
987
// if we are doing keepYaml then remove it from pandoc 'to'
988
if (options.keepYaml && allDefaults.to) {
989
allDefaults.to = allDefaults.to.replaceAll(`+${kYamlMetadataBlock}`, "");
990
}
991
992
// Attempt to cache the code page, if this windows.
993
// We cache the code page to prevent looking it up
994
// in the registry repeatedly (which triggers MS Defender)
995
if (isWindows) {
996
await cacheCodePage();
997
}
998
999
// filter results json file
1000
const filterResultsFile = options.services.temp.createFile();
1001
1002
const writerKeys: ("to" | "writer")[] = ["to", "writer"];
1003
for (const key of writerKeys) {
1004
if (allDefaults[key]?.match(/[.]lua$/)) {
1005
formatFilterParams["custom-writer"] = allDefaults[key];
1006
allDefaults[key] = resourcePath("filters/customwriter/customwriter.lua");
1007
}
1008
}
1009
1010
// set up the custom .qmd reader
1011
if (allDefaults.from) {
1012
formatFilterParams["user-defined-from"] = allDefaults.from;
1013
}
1014
allDefaults.from = resourcePath("filters/qmd-reader.lua");
1015
1016
// set parameters required for filters (possibily mutating all of it's arguments
1017
// to pull includes out into quarto parameters so they can be merged)
1018
let pandocArgs = args;
1019
const paramsJson = await filterParamsJson(
1020
pandocArgs,
1021
options,
1022
allDefaults,
1023
formatFilterParams,
1024
filterResultsFile,
1025
dependenciesFile,
1026
);
1027
1028
setupPandocHooks(
1029
handleCombinedLuaProfiles(
1030
options.source,
1031
paramsJson,
1032
options.services.temp,
1033
),
1034
);
1035
1036
// remove selected args and defaults if we are handling some things on behalf of pandoc
1037
// (e.g. handling section numbering). note that section numbering is handled by the
1038
// crossref filter so we only do this if the user hasn't disabled the crossref filter
1039
if (
1040
!isLatexOutput(options.format.pandoc) &&
1041
!isTypstOutput(options.format.pandoc) &&
1042
!isMarkdownOutput(options.format) && crossrefFilterActive(options)
1043
) {
1044
delete allDefaults[kNumberSections];
1045
delete allDefaults[kNumberOffset];
1046
const removeArgs = new Map<string, boolean>();
1047
removeArgs.set("--number-sections", false);
1048
removeArgs.set("--number-offset", true);
1049
pandocArgs = removePandocArgs(pandocArgs, removeArgs);
1050
}
1051
1052
// https://github.com/quarto-dev/quarto-cli/issues/3126
1053
// it seems that we still need to coerce number-offset to be an number list,
1054
// otherwise pandoc fails.
1055
if (typeof allDefaults[kNumberOffset] === "number") {
1056
allDefaults[kNumberOffset] = [allDefaults[kNumberOffset]];
1057
}
1058
1059
// We always use our own pandoc data-dir, so tear off the user
1060
// data-dir and use ours.
1061
const dataDirArgs = new Map<string, boolean>();
1062
dataDirArgs.set("--data-dir", true);
1063
pandocArgs = removePandocArgs(
1064
pandocArgs,
1065
dataDirArgs,
1066
);
1067
pandocArgs.push("--data-dir", resourcePath("pandoc/datadir"));
1068
1069
// add any built-in syntax definition files
1070
allDefaults[kSyntaxDefinitions] = allDefaults[kSyntaxDefinitions] || [];
1071
const syntaxDefinitions = expandGlobSync(
1072
join(resourcePath(join("pandoc", "syntax-definitions")), "*.xml"),
1073
);
1074
for (const syntax of syntaxDefinitions) {
1075
allDefaults[kSyntaxDefinitions]?.push(syntax.path);
1076
}
1077
1078
// provide default webtex url
1079
if (allDefaults[kHtmlMathMethod] === "webtex") {
1080
allDefaults[kHtmlMathMethod] = {
1081
method: "webtex",
1082
url: "https://latex.codecogs.com/svg.latex?",
1083
};
1084
}
1085
1086
// provide alternate markdown template that actually prints the title block
1087
if (
1088
!allDefaults[kTemplate] && !havePandocArg(args, "--template") &&
1089
!options.keepYaml &&
1090
allDefaults.to
1091
) {
1092
const formatDesc = parseFormatString(allDefaults.to);
1093
const lookupTo = formatDesc.baseFormat;
1094
if (
1095
[
1096
"gfm",
1097
"commonmark",
1098
"commonmark_x",
1099
"markdown_strict",
1100
"markdown_phpextra",
1101
"markdown_github",
1102
"markua",
1103
].includes(
1104
lookupTo,
1105
)
1106
) {
1107
allDefaults[kTemplate] = resourcePath(
1108
join("pandoc", "templates", "default.markdown"),
1109
);
1110
}
1111
}
1112
1113
// "Hide" self contained from pandoc. Since we inject dependencies
1114
// during post processing, we need to implement self-contained ourselves
1115
// so don't allow pandoc to see this flag (but still print it)
1116
if (isHtmlFileOutput(options.format.pandoc)) {
1117
// Hide self-contained arguments
1118
pandocArgs = pandocArgs.filter((
1119
arg,
1120
) => (arg !== "--self-contained" && arg !== "--embed-resources"));
1121
1122
// Remove from defaults
1123
delete allDefaults[kSelfContained];
1124
delete allDefaults[kEmbedResources];
1125
}
1126
1127
// write the defaults file
1128
if (allDefaults) {
1129
const defaultsFile = await writeDefaultsFile(
1130
allDefaults,
1131
options.services.temp,
1132
);
1133
cmd.push("--defaults", defaultsFile);
1134
}
1135
1136
// remove front matter from markdown (we've got it all incorporated into options.format.metadata)
1137
// also save the engine metadata as that will have the result of e.g. resolved inline expressions,
1138
// (which we will use immediately below)
1139
const paritioned = partitionYamlFrontMatter(options.markdown);
1140
const engineMetadata =
1141
(paritioned?.yaml ? readYamlFromMarkdown(paritioned.yaml) : {}) as Metadata;
1142
const markdown = paritioned?.markdown || options.markdown;
1143
1144
// selectively overwrite some resolved metadata (e.g. ensure that metadata
1145
// computed from inline r expressions gets included @ the bottom).
1146
const pandocMetadata = safeCloneDeep(options.format.metadata || {});
1147
for (const key of Object.keys(engineMetadata)) {
1148
const isChapterTitle = key === kTitle && projectIsBook(options.project);
1149
1150
if (!isQuartoMetadata(key) && !isChapterTitle && !isIncludeMetadata(key)) {
1151
// if it's standard pandoc metadata and NOT contained in a format specific
1152
// override then use the engine metadata value
1153
1154
// don't do if they've overridden the value in a format
1155
const formats = engineMetadata[kMetadataFormat] as Metadata;
1156
if (ld.isObject(formats) && metadataGetDeep(formats, key).length > 0) {
1157
continue;
1158
}
1159
1160
// don't process some format specific metadata that may have been processed already
1161
// - theme is handled specifically already for revealjs with a metadata override and should not be overridden by user input
1162
if (key === kTheme && isRevealjsOutput(options.format.pandoc)) {
1163
continue;
1164
}
1165
// - categories are handled specifically already for website projects with a metadata override and should not be overridden by user input
1166
if (key === kFieldCategories && projectIsWebsite(options.project)) {
1167
continue;
1168
}
1169
// perform the override
1170
pandocMetadata[key] = engineMetadata[key];
1171
}
1172
}
1173
1174
// Resolve any date fields
1175
const dateRaw = pandocMetadata[kDate];
1176
const dateFields = [kDate, kDateModified];
1177
dateFields.forEach((dateField) => {
1178
const date = pandocMetadata[dateField];
1179
const format = pandocMetadata[kDateFormat];
1180
assert(format === undefined || typeof format === "string");
1181
pandocMetadata[dateField] = resolveAndFormatDate(
1182
options.source,
1183
date,
1184
format,
1185
);
1186
});
1187
1188
// Ensure that citationMetadata is expanded into
1189
// and object for downstream use
1190
if (
1191
typeof (pandocMetadata[kCitation]) === "boolean" &&
1192
pandocMetadata[kCitation] === true
1193
) {
1194
pandocMetadata[kCitation] = {};
1195
}
1196
1197
// Expand citation dates into CSL dates
1198
const citationMetadata = pandocMetadata[kCitation];
1199
if (citationMetadata) {
1200
assert(typeof citationMetadata === "object");
1201
// ideally we should be asserting non-arrayness here but that's not very fast.
1202
// assert(!Array.isArray(citationMetadata));
1203
const citationMetadataObj = citationMetadata as Record<string, unknown>;
1204
const docCSLDate = dateRaw
1205
? cslDate(resolveDate(options.source, dateRaw))
1206
: undefined;
1207
const fields = ["issued", "available-date"];
1208
fields.forEach((field) => {
1209
if (citationMetadataObj[field]) {
1210
citationMetadataObj[field] = cslDate(citationMetadataObj[field]);
1211
} else if (docCSLDate) {
1212
citationMetadataObj[field] = docCSLDate;
1213
}
1214
});
1215
}
1216
1217
// Resolve the author metadata into a form that Pandoc will recognize
1218
const authorsRaw = pandocMetadata[kAuthors] || pandocMetadata[kAuthor];
1219
if (authorsRaw) {
1220
const authors = parseAuthor(pandocMetadata[kAuthor], true);
1221
if (authors) {
1222
pandocMetadata[kAuthor] = authors.map((author) =>
1223
cslNameToString(author.name)
1224
);
1225
pandocMetadata[kAuthors] = Array.isArray(authorsRaw)
1226
? authorsRaw
1227
: [authorsRaw];
1228
}
1229
}
1230
1231
// Ensure that there are institutes around for use when resolving authors
1232
// and affilations
1233
const instituteRaw = pandocMetadata[kInstitute];
1234
if (instituteRaw) {
1235
pandocMetadata[kInstitutes] = Array.isArray(instituteRaw)
1236
? instituteRaw
1237
: [instituteRaw];
1238
}
1239
1240
// If the user provides only `zh` as a lang, disambiguate to 'simplified'
1241
if (pandocMetadata.lang === "zh") {
1242
pandocMetadata.lang = "zh-Hans";
1243
}
1244
1245
// If there are no specified options for link coloring in PDF, set them
1246
// do not color links for obviously printed book output or beamer presentations
1247
if (
1248
isLatexOutput(options.format.pandoc) &&
1249
!isBeamerOutput(options.format.pandoc)
1250
) {
1251
const docClass = pandocMetadata[kDocumentClass];
1252
assert(!docClass || typeof docClass === "string");
1253
const isPrintDocumentClass = docClass &&
1254
["book", "scrbook"].includes(docClass as string);
1255
1256
if (!isPrintDocumentClass) {
1257
if (pandocMetadata[kColorLinks] === undefined) {
1258
pandocMetadata[kColorLinks] = true;
1259
}
1260
1261
if (pandocMetadata[kLinkColor] === undefined) {
1262
pandocMetadata[kLinkColor] = "blue";
1263
}
1264
}
1265
}
1266
1267
// If the format provides any additional markdown to render after the body
1268
// then append that before rendering
1269
const markdownWithRenderAfter =
1270
isHtmlOutput(options.format.pandoc) && htmlRenderAfterBody.length > 0
1271
? markdown + "\n\n\n" + htmlRenderAfterBody.join("\n") + "\n\n"
1272
: markdown;
1273
1274
// append render after + keep-source if requested
1275
const input = markdownWithRenderAfter +
1276
keepSourceBlock(options.format, options.source);
1277
1278
// write input to temp file and pass it to pandoc
1279
const inputTemp = options.services.temp.createFile({
1280
prefix: "quarto-input",
1281
suffix: ".md",
1282
});
1283
Deno.writeTextFileSync(inputTemp, input);
1284
cmd.push(inputTemp);
1285
1286
// Pass metadata to Pandoc. This metadata reflects all of our merged project and format
1287
// metadata + the user's original metadata from the top of the document. Note that we
1288
// used to append this to the end of the file (so it would always 'win' over the front-matter
1289
// at the top) however we ran into problems w/ the pandoc parser seeing an hr (------)
1290
// followed by text on the next line as the beginning of a table that was terminated
1291
// with our yaml block! Note that subsequent to the original implementation we started
1292
// stripping the yaml from the top, see:
1293
// https://github.com/quarto-dev/quarto-cli/commit/35f4729defb20ceb8b45e08d0a97c079e7a3bab6
1294
// The way this yaml is now processed relative to other yaml sources is described in
1295
// the docs for --metadata-file:
1296
// Values in files specified later on the command line will be preferred over those
1297
// specified in earlier files. Metadata values specified inside the document, or by
1298
// using -M, overwrite values specified with this option.
1299
// This gives the semantics we want, as our metadata is 'logically' at the top of the
1300
// file and subsequent blocks within the file should indeed override it (as should
1301
// user invocations of --metadata-file or -M, which are included below in pandocArgs)
1302
const metadataTemp = options.services.temp.createFile({
1303
prefix: "quarto-metadata",
1304
suffix: ".yml",
1305
});
1306
const pandocPassedMetadata = safeCloneDeep(pandocMetadata);
1307
delete pandocPassedMetadata.format;
1308
delete pandocPassedMetadata.project;
1309
delete pandocPassedMetadata.website;
1310
delete pandocPassedMetadata.about;
1311
// these shouldn't be visible because they are emitted on markdown output
1312
// and it breaks ensureFileRegexMatches
1313
cleanQuartoTestsMetadata(pandocPassedMetadata);
1314
1315
// Filter out bundled engines from metadata passed to Pandoc
1316
if (Array.isArray(pandocPassedMetadata.engines)) {
1317
const filteredEngines = pandocPassedMetadata.engines.filter((engine) => {
1318
const enginePath = typeof engine === "string" ? engine : engine.path;
1319
if (!enginePath) return true;
1320
return !enginePath.replace(/\\/g, "/").includes(
1321
"resources/extension-subtrees/",
1322
);
1323
});
1324
1325
if (filteredEngines.length === 0) {
1326
delete pandocPassedMetadata.engines;
1327
} else {
1328
pandocPassedMetadata.engines = filteredEngines;
1329
}
1330
}
1331
1332
Deno.writeTextFileSync(
1333
metadataTemp,
1334
stringify(pandocPassedMetadata, {
1335
indent: 2,
1336
lineWidth: -1,
1337
sortKeys: false,
1338
skipInvalid: true,
1339
}),
1340
);
1341
cmd.push("--metadata-file", metadataTemp);
1342
1343
// add user command line args
1344
cmd.push(...pandocArgs);
1345
1346
// print full resolved input to pandoc
1347
if (!options.quiet && !options.flags?.quiet) {
1348
runPandocMessage(
1349
printArgs,
1350
printAllDefaults,
1351
sysFilters,
1352
printMetadata,
1353
);
1354
}
1355
1356
// run beforePandoc hooks
1357
for (const hook of beforePandocHooks) {
1358
await hook();
1359
}
1360
1361
setupPandocEnv();
1362
1363
const params = {
1364
cmd: cmd[0],
1365
args: cmd.slice(1),
1366
cwd,
1367
env: pandocEnv,
1368
ourEnv: Deno.env.toObject(),
1369
};
1370
const captureCommand = Deno.env.get("QUARTO_CAPTURE_RENDER_COMMAND");
1371
if (captureCommand) {
1372
captureRenderCommand(params, options.services.temp, captureCommand);
1373
}
1374
const pandocRender = makeTimedFunctionAsync("pandoc-render", async () => {
1375
return await execProcess(params);
1376
});
1377
1378
// run pandoc
1379
const result = await pandocRender();
1380
1381
// run afterPandoc hooks
1382
for (const hook of afterPandocHooks) {
1383
await hook();
1384
}
1385
1386
// resolve resource files from metadata
1387
const resources: string[] = resourcesFromMetadata(
1388
options.format.metadata[kResources],
1389
);
1390
1391
// read any resourceFiles generated by filters
1392
let inputTraits = {};
1393
if (existsSync(filterResultsFile)) {
1394
const filterResultsJSON = Deno.readTextFileSync(filterResultsFile);
1395
if (filterResultsJSON.trim().length > 0) {
1396
const filterResults = JSON.parse(filterResultsJSON);
1397
1398
// Read any input traits
1399
inputTraits = filterResults.inputTraits;
1400
1401
// Read any resource files
1402
const resourceFiles = filterResults.resourceFiles || [];
1403
resources.push(...resourceFiles);
1404
}
1405
}
1406
1407
if (result.success) {
1408
return {
1409
inputMetadata: pandocMetadata,
1410
inputTraits,
1411
resources,
1412
postprocessors,
1413
htmlPostprocessors: isHtmlOutput(options.format.pandoc)
1414
? htmlPostprocessors
1415
: [],
1416
htmlFinalizers: isHtmlDocOutput(options.format.pandoc)
1417
? htmlFinalizers
1418
: [],
1419
};
1420
} else {
1421
// Since this render wasn't successful, clear the code page cache
1422
// (since the code page could've changed and we could be caching the
1423
// wrong value)
1424
if (isWindows) {
1425
clearCodePageCache();
1426
}
1427
1428
return null;
1429
}
1430
}
1431
1432
// this mutates metadata[kClassOption]
1433
function cleanupPandocMetadata(metadata: Metadata) {
1434
// pdf classoption can end up with duplicated options
1435
const classoption = metadata[kClassOption];
1436
if (Array.isArray(classoption)) {
1437
metadata[kClassOption] = ld.uniqBy(
1438
classoption.reverse(),
1439
(option: string) => {
1440
return option.replace(/=.+$/, "");
1441
},
1442
).reverse();
1443
}
1444
1445
return metadata;
1446
}
1447
1448
async function resolveExtras(
1449
input: string,
1450
extras: FormatExtras, // input format extras (project, format, brand)
1451
format: Format,
1452
inputDir: string,
1453
libDir: string,
1454
dependenciesFile: string,
1455
project: ProjectContext,
1456
) {
1457
// resolve format resources
1458
await writeFormatResources(
1459
inputDir,
1460
dependenciesFile,
1461
format.render[kFormatResources],
1462
);
1463
1464
// perform html-specific merging
1465
if (isHtmlOutput(format.pandoc)) {
1466
// resolve sass bundles
1467
extras = await resolveSassBundles(
1468
inputDir,
1469
extras,
1470
format,
1471
project,
1472
);
1473
1474
// resolve dependencies
1475
await writeDependencies(dependenciesFile, extras);
1476
1477
const htmlDependenciesPostProcesor = (
1478
doc: Document,
1479
_inputMedata: Metadata,
1480
): Promise<HtmlPostProcessResult> => {
1481
return withTiming(
1482
"pandocDependenciesPostProcessor",
1483
async () =>
1484
await readAndInjectDependencies(
1485
dependenciesFile,
1486
inputDir,
1487
libDir,
1488
doc,
1489
project,
1490
),
1491
);
1492
};
1493
1494
// Add a post processor to resolve dependencies
1495
extras.html = extras.html || {};
1496
extras.html[kHtmlPostprocessors] = extras.html?.[kHtmlPostprocessors] || [];
1497
if (isHtmlFileOutput(format.pandoc)) {
1498
extras.html[kHtmlPostprocessors]!.unshift(htmlDependenciesPostProcesor);
1499
}
1500
1501
// Remove the dependencies which will now process in the post
1502
// processor
1503
delete extras.html?.[kDependencies];
1504
} else {
1505
delete extras.html;
1506
}
1507
1508
// perform typst-specific merging
1509
if (isTypstOutput(format.pandoc)) {
1510
const brand = (await project.resolveBrand(input))?.light;
1511
const fontdirs: Set<string> = new Set();
1512
const base_urls = {
1513
google: "https://fonts.googleapis.com/css",
1514
bunny: "https://fonts.bunny.net/css",
1515
};
1516
const ttf_urls = [], woff_urls: Array<string> = [];
1517
if (brand?.data.typography) {
1518
const fonts = brand.data.typography.fonts || [];
1519
for (const _font of fonts) {
1520
// if font lacks a source, we assume google in typst output
1521
1522
// deno-lint-ignore no-explicit-any
1523
const source: string = (_font as any).source ?? "google";
1524
if (source === "file") {
1525
const font = Zod.BrandFontFile.parse(_font);
1526
for (const file of font.files || []) {
1527
const path = typeof file === "object" ? file.path : file;
1528
fontdirs.add(dirname(join(brand.brandDir, path)));
1529
}
1530
} else if (source === "bunny") {
1531
const font = Zod.BrandFontBunny.parse(_font);
1532
console.log(
1533
"Font bunny is not yet supported for Typst, skipping",
1534
font.family,
1535
);
1536
} else if (source === "google" /* || font.source === "bunny" */) {
1537
const font = Zod.BrandFontGoogle.parse(_font);
1538
let { family, style, weight } = font;
1539
const parts = [family!];
1540
if (style) {
1541
style = Array.isArray(style) ? style : [style];
1542
parts.push(style.join(","));
1543
}
1544
if (weight) {
1545
weight = Array.isArray(weight) ? weight : [weight];
1546
parts.push(weight.join(","));
1547
}
1548
const response = await fetch(
1549
`${base_urls[source]}?family=${parts.join(":")}`,
1550
);
1551
const lines = (await response.text()).split("\n");
1552
for (const line of lines) {
1553
const sourcelist = line.match(/^ *src: (.*); *$/);
1554
if (sourcelist) {
1555
const sources = sourcelist[1].split(",").map((s) => s.trim());
1556
let found = false;
1557
const failed_formats = [];
1558
for (const source of sources) {
1559
const match = source.match(
1560
/url\(([^)]*)\) *format\('([^)]*)'\)/,
1561
);
1562
if (match) {
1563
const [_, url, format] = match;
1564
if (["truetype", "opentype"].includes(format)) {
1565
ttf_urls.push(url);
1566
found = true;
1567
break;
1568
}
1569
// else if (["woff", "woff2"].includes(format)) {
1570
// woff_urls.push(url);
1571
// break;
1572
// }
1573
failed_formats.push(format);
1574
}
1575
}
1576
if (!found) {
1577
console.log(
1578
"skipping",
1579
family,
1580
"\nnot currently able to use formats",
1581
failed_formats.join(", "),
1582
);
1583
}
1584
}
1585
}
1586
}
1587
}
1588
}
1589
if (ttf_urls.length || woff_urls.length) {
1590
const font_cache = migrateProjectScratchPath(
1591
brand!.projectDir,
1592
"typst-font-cache",
1593
"typst/fonts",
1594
);
1595
const url_to_path = (url: string) => url.replace(/^https?:\/\//, "");
1596
const cached = async (url: string) => {
1597
const path = url_to_path(url);
1598
try {
1599
await Deno.lstat(join(font_cache, path));
1600
return true;
1601
} catch (err) {
1602
if (!(err instanceof Deno.errors.NotFound)) {
1603
throw err;
1604
}
1605
return false;
1606
}
1607
};
1608
const download = async (url: string) => {
1609
const path = url_to_path(url);
1610
await ensureDir(
1611
join(font_cache, dirname(path)),
1612
);
1613
1614
const response = await fetch(url);
1615
const blob = await response.blob();
1616
const buffer = await blob.arrayBuffer();
1617
const bytes = new Uint8Array(buffer);
1618
await Deno.writeFile(join(font_cache, path), bytes);
1619
};
1620
const woff2ttf = async (url: string) => {
1621
const path = url_to_path(url);
1622
await call("ttx", { args: [join(font_cache, path)] });
1623
await call("ttx", {
1624
args: [join(font_cache, path.replace(/woff2?$/, "ttx"))],
1625
});
1626
};
1627
const ttf_urls2: Array<string> = [], woff_urls2: Array<string> = [];
1628
await Promise.all(ttf_urls.map(async (url) => {
1629
if (!await cached(url)) {
1630
ttf_urls2.push(url);
1631
}
1632
}));
1633
1634
await woff_urls.reduce((cur, next) => {
1635
return cur.then(() => woff2ttf(next));
1636
}, Promise.resolve());
1637
// await Promise.all(woff_urls.map(async (url) => {
1638
// if (!await cached(url)) {
1639
// woff_urls2.push(url);
1640
// }
1641
// }));
1642
await Promise.all(ttf_urls2.concat(woff_urls2).map(download));
1643
if (woff_urls2.length) {
1644
await Promise.all(woff_urls2.map(woff2ttf));
1645
}
1646
fontdirs.add(font_cache);
1647
}
1648
let fontPaths = format.metadata[kFontPaths] as Array<string> || [];
1649
if (typeof fontPaths === "string") {
1650
fontPaths = [fontPaths];
1651
}
1652
fontPaths = fontPaths.map((path) =>
1653
path[0] === "/" ? join(project.dir, path) : path
1654
);
1655
fontPaths.push(...fontdirs);
1656
format.metadata[kFontPaths] = fontPaths;
1657
}
1658
1659
// Process format resources
1660
1661
// If we're generating the PDF, we can move the format resources once the pandoc
1662
// render has completed.
1663
if (format.render[kLatexAutoMk] === false) {
1664
// Process the format resouces right here on the spot
1665
await processFormatResources(inputDir, dependenciesFile);
1666
} else {
1667
const resourceDependenciesPostProcessor = async (_output: string) => {
1668
return await processFormatResources(inputDir, dependenciesFile);
1669
};
1670
extras.postprocessors = extras.postprocessors || [];
1671
extras.postprocessors.push(resourceDependenciesPostProcessor);
1672
}
1673
1674
// Resolve the highlighting theme (if any)
1675
extras = resolveTextHighlightStyle(
1676
inputDir,
1677
extras,
1678
format.pandoc,
1679
);
1680
1681
return extras;
1682
}
1683
1684
function resolveBodyEnvelope(
1685
pandoc: FormatPandoc,
1686
extras: FormatExtras,
1687
temp: TempContext,
1688
) {
1689
const envelope = extras.html?.[kBodyEnvelope];
1690
if (envelope) {
1691
const writeBodyFile = (
1692
type: "include-in-header" | "include-before-body" | "include-after-body",
1693
prepend: boolean, // should we prepend or append this element
1694
content?: string,
1695
) => {
1696
if (content) {
1697
const file = temp.createFile({ suffix: ".html" });
1698
Deno.writeTextFileSync(file, content);
1699
if (!prepend) {
1700
pandoc[type] = (pandoc[type] || []).concat(file);
1701
} else {
1702
pandoc[type] = [file].concat(pandoc[type] || []);
1703
}
1704
}
1705
};
1706
writeBodyFile(kIncludeInHeader, true, envelope.header);
1707
writeBodyFile(kIncludeBeforeBody, true, envelope.before);
1708
1709
// Process the after body preamble and postamble (include-after-body appears between these)
1710
writeBodyFile(kIncludeAfterBody, true, envelope.afterPreamble);
1711
writeBodyFile(kIncludeAfterBody, false, envelope.afterPostamble);
1712
}
1713
}
1714
1715
function runPandocMessage(
1716
args: string[],
1717
pandoc: FormatPandoc | undefined,
1718
sysFilters: string[],
1719
metadata: Metadata,
1720
debug?: boolean,
1721
) {
1722
info(`pandoc ${args.join(" ")}`, { bold: true });
1723
if (pandoc) {
1724
info(pandocDefaultsMessage(pandoc, sysFilters, debug), { indent: 2 });
1725
}
1726
1727
const keys = Object.keys(metadata);
1728
if (keys.length > 0) {
1729
const printMetadata = safeCloneDeep(metadata);
1730
delete printMetadata.format;
1731
1732
// print message
1733
if (Object.keys(printMetadata).length > 0) {
1734
info("metadata", { bold: true });
1735
info(
1736
stringify(printMetadata, {
1737
indent: 2,
1738
lineWidth: -1,
1739
sortKeys: false,
1740
skipInvalid: true,
1741
}),
1742
{ indent: 2 },
1743
);
1744
}
1745
}
1746
}
1747
1748
function resolveTextHighlightStyle(
1749
inputDir: string,
1750
extras: FormatExtras,
1751
pandoc: FormatPandoc,
1752
): FormatExtras {
1753
extras = {
1754
...extras,
1755
pandoc: extras.pandoc ? { ...extras.pandoc } : {},
1756
} as FormatExtras;
1757
1758
// Get the user selected theme or choose a default
1759
const highlightTheme = pandoc[kHighlightStyle] || kDefaultHighlightStyle;
1760
const textHighlightingMode = extras.html?.[kTextHighlightingMode];
1761
1762
if (highlightTheme === "none") {
1763
// Disable highlighting - pass "none" string (not null, which Pandoc 3.8+ rejects)
1764
extras.pandoc = extras.pandoc || {};
1765
extras.pandoc[kHighlightStyle] = "none";
1766
return extras;
1767
}
1768
1769
// create the possible name matches based upon the dark vs. light
1770
// and find a matching theme file
1771
// Themes from
1772
// https://invent.kde.org/frameworks/syntax-highlighting/-/tree/master/data/themes
1773
switch (textHighlightingMode) {
1774
case "light":
1775
case "dark":
1776
// Set light or dark mode as appropriate
1777
extras.pandoc = extras.pandoc || {};
1778
extras.pandoc[kHighlightStyle] = textHighlightThemePath(
1779
inputDir,
1780
highlightTheme,
1781
textHighlightingMode,
1782
) ||
1783
highlightTheme;
1784
1785
break;
1786
case "none":
1787
// Clear the highlighting
1788
if (extras.pandoc) {
1789
extras.pandoc = extras.pandoc || {};
1790
extras.pandoc[kHighlightStyle] = textHighlightThemePath(
1791
inputDir,
1792
"none",
1793
);
1794
}
1795
break;
1796
case undefined:
1797
default:
1798
// Set the the light (default) highlighting mode
1799
extras.pandoc = extras.pandoc || {};
1800
extras.pandoc[kHighlightStyle] =
1801
textHighlightThemePath(inputDir, highlightTheme, "light") ||
1802
highlightTheme;
1803
break;
1804
}
1805
return extras;
1806
}
1807
1808