pyact-cli 0.8.0__tar.gz → 0.9.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: 0.9.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__ = "0.9.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}]")
@@ -705,23 +760,213 @@ def _apply_docx_config(doc: Document, config: dict):
705
760
  if footer_conf:
706
761
  _configure_header_or_footer(section.footer, footer_conf, section)
707
762
 
708
- def docx_writer(blocks: list, styles: dict, output_file: str, config: dict = None):
709
- """
710
- Translates parsed markdown blocks into a Word document and applies CSS styling.
763
+ def _add_callout_box(doc, callout_type: str, title: str, body_lines: list, styles: dict):
764
+ """Renders an alert callout box or blockquote as a styled 1x1 table."""
765
+ table = doc.add_table(rows=1, cols=1)
766
+ table.autofit = False
711
767
 
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.
768
+ theme = {
769
+ 'NOTE': {'border': '0969DA', 'bg': 'F0F7FF', 'title_col': (9, 105, 218)},
770
+ 'TIP': {'border': '1A7F37', 'bg': 'F0FFF4', 'title_col': (26, 127, 55)},
771
+ 'IMPORTANT': {'border': '8250DF', 'bg': 'F8F5FF', 'title_col': (130, 80, 223)},
772
+ 'WARNING': {'border': '9A6700', 'bg': 'FFFBEB', 'title_col': (154, 103, 0)},
773
+ 'CAUTION': {'border': 'CF222E', 'bg': 'FFF1F2', 'title_col': (207, 34, 46)},
774
+ 'BLOCKQUOTE': {'border': 'D0D7DE', 'bg': 'F6F8FA', 'title_col': (87, 96, 106)},
775
+ }
776
+ cfg = theme.get(callout_type.upper(), theme['NOTE'])
716
777
 
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).
778
+ tblPr = table._tbl.tblPr
779
+ tblW = parse_xml(f'<w:tblW {nsdecls("w")} w:w="5000" w:type="pct"/>')
780
+ tblPr.append(tblW)
781
+
782
+ tblBorders = parse_xml(f'''
783
+ <w:tblBorders {nsdecls("w")}>
784
+ <w:top w:val="none"/>
785
+ <w:left w:val="none"/>
786
+ <w:bottom w:val="none"/>
787
+ <w:right w:val="none"/>
788
+ <w:insideH w:val="none"/>
789
+ <w:insideV w:val="none"/>
790
+ </w:tblBorders>
791
+ ''')
792
+ tblPr.append(tblBorders)
793
+
794
+ cell = table.cell(0, 0)
795
+ tcPr = cell._tc.get_or_add_tcPr()
796
+
797
+ shd = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{cfg["bg"]}"/>')
798
+ tcPr.append(shd)
799
+
800
+ tcBorders = parse_xml(f'''
801
+ <w:tcBorders {nsdecls("w")}>
802
+ <w:top w:val="none"/>
803
+ <w:left w:val="single" w:sz="36" w:space="0" w:color="{cfg['border']}"/>
804
+ <w:bottom w:val="none"/>
805
+ <w:right w:val="none"/>
806
+ </w:tcBorders>
807
+ ''')
808
+ tcPr.append(tcBorders)
809
+
810
+ tcMar = parse_xml(f'''
811
+ <w:tcMar {nsdecls("w")}>
812
+ <w:top w:w="120" w:type="dxa"/>
813
+ <w:bottom w:w="120" w:type="dxa"/>
814
+ <w:left w:w="180" w:type="dxa"/>
815
+ <w:right w:w="180" w:type="dxa"/>
816
+ </w:tcMar>
817
+ ''')
818
+ tcPr.append(tcMar)
819
+
820
+ p = cell.paragraphs[0]
821
+ p.paragraph_format.space_before = Pt(2)
822
+ p.paragraph_format.space_after = Pt(3)
823
+
824
+ if title:
825
+ run_title = p.add_run()
826
+ if callout_type != 'BLOCKQUOTE':
827
+ if title.upper() == callout_type:
828
+ run_title.text = callout_type
829
+ else:
830
+ run_title.text = f"{callout_type}: {title}"
831
+ else:
832
+ run_title.text = title
833
+ run_title.bold = True
834
+ r, g, b = cfg['title_col']
835
+ run_title.font.color.rgb = RGBColor(r, g, b)
722
836
 
723
- Outputs:
724
- None: This function does not return a value, but it writes a binary .docx file to disk.
837
+ for idx, line in enumerate(body_lines):
838
+ if not title and idx == 0:
839
+ p_body = p
840
+ else:
841
+ p_body = cell.add_paragraph()
842
+ p_body.paragraph_format.space_before = Pt(0)
843
+ p_body.paragraph_format.space_after = Pt(2)
844
+ _process_inline(p_body, line, styles, 'p')
845
+
846
+ p_spacer = doc.add_paragraph()
847
+ p_spacer.paragraph_format.space_before = Pt(0)
848
+ p_spacer.paragraph_format.space_after = Pt(4)
849
+
850
+ def _add_table_of_contents(doc, title="Table of Contents", headings=None, styles=None):
851
+ """Inserts a native Word Table of Contents with dynamic updating and pre-populated entries."""
852
+ if headings is None:
853
+ headings = []
854
+ if styles is None:
855
+ styles = {}
856
+
857
+ try:
858
+ settings = doc.settings.element
859
+ if not settings.xpath('w:updateFields'):
860
+ update_fields = OxmlElement('w:updateFields')
861
+ update_fields.set(qn('w:val'), 'true')
862
+ settings.append(update_fields)
863
+ except Exception:
864
+ pass
865
+
866
+ if title and title.lower() not in ('none', 'false', 'no'):
867
+ p_title = doc.add_heading(title, level=1)
868
+ if 'h1' in styles:
869
+ _apply_paragraph_formatting(p_title, styles['h1'])
870
+
871
+ p_first = doc.add_paragraph()
872
+ p_first.paragraph_format.space_before = Pt(2)
873
+ p_first.paragraph_format.space_after = Pt(2)
874
+
875
+ r_begin = p_first.add_run()
876
+ fld1 = OxmlElement('w:fldChar')
877
+ fld1.set(qn('w:fldCharType'), 'begin')
878
+ r_begin._r.append(fld1)
879
+
880
+ r_instr = p_first.add_run()
881
+ instr = OxmlElement('w:instrText')
882
+ instr.set(qn('xml:space'), 'preserve')
883
+ instr.text = ' TOC \\o "1-3" \\h \\z \\u '
884
+ r_instr._r.append(instr)
885
+
886
+ r_sep = p_first.add_run()
887
+ fld2 = OxmlElement('w:fldChar')
888
+ fld2.set(qn('w:fldCharType'), 'separate')
889
+ r_sep._r.append(fld2)
890
+
891
+ if headings:
892
+ base_level = min(h[1] for h in headings)
893
+ section = doc.sections[-1]
894
+ content_width = section.page_width - section.left_margin - section.right_margin
895
+
896
+ for i, h_block in enumerate(headings):
897
+ h_level, h_text = h_block[1], h_block[2]
898
+ bm_name = f"_Toc_{i}"
899
+ rel_level = h_level - base_level
900
+ indent_in = max(0, rel_level * 0.25)
901
+
902
+ p = p_first if i == 0 else doc.add_paragraph()
903
+ p.paragraph_format.space_before = Pt(1)
904
+ p.paragraph_format.space_after = Pt(2)
905
+ if indent_in > 0:
906
+ p.paragraph_format.left_indent = Inches(indent_in)
907
+
908
+ tab_pos = content_width - Inches(indent_in)
909
+ p.paragraph_format.tab_stops.add_tab_stop(tab_pos, WD_TAB_ALIGNMENT.RIGHT, WD_TAB_LEADER.DOTS)
910
+
911
+ hyperlink = OxmlElement('w:hyperlink')
912
+ hyperlink.set(qn('w:anchor'), bm_name)
913
+
914
+ r_text = OxmlElement('w:r')
915
+ t = OxmlElement('w:t')
916
+ t.text = h_text
917
+ r_text.append(t)
918
+ hyperlink.append(r_text)
919
+
920
+ r_tab = OxmlElement('w:r')
921
+ tab = OxmlElement('w:tab')
922
+ r_tab.append(tab)
923
+ hyperlink.append(r_tab)
924
+
925
+ fld_p1 = OxmlElement('w:fldChar')
926
+ fld_p1.set(qn('w:fldCharType'), 'begin')
927
+ r_p1 = OxmlElement('w:r')
928
+ r_p1.append(fld_p1)
929
+
930
+ instr_p = OxmlElement('w:instrText')
931
+ instr_p.set(qn('xml:space'), 'preserve')
932
+ instr_p.text = f' PAGEREF {bm_name} \\h '
933
+ r_pinstr = OxmlElement('w:r')
934
+ r_pinstr.append(instr_p)
935
+
936
+ fld_p2 = OxmlElement('w:fldChar')
937
+ fld_p2.set(qn('w:fldCharType'), 'separate')
938
+ r_p2 = OxmlElement('w:r')
939
+ r_p2.append(fld_p2)
940
+
941
+ r_pnum = OxmlElement('w:r')
942
+ t_pnum = OxmlElement('w:t')
943
+ t_pnum.text = str(max(1, h_level))
944
+ r_pnum.append(t_pnum)
945
+
946
+ fld_p3 = OxmlElement('w:fldChar')
947
+ fld_p3.set(qn('w:fldCharType'), 'end')
948
+ r_p3 = OxmlElement('w:r')
949
+ r_p3.append(fld_p3)
950
+
951
+ hyperlink.append(r_p1)
952
+ hyperlink.append(r_pinstr)
953
+ hyperlink.append(r_p2)
954
+ hyperlink.append(r_pnum)
955
+ hyperlink.append(r_p3)
956
+
957
+ p._p.append(hyperlink)
958
+
959
+ p_end = doc.add_paragraph()
960
+ p_end.paragraph_format.space_before = Pt(0)
961
+ p_end.paragraph_format.space_after = Pt(4)
962
+ r_end = p_end.add_run()
963
+ fld_end = OxmlElement('w:fldChar')
964
+ fld_end.set(qn('w:fldCharType'), 'end')
965
+ r_end._r.append(fld_end)
966
+
967
+ def docx_writer(blocks: list, styles: dict, output_file: str, config: dict = None):
968
+ """
969
+ Translates parsed markdown blocks into a Word document and applies CSS styling.
725
970
  """
726
971
  doc = Document()
727
972
  current_ordered_numId = None
@@ -736,13 +981,47 @@ def docx_writer(blocks: list, styles: dict, output_file: str, config: dict = Non
736
981
  _apply_css_to_run(normal_style, styles['body'])
737
982
  _apply_paragraph_formatting(normal_style, styles['body'])
738
983
 
984
+ # Pre-scan headings for Table of Contents
985
+ headings_for_toc = []
986
+ toc_seen_scan = False
987
+ for b in blocks:
988
+ if b[0] == 'toc':
989
+ toc_seen_scan = True
990
+ continue
991
+ if b[0] == 'heading' and toc_seen_scan:
992
+ headings_for_toc.append(b)
993
+
994
+ if not headings_for_toc:
995
+ headings_for_toc = [b for b in blocks if b[0] == 'heading']
996
+
997
+ toc_seen = False
998
+ heading_counter = 0
999
+
739
1000
  for block in blocks:
740
1001
  btype = block[0]
741
1002
 
742
1003
  if btype == 'heading':
743
1004
  level, text = block[1], block[2]
744
1005
  p = doc.add_heading('', level=level)
745
- _process_inline(p, text, styles, f'h{level}')
1006
+
1007
+ if toc_seen:
1008
+ bm_name = f"_Toc_{heading_counter}"
1009
+ bm_id = str(2000 + heading_counter)
1010
+ heading_counter += 1
1011
+
1012
+ bm_start = OxmlElement('w:bookmarkStart')
1013
+ bm_start.set(qn('w:id'), bm_id)
1014
+ bm_start.set(qn('w:name'), bm_name)
1015
+ p._p.append(bm_start)
1016
+
1017
+ _process_inline(p, text, styles, f'h{level}')
1018
+
1019
+ bm_end = OxmlElement('w:bookmarkEnd')
1020
+ bm_end.set(qn('w:id'), bm_id)
1021
+ p._p.append(bm_end)
1022
+ else:
1023
+ _process_inline(p, text, styles, f'h{level}')
1024
+
746
1025
  if f'h{level}' in styles:
747
1026
  _apply_paragraph_formatting(p, styles[f'h{level}'])
748
1027
 
@@ -752,6 +1031,10 @@ def docx_writer(blocks: list, styles: dict, output_file: str, config: dict = Non
752
1031
  if 'p' in styles:
753
1032
  _apply_paragraph_formatting(p, styles['p'])
754
1033
  _process_inline(p, text, styles, 'p')
1034
+
1035
+ elif btype == 'callout':
1036
+ c_type, title, body_lines = block[1], block[2], block[3]
1037
+ _add_callout_box(doc, c_type, title, body_lines, styles)
755
1038
 
756
1039
  elif btype == 'list':
757
1040
  text = block[1]
@@ -805,6 +1088,29 @@ def docx_writer(blocks: list, styles: dict, output_file: str, config: dict = Non
805
1088
  if 'table' in styles:
806
1089
  pass
807
1090
 
1091
+ elif btype == 'toc':
1092
+ toc_seen = True
1093
+ title = block[1]
1094
+ _add_table_of_contents(doc, title, headings_for_toc, styles)
1095
+
1096
+ elif btype == 'pagebreak':
1097
+ doc.add_page_break()
1098
+
1099
+ elif btype == 'sectionbreak':
1100
+ doc.add_section(WD_SECTION.NEW_PAGE)
1101
+
1102
+ elif btype == 'landscape_start':
1103
+ new_sec = doc.add_section(WD_SECTION.NEW_PAGE)
1104
+ new_sec.orientation = WD_ORIENT.LANDSCAPE
1105
+ if new_sec.page_width < new_sec.page_height:
1106
+ new_sec.page_width, new_sec.page_height = new_sec.page_height, new_sec.page_width
1107
+
1108
+ elif btype == 'landscape_end':
1109
+ new_sec = doc.add_section(WD_SECTION.NEW_PAGE)
1110
+ new_sec.orientation = WD_ORIENT.PORTRAIT
1111
+ if new_sec.page_width > new_sec.page_height:
1112
+ new_sec.page_width, new_sec.page_height = new_sec.page_height, new_sec.page_width
1113
+
808
1114
  doc.save(output_file)
809
1115
 
810
1116
 
@@ -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: 0.9.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="0.9.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