Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/format/typst/format-typst.ts
12925 views
1
/*
2
* format-typst.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { join } from "../../deno_ral/path.ts";
8
9
import { RenderServices } from "../../command/render/types.ts";
10
import { ProjectContext } from "../../project/types.ts";
11
import { BookExtension } from "../../project/types/book/book-shared.ts";
12
import {
13
kBrand,
14
kCiteproc,
15
kColumns,
16
kDefaultImageExtension,
17
kFigFormat,
18
kFigHeight,
19
kFigWidth,
20
kLight,
21
kLogo,
22
kNumberSections,
23
kSectionNumbering,
24
kShiftHeadingLevelBy,
25
kVariant,
26
kWrap,
27
} from "../../config/constants.ts";
28
import {
29
Format,
30
FormatExtras,
31
FormatPandoc,
32
LightDarkBrand,
33
Metadata,
34
PandocFlags,
35
} from "../../config/types.ts";
36
import { formatResourcePath } from "../../core/resources.ts";
37
import { createFormat } from "../formats-shared.ts";
38
import { hasLevelOneHeadings as hasL1Headings } from "../../core/lib/markdown-analysis/level-one-headings.ts";
39
import {
40
BrandNamedLogo,
41
LogoLightDarkSpecifier,
42
} from "../../resources/types/schema-types.ts";
43
import {
44
brandWithAbsoluteLogoPaths,
45
fillLogoPaths,
46
resolveLogo,
47
} from "../../core/brand/brand.ts";
48
import { LogoLightDarkSpecifierPathOptional } from "../../resources/types/zod/schema-types.ts";
49
50
const typstBookExtension: BookExtension = {
51
selfContainedOutput: true,
52
// multiFile defaults to false (single-file book)
53
};
54
55
export function typstFormat(): Format {
56
return createFormat("Typst", "pdf", {
57
execute: {
58
[kFigWidth]: 5.5,
59
[kFigHeight]: 3.5,
60
[kFigFormat]: "svg",
61
},
62
pandoc: {
63
standalone: true,
64
[kDefaultImageExtension]: "svg",
65
[kWrap]: "none",
66
[kCiteproc]: false,
67
},
68
extensions: {
69
book: typstBookExtension,
70
},
71
resolveFormat: typstResolveFormat,
72
formatExtras: async (
73
_input: string,
74
markdown: string,
75
flags: PandocFlags,
76
format: Format,
77
_libDir: string,
78
_services: RenderServices,
79
_offset?: string,
80
_project?: ProjectContext,
81
): Promise<FormatExtras> => {
82
const pandoc: FormatPandoc = {};
83
const metadata: Metadata = {};
84
85
// provide default section numbering if required
86
if (
87
(flags?.[kNumberSections] === true ||
88
format.pandoc[kNumberSections] === true)
89
) {
90
// number-sections imples section-numbering
91
if (!format.metadata?.[kSectionNumbering]) {
92
metadata[kSectionNumbering] = "1.1.a";
93
}
94
}
95
96
// unless otherwise specified, pdfs with only level 2 or greater headings get their
97
// heading level shifted by -1.
98
const hasLevelOneHeadings = await hasL1Headings(markdown);
99
if (
100
!hasLevelOneHeadings &&
101
flags?.[kShiftHeadingLevelBy] === undefined &&
102
format.pandoc?.[kShiftHeadingLevelBy] === undefined
103
) {
104
pandoc[kShiftHeadingLevelBy] = -1;
105
}
106
107
const brand = format.render.brand;
108
// For Typst, convert brand logo paths to project-absolute (with /)
109
// before merging with document logo metadata. Typst resolves / paths
110
// via --root which points to the project directory.
111
const typstBrand = brandWithAbsoluteLogoPaths(brand);
112
const logoSpec = format
113
.metadata[kLogo] as LogoLightDarkSpecifierPathOptional;
114
const sizeOrder: BrandNamedLogo[] = [
115
"small",
116
"medium",
117
"large",
118
];
119
// temporary: if document logo has object or light/dark objects
120
// without path, do our own findLogo to add the path
121
// typst is the exception not needing path but we'll probably deprecate this
122
const logo = fillLogoPaths(typstBrand, logoSpec, sizeOrder);
123
format.metadata[kLogo] = resolveLogo(typstBrand, logo, sizeOrder);
124
// force columns to wrap and move any 'columns' setting to metadata
125
const columns = format.pandoc[kColumns];
126
if (columns) {
127
pandoc[kColumns] = undefined;
128
metadata[kColumns] = columns;
129
}
130
131
// Provide a template and partials
132
// For Typst books, a book extension overrides these partials
133
const templateDir = formatResourcePath("typst", join("pandoc", "quarto"));
134
135
const templateContext = {
136
template: join(templateDir, "template.typ"),
137
partials: [
138
"numbering.typ",
139
"definitions.typ",
140
"typst-template.typ",
141
"page.typ",
142
"typst-show.typ",
143
"notes.typ",
144
"biblio.typ",
145
].map((partial) => join(templateDir, partial)),
146
};
147
148
// Postprocessor to fix Skylighting code block styling (issue #14126).
149
// Pandoc's generated Skylighting function uses block(fill: bgcolor, blocks)
150
// which lacks width, inset, and radius. We surgically fix this in the .typ
151
// output. If brand monospace-block has a background-color, we also override
152
// the bgcolor value.
153
const brandData = (format.render[kBrand] as LightDarkBrand | undefined)
154
?.[kLight];
155
const monospaceBlock = brandData?.processedData?.typography?.[
156
"monospace-block"
157
];
158
let brandBgColor = (monospaceBlock && typeof monospaceBlock !== "string")
159
? monospaceBlock["background-color"] as string | undefined
160
: undefined;
161
// Resolve palette color names (e.g. "code-bg" → "#1e1e2e")
162
if (brandBgColor && brandData?.data?.color?.palette) {
163
const palette = brandData.data.color.palette as Record<string, string>;
164
let resolved = brandBgColor;
165
while (palette[resolved]) {
166
resolved = palette[resolved];
167
}
168
brandBgColor = resolved;
169
}
170
171
return {
172
pandoc,
173
metadata,
174
templateContext,
175
postprocessors: [
176
skylightingPostProcessor(brandBgColor),
177
],
178
};
179
},
180
});
181
}
182
183
// Fix Skylighting code block styling in .typ output (issue #14126).
184
// The Pandoc-generated Skylighting function uses block(fill: bgcolor, blocks)
185
// which lacks width, inset, and radius. This postprocessor matches the entire
186
// Skylighting function by its distinctive signature and patches only within it.
187
// When brand provides a monospace-block background-color, also overrides the
188
// bgcolor value. This is a temporary workaround until the fix is upstreamed
189
// to the Skylighting library.
190
function skylightingPostProcessor(brandBgColor?: string) {
191
// Match the entire #let Skylighting(...) = { ... } function.
192
// The signature is stable and generated by Skylighting's Typst backend.
193
const skylightingFnRe =
194
/(#let Skylighting\(fill: none, number: false, start: 1, sourcelines\) = \{[\s\S]*?\n\})/;
195
196
return async (output: string) => {
197
const content = Deno.readTextFileSync(output);
198
199
const match = skylightingFnRe.exec(content);
200
if (!match) {
201
// No Skylighting function found — document may not have code blocks,
202
// or upstream changed the function signature. Nothing to patch.
203
return;
204
}
205
206
let fn = match[1];
207
208
// Fix block() call: add width, inset, radius
209
fn = fn.replace(
210
"block(fill: bgcolor, blocks)",
211
"block(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, blocks)",
212
);
213
214
// Override bgcolor with brand monospace-block background-color
215
if (brandBgColor) {
216
fn = fn.replace(
217
/let bgcolor = rgb\("[^"]*"\)/,
218
`let bgcolor = rgb("${brandBgColor}")`,
219
);
220
}
221
222
if (fn !== match[1]) {
223
Deno.writeTextFileSync(output, content.replace(match[1], fn));
224
}
225
};
226
}
227
228
function typstResolveFormat(format: Format) {
229
// Pandoc citeproc with typst output requires adjustment
230
// https://github.com/jgm/pandoc/commit/e89a3edf24a025d5bb0fe8c4c7a8e6e0208fa846
231
if (
232
format.pandoc?.[kCiteproc] === true &&
233
!format.pandoc.to?.includes("-citations") &&
234
!format.render[kVariant]?.includes("-citations")
235
) {
236
// citeproc: false is the default, so user setting it to true means they want to use
237
// Pandoc's citeproc which requires `citations` extensions to be disabled (e.g typst-citations)
238
// This adds the variants for them if not set already
239
format.render[kVariant] = [format.render?.[kVariant], "-citations"].join(
240
"",
241
);
242
}
243
}
244
245