Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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.

Expand Down
66 changes: 65 additions & 1 deletion src/resources/filters/customnodes/proof.lua
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,36 @@ _quarto.ast.add_handler({
end
})

-- Color mapping for clouds/rainbow themes (per proof type)
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 = _quarto.modules.theorems.render_code(appearance, {
color_scheme = "tertiary",
color = proof_theme_colors[proof_env] or "gray",
simple_render = "simple-proof-render",
simple_style = "proof",
})

-- 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)
local ref = refType(div.identifier)
if ref ~= nil then
Expand Down Expand Up @@ -141,6 +171,40 @@ end, function(proof_tbl)
end
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
el.content = pandoc.Blocks({pandoc.Para({pandoc.Str '\u{a0}'})})
end
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
-- 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.header_code(envTitle(proof.env, proof.title), bold)))
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", ']'))
end
preamble.content:insert(pandoc.RawInline("typst", ")["))
local callproof = make_scaffold(pandoc.Div, preamble)
Comment thread
mcanouil marked this conversation as resolved.
Resolved
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 _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)))
Expand Down Expand Up @@ -176,4 +240,4 @@ end, function(proof_tbl)

return el

end)
end)
119 changes: 17 additions & 102 deletions src/resources/filters/customnodes/theorem.lua
Original file line number Diff line number Diff line change
Expand Up @@ -93,115 +93,30 @@ _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()
}
]])
end
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)
local appearance = _quarto.modules.theorems.ensure_appearance_imports()
local theorem_type = theorem_types[reftype]
local title = titleString(reftype, theorem_type.title)
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",
simple_style = "theorem",
})

_quarto.modules.theorems.ensure_frame(theorem_type.env, title, render_code, true)
end


Expand Down Expand Up @@ -267,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
Expand Down
1 change: 1 addition & 0 deletions src/resources/filters/modules/import_all.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
156 changes: 156 additions & 0 deletions src/resources/filters/modules/theorems.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
-- theorems.lua
-- Shared Typst theorem/proof setup logic using theorion
-- Copyright (C) 2026 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

-- 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
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) = {
if full-title != "" and full-title != auto and full-title != none {
strong[#full-title.]
h(0.5em)
}
emph(body)
parbreak()
}
]])
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.simple_style: name and style ("theorem" or "proof")
-- of the simple renderer
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_name == "clouds" then
return " render: clouds-render.with(fill: " .. opts.color .. ".lighten(85%)),\n"
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)
return " render: " .. opts.simple_render .. ",\n"
end

-- 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_arg, bold)
if typst_theorem_like_frames[env_name] then
return
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" ..
" " .. header_code(title, bold) .. ",\n" ..
" inherited-levels: theorem-inherited-levels,\n" ..
" numbering: theorem-numbering,\n" ..
render_arg ..
")")
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,
}
Loading
Loading