modelflowib 2.73__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.
modeljupytermagic.py ADDED
@@ -0,0 +1,813 @@
1
+ #!/usr/bin/env python
2
+ # coding: utf-8
3
+ '''
4
+ This module defines several magic jupyter functions:
5
+
6
+ :graphviz: Draw Graphviz graph
7
+ :dataframe: Create Pandas Dataframe
8
+ :latexflow: Create a modelflow modelinstance from latex script
9
+
10
+ he doc strings can only be displayed when the module is used from Jupyter. So they are not shown in the Sphinx documentation To display the doc strings use the functions in jupyter.
11
+
12
+ '''
13
+
14
+ from IPython.display import display, Math, Latex, Markdown , Image, SVG, display_svg,IFrame
15
+ from IPython.lib.latextools import latex_to_png
16
+ from io import StringIO
17
+ import pandas as pd
18
+ from IPython.core.magic import register_line_magic, register_cell_magic
19
+ from subprocess import run
20
+ import re
21
+ import ast
22
+ import shlex
23
+
24
+
25
+ from model_latex import latextotxt
26
+ from modelclass import model
27
+ from modelmanipulation import explode
28
+ from model_latex_class import a_latex_model
29
+ from modelconstruct_estimation import Makemodel, display_model
30
+ from modelhelp import debug_var
31
+ from modelreport import LatexRepo
32
+
33
+
34
+ def get_options(line,defaultname = 'test'):
35
+ '''
36
+ Retrives options from the first line.
37
+
38
+ The line is tokenized with :mod:`shlex` (POSIX mode), so values that
39
+ contain whitespace or shell-special characters can be passed by quoting
40
+ them. Examples::
41
+
42
+ %%Makemymodel npl smpl="(2012, 2019)"
43
+ %%Makemymodel npl caption="My model"
44
+
45
+ Unquoted whitespace still separates tokens, exactly as before, so
46
+ existing usage like ``smpl=(2012,2019)`` (no inner space) keeps working.
47
+ A value may itself contain ``=`` — only the first ``=`` is treated as
48
+ the key/value separator.
49
+
50
+ Args:
51
+ line (str): the magic line (everything after the magic name).
52
+ defaultname (str, optional): fallback name when the line is empty.
53
+
54
+ Returns:
55
+ name (str): the model/object name (first token).
56
+ opt (dict): parsed options. Bare flags map to ``True``;
57
+ ``key=0`` and ``key=False`` map to ``False``.
58
+ '''
59
+ if line:
60
+ arglistfull = shlex.split(line, posix=True)
61
+ name= arglistfull[0]
62
+ arglist=arglistfull[1:] if len(arglistfull)>= 1 else []
63
+ else:
64
+ name = defaultname
65
+ arglist=[]
66
+
67
+ opt = {}
68
+ for o in arglist:
69
+ parts = o.split('=', 1)
70
+ opt[parts[0]] = parts[1] if len(parts) == 2 else True
71
+ opt = {o : False if ( v== '0' or v=='False') else v for o,v in opt.items()}
72
+
73
+ return name,opt
74
+
75
+
76
+ def _resolve_option(options, opt_name, user_ns, default=None):
77
+ """Resolve a magic-line option that may be a literal or a name in user_ns.
78
+
79
+ Tries ``ast.literal_eval`` first so options like ``replacements=[('a','b')]``
80
+ work directly on the line. Falls back to looking up the raw string in the
81
+ notebook namespace, so users can pass a Python object by name:
82
+
83
+ df = pd.read_csv(...)
84
+ %%Makemymodel mymodel input_df=df
85
+
86
+ Returns ``default`` if the option is missing, unparseable, and not a name
87
+ in ``user_ns``. A warning is printed in the bad-name case so silent typos
88
+ don't go unnoticed.
89
+ """
90
+ if opt_name not in options:
91
+ return default
92
+ raw = options[opt_name]
93
+ # Bare flags (e.g. ``segment`` with no =value) come through as True;
94
+ # handle them before ast.literal_eval which would reject them.
95
+ if raw is True or raw is False:
96
+ return raw
97
+ try:
98
+ return ast.literal_eval(raw)
99
+ except (ValueError, SyntaxError):
100
+ if raw in user_ns:
101
+ return user_ns[raw]
102
+ print(f"⚠️ Warning: {opt_name}={raw!r} not found in namespace and not a literal.")
103
+ return default
104
+
105
+
106
+ try:
107
+
108
+ from IPython.core.magic import register_cell_magic, register_line_magic
109
+
110
+
111
+
112
+ @register_cell_magic
113
+ def graphviz(line, cell):
114
+ '''Creates a ModelFlow model from a Latex document'''
115
+ name,options = get_options(line,'Testgraph')
116
+ gv = cell
117
+ # print(options)
118
+ model.display_graph(gv,name,**options)
119
+ return
120
+
121
+ # In[29]:
122
+
123
+
124
+ @register_cell_magic
125
+ def latexflow(line, cell):
126
+ '''Creates a ModelFlow model from a Latex document'''
127
+ name,options = get_options(line)
128
+
129
+ lmodel = cell
130
+
131
+
132
+ # display(Markdown(f'## Now creating the model **{name}**'))
133
+
134
+ fmodel = latextotxt(cell)
135
+ if options.get('debug',False):
136
+ display(Markdown('## Creating this Template model'))
137
+ print(fmodel)
138
+ mmodel = model.from_eq(fmodel)
139
+ mmodel.equations_latex = cell
140
+
141
+
142
+ globals()[f'{name}'] = mmodel
143
+
144
+ ia = get_ipython()
145
+ ia.push(f'{name}',interactive=True)
146
+ if options.get('render',True):
147
+ display(Markdown(cell))
148
+
149
+ # display(Markdown('## The model'))
150
+ if options.get('display',False):
151
+ display(Markdown(cell))
152
+ display(Markdown('## Creating this Template model'))
153
+ print(mmodel.equations_original)
154
+ display(Markdown('## And this Business Logic Language model'))
155
+ print(mmodel.equations)
156
+
157
+ return
158
+
159
+ @register_cell_magic
160
+ def latexmodelgrab(line, cell):
161
+ '''Creates a ModelFlow model from a Latex document'''
162
+ name,options = get_options(line)
163
+ ia = get_ipython()
164
+
165
+ # print(f'{options=}')
166
+ if options.get('segment',False):
167
+
168
+ if f'{name}_dict' not in globals():
169
+ globals()[f'{name}_dict'] = {}
170
+ ia.push(f'{name}_dict',interactive=True)
171
+
172
+ modelsegment= options.get('segment','rest')
173
+ globals()[f'{name}_dict'][modelsegment]=cell
174
+
175
+ if modelsegment.startswith('list'):
176
+ display(Markdown(cell))
177
+ return
178
+ if modelsegment.startswith('text'):
179
+ display(Markdown(cell))
180
+ return
181
+
182
+ # we want this cell plus all list cells to make this small model
183
+ if options.get('all',False):
184
+ model_text = '\n'.join([text for name,text in globals()[f'{name}_dict'].items()] )
185
+
186
+ else:
187
+ model_text = '\n'.join([text for name,text in globals()[f'{name}_dict'].items()
188
+ if name.startswith('list')])+cell
189
+ else:
190
+ if f'{name}_dict' in globals():
191
+ temp='\n'
192
+ model_text = '\n'.join([text for name,text in globals()[f'{name}_dict'].items()] )
193
+ else:
194
+ model_text = cell
195
+
196
+ model_text = r'''
197
+ \documentclass{article}
198
+
199
+ % Add necessary packages
200
+
201
+
202
+ \usepackage{amsmath} % For mathematical equations
203
+
204
+
205
+ \begin{document}
206
+
207
+
208
+
209
+ ''' + model_text + r'''
210
+
211
+
212
+ \end{document}'''
213
+ replace = [('##','title'),('###','section')]
214
+ for pat,rep in replace:
215
+ pattern = fr'^{pat} (.*?)\s*$'
216
+ replacement = fr'\\{rep}{{\1}}'+'\n'
217
+
218
+ model_text = re.sub(pattern, replacement, model_text, flags=re.MULTILINE)
219
+
220
+
221
+
222
+
223
+ latex_model = a_latex_model(model_text,modelname=name)
224
+ mmodel = latex_model.mmodel
225
+ mmodel.equations_latex = model_text
226
+
227
+
228
+ globals()[f'{name}'] = mmodel
229
+ globals()[f'{name}_latex_model_instance'] = latex_model
230
+
231
+ ia.push(f'{name}',interactive=True)
232
+ ia.push(f'{name}_latex_model_instance',interactive=True)
233
+
234
+ if options.get('render',True) and not options.get('display',False):
235
+ display(Markdown(cell))
236
+
237
+ # display(Markdown('## The model'))
238
+ if options.get('display',False):
239
+ display(Markdown(cell))
240
+ try:
241
+ print(f'Model:{name} is created from these segments:\n'+
242
+ f"{temp.join([s for s in globals()[f'{name}_dict'].keys()])} \n")
243
+ except:
244
+ ...
245
+ display(Markdown('## Creating this Template model'))
246
+ print(mmodel.equations_original)
247
+ display(Markdown('## And this Business Logic Language model'))
248
+ print(mmodel.equations)
249
+
250
+ return
251
+
252
+
253
+
254
+
255
+
256
+
257
+ def ibmelt(df,prefix='',per=3):
258
+ # breakpoint()
259
+ temp= df.reset_index().rename(columns={'index':'row'}).melt(id_vars='row',var_name='column').assign(var_name=lambda x: prefix+x.row+'_'+x.column) .loc[:,['value','var_name']].set_index('var_name')
260
+ newdf = pd.concat([temp]*per,axis=1).T
261
+ newdf.index = range(per)
262
+ newdf.index.name = 'Year'
263
+ return newdf
264
+
265
+ # @register_cell_magic
266
+ # def dataframe(line, cell):
267
+ # '''Converts this cell to a dataframe. and create a melted dataframe
268
+
269
+ # options can be added to the inpput line\:
270
+
271
+ # - t transposes the dataframe
272
+ # - periods=<number> repeats the melted datframe
273
+ # - repeat = <number> repeats the original dataframe - for parameters
274
+ # - melt will create a melted dataframe
275
+ # - prefix=<a string> prefix columns in the melted dataframe
276
+ # - show will show the resulting dataframes
277
+ # - start=index will set the index (default 2021)
278
+ # '''
279
+
280
+ # name,options = get_options(line,'Testgraph')
281
+
282
+ # trans = options.get('t',False )
283
+
284
+ # if (options.get('help',False )):
285
+ # print(__doc__)
286
+
287
+
288
+ # prefix = options.get('prefix','')
289
+ # periods = int(options.get('periods','1'))
290
+ # melt = options.get('melt',False)
291
+ # start = int(options.get('start','2021'))
292
+ # silent = options.get('silent',True)
293
+ # ia = get_ipython()
294
+
295
+ # xtrans = (lambda xx:xx.T) if trans else (lambda xx:xx)
296
+ # xcell= cell.replace('%','').replace(',','.')
297
+ # mul = 0.01 if '%' in cell else 1.
298
+ # sio = StringIO(xcell)
299
+
300
+ # df = pd.read_csv(sio,sep=r"\s+|\t+|\s+\t+|\t+\s+",engine='python')\
301
+ # .pipe(xtrans)\
302
+ # .pipe(lambda xx:xx.rename(index = {i:i.upper() if type(i) == str else i for i in xx.index}
303
+ # ,columns={c:c.upper() for c in xx.columns})) *mul
304
+ # # breakpoint()
305
+ # if not melt:
306
+ # df= pd.concat([df]*periods,axis=0)
307
+ # df.index = pd.period_range(start=start,freq = 'Y',periods=len(df))
308
+ # df.index.name = 'index'
309
+
310
+
311
+ # globals()[f'{name}'] = df
312
+ # ia.push(f'{name}',interactive=True)
313
+ # if melt:
314
+ # df_melted = ibmelt(df,prefix=prefix.upper(),per=periods)
315
+ # df_melted.index = pd.period_range(start=start,freq = 'Y',periods=len(df_melted))
316
+ # df_melted.index.name = 'index'
317
+
318
+ # globals()[f'{name}_melted'] = df_melted
319
+ # ia.push(f'{name}_melted',interactive=True)
320
+ # if not silent:
321
+ # melttext = f' and {name}_melted' if melt else ''
322
+ # display(Markdown(f'## Created the dataframes: {name}{melttext}'))
323
+ # if options.get('show',False):
324
+ # display(df)
325
+ # if melt: display(df_melted)
326
+ # return
327
+
328
+ @register_cell_magic
329
+ def modeleviews(line, cell):
330
+ '''Creates a ModelFlow model from a Latex document
331
+
332
+ In developement
333
+ '''
334
+
335
+ try:
336
+ import pyeviews as evp
337
+ except:
338
+ print('no pyeviews')
339
+ raise Exception('No pyeviews ')
340
+ from pathlib import Path, PureWindowsPath
341
+
342
+ name,options = get_options(line)
343
+
344
+
345
+ display(Markdown(f'# Now creating the model **{name}**'))
346
+
347
+ commands = cell.split('\n')
348
+ print(commands)
349
+ if options.get('debug',False):
350
+ display(Markdown('# executing these eviews commands'))
351
+ print(cell)
352
+
353
+ eviewsapp = evp.GetEViewsApp(instance='new',showwindow=True)
354
+ for c in commands:
355
+ print(c)
356
+ evp.Run(c,eviewsapp)
357
+
358
+
359
+ evp.Cleanup()
360
+
361
+
362
+ return
363
+
364
+
365
+ def ibmelt(df,prefix='',per=3):
366
+ # breakpoint()
367
+ temp= df.reset_index().rename(columns={'index':'row'}).melt(id_vars='row',var_name='column').assign(var_name=lambda x: prefix+x.row+'_'+x.column) .loc[:,['value','var_name']].set_index('var_name')
368
+ newdf = pd.concat([temp]*per,axis=1).T
369
+ newdf.index = range(per)
370
+ newdf.index.name = 'Year'
371
+ return newdf
372
+
373
+ @register_cell_magic
374
+ def dataframe(line, cell):
375
+ '''Converts this cell to a dataframe. and create a melted dataframe
376
+
377
+ Works for yearly data.
378
+
379
+ Options can be added to the input line
380
+ - t transposes the dataframe
381
+ - periods=<number> repeats the melted datframe
382
+ - melt will create a melted dataframe
383
+ - prefix=<a string> prefix columns in the melted dataframe
384
+ - show will show the resulting dataframes
385
+ - start will set the start index (default to 2021) '''
386
+
387
+ name,options = get_options(line,'Testgraph')
388
+
389
+ trans = options.get('t',False )
390
+
391
+ prefix = options.get('prefix','')
392
+ periods = int(options.get('periods','1'))
393
+ melt = options.get('melt',False)
394
+ start = int(options.get('start','2021'))
395
+ silent = options.get('silent',True)
396
+ ia = get_ipython()
397
+
398
+ xtrans = (lambda xx:xx.T) if trans else (lambda xx:xx)
399
+ xcell= cell.replace('%','').replace(',','.')
400
+ mul = 0.01 if '%' in cell else 1.
401
+ sio = StringIO(xcell)
402
+
403
+ df = pd.read_csv(sio,sep=r"\s+|\t+|\s+\t+|\t+\s+",engine='python')\
404
+ .pipe(xtrans)\
405
+ .pipe(lambda xx:xx.rename(index = {i:i.upper() if type(i) == str else i for i in xx.index}
406
+ ,columns={c:c.upper() for c in xx.columns})) *mul
407
+ # breakpoint()
408
+ if not melt:
409
+ df= pd.concat([df]*periods,axis=0)
410
+ df.index = pd.period_range(start=start,freq = 'Y',periods=len(df))
411
+ df.index.name = 'index'
412
+
413
+
414
+ globals()[f'{name}'] = df
415
+ ia.push(f'{name}',interactive=True)
416
+ if melt:
417
+ df_melted = ibmelt(df,prefix=prefix.upper(),per=periods)
418
+ df_melted.index = pd.period_range(start=start,freq = 'Y',periods=len(df_melted))
419
+ df_melted.index.name = 'index'
420
+
421
+ globals()[f'{name}_melted'] = df_melted
422
+ ia.push(f'{name}_melted',interactive=True)
423
+ if not silent:
424
+ melttext = f' and {name}_melted' if melt else ''
425
+ display(Markdown(f'## Created the dataframes: {name}{melttext}'))
426
+ if options.get('show',False):
427
+ display(df)
428
+ if melt: display(df_melted)
429
+ return
430
+
431
+
432
+
433
+
434
+
435
+
436
+
437
+
438
+ def _mdmodel_impl(line, cell=None):
439
+ """
440
+ Shared implementation for %mdmodel and %%mdmodel
441
+ """
442
+ ip = get_ipython()
443
+ user_ns = ip.user_ns
444
+
445
+ name, options = get_options(line)
446
+ spec = options.get('spec', 'markdown')
447
+
448
+ # ------------------------------------------------------------
449
+ # Resolve options that can be literals or names in the user namespace.
450
+ # ``_resolve_option`` handles ast.literal_eval first, then user_ns
451
+ # lookup, and prints a warning on bad names instead of failing silently.
452
+ # ------------------------------------------------------------
453
+ replacements = _resolve_option(options, 'replacements', user_ns)
454
+ funks = _resolve_option(options, 'funks', user_ns, default=[]) or []
455
+ input_df = _resolve_option(options, 'input_df', user_ns)
456
+ estimator = _resolve_option(options, "estimator", user_ns, default=None)
457
+
458
+ if estimator is None:
459
+ estimator = _resolve_option(options, "est", user_ns, default=None) # smpl can be a tuple/list literal like (2010, 2019) or a name in
460
+ # user_ns; pass it through to Makemodel which already knows how to
461
+ # parse strings, tuples, slices, etc. via _parse_smpl.
462
+ smpl = _resolve_option(options, 'smpl', user_ns)
463
+
464
+
465
+ # ------------------------------------------------------------
466
+ # Handle segmented models
467
+ # ------------------------------------------------------------
468
+ if options.get('segment', False):
469
+ dict_name = f"{name}_dict"
470
+ user_ns.setdefault(dict_name, {})
471
+
472
+ segment_name = options.get('segment', 'rest')
473
+
474
+ if cell is not None:
475
+ user_ns[dict_name][segment_name] = cell
476
+
477
+ # Display-only segments
478
+ if segment_name.startswith(('list', 'text')) and cell is not None:
479
+ display_model(cell, spec=spec)
480
+ return
481
+
482
+ # Combine lists + current cell
483
+ model_text = '\n'.join(
484
+ txt for seg, txt in user_ns[dict_name].items()
485
+ if seg.startswith('list')
486
+ ) + (cell or "")
487
+
488
+ model_text_no_list = cell or ""
489
+
490
+ model_text_latex_nowrap = modeltext_to_latex(model_text)
491
+
492
+
493
+ exploded_name = segment_name
494
+
495
+ else:
496
+ dict_name = f"{name}_dict"
497
+
498
+ if dict_name in user_ns:
499
+ segments = user_ns[dict_name]
500
+
501
+ model_text_lists = '\n'.join(
502
+ txt for k, txt in segments.items()
503
+ if k.startswith('list')
504
+ )
505
+
506
+ model_text_no_list = '\n'.join(
507
+ txt for k, txt in segments.items()
508
+ if not k.startswith('list')
509
+ ) + (cell or "")
510
+
511
+ model_text = model_text_no_list + '\n' + model_text_lists
512
+
513
+ model_text_latex_nowrap = '\n'.join(
514
+ modeltext_to_latex(txt)
515
+ if k.startswith(('text', 'list')) else txt
516
+ for k, txt in segments.items()
517
+ )
518
+
519
+ else:
520
+ model_text = cell or ""
521
+ model_text_no_list = model_text
522
+ model_text_latex_nowrap = modeltext_to_latex(model_text)
523
+
524
+ exploded_name = name
525
+
526
+ # ------------------------------------------------------------
527
+ # Create Makemodel model
528
+ # ------------------------------------------------------------
529
+ # Only forward estimation kwargs when the user actually supplied them
530
+ # on the magic line; that keeps the no-estimation path identical to
531
+ # the previous behavior.
532
+ makemodel_kwargs = dict(
533
+ replacements=replacements,
534
+ estimator_namespace=user_ns,
535
+ funks=funks,
536
+ )
537
+ if input_df is not None:
538
+ makemodel_kwargs['input_df'] = input_df
539
+ if estimator is not None:
540
+ makemodel_kwargs['estimator'] = estimator
541
+ if smpl is not None:
542
+ makemodel_kwargs['smpl'] = smpl
543
+
544
+ emodel = Makemodel(model_text, **makemodel_kwargs)
545
+
546
+
547
+ user_ns[exploded_name] = emodel
548
+
549
+ # ------------------------------------------------------------
550
+ # Rendering
551
+ # ------------------------------------------------------------
552
+
553
+
554
+ if options.get('render_est', True):
555
+ if options.get('render_list', True):
556
+ display_model(emodel.markdown_with_estimation, spec=spec)
557
+ else:
558
+ display_model(emodel.markdown_with_estimation_no_list, spec=spec)
559
+ else:
560
+ if options.get('render', True):
561
+ if options.get('render_list', True):
562
+ display_model(model_text, spec=spec)
563
+ else:
564
+ display_model(model_text_no_list, spec=spec)
565
+
566
+
567
+
568
+ if options.get('show', False):
569
+ emodel.show
570
+
571
+ if options.get('draw', False) and not options.get('display', False):
572
+ emodel.draw
573
+
574
+
575
+ if options.get('latex', False):
576
+
577
+ try:
578
+
579
+ emodel.latex_nowrap = markdown_titles_to_latex(model_text_latex_nowrap)
580
+
581
+ LatexRepo( emodel.latex_nowrap,name=exploded_name).pdf(pdfopen=True)
582
+ except Exception:
583
+ print("no latex")
584
+
585
+
586
+
587
+ if options.get('display', False):
588
+ display_model(model_text, spec=spec)
589
+ if dict_name in user_ns:
590
+ segs = list(user_ns[dict_name].keys())
591
+ print(f"Model `{name}` built from segments: {', '.join(segs)}")
592
+ print(f"✅ Created Mexplode model: {name}")
593
+ print(emodel)
594
+
595
+ return emodel
596
+
597
+
598
+ # -----------------------------------------------------------------
599
+ # Cell magic
600
+ # -----------------------------------------------------------------
601
+ @register_cell_magic
602
+ def Makemymodel(line, cell):
603
+ _ = _mdmodel_impl(line, cell)
604
+
605
+
606
+
607
+ @register_line_magic
608
+ def Makemymodel(line):
609
+ """
610
+ %Makemymodel <name> [options...]
611
+
612
+ Rebuild / re-render an existing Markdown-based model
613
+ without adding new content.
614
+ """
615
+ _ = _mdmodel_impl(line, cell=None)
616
+
617
+
618
+ except:
619
+ print('no magic')
620
+
621
+
622
+ def modeltext_to_latex(source: str) -> str:
623
+ import re
624
+ from textwrap import dedent
625
+
626
+ def wrap_blockquote_equations(text):
627
+ """
628
+ Wrap consecutive Markdown lines starting with '>' in LaTeX \\verb blocks.
629
+
630
+ Parameters
631
+ ----------
632
+ text : str
633
+ Full Markdown text
634
+
635
+ Returns
636
+ -------
637
+ str
638
+ LaTeX-safe text
639
+ """
640
+ lines = text.splitlines()
641
+ out = []
642
+ in_block = False
643
+
644
+ for line in lines:
645
+ if line.startswith(">"):
646
+ if not in_block:
647
+ out.append(r"\par\noindent")
648
+ in_block = True
649
+
650
+ # pick a safe delimiter for \verb
651
+ for delim in ("|", "!", "/", "+", "#"):
652
+ if delim not in line:
653
+ break
654
+
655
+ out.append(rf"\verb{delim}{line}{delim}\\")
656
+ else:
657
+ if in_block:
658
+ out.append(r"\par")
659
+ in_block = False
660
+ out.append(line)
661
+
662
+ if in_block:
663
+ out.append(r"\par")
664
+
665
+ return "\n".join(out)
666
+
667
+
668
+ def markdown_item_lists_to_latex(text):
669
+ """
670
+ Convert Markdown bullet (- item) and numbered (1. item) lists
671
+ into LaTeX itemize / enumerate environments.
672
+
673
+ Guards against formulas, numeric lines, and symbols.
674
+ """
675
+
676
+ list_block = re.compile(
677
+ r'(?:^|\n)'
678
+ r'('
679
+ r'(?:\s*(?:-|\d+\.)\s+'
680
+ r'(?=.*[A-Za-z])' # must contain at least one letter
681
+ r'(?!.*=)' # reject equations
682
+ r'.+'
683
+ r'\n?)+'
684
+ r')',
685
+ flags=re.MULTILINE
686
+ )
687
+
688
+ def repl_list(match):
689
+ block = match.group(1)
690
+
691
+ first = re.search(r'\S+', block).group()
692
+
693
+ if first.startswith('-'):
694
+ items = re.findall(
695
+ r'\s*-\s+(?=(?:.*[A-Za-z]))(?!.*=)(.+)',
696
+ block
697
+ )
698
+ env = 'itemize'
699
+ else:
700
+ items = re.findall(
701
+ r'\s*\d+\.\s+(?=(?:.*[A-Za-z]))(?!.*=)(.+)',
702
+ block
703
+ )
704
+ env = 'enumerate'
705
+
706
+ latex = [f'\\begin{{{env}}}']
707
+ latex += [f' \\item {item}' for item in items]
708
+ latex.append(f'\\end{{{env}}}')
709
+
710
+ return '\n' + '\n'.join(latex)
711
+
712
+ return list_block.sub(repl_list, text)
713
+
714
+
715
+ TABLE_RE = re.compile(
716
+ r"""
717
+ ( # entire table
718
+ (?:^\|.*\|\s*\n) # header
719
+ (?:^\|\s*[-:]+.*\|\s*\n) # separator
720
+ (?:^\|.*\|\s*\n?)+ # body
721
+ )
722
+ """,
723
+ re.MULTILINE | re.VERBOSE,
724
+ )
725
+
726
+
727
+ def markdown_table_to_latex(table, align=None):
728
+ lines = [l.strip() for l in table.strip().splitlines()]
729
+
730
+ # Remove separator row
731
+ rows = []
732
+ for l in lines:
733
+ if re.match(r'^\|\s*[-:]+', l):
734
+ continue
735
+ rows.append([c.strip() for c in l.strip('|').split('|')])
736
+
737
+ ncols = len(rows[0])
738
+ align = align or ("l" * ncols)
739
+
740
+ def md_to_tex(cell):
741
+ cell = re.sub(r'\*\*(.*?)\*\*', r'\\textbf{\1}', cell)
742
+ return cell
743
+
744
+ rows = [[md_to_tex(c) for c in r] for r in rows]
745
+
746
+ latex = []
747
+ # latex.append(r"\\")
748
+ latex.append(r"\begin{tabular}{" + align + r"}")
749
+ latex.append(r"\hline")
750
+
751
+ for i, row in enumerate(rows):
752
+ latex.append(" & ".join(row) + r" \\")
753
+ if i == 0:
754
+ latex.append(r"\hline")
755
+
756
+ latex.append(r"\hline")
757
+ latex.append(r"\end{tabular}")
758
+ latex.append(r"\\")
759
+ latex.append(r"")
760
+
761
+ return "\n".join(latex)
762
+
763
+
764
+ def replace_markdown_tables(text):
765
+ """
766
+ Replace all Markdown tables in a string with LaTeX tables.
767
+ """
768
+
769
+ def repl_table(match):
770
+ table = match.group(1)
771
+ return markdown_table_to_latex(table)
772
+
773
+ return TABLE_RE.sub(repl_table, dedent(text))
774
+
775
+
776
+
777
+
778
+ # source = dedent(source).strip()
779
+
780
+ # Markdown → LaTeX
781
+
782
+ body = markdown_item_lists_to_latex(source)
783
+ body = replace_markdown_tables(body)
784
+ body = wrap_blockquote_equations(body)
785
+
786
+ return body
787
+
788
+
789
+ def wrap_latex(body):
790
+ # Assemble LaTeX
791
+ latex = rf"""
792
+ \documentclass[11pt]{{article}}
793
+ \usepackage{{amsmath,amssymb}}
794
+ \usepackage{{booktabs}}
795
+ \usepackage[utf8]{{inputenc}}
796
+ \usepackage{{geometry}}
797
+ \geometry{{margin=1in}}
798
+
799
+ \begin{{document}}
800
+
801
+ {body}
802
+
803
+ \end{{document}}
804
+ """.strip()
805
+
806
+ return latex
807
+
808
+
809
+ def markdown_titles_to_latex(text: str) -> str:
810
+ outtext = re.sub(r"^# (.+)$", r"\\section{\1}", text, flags=re.MULTILINE)
811
+ outtext = re.sub(r"^## (.+)$", r"\\subsection{\1}", outtext, flags=re.MULTILINE)
812
+ outtext = re.sub(r"^### (.+)$", r"\\subsubsection{\1}", outtext, flags=re.MULTILINE)
813
+ return outtext