engrapha-notes 0.1.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.
engrapha_notes/cli.py ADDED
@@ -0,0 +1,883 @@
1
+ """
2
+ cli.py -- Command Line Interface for compiling markdown notes to themed PDFs.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import os
9
+ import re
10
+ import sys
11
+ from typing import List, Dict, Optional
12
+
13
+ import engrapha_notes as en
14
+ import engrapha_diagrams as ed
15
+ from reportlab.platypus import Paragraph, Spacer, Table, PageBreak, Flowable
16
+
17
+
18
+ class PDFCompilerError(Exception):
19
+ """Base exception for PDF compiler errors."""
20
+
21
+ pass
22
+
23
+
24
+ def parse_metadata(lines: List[str]) -> tuple[Dict[str, str], List[str]]:
25
+ """
26
+ Parse optional front-matter metadata from the top of the file.
27
+ Example:
28
+ ---
29
+ title: Java Programming
30
+ author: Bharat Dangi
31
+ ---
32
+ """
33
+ metadata: Dict[str, str] = {}
34
+ content_lines = lines
35
+
36
+ if len(lines) > 0 and lines[0].strip() == "---":
37
+ metadata_lines = []
38
+ idx = 1
39
+ while idx < len(lines) and lines[idx].strip() != "---":
40
+ metadata_lines.append(lines[idx])
41
+ idx += 1
42
+
43
+ if idx < len(lines):
44
+ content_lines = lines[idx + 1 :]
45
+ for line in metadata_lines:
46
+ if ":" in line:
47
+ k, v = line.split(":", 1)
48
+ metadata[k.strip().lower()] = v.strip()
49
+
50
+ return metadata, content_lines
51
+
52
+
53
+ def format_inline_markdown(text: str) -> str:
54
+ """
55
+ Convert markdown inline elements (**bold**, *italic*, `code`)
56
+ to ReportLab paragraph XML tags.
57
+ """
58
+ import xml.sax.saxutils as saxutils
59
+
60
+ escaped = saxutils.escape(text)
61
+
62
+ escaped = escaped.replace("&lt;b&gt;", "<b>").replace("&lt;/b&gt;", "</b>")
63
+ escaped = escaped.replace("&lt;i&gt;", "<i>").replace("&lt;/i&gt;", "</i>")
64
+ escaped = escaped.replace("&lt;u&gt;", "<u>").replace("&lt;/u&gt;", "</u>")
65
+
66
+ code_spans: list[str] = []
67
+ def _code_repl(m: re.Match[str]) -> str:
68
+ code_spans.append(m.group(1))
69
+ return f"@@CODE{len(code_spans)-1}@@"
70
+
71
+ escaped = re.sub(r"`(.*?)`", _code_repl, escaped)
72
+
73
+ escaped = re.sub(r"\*\*(.*?)\*\*", r"<b>\1</b>", escaped)
74
+ escaped = re.sub(r"__(.*?)__", r"<b>\1</b>", escaped)
75
+
76
+ escaped = re.sub(r"\*(.*?)\*", r"<i>\1</i>", escaped)
77
+ escaped = re.sub(r"_(.*?)_", r"<i>\1</i>", escaped)
78
+
79
+ for idx, span in enumerate(code_spans):
80
+ escaped = escaped.replace(
81
+ f"@@CODE{idx}@@",
82
+ f'<font face="Courier" size="8.5">{span}</font>',
83
+ )
84
+
85
+ return escaped
86
+
87
+
88
+ def parse_diagram_dsl(block_type: str, content: List[str]) -> Optional[List[Flowable]]:
89
+ """
90
+ Parse a simple textual DSL inside diagram code blocks and return flowables.
91
+ """
92
+
93
+ def parse_kwargs(line: str) -> Dict[str, str]:
94
+ pattern = r'(\w+)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s]+))'
95
+ matches = re.findall(pattern, line)
96
+ return {m[0].lower(): (m[1] or m[2] or m[3]) for m in matches}
97
+
98
+ width = 450.0
99
+ height = 240.0
100
+ caption: Optional[str] = None
101
+ direction = "TB"
102
+ scale_factor: Optional[float] = None
103
+ label: Optional[str] = None
104
+
105
+ dsl_lines = []
106
+ for line in content:
107
+ line_strip = line.strip()
108
+ if not line_strip or line_strip.startswith("#"):
109
+ continue
110
+ is_config = False
111
+ if "=" in line_strip:
112
+ parts_eq = line_strip.split("=", 1)
113
+ if parts_eq[0].strip().lower() in (
114
+ "width",
115
+ "height",
116
+ "caption",
117
+ "direction",
118
+ "scale_factor",
119
+ ):
120
+ is_config = True
121
+ if is_config:
122
+ k, v = line_strip.split("=", 1)
123
+ k = k.strip().lower()
124
+ v = v.strip().strip('"').strip("'")
125
+ if k == "width":
126
+ width = float(v)
127
+ elif k == "height":
128
+ height = float(v)
129
+ elif k == "caption":
130
+ caption = v
131
+ elif k == "direction":
132
+ direction = v
133
+ elif k == "scale_factor":
134
+ scale_factor = float(v)
135
+ else:
136
+ dsl_lines.append(line_strip)
137
+
138
+ theme = ed.DiagramTheme.from_notes_theme(en.get_theme())
139
+
140
+ norm_block_type = block_type.lower()
141
+ if norm_block_type == "er":
142
+ norm_block_type = "schema"
143
+ elif norm_block_type == "arch":
144
+ norm_block_type = "architecture"
145
+ elif norm_block_type == "c4container":
146
+ norm_block_type = "c4"
147
+ elif norm_block_type == "cloud":
148
+ norm_block_type = "aws"
149
+
150
+ try:
151
+ if norm_block_type == "flowchart":
152
+ fc = ed.Flowchart(
153
+ width=width,
154
+ height=height,
155
+ theme=theme,
156
+ caption=caption,
157
+ direction=direction,
158
+ scale_factor=scale_factor,
159
+ )
160
+ for line in dsl_lines:
161
+ parts = line.split(maxsplit=2)
162
+ if not parts:
163
+ continue
164
+ cmd = parts[0].lower()
165
+
166
+ if cmd in (
167
+ "terminal",
168
+ "process",
169
+ "decision",
170
+ "io",
171
+ "connector",
172
+ "predefined",
173
+ ):
174
+ if len(parts) < 2:
175
+ continue
176
+ node_id = parts[1]
177
+ node_label: str = (
178
+ parts[2].strip('"').strip("'")
179
+ if len(parts) > 2
180
+ else node_id.upper()
181
+ )
182
+
183
+ if cmd == "terminal":
184
+ fc.terminal(node_id, node_label)
185
+ elif cmd == "process":
186
+ fc.process(node_id, node_label)
187
+ elif cmd == "decision":
188
+ fc.decision(node_id, node_label)
189
+ elif cmd == "io":
190
+ fc.io_box(node_id, node_label)
191
+ elif cmd == "connector":
192
+ fc.connector(node_id, node_label)
193
+ elif cmd == "predefined":
194
+ fc.predefined(node_id, node_label)
195
+
196
+ elif cmd == "edge":
197
+ subparts = re.findall(r'(?:[^\s"\']|"[^"]*"|\'[^\']*\')+', line)
198
+ if len(subparts) < 3:
199
+ continue
200
+ src = subparts[1]
201
+ dst = subparts[2]
202
+
203
+ label_val = None
204
+ orthogonal_val = False
205
+
206
+ for param in subparts[3:]:
207
+ if "=" in param:
208
+ pk, pv = param.split("=", 1)
209
+ pk = pk.strip().lower()
210
+ pv = pv.strip().strip('"').strip("'")
211
+ if pk == "orthogonal":
212
+ orthogonal_val = pv.lower() == "true"
213
+ elif pk == "label":
214
+ label_val = pv
215
+ else:
216
+ label_val = param.strip('"').strip("'")
217
+
218
+ fc.edge(src, dst, label=label_val or "", orthogonal=orthogonal_val)
219
+
220
+ return fc.as_flowable()
221
+
222
+ elif norm_block_type == "sequence":
223
+ seq = ed.SequenceDiagram(
224
+ width=width, height=height, theme=theme, caption=caption
225
+ )
226
+ for line in dsl_lines:
227
+ subparts = re.findall(r'(?:[^\s"\']|"[^"]*"|\'[^\']*\')+', line)
228
+ if not subparts:
229
+ continue
230
+ cmd = subparts[0].lower()
231
+
232
+ if cmd in ("actor", "participant"):
233
+ if len(subparts) < 2:
234
+ continue
235
+ actor_id = subparts[1]
236
+ actor_label = (
237
+ subparts[2].strip('"').strip("'")
238
+ if len(subparts) > 2
239
+ else actor_id.upper()
240
+ )
241
+
242
+ # Both commands map to seq.actor()
243
+ seq.actor(actor_id, actor_label)
244
+
245
+ elif cmd == "message":
246
+ if len(subparts) < 4:
247
+ continue
248
+ src = subparts[1]
249
+ dst = subparts[2]
250
+ msg_text = subparts[3].strip('"').strip("'")
251
+
252
+ seq.message(src, dst, msg_text)
253
+
254
+ elif cmd == "divider":
255
+ text_val = (
256
+ subparts[1].strip('"').strip("'") if len(subparts) > 1 else ""
257
+ )
258
+ seq.divider(text=text_val)
259
+
260
+ return seq.as_flowable()
261
+
262
+ elif norm_block_type == "layeredstack":
263
+ stack = ed.LayeredStack(
264
+ width=width, height=height, theme=theme, caption=caption
265
+ )
266
+ for line in dsl_lines:
267
+ subparts = re.findall(r'(?:[^\s"\']|"[^"]*"|\'[^\']*\')+', line)
268
+ if not subparts:
269
+ continue
270
+ cmd = subparts[0].lower()
271
+
272
+ if cmd == "layer":
273
+ if len(subparts) < 2:
274
+ continue
275
+ layer_label = subparts[1].strip('"').strip("'")
276
+ sublabel = (
277
+ subparts[2].strip('"').strip("'") if len(subparts) > 2 else None
278
+ )
279
+
280
+ stack.layer(layer_label, sublabel=sublabel or "")
281
+
282
+ return stack.as_flowable()
283
+
284
+ elif norm_block_type == "schema":
285
+ schema = ed.SchemaDiagram(
286
+ width=width, height=height, theme=theme, caption=caption
287
+ )
288
+ current_table = None
289
+ for line in dsl_lines:
290
+ table_match = re.match(
291
+ r"^table\s+(\w+)(?:\s+x\s*=\s*([0-9\.]+))?(?:\s+y\s*=\s*([0-9\.]+))?:?",
292
+ line,
293
+ re.IGNORECASE,
294
+ )
295
+ if table_match:
296
+ current_table = table_match.group(1)
297
+ tx = float(table_match.group(2)) if table_match.group(2) else 0.0
298
+ ty = float(table_match.group(3)) if table_match.group(3) else 0.0
299
+ schema.table(current_table, [], x=tx, y=ty)
300
+ continue
301
+
302
+ if line.lower().startswith("relation") or line.lower().startswith(
303
+ "relate"
304
+ ):
305
+ rel_line = re.sub(
306
+ r"^(?:relation|relate)\s+", "", line, flags=re.IGNORECASE
307
+ )
308
+ subparts = re.split(r"\s*(?:->|to|\s)\s*", rel_line)
309
+ subparts = [p for p in subparts if p]
310
+ if len(subparts) >= 2:
311
+ p1 = subparts[0]
312
+ p2 = subparts[1]
313
+ if "." in p1 and "." in p2:
314
+ ft, fc = p1.split(".", 1)
315
+ tt, tc = p2.split(".", 1)
316
+ schema.relation(
317
+ ft.strip(), fc.strip(), tt.strip(), tc.strip()
318
+ )
319
+ elif len(subparts) >= 4:
320
+ schema.relation(
321
+ subparts[0], subparts[1], subparts[2], subparts[3]
322
+ )
323
+ continue
324
+
325
+ if current_table and ":" in line:
326
+ parts_col = line.split(":", 1)
327
+ col_name = parts_col[0].strip()
328
+ rest = parts_col[1].strip()
329
+
330
+ relation_target = None
331
+ if "->" in rest:
332
+ rest, target = rest.split("->", 1)
333
+ relation_target = target.strip()
334
+
335
+ is_pk = "(pk)" in rest.lower()
336
+ is_fk = "(fk)" in rest.lower() or relation_target is not None
337
+
338
+ col_type = (
339
+ rest.replace("(pk)", "")
340
+ .replace("(PK)", "")
341
+ .replace("(fk)", "")
342
+ .replace("(FK)", "")
343
+ .strip()
344
+ )
345
+ if not col_type:
346
+ col_type = "VARCHAR"
347
+
348
+ schema._tables[current_table]["columns"].append(
349
+ (col_name, col_type, {"pk": is_pk, "fk": is_fk})
350
+ )
351
+
352
+ if relation_target:
353
+ if "." in relation_target:
354
+ target_table, target_col = relation_target.split(".", 1)
355
+ schema.relation(
356
+ current_table,
357
+ col_name,
358
+ target_table.strip(),
359
+ target_col.strip(),
360
+ )
361
+ else:
362
+ schema.relation(
363
+ current_table, col_name, relation_target, "id"
364
+ )
365
+
366
+ return schema.as_flowable()
367
+
368
+ elif norm_block_type == "git":
369
+ git = ed.GitDiagram(
370
+ width=width, height=height, theme=theme, caption=caption
371
+ )
372
+ known_branches = {"main"}
373
+ for line in dsl_lines:
374
+ kwargs = parse_kwargs(line)
375
+ parts = re.findall(r'(?:[^\s"\']|"[^"]*"|\'[^\']*\')+', line)
376
+ if not parts:
377
+ continue
378
+ cmd = parts[0].lower()
379
+
380
+ if cmd == "commit":
381
+ branch = kwargs.get("branch") or kwargs.get("branch_name")
382
+ label = (
383
+ kwargs.get("label")
384
+ or kwargs.get("msg")
385
+ or kwargs.get("message")
386
+ )
387
+ if not branch and not label:
388
+ if len(parts) > 1:
389
+ val = parts[1].strip("\"'")
390
+ if val in known_branches:
391
+ branch = val
392
+ if len(parts) > 2:
393
+ label = parts[2].strip("\"'")
394
+ else:
395
+ branch = "main"
396
+ label = val
397
+ if not branch:
398
+ branch = "main"
399
+ git.commit(branch, label or "")
400
+
401
+ elif cmd == "branch":
402
+ parent = kwargs.get("parent") or kwargs.get("from")
403
+ child = kwargs.get("child") or kwargs.get("to")
404
+ if not parent and not child:
405
+ if len(parts) > 2:
406
+ parent = parts[1].strip("\"'")
407
+ child = parts[2].strip("\"'")
408
+ if parent and child:
409
+ known_branches.add(child)
410
+ git.branch(parent, child)
411
+
412
+ elif cmd == "merge":
413
+ from_b = kwargs.get("from") or kwargs.get("from_branch")
414
+ to_b = kwargs.get("to") or kwargs.get("to_branch")
415
+ label = kwargs.get("label") or kwargs.get("msg")
416
+ if not from_b and not to_b:
417
+ if len(parts) > 2:
418
+ from_b = parts[1].strip("\"'")
419
+ to_b = parts[2].strip("\"'")
420
+ if len(parts) > 3:
421
+ label = parts[3].strip("\"'")
422
+ if from_b and to_b:
423
+ git.merge(from_b, to_b, label or "")
424
+
425
+ return git.as_flowable()
426
+
427
+ elif norm_block_type == "architecture":
428
+ arch = ed.ArchitectureDiagram(
429
+ width=width, height=height, theme=theme, caption=caption
430
+ )
431
+ for line in dsl_lines:
432
+ kwargs = parse_kwargs(line)
433
+ parts = re.findall(r'(?:[^\s"\']|"[^"]*"|\'[^\']*\')+', line)
434
+ if not parts:
435
+ continue
436
+ cmd = parts[0].lower()
437
+
438
+ if cmd in ("client", "service", "database", "queue"):
439
+ name = kwargs.get("name")
440
+ label = kwargs.get("label")
441
+ if not name and len(parts) > 1:
442
+ name = parts[1].strip("\"'")
443
+ if len(parts) > 2:
444
+ label = parts[2].strip("\"'")
445
+ if name:
446
+ if cmd == "client":
447
+ arch.client(name, label or "")
448
+ elif cmd == "service":
449
+ arch.service(name, label or "")
450
+ elif cmd == "database":
451
+ arch.database(name, label or "")
452
+ elif cmd == "queue":
453
+ arch.queue(name, label or "")
454
+ elif cmd in ("connect", "link", "edge"):
455
+ from_node = kwargs.get("from") or kwargs.get("src")
456
+ to_node = kwargs.get("to") or kwargs.get("dst")
457
+ label = kwargs.get("label")
458
+ if not from_node and not to_node and len(parts) > 2:
459
+ from_node = parts[1].strip("\"'")
460
+ to_node = parts[2].strip("\"'")
461
+ if len(parts) > 3:
462
+ label = parts[3].strip("\"'")
463
+ if from_node and to_node:
464
+ arch.connect(from_node, to_node, label or "")
465
+
466
+ return arch.as_flowable()
467
+
468
+ elif norm_block_type == "c4":
469
+ c4 = ed.C4ContainerDiagram(
470
+ width=width, height=height, theme=theme, caption=caption
471
+ )
472
+ for line in dsl_lines:
473
+ kwargs = parse_kwargs(line)
474
+ parts = re.findall(r'(?:[^\s"\']|"[^"]*"|\'[^\']*\')+', line)
475
+ if not parts:
476
+ continue
477
+ cmd = parts[0].lower()
478
+
479
+ if cmd == "system":
480
+ name = kwargs.get("name")
481
+ desc = kwargs.get("desc") or kwargs.get("description")
482
+ if not name and len(parts) > 1:
483
+ name = parts[1].strip("\"'")
484
+ if len(parts) > 2:
485
+ desc = parts[2].strip("\"'")
486
+ if name:
487
+ c4.system(name, desc or "")
488
+ elif cmd == "container":
489
+ name = kwargs.get("name")
490
+ tech = kwargs.get("tech") or kwargs.get("technology")
491
+ desc = kwargs.get("desc") or kwargs.get("description")
492
+ if not name and len(parts) > 1:
493
+ name = parts[1].strip("\"'")
494
+ if len(parts) > 2:
495
+ tech = parts[2].strip("\"'")
496
+ if len(parts) > 3:
497
+ desc = parts[3].strip("\"'")
498
+ if name:
499
+ c4.container(name, tech or "", desc or "")
500
+ elif cmd in ("relate", "connect", "link", "edge"):
501
+ from_item = kwargs.get("from") or kwargs.get("src")
502
+ to_item = kwargs.get("to") or kwargs.get("dst")
503
+ label = kwargs.get("label")
504
+ if not from_item and not to_item and len(parts) > 2:
505
+ from_item = parts[1].strip("\"'")
506
+ to_item = parts[2].strip("\"'")
507
+ if len(parts) > 3:
508
+ label = parts[3].strip("\"'")
509
+ if from_item and to_item:
510
+ c4.relate(from_item, to_item, label or "")
511
+
512
+ return c4.as_flowable()
513
+
514
+ elif norm_block_type == "aws":
515
+ aws = ed.AWSDiagram(
516
+ width=width, height=height, theme=theme, caption=caption
517
+ )
518
+ for line in dsl_lines:
519
+ kwargs = parse_kwargs(line)
520
+ parts = re.findall(r'(?:[^\s"\']|"[^"]*"|\'[^\']*\')+', line)
521
+ if not parts:
522
+ continue
523
+ cmd = parts[0].lower()
524
+
525
+ if cmd in ("ec2", "rds", "s3", "lambda", "lambda_fn", "sqs"):
526
+ name = kwargs.get("name")
527
+ label = kwargs.get("label")
528
+ if not name and len(parts) > 1:
529
+ name = parts[1].strip("\"'")
530
+ if len(parts) > 2:
531
+ label = parts[2].strip("\"'")
532
+ if name:
533
+ if cmd == "ec2":
534
+ aws.ec2(name, label or "")
535
+ elif cmd == "rds":
536
+ aws.rds(name, label or "")
537
+ elif cmd == "s3":
538
+ aws.s3(name, label or "")
539
+ elif cmd in ("lambda", "lambda_fn"):
540
+ aws.lambda_fn(name, label or "")
541
+ elif cmd == "sqs":
542
+ aws.sqs(name, label or "")
543
+ elif cmd in ("connect", "link", "edge"):
544
+ from_node = kwargs.get("from") or kwargs.get("src")
545
+ to_node = kwargs.get("to") or kwargs.get("dst")
546
+ label = kwargs.get("label")
547
+ if not from_node and not to_node and len(parts) > 2:
548
+ from_node = parts[1].strip("\"'")
549
+ to_node = parts[2].strip("\"'")
550
+ if len(parts) > 3:
551
+ label = parts[3].strip("\"'")
552
+ if from_node and to_node:
553
+ aws.connect(from_node, to_node, label or "")
554
+
555
+ return aws.as_flowable()
556
+
557
+ except Exception as exc:
558
+ sys.stderr.write(f"Warning: Failed to compile diagram DSL: {exc}\n")
559
+ return None
560
+
561
+ return None
562
+
563
+
564
+ def compile_markdown_to_pdf(
565
+ input_file: str,
566
+ output_file: Optional[str] = None,
567
+ theme_name: str = "dark",
568
+ title: Optional[str] = None,
569
+ author: Optional[str] = None,
570
+ ) -> None:
571
+ """
572
+ Parse a markdown file and compile it into a themed PDF notes document.
573
+ """
574
+ if not os.path.exists(input_file):
575
+ raise PDFCompilerError(f"Input file not found: {input_file}")
576
+
577
+ try:
578
+ with open(input_file, "r", encoding="utf-8") as f:
579
+ lines = f.readlines()
580
+ except Exception as exc:
581
+ raise PDFCompilerError(f"Failed to read input file {input_file}: {exc}")
582
+
583
+ metadata, content_lines = parse_metadata(lines)
584
+
585
+ doc_title = metadata.get("title", title)
586
+ doc_author = metadata.get("author", author)
587
+ doc_theme = metadata.get("theme", theme_name).lower()
588
+
589
+ en.set_story([])
590
+
591
+ all_themes = {
592
+ "dark": en.DARK,
593
+ "light": en.LIGHT,
594
+ "ocean-dark": en.OCEAN_DARK,
595
+ "forest-dark": en.FOREST_DARK,
596
+ "sunset-dark": en.SUNSET_DARK,
597
+ "midnight-dark": en.MIDNIGHT_DARK,
598
+ "ocean-light": en.OCEAN_LIGHT,
599
+ "sepia": en.SEPIA,
600
+ "catppuccin-latte": en.CATPPUCCIN_LATTE,
601
+ "catppuccin-mocha": en.CATPPUCCIN_MOCHA,
602
+ }
603
+
604
+ theme_obj = all_themes.get(doc_theme, en.DARK)
605
+ en.set_theme(theme_obj)
606
+
607
+ if doc_title:
608
+ en.bookmark("Cover Page")
609
+ en.suppress_footer(page_only=True)
610
+ en.add(Spacer(1, 40))
611
+ en.add(Table([[Paragraph(doc_title, en.COVER_H1)]], colWidths=[en.CW]))
612
+ en.add(Spacer(1, 10))
613
+ if doc_author:
614
+ en.add(Paragraph(f"Author: {doc_author}", en.COVER_SUB))
615
+ en.add(PageBreak())
616
+ en.toc()
617
+
618
+ en.footer(
619
+ left=doc_title if doc_title else "Study Notes",
620
+ right=doc_author if doc_author else "",
621
+ show_page_num=True,
622
+ )
623
+
624
+ parse_markdown_lines(content_lines)
625
+
626
+ if not output_file:
627
+ base, _ = os.path.splitext(input_file)
628
+ output_file = f"{base}.pdf"
629
+
630
+ try:
631
+ en.build_doc(output_file, title=doc_title, author=doc_author)
632
+ except Exception as exc:
633
+ raise PDFCompilerError(f"Failed to build PDF output {output_file}: {exc}")
634
+
635
+
636
+ def parse_markdown_lines(content_lines: List[str]) -> None:
637
+ """Parse markdown lines and append content flowables to the active story."""
638
+ in_code_block = False
639
+ code_block_lang = ""
640
+ code_block_lines: List[str] = []
641
+
642
+ in_alert = False
643
+ alert_type = ""
644
+ alert_lines: List[str] = []
645
+
646
+ bullet_items: List[str] = []
647
+
648
+ in_table = False
649
+ table_rows: List[List[str]] = []
650
+ table_header: List[str] = []
651
+
652
+ def flush_table() -> None:
653
+ nonlocal in_table, table_rows, table_header
654
+ if in_table and table_header:
655
+ formatted_header = [format_inline_markdown(h) for h in table_header]
656
+ formatted_rows = [
657
+ [format_inline_markdown(cell) for cell in row]
658
+ for row in table_rows
659
+ ]
660
+ en.info_table(formatted_header, formatted_rows)
661
+ table_rows.clear()
662
+ table_header.clear()
663
+ in_table = False
664
+
665
+ def flush_bullets() -> None:
666
+ if bullet_items:
667
+ en.bullet(bullet_items)
668
+ bullet_items.clear()
669
+
670
+ def flush_alert() -> None:
671
+ nonlocal in_alert, alert_type, alert_lines
672
+ if in_alert and alert_lines:
673
+ text = " ".join(alert_lines)
674
+ if alert_type == "note":
675
+ en.note(text)
676
+ elif alert_type == "tip":
677
+ en.tip(text)
678
+ elif alert_type in ("warning", "caution"):
679
+ en.highlight(text)
680
+ alert_lines.clear()
681
+ in_alert = False
682
+
683
+ idx = 0
684
+ while idx < len(content_lines):
685
+ line = content_lines[idx]
686
+ line_strip = line.strip()
687
+
688
+ if line_strip.startswith("```"):
689
+ if in_code_block:
690
+ if code_block_lang in (
691
+ "flowchart",
692
+ "sequence",
693
+ "layeredstack",
694
+ "schema",
695
+ "er",
696
+ "git",
697
+ "architecture",
698
+ "arch",
699
+ "c4",
700
+ "c4container",
701
+ "aws",
702
+ "cloud",
703
+ ):
704
+ diagram_flowables = parse_diagram_dsl(
705
+ code_block_lang, code_block_lines
706
+ )
707
+ if diagram_flowables:
708
+ for f in diagram_flowables:
709
+ en.add(f)
710
+ else:
711
+ code_text = "\n".join(code_block_lines)
712
+ en.code_block(code_text, lang=code_block_lang)
713
+
714
+ in_code_block = False
715
+ code_block_lines.clear()
716
+ else:
717
+ flush_bullets()
718
+ flush_alert()
719
+ in_code_block = True
720
+ code_block_lang = line_strip[3:].strip().lower()
721
+ code_block_lines = []
722
+
723
+ idx += 1
724
+ continue
725
+
726
+ if in_code_block:
727
+ code_block_lines.append(line.rstrip("\n"))
728
+ idx += 1
729
+ continue
730
+
731
+ # Table detection (GitHub-flavored markdown)
732
+ is_table_line = False
733
+ if (
734
+ not in_code_block
735
+ and not in_alert
736
+ and "|" in line_strip
737
+ and not line_strip.startswith(">")
738
+ ):
739
+ parts = [c.strip() for c in line_strip.split("|")]
740
+ if parts and parts[0] == "":
741
+ parts = parts[1:]
742
+ if parts and parts[-1] == "":
743
+ parts = parts[:-1]
744
+ if len(parts) >= 2:
745
+ is_separator = all(
746
+ re.match(r"^:?-+:?$", p) for p in parts
747
+ )
748
+ if is_separator:
749
+ in_table = True
750
+ idx += 1
751
+ is_table_line = True
752
+ elif not in_table:
753
+ table_header = parts
754
+ in_table = True
755
+ idx += 1
756
+ is_table_line = True
757
+ else:
758
+ table_rows.append(parts)
759
+ idx += 1
760
+ is_table_line = True
761
+
762
+ if in_table and not is_table_line:
763
+ flush_table()
764
+
765
+ if is_table_line:
766
+ continue
767
+
768
+ alert_match = re.match(
769
+ r"^>\s*\[!(NOTE|TIP|WARNING|CAUTION)\](.*)", line_strip, re.IGNORECASE
770
+ )
771
+ if alert_match:
772
+ flush_bullets()
773
+ flush_alert()
774
+ in_alert = True
775
+ alert_type = alert_match.group(1).lower()
776
+ initial_text = alert_match.group(2).strip()
777
+ if initial_text:
778
+ alert_lines.append(format_inline_markdown(initial_text))
779
+ idx += 1
780
+ continue
781
+
782
+ if in_alert:
783
+ if line_strip.startswith(">"):
784
+ content = line_strip[1:].strip()
785
+ if content:
786
+ alert_lines.append(format_inline_markdown(content))
787
+ idx += 1
788
+ continue
789
+ else:
790
+ flush_alert()
791
+
792
+ bullet_match = re.match(r"^^[\-\*\+]\s+(.*)", line_strip)
793
+ if bullet_match:
794
+ flush_alert()
795
+ bullet_items.append(format_inline_markdown(bullet_match.group(1)))
796
+ idx += 1
797
+ continue
798
+ elif line_strip:
799
+ if not in_alert:
800
+ flush_bullets()
801
+
802
+ if line_strip.startswith("#"):
803
+ flush_bullets()
804
+ flush_alert()
805
+ flush_table()
806
+
807
+ level = 0
808
+ while level < len(line_strip) and line_strip[level] == "#":
809
+ level += 1
810
+
811
+ title_text = line_strip[level:].strip()
812
+ title_formatted = format_inline_markdown(title_text)
813
+
814
+ if level == 1:
815
+ en.part_box(title_formatted)
816
+ elif level == 2:
817
+ en.chap_box(title_formatted)
818
+ elif level == 3:
819
+ en.section(title_formatted)
820
+ else:
821
+ en.subsection(title_formatted)
822
+
823
+ idx += 1
824
+ continue
825
+
826
+ if not line_strip:
827
+ flush_bullets()
828
+ flush_alert()
829
+ flush_table()
830
+ idx += 1
831
+ continue
832
+
833
+ en.body(format_inline_markdown(line_strip))
834
+ idx += 1
835
+
836
+ flush_bullets()
837
+ flush_alert()
838
+ flush_table()
839
+
840
+
841
+ def main() -> None:
842
+ """CLI entrypoint."""
843
+ parser = argparse.ArgumentParser(
844
+ description="Compile Markdown documents to themed ReportLab PDFs with native diagrams."
845
+ )
846
+ parser.add_argument("input", help="Path to the input markdown file.")
847
+ parser.add_argument(
848
+ "-o",
849
+ "--output",
850
+ help="Path to the output PDF file (defaults to same name with .pdf extension).",
851
+ )
852
+ parser.add_argument(
853
+ "-t",
854
+ "--theme",
855
+ default="dark",
856
+ help="Theme name (options: dark, light, ocean-dark, forest-dark, catppuccin-mocha, etc. Default: dark).",
857
+ )
858
+ parser.add_argument("--title", help="Document title metadata.")
859
+ parser.add_argument("--author", help="Document author metadata.")
860
+
861
+ args = parser.parse_args()
862
+
863
+ try:
864
+ compile_markdown_to_pdf(
865
+ input_file=args.input,
866
+ output_file=args.output,
867
+ theme_name=args.theme,
868
+ title=args.title,
869
+ author=args.author,
870
+ )
871
+ print(
872
+ f"Successfully compiled PDF document: {args.output if args.output else args.input.replace('.md', '.pdf')}"
873
+ )
874
+ except PDFCompilerError as err:
875
+ sys.stderr.write(f"Error: {err}\n")
876
+ sys.exit(1)
877
+ except Exception as exc:
878
+ sys.stderr.write(f"Unhandled error: {exc}\n")
879
+ sys.exit(1)
880
+
881
+
882
+ if __name__ == "__main__":
883
+ main()