pyact-cli 0.8.0__tar.gz → 1.0.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyact-cli
3
- Version: 0.8.0
3
+ Version: 1.0.0
4
4
  Summary: A CLI tool to compile .pamd files to Markdown.
5
5
  Home-page: https://github.com/Abstergo2003/PyAct
6
6
  Author: Abstergo2003
@@ -112,4 +112,11 @@ title = Monthly Report
112
112
 
113
113
  PyAct will automatically detect `pamd.conf` in your directory, inject dynamic page numbers (`{page}` / `{pages}`), configure tab stops, and populate document metadata!
114
114
 
115
+ ### 7. Scaffolding New Projects with `pyact init`
116
+ Quickly spin up a new project ready to compile:
117
+ ```bash
118
+ pyact init my_project --template engineering
119
+ ```
120
+ Templates: `default`, `engineering`, `academic`.
121
+
115
122
  That's it! You now have documents ready to be shared, published, or distributed.
@@ -89,4 +89,11 @@ title = Monthly Report
89
89
 
90
90
  PyAct will automatically detect `pamd.conf` in your directory, inject dynamic page numbers (`{page}` / `{pages}`), configure tab stops, and populate document metadata!
91
91
 
92
+ ### 7. Scaffolding New Projects with `pyact init`
93
+ Quickly spin up a new project ready to compile:
94
+ ```bash
95
+ pyact init my_project --template engineering
96
+ ```
97
+ Templates: `default`, `engineering`, `academic`.
98
+
92
99
  That's it! You now have documents ready to be shared, published, or distributed.
@@ -52,6 +52,75 @@ def image(link: str, caption: str) -> str:
52
52
  caption_html = f"<span style='text-align: center; display: block; font-style: italic;'>{caption}</span>"
53
53
  return f"{image_link} \n {caption_html}"
54
54
 
55
+ def plot(fig_or_plt: Any, caption: str = "", filename: str = None, dpi: int = 300, output_dir: str = "images") -> str:
56
+ """
57
+ Saves a Matplotlib/Seaborn figure or pyplot module to an image file and returns a styled Markdown image string.
58
+
59
+ Why it is needed:
60
+ Allows data analysts and engineers to generate plots directly in Python code cells and
61
+ seamlessly embed them into both Markdown and Word (DOCX) documents without manual file saving.
62
+
63
+ Inputs:
64
+ fig_or_plt: A matplotlib Figure object or the matplotlib.pyplot module.
65
+ caption (str): Caption text to display below the plot.
66
+ filename (str): Optional file name (e.g. 'beam_deflection.png'). If omitted, a name is automatically generated.
67
+ dpi (int): Resolution of the exported image (default 300 dpi for sharp printing).
68
+ output_dir (str): Directory where the image should be saved (default 'images').
69
+
70
+ Outputs:
71
+ str: A Markdown image tag with an italicized caption span, ready for injection into the document.
72
+ """
73
+ import os
74
+ import time
75
+
76
+ if not os.path.exists(output_dir):
77
+ os.makedirs(output_dir, exist_ok=True)
78
+
79
+ if not filename:
80
+ if caption:
81
+ import re
82
+ clean_name = re.sub(r'[^a-zA-Z0-9_-]', '_', caption.strip().lower())[:30].strip('_')
83
+ filename = f"{clean_name}.png" if clean_name else f"plot_{int(time.time()*1000)}.png"
84
+ else:
85
+ filename = f"plot_{int(time.time()*1000)}.png"
86
+
87
+ if not filename.endswith(('.png', '.jpg', '.jpeg', '.svg')):
88
+ filename += '.png'
89
+
90
+ filepath = os.path.join(output_dir, filename)
91
+ norm_path = filepath.replace('\\', '/')
92
+
93
+ if hasattr(fig_or_plt, 'savefig'):
94
+ fig_or_plt.savefig(filepath, dpi=dpi, bbox_inches='tight')
95
+ elif hasattr(fig_or_plt, 'figure') and hasattr(fig_or_plt.figure, 'savefig'):
96
+ fig_or_plt.figure.savefig(filepath, dpi=dpi, bbox_inches='tight')
97
+ elif hasattr(fig_or_plt, 'save'):
98
+ fig_or_plt.save(filepath)
99
+ else:
100
+ raise TypeError(f"Expected a matplotlib Figure or image object with a 'savefig' or 'save' method, got {type(fig_or_plt)}")
101
+
102
+ return image(norm_path, caption)
103
+
104
+ def callout(text: str, callout_type: str = "note", title: str = None) -> str:
105
+ """
106
+ Generates a GitHub-flavored Markdown alert/callout box.
107
+
108
+ Inputs:
109
+ text (str): The body text of the alert.
110
+ callout_type (str): 'note', 'tip', 'important', 'warning', or 'caution'.
111
+ title (str): Optional title overriding the default alert header.
112
+
113
+ Outputs:
114
+ str: A blockquote formatted alert.
115
+ """
116
+ c_type = callout_type.upper()
117
+ lines = text.strip().split('\n')
118
+ header = f"> [!{c_type}]"
119
+ if title:
120
+ header += f" {title}"
121
+ body = "\n".join(f"> {line}" for line in lines)
122
+ return f"{header}\n{body}\n"
123
+
55
124
  def table(headers: List[str], values: List[List[Any]], caption: str, add_no: bool = False, starting_number: int = 1) -> str:
56
125
  """
57
126
  Generates a Markdown table from Python lists, including a caption.
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -2,7 +2,7 @@ import argparse
2
2
  import sys
3
3
  import os
4
4
  import time
5
- from .core import map_content, process_content, build_logs
5
+ from .core import map_content, process_content, build_logs, generate_markdown_toc
6
6
 
7
7
  def is_cache_valid(build_tree, cache_mtime, root_dir=None):
8
8
  if root_dir is not None:
@@ -38,6 +38,16 @@ def main():
38
38
  Writes the generated `.md` and `.docx` files to the filesystem and prints
39
39
  status messages to stdout/stderr.
40
40
  """
41
+ if len(sys.argv) > 1 and sys.argv[1] == "init":
42
+ init_parser = argparse.ArgumentParser(description="PyAct Project Initializer")
43
+ init_parser.add_argument("command", choices=["init"])
44
+ init_parser.add_argument("name", nargs="?", default=".", help="Project folder name (default: current directory)")
45
+ init_parser.add_argument("--template", choices=["default", "engineering", "academic"], default="default", help="Project template to scaffold")
46
+ init_args = init_parser.parse_args()
47
+ from .scaffold import init_project
48
+ init_project(init_args.name, init_args.template)
49
+ return
50
+
41
51
  parser = argparse.ArgumentParser(description="PyAct CLI - PAMD to Markdown compiler")
42
52
  parser.add_argument("input", help="Path to the main .pamd file")
43
53
  parser.add_argument("-o", "--output", help="Output file path (default prints to stdout)")
@@ -101,13 +111,14 @@ def main():
101
111
  f.write(f"- {log['file']}: {log['time_ms']:.2f} ms\n")
102
112
  f.write(f"\nTotal Build Time: {total_ms:.2f} ms\n")
103
113
 
114
+ md_output = generate_markdown_toc(content)
104
115
  if args.output:
105
116
  with open(args.output, "w", encoding="utf-8") as f:
106
- f.write(content)
117
+ f.write(md_output)
107
118
  print(f"Successfully compiled to {args.output}")
108
119
  else:
109
120
  if not args.docx:
110
- print(content)
121
+ print(md_output)
111
122
 
112
123
  if args.docx:
113
124
  from .mdTOword import style_parser, markdown_parser, docx_writer
