Skip to content

Commit 079115a

Browse files
committed
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
1 parent fbaf628 commit 079115a

File tree

6 files changed

+216
-101
lines changed

6 files changed

+216
-101
lines changed

news/changelog-1.10.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ All changes included in 1.10:
1111
### `typst`
1212

1313
- ([#14261](https://github.com/quarto-dev/quarto-cli/issues/14261)): Fix theorem/example block titles containing inline code producing invalid Typst markup when syntax highlighting is applied.
14+
- ([#14290](https://github.com/quarto-dev/quarto-cli/issues/14290)): Fix cross-referencing `remark` and `solution` environments producing invalid Typst output.
1415

1516
## Commands
1617

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,93 @@
11
-- theorems.lua
22
-- Copyright (C) 2020-2022 Posit Software, PBC
3+
4+
local typst_theorem_appearance_imported = false
5+
local typst_theorem_like_frames = {}
6+
local typst_simple_renderers = {}
7+
8+
function theoremTypstAppearance()
9+
local appearance = option("theorem-appearance", "simple")
10+
if type(appearance) == "table" then
11+
appearance = pandoc.utils.stringify(appearance)
12+
end
13+
return appearance or "simple"
14+
end
15+
16+
function ensureTheoremTypstAppearanceImports()
17+
local appearance = theoremTypstAppearance()
18+
if typst_theorem_appearance_imported then
19+
return appearance
20+
end
21+
22+
typst_theorem_appearance_imported = true
23+
if appearance == "fancy" then
24+
quarto.doc.include_text("in-header", [[
25+
#import "@preview/theorion:0.4.1": make-frame, cosmos
26+
#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
27+
]])
28+
quarto.doc.include_text("before-body", [[
29+
#set-primary-border-color(brand-color.at("primary", default: green.darken(30%)))
30+
#set-primary-body-color(brand-color.at("primary", default: green).lighten(90%))
31+
#set-secondary-border-color(brand-color.at("secondary", default: orange))
32+
#set-secondary-body-color(brand-color.at("secondary", default: orange).lighten(90%))
33+
#set-tertiary-border-color(brand-color.at("tertiary", default: blue.darken(30%)))
34+
#set-tertiary-body-color(brand-color.at("tertiary", default: blue).lighten(90%))
35+
]])
36+
elseif appearance == "clouds" then
37+
quarto.doc.include_text("in-header", [[
38+
#import "@preview/theorion:0.4.1": make-frame, cosmos
39+
#import cosmos.clouds: render-fn as clouds-render
40+
]])
41+
elseif appearance == "rainbow" then
42+
quarto.doc.include_text("in-header", [[
43+
#import "@preview/theorion:0.4.1": make-frame, cosmos
44+
#import cosmos.rainbow: render-fn as rainbow-render
45+
]])
46+
else
47+
quarto.doc.include_text("in-header", [[
48+
#import "@preview/theorion:0.4.1": make-frame
49+
]])
50+
end
51+
52+
return appearance
53+
end
54+
55+
function ensureTheoremTypstSimpleRender(render_name, italic_body)
56+
if typst_simple_renderers[render_name] then
57+
return
58+
end
59+
60+
typst_simple_renderers[render_name] = true
61+
local body_render = "body"
62+
if italic_body then
63+
body_render = "emph(body)"
64+
end
65+
66+
quarto.doc.include_text("in-header", "#let " .. render_name .. [[(prefix: none, title: "", full-title: auto, body) = {
67+
if full-title != "" and full-title != auto and full-title != none {
68+
strong[#full-title.]
69+
h(0.5em)
70+
}
71+
]] .. body_render .. "\n" .. [[
72+
parbreak()
73+
}
74+
]])
75+
end
76+
77+
function ensureTheoremTypstFrame(env_name, title, render_code)
78+
if typst_theorem_like_frames[env_name] then
79+
return false
80+
end
81+
82+
typst_theorem_like_frames[env_name] = true
83+
quarto.doc.include_text("in-header", "#let (" .. env_name .. "-counter, " .. env_name .. "-box, " ..
84+
env_name .. ", show-" .. env_name .. ") = make-frame(\n" ..
85+
" \"" .. env_name .. "\",\n" ..
86+
" text(weight: \"bold\")[" .. title .. "],\n" ..
87+
" inherited-levels: theorem-inherited-levels,\n" ..
88+
" numbering: theorem-numbering,\n" ..
89+
render_code ..
90+
")")
91+
quarto.doc.include_text("in-header", "#show: show-" .. env_name)
92+
return true
93+
end

src/resources/filters/customnodes/proof.lua

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,37 @@ _quarto.ast.add_handler({
6161
end
6262
})
6363

64+
-- Color mapping for clouds/rainbow themes (per proof type)
65+
local proof_theme_colors = {
66+
proof = "gray", remark = "orange", solution = "teal"
67+
}
68+
69+
local function ensure_typst_proofs(proof_env)
70+
local appearance = ensureTheoremTypstAppearanceImports()
71+
local proof_info = proof_types[proof_env]
72+
local title = envTitle(proof_env, proof_info.title)
73+
local render_code
74+
75+
if appearance == "fancy" then
76+
render_code = " render: fancy-box.with(\n" ..
77+
" get-border-color: get-tertiary-border-color,\n" ..
78+
" get-body-color: get-tertiary-body-color,\n" ..
79+
" get-symbol: loc => none,\n" ..
80+
" ),\n"
81+
elseif appearance == "clouds" then
82+
local color = proof_theme_colors[proof_env] or "gray"
83+
render_code = " render: clouds-render.with(fill: " .. color .. ".lighten(85%)),\n"
84+
elseif appearance == "rainbow" then
85+
local color = proof_theme_colors[proof_env] or "gray"
86+
render_code = " render: rainbow-render.with(fill: " .. color .. ".darken(20%)),\n"
87+
else
88+
ensureTheoremTypstSimpleRender("simple-proof-render", false)
89+
render_code = " render: simple-proof-render,\n"
90+
end
91+
92+
ensureTheoremTypstFrame(proof_env, title, render_code)
93+
end
94+
6495
function is_proof_div(div)
6596
local ref = refType(div.identifier)
6697
if ref ~= nil then
@@ -141,6 +172,27 @@ end, function(proof_tbl)
141172
end
142173
elseif _quarto.format.isJatsOutput() then
143174
el = jatsTheorem(el, nil, name )
175+
elseif _quarto.format.isTypstOutput() then
176+
if #el.content == 0 then
177+
warn("Proof block has no content; skipping")
178+
return pandoc.Null()
179+
end
180+
ensure_typst_proofs(proof.env)
181+
local preamble = pandoc.Plain({pandoc.RawInline("typst", "#" .. proof.env .. "(")})
182+
if name and #name > 0 then
183+
preamble.content:insert(pandoc.RawInline("typst", 'title: ['))
184+
tappend(preamble.content, name)
185+
preamble.content:insert(pandoc.RawInline("typst", ']'))
186+
end
187+
preamble.content:insert(pandoc.RawInline("typst", ")["))
188+
local callproof = make_scaffold(pandoc.Div, preamble)
189+
tappend(callproof.content, quarto.utils.as_blocks(el.content))
190+
if proof_tbl.identifier and proof_tbl.identifier ~= "" then
191+
callproof.content:insert(pandoc.RawInline("typst", "] <" .. proof_tbl.identifier .. ">"))
192+
else
193+
callproof.content:insert(pandoc.RawInline("typst", "]"))
194+
end
195+
return callproof
144196
else
145197
el.classes:insert(proof.title:lower())
146198
local span_title = pandoc.Emph(pandoc.Str(envTitle(proof.env, proof.title)))
@@ -176,4 +228,4 @@ end, function(proof_tbl)
176228

177229
return el
178230

179-
end)
231+
end)

src/resources/filters/customnodes/theorem.lua

Lines changed: 26 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -93,115 +93,42 @@ _quarto.ast.add_handler({
9393
end
9494
})
9595

96-
-- Get theorem-appearance option (simple, fancy, clouds, rainbow)
97-
local function get_theorem_appearance()
98-
local appearance = option("theorem-appearance", "simple")
99-
if appearance ~= nil and type(appearance) == "table" then
100-
appearance = pandoc.utils.stringify(appearance)
101-
end
102-
return appearance or "simple"
103-
end
104-
10596
-- Color mapping for clouds/rainbow themes (per theorem type)
10697
local theme_colors = {
10798
thm = "red", lem = "teal", cor = "navy", prp = "blue",
10899
cnj = "navy", def = "olive", exm = "green", exr = "purple", alg = "maroon"
109100
}
110101

111-
local included_typst_theorems = false
112-
local letted_typst_theorem = {}
113102
local function ensure_typst_theorems(reftype)
114-
local appearance = get_theorem_appearance()
115-
116-
if not included_typst_theorems then
117-
included_typst_theorems = true
118-
119-
if appearance == "fancy" then
120-
-- Import theorion's make-frame and fancy-box theming
121-
quarto.doc.include_text("in-header", [[
122-
#import "@preview/theorion:0.4.1": make-frame, cosmos
123-
#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
124-
]])
125-
-- Set theorem colors from brand-color (runs in before-body, after brand-color is defined)
126-
quarto.doc.include_text("before-body", [[
127-
#set-primary-border-color(brand-color.at("primary", default: green.darken(30%)))
128-
#set-primary-body-color(brand-color.at("primary", default: green).lighten(90%))
129-
#set-secondary-border-color(brand-color.at("secondary", default: orange))
130-
#set-secondary-body-color(brand-color.at("secondary", default: orange).lighten(90%))
131-
#set-tertiary-border-color(brand-color.at("tertiary", default: blue.darken(30%)))
132-
#set-tertiary-body-color(brand-color.at("tertiary", default: blue).lighten(90%))
133-
]])
134-
elseif appearance == "clouds" then
135-
-- Import theorion's make-frame and clouds render function
136-
quarto.doc.include_text("in-header", [[
137-
#import "@preview/theorion:0.4.1": make-frame, cosmos
138-
#import cosmos.clouds: render-fn as clouds-render
139-
]])
140-
elseif appearance == "rainbow" then
141-
-- Import theorion's make-frame and rainbow render function
142-
quarto.doc.include_text("in-header", [[
143-
#import "@preview/theorion:0.4.1": make-frame, cosmos
144-
#import cosmos.rainbow: render-fn as rainbow-render
145-
]])
146-
else -- simple (default)
147-
-- Import only make-frame and define simple render function
148-
quarto.doc.include_text("in-header", [[
149-
#import "@preview/theorion:0.4.1": make-frame
150-
151-
// Simple theorem render: bold title with period, italic body
152-
#let simple-theorem-render(prefix: none, title: "", full-title: auto, body) = {
153-
if full-title != "" and full-title != auto and full-title != none {
154-
strong[#full-title.]
155-
h(0.5em)
156-
}
157-
emph(body)
158-
parbreak()
159-
}
160-
]])
103+
local appearance = ensureTheoremTypstAppearanceImports()
104+
local theorem_type = theorem_types[reftype]
105+
local title = titleString(reftype, theorem_type.title)
106+
local render_code
107+
108+
if appearance == "fancy" then
109+
local color_scheme = "secondary"
110+
if theorem_type.style == "definition" then
111+
color_scheme = "primary"
112+
elseif reftype == "prp" then
113+
color_scheme = "tertiary"
161114
end
115+
render_code = " render: fancy-box.with(\n" ..
116+
" get-border-color: get-" .. color_scheme .. "-border-color,\n" ..
117+
" get-body-color: get-" .. color_scheme .. "-body-color,\n" ..
118+
" get-symbol: loc => none,\n" ..
119+
" ),\n"
120+
elseif appearance == "clouds" then
121+
local color = theme_colors[reftype] or "gray"
122+
render_code = " render: clouds-render.with(fill: " .. color .. ".lighten(85%)),\n"
123+
elseif appearance == "rainbow" then
124+
local color = theme_colors[reftype] or "gray"
125+
render_code = " render: rainbow-render.with(fill: " .. color .. ".darken(20%)),\n"
126+
else
127+
ensureTheoremTypstSimpleRender("simple-theorem-render", true)
128+
render_code = " render: simple-theorem-render,\n"
162129
end
163130

164-
if not letted_typst_theorem[reftype] then
165-
letted_typst_theorem[reftype] = true
166-
local theorem_type = theorem_types[reftype]
167-
local title = titleString(reftype, theorem_type.title)
168-
169-
-- Build render code based on appearance
170-
local render_code
171-
if appearance == "fancy" then
172-
-- Map theorem styles to color schemes (primary=definitions, secondary=theorems, tertiary=propositions)
173-
local color_scheme = "secondary" -- default for most theorem types
174-
if theorem_type.style == "definition" then
175-
color_scheme = "primary"
176-
elseif reftype == "prp" then
177-
color_scheme = "tertiary"
178-
end
179-
render_code = " render: fancy-box.with(\n" ..
180-
" get-border-color: get-" .. color_scheme .. "-border-color,\n" ..
181-
" get-body-color: get-" .. color_scheme .. "-body-color,\n" ..
182-
" get-symbol: loc => none,\n" ..
183-
" ),\n"
184-
elseif appearance == "clouds" then
185-
local color = theme_colors[reftype] or "gray"
186-
render_code = " render: clouds-render.with(fill: " .. color .. ".lighten(85%)),\n"
187-
elseif appearance == "rainbow" then
188-
local color = theme_colors[reftype] or "gray"
189-
render_code = " render: rainbow-render.with(fill: " .. color .. ".darken(20%)),\n"
190-
else -- simple
191-
render_code = " render: simple-theorem-render,\n"
192-
end
193-
194-
-- Use theorion's make-frame with appropriate render
195-
quarto.doc.include_text("in-header", "#let (" .. theorem_type.env .. "-counter, " .. theorem_type.env .. "-box, " ..
196-
theorem_type.env .. ", show-" .. theorem_type.env .. ") = make-frame(\n" ..
197-
" \"" .. theorem_type.env .. "\",\n" ..
198-
" text(weight: \"bold\")[" .. title .. "],\n" ..
199-
" inherited-levels: theorem-inherited-levels,\n" ..
200-
" numbering: theorem-numbering,\n" ..
201-
render_code ..
202-
")")
203-
quarto.doc.include_text("in-header", "#show: show-" .. theorem_type.env)
204-
end
131+
ensureTheoremTypstFrame(theorem_type.env, title, render_code)
205132
end
206133

207134

tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ _quarto:
1313
- []
1414
- ['Proof content visible']
1515
typst:
16+
noErrors: default
1617
ensureTypstFileRegexMatches:
17-
- ['#emph\[Proof\]\. Proof content visible']
18+
- ['#proof\(']
1819
- []
1920
---
2021

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
title: "Cross-reference remark and solution in Typst (#14290)"
3+
keep-typ: true
4+
_quarto:
5+
tests:
6+
typst:
7+
noErrors: default
8+
ensureTypstFileRegexMatches:
9+
-
10+
- '#remark\('
11+
- '#solution\('
12+
- '<rem-example>'
13+
- '<sol-exercise>'
14+
- '#show: show-remark'
15+
- '#show: show-solution'
16+
- '#ref\(<rem-example>'
17+
- '#ref\(<sol-exercise>'
18+
- []
19+
---
20+
21+
::: {#rem-example}
22+
23+
## A Remark
24+
25+
This is a remark that should be cross-referenceable.
26+
27+
:::
28+
29+
::: {#sol-exercise}
30+
31+
## A Solution
32+
33+
This is a solution that should be cross-referenceable.
34+
35+
:::
36+
37+
::: {.proof}
38+
39+
This is an unnumbered proof.
40+
41+
:::
42+
43+
See @rem-example and @sol-exercise.

0 commit comments

Comments
 (0)