typsphinx 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- typsphinx/__init__.py +58 -0
- typsphinx/builder.py +322 -0
- typsphinx/pdf.py +192 -0
- typsphinx/template_engine.py +396 -0
- typsphinx/templates/base.typ +81 -0
- typsphinx/translator.py +1583 -0
- typsphinx/writer.py +144 -0
- typsphinx-0.3.0.dist-info/METADATA +355 -0
- typsphinx-0.3.0.dist-info/RECORD +13 -0
- typsphinx-0.3.0.dist-info/WHEEL +5 -0
- typsphinx-0.3.0.dist-info/entry_points.txt +3 -0
- typsphinx-0.3.0.dist-info/licenses/LICENSE +21 -0
- typsphinx-0.3.0.dist-info/top_level.txt +1 -0
typsphinx/translator.py
ADDED
|
@@ -0,0 +1,1583 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Typst translator for docutils nodes.
|
|
3
|
+
|
|
4
|
+
This module implements the TypstTranslator class, which translates docutils
|
|
5
|
+
nodes to Typst markup.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from docutils import nodes
|
|
12
|
+
from sphinx import addnodes
|
|
13
|
+
from sphinx.util import logging
|
|
14
|
+
from sphinx.util.docutils import SphinxTranslator
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TypstTranslator(SphinxTranslator):
|
|
20
|
+
"""
|
|
21
|
+
Translator class that converts docutils nodes to Typst markup.
|
|
22
|
+
|
|
23
|
+
This translator visits nodes in the document tree and generates
|
|
24
|
+
corresponding Typst markup.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, document: nodes.document, builder: Any) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Initialize the translator.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
document: The docutils document to translate
|
|
33
|
+
builder: The Sphinx builder instance
|
|
34
|
+
"""
|
|
35
|
+
super().__init__(document, builder)
|
|
36
|
+
self.builder = builder
|
|
37
|
+
self.body = []
|
|
38
|
+
|
|
39
|
+
# State management variables
|
|
40
|
+
self.section_level = 0
|
|
41
|
+
self.in_figure = False
|
|
42
|
+
self.in_table = False
|
|
43
|
+
self.in_caption = False
|
|
44
|
+
self.list_stack = [] # Track list nesting: 'bullet' or 'enumerated'
|
|
45
|
+
|
|
46
|
+
# Figure-specific state
|
|
47
|
+
self.figure_content = []
|
|
48
|
+
self.figure_caption = ""
|
|
49
|
+
|
|
50
|
+
# Code block container state (Issue #20)
|
|
51
|
+
self.in_captioned_code_block = False
|
|
52
|
+
self.code_block_caption = ""
|
|
53
|
+
self.code_block_label = ""
|
|
54
|
+
|
|
55
|
+
def astext(self) -> str:
|
|
56
|
+
"""
|
|
57
|
+
Return the translated text as a string.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The translated Typst markup
|
|
61
|
+
"""
|
|
62
|
+
return "".join(self.body)
|
|
63
|
+
|
|
64
|
+
def add_text(self, text: str) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Add text to the output body or table cell content.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
text: The text to add
|
|
70
|
+
"""
|
|
71
|
+
if (
|
|
72
|
+
hasattr(self, "in_table")
|
|
73
|
+
and self.in_table
|
|
74
|
+
and hasattr(self, "table_cell_content")
|
|
75
|
+
):
|
|
76
|
+
self.table_cell_content.append(text)
|
|
77
|
+
else:
|
|
78
|
+
self.body.append(text)
|
|
79
|
+
|
|
80
|
+
def visit_document(self, node: nodes.document) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Visit a document node.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
node: The document node
|
|
86
|
+
"""
|
|
87
|
+
# Document root doesn't need special markup
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
def depart_document(self, node: nodes.document) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Depart a document node.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
node: The document node
|
|
96
|
+
"""
|
|
97
|
+
# Document root doesn't need closing
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
def visit_section(self, node: nodes.section) -> None:
|
|
101
|
+
"""
|
|
102
|
+
Visit a section node.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
node: The section node
|
|
106
|
+
"""
|
|
107
|
+
# Increment section level
|
|
108
|
+
self.section_level += 1
|
|
109
|
+
|
|
110
|
+
def depart_section(self, node: nodes.section) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Depart a section node.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
node: The section node
|
|
116
|
+
"""
|
|
117
|
+
# Decrement section level
|
|
118
|
+
self.section_level -= 1
|
|
119
|
+
# Add a newline after sections
|
|
120
|
+
self.add_text("\n")
|
|
121
|
+
|
|
122
|
+
def visit_title(self, node: nodes.title) -> None:
|
|
123
|
+
"""
|
|
124
|
+
Visit a title node.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
node: The title node
|
|
128
|
+
"""
|
|
129
|
+
# Typst heading syntax: = Title, == Title, === Title, etc.
|
|
130
|
+
# Use section_level to determine heading level
|
|
131
|
+
heading_prefix = "=" * self.section_level
|
|
132
|
+
self.add_text(f"{heading_prefix} ")
|
|
133
|
+
|
|
134
|
+
def depart_title(self, node: nodes.title) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Depart a title node.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
node: The title node
|
|
140
|
+
"""
|
|
141
|
+
self.add_text("\n\n")
|
|
142
|
+
|
|
143
|
+
def visit_subtitle(self, node: nodes.subtitle) -> None:
|
|
144
|
+
"""
|
|
145
|
+
Visit a subtitle node.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
node: The subtitle node
|
|
149
|
+
"""
|
|
150
|
+
# Typst subtitle syntax: use emphasized text for subtitle
|
|
151
|
+
self.add_text("_")
|
|
152
|
+
|
|
153
|
+
def depart_subtitle(self, node: nodes.subtitle) -> None:
|
|
154
|
+
"""
|
|
155
|
+
Depart a subtitle node.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
node: The subtitle node
|
|
159
|
+
"""
|
|
160
|
+
self.add_text("_\n\n")
|
|
161
|
+
|
|
162
|
+
def visit_compound(self, node: nodes.compound) -> None:
|
|
163
|
+
"""
|
|
164
|
+
Visit a compound node.
|
|
165
|
+
|
|
166
|
+
Compound nodes are containers that group related content.
|
|
167
|
+
They are often used to wrap toctree directives.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
node: The compound node
|
|
171
|
+
"""
|
|
172
|
+
# Compound nodes are just containers, process their children
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
def depart_compound(self, node: nodes.compound) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Depart a compound node.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
node: The compound node
|
|
181
|
+
"""
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
def visit_container(self, node: nodes.container) -> None:
|
|
185
|
+
"""
|
|
186
|
+
Visit a container node.
|
|
187
|
+
|
|
188
|
+
Handle Sphinx-generated containers, particularly literal-block-wrapper
|
|
189
|
+
for captioned code blocks (Issue #20).
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
node: The container node
|
|
193
|
+
"""
|
|
194
|
+
# Check if this is a literal-block-wrapper (captioned code block)
|
|
195
|
+
if "literal-block-wrapper" in node.get("classes", []):
|
|
196
|
+
self.in_captioned_code_block = True
|
|
197
|
+
# Caption and literal_block children will be processed separately
|
|
198
|
+
# We need to extract caption text first
|
|
199
|
+
for child in node.children:
|
|
200
|
+
if isinstance(child, nodes.caption):
|
|
201
|
+
self.code_block_caption = child.astext()
|
|
202
|
+
elif isinstance(child, nodes.literal_block):
|
|
203
|
+
# Extract label from :name: option
|
|
204
|
+
if child.get("names"):
|
|
205
|
+
self.code_block_label = child.get("names")[0]
|
|
206
|
+
# Other container types: just process children
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
def depart_container(self, node: nodes.container) -> None:
|
|
210
|
+
"""
|
|
211
|
+
Depart a container node.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
node: The container node
|
|
215
|
+
"""
|
|
216
|
+
# Reset state after literal-block-wrapper
|
|
217
|
+
if "literal-block-wrapper" in node.get("classes", []):
|
|
218
|
+
self.in_captioned_code_block = False
|
|
219
|
+
self.code_block_caption = ""
|
|
220
|
+
self.code_block_label = ""
|
|
221
|
+
|
|
222
|
+
def visit_paragraph(self, node: nodes.paragraph) -> None:
|
|
223
|
+
"""
|
|
224
|
+
Visit a paragraph node.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
node: The paragraph node
|
|
228
|
+
"""
|
|
229
|
+
# Paragraphs don't need special markup in Typst
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
def depart_paragraph(self, node: nodes.paragraph) -> None:
|
|
233
|
+
"""
|
|
234
|
+
Depart a paragraph node.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
node: The paragraph node
|
|
238
|
+
"""
|
|
239
|
+
# Add double newline after paragraphs
|
|
240
|
+
self.add_text("\n\n")
|
|
241
|
+
|
|
242
|
+
def visit_comment(self, node: nodes.comment) -> None:
|
|
243
|
+
"""
|
|
244
|
+
Visit a comment node.
|
|
245
|
+
|
|
246
|
+
Comments are skipped entirely in Typst output as they are meant
|
|
247
|
+
for source-level documentation only.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
node: The comment node
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
nodes.SkipNode: Always raised to skip the comment
|
|
254
|
+
"""
|
|
255
|
+
raise nodes.SkipNode
|
|
256
|
+
|
|
257
|
+
def depart_comment(self, node: nodes.comment) -> None:
|
|
258
|
+
"""
|
|
259
|
+
Depart a comment node.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
node: The comment node
|
|
263
|
+
|
|
264
|
+
Note:
|
|
265
|
+
This method is not called when SkipNode is raised in visit_comment.
|
|
266
|
+
"""
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
def visit_raw(self, node: nodes.raw) -> None:
|
|
270
|
+
"""
|
|
271
|
+
Visit a raw node.
|
|
272
|
+
|
|
273
|
+
Pass through content if format is 'typst', otherwise skip.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
node: The raw node
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
nodes.SkipNode: When format is not 'typst'
|
|
280
|
+
"""
|
|
281
|
+
format_name = node.get("format", "").lower()
|
|
282
|
+
|
|
283
|
+
if format_name == "typst":
|
|
284
|
+
# Output the raw Typst content directly
|
|
285
|
+
content = node.astext()
|
|
286
|
+
if content: # Only add non-empty content
|
|
287
|
+
self.add_text(content)
|
|
288
|
+
self.add_text("\n\n")
|
|
289
|
+
raise nodes.SkipNode
|
|
290
|
+
else:
|
|
291
|
+
# Skip content for other formats
|
|
292
|
+
logger.debug(f"Skipping raw node with format: {format_name}")
|
|
293
|
+
raise nodes.SkipNode
|
|
294
|
+
|
|
295
|
+
def depart_raw(self, node: nodes.raw) -> None:
|
|
296
|
+
"""
|
|
297
|
+
Depart a raw node.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
node: The raw node
|
|
301
|
+
|
|
302
|
+
Note:
|
|
303
|
+
This method is not called when SkipNode is raised in visit_raw.
|
|
304
|
+
"""
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
def visit_Text(self, node: nodes.Text) -> None:
|
|
308
|
+
"""
|
|
309
|
+
Visit a text node.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
node: The text node
|
|
313
|
+
"""
|
|
314
|
+
# Add the text content
|
|
315
|
+
self.add_text(node.astext())
|
|
316
|
+
|
|
317
|
+
def depart_Text(self, node: nodes.Text) -> None:
|
|
318
|
+
"""
|
|
319
|
+
Depart a text node.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
node: The text node
|
|
323
|
+
"""
|
|
324
|
+
# Text nodes don't need closing
|
|
325
|
+
pass
|
|
326
|
+
|
|
327
|
+
def visit_emphasis(self, node: nodes.emphasis) -> None:
|
|
328
|
+
"""
|
|
329
|
+
Visit an emphasis (italic) node.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
node: The emphasis node
|
|
333
|
+
"""
|
|
334
|
+
# Typst italic syntax: _text_
|
|
335
|
+
self.add_text("_")
|
|
336
|
+
|
|
337
|
+
def depart_emphasis(self, node: nodes.emphasis) -> None:
|
|
338
|
+
"""
|
|
339
|
+
Depart an emphasis (italic) node.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
node: The emphasis node
|
|
343
|
+
"""
|
|
344
|
+
self.add_text("_")
|
|
345
|
+
|
|
346
|
+
def visit_strong(self, node: nodes.strong) -> None:
|
|
347
|
+
"""
|
|
348
|
+
Visit a strong (bold) node.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
node: The strong node
|
|
352
|
+
"""
|
|
353
|
+
# Typst bold syntax: *text*
|
|
354
|
+
self.add_text("*")
|
|
355
|
+
|
|
356
|
+
def depart_strong(self, node: nodes.strong) -> None:
|
|
357
|
+
"""
|
|
358
|
+
Depart a strong (bold) node.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
node: The strong node
|
|
362
|
+
"""
|
|
363
|
+
self.add_text("*")
|
|
364
|
+
|
|
365
|
+
def visit_literal(self, node: nodes.literal) -> None:
|
|
366
|
+
"""
|
|
367
|
+
Visit a literal (inline code) node.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
node: The literal node
|
|
371
|
+
"""
|
|
372
|
+
# Typst inline code syntax: `code`
|
|
373
|
+
self.add_text("`")
|
|
374
|
+
|
|
375
|
+
def depart_literal(self, node: nodes.literal) -> None:
|
|
376
|
+
"""
|
|
377
|
+
Depart a literal (inline code) node.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
node: The literal node
|
|
381
|
+
"""
|
|
382
|
+
self.add_text("`")
|
|
383
|
+
|
|
384
|
+
def visit_subscript(self, node: nodes.subscript) -> None:
|
|
385
|
+
"""
|
|
386
|
+
Visit a subscript node.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
node: The subscript node
|
|
390
|
+
"""
|
|
391
|
+
# Typst subscript syntax: #sub[text]
|
|
392
|
+
self.add_text("#sub[")
|
|
393
|
+
|
|
394
|
+
def depart_subscript(self, node: nodes.subscript) -> None:
|
|
395
|
+
"""
|
|
396
|
+
Depart a subscript node.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
node: The subscript node
|
|
400
|
+
"""
|
|
401
|
+
self.add_text("]")
|
|
402
|
+
|
|
403
|
+
def visit_superscript(self, node: nodes.superscript) -> None:
|
|
404
|
+
"""
|
|
405
|
+
Visit a superscript node.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
node: The superscript node
|
|
409
|
+
"""
|
|
410
|
+
# Typst superscript syntax: #super[text]
|
|
411
|
+
self.add_text("#super[")
|
|
412
|
+
|
|
413
|
+
def depart_superscript(self, node: nodes.superscript) -> None:
|
|
414
|
+
"""
|
|
415
|
+
Depart a superscript node.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
node: The superscript node
|
|
419
|
+
"""
|
|
420
|
+
self.add_text("]")
|
|
421
|
+
|
|
422
|
+
def visit_bullet_list(self, node: nodes.bullet_list) -> None:
|
|
423
|
+
"""
|
|
424
|
+
Visit a bullet list node.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
node: The bullet list node
|
|
428
|
+
"""
|
|
429
|
+
self.list_stack.append("bullet")
|
|
430
|
+
|
|
431
|
+
def depart_bullet_list(self, node: nodes.bullet_list) -> None:
|
|
432
|
+
"""
|
|
433
|
+
Depart a bullet list node.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
node: The bullet list node
|
|
437
|
+
"""
|
|
438
|
+
self.list_stack.pop()
|
|
439
|
+
self.add_text("\n")
|
|
440
|
+
|
|
441
|
+
def visit_enumerated_list(self, node: nodes.enumerated_list) -> None:
|
|
442
|
+
"""
|
|
443
|
+
Visit an enumerated (numbered) list node.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
node: The enumerated list node
|
|
447
|
+
"""
|
|
448
|
+
self.list_stack.append("enumerated")
|
|
449
|
+
|
|
450
|
+
def depart_enumerated_list(self, node: nodes.enumerated_list) -> None:
|
|
451
|
+
"""
|
|
452
|
+
Depart an enumerated (numbered) list node.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
node: The enumerated list node
|
|
456
|
+
"""
|
|
457
|
+
self.list_stack.pop()
|
|
458
|
+
self.add_text("\n")
|
|
459
|
+
|
|
460
|
+
def visit_list_item(self, node: nodes.list_item) -> None:
|
|
461
|
+
"""
|
|
462
|
+
Visit a list item node.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
node: The list item node
|
|
466
|
+
"""
|
|
467
|
+
# Calculate indentation based on nesting level
|
|
468
|
+
indent = " " * (len(self.list_stack) - 1)
|
|
469
|
+
|
|
470
|
+
# Determine list marker based on list type
|
|
471
|
+
if self.list_stack and self.list_stack[-1] == "bullet":
|
|
472
|
+
self.add_text(f"{indent}- ")
|
|
473
|
+
elif self.list_stack and self.list_stack[-1] == "enumerated":
|
|
474
|
+
self.add_text(f"{indent}+ ")
|
|
475
|
+
|
|
476
|
+
def depart_list_item(self, node: nodes.list_item) -> None:
|
|
477
|
+
"""
|
|
478
|
+
Depart a list item node.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
node: The list item node
|
|
482
|
+
"""
|
|
483
|
+
self.add_text("\n")
|
|
484
|
+
|
|
485
|
+
def visit_literal_block(self, node: nodes.literal_block) -> None:
|
|
486
|
+
"""
|
|
487
|
+
Visit a literal block (code block) node.
|
|
488
|
+
|
|
489
|
+
Implements Task 4.2.2: codly forced usage with #codly-range() for highlighted lines
|
|
490
|
+
Design 3.5: All code blocks use codly, with #codly-range() for highlights
|
|
491
|
+
Requirements 7.3, 7.4: Support line numbers and highlighted lines
|
|
492
|
+
Issue #20: Support :linenos:, :caption:, and :name: options
|
|
493
|
+
Issue #31: Support :lineno-start: and :dedent: options
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
node: The literal block node
|
|
497
|
+
"""
|
|
498
|
+
# Issue #20: Handle captioned code blocks
|
|
499
|
+
# If we're in a captioned code block (literal-block-wrapper container),
|
|
500
|
+
# wrap the code block in a #figure()
|
|
501
|
+
if self.in_captioned_code_block and self.code_block_caption:
|
|
502
|
+
# Escape special characters in caption
|
|
503
|
+
escaped_caption = self.code_block_caption
|
|
504
|
+
# Start figure with caption (will add closing bracket in depart)
|
|
505
|
+
self.add_text(f"#figure(caption: [{escaped_caption}])[\n")
|
|
506
|
+
|
|
507
|
+
# Check for :linenos: option (Issue #20)
|
|
508
|
+
# If linenos is not set or False, disable line numbers in codly
|
|
509
|
+
linenos = node.get("linenos", False)
|
|
510
|
+
if not linenos:
|
|
511
|
+
self.add_text("#codly(number-format: none)\n")
|
|
512
|
+
|
|
513
|
+
# Extract highlight_args if present (Task 4.2.2)
|
|
514
|
+
highlight_args = node.get("highlight_args", {})
|
|
515
|
+
hl_lines = highlight_args.get("hl_lines", [])
|
|
516
|
+
|
|
517
|
+
# Issue #31: Support :lineno-start: option
|
|
518
|
+
# Sphinx stores lineno-start in highlight_args['linenostart']
|
|
519
|
+
lineno_start = highlight_args.get("linenostart")
|
|
520
|
+
if linenos and lineno_start is not None:
|
|
521
|
+
self.add_text(f"#codly(start: {lineno_start})\n")
|
|
522
|
+
|
|
523
|
+
# Generate #codly-range() if highlight lines are specified
|
|
524
|
+
if hl_lines:
|
|
525
|
+
# Convert list of line numbers to Typst array format
|
|
526
|
+
# Example: [2, 3] -> #codly-range(highlight: (2, 3))
|
|
527
|
+
# Example: [2, 4, 5, 6] -> #codly-range(highlight: (2, 4, 5, 6))
|
|
528
|
+
highlight_str = ", ".join(str(line) for line in hl_lines)
|
|
529
|
+
self.add_text(f"#codly-range(highlight: ({highlight_str}))\n")
|
|
530
|
+
|
|
531
|
+
# Typst code block syntax: ```language\ncode\n```
|
|
532
|
+
# Extract language if specified
|
|
533
|
+
language = node.get("language", "")
|
|
534
|
+
if language:
|
|
535
|
+
self.add_text(f"```{language}\n")
|
|
536
|
+
else:
|
|
537
|
+
self.add_text("```\n")
|
|
538
|
+
|
|
539
|
+
def depart_literal_block(self, node: nodes.literal_block) -> None:
|
|
540
|
+
"""
|
|
541
|
+
Depart a literal block (code block) node.
|
|
542
|
+
|
|
543
|
+
Issue #20: Handle closing figure bracket and labels.
|
|
544
|
+
|
|
545
|
+
Args:
|
|
546
|
+
node: The literal block node
|
|
547
|
+
"""
|
|
548
|
+
# Close code block
|
|
549
|
+
self.add_text("\n```\n")
|
|
550
|
+
|
|
551
|
+
# Issue #20: Close figure wrapper if we're in a captioned code block
|
|
552
|
+
if self.in_captioned_code_block and self.code_block_caption:
|
|
553
|
+
# Close the figure's trailing content block with ]
|
|
554
|
+
self.add_text("]")
|
|
555
|
+
# Add label if present
|
|
556
|
+
if self.code_block_label:
|
|
557
|
+
self.add_text(f" <{self.code_block_label}>")
|
|
558
|
+
self.add_text("\n\n")
|
|
559
|
+
elif node.get("names"):
|
|
560
|
+
# Handle :name: option without :caption: - just add label after code block
|
|
561
|
+
label = node.get("names")[0]
|
|
562
|
+
self.add_text(f" <{label}>\n\n")
|
|
563
|
+
else:
|
|
564
|
+
# Normal code block - just add spacing
|
|
565
|
+
self.add_text("\n")
|
|
566
|
+
|
|
567
|
+
def visit_definition_list(self, node: nodes.definition_list) -> None:
|
|
568
|
+
"""
|
|
569
|
+
Visit a definition list node.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
node: The definition list node
|
|
573
|
+
"""
|
|
574
|
+
# Definition lists don't need special opening markup in Typst
|
|
575
|
+
pass
|
|
576
|
+
|
|
577
|
+
def depart_definition_list(self, node: nodes.definition_list) -> None:
|
|
578
|
+
"""
|
|
579
|
+
Depart a definition list node.
|
|
580
|
+
|
|
581
|
+
Args:
|
|
582
|
+
node: The definition list node
|
|
583
|
+
"""
|
|
584
|
+
# Add newline after definition list
|
|
585
|
+
self.add_text("\n")
|
|
586
|
+
|
|
587
|
+
def visit_definition_list_item(self, node: nodes.definition_list_item) -> None:
|
|
588
|
+
"""
|
|
589
|
+
Visit a definition list item node.
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
node: The definition list item node
|
|
593
|
+
"""
|
|
594
|
+
# Definition list items don't need special markup
|
|
595
|
+
pass
|
|
596
|
+
|
|
597
|
+
def depart_definition_list_item(self, node: nodes.definition_list_item) -> None:
|
|
598
|
+
"""
|
|
599
|
+
Depart a definition list item node.
|
|
600
|
+
|
|
601
|
+
Args:
|
|
602
|
+
node: The definition list item node
|
|
603
|
+
"""
|
|
604
|
+
# Definition list items don't need closing
|
|
605
|
+
pass
|
|
606
|
+
|
|
607
|
+
def visit_term(self, node: nodes.term) -> None:
|
|
608
|
+
"""
|
|
609
|
+
Visit a term (definition list term) node.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
node: The term node
|
|
613
|
+
"""
|
|
614
|
+
# Typst definition list syntax: / term: definition
|
|
615
|
+
self.add_text("/ ")
|
|
616
|
+
|
|
617
|
+
def depart_term(self, node: nodes.term) -> None:
|
|
618
|
+
"""
|
|
619
|
+
Depart a term (definition list term) node.
|
|
620
|
+
|
|
621
|
+
Args:
|
|
622
|
+
node: The term node
|
|
623
|
+
"""
|
|
624
|
+
# Add colon after term
|
|
625
|
+
self.add_text(": ")
|
|
626
|
+
|
|
627
|
+
def visit_definition(self, node: nodes.definition) -> None:
|
|
628
|
+
"""
|
|
629
|
+
Visit a definition (definition list definition) node.
|
|
630
|
+
|
|
631
|
+
Args:
|
|
632
|
+
node: The definition node
|
|
633
|
+
"""
|
|
634
|
+
# Definitions don't need special opening markup
|
|
635
|
+
pass
|
|
636
|
+
|
|
637
|
+
def depart_definition(self, node: nodes.definition) -> None:
|
|
638
|
+
"""
|
|
639
|
+
Depart a definition (definition list definition) node.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
node: The definition node
|
|
643
|
+
"""
|
|
644
|
+
# Add newline after definition
|
|
645
|
+
self.add_text("\n")
|
|
646
|
+
|
|
647
|
+
def visit_figure(self, node: nodes.figure) -> None:
|
|
648
|
+
"""
|
|
649
|
+
Visit a figure node.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
node: The figure node
|
|
653
|
+
"""
|
|
654
|
+
self.in_figure = True
|
|
655
|
+
self.figure_content = [] # Store figure content (image)
|
|
656
|
+
self.figure_caption = "" # Store caption text
|
|
657
|
+
|
|
658
|
+
# Start figure with potential label
|
|
659
|
+
self.add_text("#figure(\n")
|
|
660
|
+
|
|
661
|
+
def depart_figure(self, node: nodes.figure) -> None:
|
|
662
|
+
"""
|
|
663
|
+
Depart a figure node.
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
node: The figure node
|
|
667
|
+
"""
|
|
668
|
+
# Close the figure
|
|
669
|
+
if self.figure_caption:
|
|
670
|
+
self.add_text(f",\n caption: [{self.figure_caption}]")
|
|
671
|
+
|
|
672
|
+
# Add label if figure has ids
|
|
673
|
+
if node.get("ids"):
|
|
674
|
+
label = node["ids"][0]
|
|
675
|
+
self.add_text(f"\n) <{label}>\n\n")
|
|
676
|
+
else:
|
|
677
|
+
self.add_text("\n)\n\n")
|
|
678
|
+
|
|
679
|
+
self.in_figure = False
|
|
680
|
+
self.figure_content = []
|
|
681
|
+
self.figure_caption = ""
|
|
682
|
+
|
|
683
|
+
def visit_caption(self, node: nodes.caption) -> None:
|
|
684
|
+
"""
|
|
685
|
+
Visit a caption node.
|
|
686
|
+
|
|
687
|
+
Handles captions for both figures and code blocks (Issue #20).
|
|
688
|
+
|
|
689
|
+
Args:
|
|
690
|
+
node: The caption node
|
|
691
|
+
"""
|
|
692
|
+
# For captioned code blocks, caption is already extracted in visit_container
|
|
693
|
+
# We should skip output to avoid duplicate caption text
|
|
694
|
+
if self.in_captioned_code_block:
|
|
695
|
+
raise nodes.SkipNode
|
|
696
|
+
# For figures, start collecting caption text
|
|
697
|
+
self.in_caption = True
|
|
698
|
+
|
|
699
|
+
def depart_caption(self, node: nodes.caption) -> None:
|
|
700
|
+
"""
|
|
701
|
+
Depart a caption node.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
node: The caption node
|
|
705
|
+
"""
|
|
706
|
+
# Store caption text for figures
|
|
707
|
+
if self.in_figure:
|
|
708
|
+
self.figure_caption = node.astext()
|
|
709
|
+
self.in_caption = False
|
|
710
|
+
|
|
711
|
+
def visit_table(self, node: nodes.table) -> None:
|
|
712
|
+
"""
|
|
713
|
+
Visit a table node.
|
|
714
|
+
|
|
715
|
+
Args:
|
|
716
|
+
node: The table node
|
|
717
|
+
"""
|
|
718
|
+
self.in_table = True
|
|
719
|
+
self.table_cells = [] # Store cells for table generation
|
|
720
|
+
self.table_colcount = 0 # Track number of columns
|
|
721
|
+
|
|
722
|
+
def depart_table(self, node: nodes.table) -> None:
|
|
723
|
+
"""
|
|
724
|
+
Depart a table node.
|
|
725
|
+
|
|
726
|
+
Args:
|
|
727
|
+
node: The table node
|
|
728
|
+
"""
|
|
729
|
+
# Generate Typst #table() syntax
|
|
730
|
+
if self.table_colcount > 0:
|
|
731
|
+
# Use self.body.append directly to avoid routing to table_cell_content
|
|
732
|
+
self.body.append(f"#table(\n columns: {self.table_colcount},\n")
|
|
733
|
+
|
|
734
|
+
# Add all cells
|
|
735
|
+
for cell in self.table_cells:
|
|
736
|
+
self.body.append(f" [{cell}],\n")
|
|
737
|
+
|
|
738
|
+
self.body.append(")\n\n")
|
|
739
|
+
|
|
740
|
+
self.in_table = False
|
|
741
|
+
self.table_cells = []
|
|
742
|
+
self.table_colcount = 0
|
|
743
|
+
|
|
744
|
+
def visit_tgroup(self, node: nodes.tgroup) -> None:
|
|
745
|
+
"""
|
|
746
|
+
Visit a tgroup (table group) node.
|
|
747
|
+
|
|
748
|
+
Args:
|
|
749
|
+
node: The tgroup node
|
|
750
|
+
"""
|
|
751
|
+
# Get column count from tgroup
|
|
752
|
+
self.table_colcount = node.get("cols", 0)
|
|
753
|
+
|
|
754
|
+
def depart_tgroup(self, node: nodes.tgroup) -> None:
|
|
755
|
+
"""
|
|
756
|
+
Depart a tgroup (table group) node.
|
|
757
|
+
|
|
758
|
+
Args:
|
|
759
|
+
node: The tgroup node
|
|
760
|
+
"""
|
|
761
|
+
pass
|
|
762
|
+
|
|
763
|
+
def visit_colspec(self, node: nodes.colspec) -> None:
|
|
764
|
+
"""
|
|
765
|
+
Visit a colspec (column specification) node.
|
|
766
|
+
|
|
767
|
+
Args:
|
|
768
|
+
node: The colspec node
|
|
769
|
+
"""
|
|
770
|
+
# Column specifications are handled by tgroup
|
|
771
|
+
raise nodes.SkipNode
|
|
772
|
+
|
|
773
|
+
def depart_colspec(self, node: nodes.colspec) -> None:
|
|
774
|
+
"""
|
|
775
|
+
Depart a colspec (column specification) node.
|
|
776
|
+
|
|
777
|
+
Args:
|
|
778
|
+
node: The colspec node
|
|
779
|
+
"""
|
|
780
|
+
pass
|
|
781
|
+
|
|
782
|
+
def visit_thead(self, node: nodes.thead) -> None:
|
|
783
|
+
"""
|
|
784
|
+
Visit a thead (table header) node.
|
|
785
|
+
|
|
786
|
+
Args:
|
|
787
|
+
node: The thead node
|
|
788
|
+
"""
|
|
789
|
+
# Header rows are handled the same as body rows in Typst
|
|
790
|
+
pass
|
|
791
|
+
|
|
792
|
+
def depart_thead(self, node: nodes.thead) -> None:
|
|
793
|
+
"""
|
|
794
|
+
Depart a thead (table header) node.
|
|
795
|
+
|
|
796
|
+
Args:
|
|
797
|
+
node: The thead node
|
|
798
|
+
"""
|
|
799
|
+
pass
|
|
800
|
+
|
|
801
|
+
def visit_tbody(self, node: nodes.tbody) -> None:
|
|
802
|
+
"""
|
|
803
|
+
Visit a tbody (table body) node.
|
|
804
|
+
|
|
805
|
+
Args:
|
|
806
|
+
node: The tbody node
|
|
807
|
+
"""
|
|
808
|
+
pass
|
|
809
|
+
|
|
810
|
+
def depart_tbody(self, node: nodes.tbody) -> None:
|
|
811
|
+
"""
|
|
812
|
+
Depart a tbody (table body) node.
|
|
813
|
+
|
|
814
|
+
Args:
|
|
815
|
+
node: The tbody node
|
|
816
|
+
"""
|
|
817
|
+
pass
|
|
818
|
+
|
|
819
|
+
def visit_row(self, node: nodes.row) -> None:
|
|
820
|
+
"""
|
|
821
|
+
Visit a row (table row) node.
|
|
822
|
+
|
|
823
|
+
Args:
|
|
824
|
+
node: The row node
|
|
825
|
+
"""
|
|
826
|
+
# Rows are processed by collecting entries
|
|
827
|
+
pass
|
|
828
|
+
|
|
829
|
+
def depart_row(self, node: nodes.row) -> None:
|
|
830
|
+
"""
|
|
831
|
+
Depart a row (table row) node.
|
|
832
|
+
|
|
833
|
+
Args:
|
|
834
|
+
node: The row node
|
|
835
|
+
"""
|
|
836
|
+
pass
|
|
837
|
+
|
|
838
|
+
def visit_entry(self, node: nodes.entry) -> None:
|
|
839
|
+
"""
|
|
840
|
+
Visit an entry (table cell) node.
|
|
841
|
+
|
|
842
|
+
Args:
|
|
843
|
+
node: The entry node
|
|
844
|
+
"""
|
|
845
|
+
# Start collecting cell content
|
|
846
|
+
self.table_cell_content = []
|
|
847
|
+
|
|
848
|
+
def depart_entry(self, node: nodes.entry) -> None:
|
|
849
|
+
"""
|
|
850
|
+
Depart an entry (table cell) node.
|
|
851
|
+
|
|
852
|
+
Args:
|
|
853
|
+
node: The entry node
|
|
854
|
+
"""
|
|
855
|
+
# Get cell content and add to table cells
|
|
856
|
+
# Extract text from the accumulated body content since visit_entry
|
|
857
|
+
cell_text = ""
|
|
858
|
+
if hasattr(self, "table_cell_content") and self.table_cell_content:
|
|
859
|
+
cell_text = "".join(self.table_cell_content).strip()
|
|
860
|
+
|
|
861
|
+
if not cell_text:
|
|
862
|
+
# If no content was captured, try to get text from the node
|
|
863
|
+
cell_text = node.astext().strip()
|
|
864
|
+
|
|
865
|
+
self.table_cells.append(cell_text)
|
|
866
|
+
self.table_cell_content = []
|
|
867
|
+
|
|
868
|
+
def visit_block_quote(self, node: nodes.block_quote) -> None:
|
|
869
|
+
"""
|
|
870
|
+
Visit a block quote node.
|
|
871
|
+
|
|
872
|
+
Args:
|
|
873
|
+
node: The block quote node
|
|
874
|
+
"""
|
|
875
|
+
# Typst block quote syntax: #quote[...]
|
|
876
|
+
# Check if there's an attribution child node
|
|
877
|
+
has_attribution = any(isinstance(child, nodes.attribution) for child in node)
|
|
878
|
+
|
|
879
|
+
if has_attribution:
|
|
880
|
+
# Will add attribution parameter when we encounter the attribution node
|
|
881
|
+
self.add_text("#quote(")
|
|
882
|
+
else:
|
|
883
|
+
self.add_text("#quote[")
|
|
884
|
+
|
|
885
|
+
def depart_block_quote(self, node: nodes.block_quote) -> None:
|
|
886
|
+
"""
|
|
887
|
+
Depart a block quote node.
|
|
888
|
+
|
|
889
|
+
Args:
|
|
890
|
+
node: The block quote node
|
|
891
|
+
"""
|
|
892
|
+
# Check if there's an attribution child node
|
|
893
|
+
has_attribution = any(isinstance(child, nodes.attribution) for child in node)
|
|
894
|
+
|
|
895
|
+
if has_attribution:
|
|
896
|
+
self.add_text(")\n\n")
|
|
897
|
+
else:
|
|
898
|
+
self.add_text("]\n\n")
|
|
899
|
+
|
|
900
|
+
def visit_attribution(self, node: nodes.attribution) -> None:
|
|
901
|
+
"""
|
|
902
|
+
Visit an attribution node (quote attribution).
|
|
903
|
+
|
|
904
|
+
Args:
|
|
905
|
+
node: The attribution node
|
|
906
|
+
"""
|
|
907
|
+
# Close the quote content and add attribution parameter
|
|
908
|
+
self.add_text("], attribution: [")
|
|
909
|
+
|
|
910
|
+
def depart_attribution(self, node: nodes.attribution) -> None:
|
|
911
|
+
"""
|
|
912
|
+
Depart an attribution node.
|
|
913
|
+
|
|
914
|
+
Args:
|
|
915
|
+
node: The attribution node
|
|
916
|
+
"""
|
|
917
|
+
# Close attribution parameter
|
|
918
|
+
self.add_text("]")
|
|
919
|
+
|
|
920
|
+
def visit_image(self, node: nodes.image) -> None:
|
|
921
|
+
"""
|
|
922
|
+
Visit an image node.
|
|
923
|
+
|
|
924
|
+
Args:
|
|
925
|
+
node: The image node
|
|
926
|
+
"""
|
|
927
|
+
# Typst image syntax: #image("path", width: value)
|
|
928
|
+
uri = node.get("uri", "")
|
|
929
|
+
|
|
930
|
+
# If inside a figure, don't add # prefix (figure will handle it)
|
|
931
|
+
if self.in_figure:
|
|
932
|
+
self.add_text(f' image("{uri}"')
|
|
933
|
+
else:
|
|
934
|
+
self.add_text(f'#image("{uri}"')
|
|
935
|
+
|
|
936
|
+
# Add optional attributes
|
|
937
|
+
if "width" in node:
|
|
938
|
+
width = node["width"]
|
|
939
|
+
self.add_text(f", width: {width}")
|
|
940
|
+
|
|
941
|
+
if "height" in node:
|
|
942
|
+
height = node["height"]
|
|
943
|
+
self.add_text(f", height: {height}")
|
|
944
|
+
|
|
945
|
+
self.add_text(")")
|
|
946
|
+
|
|
947
|
+
def depart_image(self, node: nodes.image) -> None:
|
|
948
|
+
"""
|
|
949
|
+
Depart an image node.
|
|
950
|
+
|
|
951
|
+
Args:
|
|
952
|
+
node: The image node
|
|
953
|
+
"""
|
|
954
|
+
# If inside a figure, don't add extra newlines (figure will handle spacing)
|
|
955
|
+
if not self.in_figure:
|
|
956
|
+
self.add_text("\n\n")
|
|
957
|
+
|
|
958
|
+
def visit_target(self, node: nodes.target) -> None:
|
|
959
|
+
"""
|
|
960
|
+
Visit a target node (label definition).
|
|
961
|
+
|
|
962
|
+
Args:
|
|
963
|
+
node: The target node
|
|
964
|
+
"""
|
|
965
|
+
# Generate Typst label if target has ids
|
|
966
|
+
if node.get("ids"):
|
|
967
|
+
label = node["ids"][0]
|
|
968
|
+
self.add_text(f"<{label}> ")
|
|
969
|
+
# Skip processing children as target is typically empty
|
|
970
|
+
raise nodes.SkipNode
|
|
971
|
+
|
|
972
|
+
def depart_target(self, node: nodes.target) -> None:
|
|
973
|
+
"""
|
|
974
|
+
Depart a target node.
|
|
975
|
+
|
|
976
|
+
Args:
|
|
977
|
+
node: The target node
|
|
978
|
+
"""
|
|
979
|
+
# Target is handled in visit
|
|
980
|
+
pass
|
|
981
|
+
|
|
982
|
+
def visit_pending_xref(self, node: nodes.Node) -> None:
|
|
983
|
+
"""
|
|
984
|
+
Visit a pending_xref node (Sphinx cross-reference).
|
|
985
|
+
|
|
986
|
+
Args:
|
|
987
|
+
node: The pending_xref node
|
|
988
|
+
"""
|
|
989
|
+
# pending_xref nodes are typically resolved by Sphinx before reaching the writer
|
|
990
|
+
# If we encounter one, it means resolution failed or we're in a special case
|
|
991
|
+
# We handle it by generating a link to the target
|
|
992
|
+
|
|
993
|
+
reftarget = node.get("reftarget", "")
|
|
994
|
+
reftype = node.get("reftype", "")
|
|
995
|
+
|
|
996
|
+
if reftarget:
|
|
997
|
+
# Generate a link to the target
|
|
998
|
+
# Sanitize the target for Typst label format
|
|
999
|
+
label = reftarget.replace(".", "-").replace("_", "-")
|
|
1000
|
+
self.add_text(f"#link(<{label}>)[")
|
|
1001
|
+
# Continue processing children to get the link text
|
|
1002
|
+
|
|
1003
|
+
def depart_pending_xref(self, node: nodes.Node) -> None:
|
|
1004
|
+
"""
|
|
1005
|
+
Depart a pending_xref node.
|
|
1006
|
+
|
|
1007
|
+
Args:
|
|
1008
|
+
node: The pending_xref node
|
|
1009
|
+
"""
|
|
1010
|
+
reftarget = node.get("reftarget", "")
|
|
1011
|
+
if reftarget:
|
|
1012
|
+
self.add_text("]")
|
|
1013
|
+
|
|
1014
|
+
def _compute_relative_include_path(
|
|
1015
|
+
self, target_docname: str, current_docname: Optional[str]
|
|
1016
|
+
) -> str:
|
|
1017
|
+
"""
|
|
1018
|
+
Compute relative path for toctree #include() directive.
|
|
1019
|
+
|
|
1020
|
+
This method calculates the relative path from the current document
|
|
1021
|
+
to the target document for use in Typst #include() directives.
|
|
1022
|
+
Uses PurePosixPath for OS-independent POSIX path handling.
|
|
1023
|
+
|
|
1024
|
+
Args:
|
|
1025
|
+
target_docname: Target document name (e.g., "chapter1/section1")
|
|
1026
|
+
current_docname: Current document name (e.g., "chapter1/index"), or None
|
|
1027
|
+
|
|
1028
|
+
Returns:
|
|
1029
|
+
Relative path string for #include() (e.g., "section1" or "../chapter2/doc")
|
|
1030
|
+
|
|
1031
|
+
Examples:
|
|
1032
|
+
>>> _compute_relative_include_path("chapter1/section1", "chapter1/index")
|
|
1033
|
+
"section1"
|
|
1034
|
+
>>> _compute_relative_include_path("chapter2/doc", "chapter1/index")
|
|
1035
|
+
"../chapter2/doc"
|
|
1036
|
+
>>> _compute_relative_include_path("chapter1/doc", None)
|
|
1037
|
+
"chapter1/doc"
|
|
1038
|
+
|
|
1039
|
+
Notes:
|
|
1040
|
+
This method implements Issue #5 fix for nested toctree relative paths.
|
|
1041
|
+
It handles three cases:
|
|
1042
|
+
1. current_docname is None: return absolute path
|
|
1043
|
+
2. Same directory: use relative_to() directly
|
|
1044
|
+
3. Cross-directory: calculate via common parent
|
|
1045
|
+
|
|
1046
|
+
Requirements: 1.1, 1.2, 1.3, 1.4, 1.5
|
|
1047
|
+
"""
|
|
1048
|
+
from pathlib import PurePosixPath
|
|
1049
|
+
|
|
1050
|
+
logger.debug(
|
|
1051
|
+
f"Computing relative include path: target={target_docname}, "
|
|
1052
|
+
f"current={current_docname}"
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
# Fallback to absolute path if current_docname is None
|
|
1056
|
+
if not current_docname:
|
|
1057
|
+
logger.debug(f"No current document, using absolute path: {target_docname}")
|
|
1058
|
+
return target_docname
|
|
1059
|
+
|
|
1060
|
+
current_path = PurePosixPath(current_docname)
|
|
1061
|
+
target_path = PurePosixPath(target_docname)
|
|
1062
|
+
current_dir = current_path.parent
|
|
1063
|
+
|
|
1064
|
+
logger.debug(
|
|
1065
|
+
f"Path components: current_dir={current_dir}, " f"target_path={target_path}"
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
# Root directory case: use absolute path (backward compatibility)
|
|
1069
|
+
if current_dir == PurePosixPath("."):
|
|
1070
|
+
logger.debug(
|
|
1071
|
+
f"Current document is in root directory, "
|
|
1072
|
+
f"using absolute path: {target_docname}"
|
|
1073
|
+
)
|
|
1074
|
+
return target_docname
|
|
1075
|
+
|
|
1076
|
+
# Try to compute relative path
|
|
1077
|
+
try:
|
|
1078
|
+
rel_path = target_path.relative_to(current_dir)
|
|
1079
|
+
result = str(rel_path)
|
|
1080
|
+
logger.debug(
|
|
1081
|
+
f"Same directory reference: {current_dir} -> {target_path}, "
|
|
1082
|
+
f"result: {result}"
|
|
1083
|
+
)
|
|
1084
|
+
return result
|
|
1085
|
+
except ValueError:
|
|
1086
|
+
# Different directory trees - build path via common parent
|
|
1087
|
+
logger.debug(
|
|
1088
|
+
"Cross-directory reference detected, calculating via common parent"
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
current_parts = current_dir.parts
|
|
1092
|
+
target_parts = target_path.parts
|
|
1093
|
+
|
|
1094
|
+
# Find common parent by comparing path components
|
|
1095
|
+
common_length = 0
|
|
1096
|
+
for i, (c, t) in enumerate(zip(current_parts, target_parts)):
|
|
1097
|
+
if c == t:
|
|
1098
|
+
common_length = i + 1
|
|
1099
|
+
else:
|
|
1100
|
+
break
|
|
1101
|
+
|
|
1102
|
+
logger.debug(
|
|
1103
|
+
f"Common parent depth: {common_length}, "
|
|
1104
|
+
f"current_parts={current_parts}, target_parts={target_parts}"
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
# Build path: "../" from current to common parent
|
|
1108
|
+
up_count = len(current_parts) - common_length
|
|
1109
|
+
up_path = "../" * up_count if up_count > 0 else ""
|
|
1110
|
+
|
|
1111
|
+
# Build path: from common parent to target
|
|
1112
|
+
down_parts = target_parts[common_length:]
|
|
1113
|
+
down_path = "/".join(down_parts) if down_parts else ""
|
|
1114
|
+
|
|
1115
|
+
relative_path: str = up_path + down_path
|
|
1116
|
+
|
|
1117
|
+
logger.debug(
|
|
1118
|
+
f"Cross-directory path calculation: up_count={up_count}, "
|
|
1119
|
+
f"up_path='{up_path}', down_path='{down_path}', "
|
|
1120
|
+
f"result: {relative_path}"
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
return relative_path
|
|
1124
|
+
|
|
1125
|
+
def visit_toctree(self, node: nodes.Node) -> None:
|
|
1126
|
+
"""
|
|
1127
|
+
Visit a toctree node (Sphinx table of contents tree).
|
|
1128
|
+
|
|
1129
|
+
Requirement 13: Multi-document integration and toctree processing
|
|
1130
|
+
- Generate #include() for each entry
|
|
1131
|
+
- Apply #set heading(offset: 1) to lower heading levels
|
|
1132
|
+
- Issue #5: Fix relative paths for nested toctrees
|
|
1133
|
+
- Calculate relative paths from current document
|
|
1134
|
+
- Issue #7: Simplify toctree output with single content block
|
|
1135
|
+
- Generate single #[...] block containing all includes
|
|
1136
|
+
- Apply #set heading(offset: 1) once per toctree
|
|
1137
|
+
|
|
1138
|
+
Args:
|
|
1139
|
+
node: The toctree node
|
|
1140
|
+
|
|
1141
|
+
Notes:
|
|
1142
|
+
This method generates Typst #include() directives for each toctree entry
|
|
1143
|
+
within a single content block #[...] to apply heading offset without
|
|
1144
|
+
displaying the block delimiters in the output. This simplifies the
|
|
1145
|
+
generated Typst code and improves readability.
|
|
1146
|
+
"""
|
|
1147
|
+
# Get entries from the toctree node
|
|
1148
|
+
entries = node.get("entries", [])
|
|
1149
|
+
|
|
1150
|
+
logger.debug(f"Processing toctree with {len(entries)} entries")
|
|
1151
|
+
|
|
1152
|
+
# If no entries, don't generate anything
|
|
1153
|
+
if not entries:
|
|
1154
|
+
logger.debug("Toctree has no entries, skipping")
|
|
1155
|
+
raise nodes.SkipNode
|
|
1156
|
+
|
|
1157
|
+
# Get current document name for relative path calculation
|
|
1158
|
+
current_docname = getattr(self.builder, "current_docname", None)
|
|
1159
|
+
|
|
1160
|
+
logger.debug(
|
|
1161
|
+
f"Current document for toctree: {current_docname}, "
|
|
1162
|
+
f"entries: {[docname for _, docname in entries]}"
|
|
1163
|
+
)
|
|
1164
|
+
|
|
1165
|
+
# Issue #7: Generate single content block for all includes
|
|
1166
|
+
# Start single content block
|
|
1167
|
+
self.add_text("#[\n")
|
|
1168
|
+
self.add_text(" #set heading(offset: 1)\n")
|
|
1169
|
+
|
|
1170
|
+
# Generate #include() for each entry within the single block
|
|
1171
|
+
# Each included file has its own imports, so block scope is safe
|
|
1172
|
+
for _title, docname in entries:
|
|
1173
|
+
# Compute relative path for #include() (Issue #5 fix)
|
|
1174
|
+
relative_path = self._compute_relative_include_path(
|
|
1175
|
+
docname, current_docname
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
logger.debug(
|
|
1179
|
+
f"Generated #include() for toctree: {docname} -> {relative_path}.typ"
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
# Issue #7: Generate only #include() within the block
|
|
1183
|
+
self.add_text(f' #include("{relative_path}.typ")\n')
|
|
1184
|
+
|
|
1185
|
+
# End single content block
|
|
1186
|
+
self.add_text("]\n\n")
|
|
1187
|
+
|
|
1188
|
+
# Skip processing children as we've handled the toctree entries
|
|
1189
|
+
raise nodes.SkipNode
|
|
1190
|
+
|
|
1191
|
+
def depart_toctree(self, node: nodes.Node) -> None:
|
|
1192
|
+
"""
|
|
1193
|
+
Depart a toctree node.
|
|
1194
|
+
|
|
1195
|
+
Args:
|
|
1196
|
+
node: The toctree node
|
|
1197
|
+
"""
|
|
1198
|
+
# Toctree is handled in visit
|
|
1199
|
+
pass
|
|
1200
|
+
|
|
1201
|
+
def visit_reference(self, node: nodes.reference) -> None:
|
|
1202
|
+
"""
|
|
1203
|
+
Visit a reference node (link).
|
|
1204
|
+
|
|
1205
|
+
Args:
|
|
1206
|
+
node: The reference node
|
|
1207
|
+
"""
|
|
1208
|
+
# Get the reference URI
|
|
1209
|
+
refuri = node.get("refuri", "")
|
|
1210
|
+
|
|
1211
|
+
# Check if it's an internal reference (starts with #)
|
|
1212
|
+
if refuri.startswith("#"):
|
|
1213
|
+
# Internal reference to a label
|
|
1214
|
+
label = refuri[1:] # Remove the #
|
|
1215
|
+
self.add_text(f"#link(<{label}>)[")
|
|
1216
|
+
else:
|
|
1217
|
+
# External reference (HTTP/HTTPS URL or relative path)
|
|
1218
|
+
self.add_text(f'#link("{refuri}")[')
|
|
1219
|
+
|
|
1220
|
+
def depart_reference(self, node: nodes.reference) -> None:
|
|
1221
|
+
"""
|
|
1222
|
+
Depart a reference node.
|
|
1223
|
+
|
|
1224
|
+
Args:
|
|
1225
|
+
node: The reference node
|
|
1226
|
+
"""
|
|
1227
|
+
# Close the link
|
|
1228
|
+
self.add_text("]")
|
|
1229
|
+
|
|
1230
|
+
def unknown_visit(self, node: nodes.Node) -> None:
|
|
1231
|
+
"""
|
|
1232
|
+
Handle unknown nodes during visit.
|
|
1233
|
+
|
|
1234
|
+
Args:
|
|
1235
|
+
node: The unknown node
|
|
1236
|
+
"""
|
|
1237
|
+
# Log a warning for unknown nodes but don't raise an exception
|
|
1238
|
+
from sphinx.util import logging
|
|
1239
|
+
|
|
1240
|
+
logger = logging.getLogger(__name__)
|
|
1241
|
+
logger.warning(f"unknown node type: {node}")
|
|
1242
|
+
|
|
1243
|
+
def unknown_departure(self, node: nodes.Node) -> None:
|
|
1244
|
+
"""
|
|
1245
|
+
Handle unknown nodes during departure.
|
|
1246
|
+
|
|
1247
|
+
Args:
|
|
1248
|
+
node: The unknown node
|
|
1249
|
+
"""
|
|
1250
|
+
# Silently ignore unknown departures
|
|
1251
|
+
pass
|
|
1252
|
+
|
|
1253
|
+
def _convert_latex_to_typst(self, latex_content: str) -> str:
|
|
1254
|
+
"""
|
|
1255
|
+
Convert LaTeX math syntax to Typst native syntax.
|
|
1256
|
+
|
|
1257
|
+
Implements Task 6.5: Basic LaTeX to Typst conversion
|
|
1258
|
+
Requirement 4.9: Fallback when typst_use_mitex=False
|
|
1259
|
+
|
|
1260
|
+
Args:
|
|
1261
|
+
latex_content: LaTeX math content
|
|
1262
|
+
|
|
1263
|
+
Returns:
|
|
1264
|
+
Typst native math content
|
|
1265
|
+
"""
|
|
1266
|
+
# Basic conversion rules for common LaTeX commands
|
|
1267
|
+
result = latex_content
|
|
1268
|
+
|
|
1269
|
+
# Greek letters: \alpha -> alpha, \beta -> beta, etc.
|
|
1270
|
+
greek_letters = [
|
|
1271
|
+
"alpha",
|
|
1272
|
+
"beta",
|
|
1273
|
+
"gamma",
|
|
1274
|
+
"delta",
|
|
1275
|
+
"epsilon",
|
|
1276
|
+
"zeta",
|
|
1277
|
+
"eta",
|
|
1278
|
+
"theta",
|
|
1279
|
+
"iota",
|
|
1280
|
+
"kappa",
|
|
1281
|
+
"lambda",
|
|
1282
|
+
"mu",
|
|
1283
|
+
"nu",
|
|
1284
|
+
"xi",
|
|
1285
|
+
"omicron",
|
|
1286
|
+
"pi",
|
|
1287
|
+
"rho",
|
|
1288
|
+
"sigma",
|
|
1289
|
+
"tau",
|
|
1290
|
+
"upsilon",
|
|
1291
|
+
"phi",
|
|
1292
|
+
"chi",
|
|
1293
|
+
"psi",
|
|
1294
|
+
"omega",
|
|
1295
|
+
"Alpha",
|
|
1296
|
+
"Beta",
|
|
1297
|
+
"Gamma",
|
|
1298
|
+
"Delta",
|
|
1299
|
+
"Epsilon",
|
|
1300
|
+
"Zeta",
|
|
1301
|
+
"Eta",
|
|
1302
|
+
"Theta",
|
|
1303
|
+
"Iota",
|
|
1304
|
+
"Kappa",
|
|
1305
|
+
"Lambda",
|
|
1306
|
+
"Mu",
|
|
1307
|
+
"Nu",
|
|
1308
|
+
"Xi",
|
|
1309
|
+
"Omicron",
|
|
1310
|
+
"Pi",
|
|
1311
|
+
"Rho",
|
|
1312
|
+
"Sigma",
|
|
1313
|
+
"Tau",
|
|
1314
|
+
"Upsilon",
|
|
1315
|
+
"Phi",
|
|
1316
|
+
"Chi",
|
|
1317
|
+
"Psi",
|
|
1318
|
+
"Omega",
|
|
1319
|
+
]
|
|
1320
|
+
for letter in greek_letters:
|
|
1321
|
+
result = result.replace(f"\\{letter}", letter)
|
|
1322
|
+
|
|
1323
|
+
# Fractions: \frac{a}{b} -> frac(a, b)
|
|
1324
|
+
result = re.sub(r"\\frac\{([^}]+)\}\{([^}]+)\}", r"frac(\1, \2)", result)
|
|
1325
|
+
|
|
1326
|
+
# Sum: \sum_{lower}^{upper} -> sum_(lower)^upper
|
|
1327
|
+
result = re.sub(r"\\sum_\{([^}]+)\}\^\{([^}]+)\}", r"sum_(\1)^(\2)", result)
|
|
1328
|
+
result = re.sub(r"\\sum_\{([^}]+)\}", r"sum_(\1)", result)
|
|
1329
|
+
result = result.replace(r"\sum", "sum")
|
|
1330
|
+
|
|
1331
|
+
# Integral: \int_{lower}^{upper} -> integral_(lower)^upper
|
|
1332
|
+
result = re.sub(
|
|
1333
|
+
r"\\int_\{([^}]+)\}\^\{([^}]+)\}", r"integral_(\1)^(\2)", result
|
|
1334
|
+
)
|
|
1335
|
+
result = re.sub(r"\\int_\{([^}]+)\}", r"integral_(\1)", result)
|
|
1336
|
+
result = result.replace(r"\int", "integral")
|
|
1337
|
+
|
|
1338
|
+
# Product: \prod -> product
|
|
1339
|
+
result = result.replace(r"\prod", "product")
|
|
1340
|
+
|
|
1341
|
+
# Square root: \sqrt{x} -> sqrt(x)
|
|
1342
|
+
result = re.sub(r"\\sqrt\{([^}]+)\}", r"sqrt(\1)", result)
|
|
1343
|
+
|
|
1344
|
+
# Infinity: \infty -> infinity
|
|
1345
|
+
result = result.replace(r"\infty", "infinity")
|
|
1346
|
+
|
|
1347
|
+
# Partial derivative: \partial -> diff (Typst uses diff or ∂)
|
|
1348
|
+
result = result.replace(r"\partial", "diff")
|
|
1349
|
+
|
|
1350
|
+
# Common functions
|
|
1351
|
+
result = result.replace(r"\sin", "sin")
|
|
1352
|
+
result = result.replace(r"\cos", "cos")
|
|
1353
|
+
result = result.replace(r"\tan", "tan")
|
|
1354
|
+
result = result.replace(r"\log", "log")
|
|
1355
|
+
result = result.replace(r"\ln", "ln")
|
|
1356
|
+
result = result.replace(r"\exp", "exp")
|
|
1357
|
+
|
|
1358
|
+
# If there are still backslashes, warn about unconverted syntax
|
|
1359
|
+
if "\\" in result:
|
|
1360
|
+
logger.warning(
|
|
1361
|
+
f"LaTeX math contains commands that may not convert well to Typst: {latex_content}"
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
return result
|
|
1365
|
+
|
|
1366
|
+
def visit_math(self, node: nodes.math) -> None:
|
|
1367
|
+
"""
|
|
1368
|
+
Visit an inline math node.
|
|
1369
|
+
|
|
1370
|
+
Implements Task 6.2: LaTeX math conversion (mitex)
|
|
1371
|
+
Implements Task 6.3: Labeled equations
|
|
1372
|
+
Implements Task 6.4: Typst native math support
|
|
1373
|
+
Implements Task 6.5: Math fallback functionality
|
|
1374
|
+
Requirement 4.3: Inline math should use #mi(`...`) format (LaTeX)
|
|
1375
|
+
Requirement 4.9: Fallback when typst_use_mitex=False
|
|
1376
|
+
Requirement 5.2: Inline math should use $...$ format (Typst native)
|
|
1377
|
+
Requirement 4.7: Labeled equations should generate <eq:label> format
|
|
1378
|
+
Design 3.3: Support both mitex and Typst native math
|
|
1379
|
+
|
|
1380
|
+
Args:
|
|
1381
|
+
node: The inline math node
|
|
1382
|
+
"""
|
|
1383
|
+
# Extract math content
|
|
1384
|
+
math_content = node.astext()
|
|
1385
|
+
|
|
1386
|
+
# Task 6.4: Check if this is explicitly marked as Typst native
|
|
1387
|
+
is_typst_native = "typst-native" in node.get("classes", [])
|
|
1388
|
+
|
|
1389
|
+
# Task 6.5: Check typst_use_mitex config (default to True)
|
|
1390
|
+
use_mitex = getattr(self.builder.config, "typst_use_mitex", True)
|
|
1391
|
+
|
|
1392
|
+
if is_typst_native or not use_mitex:
|
|
1393
|
+
# Requirement 5.2: Typst native inline math syntax
|
|
1394
|
+
# Task 6.5: Convert LaTeX to Typst if use_mitex=False
|
|
1395
|
+
if not is_typst_native and not use_mitex:
|
|
1396
|
+
# Convert LaTeX syntax to Typst native
|
|
1397
|
+
math_content = self._convert_latex_to_typst(math_content)
|
|
1398
|
+
self.add_text(f"${math_content}$")
|
|
1399
|
+
else:
|
|
1400
|
+
# Requirement 4.3: LaTeX math via mitex
|
|
1401
|
+
self.add_text(f"#mi(`{math_content}`)")
|
|
1402
|
+
|
|
1403
|
+
# Task 6.3: Add label if present
|
|
1404
|
+
if "ids" in node and node["ids"]:
|
|
1405
|
+
label = node["ids"][0]
|
|
1406
|
+
self.add_text(f" <{label}>")
|
|
1407
|
+
|
|
1408
|
+
# Skip children to prevent duplicate output of math content
|
|
1409
|
+
raise nodes.SkipNode
|
|
1410
|
+
|
|
1411
|
+
def depart_math(self, node: nodes.math) -> None:
|
|
1412
|
+
"""
|
|
1413
|
+
Depart an inline math node.
|
|
1414
|
+
|
|
1415
|
+
Args:
|
|
1416
|
+
node: The inline math node
|
|
1417
|
+
"""
|
|
1418
|
+
# No additional output needed
|
|
1419
|
+
pass
|
|
1420
|
+
|
|
1421
|
+
def visit_math_block(self, node: nodes.math_block) -> None:
|
|
1422
|
+
"""
|
|
1423
|
+
Visit a block math node.
|
|
1424
|
+
|
|
1425
|
+
Implements Task 6.2: LaTeX math conversion (mitex)
|
|
1426
|
+
Implements Task 6.3: Labeled equations
|
|
1427
|
+
Implements Task 6.4: Typst native math support
|
|
1428
|
+
Implements Task 6.5: Math fallback functionality
|
|
1429
|
+
Requirement 4.2: Block math should use #mitex(`...`) format (LaTeX)
|
|
1430
|
+
Requirement 4.9: Fallback when typst_use_mitex=False
|
|
1431
|
+
Requirement 5.2: Block math should use $ ... $ format (Typst native)
|
|
1432
|
+
Requirement 4.7: Labeled equations should generate <eq:label> format
|
|
1433
|
+
Design 3.3: Support both mitex and Typst native math
|
|
1434
|
+
|
|
1435
|
+
Args:
|
|
1436
|
+
node: The block math node
|
|
1437
|
+
"""
|
|
1438
|
+
# Extract math content
|
|
1439
|
+
math_content = node.astext()
|
|
1440
|
+
|
|
1441
|
+
# Task 6.4: Check if this is explicitly marked as Typst native
|
|
1442
|
+
is_typst_native = "typst-native" in node.get("classes", [])
|
|
1443
|
+
|
|
1444
|
+
# Task 6.5: Check typst_use_mitex config (default to True)
|
|
1445
|
+
use_mitex = getattr(self.builder.config, "typst_use_mitex", True)
|
|
1446
|
+
|
|
1447
|
+
if is_typst_native or not use_mitex:
|
|
1448
|
+
# Requirement 5.2: Typst native block math syntax
|
|
1449
|
+
# Task 6.5: Convert LaTeX to Typst if use_mitex=False
|
|
1450
|
+
if not is_typst_native and not use_mitex:
|
|
1451
|
+
# Convert LaTeX syntax to Typst native
|
|
1452
|
+
math_content = self._convert_latex_to_typst(math_content)
|
|
1453
|
+
self.add_text(f"$ {math_content} $")
|
|
1454
|
+
else:
|
|
1455
|
+
# Requirement 4.2: LaTeX math via mitex
|
|
1456
|
+
self.add_text(f"#mitex(`{math_content}`)")
|
|
1457
|
+
|
|
1458
|
+
# Task 6.3: Add label if present
|
|
1459
|
+
if "ids" in node and node["ids"]:
|
|
1460
|
+
label = node["ids"][0]
|
|
1461
|
+
self.add_text(f" <{label}>")
|
|
1462
|
+
|
|
1463
|
+
self.add_text("\n\n")
|
|
1464
|
+
|
|
1465
|
+
# Skip children to prevent duplicate output of math content
|
|
1466
|
+
raise nodes.SkipNode
|
|
1467
|
+
|
|
1468
|
+
def depart_math_block(self, node: nodes.math_block) -> None:
|
|
1469
|
+
"""
|
|
1470
|
+
Depart a block math node.
|
|
1471
|
+
|
|
1472
|
+
Args:
|
|
1473
|
+
node: The block math node
|
|
1474
|
+
"""
|
|
1475
|
+
# No additional output needed
|
|
1476
|
+
pass
|
|
1477
|
+
|
|
1478
|
+
# Admonition nodes (Task 3.4)
|
|
1479
|
+
# Requirement 2.8-2.10: Convert Sphinx admonitions to gentle-clues
|
|
1480
|
+
|
|
1481
|
+
def _visit_admonition(
|
|
1482
|
+
self, node: nodes.Node, clue_type: str, custom_title: str = None
|
|
1483
|
+
) -> None:
|
|
1484
|
+
"""
|
|
1485
|
+
Helper method to visit any admonition node.
|
|
1486
|
+
|
|
1487
|
+
Args:
|
|
1488
|
+
node: The admonition node
|
|
1489
|
+
clue_type: The gentle-clues function name (e.g., 'info', 'warning', 'tip')
|
|
1490
|
+
custom_title: Optional custom title for the admonition
|
|
1491
|
+
"""
|
|
1492
|
+
# Check if there's a title element in the node
|
|
1493
|
+
title = None
|
|
1494
|
+
for child in node.children:
|
|
1495
|
+
if isinstance(child, nodes.title):
|
|
1496
|
+
title = child.astext()
|
|
1497
|
+
break
|
|
1498
|
+
|
|
1499
|
+
# Use custom title if provided, otherwise check for title element
|
|
1500
|
+
if title:
|
|
1501
|
+
self.add_text(f'#{clue_type}(title: "{title}")[')
|
|
1502
|
+
elif custom_title:
|
|
1503
|
+
self.add_text(f'#{clue_type}(title: "{custom_title}")[')
|
|
1504
|
+
else:
|
|
1505
|
+
self.add_text(f"#{clue_type}[")
|
|
1506
|
+
|
|
1507
|
+
def _depart_admonition(self) -> None:
|
|
1508
|
+
"""
|
|
1509
|
+
Helper method to depart any admonition node.
|
|
1510
|
+
"""
|
|
1511
|
+
self.add_text("]\n\n")
|
|
1512
|
+
|
|
1513
|
+
def visit_note(self, node: nodes.note) -> None:
|
|
1514
|
+
"""Visit a note admonition (converts to #info[])."""
|
|
1515
|
+
self._visit_admonition(node, "info")
|
|
1516
|
+
|
|
1517
|
+
def depart_note(self, node: nodes.note) -> None:
|
|
1518
|
+
"""Depart a note admonition."""
|
|
1519
|
+
self._depart_admonition()
|
|
1520
|
+
|
|
1521
|
+
def visit_warning(self, node: nodes.warning) -> None:
|
|
1522
|
+
"""Visit a warning admonition (converts to #warning[])."""
|
|
1523
|
+
self._visit_admonition(node, "warning")
|
|
1524
|
+
|
|
1525
|
+
def depart_warning(self, node: nodes.warning) -> None:
|
|
1526
|
+
"""Depart a warning admonition."""
|
|
1527
|
+
self._depart_admonition()
|
|
1528
|
+
|
|
1529
|
+
def visit_tip(self, node: nodes.tip) -> None:
|
|
1530
|
+
"""Visit a tip admonition (converts to #tip[])."""
|
|
1531
|
+
self._visit_admonition(node, "tip")
|
|
1532
|
+
|
|
1533
|
+
def depart_tip(self, node: nodes.tip) -> None:
|
|
1534
|
+
"""Depart a tip admonition."""
|
|
1535
|
+
self._depart_admonition()
|
|
1536
|
+
|
|
1537
|
+
def visit_important(self, node: nodes.important) -> None:
|
|
1538
|
+
"""Visit an important admonition (converts to #warning(title: "Important")[])."""
|
|
1539
|
+
self._visit_admonition(node, "warning", custom_title="Important")
|
|
1540
|
+
|
|
1541
|
+
def depart_important(self, node: nodes.important) -> None:
|
|
1542
|
+
"""Depart an important admonition."""
|
|
1543
|
+
self._depart_admonition()
|
|
1544
|
+
|
|
1545
|
+
def visit_caution(self, node: nodes.caution) -> None:
|
|
1546
|
+
"""Visit a caution admonition (converts to #warning[])."""
|
|
1547
|
+
self._visit_admonition(node, "warning")
|
|
1548
|
+
|
|
1549
|
+
def depart_caution(self, node: nodes.caution) -> None:
|
|
1550
|
+
"""Depart a caution admonition."""
|
|
1551
|
+
self._depart_admonition()
|
|
1552
|
+
|
|
1553
|
+
def visit_seealso(self, node: addnodes.seealso) -> None:
|
|
1554
|
+
"""Visit a seealso admonition (converts to #info(title: "See Also")[])."""
|
|
1555
|
+
self._visit_admonition(node, "info", custom_title="See Also")
|
|
1556
|
+
|
|
1557
|
+
def depart_seealso(self, node: addnodes.seealso) -> None:
|
|
1558
|
+
"""Depart a seealso admonition."""
|
|
1559
|
+
self._depart_admonition()
|
|
1560
|
+
|
|
1561
|
+
# Inline nodes (Task 7.4)
|
|
1562
|
+
# Requirement 3.1: Inline cross-references and links
|
|
1563
|
+
|
|
1564
|
+
def visit_inline(self, node: nodes.inline) -> None:
|
|
1565
|
+
"""
|
|
1566
|
+
Visit an inline node.
|
|
1567
|
+
|
|
1568
|
+
Inline nodes are generic containers for inline content.
|
|
1569
|
+
They are often used for cross-references with specific CSS classes.
|
|
1570
|
+
|
|
1571
|
+
Task 7.4: Handle inline nodes, especially those with 'xref' class
|
|
1572
|
+
Requirement 3.1: Cross-references and links
|
|
1573
|
+
"""
|
|
1574
|
+
# Inline nodes are transparent containers - we just process their children
|
|
1575
|
+
# The CSS classes (like 'xref', 'doc', 'std-ref') are mainly for HTML/CSS styling
|
|
1576
|
+
# For Typst output, we simply render the text content
|
|
1577
|
+
pass
|
|
1578
|
+
|
|
1579
|
+
def depart_inline(self, node: nodes.inline) -> None:
|
|
1580
|
+
"""
|
|
1581
|
+
Depart an inline node.
|
|
1582
|
+
"""
|
|
1583
|
+
pass
|