@@ -257,4 +257,83 @@ def process_content(build_tree: dict, globals_dict: dict = None):
257
257
  "time_ms": elapsed_ms
258
258
  })
259
259
 
260
- return file_text
260
+ return file_text
261
+
262
+ def make_github_slug(heading: str) -> str:
263
+ """Creates a GitHub-compatible anchor slug from heading text."""
264
+ clean = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', heading)
265
+ clean = re.sub(r'[*_`]', '', clean)
266
+ clean = clean.lower().strip()
267
+ clean = re.sub(r'[^\w\s-]', '', clean, flags=re.UNICODE)
268
+ clean = re.sub(r'\s', '-', clean)
269
+ return clean
270
+
271
+ def generate_markdown_toc(text: str) -> str:
272
+ """
273
+ Replaces `<toc/>`, `<toc>`, `[toc]`, or `<toc title="..."/>` with a GitHub-compatible
274
+ Markdown Table of Contents.
275
+ """
276
+ toc_pattern = r'<(?:toc|TOC)(?:\s+title=["\'](.*?)["\'])?\s*/?>|\[toc\]|\[TOC\]'
277
+ m = re.search(toc_pattern, text)
278
+ if not m:
279
+ return text
280
+
281
+ toc_pos = m.start()
282
+ title_override = m.group(1)
283
+
284
+ # Scan headings after the TOC tag
285
+ text_after = text[toc_pos:]
286
+ lines = text_after.split('\n')
287
+
288
+ in_code = False
289
+ headings = []
290
+ for line in lines:
291
+ ls = line.strip()
292
+ if ls.startswith('```'):
293
+ in_code = not in_code
294
+ continue
295
+ if in_code:
296
+ continue
297
+ h_match = re.match(r'^(#{1,6})\s+(.*)$', ls)
298
+ if h_match:
299
+ level = len(h_match.group(1))
300
+ h_text = h_match.group(2).strip()
301
+ headings.append((level, h_text))
302
+
303
+ if not headings:
304
+ # Fallback to scanning whole document if no headings were found after TOC tag
305
+ lines = text.split('\n')
306
+ in_code = False
307
+ for line in lines:
308
+ ls = line.strip()
309
+ if ls.startswith('```'):
310
+ in_code = not in_code
311
+ continue
312
+ if in_code:
313
+ continue
314
+ h_match = re.match(r'^(#{1,6})\s+(.*)$', ls)
315
+ if h_match:
316
+ level = len(h_match.group(1))
317
+ h_text = h_match.group(2).strip()
318
+ headings.append((level, h_text))
319
+
320
+ if not headings:
321
+ return re.sub(toc_pattern, '', text, count=1)
322
+
323
+ base_level = min(h[0] for h in headings)
324
+ toc_lines = []
325
+ for level, h_text in headings:
326
+ indent = " " * (level - base_level)
327
+ clean_title = re.sub(r'[*_`]', '', h_text).strip()
328
+ slug = make_github_slug(h_text)
329
+ toc_lines.append(f"{indent}- [{clean_title}](#{slug})")
330
+
331
+ toc_header = ""
332
+ if title_override:
333
+ if title_override.lower() not in ('none', 'false', 'no'):
334
+ toc_header = f"## {title_override}\n"
335
+ else:
336
+ toc_header = "## Table of Contents\n"
337
+
338
+ full_toc = toc_header + "\n".join(toc_lines) + "\n"
339
+ return re.sub(toc_pattern, lambda _: full_toc, text, count=1)
@@ -36,7 +36,8 @@ def css_to_dict(css_string: str) -> dict:
36
36
  continue
37
37
  if ':' in line:
38
38
  key, val = line.split(':', 1)
39
- rules[key.strip()] = val.strip()
39
+ val_clean = re.sub(r'\s*!important\s*$', '', val.strip(), flags=re.IGNORECASE).strip()
40
+ rules[key.strip()] = val_clean
40
41
 
41
42
  # Handle comma-separated selectors (e.g., 'h1, h2, h3')
42
43
  for sel in selector.split(','):
@@ -1,7 +1,9 @@
1
+ import os
1
2
  import re
2
3
  from docx import Document
3
4
  from docx.shared import Pt, RGBColor, Inches, Cm, Mm
4
- from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_TAB_ALIGNMENT
5
+ from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_TAB_ALIGNMENT, WD_TAB_LEADER
6
+ from docx.enum.section import WD_SECTION, WD_ORIENT
5
7
  from docx.oxml import parse_xml, OxmlElement
6
8
  from docx.oxml.ns import nsdecls, qn
7
9
  import math2docx
@@ -31,35 +33,48 @@ def style_parser(css_path: str) -> dict:
31
33
  print(f"Warning: Could not parse CSS ({e})")
32
34
  return {}
33
35
 
36
+ def _parse_quote_block(quote_lines: list) -> tuple:
37
+ """Parses a group of quote lines into an alert callout or standard blockquote."""
38
+ clean_lines = [re.sub(r'^>\s?', '', ql).strip() for ql in quote_lines]
39
+ first_line = clean_lines[0] if clean_lines else ""
40
+ m = re.match(r'^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:\s+(.*))?$', first_line, re.IGNORECASE)
41
+ if m:
42
+ c_type = m.group(1).upper()
43
+ header_text = m.group(2).strip() if m.group(2) else ""
44
+ if len(clean_lines) > 1:
45
+ title = header_text if header_text else c_type.capitalize()
46
+ body = clean_lines[1:]
47
+ else:
48
+ title = c_type.capitalize()
49
+ body = [header_text] if header_text else []
50
+ return ('callout', c_type, title, body)
51
+ else:
52
+ return ('callout', 'BLOCKQUOTE', '', clean_lines)
53
+
34
54
  def markdown_parser(md_text: str) -> list:
35
55
  """
36
56
  Parses raw Markdown text into a structured list of block tuples.
37
-
38
- Why it is needed:
39
- Because python-docx generates documents block-by-block (paragraphs, tables, headings),
40
- we need to break down the raw string of Markdown text into distinct, classified chunks
41
- that our DOCX writer can loop through and translate into native Word elements.
42
-
43
- Inputs:
44
- md_text (str): The complete raw Markdown text string.
45
-
46
- Outputs:
47
- list: A list of tuples where each tuple represents a block element.
48
- Examples:
49
- - ('heading', 2, 'Title Text')
50
- - ('paragraph', 'Some plain text')
51
- - ('list', 'List item text', 'ordered')
52
- - ('math', 'x = 2')
53
- - ('table', ['|Header|', '|---|', '|Data|'])
54
57
  """
55
58
  blocks = []
56
59
  lines = md_text.split('\n')
57
60
 
58
61
  current_table = []
62
+ current_quote = []
59
63
 
60
64
  for line in lines:
61
65
  line_s = line.strip()
62
66
 
67
+ # Blockquote / Callout Parsing
68
+ if line_s.startswith('>'):
69
+ if current_table:
70
+ blocks.append(('table', current_table))
71
+ current_table = []
72
+ current_quote.append(line_s)
73
+ continue
74
+ elif current_quote:
75
+ blocks.append(_parse_quote_block(current_quote))
76
+ current_quote = []
77
+
63
78
  # Table Parsing
64
79
  if line_s.startswith('|') and line_s.endswith('|'):
65
80
  current_table.append(line_s)
@@ -71,8 +86,31 @@ def markdown_parser(md_text: str) -> list:
71
86
  if not line_s:
72
87
  continue
73
88
 
