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.
- {pyact_cli-0.3.0 → pyact_cli-0.4.0}/PKG-INFO +1 -1
- pyact_cli-0.4.0/pyact/__init__.py +1 -0
- {pyact_cli-0.3.0 → pyact_cli-0.4.0}/pyact/cli.py +22 -0
- pyact_cli-0.4.0/pyact/css2json.py +54 -0
- pyact_cli-0.4.0/pyact/mdTOword.py +425 -0
- {pyact_cli-0.3.0 → pyact_cli-0.4.0}/pyact_cli.egg-info/PKG-INFO +1 -1
- {pyact_cli-0.3.0 → pyact_cli-0.4.0}/pyact_cli.egg-info/SOURCES.txt +2 -0
- {pyact_cli-0.3.0 → pyact_cli-0.4.0}/setup.py +1 -1
- pyact_cli-0.3.0/pyact/__init__.py +0 -1
- {pyact_cli-0.3.0 → pyact_cli-0.4.0}/README.md +0 -0
- {pyact_cli-0.3.0 → pyact_cli-0.4.0}/pamd_helpers/__init__.py +0 -0
- {pyact_cli-0.3.0 → pyact_cli-0.4.0}/pyact/core.py +0 -0
- {pyact_cli-0.3.0 → pyact_cli-0.4.0}/pyact/py2tex.py +0 -0
- {pyact_cli-0.3.0 → pyact_cli-0.4.0}/pyact_cli.egg-info/dependency_links.txt +0 -0
- {pyact_cli-0.3.0 → pyact_cli-0.4.0}/pyact_cli.egg-info/entry_points.txt +0 -0
- {pyact_cli-0.3.0 → pyact_cli-0.4.0}/pyact_cli.egg-info/top_level.txt +0 -0
- {pyact_cli-0.3.0 → pyact_cli-0.4.0}/setup.cfg +0 -0
|
@@ -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 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.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
|