Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions llm-docs/pandoc-quarto-typst-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ Applies the `article()` function via a show rule, mapping Pandoc metadata and br
- `brand.typography.headings.color` → `heading-color`
- `brand.typography.headings.line-height` → `heading-line-height`

**Font availability filtering** (#12556): CSS font-family fallback lists (from brand.yaml or inline CSS) are filtered against fonts available to the Typst compiler before reaching the template. `typst_css.lua:translate_font_family_list()` reads the `typst-available-fonts` filter param (populated by `getAvailableTypstFonts()` in `src/core/typst.ts`) and removes unavailable fonts. If all fonts are filtered out, the original list is preserved. This prevents Typst 1.12+ from emitting "unknown font family" warnings for CSS fallback fonts that aren't installed.

### notes.typ - Endnotes Section

Renders endnotes when present:
Expand Down
15 changes: 15 additions & 0 deletions src/command/render/pandoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
metadataGetDeep,
} from "../../config/metadata.ts";
import { pandocBinaryPath, resourcePath } from "../../core/resources.ts";
import { getAvailableTypstFonts } from "../../core/typst.ts";
import { filterBundledSubtreeEngines } from "../../extension/extension.ts";
import { pandocAutoIdentifier } from "../../core/pandoc/pandoc-id.ts";
import {
Expand Down Expand Up @@ -1663,6 +1664,20 @@ async function resolveExtras(
);
fontPaths.push(...fontdirs);
format.metadata[kFontPaths] = fontPaths;

// Enumerate available fonts for CSS fallback list filtering (#12556)
// Resolve relative paths to absolute, matching compilation in output-typst.ts
const resolvedFontPaths = fontPaths.map((p: string) =>
isAbsolute(p) ? p : resolve(inputDir, p)
);
const availableTypstFonts = await getAvailableTypstFonts(
resolvedFontPaths,
project?.dir,
);
if (availableTypstFonts.length > 0) {
extras[kFilterParams] = extras[kFilterParams] || {};
extras[kFilterParams]["typst-available-fonts"] = availableTypstFonts;
}
}

// Process format resources
Expand Down
86 changes: 85 additions & 1 deletion src/core/typst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ import { satisfies } from "semver/mod.ts";
import { execProcess } from "./process.ts";
import { architectureToolsPath } from "./resources.ts";
import { resourcePath } from "./resources.ts";
import { md5HashSync } from "./hash.ts";
import { projectScratchPath } from "../project/project-scratch.ts";

export function typstBinaryPath() {
return Deno.env.get("QUARTO_TYPST") ||
architectureToolsPath("typst");
}