89
+ # Table of Contents Parsing
90
+ if line_s.lower() in ('<toc/>', '<toc>', '[toc]'):
91
+ blocks.append(('toc', 'Table of Contents'))
92
+
93
+ elif re.match(r'^<toc\s+title=["\'](.*?)["\']\s*/?>', line_s, re.IGNORECASE):
94
+ m = re.match(r'^<toc\s+title=["\'](.*?)["\']\s*/?>', line_s, re.IGNORECASE)
95
+ blocks.append(('toc', m.group(1)))
96
+
97
+ # Page Break Parsing
98
+ elif line_s.lower() in ('<pagebreak/>', '<pagebreak>', '<page-break/>', '<pb/>', '<pb>', '<div style="page-break-after: always;"></div>'):
99
+ blocks.append(('pagebreak',))
100
+
101
+ # Section Break Parsing
102
+ elif line_s.lower() in ('<sectionbreak/>', '<sectionbreak>', '<sb/>', '<sb>'):
103
+ blocks.append(('sectionbreak',))
104
+
105
+ # Landscape Orientation Blocks
106
+ elif line_s.lower() in ('<landscape>', '<landscape/>'):
107
+ blocks.append(('landscape_start',))
108
+
109
+ elif line_s.lower() in ('</landscape>',):
110
+ blocks.append(('landscape_end',))
111
+
74
112
  # Heading Parsing
75
- if line_s.startswith('#'):
113
+ elif line_s.startswith('#'):
76
114
  level = len(line_s) - len(line_s.lstrip('#'))
77
115
  text = line_s.lstrip('#').strip()
78
116
  blocks.append(('heading', level, text))
@@ -96,6 +134,8 @@ def markdown_parser(md_text: str) -> list:
96
134
 
97
135
  if current_table:
98
136
  blocks.append(('table', current_table))
137
+ if current_quote:
138
+ blocks.append(_parse_quote_block(current_quote))
99
139
 
100
140
  return blocks
101
141
 
@@ -288,24 +328,39 @@ def _add_hyperlink(paragraph, text, url, styles, tag):
288
328
  paragraph._p.append(hyperlink)
289
329
 
290
330
  def _add_image(paragraph, url, caption, styles):
291
- """Adds an inline image fetched from URL."""
331
+ """Adds an inline image fetched from URL or local file path."""
292
332
  try:
293
- req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
294
- with urllib.request.urlopen(req) as response:
295
- image_stream = io.BytesIO(response.read())
296
-
333
+ image_stream = None
334
+ if url.startswith(('http://', 'https://')):
335
+ req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
336
+ with urllib.request.urlopen(req) as response:
337
+ image_stream = io.BytesIO(response.read())
338
+ elif os.path.exists(url):
339
+ with open(url, 'rb') as f:
340
+ image_stream = io.BytesIO(f.read())
341
+ elif os.path.exists(os.path.abspath(url)):
342
+ with open(os.path.abspath(url), 'rb') as f:
343
+ image_stream = io.BytesIO(f.read())
344
+
345
+ if image_stream is None:
346
+ paragraph.add_run(f"[Image Failed: {url}]")
347
+ return
348
+
297
349
  run = paragraph.add_run()
298
350
 
299
351
  # Apply CSS dimensions if available
300
352
  width = None
301
353
  if 'image' in styles and 'width' in styles['image']:
302
- w_str = styles['image']['width']
303
- if 'in' in w_str: width = docx.shared.Inches(float(w_str.replace('in', '').strip()))
354
+ w_str = str(styles['image']['width']).strip().lower()
355
+ if 'in' in w_str: width = Inches(float(w_str.replace('in', '').strip()))
356
+ elif 'cm' in w_str: width = Cm(float(w_str.replace('cm', '').strip()))
357
+ elif 'mm' in w_str: width = Mm(float(w_str.replace('mm', '').strip()))
358
+ elif 'pt' in w_str: width = Pt(float(w_str.replace('pt', '').strip()))
304
359
 
305
360
  if width:
306
361
  run.add_picture(image_stream, width=width)
307
362
  else:
308
- run.add_picture(image_stream, width=docx.shared.Inches(4))
363
+ run.add_picture(image_stream, width=Inches(4))
309
364
 
310
365
  except Exception as e:
311
366
  paragraph.add_run(f"[Image Failed: {url}]")
@@ -368,8 +423,8 @@ def _process_inline(paragraph, text, styles, tag):
368
423
  _add_internal_hyperlink(paragraph, f"[{fn_id}]", f"footnote_{fn_id}")
369
424
 
370
425
  else:
371
- # Handle plain text + bold/italic
372
- subparts = re.split(r'(\*\*.*?\*\*|\*.*?\*)', part)
426
+ # Handle plain text + bold/italic + inline code
427
+ subparts = re.split(r'(\*\*.*?\*\*|\*.*?\*|`[^`]+`)', part)
373
428
  for subpart in subparts:
374
429
  if not subpart: continue
375
430
  if subpart.startswith('**') and subpart.endswith('**'):
@@ -380,6 +435,13 @@ def _process_inline(paragraph, text, styles, tag):
380
435
  run = paragraph.add_run(subpart[1:-1])
381
436
  run.italic = True
382
437
  if tag in styles: _apply_css_to_run(run, styles[tag])
438
+ elif subpart.startswith('`') and subpart.endswith('`') and len(subpart) >= 2:
439
+ run = paragraph.add_run(subpart[1:-1])
440
+ run.font.name = "Consolas"
441
+ run.font.size = Pt(9.5)
442
+ rPr = run._r.get_or_add_rPr()
443
+ rPr.append(parse_xml(f'<w:shd {nsdecls("w")} w:fill="F0F2F5"/>'))
444
+ if 'code' in styles: _apply_css_to_run(run, styles['code'])
383
445
  else:
384
446
  run = paragraph.add_run(subpart)
385
447
  if tag in styles: _apply_css_to_run(run, styles[tag])
@@ -705,23 +767,444 @@ def _apply_docx_config(doc: Document, config: dict):
705
767
  if footer_conf:
706
768
  _configure_header_or_footer(section.footer, footer_conf, section)
707
769
 
