|
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; |
|
} |
Description
In a Quarto book project rendered to Typst with brand fonts declared in
_brand.ymlviasource: google, the fonts are downloaded to.quarto/typst/fonts/but the directory is not passed totypst compilevia--font-path. Headings fall back to Libertinus Serif and the render emits anunknown font familywarning.The same
_brand.ymland the same fonts work correctly for a standalone document (format: typstwithoutproject.type: book). The bug is book-mode specific.Reproduction
_quarto.yml:_brand.yml:index.qmd:# Preface {.unnumbered} This is the index page. Headings should render in Orbitron, body in Inter.01-chapter.qmd:Render with
quarto render.Actual behavior
The fonts are present on disk after the render:
But the
typst compilecall 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: typstdocument with the same_brand.yml.Workaround
Explicitly add the brand cache to
font-pathsunder the typst format: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 inresolveExtrasfor typst output:quarto-cli/src/command/render/pandoc.ts
Lines 1527 to 1675 in 4ed3ffb
The typst PDF recipe reads
format.metadata["font-paths"]from theformatparameter captured at recipe-construction time:quarto-cli/src/command/render/output-typst.ts
Lines 210 to 249 in 4ed3ffb
For a book,
renderSingleFileBookcallswithBookTitleMetadatabetween recipe construction andrenderPandoc, and that helper deep-clones the format:quarto-cli/src/project/types/book/book-render.ts
Lines 329 to 381 in 4ed3ffb
quarto-cli/src/project/types/book/book-render.ts
Lines 724 to 746 in 4ed3ffb
After the deep clone,
recipe.formatand the recipe's capturedformatreference different metadata objects.resolveExtrasthen mutatesrecipe.format.metadata["font-paths"](the post-clone object) when it adds the brand font directory, but the recipe'scompletereads from the pre-clone capture, which never sees the mutation. The standalone document path never reacheswithBookTitleMetadata, so the reference is preserved and brand fonts work.We could fix this by having
typstPdfOutputRecipe'scompletereadfont-pathsfromrecipe.format.metadatarather than from the capturedformatparameter, so any later reassignment ofrecipe.formatis observed at compile time. A more invasive alternative would be to mutate format in place insidewithBookTitleMetadatainstead 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
brand.typography.fontsshould be made available in websites #11929 — All brand.typography.fonts should be available in websitesfont-pathfor typst format in_quarto.ymlbeginning with/is not resolved relative to project root #12695 — font-path resolution relative to project root