Skip to content

typst book: brand fonts downloaded but not passed to typst compile (unknown font family warning) #14511

@cderv

Description

@cderv

Description

In a Quarto book project rendered to Typst with brand fonts declared in _brand.yml via source: google, the fonts are downloaded to .quarto/typst/fonts/ but the directory is not passed to typst compile via --font-path. Headings fall back to Libertinus Serif and the render emits an unknown font family warning.

The same _brand.yml and the same fonts work correctly for a standalone document (format: typst without project.type: book). The bug is book-mode specific.

Reproduction

_quarto.yml:

project:
  type: book

book:
  title: "Brand Fonts Repro"
  author: "Test"
  chapters:
    - index.qmd
    - 01-chapter.qmd

format: typst

_brand.yml:

color:
  palette:
    sw-yellow: "#FFE81F"
    sw-black:  "#0B0B0F"
  primary:    sw-yellow
  foreground: sw-black

typography:
  fonts:
    - family: Orbitron
      source: google
      weight: [400, 700]
    - family: Inter
      source: google
      weight: [400, 600]
  base: Inter
  headings: Orbitron

index.qmd:

# Preface {.unnumbered}

This is the index page. Headings should render in Orbitron, body in Inter.

01-chapter.qmd:

# First chapter

Some body text.

## A subheading

More text.

Render with quarto render.

Actual behavior

[typst]: Compiling index.typ to index.pdf...warning: unknown font family: orbitron
    ┌─ \\?\C:\tmp\typst-book-repro\index.typ:387:30
    │
387 │ #show heading: set text(font: ("Orbitron",), )
    │                               ^^^^^^^^^^^^^

DONE

Output created: _book\Brand-Fonts-Repro.pdf

The fonts are present on disk after the render:

.quarto/typst/fonts/fonts.gstatic.com/s/orbitron/v35/*.ttf
.quarto/typst/fonts/fonts.gstatic.com/s/inter/v20/*.ttf

But the typst compile call only receives --font-path <resourcePath>/formats/typst/fonts (the bundled fonts), not the project's brand cache.

Expected behavior

Headings render in Orbitron and body in Inter, with no warning, as they do for a standalone format: typst document with the same _brand.yml.

Workaround

Explicitly add the brand cache to font-paths under the typst format:

format:
  typst:
    font-paths:
      - .quarto/typst/fonts

With this added, the warning disappears and the brand fonts apply.

Where the fix can live

The brand font download and the corresponding mutation of format.metadata["font-paths"] happens in resolveExtras for typst output:

if (isTypstOutput(format.pandoc)) {
const brand = (await project.resolveBrand(input))?.light;
const fontdirs: Set<string> = new Set();
const base_urls = {
google: "https://fonts.googleapis.com/css",
bunny: "https://fonts.bunny.net/css",
};
const ttf_urls = [], woff_urls: Array<string> = [];
if (brand?.data.typography) {
const fonts = brand.data.typography.fonts || [];
for (const _font of fonts) {
// if font lacks a source, we assume google in typst output
// deno-lint-ignore no-explicit-any
const source: string = (_font as any).source ?? "google";
if (source === "file") {
const font = Zod.BrandFontFile.parse(_font);
for (const file of font.files || []) {
const path = typeof file === "object" ? file.path : file;
fontdirs.add(resolve(dirname(join(brand.brandDir, path))));
}
} else if (source === "bunny") {
const font = Zod.BrandFontBunny.parse(_font);
console.log(
"Font bunny is not yet supported for Typst, skipping",
font.family,
);
} else if (source === "google" /* || font.source === "bunny" */) {
const font = Zod.BrandFontGoogle.parse(_font);
let { family, style, weight } = font;
const parts = [family!];
if (style) {
style = Array.isArray(style) ? style : [style];
parts.push(style.join(","));
}
if (weight) {
weight = Array.isArray(weight) ? weight : [weight];
parts.push(weight.join(","));
}
const response = await fetch(
`${base_urls[source]}?family=${parts.join(":")}`,
);
const lines = (await response.text()).split("\n");
for (const line of lines) {
const sourcelist = line.match(/^ *src: (.*); *$/);
if (sourcelist) {
const sources = sourcelist[1].split(",").map((s) => s.trim());
let found = false;
const failed_formats = [];
for (const source of sources) {
const match = source.match(
/url\(([^)]*)\) *format\('([^)]*)'\)/,
);
if (match) {
const [_, url, format] = match;
if (["truetype", "opentype"].includes(format)) {
ttf_urls.push(url);
found = true;
break;
}
// else if (["woff", "woff2"].includes(format)) {
// woff_urls.push(url);
// break;
// }
failed_formats.push(format);
}
}
if (!found) {
console.log(
"skipping",
family,
"\nnot currently able to use formats",
failed_formats.join(", "),
);
}
}
}
}
}
}
if (ttf_urls.length || woff_urls.length) {
const font_cache = migrateProjectScratchPath(
brand!.projectDir,
"typst-font-cache",
"typst/fonts",
);
const url_to_path = (url: string) => url.replace(/^https?:\/\//, "");
const cached = async (url: string) => {
const path = url_to_path(url);
try {
await Deno.lstat(join(font_cache, path));
return true;
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) {
throw err;
}
return false;
}
};
const download = async (url: string) => {
const path = url_to_path(url);
await ensureDir(
join(font_cache, dirname(path)),
);
const response = await fetch(url);
const blob = await response.blob();
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
await Deno.writeFile(join(font_cache, path), bytes);
};
const woff2ttf = async (url: string) => {
const path = url_to_path(url);
await call("ttx", { args: [join(font_cache, path)] });
await call("ttx", {
args: [join(font_cache, path.replace(/woff2?$/, "ttx"))],
});
};
const ttf_urls2: Array<string> = [], woff_urls2: Array<string> = [];
await Promise.all(ttf_urls.map(async (url) => {
if (!await cached(url)) {
ttf_urls2.push(url);
}
}));
await woff_urls.reduce((cur, next) => {
return cur.then(() => woff2ttf(next));
}, Promise.resolve());
// await Promise.all(woff_urls.map(async (url) => {
// if (!await cached(url)) {
// woff_urls2.push(url);
// }
// }));
await Promise.all(ttf_urls2.concat(woff_urls2).map(download));
if (woff_urls2.length) {
await Promise.all(woff_urls2.map(woff2ttf));
}
fontdirs.add(font_cache);
}
let fontPaths = format.metadata[kFontPaths] as Array<string> || [];
if (typeof fontPaths === "string") {
fontPaths = [fontPaths];
}
fontPaths = fontPaths.map((path) =>
path[0] === "/" ? join(project.dir, path) : path
);
fontPaths.push(...fontdirs);
format.metadata[kFontPaths] = fontPaths;
}

