pyact-cli 0.3.0__tar.gz → 0.4.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.3.0
3
+ Version: 0.4.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
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -7,6 +7,8 @@ def main():
7
7
  parser = argparse.ArgumentParser(description="PyAct CLI - PAMD to Markdown compiler")
8
8
  parser.add_argument("input", help="Path to the main .pamd file")
9
9
  parser.add_argument("-o", "--output", help="Output file path (default prints to stdout)")
10
+ parser.add_argument("--docx", help="Also generate a DOCX file at this path")
11
+ parser.add_argument("--css", help="Optional CSS file path to style the DOCX")
10
12
 
11
13
  args = parser.parse_args()
12
14
 
@@ -27,6 +29,26 @@ def main():
27
29
  print(f"Successfully compiled to {args.output}")
28
30
  else:
29
31
  print(content)
32
+
33
+ if args.docx:
34
+ from .mdTOword import style_parser, markdown_parser, docx_writer
35
+ styles = {}
36
+ if args.css:
37
+ styles = style_parser(args.css)
38
+ else:
39
+ # Try to look for style.css in the active directory
40
+ local_css = os.path.join(directory, "style.css")
41
+ if os.path.exists(local_css):
42
+ styles = style_parser(local_css)
43
+ else:
44
+ # Fallback to the default one packaged in pyact
45
+ default_css = os.path.join(os.path.dirname(__file__), "style.css")
46
+ styles = style_parser(default_css)
47
+
48
+ blocks = markdown_parser(content)
49
+ docx_writer(blocks, styles, args.docx)
50
+ print(f"Successfully compiled DOCX to {args.docx}")
51
+
30
52
  except Exception as e:
31
53
  print(f"Error: {e}", file=sys.stderr)
32
54
  sys.exit(1)
