1
+ #!/usr/bin/env python3
2
+ """
3
+ Convert Quarto .qmd files to Jupyter .ipynb notebooks with proper cell structure.
4
+ Each code block becomes a code cell, and markdown content becomes markdown cells.
5
+ """
6
+
7
+ import sys
8
+ import json
9
+ import re
10
+ from pathlib import Path
11
+ from typing import List , Dict , Any , Optional
12
+
13
+
14
+ class QmdToIpynb :
15
+ def __init__ (self , qmd_path : str ):
16
+ self .qmd_path = Path (qmd_path )
17
+ self .cells : List [Dict [str , Any ]] = []
18
+ self .kernel_name = "julia-1.11" # Default kernel
19
+
20
+ def parse (self ) -> None :
21
+ """Parse the .qmd file and extract cells."""
22
+ with open (self .qmd_path , 'r' , encoding = 'utf-8' ) as f :
23
+ content = f .read ()
24
+
25
+ lines = content .split ('\n ' )
26
+ i = 0
27
+
28
+ # Skip YAML frontmatter
29
+ if lines [0 ].strip () == '---' :
30
+ i = 1
31
+ while i < len (lines ) and lines [i ].strip () != '---' :
32
+ # Check for engine specification
33
+ if lines [i ].strip ().startswith ('engine:' ):
34
+ engine = lines [i ].split (':' , 1 )[1 ].strip ()
35
+ if engine == 'julia' :
36
+ self .kernel_name = "julia-1.11"
37
+ elif engine == 'python' :
38
+ self .kernel_name = "python3"
39
+ i += 1
40
+ i += 1 # Skip the closing ---
41
+
42
+ # Parse the rest of the document
43
+ current_markdown = []
44
+
45
+ while i < len (lines ):
46
+ line = lines [i ]
47
+
48
+ # Check for code block start
49
+ code_block_match = re .match (r'^```\{(\w+)\}' , line )
50
+ if code_block_match :
51
+ # Save any accumulated markdown
52
+ if current_markdown :
53
+ self ._add_markdown_cell (current_markdown )
54
+ current_markdown = []
55
+
56
+ # Extract code block
57
+ lang = code_block_match .group (1 )
58
+ i += 1
59
+ code_lines = []
60
+ cell_options = []
61
+
62
+ # Collect code and options
63
+ while i < len (lines ) and not lines [i ].startswith ('```' ):
64
+ if lines [i ].startswith ('#|' ):
65
+ cell_options .append (lines [i ])
66
+ else :
67
+ code_lines .append (lines [i ])
68
+ i += 1
69
+
70
+ # Add code cell (with options as comments at the top)
71
+ full_code = cell_options + code_lines
72
+ self ._add_code_cell (full_code , lang )
73
+
74
+ i += 1 # Skip closing ```
75
+ else :
76
+ # Accumulate markdown
77
+ current_markdown .append (line )
78
+ i += 1
79
+
80
+ # Add any remaining markdown
81
+ if current_markdown :
82
+ self ._add_markdown_cell (current_markdown )
83
+
84
+ def _add_markdown_cell (self , lines : List [str ]) -> None :
85
+ """Add a markdown cell, stripping leading/trailing empty lines."""
86
+ # Strip leading empty lines
87
+ while lines and not lines [0 ].strip ():
88
+ lines .pop (0 )
89
+
90
+ # Strip trailing empty lines
91
+ while lines and not lines [- 1 ].strip ():
92
+ lines .pop ()
93
+
94
+ if not lines :
95
+ return
96
+
97
+ content = '\n ' .join (lines )
98
+ cell = {
99
+ "cell_type" : "markdown" ,
100
+ "metadata" : {},
101
+ "source" : content
102
+ }
103
+ self .cells .append (cell )
104
+
105
+ def _add_code_cell (self , lines : List [str ], lang : str ) -> None :
106
+ """Add a code cell."""
107
+ content = '\n ' .join (lines )
108
+
109
+ # For non-Julia code blocks (like dot/graphviz), add as markdown with code formatting
110
+ # since Jupyter notebooks typically use Julia kernel for these docs
111
+ if lang != 'julia' and lang != 'python' :
112
+ # Convert to markdown with code fence
113
+ markdown_content = f"```{ lang } \n { content } \n ```"
114
+ cell = {
115
+ "cell_type" : "markdown" ,
116
+ "metadata" : {},
117
+ "source" : markdown_content
118
+ }
119
+ else :
120
+ cell = {
121
+ "cell_type" : "code" ,
122
+ "execution_count" : None ,
123
+ "metadata" : {},
124
+ "outputs" : [],
125
+ "source" : content
126
+ }
127
+
128
+ self .cells .append (cell )
129
+
130
+ def to_notebook (self ) -> Dict [str , Any ]:
131
+ """Convert parsed cells to Jupyter notebook format."""
132
+ notebook = {
133
+ "cells" : self .cells ,
134
+ "metadata" : {
135
+ "kernelspec" : {
136
+ "display_name" : "Julia 1.11" ,
137
+ "language" : "julia" ,
138
+ "name" : self .kernel_name
139
+ },
140
+ "language_info" : {
141
+ "file_extension" : ".jl" ,
142
+ "mimetype" : "application/julia" ,
143
+ "name" : "julia" ,
144
+ "version" : "1.11.0"
145
+ }
146
+ },
147
+ "nbformat" : 4 ,
148
+ "nbformat_minor" : 5
149
+ }
150
+ return notebook
151
+
152
+ def write (self , output_path : str ) -> None :
153
+ """Write the notebook to a file."""
154
+ notebook = self .to_notebook ()
155
+ with open (output_path , 'w' , encoding = 'utf-8' ) as f :
156
+ json .dump (notebook , f , indent = 2 , ensure_ascii = False )
157
+
158
+
159
+ def main ():
160
+ if len (sys .argv ) < 2 :
161
+ print ("Usage: qmd_to_ipynb.py <input.qmd> [output.ipynb]" )
162
+ sys .exit (1 )
163
+
164
+ qmd_path = sys .argv [1 ]
165
+
166
+ # Determine output path
167
+ if len (sys .argv ) >= 3 :
168
+ ipynb_path = sys .argv [2 ]
169
+ else :
170
+ ipynb_path = Path (qmd_path ).with_suffix ('.ipynb' )
171
+
172
+ # Convert
173
+ converter = QmdToIpynb (qmd_path )
174
+ converter .parse ()
175
+ converter .write (ipynb_path )
176
+
177
+ print (f"Converted { qmd_path } -> { ipynb_path } " )
178
+
179
+
180
+ if __name__ == "__main__" :
181
+ main ()
0 commit comments