The typst PDF recipe reads format.metadata["font-paths"] from the format parameter captured at recipe-construction time:

export function typstPdfOutputRecipe(
input: string,
finalOutput: string,
options: RenderOptions,
format: Format,
project?: ProjectContext,
): OutputRecipe {
// calculate output and args for pandoc (this is an intermediate file
// which we will then compile to a pdf and rename to .typ)
const [inputDir, inputStem] = dirAndStem(input);
const output = inputStem + ".typ";
let args = options.pandocArgs || [];
const pandoc = { ...format.pandoc };
if (options.flags?.output) {
args = replacePandocOutputArg(args, output);
} else {
pandoc[kOutputFile] = output;
}
// when pandoc is done, we need to run the pdf generator and then copy the
// output to the user's requested destination
const complete = async () => {
// input file is pandoc's output
const typstInput = join(inputDir, output);
// run typst
await validateRequiredTypstVersion();
const pdfOutput = join(inputDir, inputStem + ".pdf");
const typstOptions: TypstCompileOptions = {
quiet: options.flags?.quiet,
fontPaths: (asArray(format.metadata?.[kFontPaths]) as string[]).map(
(p) => isAbsolute(p) ? p : resolve(inputDir, p),
),
pdfStandard: normalizePdfStandardForTypst(
asArray(
format.render?.[kPdfStandard] ?? format.metadata?.[kPdfStandard] ??
pdfStandardEnv(),
),
),
};

For a book, renderSingleFileBook calls withBookTitleMetadata between recipe construction and renderPandoc, and that helper deep-clones the format:

async function renderSingleFileBook(
project: ProjectContext,
options: RenderOptions,
files: ExecutedFile[],
quiet: boolean,
): Promise<RenderedFile> {
const fileLifetime = await waitUntilNamedLifetime("render-file");
try {
// we are going to compose a single ExecutedFile from the array we have been passed
const executedFile = await mergeExecutedFiles(
project,
options,
files,
);
// set book title metadata
executedFile.recipe.format = withBookTitleMetadata(
executedFile.recipe.format,
project.config,
);
// call book extension if applicable
executedFile.recipe.format = onSingleFileBookPreRender(
executedFile.recipe.format,
project.config,
);
// do pandoc render
const renderCompletion = await renderPandoc(executedFile, quiet);
const renderedFormats: RenderedFormat[] = [];
const renderedFile = await renderCompletion.complete(renderedFormats);
// cleanup step for each executed file
files.forEach((file) => {
// Forward render cleanup options from parent format
file.recipe.format.render[kKeepTex] =
executedFile.recipe.format.render[kKeepTex];
cleanupExecutedFile(
file,
join(project.dir, renderedFile.file),
);
});
// call book extension if applicable
onSingleFileBookPostRender(project, renderedFile);
// return rendered file
return renderedFile;
} finally {
fileLifetime.cleanup();
}
}

function withBookTitleMetadata(format: Format, config?: ProjectConfig): Format {
format = ld.cloneDeep(format);
if (config) {
const setMetadata = (
key: BookConfigKey,
) => {
const value = bookConfig(key, config);
if (value) {
format.metadata[key] = value;
}
};
setMetadata(kTitle);
setMetadata(kSubtitle);
setMetadata(kAuthor);
setMetadata(kDate);
setMetadata(kDateFormat);
setMetadata(kAbstract);
setMetadata(kDescription);
setMetadata(kDoi);
}
return format;
}

After the deep clone, recipe.format and the recipe's captured format reference different metadata objects. resolveExtras then mutates recipe.format.metadata["font-paths"] (the post-clone object) when it adds the brand font directory, but the recipe's complete reads from the pre-clone capture, which never sees the mutation. The standalone document path never reaches withBookTitleMetadata, so the reference is preserved and brand fonts work.

We could fix this by having typstPdfOutputRecipe's complete read font-paths from recipe.format.metadata rather than from the captured format parameter, so any later reassignment of recipe.format is observed at compile time. A more invasive alternative would be to mutate format in place inside withBookTitleMetadata instead of deep-cloning, but that helper is also used on the per-chapter HTML path and the wider impact would need to be assessed.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions