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/__init__.py +236 -0
- engrapha_notes/cli.py +883 -0
- engrapha_notes/document.py +2691 -0
- engrapha_notes/helpers.py +4386 -0
- engrapha_notes/packet.py +282 -0
- engrapha_notes/palette.py +61 -0
- engrapha_notes/styles.py +28 -0
- engrapha_notes/theme.py +348 -0
- engrapha_notes-0.1.0.dist-info/METADATA +530 -0
- engrapha_notes-0.1.0.dist-info/RECORD +13 -0
- engrapha_notes-0.1.0.dist-info/WHEEL +5 -0
- engrapha_notes-0.1.0.dist-info/entry_points.txt +3 -0
- engrapha_notes-0.1.0.dist-info/top_level.txt +1 -0
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("<b>", "<b>").replace("</b>", "</b>")
|
|
63
|
+
escaped = escaped.replace("<i>", "<i>").replace("</i>", "</i>")
|
|
64
|
+
escaped = escaped.replace("<u>", "<u>").replace("</u>", "</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()
|