708
- def docx_writer(blocks: list, styles: dict, output_file: str, config: dict = None):
770
+ def _add_callout_box(doc, callout_type: str, title: str, body_lines: list, styles: dict):
771
+ """Renders an alert callout box or blockquote as a styled 1x1 table."""
772
+ table = doc.add_table(rows=1, cols=1)
773
+ table.autofit = False
774
+
775
+ theme = {
776
+ 'NOTE': {'border': '0969DA', 'bg': 'F0F7FF', 'title_col': (9, 105, 218)},
777
+ 'TIP': {'border': '1A7F37', 'bg': 'F0FFF4', 'title_col': (26, 127, 55)},
778
+ 'IMPORTANT': {'border': '8250DF', 'bg': 'F8F5FF', 'title_col': (130, 80, 223)},
779
+ 'WARNING': {'border': '9A6700', 'bg': 'FFFBEB', 'title_col': (154, 103, 0)},
780
+ 'CAUTION': {'border': 'CF222E', 'bg': 'FFF1F2', 'title_col': (207, 34, 46)},
781
+ 'BLOCKQUOTE': {'border': 'D0D7DE', 'bg': 'F6F8FA', 'title_col': (87, 96, 106)},
782
+ }
783
+ cfg = theme.get(callout_type.upper(), theme['NOTE'])
784
+
785
+ tblPr = table._tbl.tblPr
786
+ tblW = parse_xml(f'<w:tblW {nsdecls("w")} w:w="5000" w:type="pct"/>')
787
+ tblPr.append(tblW)
788
+
789
+ tblBorders = parse_xml(f'''
790
+ <w:tblBorders {nsdecls("w")}>
791
+ <w:top w:val="none"/>
792
+ <w:left w:val="none"/>
793
+ <w:bottom w:val="none"/>
794
+ <w:right w:val="none"/>
795
+ <w:insideH w:val="none"/>
796
+ <w:insideV w:val="none"/>
797
+ </w:tblBorders>
798
+ ''')
799
+ tblPr.append(tblBorders)
800
+
801
+ cell = table.cell(0, 0)
802
+ tcPr = cell._tc.get_or_add_tcPr()
803
+
804
+ shd = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{cfg["bg"]}"/>')
805
+ tcPr.append(shd)
806
+
807
+ tcBorders = parse_xml(f'''
808
+ <w:tcBorders {nsdecls("w")}>
809
+ <w:top w:val="none"/>
810
+ <w:left w:val="single" w:sz="36" w:space="0" w:color="{cfg['border']}"/>
811
+ <w:bottom w:val="none"/>
812
+ <w:right w:val="none"/>
813
+ </w:tcBorders>
814
+ ''')
815
+ tcPr.append(tcBorders)
816
+
817
+ tcMar = parse_xml(f'''
818
+ <w:tcMar {nsdecls("w")}>
819
+ <w:top w:w="120" w:type="dxa"/>
820
+ <w:bottom w:w="120" w:type="dxa"/>
821
+ <w:left w:w="180" w:type="dxa"/>
822
+ <w:right w:w="180" w:type="dxa"/>
823
+ </w:tcMar>
824
+ ''')
825
+ tcPr.append(tcMar)
826
+
827
+ p = cell.paragraphs[0]
828
+ p.paragraph_format.space_before = Pt(2)
829
+ p.paragraph_format.space_after = Pt(3)
830
+
831
+ if title:
832
+ run_title = p.add_run()
833
+ if callout_type != 'BLOCKQUOTE':
834
+ if title.upper() == callout_type:
835
+ run_title.text = callout_type
836
+ else:
837
+ run_title.text = f"{callout_type}: {title}"
838
+ else:
839
+ run_title.text = title
840
+ run_title.bold = True
841
+ r, g, b = cfg['title_col']
842
+ run_title.font.color.rgb = RGBColor(r, g, b)
843
+
844
+ for idx, line in enumerate(body_lines):
845
+ if not title and idx == 0:
846
+ p_body = p
847
+ else:
848
+ p_body = cell.add_paragraph()
849
+ p_body.paragraph_format.space_before = Pt(0)
850
+ p_body.paragraph_format.space_after = Pt(2)
851
+ _process_inline(p_body, line, styles, 'p')
852
+
853
+ p_spacer = doc.add_paragraph()
854
+ p_spacer.paragraph_format.space_before = Pt(0)
855
+ p_spacer.paragraph_format.space_after = Pt(4)
856
+
857
+ def _add_table_of_contents(doc, title="Table of Contents", headings=None, styles=None):
858
+ """Inserts a native Word Table of Contents with dynamic updating and pre-populated entries."""
859
+ if headings is None:
860
+ headings = []
861
+ if styles is None:
862
+ styles = {}
863
+
864
+ try:
865
+ settings = doc.settings.element
866
+ if not settings.xpath('w:updateFields'):
867
+ update_fields = OxmlElement('w:updateFields')
868
+ update_fields.set(qn('w:val'), 'true')
869
+ settings.append(update_fields)
870
+ except Exception:
871
+ pass
872
+
873
+ if title and title.lower() not in ('none', 'false', 'no'):
874
+ p_title = doc.add_heading(title, level=1)
875
+ if 'h1' in styles:
876
+ _apply_paragraph_formatting(p_title, styles['h1'])
877
+
878
+ p_first = doc.add_paragraph()
879
+ p_first.paragraph_format.space_before = Pt(2)
880
+ p_first.paragraph_format.space_after = Pt(2)
881
+
882
+ r_begin = p_first.add_run()
883
+ fld1 = OxmlElement('w:fldChar')
884
+ fld1.set(qn('w:fldCharType'), 'begin')
885
+ r_begin._r.append(fld1)
886
+
887
+ r_instr = p_first.add_run()
888
+ instr = OxmlElement('w:instrText')
889
+ instr.set(qn('xml:space'), 'preserve')
890
+ instr.text = ' TOC \\o "1-3" \\h \\z \\u '
891
+ r_instr._r.append(instr)
892
+
893
+ r_sep = p_first.add_run()
894
+ fld2 = OxmlElement('w:fldChar')
895
+ fld2.set(qn('w:fldCharType'), 'separate')
896
+ r_sep._r.append(fld2)
897
+
898
+ if headings:
899
+ base_level = min(h[1] for h in headings)
900
+ section = doc.sections[-1]
901
+ content_width = section.page_width - section.left_margin - section.right_margin
902
+
903
+ for i, h_block in enumerate(headings):
904
+ h_level, h_text = h_block[1], h_block[2]
905
+ bm_name = f"_Toc_{i}"
906
+ rel_level = h_level - base_level
907
+ indent_in = max(0, rel_level * 0.25)
908
+
909
+ p = p_first if i == 0 else doc.add_paragraph()
910
+ p.paragraph_format.space_before = Pt(1)
911
+ p.paragraph_format.space_after = Pt(2)
912
+ if indent_in > 0:
913
+ p.paragraph_format.left_indent = Inches(indent_in)
914
+
915
+ tab_pos = content_width - Inches(indent_in)
916
+ p.paragraph_format.tab_stops.add_tab_stop(tab_pos, WD_TAB_ALIGNMENT.RIGHT, WD_TAB_LEADER.DOTS)
917
+
918
+ hyperlink = OxmlElement('w:hyperlink')
919
+ hyperlink.set(qn('w:anchor'), bm_name)
920
+
921
+ r_text = OxmlElement('w:r')
922
+ t = OxmlElement('w:t')
923
+ t.text = h_text
924
+ r_text.append(t)
925
+ hyperlink.append(r_text)
926
+
927
+ r_tab = OxmlElement('w:r')
928
+ tab = OxmlElement('w:tab')
929
+ r_tab.append(tab)
930
+ hyperlink.append(r_tab)
931
+
932
+ fld_p1 = OxmlElement('w:fldChar')
933
+ fld_p1.set(qn('w:fldCharType'), 'begin')
934
+ r_p1 = OxmlElement('w:r')
935
+ r_p1.append(fld_p1)
936
+
937
+ instr_p = OxmlElement('w:instrText')
938
+ instr_p.set(qn('xml:space'), 'preserve')
939
+ instr_p.text = f' PAGEREF {bm_name} \\h '
940
+ r_pinstr = OxmlElement('w:r')
941
+ r_pinstr.append(instr_p)
942
+
943
+ fld_p2 = OxmlElement('w:fldChar')
944
+ fld_p2.set(qn('w:fldCharType'), 'separate')
945
+ r_p2 = OxmlElement('w:r')
946
+ r_p2.append(fld_p2)
947
+
948
+ r_pnum = OxmlElement('w:r')
949
+ t_pnum = OxmlElement('w:t')
950
+ t_pnum.text = str(max(1, h_level))
951
+ r_pnum.append(t_pnum)
952
+
953
+ fld_p3 = OxmlElement('w:fldChar')
954
+ fld_p3.set(qn('w:fldCharType'), 'end')
955
+ r_p3 = OxmlElement('w:r')
956
+ r_p3.append(fld_p3)
957
+
958
+ hyperlink.append(r_p1)
959
+ hyperlink.append(r_pinstr)
960
+ hyperlink.append(r_p2)
961
+ hyperlink.append(r_pnum)
962
+ hyperlink.append(r_p3)
963
+
964
+ p._p.append(hyperlink)
965
+
966
+ p_end = doc.add_paragraph()
967
+ p_end.paragraph_format.space_before = Pt(0)
968
+ p_end.paragraph_format.space_after = Pt(4)
969
+ r_end = p_end.add_run()
970
+ fld_end = OxmlElement('w:fldChar')
971
+ fld_end.set(qn('w:fldCharType'), 'end')
972
+ r_end._r.append(fld_end)
973
+
974
+ def _parse_border_component(rules: dict, prefix: str = 'border'):
709
975
  """
710
- Translates parsed markdown blocks into a Word document and applies CSS styling.
976
+ Parses border component from rules dict (e.g. prefix='border' or 'border-top').
977
+ Returns (val, sz, color) or None if not specified.
978
+ """
979
+ val = None
980
+ sz = None
981
+ color = None
711
982
 
