From 95c568208bd967e57aa6c1f7fa6272ab08eb4262 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Sat, 3 Oct 2026 12:22:24 -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. Fixes #11683. Replaces #11918 and #14592. --- news/changelog-1.11.md | 1 + src/resources/filters/modules/typst_css.lua | 133 ++++++++++++++++-- .../font-filtering-generics.qmd | 18 +-- .../generic-font-families/_brand.yml | 7 + .../generic-font-families.qmd | 28 ++++ .../system-ui-table.qmd | 26 ++++ 6 files changed, 193 insertions(+), 20 deletions(-) create mode 100644 tests/docs/smoke-all/typst/brand-yaml/generic-font-families/_brand.yml create mode 100644 tests/docs/smoke-all/typst/brand-yaml/generic-font-families/generic-font-families.qmd create mode 100644 tests/docs/smoke-all/typst/css-property-processing/system-ui-table.qmd diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 9dc49c242da..413511e9289 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -36,6 +36,7 @@ All changes included in 1.11: ### `typst` +- ([#11683](https://github.com/quarto-dev/quarto-cli/issues/11683)): Fix `unknown font family` warnings, and wrong fonts, when a `font-family` list or brand typography uses a CSS generic family (`serif`, `sans-serif`, `monospace`, `cursive`, `fantasy`, `math`, `system-ui`, `ui-*`). Typst has no generic families, so each one is now replaced by the first installed font from a list of well-known candidates, and the keyword is no longer passed to Typst. No fonts are bundled. - ([#14847](https://github.com/quarto-dev/quarto-cli/pull/14847)): Fix `toc_title` auto-fallback in typst outline template that was ignoring the computed fallback value when `toc_title` is `none`. - ([#14947](https://github.com/quarto-dev/quarto-cli/issues/14947)): Fix Typst compilation failing with `invalid number suffix: px` when an image with alt text (`fig-alt`, `alt`, or an inline image caption) has its `width` or `height` in pixels. These images are now written by Pandoc's Typst writer like other images, which also fixes URL-encoded image paths and unsupported size units for them. diff --git a/src/resources/filters/modules/typst_css.lua b/src/resources/filters/modules/typst_css.lua index 6660a71022b..32e1334e8fb 100644 --- a/src/resources/filters/modules/typst_css.lua +++ b/src/resources/filters/modules/typst_css.lua @@ -658,11 +658,86 @@ 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, + +-- 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 +756,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 +945,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, diff --git a/tests/docs/smoke-all/typst/brand-yaml/font-filtering-generics/font-filtering-generics.qmd b/tests/docs/smoke-all/typst/brand-yaml/font-filtering-generics/font-filtering-generics.qmd index 89fb7ecb7fc..3743f876645 100644 --- a/tests/docs/smoke-all/typst/brand-yaml/font-filtering-generics/font-filtering-generics.qmd +++ b/tests/docs/smoke-all/typst/brand-yaml/font-filtering-generics/font-filtering-generics.qmd @@ -8,13 +8,15 @@ _quarto: typst: ensureTypstFileRegexMatches: - - # CSS generic families (sans-serif, monospace) preserved alongside real fonts - - 'font: \("Roboto", "sans-serif"\),' - - 'codefont: \("Inconsolata", "monospace"\),' - - [] + - 'font: \("[^"]+"' + - 'codefont: \("[^"]+"' + - + - '(font|codefont): \([^)]*"(sans-serif|monospace)"' --- -Verifies that CSS generic font family names (sans-serif, monospace, serif, -cursive, fantasy, math) are preserved during font availability filtering, -since they are valid fallback specifiers even though they don't appear in -`typst fonts` output. +Typst has no CSS generic font families, so a brand family such as +`Roboto, sans-serif` must not pass the keyword through (Typst would warn +`unknown font family`). Each generic is replaced by the first available font +from a candidate list. Which font wins differs per machine, so the test only +asserts that a concrete family is emitted and no keyword reaches the `.typ`; +the default warning gate covers the absence of the Typst warning. diff --git a/tests/docs/smoke-all/typst/brand-yaml/generic-font-families/_brand.yml b/tests/docs/smoke-all/typst/brand-yaml/generic-font-families/_brand.yml new file mode 100644 index 00000000000..b72096f9f78 --- /dev/null +++ b/tests/docs/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/tests/docs/smoke-all/typst/brand-yaml/generic-font-families/generic-font-families.qmd b/tests/docs/smoke-all/typst/brand-yaml/generic-font-families/generic-font-families.qmd new file mode 100644 index 00000000000..21b1c6bb7e6 --- /dev/null +++ b/tests/docs/smoke-all/typst/brand-yaml/generic-font-families/generic-font-families.qmd @@ -0,0 +1,28 @@ +--- +title: "Generic Font Families" +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'font: \("[^"]+"' + - 'codefont: \("[^"]+"' + - + - '(font|codefont): \([^)]*"(serif|sans-serif|monospace)"' +--- + +```{=typst} +#set text(fallback: false) +``` + +The brand sets `serif`, `sans-serif` and `monospace` as the base, heading and +code families. With `fallback: false` a missing family would show up as +missing glyphs, so each keyword must resolve to a concrete installed font. The +font picked differs per machine; only the absence of keywords is asserted. + +# Heading + +Body text with `inline code`. diff --git a/tests/docs/smoke-all/typst/css-property-processing/system-ui-table.qmd b/tests/docs/smoke-all/typst/css-property-processing/system-ui-table.qmd new file mode 100644 index 00000000000..82dafc2497b --- /dev/null +++ b/tests/docs/smoke-all/typst/css-property-processing/system-ui-table.qmd @@ -0,0 +1,26 @@ +--- +title: "system-ui in table styling" +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - '#set text\(font: \("[^"]+"' + - + - '#set text\(font: \([^)]*"(system-ui|sans-serif)"' +--- + +Regression test for [#11683](https://github.com/quarto-dev/quarto-cli/issues/11683): +a `system-ui` font stack, as emitted by `gt` tables, must resolve to concrete +families rather than reaching Typst as keywords. + +::: {style='font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji"'} + +| Col | Num | +|-----|-----| +| abc | 123 | + +:::