From 17d13de0168a800339779c3365ee92d7c77f665c Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Sat, 3 Oct 2026 12:42:34 -0400 Subject: [PATCH] typst: resolve CSS generic font families to an installed font Typst has no generic font families, but typst_css.lua passed `serif`, `sans-serif`, `monospace` and friends straight through, so every compile warned `unknown font family`. `system-ui` and the `ui-*` keywords were not recognized at all. Replace each generic keyword, at its position in the list, with the first available font from an ordered candidate list built from Chromium's per-OS defaults, fontconfig's 60-latin.conf, Modern Font Stacks and Bootstrap's stacks, ending in Typst's bundled fonts so serif, monospace and math always resolve. The keyword is never emitted. If nothing resolves, the keyword is dropped, and an otherwise empty list falls back to Typst's default text font, since Typst rejects an empty font list. No fonts are bundled. Tracked as bd-hkf3r8i1. --- crates/quarto-core/tests/integration/main.rs | 1 + .../typst_generic_font_families.rs | 137 ++++++++++++++++++ .../font-filtering-generics/_brand.yml | 5 + .../font-filtering-generics.qmd | 26 ++++ .../generic-font-families/_brand.yml | 7 + .../generic-font-families.qmd | 29 ++++ .../system-ui-table/system-ui-table.qmd | 25 ++++ resources/pandoc-filters/README.md | 12 ++ .../filters/modules/typst_css.lua | 134 +++++++++++++++-- 9 files changed, 364 insertions(+), 12 deletions(-) create mode 100644 crates/quarto-core/tests/integration/typst_generic_font_families.rs create mode 100644 crates/quarto/tests/smoke-all/typst/brand-yaml/font-filtering-generics/_brand.yml create mode 100644 crates/quarto/tests/smoke-all/typst/brand-yaml/font-filtering-generics/font-filtering-generics.qmd create mode 100644 crates/quarto/tests/smoke-all/typst/brand-yaml/generic-font-families/_brand.yml create mode 100644 crates/quarto/tests/smoke-all/typst/brand-yaml/generic-font-families/generic-font-families.qmd create mode 100644 crates/quarto/tests/smoke-all/typst/brand-yaml/system-ui-table/system-ui-table.qmd diff --git a/crates/quarto-core/tests/integration/main.rs b/crates/quarto-core/tests/integration/main.rs index 0dc75debd..38fc745fe 100644 --- a/crates/quarto-core/tests/integration/main.rs +++ b/crates/quarto-core/tests/integration/main.rs @@ -142,6 +142,7 @@ pub mod title_block_pipeline; pub mod toc_location; pub mod toc_markup; pub mod toc_title_context; +pub mod typst_generic_font_families; pub mod unreferenced_brand; pub mod video_shortcode_preview; pub mod website_aliases; diff --git a/crates/quarto-core/tests/integration/typst_generic_font_families.rs b/crates/quarto-core/tests/integration/typst_generic_font_families.rs new file mode 100644 index 000000000..c1bc3cca5 --- /dev/null +++ b/crates/quarto-core/tests/integration/typst_generic_font_families.rs @@ -0,0 +1,137 @@ +//! Typst has no CSS generic font families, so `typst_css.lua` resolves each +//! generic keyword (`sans-serif`, `monospace`, `system-ui`, ...) to the first +//! *available* font from a curated candidate list instead of passing the +//! keyword through (which made every compile warn `unknown font family`). +//! +//! The resolver is a pure function (`resolve_font_families`), exercised here +//! with synthetic availability sets through a standalone `pandoc lua` run, so +//! the assertions don't depend on which fonts the host machine has. + +use quarto_core::pandoc_filters::harness::assert_pandoc_available; +use std::io::Write; +use std::path::PathBuf; +use std::process::Command; + +fn typst_css_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../resources/pandoc-filters/filters/modules/typst_css.lua") +} + +/// Runs `body` with `resolve(families, available)` in scope, where +/// `families` is a list of names and `available` a list of font names (or +/// `nil` for "unknown"). `resolve` returns the result joined by `|`. +fn run_lua(body: &str) -> String { + assert_pandoc_available(); + let path = typst_css_path().to_string_lossy().replace('\\', "\\\\"); + let script = format!( + r#" +param = function() return nil end +quarto = {{}} +local css = dofile("{path}") +local function resolve(families, available) + local set = nil + if available then + set = {{}} + for _, n in ipairs(available) do set[n:lower()] = true end + end + return table.concat(css.resolve_font_families(families, set), '|') +end +{body} +"# + ); + let mut f = tempfile::Builder::new().suffix(".lua").tempfile().unwrap(); + f.write_all(script.as_bytes()).unwrap(); + let out = Command::new("pandoc") + .arg("lua") + .arg(f.path()) + .output() + .expect("failed to execute pandoc lua"); + assert!( + out.status.success(), + "lua failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +#[test] +fn first_available_candidate_wins_in_list_order() { + // Both Arial and Helvetica present (macOS): the earlier candidate wins. + let out = run_lua("print(resolve({'sans-serif'}, {'Helvetica', 'Arial'}))"); + assert_eq!(out, "Arial"); + let out = run_lua("print(resolve({'sans-serif'}, {'Helvetica', 'DejaVu Sans'}))"); + assert_eq!(out, "Helvetica"); +} + +#[test] +fn keyword_is_replaced_in_place_mid_list() { + let out = run_lua( + "print(resolve({'Roboto', 'sans-serif', 'Georgia'}, {'Roboto', 'Arial', 'Georgia'}))", + ); + assert_eq!(out, "Roboto|Arial|Georgia"); +} + +#[test] +fn keyword_never_emitted_even_when_nothing_matches() { + // sans-serif has no built-in target: with nothing available it is dropped + // and the terminal fallback (Typst's default text font) is used, because + // Typst rejects an empty font list. + let out = run_lua("print(resolve({'sans-serif'}, {'Libertinus Serif'}))"); + assert_eq!(out, "Libertinus Serif"); + // Unknown availability (no typst-available-fonts param): also dropped. + let out = run_lua("print(resolve({'Roboto', 'monospace'}, nil))"); + assert_eq!(out, "Roboto"); +} + +#[test] +fn builtin_targets_always_resolve() { + let builtins = "{'DejaVu Sans Mono', 'Libertinus Serif', 'New Computer Modern', 'New Computer Modern Math'}"; + let out = run_lua(&format!("print(resolve({{'monospace'}}, {builtins}))")); + assert_eq!(out, "DejaVu Sans Mono"); + let out = run_lua(&format!("print(resolve({{'serif'}}, {builtins}))")); + assert_eq!(out, "Libertinus Serif"); + let out = run_lua(&format!("print(resolve({{'math'}}, {builtins}))")); + assert_eq!(out, "New Computer Modern Math"); +} + +#[test] +fn matching_is_case_insensitive() { + let out = run_lua("print(resolve({'SANS-SERIF'}, {'arial'}))"); + assert_eq!(out, "Arial"); +} + +#[test] +fn system_ui_and_ui_variants_resolve() { + let out = run_lua("print(resolve({'system-ui'}, {'Segoe UI', 'Arial'}))"); + assert_eq!(out, "Segoe UI"); + // system-ui falls through to the plain sans-serif candidates. + let out = run_lua("print(resolve({'system-ui'}, {'Arial'}))"); + assert_eq!(out, "Arial"); + let out = run_lua("print(resolve({'ui-monospace'}, {'SF Mono', 'Menlo'}))"); + assert_eq!(out, "SF Mono"); + let out = run_lua("print(resolve({'ui-serif'}, {'Times New Roman'}))"); + assert_eq!(out, "Times New Roman"); +} + +#[test] +fn script_specific_generics_are_dropped() { + let out = run_lua("print(resolve({'generic(kai)', 'Arial'}, {'Arial'}))"); + assert_eq!(out, "Arial"); +} + +#[test] +fn duplicates_are_collapsed() { + let out = run_lua("print(resolve({'Arial', 'sans-serif'}, {'Arial'}))"); + assert_eq!(out, "Arial"); +} + +#[test] +fn unavailable_named_fonts_still_filtered_or_kept_as_before() { + // Available named fonts survive, unavailable ones drop. + let out = run_lua("print(resolve({'Nope', 'Arial'}, {'Arial'}))"); + assert_eq!(out, "Arial"); + // Nothing available at all: keep the user's names (Typst will warn about + // them, which is the honest signal), rather than invent a font. + let out = run_lua("print(resolve({'Nope'}, {'Arial'}))"); + assert_eq!(out, "Nope"); +} diff --git a/crates/quarto/tests/smoke-all/typst/brand-yaml/font-filtering-generics/_brand.yml b/crates/quarto/tests/smoke-all/typst/brand-yaml/font-filtering-generics/_brand.yml new file mode 100644 index 000000000..8bcf6dac3 --- /dev/null +++ b/crates/quarto/tests/smoke-all/typst/brand-yaml/font-filtering-generics/_brand.yml @@ -0,0 +1,5 @@ +typography: + base: + family: "Roboto, sans-serif" + monospace: + family: "Inconsolata, monospace" diff --git a/crates/quarto/tests/smoke-all/typst/brand-yaml/font-filtering-generics/font-filtering-generics.qmd b/crates/quarto/tests/smoke-all/typst/brand-yaml/font-filtering-generics/font-filtering-generics.qmd new file mode 100644 index 000000000..429e77481 --- /dev/null +++ b/crates/quarto/tests/smoke-all/typst/brand-yaml/font-filtering-generics/font-filtering-generics.qmd @@ -0,0 +1,26 @@ +--- +title: "CSS Generic Font Family Test" +brand: _brand.yml +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + # Divergence from Q1, which asserts the generic keyword is preserved + # (`font: ("Roboto", "sans-serif")`). Typst has no generic families, so + # Q2 replaces each keyword with the first available font from a + # candidate list. Which font wins differs per machine, so only + # platform-independent facts are asserted: no keyword reaches the .typ + # (and, via the default warning gate, Typst reports no unknown family). + ensureTypstFileRegexMatches: + - + - 'font: \("[^"]+"' + - 'codefont: \("[^"]+"' + - + - '"sans-serif"' + - '"monospace"' +--- + +Verifies that CSS generic font family names (sans-serif, monospace) are +resolved to a concrete available font rather than passed through to Typst. diff --git a/crates/quarto/tests/smoke-all/typst/brand-yaml/generic-font-families/_brand.yml b/crates/quarto/tests/smoke-all/typst/brand-yaml/generic-font-families/_brand.yml new file mode 100644 index 000000000..b72096f9f --- /dev/null +++ b/crates/quarto/tests/smoke-all/typst/brand-yaml/generic-font-families/_brand.yml @@ -0,0 +1,7 @@ +typography: + base: + family: serif + headings: + family: sans-serif + monospace: + family: monospace diff --git a/crates/quarto/tests/smoke-all/typst/brand-yaml/generic-font-families/generic-font-families.qmd b/crates/quarto/tests/smoke-all/typst/brand-yaml/generic-font-families/generic-font-families.qmd new file mode 100644 index 000000000..24deae133 --- /dev/null +++ b/crates/quarto/tests/smoke-all/typst/brand-yaml/generic-font-families/generic-font-families.qmd @@ -0,0 +1,29 @@ +--- +title: "Generic Font Families" +brand: _brand.yml +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + # Q1 PR #11918 asserts fixed fonts (Noto Sans, DejaVu Sans Mono). Q2 + # picks the first available candidate per machine, so assert only that + # no generic keyword is emitted. + ensureTypstFileRegexMatches: + - + - 'font: \("[^"]+"' + - 'codefont: \("[^"]+"' + - + - '"serif"' + - '"sans-serif"' + - '"monospace"' +--- + +```{=typst} +#set text(fallback: false) +``` + +# Heading + +Body text with `inline code`. diff --git a/crates/quarto/tests/smoke-all/typst/brand-yaml/system-ui-table/system-ui-table.qmd b/crates/quarto/tests/smoke-all/typst/brand-yaml/system-ui-table/system-ui-table.qmd new file mode 100644 index 000000000..43ea98c6c --- /dev/null +++ b/crates/quarto/tests/smoke-all/typst/brand-yaml/system-ui-table/system-ui-table.qmd @@ -0,0 +1,25 @@ +--- +title: "system-ui in table styling (quarto-cli #11683)" +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + # system-ui and the other stack entries aren't Typst families; the + # keywords must be resolved or dropped, never emitted. + ensureTypstFileRegexMatches: + - + - '#set text\(font: \("[^"]+"' + - + - '"system-ui"' + - '"sans-serif"' +--- + +::: {style='font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji"'} + +| Col | Num | +|-----|-----| +| abc | 123 | + +::: diff --git a/resources/pandoc-filters/README.md b/resources/pandoc-filters/README.md index 8d2a699bb..7eada7667 100644 --- a/resources/pandoc-filters/README.md +++ b/resources/pandoc-filters/README.md @@ -202,6 +202,18 @@ The following files and modifications are *not* from `v1.11.3` and should be pre crashed indexing a string. Added `pandoc.utils.type(meta.brand) == 'string'` to the guard. Tests: the `typst/orange-book` smoke-all fixture (`crates/quarto/tests/smoke-all/typst/orange-book/`). +- `resources/pandoc-filters/filters/modules/typst_css.lua` — patched (ours, + bd-hkf3r8i1; no upstream PR — marked in-place by a `Q2 divergence` + comment). Typst has no CSS generic font families, so Q1's pass-through of + `serif`/`sans-serif`/`monospace`/... made every compile warn `unknown + font family`. `translate_font_family_list` now resolves each generic + keyword (plus `system-ui` and `ui-*`; `generic(...)` is dropped) to the + first *available* font from an ordered candidate list + (`_generic_candidates`, sources cited beside the data), via the pure + `resolve_font_families`. The keyword is never emitted. Tests: + `crates/quarto-core/tests/integration/typst_generic_font_families.rs` and + the `typst/brand-yaml` smoke-all fixtures `font-filtering-generics`, + `generic-font-families`, `system-ui-table`. ## License diff --git a/resources/pandoc-filters/filters/modules/typst_css.lua b/resources/pandoc-filters/filters/modules/typst_css.lua index 6660a7102..b501744b4 100644 --- a/resources/pandoc-filters/filters/modules/typst_css.lua +++ b/resources/pandoc-filters/filters/modules/typst_css.lua @@ -658,11 +658,87 @@ end local _available_fonts = nil local _fonts_initialized = false -local _generic_families = { - ["serif"] = true, ["sans-serif"] = true, ["monospace"] = true, - ["cursive"] = true, ["fantasy"] = true, ["math"] = true, + +-- Q2 divergence from the vendored Q1 module: Typst has no CSS generic +-- families, so emitting `sans-serif` / `monospace` makes every compile warn +-- `unknown font family`. Each generic keyword is instead replaced, at its +-- position in the user's list, by the first *available* font from an ordered +-- candidate list. The keyword itself is never emitted. No font is bundled. +-- +-- Sources (fetched 2026-10-03), merged in this order per keyword: +-- 1. Chromium per-OS defaults (chrome/app/resources/locale_settings_{win,mac}.grd) +-- 2. fontconfig conf.d/60-latin.conf (Linux) +-- 3. Modern Font Stacks (github.com/system-fonts/modern-font-stacks, CC0) +-- 4. Bootstrap 5 $font-family-sans-serif / $font-family-monospace +-- 5. Typst's built-in fonts, last, so serif/monospace/math always resolve. +-- Non-family tokens from those sources (sans-serif-condensed, casual, ...) +-- are omitted. sans-serif has no built-in target and may resolve to nothing. +local _generic_candidates = { + ["serif"] = { + "Times New Roman", "Times", + "Noto Serif", "DejaVu Serif", "Thorndale AMT", "Luxi Serif", + "Nimbus Roman No9 L", "Nimbus Roman", + "Charter", "Bitstream Charter", "Sitka Text", "Cambria", "Georgia", + "Libertinus Serif", + }, + ["sans-serif"] = { + "Arial", "Helvetica", + "Noto Sans", "DejaVu Sans", "Verdana", "Albany AMT", "Luxi Sans", + "Nimbus Sans L", "Nimbus Sans", "Lucida Sans Unicode", "Tahoma", + "Inter", "Roboto", "Helvetica Neue", "Arial Nova", "Segoe UI", + "Liberation Sans", "Ubuntu", "Calibri", + }, + ["monospace"] = { + "Courier New", "Menlo", + "Noto Sans Mono", "Inconsolata", "Andale Mono", "Cumberland AMT", + "Luxi Mono", "Nimbus Mono L", "Nimbus Mono", "Nimbus Mono PS", "Courier", + "Cascadia Code", "Source Code Pro", "Consolas", + "SFMono-Regular", "Monaco", "Liberation Mono", + "DejaVu Sans Mono", + }, + ["cursive"] = { + "Comic Sans MS", "Apple Chancery", + "ITC Zapf Chancery Std", "Zapfino", + "Segoe Print", "Bradley Hand", "Chilanka", + }, + ["fantasy"] = { + "Impact", "Papyrus", + "Copperplate Gothic Std", "Cooper Std", "Bauhaus Std", + }, + ["math"] = { + "Cambria Math", "STIX Two Math", + "New Computer Modern Math", + }, } +local function concat_lists(...) + local out = {} + for _, l in ipairs({...}) do + for _, v in ipairs(l) do out[#out + 1] = v end + end + return out +end + +-- CSS Fonts 4 `system-ui` / `ui-*`: platform UI faces first, then the plain +-- generic they specialise (ui-rounded has no plain fallback). +_generic_candidates["system-ui"] = concat_lists( + { "SF Pro Text", "SF Pro", "Segoe UI", "Cantarell", "Adwaita Sans", + "Noto Sans UI", "Ubuntu" }, + _generic_candidates["sans-serif"]) +_generic_candidates["ui-sans-serif"] = _generic_candidates["system-ui"] +_generic_candidates["ui-serif"] = concat_lists( + { "New York" }, _generic_candidates["serif"]) +_generic_candidates["ui-monospace"] = concat_lists( + { "SF Mono", "Cascadia Mono" }, _generic_candidates["monospace"]) +_generic_candidates["ui-rounded"] = { + "SF Pro Rounded", "Hiragino Maru Gothic ProN", "Arial Rounded MT Bold", + "Arial Rounded MT", "Quicksand", "Comfortaa", "Manjari", +} + +-- Typst's default text font; the last resort when a list resolves to nothing, +-- since Typst rejects an empty `font: ()`. +local _terminal_fallback = "Libertinus Serif" + local function init_available_fonts(list) _fonts_initialized = true if list == nil then @@ -681,25 +757,58 @@ local function ensure_available_fonts() init_available_fonts(param('typst-available-fonts')) end +-- Pure resolver: `families` is a list of unquoted CSS family names/keywords, +-- `available` a set of lower-cased available family names (nil = unknown). +-- Returns the list of concrete family names to hand to Typst. +local function resolve_font_families(families, available) + local resolved, unavailable, seen = {}, {}, {} + local function add(into, name) + local k = name:lower() + if not seen[k] then + seen[k] = true + into[#into + 1] = name + end + end + for _, name in ipairs(families) do + local key = name:lower() + local candidates = _generic_candidates[key] + if candidates then + -- Without availability data there is no way to choose; drop it. + for _, c in ipairs(available and candidates or {}) do + if available[c:lower()] then + add(resolved, c) + break + end + end + elseif key:find('^generic%(') then + -- script-specific generics (fangsong, kai, ...) have no Typst analogue + elseif not available or available[key] then + add(resolved, name) + else + add(unavailable, name) + end + end + if #resolved > 0 then return resolved end + if #unavailable > 0 then return unavailable end + return { _terminal_fallback } +end + local function translate_font_family_list(sl) if sl == nil then return '()' end ensure_available_fonts() - local all_strings = {} - local filtered = {} + local families = {} for s in sl:gmatch('([^,]+)') do s = s:gsub('^%s+', ''):gsub('%s+$', '') if s ~= '' then - local cleaned = dequote(s) - local quoted = quote(cleaned) - table.insert(all_strings, quoted) - if not _available_fonts or _available_fonts[cleaned:lower()] or _generic_families[cleaned:lower()] then - table.insert(filtered, quoted) - end + families[#families + 1] = (dequote(s)) end end - local result = #filtered > 0 and filtered or all_strings + local result = {} + for i, name in ipairs(resolve_font_families(families, _available_fonts)) do + result[i] = quote(name) + end local trailcomma = #result == 1 and ',' or '' return '(' .. table.concat(result, ', ') .. trailcomma .. ')' end @@ -837,6 +946,7 @@ return { translate_border_color = translate_border_color, translate_font_weight = translate_font_weight, translate_font_family_list = translate_font_family_list, + resolve_font_families = resolve_font_families, init_available_fonts = init_available_fonts, consume_width = consume_width, consume_style = consume_style,