Skip to content

Commit a7a5b48

Browse files
author
Agah
committed
Resolve MyST cross references for LaTeX output
Pandoc has no concept of MyST reference syntax: it parses `[](#fig-circuit)` as a link with empty content and emits `\hyperref[fig-circuit]{}`, which is invisible, and it prints the `%s` of `[Sec. %s](#clustering)` verbatim. The `\label{}` anchors were never the problem -- headings already get one from pandoc, figures from myst-admonitions.lua -- so only the reference body was missing. Add a filter that builds one from `\ref{}`, classifying each label as figure/table/section/equation from the innermost enclosing `\begin{...}` or `\refstepcounter{...}` plus the native Header/Table/Figure/ Math nodes. It runs after myst-admonitions.lua so that it discovers figure labels in the LaTeX that filter emits, which guarantees every `\ref` written here has a matching `\label`. Explicit link text is left to pandoc's `\hyperref`; a bare reference to a heading renders the heading text, as MyST does. Unresolvable references emit a warning and fall through to `\ref`, so they surface as "??" rather than disappearing. Claude-Session: https://claude.ai/code/session_01YFCLUc1iAr5jjjuUTMMEB8
1 parent b6a01a9 commit a7a5b48

2 files changed

Lines changed: 209 additions & 0 deletions

File tree

data/defaults/neurolibre.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ filters:
1717
path: conditional-archives.lua
1818
- type: lua
1919
path: myst-admonitions.lua
20+
# Must follow myst-admonitions.lua: it finds figure labels in the LaTeX
21+
# that filter emits.
22+
- type: lua
23+
path: myst-references.lua
2024

2125
variables:
2226
# styling options