712
- Why it is needed:
713
- This is the core generation engine. It takes the abstract syntax structure generated
714
- by `markdown_parser()` and the style rules from `style_parser()`, loops through them,
715
- and issues the corresponding `python-docx` commands to physically build the `.docx` file.
983
+ shorthand = rules.get(prefix, '')
984
+ if shorthand:
985
+ for p in shorthand.split():
986
+ p_low = p.lower()
987
+ if p_low in ('none', 'hidden'):
988
+ val = 'none'
989
+ elif p_low in ('solid', 'dashed', 'dotted', 'double'):
990
+ val = 'single' if p_low == 'solid' else p_low
991
+ elif '#' in p:
992
+ hex_c = p.replace('#', '').strip()
993
+ if len(hex_c) == 3: hex_c = ''.join([c*2 for c in hex_c])
994
+ if len(hex_c) == 6: color = hex_c.upper()
995
+ elif 'pt' in p_low or 'px' in p_low or p_low.isdigit():
996
+ num = float(re.sub(r'[^\d.]', '', p_low) or '1')
997
+ sz = str(max(1, int(num * 8)))
998
+
999
+ style_key = f"{prefix}-style" if prefix != 'border' else 'border-style'
1000
+ if style_key in rules:
1001
+ bs = rules[style_key].lower()
1002
+ if bs in ('none', 'hidden'): val = 'none'
1003
+ elif bs == 'solid': val = 'single'
1004
+ elif bs in ('dashed', 'dotted', 'double'): val = bs
1005
+
1006
+ color_key = f"{prefix}-color" if prefix != 'border' else 'border-color'
1007
+ if color_key in rules:
1008
+ hex_c = rules[color_key].replace('#', '').strip()
1009
+ if len(hex_c) == 3: hex_c = ''.join([c*2 for c in hex_c])
1010
+ if len(hex_c) == 6: color = hex_c.upper()
1011
+
1012
+ width_key = f"{prefix}-width" if prefix != 'border' else 'border-width'
1013
+ if width_key in rules:
1014
+ num = float(re.sub(r'[^\d.]', '', rules[width_key]) or '1')
1015
+ sz = str(max(1, int(num * 8)))
1016
+
1017
+ return val, sz, color
1018
+
1019
+ def _apply_table_styling(table, table_lines: list, styles: dict):
1020
+ """
1021
+ Applies CSS styling to a python-docx table, including borders, cell padding,
1022
+ header shading, alignments, and Word table properties.
1023
+ """
1024
+ t_styles = styles.get('table', {})
1025
+ th_styles = styles.get('th', styles.get('table_header', {}))
1026
+ td_styles = styles.get('td', styles.get('table_cell', {}))
1027
+ zebra_styles = styles.get('tr:nth-child(even)', styles.get('table_row:nth-child(even)', {}))
716
1028
 
717
- Inputs:
718
- blocks (list): The list of parsed Markdown block tuples (from `markdown_parser`).
719
- styles (dict): The nested dictionary of CSS rules (from `style_parser`).
720
- output_file (str): The absolute or relative file path where the resulting .docx should be saved.
721
- config (dict): Optional dictionary of global settings parsed from pamd.conf (header, footer, metadata).
1029
+ # 1. Built-in Word table style if specified (e.g. style: "Table Grid")
1030
+ if 'style' in t_styles:
1031
+ s_name = t_styles['style'].strip("'\"")
1032
+ try:
1033
+ table.style = s_name
1034
+ except Exception:
1035
+ pass
1036
+
1037
+ tblPr = table._tbl.tblPr
1038
+
1039
+ # 2. Table Width
1040
+ width_val = t_styles.get('width', '100%').strip().lower()
1041
+ if width_val in ('100%', 'full'):
1042
+ tblPr.append(parse_xml(f'<w:tblW {nsdecls("w")} w:w="5000" w:type="pct"/>'))
1043
+ elif width_val == 'auto':
1044
+ tblPr.append(parse_xml(f'<w:tblW {nsdecls("w")} w:w="0" w:type="auto"/>'))
1045
+ else:
1046
+ m = re.search(r'([\d.]+)\s*(in|cm|mm|pt)?', width_val)
1047
+ if m:
1048
+ val, unit = float(m.group(1)), m.group(2)
1049
+ if unit == 'in': dxa = int(val * 1440)
1050
+ elif unit == 'cm': dxa = int(val * 567)
1051
+ elif unit == 'mm': dxa = int(val * 56.7)
1052
+ elif unit == 'pt': dxa = int(val * 20)
1053
+ else: dxa = int(val * 1440)
1054
+ tblPr.append(parse_xml(f'<w:tblW {nsdecls("w")} w:w="{dxa}" w:type="dxa"/>'))
1055
+
1056
+ # 3. Table Alignment (center, left, right)
1057
+ align_val = t_styles.get('text-align', t_styles.get('align', 'center')).strip().lower()
1058
+ if align_val in ('center', 'left', 'right'):
1059
+ tblPr.append(parse_xml(f'<w:jc {nsdecls("w")} w:val="{align_val}"/>'))
722
1060
 
