Skip to content

Commit b3eb5f5

Browse files
authored
fix(typst): add rendering for proof, remark, and solution environments (#14312)
In Typst output, proof, remark and solution blocks fell through to the generic renderer as plain blocks, so references to them failed with "cannot reference block". They now use theorion frames like theorems, with a label when the block has a crossref id. The shared frame setup lives in modules/theorems.lua, used by both theorem and proof rendering. Unnumbered blocks pass number "" and keep their own prefix, so they no longer step the frame counter. A block holding only a title keeps its frame and label. Frame titles from the language files are written as Typst strings, so markup characters no longer break compilation. In the simple appearance, proof, remark and solution headers are italic with an upright body, matching LaTeX and HTML. Theorems keep bold headers. Block attributes such as style are kept on a wrapper div around the frame, for theorems and proofs alike. See #15113 for a crash on blocks whose id is not a crossref id, which also occurs on main in every format. Fixes #14290
1 parent ae03c7a commit b3eb5f5

10 files changed

Lines changed: 466 additions & 104 deletions

File tree

‎news/changelog-1.11.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ All changes included in 1.11:
8282

8383
- ([#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.
8484
- ([#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`.
85+
- ([#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)
8586
- ([#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`.
8687
- ([#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.
8788

‎src/resources/filters/customnodes/proof.lua‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,36 @@ _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+
-- A proof is numbered only when its identifier is a crossref id (`prf-`,
70+
-- `rem-` or `sol-`).
71+
local function is_numbered_proof(identifier)
72+
local ref = identifier and refType(identifier)
73+
return ref ~= nil and crossref.categories.by_ref_type[ref] ~= nil
74+
end
75+
76+
local function ensure_typst_proofs(proof_env)
77+
local appearance = _quarto.modules.theorems.ensure_appearance_imports()
78+
local proof_info = proof_types[proof_env]
79+
local title = envTitle(proof_env, proof_info.title)
80+
local render_code = _quarto.modules.theorems.render_code(appearance, {
81+
color_scheme = "tertiary",
82+
color = proof_theme_colors[proof_env] or "gray",
83+
simple_render = "simple-proof-render",
84+
simple_style = "proof",
85+
})
86+
87+
-- the simple renderer writes the header in italic, like LaTeX and HTML;
88+
-- the other appearances keep theorion's bold header, as theorems do
89+
local bold = appearance ~= "simple"
90+
_quarto.modules.theorems.ensure_frame(proof_env, title, render_code, bold)
91+
return bold
92+
end
93+
6494
function is_proof_div(div)
6595
local ref = refType(div.identifier)
6696
if ref ~= nil then
@@ -141,6 +171,40 @@ end, function(proof_tbl)
141171
end
142172
elseif _quarto.format.isJatsOutput() then
143173
el = jatsTheorem(el, nil, name )
174+
elseif _quarto.format.isTypstOutput() then
175+
-- a block holding only a title still needs its frame, so that its label
176+
-- exists for references; theorems do the same
177+
if #el.content == 0 then
178+
el.content = pandoc.Blocks({pandoc.Para({pandoc.Str '\u{a0}'})})
179+
end
180+
local bold = ensure_typst_proofs(proof.env)
181+
local preamble = pandoc.Plain({pandoc.RawInline("typst", "#" .. proof.env .. "(")})
182+
local numbered = is_numbered_proof(proof_tbl.identifier)
183+
if not numbered then
184+
-- theorion steps the counter and prints the number only for
185+
-- `number: auto`, and its prefix would keep a space before the empty
186+
-- number, so an unnumbered block pins both
187+
preamble.content:insert(pandoc.RawInline("typst",
188+
'number: "", get-prefix: (..args) => ' ..
189+
_quarto.modules.theorems.header_code(envTitle(proof.env, proof.title), bold)))
190+
end
191+
if name and #name > 0 then
192+
if not numbered then
193+
preamble.content:insert(pandoc.RawInline("typst", ", "))
194+
end
195+
preamble.content:insert(pandoc.RawInline("typst", 'title: ['))
196+
tappend(preamble.content, name)
197+
preamble.content:insert(pandoc.RawInline("typst", ']'))
198+
end
199+
preamble.content:insert(pandoc.RawInline("typst", ")["))
200+
local callproof = make_scaffold(pandoc.Div, preamble)
201+
tappend(callproof.content, quarto.utils.as_blocks(el.content))
202+
if proof_tbl.identifier and proof_tbl.identifier ~= "" then
203+
callproof.content:insert(pandoc.RawInline("typst", "] <" .. proof_tbl.identifier .. ">"))
204+
else
205+
callproof.content:insert(pandoc.RawInline("typst", "]"))
206+
end
207+
return _quarto.modules.theorems.keep_attributes(callproof, el.attr.attributes)
144208
else
145209
el.classes:insert(proof.title:lower())
146210
local span_title = pandoc.Emph(pandoc.Str(envTitle(proof.env, proof.title)))
@@ -176,4 +240,4 @@ end, function(proof_tbl)
176240

177241
return el
178242

179-
end)
243+
end)

‎src/resources/filters/customnodes/theorem.lua‎

Lines changed: 17 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -93,115 +93,30 @@ _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-
]])
161-
end
162-
end
163-
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)
103+
local appearance = _quarto.modules.theorems.ensure_appearance_imports()
104+
local theorem_type = theorem_types[reftype]
105+
local title = titleString(reftype, theorem_type.title)
106+
local color_scheme = "secondary"
107+
if theorem_type.style == "definition" then
108+
color_scheme = "primary"
109+
elseif reftype == "prp" then
110+
color_scheme = "tertiary"
204111
end
112+
local render_code = _quarto.modules.theorems.render_code(appearance, {
113+
color_scheme = color_scheme,
114+
color = theme_colors[reftype] or "gray",
115+
simple_render = "simple-theorem-render",
116+
simple_style = "theorem",
117+
})
118+
119+
_quarto.modules.theorems.ensure_frame(theorem_type.env, title, render_code, true)
205120
end
206121

207122

@@ -267,7 +182,7 @@ end, function(thm)
267182
local callthm = make_scaffold(pandoc.Div, preamble)
268183
tappend(callthm.content, quarto.utils.as_blocks(el.content))
269184
callthm.content:insert(pandoc.RawInline("typst", "] <" .. el.attr.identifier .. ">"))
270-
return callthm
185+
return _quarto.modules.theorems.keep_attributes(callthm, el.attr.attributes)
271186

272187
else
273188
-- order might be nil in the case of an ipynb rendering in

‎src/resources/filters/modules/import_all.lua‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ _quarto.modules = {
2121
scope = require("modules/scope"),
2222
string = require("modules/string"),
2323
tablecolwidths = require("modules/tablecolwidths"),
24+
theorems = require("modules/theorems"),
2425
typst = require("modules/typst"),
2526
listtable = require("modules/listtable"),
2627
tableutils = require("modules/tableutils"),
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
-- theorems.lua
2+
-- Shared Typst theorem/proof setup logic using theorion
3+
-- Copyright (C) 2026 Posit Software, PBC
4+
5+
local typst_theorem_appearance_imported = false
6+
local typst_theorem_like_frames = {}
7+
local typst_simple_renderers = {}
8+
9+
local function appearance()
10+
local val = option("theorem-appearance", "simple")
11+
if type(val) == "table" then
12+
val = pandoc.utils.stringify(val)
13+
end
14+
return val or "simple"
15+
end
16+
17+
local function ensure_appearance_imports()
18+
local val = appearance()
19+
if typst_theorem_appearance_imported then
20+
return val
21+
end
22+
23+
typst_theorem_appearance_imported = true
24+
if val == "fancy" then
25+
quarto.doc.include_text("in-header", [[
26+
#import "@preview/theorion:0.4.1": make-frame, cosmos
27+
#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
28+
]])
29+
quarto.doc.include_text("before-body", [[
30+
#set-primary-border-color(brand-color.at("primary", default: green.darken(30%)))
31+
#set-primary-body-color(brand-color.at("primary", default: green).lighten(90%))
32+
#set-secondary-border-color(brand-color.at("secondary", default: orange))
33+
#set-secondary-body-color(brand-color.at("secondary", default: orange).lighten(90%))
34+
#set-tertiary-border-color(brand-color.at("tertiary", default: blue.darken(30%)))
35+
#set-tertiary-body-color(brand-color.at("tertiary", default: blue).lighten(90%))
36+
]])
37+
elseif val == "clouds" then
38+
quarto.doc.include_text("in-header", [[
39+
#import "@preview/theorion:0.4.1": make-frame, cosmos
40+
#import cosmos.clouds: render-fn as clouds-render
41+
]])
42+
elseif val == "rainbow" then
43+
quarto.doc.include_text("in-header", [[
44+
#import "@preview/theorion:0.4.1": make-frame, cosmos
45+
#import cosmos.rainbow: render-fn as rainbow-render
46+
]])
47+
else
48+
quarto.doc.include_text("in-header", [[
49+
#import "@preview/theorion:0.4.1": make-frame
50+
]])
51+
end
52+
53+
return val
54+
end
55+
56+
-- Defines a simple renderer once. The "theorem" style writes a bold header and
57+
-- an italic body; the "proof" style writes an italic header and an upright
58+
-- body, with the name in brackets, as amsthm and the HTML output do.
59+
local function ensure_simple_render(render_name, style)
60+
if typst_simple_renderers[render_name] then
61+
return
62+
end
63+
64+
typst_simple_renderers[render_name] = true
65+
if style == "proof" then
66+
quarto.doc.include_text("in-header", "#let " .. render_name .. [[(prefix: none, title: "", full-title: auto, body) = {
67+
emph(prefix)
68+
if title != "" and title != none [ (#title)]
69+
[. ]
70+
body
71+
parbreak()
72+
}
73+
]])
74+
return
75+
end
76+
77+
quarto.doc.include_text("in-header", "#let " .. render_name .. [[(prefix: none, title: "", full-title: auto, body) = {
78+
if full-title != "" and full-title != auto and full-title != none {
79+
strong[#full-title.]
80+
h(0.5em)
81+
}
82+
emph(body)
83+
parbreak()
84+
}
85+
]])
86+
end
87+
88+
-- Writes text as a Typst string literal, so that titles from the language
89+
-- files reach the page as written instead of being read as Typst markup.
90+
local function typst_string(text)
91+
return '"' .. text:gsub("\\", "\\\\"):gsub('"', '\\"') .. '"'
92+
end
93+
94+
-- Returns the `render:` argument of make-frame for the theorem appearance.
95+
-- opts.color_scheme: fancy color set (primary, secondary or tertiary)
96+
-- opts.color: clouds and rainbow fill color
97+
-- opts.simple_render, opts.simple_style: name and style ("theorem" or "proof")
98+
-- of the simple renderer
99+
local function render_code(appearance_name, opts)
100+
if appearance_name == "fancy" then
101+
return " render: fancy-box.with(\n" ..
102+
" get-border-color: get-" .. opts.color_scheme .. "-border-color,\n" ..
103+
" get-body-color: get-" .. opts.color_scheme .. "-body-color,\n" ..
104+
" get-symbol: loc => none,\n" ..
105+
" ),\n"
106+
elseif appearance_name == "clouds" then
107+
return " render: clouds-render.with(fill: " .. opts.color .. ".lighten(85%)),\n"
108+
elseif appearance_name == "rainbow" then
109+
return " render: rainbow-render.with(fill: " .. opts.color .. ".darken(20%)),\n"
110+
end
111+
ensure_simple_render(opts.simple_render, opts.simple_style)
112+
return " render: " .. opts.simple_render .. ",\n"
113+
end
114+
115+
-- Returns the Typst code for a frame header: bold, or plain for blocks whose
116+
-- renderer styles the header itself.
117+
local function header_code(title, bold)
118+
if bold then
119+
return 'text(weight: "bold", ' .. typst_string(title) .. ')'
120+
end
121+
return typst_string(title)
122+
end
123+
124+
local function ensure_frame(env_name, title, render_arg, bold)
125+
if typst_theorem_like_frames[env_name] then
126+
return
127+
end
128+
129+
typst_theorem_like_frames[env_name] = true
130+
quarto.doc.include_text("in-header", "#let (" .. env_name .. "-counter, " .. env_name .. "-box, " ..
131+
env_name .. ", show-" .. env_name .. ") = make-frame(\n" ..
132+
" \"" .. env_name .. "\",\n" ..
133+
" " .. header_code(title, bold) .. ",\n" ..
134+
" inherited-levels: theorem-inherited-levels,\n" ..
135+
" numbering: theorem-numbering,\n" ..
136+
render_arg ..
137+
")")
138+
quarto.doc.include_text("in-header", "#show: show-" .. env_name)
139+
end
140+
141+
-- Keeps the block's attributes, such as `style`, on a wrapper div, so the Typst
142+
-- CSS support turns them into a block around the frame, as for other divs.
143+
local function keep_attributes(frame, attributes)
144+
if #attributes > 0 then
145+
return pandoc.Div({frame}, pandoc.Attr("", {}, attributes))
146+
end
147+
return frame
148+
end
149+
150+
return {
151+
ensure_appearance_imports = ensure_appearance_imports,
152+
ensure_frame = ensure_frame,
153+
header_code = header_code,
154+
keep_attributes = keep_attributes,
155+
render_code = render_code,
156+
}

0 commit comments

Comments
 (0)