From 2e0e918fc2e3faaf2a58fdd1a73941d3df0854ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:06:13 +0200 Subject: [PATCH 01/14] fix: add Typst rendering for proof, remark, and solution environments (#14290) The Proof renderer lacked a Typst-specific branch, causing remark and solution environments to fall through to the generic renderer which produces plain `#block[...]` output that Typst cannot cross-reference. Add a Typst branch that generates proper theorion `make-frame()` calls with labels, matching how theorem environments already work. Extract shared theorion setup logic into `common/theorems.lua` to avoid duplication between theorem.lua and proof.lua. Closes #14290 --- src/resources/filters/common/theorems.lua | 91 +++++++++++++ src/resources/filters/customnodes/proof.lua | 54 +++++++- src/resources/filters/customnodes/theorem.lua | 125 ++++-------------- .../2026/02/04/issue-13992-proof.qmd | 3 +- .../docs/smoke-all/2026/04/04/issue-14290.qmd | 43 ++++++ 5 files changed, 215 insertions(+), 101 deletions(-) create mode 100644 tests/docs/smoke-all/2026/04/04/issue-14290.qmd diff --git a/src/resources/filters/common/theorems.lua b/src/resources/filters/common/theorems.lua index a88eb355a50..0893a1e6fb1 100644 --- a/src/resources/filters/common/theorems.lua +++ b/src/resources/filters/common/theorems.lua @@ -1,2 +1,93 @@ -- theorems.lua -- Copyright (C) 2020-2022 Posit Software, PBC + +local typst_theorem_appearance_imported = false +local typst_theorem_like_frames = {} +local typst_simple_renderers = {} + +function theoremTypstAppearance() + local appearance = option("theorem-appearance", "simple") + if type(appearance) == "table" then + appearance = pandoc.utils.stringify(appearance) + end + return appearance or "simple" +end + +function ensureTheoremTypstAppearanceImports() + local appearance = theoremTypstAppearance() + if typst_theorem_appearance_imported then + return appearance + end + + typst_theorem_appearance_imported = true + if appearance == "fancy" then + quarto.doc.include_text("in-header", [[ +#import "@preview/theorion:0.4.1": make-frame, cosmos +#import cosmos.fancy: fancy-box, set-primary-border-color, set-primary-body-color, set-secondary-border-color, set-secondary-body-color, set-tertiary-border-color, set-tertiary-body-color, get-primary-border-color, get-primary-body-color, get-secondary-border-color, get-secondary-body-color, get-tertiary-border-color, get-tertiary-body-color +]]) + quarto.doc.include_text("before-body", [[ +#set-primary-border-color(brand-color.at("primary", default: green.darken(30%))) +#set-primary-body-color(brand-color.at("primary", default: green).lighten(90%)) +#set-secondary-border-color(brand-color.at("secondary", default: orange)) +#set-secondary-body-color(brand-color.at("secondary", default: orange).lighten(90%)) +#set-tertiary-border-color(brand-color.at("tertiary", default: blue.darken(30%))) +#set-tertiary-body-color(brand-color.at("tertiary", default: blue).lighten(90%)) +]]) + elseif appearance == "clouds" then + quarto.doc.include_text("in-header", [[ +#import "@preview/theorion:0.4.1": make-frame, cosmos +#import cosmos.clouds: render-fn as clouds-render +]]) + elseif appearance == "rainbow" then + quarto.doc.include_text("in-header", [[ +#import "@preview/theorion:0.4.1": make-frame, cosmos +#import cosmos.rainbow: render-fn as rainbow-render +]]) + else + quarto.doc.include_text("in-header", [[ +#import "@preview/theorion:0.4.1": make-frame +]]) + end + + return appearance +end + +function ensureTheoremTypstSimpleRender(render_name, italic_body) + if typst_simple_renderers[render_name] then + return + end + + typst_simple_renderers[render_name] = true + local body_render = "body" + if italic_body then + body_render = "emph(body)" + end + + quarto.doc.include_text("in-header", "#let " .. render_name .. [[(prefix: none, title: "", full-title: auto, body) = { + if full-title != "" and full-title != auto and full-title != none { + strong[#full-title.] + h(0.5em) + } + ]] .. body_render .. "\n" .. [[ + parbreak() +} +]]) +end + +function ensureTheoremTypstFrame(env_name, title, render_code) + if typst_theorem_like_frames[env_name] then + return false + end + + typst_theorem_like_frames[env_name] = true + quarto.doc.include_text("in-header", "#let (" .. env_name .. "-counter, " .. env_name .. "-box, " .. + env_name .. ", show-" .. env_name .. ") = make-frame(\n" .. + " \"" .. env_name .. "\",\n" .. + " text(weight: \"bold\")[" .. title .. "],\n" .. + " inherited-levels: theorem-inherited-levels,\n" .. + " numbering: theorem-numbering,\n" .. + render_code .. + ")") + quarto.doc.include_text("in-header", "#show: show-" .. env_name) + return true +end diff --git a/src/resources/filters/customnodes/proof.lua b/src/resources/filters/customnodes/proof.lua index 6d700c0664d..1d63ca834a5 100644 --- a/src/resources/filters/customnodes/proof.lua +++ b/src/resources/filters/customnodes/proof.lua @@ -61,6 +61,37 @@ _quarto.ast.add_handler({ end }) +-- Color mapping for clouds/rainbow themes (per proof type) +local proof_theme_colors = { + proof = "gray", remark = "orange", solution = "teal" +} + +local function ensure_typst_proofs(proof_env) + local appearance = ensureTheoremTypstAppearanceImports() + local proof_info = proof_types[proof_env] + local title = envTitle(proof_env, proof_info.title) + local render_code + + if appearance == "fancy" then + render_code = " render: fancy-box.with(\n" .. + " get-border-color: get-tertiary-border-color,\n" .. + " get-body-color: get-tertiary-body-color,\n" .. + " get-symbol: loc => none,\n" .. + " ),\n" + elseif appearance == "clouds" then + local color = proof_theme_colors[proof_env] or "gray" + render_code = " render: clouds-render.with(fill: " .. color .. ".lighten(85%)),\n" + elseif appearance == "rainbow" then + local color = proof_theme_colors[proof_env] or "gray" + render_code = " render: rainbow-render.with(fill: " .. color .. ".darken(20%)),\n" + else + ensureTheoremTypstSimpleRender("simple-proof-render", false) + render_code = " render: simple-proof-render,\n" + end + + ensureTheoremTypstFrame(proof_env, title, render_code) +end + function is_proof_div(div) local ref = refType(div.identifier) if ref ~= nil then @@ -141,6 +172,27 @@ end, function(proof_tbl) end elseif _quarto.format.isJatsOutput() then el = jatsTheorem(el, nil, name ) + elseif _quarto.format.isTypstOutput() then + if #el.content == 0 then + warn("Proof block has no content; skipping") + return pandoc.Null() + end + ensure_typst_proofs(proof.env) + local preamble = pandoc.Plain({pandoc.RawInline("typst", "#" .. proof.env .. "(")}) + if name and #name > 0 then + preamble.content:insert(pandoc.RawInline("typst", 'title: [')) + tappend(preamble.content, name) + preamble.content:insert(pandoc.RawInline("typst", ']')) + end + preamble.content:insert(pandoc.RawInline("typst", ")[")) + local callproof = make_scaffold(pandoc.Div, preamble) + tappend(callproof.content, quarto.utils.as_blocks(el.content)) + if proof_tbl.identifier and proof_tbl.identifier ~= "" then + callproof.content:insert(pandoc.RawInline("typst", "] <" .. proof_tbl.identifier .. ">")) + else + callproof.content:insert(pandoc.RawInline("typst", "]")) + end + return callproof else el.classes:insert(proof.title:lower()) local span_title = pandoc.Emph(pandoc.Str(envTitle(proof.env, proof.title))) @@ -176,4 +228,4 @@ end, function(proof_tbl) return el -end) \ No newline at end of file +end) diff --git a/src/resources/filters/customnodes/theorem.lua b/src/resources/filters/customnodes/theorem.lua index 29c222aae51..8938faa032f 100644 --- a/src/resources/filters/customnodes/theorem.lua +++ b/src/resources/filters/customnodes/theorem.lua @@ -93,115 +93,42 @@ _quarto.ast.add_handler({ end }) --- Get theorem-appearance option (simple, fancy, clouds, rainbow) -local function get_theorem_appearance() - local appearance = option("theorem-appearance", "simple") - if appearance ~= nil and type(appearance) == "table" then - appearance = pandoc.utils.stringify(appearance) - end - return appearance or "simple" -end - -- Color mapping for clouds/rainbow themes (per theorem type) local theme_colors = { thm = "red", lem = "teal", cor = "navy", prp = "blue", cnj = "navy", def = "olive", exm = "green", exr = "purple", alg = "maroon" } -local included_typst_theorems = false -local letted_typst_theorem = {} local function ensure_typst_theorems(reftype) - local appearance = get_theorem_appearance() - - if not included_typst_theorems then - included_typst_theorems = true - - if appearance == "fancy" then - -- Import theorion's make-frame and fancy-box theming - quarto.doc.include_text("in-header", [[ -#import "@preview/theorion:0.4.1": make-frame, cosmos -#import cosmos.fancy: fancy-box, set-primary-border-color, set-primary-body-color, set-secondary-border-color, set-secondary-body-color, set-tertiary-border-color, set-tertiary-body-color, get-primary-border-color, get-primary-body-color, get-secondary-border-color, get-secondary-body-color, get-tertiary-border-color, get-tertiary-body-color -]]) - -- Set theorem colors from brand-color (runs in before-body, after brand-color is defined) - quarto.doc.include_text("before-body", [[ -#set-primary-border-color(brand-color.at("primary", default: green.darken(30%))) -#set-primary-body-color(brand-color.at("primary", default: green).lighten(90%)) -#set-secondary-border-color(brand-color.at("secondary", default: orange)) -#set-secondary-body-color(brand-color.at("secondary", default: orange).lighten(90%)) -#set-tertiary-border-color(brand-color.at("tertiary", default: blue.darken(30%))) -#set-tertiary-body-color(brand-color.at("tertiary", default: blue).lighten(90%)) -]]) - elseif appearance == "clouds" then - -- Import theorion's make-frame and clouds render function - quarto.doc.include_text("in-header", [[ -#import "@preview/theorion:0.4.1": make-frame, cosmos -#import cosmos.clouds: render-fn as clouds-render -]]) - elseif appearance == "rainbow" then - -- Import theorion's make-frame and rainbow render function - quarto.doc.include_text("in-header", [[ -#import "@preview/theorion:0.4.1": make-frame, cosmos -#import cosmos.rainbow: render-fn as rainbow-render -]]) - else -- simple (default) - -- Import only make-frame and define simple render function - quarto.doc.include_text("in-header", [[ -#import "@preview/theorion:0.4.1": make-frame - -// Simple theorem render: bold title with period, italic body -#let simple-theorem-render(prefix: none, title: "", full-title: auto, body) = { - if full-title != "" and full-title != auto and full-title != none { - strong[#full-title.] - h(0.5em) - } - emph(body) - parbreak() -} -]]) + local appearance = ensureTheoremTypstAppearanceImports() + local theorem_type = theorem_types[reftype] + local title = titleString(reftype, theorem_type.title) + local render_code + + if appearance == "fancy" then + local color_scheme = "secondary" + if theorem_type.style == "definition" then + color_scheme = "primary" + elseif reftype == "prp" then + color_scheme = "tertiary" end + render_code = " render: fancy-box.with(\n" .. + " get-border-color: get-" .. color_scheme .. "-border-color,\n" .. + " get-body-color: get-" .. color_scheme .. "-body-color,\n" .. + " get-symbol: loc => none,\n" .. + " ),\n" + elseif appearance == "clouds" then + local color = theme_colors[reftype] or "gray" + render_code = " render: clouds-render.with(fill: " .. color .. ".lighten(85%)),\n" + elseif appearance == "rainbow" then + local color = theme_colors[reftype] or "gray" + render_code = " render: rainbow-render.with(fill: " .. color .. ".darken(20%)),\n" + else + ensureTheoremTypstSimpleRender("simple-theorem-render", true) + render_code = " render: simple-theorem-render,\n" end - if not letted_typst_theorem[reftype] then - letted_typst_theorem[reftype] = true - local theorem_type = theorem_types[reftype] - local title = titleString(reftype, theorem_type.title) - - -- Build render code based on appearance - local render_code - if appearance == "fancy" then - -- Map theorem styles to color schemes (primary=definitions, secondary=theorems, tertiary=propositions) - local color_scheme = "secondary" -- default for most theorem types - if theorem_type.style == "definition" then - color_scheme = "primary" - elseif reftype == "prp" then - color_scheme = "tertiary" - end - render_code = " render: fancy-box.with(\n" .. - " get-border-color: get-" .. color_scheme .. "-border-color,\n" .. - " get-body-color: get-" .. color_scheme .. "-body-color,\n" .. - " get-symbol: loc => none,\n" .. - " ),\n" - elseif appearance == "clouds" then - local color = theme_colors[reftype] or "gray" - render_code = " render: clouds-render.with(fill: " .. color .. ".lighten(85%)),\n" - elseif appearance == "rainbow" then - local color = theme_colors[reftype] or "gray" - render_code = " render: rainbow-render.with(fill: " .. color .. ".darken(20%)),\n" - else -- simple - render_code = " render: simple-theorem-render,\n" - end - - -- Use theorion's make-frame with appropriate render - quarto.doc.include_text("in-header", "#let (" .. theorem_type.env .. "-counter, " .. theorem_type.env .. "-box, " .. - theorem_type.env .. ", show-" .. theorem_type.env .. ") = make-frame(\n" .. - " \"" .. theorem_type.env .. "\",\n" .. - " text(weight: \"bold\")[" .. title .. "],\n" .. - " inherited-levels: theorem-inherited-levels,\n" .. - " numbering: theorem-numbering,\n" .. - render_code .. - ")") - quarto.doc.include_text("in-header", "#show: show-" .. theorem_type.env) - end + ensureTheoremTypstFrame(theorem_type.env, title, render_code) end diff --git a/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd b/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd index 00db55838c3..54c89783a51 100644 --- a/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd +++ b/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd @@ -13,8 +13,9 @@ _quarto: - [] - ['Proof content visible'] typst: + noErrors: default ensureTypstFileRegexMatches: - - ['#emph\[Proof\]\\?\. Proof content visible'] + - ['#proof\('] - [] --- diff --git a/tests/docs/smoke-all/2026/04/04/issue-14290.qmd b/tests/docs/smoke-all/2026/04/04/issue-14290.qmd new file mode 100644 index 00000000000..cfe8f63ae4b --- /dev/null +++ b/tests/docs/smoke-all/2026/04/04/issue-14290.qmd @@ -0,0 +1,43 @@ +--- +title: "Cross-reference remark and solution in Typst (#14290)" +keep-typ: true +_quarto: + tests: + typst: + noErrors: default + ensureTypstFileRegexMatches: + - + - '#remark\(' + - '#solution\(' + - '' + - '' + - '#show: show-remark' + - '#show: show-solution' + - '#ref\(' + - '#ref\(' + - [] +--- + +::: {#rem-example} + +## A Remark + +This is a remark that should be cross-referenceable. + +::: + +::: {#sol-exercise} + +## A Solution + +This is a solution that should be cross-referenceable. + +::: + +::: {.proof} + +This is an unnumbered proof. + +::: + +See @rem-example and @sol-exercise. From c95dc076c4f7c7799803ec4b9a25ebac85ce9d27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:39:58 +0200 Subject: [PATCH 02/14] refactor: move theorems.lua to a require() module to avoid Lua local variable limit The import()-based common/theorems.lua added locals to the main chunk scope, pushing past Lua's 200-variable limit and breaking CI. Move the logic to modules/theorems.lua loaded via require(), which runs in its own scope. --- src/resources/filters/common/theorems.lua | 91 ---------------- src/resources/filters/customnodes/proof.lua | 6 +- src/resources/filters/customnodes/theorem.lua | 6 +- src/resources/filters/modules/import_all.lua | 1 + src/resources/filters/modules/theorems.lua | 101 ++++++++++++++++++ 5 files changed, 108 insertions(+), 97 deletions(-) create mode 100644 src/resources/filters/modules/theorems.lua diff --git a/src/resources/filters/common/theorems.lua b/src/resources/filters/common/theorems.lua index 0893a1e6fb1..a88eb355a50 100644 --- a/src/resources/filters/common/theorems.lua +++ b/src/resources/filters/common/theorems.lua @@ -1,93 +1,2 @@ -- theorems.lua -- Copyright (C) 2020-2022 Posit Software, PBC - -local typst_theorem_appearance_imported = false -local typst_theorem_like_frames = {} -local typst_simple_renderers = {} - -function theoremTypstAppearance() - local appearance = option("theorem-appearance", "simple") - if type(appearance) == "table" then - appearance = pandoc.utils.stringify(appearance) - end - return appearance or "simple" -end - -function ensureTheoremTypstAppearanceImports() - local appearance = theoremTypstAppearance() - if typst_theorem_appearance_imported then - return appearance - end - - typst_theorem_appearance_imported = true - if appearance == "fancy" then - quarto.doc.include_text("in-header", [[ -#import "@preview/theorion:0.4.1": make-frame, cosmos -#import cosmos.fancy: fancy-box, set-primary-border-color, set-primary-body-color, set-secondary-border-color, set-secondary-body-color, set-tertiary-border-color, set-tertiary-body-color, get-primary-border-color, get-primary-body-color, get-secondary-border-color, get-secondary-body-color, get-tertiary-border-color, get-tertiary-body-color -]]) - quarto.doc.include_text("before-body", [[ -#set-primary-border-color(brand-color.at("primary", default: green.darken(30%))) -#set-primary-body-color(brand-color.at("primary", default: green).lighten(90%)) -#set-secondary-border-color(brand-color.at("secondary", default: orange)) -#set-secondary-body-color(brand-color.at("secondary", default: orange).lighten(90%)) -#set-tertiary-border-color(brand-color.at("tertiary", default: blue.darken(30%))) -#set-tertiary-body-color(brand-color.at("tertiary", default: blue).lighten(90%)) -]]) - elseif appearance == "clouds" then - quarto.doc.include_text("in-header", [[ -#import "@preview/theorion:0.4.1": make-frame, cosmos -#import cosmos.clouds: render-fn as clouds-render -]]) - elseif appearance == "rainbow" then - quarto.doc.include_text("in-header", [[ -#import "@preview/theorion:0.4.1": make-frame, cosmos -#import cosmos.rainbow: render-fn as rainbow-render -]]) - else - quarto.doc.include_text("in-header", [[ -#import "@preview/theorion:0.4.1": make-frame -]]) - end - - return appearance -end - -function ensureTheoremTypstSimpleRender(render_name, italic_body) - if typst_simple_renderers[render_name] then - return - end - - typst_simple_renderers[render_name] = true - local body_render = "body" - if italic_body then - body_render = "emph(body)" - end - - quarto.doc.include_text("in-header", "#let " .. render_name .. [[(prefix: none, title: "", full-title: auto, body) = { - if full-title != "" and full-title != auto and full-title != none { - strong[#full-title.] - h(0.5em) - } - ]] .. body_render .. "\n" .. [[ - parbreak() -} -]]) -end - -function ensureTheoremTypstFrame(env_name, title, render_code) - if typst_theorem_like_frames[env_name] then - return false - end - - typst_theorem_like_frames[env_name] = true - quarto.doc.include_text("in-header", "#let (" .. env_name .. "-counter, " .. env_name .. "-box, " .. - env_name .. ", show-" .. env_name .. ") = make-frame(\n" .. - " \"" .. env_name .. "\",\n" .. - " text(weight: \"bold\")[" .. title .. "],\n" .. - " inherited-levels: theorem-inherited-levels,\n" .. - " numbering: theorem-numbering,\n" .. - render_code .. - ")") - quarto.doc.include_text("in-header", "#show: show-" .. env_name) - return true -end diff --git a/src/resources/filters/customnodes/proof.lua b/src/resources/filters/customnodes/proof.lua index 1d63ca834a5..76439316dd0 100644 --- a/src/resources/filters/customnodes/proof.lua +++ b/src/resources/filters/customnodes/proof.lua @@ -67,7 +67,7 @@ local proof_theme_colors = { } local function ensure_typst_proofs(proof_env) - local appearance = ensureTheoremTypstAppearanceImports() + local appearance = _quarto.modules.theorems.ensure_appearance_imports() local proof_info = proof_types[proof_env] local title = envTitle(proof_env, proof_info.title) local render_code @@ -85,11 +85,11 @@ local function ensure_typst_proofs(proof_env) local color = proof_theme_colors[proof_env] or "gray" render_code = " render: rainbow-render.with(fill: " .. color .. ".darken(20%)),\n" else - ensureTheoremTypstSimpleRender("simple-proof-render", false) + _quarto.modules.theorems.ensure_simple_render("simple-proof-render", false) render_code = " render: simple-proof-render,\n" end - ensureTheoremTypstFrame(proof_env, title, render_code) + _quarto.modules.theorems.ensure_frame(proof_env, title, render_code) end function is_proof_div(div) diff --git a/src/resources/filters/customnodes/theorem.lua b/src/resources/filters/customnodes/theorem.lua index 8938faa032f..79745d65c3d 100644 --- a/src/resources/filters/customnodes/theorem.lua +++ b/src/resources/filters/customnodes/theorem.lua @@ -100,7 +100,7 @@ local theme_colors = { } local function ensure_typst_theorems(reftype) - local appearance = ensureTheoremTypstAppearanceImports() + local appearance = _quarto.modules.theorems.ensure_appearance_imports() local theorem_type = theorem_types[reftype] local title = titleString(reftype, theorem_type.title) local render_code @@ -124,11 +124,11 @@ local function ensure_typst_theorems(reftype) local color = theme_colors[reftype] or "gray" render_code = " render: rainbow-render.with(fill: " .. color .. ".darken(20%)),\n" else - ensureTheoremTypstSimpleRender("simple-theorem-render", true) + _quarto.modules.theorems.ensure_simple_render("simple-theorem-render", true) render_code = " render: simple-theorem-render,\n" end - ensureTheoremTypstFrame(theorem_type.env, title, render_code) + _quarto.modules.theorems.ensure_frame(theorem_type.env, title, render_code) end diff --git a/src/resources/filters/modules/import_all.lua b/src/resources/filters/modules/import_all.lua index b642911129f..78512ac6a15 100644 --- a/src/resources/filters/modules/import_all.lua +++ b/src/resources/filters/modules/import_all.lua @@ -21,6 +21,7 @@ _quarto.modules = { scope = require("modules/scope"), string = require("modules/string"), tablecolwidths = require("modules/tablecolwidths"), + theorems = require("modules/theorems"), typst = require("modules/typst"), listtable = require("modules/listtable"), tableutils = require("modules/tableutils"), diff --git a/src/resources/filters/modules/theorems.lua b/src/resources/filters/modules/theorems.lua new file mode 100644 index 00000000000..437dd71fa8e --- /dev/null +++ b/src/resources/filters/modules/theorems.lua @@ -0,0 +1,101 @@ +-- theorems.lua +-- Shared Typst theorem/proof setup logic using theorion +-- Copyright (C) 2020-2022 Posit Software, PBC + +local typst_theorem_appearance_imported = false +local typst_theorem_like_frames = {} +local typst_simple_renderers = {} + +local function appearance() + local val = option("theorem-appearance", "simple") + if type(val) == "table" then + val = pandoc.utils.stringify(val) + end + return val or "simple" +end + +local function ensure_appearance_imports() + local val = appearance() + if typst_theorem_appearance_imported then + return val + end + + typst_theorem_appearance_imported = true + if val == "fancy" then + quarto.doc.include_text("in-header", [[ +#import "@preview/theorion:0.4.1": make-frame, cosmos +#import cosmos.fancy: fancy-box, set-primary-border-color, set-primary-body-color, set-secondary-border-color, set-secondary-body-color, set-tertiary-border-color, set-tertiary-body-color, get-primary-border-color, get-primary-body-color, get-secondary-border-color, get-secondary-body-color, get-tertiary-border-color, get-tertiary-body-color +]]) + quarto.doc.include_text("before-body", [[ +#set-primary-border-color(brand-color.at("primary", default: green.darken(30%))) +#set-primary-body-color(brand-color.at("primary", default: green).lighten(90%)) +#set-secondary-border-color(brand-color.at("secondary", default: orange)) +#set-secondary-body-color(brand-color.at("secondary", default: orange).lighten(90%)) +#set-tertiary-border-color(brand-color.at("tertiary", default: blue.darken(30%))) +#set-tertiary-body-color(brand-color.at("tertiary", default: blue).lighten(90%)) +]]) + elseif val == "clouds" then + quarto.doc.include_text("in-header", [[ +#import "@preview/theorion:0.4.1": make-frame, cosmos +#import cosmos.clouds: render-fn as clouds-render +]]) + elseif val == "rainbow" then + quarto.doc.include_text("in-header", [[ +#import "@preview/theorion:0.4.1": make-frame, cosmos +#import cosmos.rainbow: render-fn as rainbow-render +]]) + else + quarto.doc.include_text("in-header", [[ +#import "@preview/theorion:0.4.1": make-frame +]]) + end + + return val +end + +local function ensure_simple_render(render_name, italic_body) + if typst_simple_renderers[render_name] then + return + end + + typst_simple_renderers[render_name] = true + local body_render = "body" + if italic_body then + body_render = "emph(body)" + end + + quarto.doc.include_text("in-header", "#let " .. render_name .. [[(prefix: none, title: "", full-title: auto, body) = { + if full-title != "" and full-title != auto and full-title != none { + strong[#full-title.] + h(0.5em) + } + ]] .. body_render .. "\n" .. [[ + parbreak() +} +]]) +end + +local function ensure_frame(env_name, title, render_code) + if typst_theorem_like_frames[env_name] then + return false + end + + typst_theorem_like_frames[env_name] = true + quarto.doc.include_text("in-header", "#let (" .. env_name .. "-counter, " .. env_name .. "-box, " .. + env_name .. ", show-" .. env_name .. ") = make-frame(\n" .. + " \"" .. env_name .. "\",\n" .. + " text(weight: \"bold\")[" .. title .. "],\n" .. + " inherited-levels: theorem-inherited-levels,\n" .. + " numbering: theorem-numbering,\n" .. + render_code .. + ")") + quarto.doc.include_text("in-header", "#show: show-" .. env_name) + return true +end + +return { + appearance = appearance, + ensure_appearance_imports = ensure_appearance_imports, + ensure_simple_render = ensure_simple_render, + ensure_frame = ensure_frame, +} From d07fb276fa8c8b98aaa87763a180089cc58a7350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:44:35 +0200 Subject: [PATCH 03/14] test: check unnumbered proofs and titles with Typst markup --- .../2026/02/04/issue-13992-proof.qmd | 1 - .../2026/04/04/issue-14290-title-markup.qmd | 32 +++++++++++++ .../docs/smoke-all/2026/04/04/issue-14290.qmd | 46 ++++++++++++++++++- 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 tests/docs/smoke-all/2026/04/04/issue-14290-title-markup.qmd diff --git a/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd b/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd index 54c89783a51..d33c8ec6e22 100644 --- a/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd +++ b/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd @@ -13,7 +13,6 @@ _quarto: - [] - ['Proof content visible'] typst: - noErrors: default ensureTypstFileRegexMatches: - ['#proof\('] - [] diff --git a/tests/docs/smoke-all/2026/04/04/issue-14290-title-markup.qmd b/tests/docs/smoke-all/2026/04/04/issue-14290-title-markup.qmd new file mode 100644 index 00000000000..62202e6b964 --- /dev/null +++ b/tests/docs/smoke-all/2026/04/04/issue-14290-title-markup.qmd @@ -0,0 +1,32 @@ +--- +title: "Theorem and proof titles that hold Typst markup" +format: typst +language: + crossref-thm-title: "Thm #y [*x*]" + environment-proof-title: "Pf #y [*x*] \"q\"" +_quarto: + tests: + typst: + ensurePdfRegexMatches: + - + - 'Thm #y \[\*x\*\] 1\. A theorem' + - 'Pf #y \[\*x\*\] "q"\. An unnumbered proof' + - [] +--- + +Theorem and proof titles come from the language files, so an author can put +any text in them. The titles are written into the Typst frame definitions and +the unnumbered proof prefix, and must reach the page as written instead of +being read as Typst markup. + +::: {#thm-a} + +A theorem. + +::: + +::: {.proof} + +An unnumbered proof. + +::: diff --git a/tests/docs/smoke-all/2026/04/04/issue-14290.qmd b/tests/docs/smoke-all/2026/04/04/issue-14290.qmd index cfe8f63ae4b..c7af284738c 100644 --- a/tests/docs/smoke-all/2026/04/04/issue-14290.qmd +++ b/tests/docs/smoke-all/2026/04/04/issue-14290.qmd @@ -4,7 +4,6 @@ keep-typ: true _quarto: tests: typst: - noErrors: default ensureTypstFileRegexMatches: - - '#remark\(' @@ -15,9 +14,38 @@ _quarto: - '#show: show-solution' - '#ref\(' - '#ref\(' + # unnumbered blocks pin their number and keep the counter still + - '#remark\(number: "", get-prefix: ' + - '#proof\(number: "", get-prefix: ' + # a numbered proof keeps theorion's own number + - '#proof\(\)\[(\r\n?|\n)Numbered proof\.' - [] + ensurePdfRegexMatches: + - + - 'Remark\. An unnumbered remark' + - 'Remark 1 \(A Remark\)' + - 'Solution 1 \(A Solution\)' + - 'Proof\. This is an unnumbered proof' + - 'Proof 1\. Numbered proof' + - 'Proof \(With code\)\. A named proof' + - 'See Remark 1, Solution 1 and Proof 1' + - + - 'Remark 2' + - 'Proof 2' + - 'Proof \.' --- +An unnumbered `proof`, `remark` or `solution` block uses the same theorion +frame, and so the same counter, as the numbered block of that kind. It must +show no number and must not move the counter, so that `rem-example` is still +Remark 1 although an unnumbered remark comes before it. + +::: {.remark} + +An unnumbered remark. + +::: + ::: {#rem-example} ## A Remark @@ -40,4 +68,18 @@ This is an unnumbered proof. ::: -See @rem-example and @sol-exercise. +::: {#prf-a} + +Numbered proof. + +::: + +::: {.proof} + +## With `code` + +A named proof. + +::: + +See @rem-example, @sol-exercise and @prf-a. From 0ce9f5bb90e09d56195d6bc67b2e870bdc9e5a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:44:36 +0200 Subject: [PATCH 04/14] fix(typst): leave unnumbered proofs out of the frame counter and escape frame titles --- src/resources/filters/customnodes/proof.lua | 43 +++++++++++-------- src/resources/filters/customnodes/theorem.lua | 34 +++++---------- src/resources/filters/modules/theorems.lua | 32 +++++++++++++- 3 files changed, 66 insertions(+), 43 deletions(-) diff --git a/src/resources/filters/customnodes/proof.lua b/src/resources/filters/customnodes/proof.lua index 76439316dd0..0753c62253b 100644 --- a/src/resources/filters/customnodes/proof.lua +++ b/src/resources/filters/customnodes/proof.lua @@ -66,28 +66,23 @@ local proof_theme_colors = { proof = "gray", remark = "orange", solution = "teal" } +-- A proof is numbered only when its identifier is a crossref id (`prf-`, +-- `rem-` or `sol-`). +local function is_numbered_proof(identifier) + local ref = identifier and refType(identifier) + return ref ~= nil and crossref.categories.by_ref_type[ref] ~= nil +end + local function ensure_typst_proofs(proof_env) local appearance = _quarto.modules.theorems.ensure_appearance_imports() local proof_info = proof_types[proof_env] local title = envTitle(proof_env, proof_info.title) - local render_code - - if appearance == "fancy" then - render_code = " render: fancy-box.with(\n" .. - " get-border-color: get-tertiary-border-color,\n" .. - " get-body-color: get-tertiary-body-color,\n" .. - " get-symbol: loc => none,\n" .. - " ),\n" - elseif appearance == "clouds" then - local color = proof_theme_colors[proof_env] or "gray" - render_code = " render: clouds-render.with(fill: " .. color .. ".lighten(85%)),\n" - elseif appearance == "rainbow" then - local color = proof_theme_colors[proof_env] or "gray" - render_code = " render: rainbow-render.with(fill: " .. color .. ".darken(20%)),\n" - else - _quarto.modules.theorems.ensure_simple_render("simple-proof-render", false) - render_code = " render: simple-proof-render,\n" - end + local render_code = _quarto.modules.theorems.render_code(appearance, { + color_scheme = "tertiary", + color = proof_theme_colors[proof_env] or "gray", + simple_render = "simple-proof-render", + italic_body = false, + }) _quarto.modules.theorems.ensure_frame(proof_env, title, render_code) end @@ -179,7 +174,19 @@ end, function(proof_tbl) end ensure_typst_proofs(proof.env) local preamble = pandoc.Plain({pandoc.RawInline("typst", "#" .. proof.env .. "(")}) + local numbered = is_numbered_proof(proof_tbl.identifier) + if not numbered then + -- theorion steps the counter and prints the number only for + -- `number: auto`, and its prefix would keep a space before the empty + -- number, so an unnumbered block pins both + preamble.content:insert(pandoc.RawInline("typst", + 'number: "", get-prefix: (..args) => ' .. + _quarto.modules.theorems.typst_string(envTitle(proof.env, proof.title)))) + end if name and #name > 0 then + if not numbered then + preamble.content:insert(pandoc.RawInline("typst", ", ")) + end preamble.content:insert(pandoc.RawInline("typst", 'title: [')) tappend(preamble.content, name) preamble.content:insert(pandoc.RawInline("typst", ']')) diff --git a/src/resources/filters/customnodes/theorem.lua b/src/resources/filters/customnodes/theorem.lua index 79745d65c3d..faab13d7d96 100644 --- a/src/resources/filters/customnodes/theorem.lua +++ b/src/resources/filters/customnodes/theorem.lua @@ -103,30 +103,18 @@ local function ensure_typst_theorems(reftype) local appearance = _quarto.modules.theorems.ensure_appearance_imports() local theorem_type = theorem_types[reftype] local title = titleString(reftype, theorem_type.title) - local render_code - - if appearance == "fancy" then - local color_scheme = "secondary" - if theorem_type.style == "definition" then - color_scheme = "primary" - elseif reftype == "prp" then - color_scheme = "tertiary" - end - render_code = " render: fancy-box.with(\n" .. - " get-border-color: get-" .. color_scheme .. "-border-color,\n" .. - " get-body-color: get-" .. color_scheme .. "-body-color,\n" .. - " get-symbol: loc => none,\n" .. - " ),\n" - elseif appearance == "clouds" then - local color = theme_colors[reftype] or "gray" - render_code = " render: clouds-render.with(fill: " .. color .. ".lighten(85%)),\n" - elseif appearance == "rainbow" then - local color = theme_colors[reftype] or "gray" - render_code = " render: rainbow-render.with(fill: " .. color .. ".darken(20%)),\n" - else - _quarto.modules.theorems.ensure_simple_render("simple-theorem-render", true) - render_code = " render: simple-theorem-render,\n" + local color_scheme = "secondary" + if theorem_type.style == "definition" then + color_scheme = "primary" + elseif reftype == "prp" then + color_scheme = "tertiary" end + local render_code = _quarto.modules.theorems.render_code(appearance, { + color_scheme = color_scheme, + color = theme_colors[reftype] or "gray", + simple_render = "simple-theorem-render", + italic_body = true, + }) _quarto.modules.theorems.ensure_frame(theorem_type.env, title, render_code) end diff --git a/src/resources/filters/modules/theorems.lua b/src/resources/filters/modules/theorems.lua index 437dd71fa8e..4923f6a3326 100644 --- a/src/resources/filters/modules/theorems.lua +++ b/src/resources/filters/modules/theorems.lua @@ -1,6 +1,6 @@ -- theorems.lua -- Shared Typst theorem/proof setup logic using theorion --- Copyright (C) 2020-2022 Posit Software, PBC +-- Copyright (C) 2026 Posit Software, PBC local typst_theorem_appearance_imported = false local typst_theorem_like_frames = {} @@ -75,6 +75,32 @@ local function ensure_simple_render(render_name, italic_body) ]]) end +-- Writes text as a Typst string literal, so that titles from the language +-- files reach the page as written instead of being read as Typst markup. +local function typst_string(text) + return '"' .. text:gsub("\\", "\\\\"):gsub('"', '\\"') .. '"' +end + +-- Returns the `render:` argument of make-frame for the theorem appearance. +-- opts.color_scheme: fancy color set (primary, secondary or tertiary) +-- opts.color: clouds and rainbow fill color +-- opts.simple_render, opts.italic_body: name and body style of the simple renderer +local function render_code(appearance, opts) + if appearance == "fancy" then + return " render: fancy-box.with(\n" .. + " get-border-color: get-" .. opts.color_scheme .. "-border-color,\n" .. + " get-body-color: get-" .. opts.color_scheme .. "-body-color,\n" .. + " get-symbol: loc => none,\n" .. + " ),\n" + elseif appearance == "clouds" then + return " render: clouds-render.with(fill: " .. opts.color .. ".lighten(85%)),\n" + elseif appearance == "rainbow" then + return " render: rainbow-render.with(fill: " .. opts.color .. ".darken(20%)),\n" + end + ensure_simple_render(opts.simple_render, opts.italic_body) + return " render: " .. opts.simple_render .. ",\n" +end + local function ensure_frame(env_name, title, render_code) if typst_theorem_like_frames[env_name] then return false @@ -84,7 +110,7 @@ local function ensure_frame(env_name, title, render_code) quarto.doc.include_text("in-header", "#let (" .. env_name .. "-counter, " .. env_name .. "-box, " .. env_name .. ", show-" .. env_name .. ") = make-frame(\n" .. " \"" .. env_name .. "\",\n" .. - " text(weight: \"bold\")[" .. title .. "],\n" .. + " text(weight: \"bold\", " .. typst_string(title) .. "),\n" .. " inherited-levels: theorem-inherited-levels,\n" .. " numbering: theorem-numbering,\n" .. render_code .. @@ -98,4 +124,6 @@ return { ensure_appearance_imports = ensure_appearance_imports, ensure_simple_render = ensure_simple_render, ensure_frame = ensure_frame, + render_code = render_code, + typst_string = typst_string, } From 3504d4bbfbe912752d5c278c825272e7a32ff2d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:47:42 +0200 Subject: [PATCH 05/14] docs: add changelog entry for #14290 --- news/changelog-1.11.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index e5a1c1f3db8..883c3ad24c2 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -80,6 +80,7 @@ All changes included in 1.11: - ([#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. - ([#14081](https://github.com/quarto-dev/quarto-cli/issues/14081)): Support `lof` and `lot` in Typst documents, which previously had no effect. The list titles follow `lang` and can be set with `crossref: lof-title` and `lot-title`. +- ([#14290](https://github.com/quarto-dev/quarto-cli/issues/14290)): Fix `proof`, `remark` and `solution` blocks in Typst output, which rendered as plain text and could not be cross-referenced. They now use the same frames as theorems. Theorem and proof titles set in the language files may now contain Typst markup characters. (author: @mcanouil) - ([#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. From f0bdff408c2875837f47cff1f2e6cd359410c8a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:58:18 +0200 Subject: [PATCH 06/14] test: check a title-only proof keeps its label and the bold unnumbered prefix --- .../2026/02/04/issue-13992-proof.qmd | 2 +- .../docs/smoke-all/2026/04/04/issue-14290.qmd | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd b/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd index d33c8ec6e22..3814834f8e3 100644 --- a/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd +++ b/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd @@ -14,7 +14,7 @@ _quarto: - ['Proof content visible'] typst: ensureTypstFileRegexMatches: - - ['#proof\('] + - ['#proof\(', 'Proof content visible'] - [] --- diff --git a/tests/docs/smoke-all/2026/04/04/issue-14290.qmd b/tests/docs/smoke-all/2026/04/04/issue-14290.qmd index c7af284738c..984806c62c6 100644 --- a/tests/docs/smoke-all/2026/04/04/issue-14290.qmd +++ b/tests/docs/smoke-all/2026/04/04/issue-14290.qmd @@ -15,10 +15,12 @@ _quarto: - '#ref\(' - '#ref\(' # unnumbered blocks pin their number and keep the counter still - - '#remark\(number: "", get-prefix: ' - - '#proof\(number: "", get-prefix: ' + - '#remark\(number: "", get-prefix: \(\.\.args\) => text\(weight: "bold", "Remark"\)' + - '#proof\(number: "", get-prefix: \(\.\.args\) => text\(weight: "bold", "Proof"\)' # a numbered proof keeps theorion's own number - '#proof\(\)\[(\r\n?|\n)Numbered proof\.' + # a block holding only a title keeps its label for references + - '' - [] ensurePdfRegexMatches: - @@ -28,10 +30,11 @@ _quarto: - 'Proof\. This is an unnumbered proof' - 'Proof 1\. Numbered proof' - 'Proof \(With code\)\. A named proof' - - 'See Remark 1, Solution 1 and Proof 1' + - 'Proof 2 \(Only a title\)' + - 'See Remark 1, Solution 1, Proof 1 and Proof 2' - - 'Remark 2' - - 'Proof 2' + - 'Proof 3' - 'Proof \.' --- @@ -82,4 +85,10 @@ A named proof. ::: -See @rem-example, @sol-exercise and @prf-a. +::: {#prf-b} + +## Only a title + +::: + +See @rem-example, @sol-exercise, @prf-a and @prf-b. From d5252e23f696af2538499a37f3f177cc686868a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:58:18 +0200 Subject: [PATCH 07/14] fix(typst): keep title-only proofs and style the unnumbered prefix --- src/resources/filters/customnodes/proof.lua | 9 +++++---- src/resources/filters/modules/theorems.lua | 5 +---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/resources/filters/customnodes/proof.lua b/src/resources/filters/customnodes/proof.lua index 0753c62253b..485e1d47d55 100644 --- a/src/resources/filters/customnodes/proof.lua +++ b/src/resources/filters/customnodes/proof.lua @@ -168,9 +168,10 @@ end, function(proof_tbl) elseif _quarto.format.isJatsOutput() then el = jatsTheorem(el, nil, name ) elseif _quarto.format.isTypstOutput() then + -- a block holding only a title still needs its frame, so that its label + -- exists for references; theorems do the same if #el.content == 0 then - warn("Proof block has no content; skipping") - return pandoc.Null() + el.content = pandoc.Blocks({pandoc.Para({pandoc.Str '\u{a0}'})}) end ensure_typst_proofs(proof.env) local preamble = pandoc.Plain({pandoc.RawInline("typst", "#" .. proof.env .. "(")}) @@ -180,8 +181,8 @@ end, function(proof_tbl) -- `number: auto`, and its prefix would keep a space before the empty -- number, so an unnumbered block pins both preamble.content:insert(pandoc.RawInline("typst", - 'number: "", get-prefix: (..args) => ' .. - _quarto.modules.theorems.typst_string(envTitle(proof.env, proof.title)))) + 'number: "", get-prefix: (..args) => text(weight: "bold", ' .. + _quarto.modules.theorems.typst_string(envTitle(proof.env, proof.title)) .. ')')) end if name and #name > 0 then if not numbered then diff --git a/src/resources/filters/modules/theorems.lua b/src/resources/filters/modules/theorems.lua index 4923f6a3326..e7b83ef9140 100644 --- a/src/resources/filters/modules/theorems.lua +++ b/src/resources/filters/modules/theorems.lua @@ -103,7 +103,7 @@ end local function ensure_frame(env_name, title, render_code) if typst_theorem_like_frames[env_name] then - return false + return end typst_theorem_like_frames[env_name] = true @@ -116,13 +116,10 @@ local function ensure_frame(env_name, title, render_code) render_code .. ")") quarto.doc.include_text("in-header", "#show: show-" .. env_name) - return true end return { - appearance = appearance, ensure_appearance_imports = ensure_appearance_imports, - ensure_simple_render = ensure_simple_render, ensure_frame = ensure_frame, render_code = render_code, typst_string = typst_string, From f92794c51a04523810631267c83671e9e3f34b1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:08:24 +0200 Subject: [PATCH 08/14] test: check italic proof headers in the simple appearance and styled proofs --- .../2026/04/04/issue-14290-simple-style.qmd | 51 +++++++++++++++++++ .../docs/smoke-all/2026/04/04/issue-14290.qmd | 4 +- 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 tests/docs/smoke-all/2026/04/04/issue-14290-simple-style.qmd diff --git a/tests/docs/smoke-all/2026/04/04/issue-14290-simple-style.qmd b/tests/docs/smoke-all/2026/04/04/issue-14290-simple-style.qmd new file mode 100644 index 00000000000..f8faf6bf5bd --- /dev/null +++ b/tests/docs/smoke-all/2026/04/04/issue-14290-simple-style.qmd @@ -0,0 +1,51 @@ +--- +title: "Proof-like blocks keep the LaTeX style in the simple appearance" +format: typst +keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + # the header is italic and the body upright, as in LaTeX and HTML + - '#let simple-proof-render\(prefix: none, title: "", full-title: auto, body\) = \{(\r\n?|\n)\s*emph\(prefix\)' + - 'make-frame\((\r\n?|\n)\s*"proof",(\r\n?|\n)\s*"Proof",' + - 'make-frame\((\r\n?|\n)\s*"remark",(\r\n?|\n)\s*"Remark",' + - '#proof\(number: "", get-prefix: \(\.\.args\) => "Proof"\)\[' + # theorems keep the bold header + - 'make-frame\((\r\n?|\n)\s*"theorem",(\r\n?|\n)\s*text\(weight: "bold", "Theorem"\),' + # attributes on the block still reach the output + - '#block\(fill: yellow\)\[(\r\n?|\n)#set text\(fill: red\);(\r\n?|\n)#proof\(' + - + - 'text\(weight: "bold", "Proof"\)' + - 'text\(weight: "bold", "Remark"\)' + ensurePdfRegexMatches: + - + - 'Remark 1 \(Named\)\. A numbered remark' + - 'Proof\. An unnumbered proof' + - [] +--- + +In the `simple` appearance, which the documentation describes as classic +LaTeX style, `proof`, `remark` and `solution` headers are italic with an +upright body, as amsthm and the HTML output write them. Theorems keep a bold +header. A `style` attribute on the block still reaches the Typst output. + +::: {#thm-a} +A theorem. +::: + +::: {.proof} +An unnumbered proof. +::: + +::: {#rem-a} +## Named +A numbered remark. +::: + +::: {.proof style="background-color: yellow; color: red"} +A styled proof. +::: + +See @rem-a. diff --git a/tests/docs/smoke-all/2026/04/04/issue-14290.qmd b/tests/docs/smoke-all/2026/04/04/issue-14290.qmd index 984806c62c6..dc59faa13a0 100644 --- a/tests/docs/smoke-all/2026/04/04/issue-14290.qmd +++ b/tests/docs/smoke-all/2026/04/04/issue-14290.qmd @@ -15,8 +15,8 @@ _quarto: - '#ref\(' - '#ref\(' # unnumbered blocks pin their number and keep the counter still - - '#remark\(number: "", get-prefix: \(\.\.args\) => text\(weight: "bold", "Remark"\)' - - '#proof\(number: "", get-prefix: \(\.\.args\) => text\(weight: "bold", "Proof"\)' + - '#remark\(number: "", get-prefix: \(\.\.args\) => "Remark"\)' + - '#proof\(number: "", get-prefix: \(\.\.args\) => "Proof"\)' # a numbered proof keeps theorion's own number - '#proof\(\)\[(\r\n?|\n)Numbered proof\.' # a block holding only a title keeps its label for references From e18d96f685dc3463af393283bb0c3fabcb53c520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:08:24 +0200 Subject: [PATCH 09/14] fix(typst): keep italic proof headers in the simple appearance and keep block attributes --- src/resources/filters/customnodes/proof.lua | 19 ++++++--- src/resources/filters/customnodes/theorem.lua | 4 +- src/resources/filters/modules/theorems.lua | 39 ++++++++++++++----- 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/src/resources/filters/customnodes/proof.lua b/src/resources/filters/customnodes/proof.lua index 485e1d47d55..e89fbbdf1c7 100644 --- a/src/resources/filters/customnodes/proof.lua +++ b/src/resources/filters/customnodes/proof.lua @@ -81,10 +81,14 @@ local function ensure_typst_proofs(proof_env) color_scheme = "tertiary", color = proof_theme_colors[proof_env] or "gray", simple_render = "simple-proof-render", - italic_body = false, + simple_style = "proof", }) - _quarto.modules.theorems.ensure_frame(proof_env, title, render_code) + -- the simple renderer writes the header in italic, like LaTeX and HTML; + -- the other appearances keep theorion's bold header, as theorems do + local bold = appearance ~= "simple" + _quarto.modules.theorems.ensure_frame(proof_env, title, render_code, bold) + return bold end function is_proof_div(div) @@ -173,7 +177,7 @@ end, function(proof_tbl) if #el.content == 0 then el.content = pandoc.Blocks({pandoc.Para({pandoc.Str '\u{a0}'})}) end - ensure_typst_proofs(proof.env) + local bold = ensure_typst_proofs(proof.env) local preamble = pandoc.Plain({pandoc.RawInline("typst", "#" .. proof.env .. "(")}) local numbered = is_numbered_proof(proof_tbl.identifier) if not numbered then @@ -181,8 +185,8 @@ end, function(proof_tbl) -- `number: auto`, and its prefix would keep a space before the empty -- number, so an unnumbered block pins both preamble.content:insert(pandoc.RawInline("typst", - 'number: "", get-prefix: (..args) => text(weight: "bold", ' .. - _quarto.modules.theorems.typst_string(envTitle(proof.env, proof.title)) .. ')')) + 'number: "", get-prefix: (..args) => ' .. + _quarto.modules.theorems.header_code(envTitle(proof.env, proof.title), bold))) end if name and #name > 0 then if not numbered then @@ -200,6 +204,11 @@ end, function(proof_tbl) else callproof.content:insert(pandoc.RawInline("typst", "]")) end + -- attributes such as `style` stay on a wrapper div, so the Typst CSS + -- support turns them into a block around the frame, as for other divs + if #el.attr.attributes > 0 then + return pandoc.Div({callproof}, pandoc.Attr("", {}, el.attr.attributes)) + end return callproof else el.classes:insert(proof.title:lower()) diff --git a/src/resources/filters/customnodes/theorem.lua b/src/resources/filters/customnodes/theorem.lua index faab13d7d96..1f9e5f9ee24 100644 --- a/src/resources/filters/customnodes/theorem.lua +++ b/src/resources/filters/customnodes/theorem.lua @@ -113,10 +113,10 @@ local function ensure_typst_theorems(reftype) color_scheme = color_scheme, color = theme_colors[reftype] or "gray", simple_render = "simple-theorem-render", - italic_body = true, + simple_style = "theorem", }) - _quarto.modules.theorems.ensure_frame(theorem_type.env, title, render_code) + _quarto.modules.theorems.ensure_frame(theorem_type.env, title, render_code, true) end diff --git a/src/resources/filters/modules/theorems.lua b/src/resources/filters/modules/theorems.lua index e7b83ef9140..f63059f8e16 100644 --- a/src/resources/filters/modules/theorems.lua +++ b/src/resources/filters/modules/theorems.lua @@ -53,15 +53,25 @@ local function ensure_appearance_imports() return val end -local function ensure_simple_render(render_name, italic_body) +-- Defines a simple renderer once. The "theorem" style writes a bold header and +-- an italic body; the "proof" style writes an italic header and an upright +-- body, with the name in brackets, as amsthm and the HTML output do. +local function ensure_simple_render(render_name, style) if typst_simple_renderers[render_name] then return end typst_simple_renderers[render_name] = true - local body_render = "body" - if italic_body then - body_render = "emph(body)" + if style == "proof" then + quarto.doc.include_text("in-header", "#let " .. render_name .. [[(prefix: none, title: "", full-title: auto, body) = { + emph(prefix) + if title != "" and title != none [ (#title)] + [. ] + body + parbreak() +} +]]) + return end quarto.doc.include_text("in-header", "#let " .. render_name .. [[(prefix: none, title: "", full-title: auto, body) = { @@ -69,7 +79,7 @@ local function ensure_simple_render(render_name, italic_body) strong[#full-title.] h(0.5em) } - ]] .. body_render .. "\n" .. [[ + emph(body) parbreak() } ]]) @@ -84,7 +94,8 @@ end -- Returns the `render:` argument of make-frame for the theorem appearance. -- opts.color_scheme: fancy color set (primary, secondary or tertiary) -- opts.color: clouds and rainbow fill color --- opts.simple_render, opts.italic_body: name and body style of the simple renderer +-- opts.simple_render, opts.simple_style: name and style ("theorem" or "proof") +-- of the simple renderer local function render_code(appearance, opts) if appearance == "fancy" then return " render: fancy-box.with(\n" .. @@ -97,11 +108,20 @@ local function render_code(appearance, opts) elseif appearance == "rainbow" then return " render: rainbow-render.with(fill: " .. opts.color .. ".darken(20%)),\n" end - ensure_simple_render(opts.simple_render, opts.italic_body) + ensure_simple_render(opts.simple_render, opts.simple_style) return " render: " .. opts.simple_render .. ",\n" end -local function ensure_frame(env_name, title, render_code) +-- Returns the Typst code for a frame header: bold, or plain for blocks whose +-- renderer styles the header itself. +local function header_code(title, bold) + if bold then + return 'text(weight: "bold", ' .. typst_string(title) .. ')' + end + return typst_string(title) +end + +local function ensure_frame(env_name, title, render_code, bold) if typst_theorem_like_frames[env_name] then return end @@ -110,7 +130,7 @@ local function ensure_frame(env_name, title, render_code) quarto.doc.include_text("in-header", "#let (" .. env_name .. "-counter, " .. env_name .. "-box, " .. env_name .. ", show-" .. env_name .. ") = make-frame(\n" .. " \"" .. env_name .. "\",\n" .. - " text(weight: \"bold\", " .. typst_string(title) .. "),\n" .. + " " .. header_code(title, bold) .. ",\n" .. " inherited-levels: theorem-inherited-levels,\n" .. " numbering: theorem-numbering,\n" .. render_code .. @@ -121,6 +141,7 @@ end return { ensure_appearance_imports = ensure_appearance_imports, ensure_frame = ensure_frame, + header_code = header_code, render_code = render_code, typst_string = typst_string, } From 01da667ae51ec5bf9280c76ac930f1899ff603a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:18:02 +0200 Subject: [PATCH 10/14] refactor(typst): avoid shadowed names in the theorems module --- src/resources/filters/modules/theorems.lua | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/resources/filters/modules/theorems.lua b/src/resources/filters/modules/theorems.lua index f63059f8e16..01f54b08584 100644 --- a/src/resources/filters/modules/theorems.lua +++ b/src/resources/filters/modules/theorems.lua @@ -96,16 +96,16 @@ end -- opts.color: clouds and rainbow fill color -- opts.simple_render, opts.simple_style: name and style ("theorem" or "proof") -- of the simple renderer -local function render_code(appearance, opts) - if appearance == "fancy" then +local function render_code(appearance_name, opts) + if appearance_name == "fancy" then return " render: fancy-box.with(\n" .. " get-border-color: get-" .. opts.color_scheme .. "-border-color,\n" .. " get-body-color: get-" .. opts.color_scheme .. "-body-color,\n" .. " get-symbol: loc => none,\n" .. " ),\n" - elseif appearance == "clouds" then + elseif appearance_name == "clouds" then return " render: clouds-render.with(fill: " .. opts.color .. ".lighten(85%)),\n" - elseif appearance == "rainbow" then + elseif appearance_name == "rainbow" then return " render: rainbow-render.with(fill: " .. opts.color .. ".darken(20%)),\n" end ensure_simple_render(opts.simple_render, opts.simple_style) @@ -121,7 +121,7 @@ local function header_code(title, bold) return typst_string(title) end -local function ensure_frame(env_name, title, render_code, bold) +local function ensure_frame(env_name, title, render_arg, bold) if typst_theorem_like_frames[env_name] then return end @@ -133,7 +133,7 @@ local function ensure_frame(env_name, title, render_code, bold) " " .. header_code(title, bold) .. ",\n" .. " inherited-levels: theorem-inherited-levels,\n" .. " numbering: theorem-numbering,\n" .. - render_code .. + render_arg .. ")") quarto.doc.include_text("in-header", "#show: show-" .. env_name) end @@ -143,5 +143,4 @@ return { ensure_frame = ensure_frame, header_code = header_code, render_code = render_code, - typst_string = typst_string, } From 6b81598c3b92d5c28b55f7d93b0346726d25572c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:30:18 +0200 Subject: [PATCH 11/14] test: check proof frames in the fancy appearance --- .../smoke-all/typst/theorem/proof-fancy.qmd | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/docs/smoke-all/typst/theorem/proof-fancy.qmd diff --git a/tests/docs/smoke-all/typst/theorem/proof-fancy.qmd b/tests/docs/smoke-all/typst/theorem/proof-fancy.qmd new file mode 100644 index 00000000000..1a0785178ce --- /dev/null +++ b/tests/docs/smoke-all/typst/theorem/proof-fancy.qmd @@ -0,0 +1,42 @@ +--- +format: + typst: + keep-typ: true + theorem-appearance: fancy +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + # proof-like frames use the tertiary fancy colours and a bold header + - 'make-frame\((\r\n?|\n)\s*"proof",(\r\n?|\n)\s*text\(weight: "bold", "Proof"\),' + - 'make-frame\((\r\n?|\n)\s*"remark",(\r\n?|\n)\s*text\(weight: "bold", "Remark"\),' + - 'get-border-color: get-tertiary-border-color' + - '#proof\(number: "", get-prefix: \(\.\.args\) => text\(weight: "bold", "Proof"\)\)\[' + - '#remark\(\)\[' + - + - "simple-proof-render" + - "clouds-render" + - "rainbow-render" + ensurePdfRegexMatches: + - + - "Proof" + - "Remark 1" + - + - "Proof 1" + - "Remark 2" +--- + +::: {.proof} +An unnumbered proof. +::: + +::: {.remark} +An unnumbered remark. +::: + +::: {#rem-a} +A numbered remark. +::: + +See @rem-a. From 29ff13cae600de861b3157f2ae7fb2dc0d477d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:48:54 +0200 Subject: [PATCH 12/14] test: check a styled theorem keeps its attributes in Typst --- .../smoke-all/2026/04/04/issue-14290-simple-style.qmd | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/docs/smoke-all/2026/04/04/issue-14290-simple-style.qmd b/tests/docs/smoke-all/2026/04/04/issue-14290-simple-style.qmd index f8faf6bf5bd..ffdee89c414 100644 --- a/tests/docs/smoke-all/2026/04/04/issue-14290-simple-style.qmd +++ b/tests/docs/smoke-all/2026/04/04/issue-14290-simple-style.qmd @@ -16,6 +16,7 @@ _quarto: - 'make-frame\((\r\n?|\n)\s*"theorem",(\r\n?|\n)\s*text\(weight: "bold", "Theorem"\),' # attributes on the block still reach the output - '#block\(fill: yellow\)\[(\r\n?|\n)#set text\(fill: red\);(\r\n?|\n)#proof\(' + - '#block\(fill: aqua\)\[(\r\n?|\n)#set text\(fill: blue\);(\r\n?|\n)#theorem\(\)\[' - - 'text\(weight: "bold", "Proof"\)' - 'text\(weight: "bold", "Remark"\)' @@ -29,7 +30,8 @@ _quarto: In the `simple` appearance, which the documentation describes as classic LaTeX style, `proof`, `remark` and `solution` headers are italic with an upright body, as amsthm and the HTML output write them. Theorems keep a bold -header. A `style` attribute on the block still reaches the Typst output. +header. A `style` attribute on a proof or a theorem still reaches the Typst +output. ::: {#thm-a} A theorem. @@ -48,4 +50,8 @@ A numbered remark. A styled proof. ::: +::: {#thm-b style="background-color: aqua; color: blue"} +A styled theorem. +::: + See @rem-a. From 0d8d5110fc80498f6a8510905f795070501eff17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:48:55 +0200 Subject: [PATCH 13/14] fix(typst): keep block attributes on theorems as on proofs --- src/resources/filters/customnodes/proof.lua | 7 +------ src/resources/filters/customnodes/theorem.lua | 2 +- src/resources/filters/modules/theorems.lua | 10 ++++++++++ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/resources/filters/customnodes/proof.lua b/src/resources/filters/customnodes/proof.lua index e89fbbdf1c7..b1e5a1bb5a4 100644 --- a/src/resources/filters/customnodes/proof.lua +++ b/src/resources/filters/customnodes/proof.lua @@ -204,12 +204,7 @@ end, function(proof_tbl) else callproof.content:insert(pandoc.RawInline("typst", "]")) end - -- attributes such as `style` stay on a wrapper div, so the Typst CSS - -- support turns them into a block around the frame, as for other divs - if #el.attr.attributes > 0 then - return pandoc.Div({callproof}, pandoc.Attr("", {}, el.attr.attributes)) - end - return callproof + return _quarto.modules.theorems.keep_attributes(callproof, el.attr.attributes) else el.classes:insert(proof.title:lower()) local span_title = pandoc.Emph(pandoc.Str(envTitle(proof.env, proof.title))) diff --git a/src/resources/filters/customnodes/theorem.lua b/src/resources/filters/customnodes/theorem.lua index 1f9e5f9ee24..207b4959322 100644 --- a/src/resources/filters/customnodes/theorem.lua +++ b/src/resources/filters/customnodes/theorem.lua @@ -182,7 +182,7 @@ end, function(thm) local callthm = make_scaffold(pandoc.Div, preamble) tappend(callthm.content, quarto.utils.as_blocks(el.content)) callthm.content:insert(pandoc.RawInline("typst", "] <" .. el.attr.identifier .. ">")) - return callthm + return _quarto.modules.theorems.keep_attributes(callthm, el.attr.attributes) else -- order might be nil in the case of an ipynb rendering in diff --git a/src/resources/filters/modules/theorems.lua b/src/resources/filters/modules/theorems.lua index 01f54b08584..a0217ce2364 100644 --- a/src/resources/filters/modules/theorems.lua +++ b/src/resources/filters/modules/theorems.lua @@ -138,9 +138,19 @@ local function ensure_frame(env_name, title, render_arg, bold) quarto.doc.include_text("in-header", "#show: show-" .. env_name) end +-- Keeps the block's attributes, such as `style`, on a wrapper div, so the Typst +-- CSS support turns them into a block around the frame, as for other divs. +local function keep_attributes(frame, attributes) + if #attributes > 0 then + return pandoc.Div({frame}, pandoc.Attr("", {}, attributes)) + end + return frame +end + return { ensure_appearance_imports = ensure_appearance_imports, ensure_frame = ensure_frame, header_code = header_code, + keep_attributes = keep_attributes, render_code = render_code, } From 4e401492fffa075814c7fb6fe92398bad9f3082a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:49:24 +0200 Subject: [PATCH 14/14] docs: mention theorem and proof style attributes in the #14290 entry --- news/changelog-1.11.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 883c3ad24c2..4b9c334c4ba 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -80,7 +80,7 @@ All changes included in 1.11: - ([#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. - ([#14081](https://github.com/quarto-dev/quarto-cli/issues/14081)): Support `lof` and `lot` in Typst documents, which previously had no effect. The list titles follow `lang` and can be set with `crossref: lof-title` and `lot-title`. -- ([#14290](https://github.com/quarto-dev/quarto-cli/issues/14290)): Fix `proof`, `remark` and `solution` blocks in Typst output, which rendered as plain text and could not be cross-referenced. They now use the same frames as theorems. Theorem and proof titles set in the language files may now contain Typst markup characters. (author: @mcanouil) +- ([#14290](https://github.com/quarto-dev/quarto-cli/issues/14290)): Fix `proof`, `remark` and `solution` blocks in Typst output, which rendered as plain text and could not be cross-referenced. They now use the same frames as theorems. Theorem and proof titles set in the language files may now contain Typst markup characters, and a `style` attribute on a theorem or proof now applies in Typst too. (author: @mcanouil) - ([#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.