723
- Outputs:
724
- None: This function does not return a value, but it writes a binary .docx file to disk.
1061
+ # 4. Table Borders
1062
+ def_val = "single"
1063
+ def_sz = "4" # 0.5 pt
1064
+ def_col = "D0D7DE"
1065
+
1066
+ b_val, b_sz, b_col = _parse_border_component(t_styles, 'border')
1067
+ if b_val is None: b_val = def_val
1068
+ if b_sz is None: b_sz = def_sz
1069
+ if b_col is None: b_col = def_col
1070
+
1071
+ sides = {}
1072
+ for side in ('top', 'left', 'bottom', 'right', 'insideH', 'insideV'):
1073
+ css_key = f"border-{side.lower()}"
1074
+ s_val, s_sz, s_col = _parse_border_component(t_styles, css_key)
1075
+ sides[side] = {
1076
+ 'val': s_val if s_val is not None else b_val,
1077
+ 'sz': s_sz if s_sz is not None else b_sz,
1078
+ 'color': s_col if s_col is not None else b_col,
1079
+ }
1080
+
1081
+ border_elements = []
1082
+ for side, cfg in sides.items():
1083
+ if cfg['val'] == 'none':
1084
+ border_elements.append(f'<w:{side} w:val="none"/>')
1085
+ else:
1086
+ border_elements.append(f'<w:{side} w:val="{cfg["val"]}" w:sz="{cfg["sz"]}" w:space="0" w:color="{cfg["color"]}"/>')
1087
+
1088
+ tblBorders = parse_xml(f'''
1089
+ <w:tblBorders {nsdecls("w")}>
1090
+ {''.join(border_elements)}
1091
+ </w:tblBorders>
1092
+ ''')
1093
+ tblPr.append(tblBorders)
1094
+
1095
+ # 5. Cell Margins / Padding
1096
+ pad_val = t_styles.get('padding', t_styles.get('cell-padding', '')).strip().lower()
1097
+ top_dxa, bot_dxa, left_dxa, right_dxa = 120, 120, 160, 160
1098
+ if pad_val:
1099
+ nums = [float(n) for n in re.findall(r'[\d.]+', pad_val)]
1100
+ if len(nums) == 1:
1101
+ d = int(nums[0] * 20)
1102
+ top_dxa, bot_dxa, left_dxa, right_dxa = d, d, d, d
1103
+ elif len(nums) >= 2:
1104
+ tb = int(nums[0] * 20)
1105
+ lr = int(nums[1] * 20)
1106
+ top_dxa, bot_dxa, left_dxa, right_dxa = tb, tb, lr, lr
1107
+
1108
+ tblCellMar = parse_xml(f'''
1109
+ <w:tblCellMar {nsdecls("w")}>
1110
+ <w:top w:w="{top_dxa}" w:type="dxa"/>
1111
+ <w:bottom w:w="{bot_dxa}" w:type="dxa"/>
1112
+ <w:left w:w="{left_dxa}" w:type="dxa"/>
1113
+ <w:right w:w="{right_dxa}" w:type="dxa"/>
1114
+ </w:tblCellMar>
1115
+ ''')
1116
+ tblPr.append(tblCellMar)
1117
+
1118
+ # 6. Parse Markdown Column Alignments
1119
+ col_aligns = []
1120
+ for line in table_lines:
1121
+ if '---' in line:
1122
+ parts = [c.strip() for c in line.strip('|').split('|')]
1123
+ for p in parts:
1124
+ if p.startswith(':') and p.endswith(':'):
1125
+ col_aligns.append(WD_ALIGN_PARAGRAPH.CENTER)
1126
+ elif p.endswith(':'):
1127
+ col_aligns.append(WD_ALIGN_PARAGRAPH.RIGHT)
1128
+ elif p.startswith(':'):
1129
+ col_aligns.append(WD_ALIGN_PARAGRAPH.LEFT)
1130
+ else:
1131
+ col_aligns.append(None)
1132
+ break
1133
+
1134
+ # 7. Rows: Repeat header on pages & prevent row splitting
1135
+ if len(table.rows) > 0:
1136
+ table.rows[0]._tr.get_or_add_trPr().append(parse_xml(f'<w:tblHeader {nsdecls("w")}/>'))
1137
+ for row in table.rows:
1138
+ row._tr.get_or_add_trPr().append(parse_xml(f'<w:cantSplit {nsdecls("w")}/>'))
1139
+
1140
+ # 8. Header background color
1141
+ th_bg = th_styles.get('background-color', th_styles.get('background', '')).replace('#', '').strip()
1142
+ if not th_bg and 'background-color' in styles.get('table_header', {}):
1143
+ th_bg = styles['table_header']['background-color'].replace('#', '').strip()
1144
+ if not th_bg:
1145
+ th_bg = 'F2F4F8'
1146
+ if len(th_bg) == 3: th_bg = ''.join([c*2 for c in th_bg])
1147
+
1148
+ # Apply to Header Row
1149
+ if len(table.rows) > 0:
1150
+ for c_idx, cell in enumerate(table.rows[0].cells):
1151
+ tcPr = cell._tc.get_or_add_tcPr()
1152
+ if len(th_bg) == 6:
1153
+ tcPr.append(parse_xml(f'<w:shd {nsdecls("w")} w:fill="{th_bg.upper()}"/>'))
1154
+ p = cell.paragraphs[0]
1155
+ if c_idx < len(col_aligns) and col_aligns[c_idx] is not None:
1156
+ p.alignment = col_aligns[c_idx]
1157
+ elif 'text-align' in th_styles:
1158
+ ta = th_styles['text-align'].lower()
1159
+ if ta == 'center': p.alignment = WD_ALIGN_PARAGRAPH.CENTER
1160
+ elif ta == 'right': p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
1161
+ elif ta == 'left': p.alignment = WD_ALIGN_PARAGRAPH.LEFT
1162
+ else:
1163
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
1164
+
1165
+ for r in p.runs:
1166
+ if 'font-weight' not in th_styles or th_styles['font-weight'].lower() == 'bold':
1167
+ r.bold = True
1168
+ if 'color' in th_styles:
1169
+ hc = th_styles['color'].replace('#', '').strip()
1170
+ if len(hc) == 6:
1171
+ r.font.color.rgb = RGBColor(int(hc[0:2],16), int(hc[2:4],16), int(hc[4:6],16))
1172
+ if 'font-family' in th_styles:
1173
+ r.font.name = th_styles['font-family'].strip("'\"")
1174
+ if 'font-size' in th_styles:
1175
+ sz_num = re.sub(r'[^\d.]', '', th_styles['font-size'])
1176
+ if sz_num: r.font.size = Pt(float(sz_num))
1177
+
1178
+ # 9. Apply to Body Rows
1179
+ zebra_bg = zebra_styles.get('background-color', zebra_styles.get('background', '')).replace('#', '').strip()
1180
+ if len(zebra_bg) == 3: zebra_bg = ''.join([c*2 for c in zebra_bg])
1181
+
1182
+ for r_idx in range(1, len(table.rows)):
1183
+ is_even = (r_idx % 2 == 0)
1184
+ row_bg = zebra_bg if (is_even and len(zebra_bg) == 6) else None
1185
+
1186
+ for c_idx, cell in enumerate(table.rows[r_idx].cells):
1187
+ tcPr = cell._tc.get_or_add_tcPr()
1188
+ if row_bg:
1189
+ tcPr.append(parse_xml(f'<w:shd {nsdecls("w")} w:fill="{row_bg.upper()}"/>'))
1190
+ elif 'background-color' in td_styles:
1191
+ td_bg = td_styles['background-color'].replace('#', '').strip()
1192
+ if len(td_bg) == 3: td_bg = ''.join([c*2 for c in td_bg])
1193
+ if len(td_bg) == 6:
1194
+ tcPr.append(parse_xml(f'<w:shd {nsdecls("w")} w:fill="{td_bg.upper()}"/>'))
1195
+
1196
+ p = cell.paragraphs[0]
1197
+ if c_idx < len(col_aligns) and col_aligns[c_idx] is not None:
1198
+ p.alignment = col_aligns[c_idx]
1199
+ elif 'text-align' in td_styles:
1200
+ ta = td_styles['text-align'].lower()
1201
+ if ta == 'center': p.alignment = WD_ALIGN_PARAGRAPH.CENTER
1202
+ elif ta == 'right': p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
1203
+ elif ta == 'left': p.alignment = WD_ALIGN_PARAGRAPH.LEFT
1204
+
1205
+ def docx_writer(blocks: list, styles: dict, output_file: str, config: dict = None):
1206
+ """
1207
+ Translates parsed markdown blocks into a Word document and applies CSS styling.
725
1208
  """
726
1209
  doc = Document()
727
1210
  current_ordered_numId = None
