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