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,