function fontPathsArgs(fontPaths?: string[]) {
export function fontPathsArgs(fontPaths?: string[]) {
// orders matter and fontPathsQuarto should be first for our template to work
const fontPathsQuarto = ["--font-path", resourcePath("formats/typst/fonts")];
const fontPathsEnv = Deno.env.get("TYPST_FONT_PATHS");
Expand All @@ -36,6 +38,88 @@ function fontPathsArgs(fontPaths?: string[]) {
return fontPathsQuarto.concat(fontExtrasArgs);
}

export function parseTypstFontsOutput(output: string): string[] {
return output
.split(/\r?\n/)
.map((line) => line.trim().toLowerCase())
.filter((line) => line.length > 0);
}

const availableFontsMemoryCache = new Map<string, string[]>();

export async function getAvailableTypstFonts(
fontPaths: string[],
projectDir?: string,
): Promise<string[]> {
const cacheKey = md5HashSync(
[...fontPaths].sort().join("\n"),
);

// Check in-memory cache
const memoryCached = availableFontsMemoryCache.get(cacheKey);
if (memoryCached) {
return memoryCached;
}

// Check disk cache if project context
if (projectDir) {
try {
const cachePath = projectScratchPath(
projectDir,
"typst/available-fonts.json",
);
const cacheContent = Deno.readTextFileSync(cachePath);
const cached = JSON.parse(cacheContent) as {
fontPathsHash: string;
fonts: string[];
};
if (cached.fontPathsHash === cacheKey) {
availableFontsMemoryCache.set(cacheKey, cached.fonts);
return cached.fonts;
}
} catch {
// Cache miss or invalid — will re-query
}
}

// Query typst fonts
const cmd = [typstBinaryPath(), "fonts"];
cmd.push(...fontPathsArgs(fontPaths));

const result = await execProcess({
cmd: cmd[0],
args: cmd.slice(1),
stdout: "piped",
stderr: "piped",
});

if (!result.success || !result.stdout) {
return [];
}

const fonts = parseTypstFontsOutput(result.stdout);

// Populate caches
availableFontsMemoryCache.set(cacheKey, fonts);

if (projectDir) {
try {
const cachePath = projectScratchPath(
projectDir,
"typst/available-fonts.json",
);
Deno.writeTextFileSync(
cachePath,
JSON.stringify({ fontPathsHash: cacheKey, fonts }),
);
} catch {
// Non-fatal — in-memory cache still works
}
}

return fonts;
}

export type TypstCompileOptions = {
quiet?: boolean;
fontPaths?: string[];
Expand Down
38 changes: 34 additions & 4 deletions src/resources/filters/modules/typst_css.lua
Original file line number Diff line number Diff line change
Expand Up @@ -656,19 +656,48 @@ local function quote(s)
return '"' .. s .. '"'
end

local _available_fonts = nil
local _fonts_initialized = false

local function init_available_fonts(list)
_fonts_initialized = true
if list == nil then
_available_fonts = nil
return
end
_available_fonts = {}
for _, f in ipairs(list) do
local name = type(f) == 'string' and f or pandoc.utils.stringify(f)
_available_fonts[name:lower()] = true
end
end

local function ensure_available_fonts()
if _fonts_initialized then return end
init_available_fonts(param('typst-available-fonts'))
end

local function translate_font_family_list(sl)
if sl == nil then
return '()'
end
local strings = {}
ensure_available_fonts()
local all_strings = {}
local filtered = {}
for s in sl:gmatch('([^,]+)') do
s = s:gsub('^%s+', ''):gsub('%s+$', '')
if s ~= '' then
table.insert(strings, quote(dequote(s)))
local cleaned = dequote(s)
local quoted = quote(cleaned)
table.insert(all_strings, quoted)
if not _available_fonts or _available_fonts[cleaned:lower()] then
table.insert(filtered, quoted)
end
end
end
local trailcomma = #strings == 1 and ',' or ''
return '(' .. table.concat(strings, ', ') .. trailcomma .. ')'
local result = #filtered > 0 and filtered or all_strings
local trailcomma = #result == 1 and ',' or ''
return '(' .. table.concat(result, ', ') .. trailcomma .. ')'
end


Expand Down Expand Up @@ -804,6 +833,7 @@ return {
translate_border_color = translate_border_color,
translate_font_weight = translate_font_weight,
translate_font_family_list = translate_font_family_list,
init_available_fonts = init_available_fonts,
consume_width = consume_width,
consume_style = consume_style,
consume_color = consume_color
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
typography:
base:
family: "Libertinus Serif, Nonexistent Font One, Nonexistent Font Two"
monospace:
family: "DejaVu Sans Mono, Nonexistent Mono Font"
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
title: "Font Filtering Test"
format:
typst:
keep-typ: true
_quarto:
tests:
typst:
ensureTypstFileRegexMatches:
-
- '"Libertinus Serif"'
- '"DejaVu Sans Mono"'
-
- 'Nonexistent Font One'
- 'Nonexistent Font Two'
- 'Nonexistent Mono Font'
---

This document tests that unavailable fonts are filtered from brand typography fallback lists.
55 changes: 55 additions & 0 deletions tests/unit-lua/typst-css.test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -482,4 +482,59 @@ function TestModuleExports:testNoPhantomExports()
table.concat(nils, ', '))
end

-- Font filtering: init_available_fonts + translate_font_family_list -----
TestFontFiltering = {}

function TestFontFiltering:setUp()
-- Reset module state before each test
typst_css.init_available_fonts(nil)
end

function TestFontFiltering:testNoMetadataPassesThrough()
typst_css.init_available_fonts(nil)
lu.assertEquals(
typst_css.translate_font_family_list('Inter, Helvetica Neue, Arial'),
'("Inter", "Helvetica Neue", "Arial")')
end

function TestFontFiltering:testFiltersUnavailableFonts()
typst_css.init_available_fonts({ 'arial' })
lu.assertEquals(
typst_css.translate_font_family_list('Inter, Helvetica Neue, Arial'),
'("Arial",)')
end

function TestFontFiltering:testKeepsMultipleAvailableFonts()
typst_css.init_available_fonts({ 'inter', 'arial' })
lu.assertEquals(
typst_css.translate_font_family_list('Inter, Helvetica Neue, Arial'),
'("Inter", "Arial")')
end

function TestFontFiltering:testAllFilteredKeepsOriginal()
typst_css.init_available_fonts({ 'dejavu sans' })
lu.assertEquals(
typst_css.translate_font_family_list('Inter, Helvetica Neue, Arial'),
'("Inter", "Helvetica Neue", "Arial")')
end

function TestFontFiltering:testCaseInsensitiveMatching()
typst_css.init_available_fonts({ 'helvetica neue' })
lu.assertEquals(
typst_css.translate_font_family_list('"Helvetica Neue"'),
'("Helvetica Neue",)')
end

function TestFontFiltering:testNilInputStillReturnsEmpty()
typst_css.init_available_fonts({ 'arial' })
lu.assertEquals(typst_css.translate_font_family_list(nil), '()')
end

function TestFontFiltering:testEmptyMetaListNoFiltering()
typst_css.init_available_fonts({})
lu.assertEquals(
typst_css.translate_font_family_list('Inter, Arial'),
'("Inter", "Arial")')
end

os.exit(lu.LuaUnit.run())
34 changes: 34 additions & 0 deletions tests/unit/typst-fonts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* typst-fonts.test.ts
*
* Unit tests for Typst font enumeration and parsing.
*
* Copyright (C) 2025 Posit Software, PBC
*/

import { unitTest } from "../test.ts";
import { assertEquals } from "testing/asserts";
import { parseTypstFontsOutput } from "../../src/core/typst.ts";

unitTest("parseTypstFontsOutput - parses one font per line", async () => {
const output = "Arial\nDejaVu Sans Mono\nLibertinus Serif\n";
const result = parseTypstFontsOutput(output);
assertEquals(result, ["arial", "dejavu sans mono", "libertinus serif"]);
});

unitTest("parseTypstFontsOutput - trims whitespace and blank lines", async () => {
const output = " Arial \n\n DejaVu Sans \n \n";
const result = parseTypstFontsOutput(output);
assertEquals(result, ["arial", "dejavu sans"]);
});

unitTest("parseTypstFontsOutput - empty output returns empty array", async () => {
const result = parseTypstFontsOutput("");
assertEquals(result, []);
});

unitTest("parseTypstFontsOutput - handles windows line endings", async () => {
const output = "Arial\r\nTimes New Roman\r\n";
const result = parseTypstFontsOutput(output);
assertEquals(result, ["arial", "times new roman"]);
});
Loading