11-- equations.lua
2- -- Copyright (C) 2020-2022 Posit Software, PBC
2+ -- Copyright (C) 2020-2026 Posit Software, PBC
33
44-- process all equations
55function equations ()
@@ -21,67 +21,58 @@ function process_equations(blockEl)
2121
2222 local mathInlines = nil
2323 local targetInlines = pandoc .Inlines {}
24+ local skipUntil = 0
2425
2526 for i , el in ipairs (inlines ) do
26-
2727 -- see if we need special handling for pending math, if
2828 -- we do then track whether we should still process the
2929 -- inline at the end of the loop
3030 local processInline = true
31+
32+ -- Skip elements that were consumed as part of a multi-element attribute block
33+ if i <= skipUntil then
34+ processInline = false
35+ goto continue
36+ end
3137 if mathInlines then
3238 if el .t == " Space" then
3339 mathInlines :insert (el )
3440 processInline = false
35- elseif el .t == " Str" and refLabel (" eq" , el ) then
36-
37- -- add to the index
38- local label = refLabel (" eq" , el )
39- local order = indexNextOrder (" eq" )
40- indexAddEntry (label , nil , order )
41-
42- -- get the equation
43- local eq = mathInlines [1 ]
44-
45- -- write equation
46- if _quarto .format .isLatexOutput () then
47- targetInlines :insert (pandoc .RawInline (" latex" , " \\ begin{equation}" ))
48- targetInlines :insert (pandoc .Span (pandoc .RawInline (" latex" , eq .text ), pandoc .Attr (label )))
49-
50- -- Pandoc 3.1.7 started outputting a shadow section with a label as a link target
51- -- which would result in two identical labels being emitted.
52- -- https://github.com/jgm/pandoc/issues/9045
53- -- https://github.com/lierdakil/pandoc-crossref/issues/402
54- targetInlines :insert (pandoc .RawInline (" latex" , " \\ end{equation}" ))
55-
56- elseif _quarto .format .isTypstOutput () then
57- local is_block = eq .mathtype == " DisplayMath" and " true" or " false"
58- targetInlines :insert (pandoc .RawInline (" typst" ,
59- " #math.equation(block: " .. is_block .. " , numbering: \" (1)\" , " ..
60- " [ " ))
61- targetInlines :insert (eq )
62- targetInlines :insert (pandoc .RawInline (" typst" , " ])<" .. label .. " >" ))
63- else
64- local eqNumber = eqQquad
65- local mathMethod = param (" html-math-method" , nil )
66- if type (mathMethod ) == " table" and mathMethod [" method" ] then
67- mathMethod = mathMethod [" method" ]
68- end
69- if _quarto .format .isHtmlOutput () and (mathMethod == " mathjax" or mathMethod == " katex" ) then
70- eqNumber = eqTag
41+ -- Check "starts with" not complete match: Pandoc splits {#eq-label alt="..."} across elements
42+ elseif el .t == " Str" and el .text :match (" ^{#eq%-" ) then
43+ -- Collect attribute block: {#eq-label alt="..."} may span multiple elements
44+ local attrText , consumed = collectAttrBlock (inlines , i )
45+
46+ if attrText then
47+ -- Parse to extract label and optional attributes (e.g., alt for Typst)
48+ local label , attributes = parseRefAttr (attrText )
49+ if not label then
50+ local _ , extracted = extractRefLabel (" eq" , attrText )
51+ label = extracted
7152 end
72- eq .text = eq .text .. " " .. eqNumber (inlinesToString (numberOption (" eq" , order )))
73- local span = pandoc .Span (eq , pandoc .Attr (label ))
74- targetInlines :insert (span )
75- end
7653
77- -- reset state
78- mathInlines = nil
79- processInline = false
54+ local order = indexNextOrder (" eq" )
55+ indexAddEntry (label , nil , order )
56+
57+ local eq = mathInlines [1 ]
58+ local alt = attributes and attributes [" alt" ] or nil
59+ local eqInlines = renderEquation (eq , label , alt , order )
60+ targetInlines :extend (eqInlines )
61+
62+ -- Skip consumed elements and reset state
63+ skipUntil = i + consumed - 1
64+ mathInlines = nil
65+ processInline = false
66+ else
67+ targetInlines :extend (mathInlines )
68+ mathInlines = nil
69+ end
8070 else
8171 targetInlines :extend (mathInlines )
8272 mathInlines = nil
8373 end
8474 end
75+ :: continue::
8576
8677 -- process the inline unless it was already taken care of above
8778 if processInline then
@@ -103,7 +94,54 @@ function process_equations(blockEl)
10394 -- return the processed list
10495 blockEl .content = targetInlines
10596 return blockEl
106-
97+
98+ end
99+
100+ -- Render equation output for all formats.
101+ -- The alt parameter is only used for Typst output (accessibility).
102+ function renderEquation (eq , label , alt , order )
103+ local result = pandoc .Inlines {}
104+
105+ if _quarto .format .isLatexOutput () then
106+ result :insert (pandoc .RawInline (" latex" , " \\ begin{equation}" ))
107+ result :insert (pandoc .Span (pandoc .RawInline (" latex" , eq .text ), pandoc .Attr (label )))
108+
109+ -- Pandoc 3.1.7 started outputting a shadow section with a label as a link target
110+ -- which would result in two identical labels being emitted.
111+ -- https://github.com/jgm/pandoc/issues/9045
112+ -- https://github.com/lierdakil/pandoc-crossref/issues/402
113+ result :insert (pandoc .RawInline (" latex" , " \\ end{equation}" ))
114+
115+ elseif _quarto .format .isTypstOutput () then
116+ local is_block = eq .mathtype == " DisplayMath" and " true" or " false"
117+ -- Escape quotes in alt text for Typst string literal
118+ -- First normalize curly quotes to straight quotes (Pandoc may apply smart quotes)
119+ local alt_param = " "
120+ if alt then
121+ local escaped_alt = alt :gsub (" “" , ' "' ):gsub (" ”" , ' "' )
122+ escaped_alt = escaped_alt :gsub (" ‘" , " '" ):gsub (" ’" , " '" )
123+ escaped_alt = escaped_alt :gsub (' "' , ' \\ "' )
124+ alt_param = " , alt: \" " .. escaped_alt .. " \" "
125+ end
126+ result :insert (pandoc .RawInline (" typst" ,
127+ " #math.equation(block: " .. is_block .. " , numbering: \" (1)\" " .. alt_param .. " , [ " ))
128+ result :insert (eq )
129+ result :insert (pandoc .RawInline (" typst" , " ])<" .. label .. " >" ))
130+
131+ else
132+ local eqNumber = eqQquad
133+ local mathMethod = param (" html-math-method" , nil )
134+ if type (mathMethod ) == " table" and mathMethod [" method" ] then
135+ mathMethod = mathMethod [" method" ]
136+ end
137+ if _quarto .format .isHtmlOutput () and (mathMethod == " mathjax" or mathMethod == " katex" ) then
138+ eqNumber = eqTag
139+ end
140+ eq .text = eq .text .. " " .. eqNumber (inlinesToString (numberOption (" eq" , order )))
141+ result :insert (pandoc .Span (eq , pandoc .Attr (label )))
142+ end
143+
144+ return result
107145end
108146
109147function eqTag (eq )
117155function isDisplayMath (el )
118156 return el .t == " Math" and el .mathtype == " DisplayMath"
119157end
158+
159+
160+ -- Collect a complete attribute block from inline elements.
161+ --
162+ -- Pandoc tokenises `{#eq-label alt="description"}` into multiple elements:
163+ -- Str "{#eq-label", Space, Str "alt=", Quoted [...], Str "}"
164+ --
165+ -- This function reassembles these elements into a single string for parseRefAttr().
166+ -- Quoted elements are reconstructed with escaped inner quotes to preserve the
167+ -- original attribute syntax.
168+ --
169+ -- Returns: collected text (string), number of elements consumed (number)
170+ function collectAttrBlock (inlines , startIndex )
171+ local first = inlines [startIndex ]
172+ if not first or first .t ~= " Str" then
173+ return nil , 0
174+ end
175+
176+ local collected = first .text
177+ local consumed = 1
178+
179+ if collected :match (" }$" ) then
180+ return collected , consumed
181+ end
182+
183+ for j = startIndex + 1 , # inlines do
184+ local el = inlines [j ]
185+ if el .t == " Str" then
186+ collected = collected .. el .text
187+ consumed = consumed + 1
188+ elseif el .t == " Space" then
189+ collected = collected .. " "
190+ consumed = consumed + 1
191+ elseif el .t == " Quoted" then
192+ local quote = el .quotetype == " DoubleQuote" and ' "' or " '"
193+ local content = pandoc .utils .stringify (el .content )
194+ if el .quotetype == " DoubleQuote" then
195+ content = content :gsub (' "' , ' \\ "' )
196+ else
197+ content = content :gsub (" '" , " \\ '" )
198+ end
199+ collected = collected .. quote .. content .. quote
200+ consumed = consumed + 1
201+ else
202+ break
203+ end
204+ if collected :match (" }$" ) then
205+ break
206+ end
207+ end
208+
209+ if collected :match (" ^{#eq%-[^}]+}$" ) then
210+ return collected , consumed
211+ end
212+
213+ return nil , 0
214+ end
215+
216+
217+ -- Parse a Pandoc attribute block string into identifier and attributes.
218+ --
219+ -- Uses pandoc.read() with a dummy header to leverage Pandoc's native attribute
220+ -- parser, avoiding fragile regex-based parsing.
221+ --
222+ -- Single-quoted attributes (e.g., alt='text') must be converted to double quotes
223+ -- because Pandoc's attribute syntax only supports double-quoted values.
224+ -- The conversion uses a three-step process:
225+ -- 1. Protect escaped single quotes (\') with a placeholder.
226+ -- 2. Convert key='value' to key="value", escaping any internal double quotes.
227+ -- 3. Restore any remaining placeholders to literal single quotes.
228+ --
229+ -- Returns: identifier (string), attributes (table)
230+ function parseRefAttr (text )
231+ if not text then return nil , nil end
232+
233+ local placeholder = " \x00 ESC_SQUOTE\x00 "
234+ text = text :gsub (" \\ '" , placeholder )
235+ text = text :gsub (" (%w+)='([^']*)'" , function (key , value )
236+ value = value :gsub (placeholder , " '" )
237+ value = value :gsub (' "' , ' \\ "' )
238+ return key .. ' ="' .. value .. ' "'
239+ end )
240+ text = text :gsub (placeholder , " '" )
241+
242+ -- Normalise spaces around = in attributes (alt = "value" -> alt="value")
243+ text = text :gsub (" (%w+)%s*=%s*(['\" ])" , " %1=%2" )
244+
245+ local parsed = pandoc .read (" ## " .. text , " markdown" )
246+ if parsed and parsed .blocks [1 ] and parsed .blocks [1 ].attr then
247+ local attr = parsed .blocks [1 ].attr
248+ return attr .identifier , attr .attributes
249+ end
250+ return nil , nil
251+ end
0 commit comments