@@ -736,13 +1219,47 @@ def docx_writer(blocks: list, styles: dict, output_file: str, config: dict = Non
736
1219
  _apply_css_to_run(normal_style, styles['body'])
737
1220
  _apply_paragraph_formatting(normal_style, styles['body'])
738
1221
 
1222
+ # Pre-scan headings for Table of Contents
1223
+ headings_for_toc = []
1224
+ toc_seen_scan = False
1225
+ for b in blocks:
1226
+ if b[0] == 'toc':
1227
+ toc_seen_scan = True
1228
+ continue
1229
+ if b[0] == 'heading' and toc_seen_scan:
1230
+ headings_for_toc.append(b)
1231
+
1232
+ if not headings_for_toc:
1233
+ headings_for_toc = [b for b in blocks if b[0] == 'heading']
1234
+
1235
+ toc_seen = False
1236
+ heading_counter = 0
1237
+
739
1238
  for block in blocks:
740
1239
  btype = block[0]
741
1240
 
742
1241
  if btype == 'heading':
743
1242
  level, text = block[1], block[2]
744
1243
  p = doc.add_heading('', level=level)
745
- _process_inline(p, text, styles, f'h{level}')
1244
+
1245
+ if toc_seen:
1246
+ bm_name = f"_Toc_{heading_counter}"
1247
+ bm_id = str(2000 + heading_counter)
1248
+ heading_counter += 1
1249
+
1250
+ bm_start = OxmlElement('w:bookmarkStart')
1251
+ bm_start.set(qn('w:id'), bm_id)
1252
+ bm_start.set(qn('w:name'), bm_name)
1253
+ p._p.append(bm_start)
1254
+
1255
+ _process_inline(p, text, styles, f'h{level}')
1256
+
1257
+ bm_end = OxmlElement('w:bookmarkEnd')
1258
+ bm_end.set(qn('w:id'), bm_id)
1259
+ p._p.append(bm_end)
1260
+ else:
1261
+ _process_inline(p, text, styles, f'h{level}')
1262
+
746
1263
  if f'h{level}' in styles:
747
1264
  _apply_paragraph_formatting(p, styles[f'h{level}'])
748
1265
 
@@ -752,6 +1269,10 @@ def docx_writer(blocks: list, styles: dict, output_file: str, config: dict = Non
752
1269
  if 'p' in styles:
753
1270
  _apply_paragraph_formatting(p, styles['p'])
754
1271
  _process_inline(p, text, styles, 'p')
1272
+
1273
+ elif btype == 'callout':
1274
+ c_type, title, body_lines = block[1], block[2], block[3]
1275
+ _add_callout_box(doc, c_type, title, body_lines, styles)
755
1276
 
756
1277
  elif btype == 'list':
757
1278
  text = block[1]
@@ -801,9 +1322,37 @@ def docx_writer(blocks: list, styles: dict, output_file: str, config: dict = Non
801
1322
  table = doc.add_table(rows=len(rows), cols=len(rows[0]))
802
1323
  for r_idx, row in enumerate(rows):
803
1324
  for c_idx, val in enumerate(row):
804
- table.cell(r_idx, c_idx).text = val
805
- if 'table' in styles:
806
- pass
1325
+ cell = table.cell(r_idx, c_idx)
1326
+ p = cell.paragraphs[0]
1327
+ p.text = ""
1328
+ p.paragraph_format.space_before = Pt(0)
1329
+ p.paragraph_format.space_after = Pt(0)
1330
+ tag = 'th' if r_idx == 0 else 'td'
1331
+ _process_inline(p, val, styles, tag)
1332
+ _apply_table_styling(table, table_lines, styles)
1333
+
1334
+ elif btype == 'toc':
1335
+ toc_seen = True
1336
+ title = block[1]
1337
+ _add_table_of_contents(doc, title, headings_for_toc, styles)
1338
+
1339
+ elif btype == 'pagebreak':
1340
+ doc.add_page_break()
1341
+
1342
+ elif btype == 'sectionbreak':
1343
+ doc.add_section(WD_SECTION.NEW_PAGE)
1344
+
1345
+ elif btype == 'landscape_start':
1346
+ new_sec = doc.add_section(WD_SECTION.NEW_PAGE)
1347
+ new_sec.orientation = WD_ORIENT.LANDSCAPE
1348
+ if new_sec.page_width < new_sec.page_height:
1349
+ new_sec.page_width, new_sec.page_height = new_sec.page_height, new_sec.page_width
1350
+
1351
+ elif btype == 'landscape_end':
1352
+ new_sec = doc.add_section(WD_SECTION.NEW_PAGE)
1353
+ new_sec.orientation = WD_ORIENT.PORTRAIT
1354
+ if new_sec.page_width > new_sec.page_height:
1355
+ new_sec.page_width, new_sec.page_height = new_sec.page_height, new_sec.page_width
807
1356
 
808
1357
  doc.save(output_file)
809
1358
 
@@ -0,0 +1,259 @@
1
+ import os
2
+ import json
3
+
4
+ def init_project(target_dir: str = ".", template: str = "default"):
5
+ """
6
+ Scaffolds a new PyAct project with boilerplate files.
7
+
8
+ Why it is needed:
9
+ Enables users to instantly create a ready-to-run PyAct project with main.pamd,
10
+ global.py, pamd.conf, style.css, and .gitignore without manual setup.
11
+
12
+ Inputs:
13
+ target_dir (str): Destination folder path.
14
+ template (str): 'default', 'engineering', or 'academic'.
15
+ """
16
+ if target_dir != "." and not os.path.exists(target_dir):
17
+ os.makedirs(target_dir, exist_ok=True)
18
+
19
+ project_name = os.path.basename(os.path.abspath(target_dir))
20
+ if project_name == "" or project_name == ".":
21
+ project_name = "My PyAct Project"
22
+
23
+ # 1. global.py
24
+ global_py_content = f'''def global_context():
25
+ """
26
+ Global variables accessible across all templates using <glb>var_name</glb>.
27
+ """
28
+ return {{
29
+ "author": "Author Name",
30
+ "project_name": "{project_name}",
31
+ "company": "Company Name",
32
+ "version": "1.0.0"
33
+ }}
34
+ '''
35
+
36
+ # 2. pamd.conf
37
+ pamd_conf_content = f'''[Header]
38
+ left = {project_name}
39
+ right = 2026
40
+ font_size = 9pt
41
+ color = #666666
42
+
43
+ [Footer]
44
+ left = Confidential
45
+ center = Page {{page}} of {{pages}}
46
+ font_size = 9pt
47
+
48
+ [Metadata]
49
+ author = Author Name
50
+ title = {project_name}
51
+ subject = Technical Report
52
+
53
+ [Page]
54
+ size = A4
55
+ margin = 1in
56
+ '''
57
+
58
+ # 3. style.css
59
+ style_css_content = '''body {
60
+ font-family: Calibri, sans-serif;
61
+ font-size: 11pt;
62
+ line-height: 1.25;
63
+ color: #222222;
64
+ }
65
+
66
+ h1 {
67
+ color: #1a365d;
68
+ font-size: 22pt;
69
+ font-weight: bold;
70
+ margin-bottom: 14pt;
71
+ }
72
+
73
+ h2 {
74
+ color: #2b6cb0;
75
+ font-size: 16pt;
76
+ font-weight: bold;
77
+ margin-top: 14pt;
78
+ margin-bottom: 8pt;
79
+ }
80
+
81
+ h3 {
82
+ color: #4a5568;
83
+ font-size: 13pt;
84
+ font-weight: bold;
85
+ margin-top: 10pt;
86
+ margin-bottom: 6pt;
87
+ }
88
+
89
+ p {
90
+ margin-bottom: 8pt;
91
+ }
92
+ '''
93
+
94
+ # 4. .gitignore
95
+ gitignore_content = '''.pamd-cache/
96
+ __pycache__/
97
+ *.pyc
98
+ '''
99
+
100
+ # 5. main.pamd based on template
101
+ if template == "engineering":
102
+ cells = [
103
+ {
104
+ "cell_type": "code",
105
+ "source": [
106
+ "import pamd_helpers\n",
107
+ "\n",
108
+ "def context():\n",
109
+ " # 1. Design formula with Greek symbols and subscripts\n",
110
+ " f_cd = lambda f_ck, gamma_c: f_ck / gamma_c\n",
111
+ " design_strength = pamd_helpers.equation(f_cd, [30, 1.5])\n",
112
+ " \n",
113
+ " # 2. Table of material properties\n",
114
+ " headers = [\"Property\", \"Symbol\", \"Value\", \"Unit\"]\n",
115
+ " rows = [\n",
116
+ " [\"Char. Compressive Strength\", \"f_ck\", \"30\", \"MPa\"],\n",
117
+ " [\"Partial Safety Factor\", \"gamma_c\", \"1.5\", \"-\"],\n",
118
+ " [\"Design Compressive Strength\", \"f_cd\", \"20\", \"MPa\"]\n",
119
+ " ]\n",
120
+ " mat_table = pamd_helpers.table(headers, rows, \"Concrete Material Properties\", add_no=True)\n",
121
+ " \n",
122
+ " return {\n",
123
+ " \"strength_calc\": design_strength,\n",
124
+ " \"material_table\": mat_table\n",
125
+ " }\n"
126
+ ]
127
+ },
128
+ {
129
+ "cell_type": "markdown",
130
+ "source": [
131
+ "# <glb>project_name</glb>\n",
132
+ "Author: **<glb>author</glb>** | Organization: **<glb>company</glb>**\n",
133
+ "\n",
134
+ "<toc/>\n",
135
+ "\n",
136
+ "<pagebreak/>\n",
137
+ "\n",
138
+ "## 1. Material Specifications\n",
139
+ "Below are the design material parameters:\n",
140
+ "<ctx>material_table</ctx>\n",
141
+ "\n",
142
+ "## 2. Design Strength Verification\n",
143
+ "The design compressive strength is calculated in accordance with Eurocode 2:\n",
144
+ "<ctx>strength_calc</ctx>\n"
145
+ ]
146
+ }
147
+ ]
148
+ elif template == "academic":
149
+ cells = [
150
+ {
151
+ "cell_type": "code",
152
+ "source": [
153
+ "import pamd_helpers\n",
154
+ "\n",
155
+ "def context():\n",
156
+ " fn1 = pamd_helpers.Footnote(1, \"Comprehensive study published in Journal of Applied Sciences, 2026.\")\n",
157
+ " hyp = pamd_helpers.hyperlink(\"Research Data Repository\", \"https://example.org/dataset\")\n",
158
+ " \n",
159
+ " return {\n",
160
+ " \"fn_ref\": fn1.adnotation(),\n",
161
+ " \"fn_def\": fn1.define_string(),\n",
162
+ " \"data_link\": hyp\n",
163
+ " }\n"
164
+ ]
165
+ },
166
+ {
167
+ "cell_type": "markdown",
168
+ "source": [
169
+ "# <glb>project_name</glb>\n",
170
+ "**<glb>author</glb>** (<glb>company</glb>)\n",
171
+ "\n",
172
+ "### Abstract\n",
173
+ "This document explores computational report generation with PyAct.\n",
174
+ "\n",
175
+ "<toc/>\n",
176
+ "\n",
177
+ "<pagebreak/>\n",
178
+ "\n",
179
+ "## 1. Introduction\n",
180
+ "Recent advancements in dynamic markdown have bridged computational logic with formal report generation<ctx>fn_ref</ctx>.\n",
181
+ "\n",
182
+ "The primary datasets are hosted at <ctx>data_link</ctx>.\n",
183
+ "\n",
184
+ "---\n",
185
+ "<ctx>fn_def</ctx>\n"
186
+ ]
187
+ }
188
+ ]
189
+ else: # default template
190
+ cells = [
191
+ {
192
+ "cell_type": "code",
193
+ "source": [
194
+ "import pamd_helpers\n",
195
+ "\n",
196
+ "def context():\n",
197
+ " eq = pamd_helpers.equation(lambda x, y: x**2 + y, [4, 5])\n",
198
+ " tasks = pamd_helpers.checklist([\"Setup project\", \"Write logic\", \"Export to Word\"])\n",
199
+ " \n",
200
+ " return {\n",
201
+ " \"math\": eq,\n",
202
+ " \"todo\": tasks\n",
203
+ " }\n"
204
+ ]
205
+ },
206
+ {
207
+ "cell_type": "markdown",
208
+ "source": [
209
+ "# <glb>project_name</glb>\n",
210
+ "Prepared by: **<glb>author</glb>**\n",
211
+ "\n",
212
+ "<toc/>\n",
213
+ "\n",
214
+ "<pagebreak/>\n",
215
+ "\n",
216
+ "## 1. Overview\n",
217
+ "Welcome to PyAct! This project was initialized with `pyact init`.\n",
218
+ "\n",
219
+ "## 2. Dynamic Computation\n",
220
+ "Here is a mathematical calculation:\n",
221
+ "<ctx>math</ctx>\n",
222
+ "\n",
223
+ "## 3. Project Tasks\n",
224
+ "<ctx>todo</ctx>\n"
225
+ ]
226
+ }
227
+ ]
228
+
229
+ pamd_content = json.dumps({"cells": cells}, indent=2)
230
+
231
+ files_to_create = {
232
+ "global.py": global_py_content,
233
+ "pamd.conf": pamd_conf_content,
234
+ "style.css": style_css_content,
235
+ ".gitignore": gitignore_content,
236
+ "main.pamd": pamd_content
237
+ }
238
+
239
+ created = []
240
+ skipped = []
241
+ for fname, content in files_to_create.items():
242
+ fpath = os.path.join(target_dir, fname)
243
+ if os.path.exists(fpath):
244
+ skipped.append(fname)
245
+ else:
246
+ with open(fpath, "w", encoding="utf-8") as f:
247
+ f.write(content)
248
+ created.append(fname)
249
+
250
+ target_display = target_dir if target_dir != "." else "current directory"
251
+ print(f"Initialized PyAct project in {target_display} (template: {template}):")
252
+ for f in created:
253
+ print(f" + Created {f}")
254
+ for f in skipped:
255
+ print(f" ~ Skipped {f} (already exists)")
256
+ print("\nNext steps:")
257
+ if target_dir != ".":
258
+ print(f" cd {target_dir}")
259
+ print(" pyact main.pamd -o output.md --docx output.docx")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyact-cli
3
- Version: 0.8.0
3
+ Version: 1.0.0
4
4
  Summary: A CLI tool to compile .pamd files to Markdown.
5
5
  Home-page: https://github.com/Abstergo2003/PyAct
6
6
  Author: Abstergo2003
@@ -112,4 +112,11 @@ title = Monthly Report
112
112
 
113
113
  PyAct will automatically detect `pamd.conf` in your directory, inject dynamic page numbers (`{page}` / `{pages}`), configure tab stops, and populate document metadata!
114
114
 
115
+ ### 7. Scaffolding New Projects with `pyact init`
116
+ Quickly spin up a new project ready to compile:
117
+ ```bash
118
+ pyact init my_project --template engineering
119
+ ```
120
+ Templates: `default`, `engineering`, `academic`.
121
+
115
122
  That's it! You now have documents ready to be shared, published, or distributed.
@@ -9,6 +9,7 @@ pyact/css2json.py
9
9
  pyact/linter.py
10
10
  pyact/mdTOword.py
11
11
  pyact/py2tex.py
12
+ pyact/scaffold.py
12
13
  pyact_cli.egg-info/PKG-INFO
13
14
  pyact_cli.egg-info/SOURCES.txt
14
15
  pyact_cli.egg-info/dependency_links.txt
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name="pyact-cli",
8
- version="0.8.0",
8
+ version="1.0.0",
9
9
  author="Abstergo2003",
10
10
  author_email="",
11
11
  description="A CLI tool to compile .pamd files to Markdown.",
@@ -1 +0,0 @@
1
- __version__ = "0.8.0"
File without changes
File without changes
File without changes