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 | + +:::