@@ -0,0 +1,54 @@
1
+ import re
2
+ import json
3
+
4
+ def css_to_dict(css_string: str) -> dict:
5
+ """Parses a CSS string into a Python dictionary."""
6
+ # Remove CSS comments
7
+ css_string = re.sub(r'/\*[\s\S]*?\*/', '', css_string)
8
+
9
+ # Match selectors and their corresponding blocks
10
+ pattern = r'([^{]+)\{([^}]+)\}'
11
+ matches = re.findall(pattern, css_string)
12
+
13
+ css_dict = {}
14
+ for selector, block in matches:
15
+ selector = selector.strip()
16
+
17
+ # Parse individual CSS properties
18
+ rules = {}
19
+ for line in block.split(';'):
20
+ line = line.strip()
21
+ if not line:
22
+ continue
23
+ if ':' in line:
24
+ key, val = line.split(':', 1)
25
+ rules[key.strip()] = val.strip()
26
+
27
+ # Handle comma-separated selectors (e.g., 'h1, h2, h3')
28
+ for sel in selector.split(','):
29
+ sel = sel.strip()
30
+ if sel:
31
+ if sel not in css_dict:
32
+ css_dict[sel] = {}
33
+ css_dict[sel].update(rules)
34
+
35
+ return css_dict
36
+
37
+ def parse_css_file(file_path: str, as_json_string: bool = False):
38
+ """
39
+ Reads a CSS file and converts it to a dictionary map.
40
+ If as_json_string is True, returns a formatted JSON string instead.
41
+ """
42
+ with open(file_path, 'r', encoding='utf-8') as f:
43
+ css_string = f.read()
44
+
45
+ css_dict = css_to_dict(css_string)
46
+
47
+ if as_json_string:
48
+ return json.dumps(css_dict, indent=4)
49
+ return css_dict
50
+
51
+ if __name__ == "__main__":
52
+ # Example usage:
53
+ # print(parse_css_file("style.css", as_json_string=True))
54
+ pass
@@ -0,0 +1,425 @@
1
+ import re
2
+ from docx import Document
3
+ from docx.shared import Pt, RGBColor
4
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
5
+ from docx.oxml import parse_xml
6
+ from docx.oxml.ns import nsdecls
7
+ import math2docx
8
+ from . import css2json
9
+
10
+ def style_parser(css_path):
11
+ """
12
+ Reads a CSS file and converts it into a style dictionary using css2json.
13
+ """
14
+ try:
15
+ return css2json.parse_css_file(css_path)
16
+ except Exception as e:
17
+ print(f"Warning: Could not parse CSS ({e})")
18
+ return {}
19
+
20
+ def markdown_parser(md_text):
21
+ """
22
+ Parses Markdown text into a list of structured blocks.
23
+ Supported blocks: heading, paragraph, list, table, math
24
+ """
25
+ blocks = []
26
+ lines = md_text.split('\n')
27
+
28
+ current_table = []
29
+
30
+ for line in lines:
31
+ line_s = line.strip()
32
+
33
+ # Table Parsing
34
+ if line_s.startswith('|') and line_s.endswith('|'):
35
+ current_table.append(line_s)
36
+ continue
37
+ elif current_table:
38
+ blocks.append(('table', current_table))
39
+ current_table = []
40
+
41
+ if not line_s:
42
+ continue
43
+
44
+ # Heading Parsing
45
+ if line_s.startswith('#'):
46
+ level = len(line_s) - len(line_s.lstrip('#'))
47
+ text = line_s.lstrip('#').strip()
48
+ blocks.append(('heading', level, text))
49
+
50
+ # List Parsing
51
+ elif line_s.startswith('- ') or line_s.startswith('* '):
52
+ text = line_s[2:].strip()
53
+ blocks.append(('list', text, 'unordered'))
54
+
55
+ elif re.match(r'^\d+\.\s+', line_s):
56
+ m = re.match(r'^\d+\.\s+(.*)', line_s)
57
+ blocks.append(('list', m.group(1), 'ordered'))
58
+
59
+ # Math Block Parsing
60
+ elif line_s.startswith('$$') and line_s.endswith('$$'):
61
+ blocks.append(('math', line_s.strip('$').strip()))
62
+
63
+ # Standard Paragraph
64
+ else:
65
+ blocks.append(('paragraph', line_s))
66
+
67
+ if current_table:
68
+ blocks.append(('table', current_table))
69
+
70
+ return blocks
71
+
72
+ def _apply_css_to_run(run, css_rules):
73
+ """Helper to apply CSS rules to a docx run or font."""
74
+ font = run.font if hasattr(run, 'font') else run
75
+
76
+ if 'color' in css_rules:
77
+ hex_col = css_rules['color'].replace('#', '').strip()
78
+ if len(hex_col) == 6:
79
+ try:
80
+ r, g, b = tuple(int(hex_col[i:i+2], 16) for i in (0, 2, 4))
81
+ font.color.rgb = RGBColor(r, g, b)
82
+ except ValueError:
83
+ pass
84
+
85
+ if 'font-size' in css_rules:
86
+ size = css_rules['font-size'].replace('pt', '').strip()
87
+ try:
88
+ font.size = Pt(float(size))
89
+ except ValueError:
90
+ pass
91
+
92
+ if 'font-family' in css_rules:
93
+ font.name = css_rules['font-family'].strip("'\"")
94
+
95
+ if 'font-weight' in css_rules:
96
+ font.bold = (css_rules['font-weight'].lower() == 'bold')
97
+
98
+ if 'font-style' in css_rules:
99
+ font.italic = (css_rules['font-style'].lower() == 'italic')
100
+
101
+ if 'text-decoration' in css_rules:
102
+ dec = css_rules['text-decoration'].lower()
103
+ if dec == 'underline':
104
+ font.underline = True
105
+ elif dec == 'line-through':
106
+ font.strike = True
107
+ elif dec == 'none':
108
+ font.underline = False
109
+ font.strike = False
110
+
111
+ if 'text-transform' in css_rules:
112
+ trans = css_rules['text-transform'].lower()
113
+ if trans == 'uppercase':
114
+ font.all_caps = True
115
+ elif trans == 'small-caps':
116
+ font.small_caps = True
117
+
118
+ if 'background-color' in css_rules:
119
+ # Maps text background to highlight color index
120
+ from docx.enum.text import WD_COLOR_INDEX
121
+ color_map = {
122
+ 'auto': WD_COLOR_INDEX.AUTO, 'black': WD_COLOR_INDEX.BLACK, 'blue': WD_COLOR_INDEX.BLUE,
123
+ 'bright-green': WD_COLOR_INDEX.BRIGHT_GREEN, 'dark-blue': WD_COLOR_INDEX.DARK_BLUE,
124
+ 'dark-red': WD_COLOR_INDEX.DARK_RED, 'dark-yellow': WD_COLOR_INDEX.DARK_YELLOW,
125
+ 'gray-25': WD_COLOR_INDEX.GRAY_25, 'gray-50': WD_COLOR_INDEX.GRAY_50,
126
+ 'green': WD_COLOR_INDEX.GREEN, 'pink': WD_COLOR_INDEX.PINK, 'red': WD_COLOR_INDEX.RED,
127
+ 'teal': WD_COLOR_INDEX.TEAL, 'turquoise': WD_COLOR_INDEX.TURQUOISE,
128
+ 'violet': WD_COLOR_INDEX.VIOLET, 'white': WD_COLOR_INDEX.WHITE, 'yellow': WD_COLOR_INDEX.YELLOW
129
+ }
130
+ bg_col = css_rules['background-color'].lower()
131
+ if bg_col in color_map:
132
+ font.highlight_color = color_map[bg_col]
133
+
134
+ def _apply_paragraph_formatting(p, css_rules):
135
+ """Helper to apply CSS rules to paragraph formatting."""
136
+ fmt = p.paragraph_format
137
+
138
+ if 'text-align' in css_rules:
139
+ align = css_rules['text-align'].lower()
140
+ if align == 'center':
141
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
142
+ elif align == 'right':
143
+ p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
144
+ elif align == 'justify':
145
+ p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
146
+ elif align == 'distribute':
147
+ p.alignment = WD_ALIGN_PARAGRAPH.DISTRIBUTE
148
+ elif align == 'left':
149
+ p.alignment = WD_ALIGN_PARAGRAPH.LEFT
150
+
151
+ if 'margin-top' in css_rules:
152
+ val = css_rules['margin-top'].replace('pt', '').strip()
153
+ try:
154
+ fmt.space_before = Pt(float(val))
155
+ except ValueError:
156
+ pass
157
+
158
+ if 'margin-bottom' in css_rules:
159
+ val = css_rules['margin-bottom'].replace('pt', '').strip()
160
+ try:
161
+ fmt.space_after = Pt(float(val))
162
+ except ValueError:
163
+ pass
164
+
165
+ if 'line-height' in css_rules:
166
+ try:
167
+ fmt.line_spacing = float(css_rules['line-height'])
168
+ except ValueError:
169
+ pass
170
+
171
+ if 'page-break-before' in css_rules:
172
+ if css_rules['page-break-before'].lower() == 'always':
173
+ fmt.page_break_before = True
174
+
175
+ import docx.opc.constants
176
+ from docx.oxml.shared import OxmlElement, qn
177
+ import urllib.request
178
+ import io
179
+ import random
180
+
181
+ def _add_internal_hyperlink(paragraph, text, anchor):
182
+ """Adds a clickable internal hyperlink to a bookmark anchor in the docx."""
183
+ hyperlink = OxmlElement('w:hyperlink')
184
+ hyperlink.set(qn('w:anchor'), anchor)
185
+
186
+ new_run = OxmlElement('w:r')
187
+ rPr = OxmlElement('w:rPr')
188
+
189
+ # Make it superscript since it's usually a footnote marker
190
+ vertAlign = OxmlElement('w:vertAlign')
191
+ vertAlign.set(qn('w:val'), 'superscript')
192
+ rPr.append(vertAlign)
193
+
194
+ # Make it blue
195
+ c = OxmlElement('w:color')
196
+ c.set(qn('w:val'), '0563C1')
197
+ rPr.append(c)
198
+
199
+ new_run.append(rPr)
200
+
201
+ text_elem = OxmlElement('w:t')
202
+ text_elem.text = text
203
+ new_run.append(text_elem)
204
+
205
+ hyperlink.append(new_run)
206
+ paragraph._p.append(hyperlink)
207
+
208
+ def _add_bookmark(paragraph, text, anchor):
209
+ """Creates a bookmark anchor in the document and places text inside it."""
210
+ # We need a random ID to prevent collisions
211
+ bm_id = str(random.randint(10000, 99999))
212
+
213
+ bm_start = OxmlElement('w:bookmarkStart')
214
+ bm_start.set(qn('w:id'), bm_id)
215
+ bm_start.set(qn('w:name'), anchor)
216
+ paragraph._p.append(bm_start)
217
+
218
+ run = paragraph.add_run(text)
219
+
220
+ bm_end = OxmlElement('w:bookmarkEnd')
221
+ bm_end.set(qn('w:id'), bm_id)
222
+ paragraph._p.append(bm_end)
223
+ return run
224
+
225
+ def _add_hyperlink(paragraph, text, url, styles, tag):
226
+ """Adds a real hyperlink to a docx paragraph."""
227
+ part = paragraph.part
228
+ r_id = part.relate_to(url, docx.opc.constants.RELATIONSHIP_TYPE.HYPERLINK, is_external=True)
229
+
230
+ hyperlink = OxmlElement('w:hyperlink')
231
+ hyperlink.set(qn('r:id'), r_id)
232
+
233
+ new_run = OxmlElement('w:r')
234
+ rPr = OxmlElement('w:rPr')
235
+
236
+ c = OxmlElement('w:color')
237
+ if 'hyperlink' in styles and 'color' in styles['hyperlink']:
238
+ color_hex = styles['hyperlink']['color'].replace('#', '').strip()
239
+ c.set(qn('w:val'), color_hex)
240
+ else:
241
+ c.set(qn('w:val'), '0563C1')
242
+ rPr.append(c)
243
+
244
+ u = OxmlElement('w:u')
245
+ if 'hyperlink' in styles and 'text-decoration' in styles['hyperlink'] and styles['hyperlink']['text-decoration'] == 'none':
246
+ pass
247
+ else:
248
+ u.set(qn('w:val'), 'single')
249
+ rPr.append(u)
250
+
251
+ new_run.append(rPr)
252
+
253
+ text_elem = OxmlElement('w:t')
254
+ text_elem.text = text
255
+ new_run.append(text_elem)
256
+
257
+ hyperlink.append(new_run)
258
+ paragraph._p.append(hyperlink)
259
+
260
+ def _add_image(paragraph, url, caption, styles):
261
+ """Adds an inline image fetched from URL."""
262
+ try:
263
+ req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
264
+ with urllib.request.urlopen(req) as response:
265
+ image_stream = io.BytesIO(response.read())
266
+
267
+ run = paragraph.add_run()
268
+
269
+ # Apply CSS dimensions if available
270
+ width = None
271
+ if 'image' in styles and 'width' in styles['image']:
272
+ w_str = styles['image']['width']
273
+ if 'in' in w_str: width = docx.shared.Inches(float(w_str.replace('in', '').strip()))
274
+
275
+ if width:
276
+ run.add_picture(image_stream, width=width)
277
+ else:
278
+ run.add_picture(image_stream, width=docx.shared.Inches(4))
279
+
280
+ except Exception as e:
281
+ paragraph.add_run(f"[Image Failed: {url}]")
282
+
283
+ def _process_inline(paragraph, text, styles, tag):
284
+ """Parses text for images, links, math, and bold/italic."""
285
+ # Simple regex to split by math, image, or link. Captures the match as a group.
286
+ # Group 5: Footnote definition [^1]: ...
287
+ # Group 6: Footnote annotation [^1]
288
+ pattern = r'(\$\$.*?\$\$)|(!\[.*?\]\(.*?\))|(\[.*?\]\(.*?\))|(<span.*?>.*?</span>)|(\[\^.*?\]:.*?$)|(\[\^.*?\])'
289
+ parts = re.split(pattern, text)
290
+
291
+ for part in parts:
292
+ if not part: continue
293
+
294
+ if part.startswith('$$') and part.endswith('$$'):
295
+ math_text = part.strip('$').strip()
296
+ try:
297
+ math2docx.add_math(paragraph, math_text)
298
+ except Exception:
299
+ run = paragraph.add_run(part)
300
+ if tag in styles: _apply_css_to_run(run, styles[tag])
301
+
302
+ elif part.startswith('![') and part.endswith(')'):
303
+ m = re.match(r'!\[(.*?)\]\((.*?)\)', part)
304
+ if m:
305
+ _add_image(paragraph, m.group(2), m.group(1), styles)
306
+
307
+ elif part.startswith('[') and part.endswith(')'):
308
+ m = re.match(r'\[(.*?)\]\((.*?)\)', part)
309
+ if m:
310
+ _add_hyperlink(paragraph, m.group(1), m.group(2), styles, tag)
311
+
312
+ elif part.startswith('<span') and part.endswith('</span>'):
313
+ m = re.match(r'<span.*?>(.*?)</span>', part)
314
+ if m:
315
+ caption_text = m.group(1)
316
+ run = paragraph.add_run(caption_text)
317
+ run.italic = True
318
+ paragraph.alignment = docx.enum.text.WD_ALIGN_PARAGRAPH.CENTER
319
+ if tag in styles: _apply_css_to_run(run, styles[tag])
320
+
321
+ elif part.startswith('[^') and ']:' in part:
322
+ # Footnote definition e.g. [^1]: The text
323
+ m = re.match(r'\[\^(.*?)\]:\s*(.*)', part)
324
+ if m:
325
+ fn_id = m.group(1)
326
+ fn_text = m.group(2)
327
+ # Create a bookmark here for the annotation to jump to
328
+ run = _add_bookmark(paragraph, f"[{fn_id}]: {fn_text}", f"footnote_{fn_id}")
329
+ if tag in styles: _apply_css_to_run(run, styles[tag])
330
+
331
+ elif part.startswith('[^') and part.endswith(']'):
332
+ # Footnote annotation e.g. [^1]
333
+ m = re.match(r'\[\^(.*?)\]', part)
334
+ if m:
335
+ fn_id = m.group(1)
336
+ _add_internal_hyperlink(paragraph, f"[{fn_id}]", f"footnote_{fn_id}")
337
+
338
+ else:
339
+ # Handle plain text + bold/italic
340
+ subparts = re.split(r'(\*\*.*?\*\*|\*.*?\*)', part)
341
+ for subpart in subparts:
342
+ if not subpart: continue
343
+ if subpart.startswith('**') and subpart.endswith('**'):
344
+ run = paragraph.add_run(subpart[2:-2])
345
+ run.bold = True
346
+ if tag in styles: _apply_css_to_run(run, styles[tag])
347
+ elif subpart.startswith('*') and subpart.endswith('*'):
348
+ run = paragraph.add_run(subpart[1:-1])
349
+ run.italic = True
350
+ if tag in styles: _apply_css_to_run(run, styles[tag])
351
+ else:
352
+ run = paragraph.add_run(subpart)
353
+ if tag in styles: _apply_css_to_run(run, styles[tag])
354
+
355
+ def docx_writer(blocks: list, styles: dict, output_file: str):
356
+ """
357
+ Translates parsed markdown blocks into a Word document and applies CSS styling.
358
+ """
359
+ doc = Document()
360
+
361
+ # Global body style
362
+ if 'body' in styles:
363
+ normal_style = doc.styles['Normal']
364
+ _apply_css_to_run(normal_style, styles['body'])
365
+ _apply_paragraph_formatting(normal_style, styles['body'])
366
+
367
+ for block in blocks:
368
+ btype = block[0]
369
+
370
+ if btype == 'heading':
371
+ level, text = block[1], block[2]
372
+ p = doc.add_heading('', level=level)
373
+ _process_inline(p, text, styles, f'h{level}')
374
+ if f'h{level}' in styles:
375
+ _apply_paragraph_formatting(p, styles[f'h{level}'])
376
+
377
+ elif btype == 'paragraph':
378
+ text = block[1]
379
+ p = doc.add_paragraph()
380
+ if 'p' in styles:
381
+ _apply_paragraph_formatting(p, styles['p'])
382
+ _process_inline(p, text, styles, 'p')
383
+
384
+ elif btype == 'list':
385
+ text = block[1]
386
+ list_type = block[2] if len(block) > 2 else 'unordered'
387
+ style = 'List Number' if list_type == 'ordered' else 'List Bullet'
388
+ p = doc.add_paragraph(style=style)
389
+ if 'list' in styles:
390
+ _apply_paragraph_formatting(p, styles['list'])
391
+ _process_inline(p, text, styles, 'list')
392
+
393
+ elif btype == 'math':
394
+ math_text = block[1]
395
+ p = doc.add_paragraph()
396
+ try:
397
+ math2docx.add_math(p, math_text)
398
+ if 'equation' in styles:
399
+ _apply_paragraph_formatting(p, styles['equation'])
400
+ except Exception:
401
+ p.add_run(f"$$ {math_text} $$")
402
+
403
+ elif btype == 'table':
404
+ table_lines = block[1]
405
+ rows = [
406
+ [cell.strip() for cell in line.strip('|').split('|')]
407
+ for line in table_lines if '---' not in line
408
+ ]
409
+ if rows:
410
+ table = doc.add_table(rows=len(rows), cols=len(rows[0]))
411
+ for r_idx, row in enumerate(rows):
412
+ for c_idx, val in enumerate(row):
413
+ table.cell(r_idx, c_idx).text = val
414
+ if 'table' in styles:
415
+ pass
416
+
417
+ doc.save(output_file)
418
+
419
+
420
+ if __name__ == "__main__":
421
+ # Example Usage:
422
+ styles = style_parser('engine/style.css')
423
+ md_blocks = markdown_parser('## Hello World\nThis is a **bold** test.')
424
+ docx_writer(md_blocks, styles, 'output.docx')
425
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyact-cli
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -4,6 +4,8 @@ pamd_helpers/__init__.py
4
4
  pyact/__init__.py
5
5
  pyact/cli.py
6
6
  pyact/core.py
7
+ pyact/css2json.py
8
+ pyact/mdTOword.py
7
9
  pyact/py2tex.py
8
10
  pyact_cli.egg-info/PKG-INFO
9
11
  pyact_cli.egg-info/SOURCES.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.3.0",
8
+ version="0.4.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.3.0"
File without changes
File without changes
File without changes
File without changes