data/filters/myst-references.lua

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
--- Resolve MyST-style cross references for LaTeX output.
2+
--
3+
-- MyST writes cross references as ordinary markdown links to a target
4+
-- identifier, letting the renderer fill in the visible text:
5+
--
6+
-- see [](#fig-circuit) -> "see Figure 1"
7+
-- see [Sec. %s](#clustering) -> "see Sec. 3.2"
8+
-- see [](#clustering) -> "see Clustering"
9+
--
10+
-- Pandoc has no notion of this, so it emits `\hyperref[fig-circuit]{}` (an
11+
-- invisible link) and prints `%s` verbatim. The `\label{}` anchors themselves
12+
-- already make it into the LaTeX -- headings get one from pandoc, figures from
13+
-- the myst-admonitions filter -- so all that is missing is to give each
14+
-- reference a body built from `\ref{}`.
15+
--
16+
-- This filter must run *after* myst-admonitions.lua: it discovers figure and
17+
-- table labels by inspecting the raw LaTeX that filter emits, which guarantees
18+
-- that every `\ref{}` written here has a matching `\label{}` in the output.
19+
20+
if not FORMAT:match 'latex' then
21+
return {}
22+
end
23+
24+
-- Label kind -> text placed in front of the number, following MyST's defaults.
25+
local kind_prefix = {
26+
figure = 'Figure',
27+
table = 'Table',
28+
section = 'Section',
29+
equation = nil, -- `\eqref` already supplies the parentheses
30+
}
31+
32+
-- LaTeX environments and counters that tell us what a `\label{}` refers to.
33+
local kind_of_env = {
34+
figure = 'figure',
35+
['figure*'] = 'figure',
36+
subfigure = 'figure',
37+
wrapfigure = 'figure',
38+
table = 'table',
39+
['table*'] = 'table',
40+
longtable = 'table',
41+
tabular = 'table',
42+
equation = 'equation',
43+
['equation*'] = 'equation',
44+
align = 'equation',
45+
['align*'] = 'equation',
46+
gather = 'equation',
47+
multline = 'equation',
48+
}
49+
50+
local kinds = {} -- identifier -> 'figure' | 'table' | 'section' | 'equation'
51+
local section_text = {} -- identifier -> inlines of the heading, for bare section refs
52+
53+
--- Classify every `\label{}` in a chunk of raw LaTeX.
54+
--
55+
-- The kind is taken from the innermost `\begin{...}` or `\refstepcounter{...}`
56+
-- preceding the label, which is how LaTeX itself decides what the label
57+
-- captures.
58+
local function collect_raw_labels (text)
59+
local pos = 1
60+
while true do
61+
local start, stop, label = text:find('\\label%s*{(.-)}', pos)
62+
if not start then
63+
break
64+
end
65+
local preceding = text:sub(1, start - 1)
66+
local kind
67+
-- Search backwards for whichever marker comes last.
68+
local best = 0
69+
for at, env in preceding:gmatch '()\\begin%s*{([%w%*]+)}' do
70+
if kind_of_env[env] and at > best then
71+
best, kind = at, kind_of_env[env]
72+
end
73+
end
74+
for at, counter in preceding:gmatch '()\\refstepcounter%s*{([%w]+)}' do
75+
if kind_of_env[counter] and at > best then
76+
best, kind = at, kind_of_env[counter]
77+
end
78+
end
79+
if kind and label ~= '' then
80+
kinds[label] = kind
81+
end
82+
pos = stop + 1
83+
end
84+
end
85+
86+
--- Build the label registry from the whole document.
87+
local function collect (blocks)
88+
pandoc.walk_block(pandoc.Div(blocks), {
89+
traverse = 'topdown',
90+
RawBlock = function (raw)
91+
if raw.format:match 'tex' then
92+
collect_raw_labels(raw.text)
93+
end
94+
end,
95+
RawInline = function (raw)
96+
if raw.format:match 'tex' then
97+
collect_raw_labels(raw.text)
98+
end
99+
end,
100+
Header = function (header)
101+
if header.identifier ~= '' then
102+
kinds[header.identifier] = 'section'
103+
section_text[header.identifier] = header.content
104+
end
105+
end,
106+
Table = function (tbl)
107+
if tbl.identifier ~= '' then
108+
kinds[tbl.identifier] = 'table'
109+
end
110+
end,
111+
Figure = function (fig)
112+
if fig.identifier ~= '' then
113+
kinds[fig.identifier] = 'figure'
114+
end
115+
end,
116+
Math = function (math)
117+
local label = math.text:match '\\label%s*{(.-)}'
118+
if label then
119+
kinds[label] = 'equation'
120+
end
121+
end,
122+
})
123+
end
124+
125+
local function ref_command (label)
126+
local command = kinds[label] == 'equation' and '\\eqref' or '\\ref'
127+
return pandoc.RawInline('latex', command .. '{' .. label .. '}')
128+
end
129+
130+
--- Body for a reference whose link text was left empty.
131+
local function implicit_content (label)
132+
local kind = kinds[label]
133+
if kind == 'section' and section_text[label] then
134+
-- MyST shows the heading itself rather than a number.
135+
return {pandoc.Link(section_text[label], '#' .. label)}
136+
end
137+
local prefix = kind and kind_prefix[kind]
138+
if prefix then
139+
-- Non-breaking space keeps "Figure" and its number on the same line.
140+
return {pandoc.RawInline('latex', prefix .. '~'), ref_command(label)}
141+
end
142+
return {ref_command(label)}
143+
end
144+
145+
--- Substitute `%s` (MyST's number placeholder) in explicit link text.
146+
--
147+
-- Every occurrence is replaced, including inside nested inlines such as
148+
-- emphasis: a `%` that survives into the LaTeX would comment out the rest of
149+
-- the line.
150+
local function substitute_placeholder (inlines, label)
151+
local substituted = false
152+
local function expand (str)
153+
if not str.text:find('%s', 1, true) then
154+
return nil
155+
end
156+
substituted = true
157+
local result = pandoc.Inlines{}
158+
local rest = str.text
159+
while true do
160+
local before, after = rest:match '^(.-)%%s(.*)$'
161+
if not before then
162+
break
163+
end
164+
if before ~= '' then
165+
result:insert(pandoc.Str(before))
166+
end
167+
result:insert(ref_command(label))
168+
rest = after
169+
end
170+
if rest ~= '' then
171+
result:insert(pandoc.Str(rest))
172+
end
173+
return result
174+
end
175+
local expanded = pandoc.walk_inline(pandoc.Span(inlines), {Str = expand})
176+
return substituted and expanded.content or nil
177+
end
178+
179+
local function resolve_link (link)
180+
local label = link.target:match '^#(.+)$'
181+
if not label then
182+
return nil
183+
end
184+
if not kinds[label] then
185+
-- Keep going anyway: an unresolved `\ref` renders as "??", which is a far
186+
-- better signal to the author than silently dropping the reference.
187+
io.stderr:write(
188+
'[WARNING] myst-references: no LaTeX label found for reference "#'
189+
.. label .. '"\n'
190+
)
191+
end
192+
if #link.content == 0 then
193+
return implicit_content(label)
194+
end
195+
return substitute_placeholder(link.content, label)
196+
end
197+
198+
return {
199+
{
200+
Pandoc = function (doc)
201+
collect(doc.blocks)
202+
return doc:walk{Link = resolve_link}
203+
end
204+
}
205+
}

0 commit comments

Comments
 (0)