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.
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/PKG-INFO +8 -1
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/README.md +7 -0
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/pamd_helpers/__init__.py +69 -0
- pyact_cli-1.0.0/pyact/__init__.py +1 -0
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/pyact/cli.py +14 -3
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/pyact/core.py +80 -1
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/pyact/css2json.py +2 -1
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/pyact/mdTOword.py +595 -46
- pyact_cli-1.0.0/pyact/scaffold.py +259 -0
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/pyact_cli.egg-info/PKG-INFO +8 -1
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/pyact_cli.egg-info/SOURCES.txt +1 -0
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/setup.py +1 -1
- pyact_cli-0.8.0/pyact/__init__.py +0 -1
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/pyact/conf_parser.py +0 -0
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/pyact/linter.py +0 -0
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/pyact/py2tex.py +0 -0
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/pyact_cli.egg-info/dependency_links.txt +0 -0
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/pyact_cli.egg-info/entry_points.txt +0 -0
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/pyact_cli.egg-info/requires.txt +0 -0
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/pyact_cli.egg-info/top_level.txt +0 -0
- {pyact_cli-0.8.0 → pyact_cli-1.0.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyact-cli
|
|
3
|
-
Version: 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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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 =
|
|
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=
|
|
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'(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
724
|
-
|
|
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
|
-
|
|
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)
|
|
805
|
-
|
|
806
|
-
|
|
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.
|
|
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.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.8.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|