markdocx 0.1.0__py3-none-any.whl
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.
- markdocx/__init__.py +11 -0
- markdocx/cli.py +116 -0
- markdocx/code_renderer.py +164 -0
- markdocx/core.py +130 -0
- markdocx/docx_builder.py +743 -0
- markdocx/math_renderer.py +476 -0
- markdocx/md_parser.py +61 -0
- markdocx/styles.py +248 -0
- markdocx-0.1.0.dist-info/METADATA +182 -0
- markdocx-0.1.0.dist-info/RECORD +13 -0
- markdocx-0.1.0.dist-info/WHEEL +4 -0
- markdocx-0.1.0.dist-info/entry_points.txt +2 -0
- markdocx-0.1.0.dist-info/licenses/LICENSE +21 -0
markdocx/docx_builder.py
ADDED
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DOCX document builder.
|
|
3
|
+
Chuyển đổi token stream từ markdown-it-py thành tài liệu DOCX.
|
|
4
|
+
Xử lý tất cả các phần tử: heading, paragraph, list, table, code, math, image, link...
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import logging
|
|
9
|
+
from io import BytesIO
|
|
10
|
+
|
|
11
|
+
from docx import Document
|
|
12
|
+
from docx.shared import Pt, Inches, RGBColor, Emu
|
|
13
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
14
|
+
from docx.oxml import OxmlElement
|
|
15
|
+
from docx.oxml.ns import qn
|
|
16
|
+
from docx.opc.constants import RELATIONSHIP_TYPE as RT
|
|
17
|
+
|
|
18
|
+
from markdocx.styles import (
|
|
19
|
+
FONT_BODY,
|
|
20
|
+
FONT_CODE,
|
|
21
|
+
FONT_SIZE_BODY,
|
|
22
|
+
FONT_SIZE_CODE,
|
|
23
|
+
COLOR_BODY_TEXT,
|
|
24
|
+
COLOR_LINK,
|
|
25
|
+
COLOR_INLINE_CODE_BG,
|
|
26
|
+
COLOR_BLOCKQUOTE_TEXT,
|
|
27
|
+
LIST_INDENT,
|
|
28
|
+
set_run_shading,
|
|
29
|
+
set_blockquote_style,
|
|
30
|
+
add_horizontal_rule,
|
|
31
|
+
set_table_header_shading,
|
|
32
|
+
setup_document_styles,
|
|
33
|
+
)
|
|
34
|
+
from markdocx.math_renderer import latex_to_omml, latex_to_omml_para
|
|
35
|
+
from markdocx.code_renderer import add_code_block_to_doc
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DocxBuilder:
|
|
41
|
+
"""Builds a DOCX document from markdown-it-py tokens."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, base_dir=".", image_max_width=Inches(5.5)):
|
|
44
|
+
"""
|
|
45
|
+
Args:
|
|
46
|
+
base_dir: Base directory for resolving relative image paths
|
|
47
|
+
image_max_width: Maximum width for images in the document
|
|
48
|
+
"""
|
|
49
|
+
self.doc = Document()
|
|
50
|
+
self.base_dir = os.path.abspath(base_dir)
|
|
51
|
+
self.image_max_width = image_max_width
|
|
52
|
+
self._blockquote_depth = 0
|
|
53
|
+
self._list_depth = 0
|
|
54
|
+
self._ordered_counters = {} # depth -> counter
|
|
55
|
+
self._shape_id_counter = 0
|
|
56
|
+
|
|
57
|
+
setup_document_styles(self.doc)
|
|
58
|
+
|
|
59
|
+
def _next_shape_id(self):
|
|
60
|
+
self._shape_id_counter += 1
|
|
61
|
+
return self._shape_id_counter
|
|
62
|
+
|
|
63
|
+
# ════════════════════════════════════════════════════════════════
|
|
64
|
+
# Main entry point
|
|
65
|
+
# ════════════════════════════════════════════════════════════════
|
|
66
|
+
|
|
67
|
+
def build(self, tokens, output_path):
|
|
68
|
+
"""
|
|
69
|
+
Build a DOCX document from tokens and save it.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
tokens: List of markdown-it-py tokens
|
|
73
|
+
output_path: Path to save the DOCX file
|
|
74
|
+
"""
|
|
75
|
+
self._process_tokens(tokens)
|
|
76
|
+
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
|
77
|
+
self.doc.save(output_path)
|
|
78
|
+
logger.info(f"Saved: {output_path}")
|
|
79
|
+
|
|
80
|
+
# ════════════════════════════════════════════════════════════════
|
|
81
|
+
# Token stream processor
|
|
82
|
+
# ════════════════════════════════════════════════════════════════
|
|
83
|
+
|
|
84
|
+
def _process_tokens(self, tokens):
|
|
85
|
+
"""Walk the token list and dispatch to handlers."""
|
|
86
|
+
i = 0
|
|
87
|
+
while i < len(tokens):
|
|
88
|
+
token = tokens[i]
|
|
89
|
+
t = token.type
|
|
90
|
+
|
|
91
|
+
if t == "heading_open":
|
|
92
|
+
i = self._handle_heading(tokens, i)
|
|
93
|
+
elif t == "paragraph_open":
|
|
94
|
+
i = self._handle_paragraph(tokens, i)
|
|
95
|
+
elif t in ("fence", "code_block"):
|
|
96
|
+
self._handle_code_block(token)
|
|
97
|
+
i += 1
|
|
98
|
+
elif t in ("math_block", "math_block_eqno"):
|
|
99
|
+
self._handle_math_block(token)
|
|
100
|
+
i += 1
|
|
101
|
+
elif t == "bullet_list_open":
|
|
102
|
+
i = self._handle_list(tokens, i, ordered=False)
|
|
103
|
+
elif t == "ordered_list_open":
|
|
104
|
+
i = self._handle_list(tokens, i, ordered=True)
|
|
105
|
+
elif t == "blockquote_open":
|
|
106
|
+
i = self._handle_blockquote(tokens, i)
|
|
107
|
+
elif t == "table_open":
|
|
108
|
+
i = self._handle_table(tokens, i)
|
|
109
|
+
elif t == "hr":
|
|
110
|
+
add_horizontal_rule(self.doc)
|
|
111
|
+
i += 1
|
|
112
|
+
elif t == "html_block":
|
|
113
|
+
# Skip raw HTML blocks
|
|
114
|
+
i += 1
|
|
115
|
+
elif t == "front_matter":
|
|
116
|
+
# Skip YAML front matter
|
|
117
|
+
i += 1
|
|
118
|
+
elif t == "footnote_block_open":
|
|
119
|
+
i = self._handle_footnote_block(tokens, i)
|
|
120
|
+
else:
|
|
121
|
+
# Skip unknown tokens
|
|
122
|
+
i += 1
|
|
123
|
+
|
|
124
|
+
# ════════════════════════════════════════════════════════════════
|
|
125
|
+
# Block handlers
|
|
126
|
+
# ════════════════════════════════════════════════════════════════
|
|
127
|
+
|
|
128
|
+
def _handle_heading(self, tokens, i):
|
|
129
|
+
"""Handle heading_open → inline → heading_close."""
|
|
130
|
+
level = int(tokens[i].tag[1]) # h1→1, h2→2, ...
|
|
131
|
+
i += 1
|
|
132
|
+
|
|
133
|
+
if i < len(tokens) and tokens[i].type == "inline":
|
|
134
|
+
inline_token = tokens[i]
|
|
135
|
+
i += 1
|
|
136
|
+
else:
|
|
137
|
+
inline_token = None
|
|
138
|
+
|
|
139
|
+
# Skip heading_close
|
|
140
|
+
if i < len(tokens) and tokens[i].type == "heading_close":
|
|
141
|
+
i += 1
|
|
142
|
+
|
|
143
|
+
heading = self.doc.add_heading("", level=min(level, 6))
|
|
144
|
+
# Clear the default run
|
|
145
|
+
heading.clear()
|
|
146
|
+
|
|
147
|
+
if inline_token and inline_token.children:
|
|
148
|
+
self._process_inline(heading, inline_token.children)
|
|
149
|
+
|
|
150
|
+
# Apply blockquote styling if inside blockquote
|
|
151
|
+
if self._blockquote_depth > 0:
|
|
152
|
+
set_blockquote_style(heading, self._blockquote_depth)
|
|
153
|
+
|
|
154
|
+
return i
|
|
155
|
+
|
|
156
|
+
def _handle_paragraph(self, tokens, i):
|
|
157
|
+
"""Handle paragraph_open → inline → paragraph_close."""
|
|
158
|
+
i += 1 # Skip paragraph_open
|
|
159
|
+
|
|
160
|
+
inline_token = None
|
|
161
|
+
if i < len(tokens) and tokens[i].type == "inline":
|
|
162
|
+
inline_token = tokens[i]
|
|
163
|
+
i += 1
|
|
164
|
+
|
|
165
|
+
# Skip paragraph_close
|
|
166
|
+
if i < len(tokens) and tokens[i].type == "paragraph_close":
|
|
167
|
+
i += 1
|
|
168
|
+
|
|
169
|
+
# Check if this paragraph is a list item
|
|
170
|
+
if self._list_depth > 0:
|
|
171
|
+
para = self.doc.add_paragraph()
|
|
172
|
+
depth = self._list_depth - 1
|
|
173
|
+
para.paragraph_format.left_indent = LIST_INDENT * (depth + 1)
|
|
174
|
+
else:
|
|
175
|
+
para = self.doc.add_paragraph()
|
|
176
|
+
|
|
177
|
+
if inline_token and inline_token.children:
|
|
178
|
+
self._process_inline(para, inline_token.children)
|
|
179
|
+
|
|
180
|
+
# Apply blockquote styling if inside blockquote
|
|
181
|
+
if self._blockquote_depth > 0:
|
|
182
|
+
set_blockquote_style(para, self._blockquote_depth)
|
|
183
|
+
for run in para.runs:
|
|
184
|
+
run.font.color.rgb = COLOR_BLOCKQUOTE_TEXT
|
|
185
|
+
|
|
186
|
+
return i
|
|
187
|
+
|
|
188
|
+
def _handle_code_block(self, token):
|
|
189
|
+
"""Handle fence or code_block token."""
|
|
190
|
+
language = ""
|
|
191
|
+
if token.info:
|
|
192
|
+
language = token.info.strip().split()[0]
|
|
193
|
+
code = token.content
|
|
194
|
+
add_code_block_to_doc(self.doc, code, language)
|
|
195
|
+
|
|
196
|
+
def _handle_math_block(self, token):
|
|
197
|
+
"""Handle display math ($$...$$) block using native OMML."""
|
|
198
|
+
latex_str = token.content.strip()
|
|
199
|
+
if not latex_str:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
omml_para = latex_to_omml_para(latex_str)
|
|
203
|
+
if omml_para is not None:
|
|
204
|
+
para = self.doc.add_paragraph()
|
|
205
|
+
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
206
|
+
para.paragraph_format.space_before = Pt(6)
|
|
207
|
+
para.paragraph_format.space_after = Pt(6)
|
|
208
|
+
para._element.append(omml_para)
|
|
209
|
+
else:
|
|
210
|
+
# Fallback: show raw LaTeX in code font
|
|
211
|
+
para = self.doc.add_paragraph()
|
|
212
|
+
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
213
|
+
run = para.add_run(f"[{latex_str}]")
|
|
214
|
+
run.font.name = FONT_CODE
|
|
215
|
+
run.font.size = FONT_SIZE_CODE
|
|
216
|
+
run.italic = True
|
|
217
|
+
|
|
218
|
+
def _handle_list(self, tokens, i, ordered=False):
|
|
219
|
+
"""Handle bullet_list or ordered_list (with nesting)."""
|
|
220
|
+
self._list_depth += 1
|
|
221
|
+
depth = self._list_depth - 1
|
|
222
|
+
|
|
223
|
+
if ordered:
|
|
224
|
+
self._ordered_counters[depth] = 0
|
|
225
|
+
|
|
226
|
+
i += 1 # Skip list_open
|
|
227
|
+
|
|
228
|
+
while i < len(tokens):
|
|
229
|
+
token = tokens[i]
|
|
230
|
+
|
|
231
|
+
if token.type in ("bullet_list_close", "ordered_list_close"):
|
|
232
|
+
i += 1
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
if token.type == "list_item_open":
|
|
236
|
+
if ordered:
|
|
237
|
+
self._ordered_counters[depth] = \
|
|
238
|
+
self._ordered_counters.get(depth, 0) + 1
|
|
239
|
+
i += 1
|
|
240
|
+
|
|
241
|
+
# Process content within list item
|
|
242
|
+
while i < len(tokens) and tokens[i].type != "list_item_close":
|
|
243
|
+
if tokens[i].type == "paragraph_open":
|
|
244
|
+
i += 1 # Skip paragraph_open
|
|
245
|
+
|
|
246
|
+
inline_token = None
|
|
247
|
+
if i < len(tokens) and tokens[i].type == "inline":
|
|
248
|
+
inline_token = tokens[i]
|
|
249
|
+
i += 1
|
|
250
|
+
|
|
251
|
+
if i < len(tokens) and tokens[i].type == "paragraph_close":
|
|
252
|
+
i += 1
|
|
253
|
+
|
|
254
|
+
# Create list item paragraph
|
|
255
|
+
para = self.doc.add_paragraph()
|
|
256
|
+
para.paragraph_format.left_indent = LIST_INDENT * (depth + 1)
|
|
257
|
+
para.paragraph_format.space_before = Pt(1)
|
|
258
|
+
para.paragraph_format.space_after = Pt(1)
|
|
259
|
+
|
|
260
|
+
# Add bullet/number prefix
|
|
261
|
+
bullets = ["•", "◦", "▪", "▹", "•", "◦"]
|
|
262
|
+
if ordered:
|
|
263
|
+
num = self._ordered_counters.get(depth, 1)
|
|
264
|
+
prefix_run = para.add_run(f"{num}. ")
|
|
265
|
+
prefix_run.font.name = FONT_BODY
|
|
266
|
+
prefix_run.font.size = FONT_SIZE_BODY
|
|
267
|
+
prefix_run.bold = True
|
|
268
|
+
else:
|
|
269
|
+
bullet_char = bullets[min(depth, len(bullets) - 1)]
|
|
270
|
+
prefix_run = para.add_run(f"{bullet_char} ")
|
|
271
|
+
prefix_run.font.name = FONT_BODY
|
|
272
|
+
prefix_run.font.size = FONT_SIZE_BODY
|
|
273
|
+
|
|
274
|
+
# Add content
|
|
275
|
+
if inline_token and inline_token.children:
|
|
276
|
+
self._process_inline(para, inline_token.children)
|
|
277
|
+
|
|
278
|
+
# Apply blockquote if needed
|
|
279
|
+
if self._blockquote_depth > 0:
|
|
280
|
+
set_blockquote_style(para, self._blockquote_depth)
|
|
281
|
+
|
|
282
|
+
elif tokens[i].type == "bullet_list_open":
|
|
283
|
+
i = self._handle_list(tokens, i, ordered=False)
|
|
284
|
+
elif tokens[i].type == "ordered_list_open":
|
|
285
|
+
i = self._handle_list(tokens, i, ordered=True)
|
|
286
|
+
elif tokens[i].type in ("fence", "code_block"):
|
|
287
|
+
self._handle_code_block(tokens[i])
|
|
288
|
+
i += 1
|
|
289
|
+
elif tokens[i].type in ("math_block", "math_block_eqno"):
|
|
290
|
+
self._handle_math_block(tokens[i])
|
|
291
|
+
i += 1
|
|
292
|
+
elif tokens[i].type == "blockquote_open":
|
|
293
|
+
i = self._handle_blockquote(tokens, i)
|
|
294
|
+
else:
|
|
295
|
+
i += 1
|
|
296
|
+
|
|
297
|
+
# Skip list_item_close
|
|
298
|
+
if i < len(tokens) and tokens[i].type == "list_item_close":
|
|
299
|
+
i += 1
|
|
300
|
+
else:
|
|
301
|
+
i += 1
|
|
302
|
+
|
|
303
|
+
self._list_depth -= 1
|
|
304
|
+
if ordered and depth in self._ordered_counters:
|
|
305
|
+
del self._ordered_counters[depth]
|
|
306
|
+
|
|
307
|
+
return i
|
|
308
|
+
|
|
309
|
+
def _handle_blockquote(self, tokens, i):
|
|
310
|
+
"""Handle blockquote by collecting inner tokens and processing them."""
|
|
311
|
+
self._blockquote_depth += 1
|
|
312
|
+
i += 1 # Skip blockquote_open
|
|
313
|
+
depth = 1
|
|
314
|
+
|
|
315
|
+
inner_tokens = []
|
|
316
|
+
while i < len(tokens):
|
|
317
|
+
if tokens[i].type == "blockquote_open":
|
|
318
|
+
depth += 1
|
|
319
|
+
elif tokens[i].type == "blockquote_close":
|
|
320
|
+
depth -= 1
|
|
321
|
+
if depth == 0:
|
|
322
|
+
i += 1
|
|
323
|
+
break
|
|
324
|
+
inner_tokens.append(tokens[i])
|
|
325
|
+
i += 1
|
|
326
|
+
|
|
327
|
+
self._process_tokens(inner_tokens)
|
|
328
|
+
self._blockquote_depth -= 1
|
|
329
|
+
return i
|
|
330
|
+
|
|
331
|
+
def _handle_table(self, tokens, i):
|
|
332
|
+
"""Parse table tokens and create a Word table."""
|
|
333
|
+
i += 1 # Skip table_open
|
|
334
|
+
|
|
335
|
+
rows_data = []
|
|
336
|
+
current_row = []
|
|
337
|
+
is_header_section = False
|
|
338
|
+
|
|
339
|
+
while i < len(tokens):
|
|
340
|
+
token = tokens[i]
|
|
341
|
+
|
|
342
|
+
if token.type == "table_close":
|
|
343
|
+
i += 1
|
|
344
|
+
break
|
|
345
|
+
elif token.type == "thead_open":
|
|
346
|
+
is_header_section = True
|
|
347
|
+
i += 1
|
|
348
|
+
elif token.type == "thead_close":
|
|
349
|
+
is_header_section = False
|
|
350
|
+
i += 1
|
|
351
|
+
elif token.type in ("tbody_open", "tbody_close"):
|
|
352
|
+
i += 1
|
|
353
|
+
elif token.type == "tr_open":
|
|
354
|
+
current_row = []
|
|
355
|
+
i += 1
|
|
356
|
+
elif token.type == "tr_close":
|
|
357
|
+
rows_data.append((current_row[:], is_header_section))
|
|
358
|
+
i += 1
|
|
359
|
+
elif token.type in ("th_open", "td_open"):
|
|
360
|
+
# Get alignment from token attrs
|
|
361
|
+
align = None
|
|
362
|
+
if token.attrs:
|
|
363
|
+
style_val = token.attrGet("style") if hasattr(token, 'attrGet') else None
|
|
364
|
+
if style_val and "text-align:center" in str(style_val):
|
|
365
|
+
align = WD_ALIGN_PARAGRAPH.CENTER
|
|
366
|
+
elif style_val and "text-align:right" in str(style_val):
|
|
367
|
+
align = WD_ALIGN_PARAGRAPH.RIGHT
|
|
368
|
+
i += 1
|
|
369
|
+
# Next should be inline
|
|
370
|
+
inline_tok = None
|
|
371
|
+
if i < len(tokens) and tokens[i].type == "inline":
|
|
372
|
+
inline_tok = tokens[i]
|
|
373
|
+
i += 1
|
|
374
|
+
current_row.append((inline_tok, is_header_section, align))
|
|
375
|
+
# Skip th_close / td_close
|
|
376
|
+
if i < len(tokens) and tokens[i].type in ("th_close", "td_close"):
|
|
377
|
+
i += 1
|
|
378
|
+
else:
|
|
379
|
+
i += 1
|
|
380
|
+
|
|
381
|
+
# Build the table
|
|
382
|
+
if not rows_data:
|
|
383
|
+
return i
|
|
384
|
+
|
|
385
|
+
num_cols = max(len(row) for row, _ in rows_data) if rows_data else 0
|
|
386
|
+
num_rows = len(rows_data)
|
|
387
|
+
|
|
388
|
+
if num_cols == 0 or num_rows == 0:
|
|
389
|
+
return i
|
|
390
|
+
|
|
391
|
+
table = self.doc.add_table(rows=num_rows, cols=num_cols)
|
|
392
|
+
table.style = "Table Grid"
|
|
393
|
+
table.alignment = 1 # Center
|
|
394
|
+
|
|
395
|
+
for row_idx, (row_cells, is_hdr) in enumerate(rows_data):
|
|
396
|
+
for col_idx, cell_data in enumerate(row_cells):
|
|
397
|
+
if col_idx >= num_cols:
|
|
398
|
+
break
|
|
399
|
+
inline_tok, is_header, align = cell_data
|
|
400
|
+
cell = table.rows[row_idx].cells[col_idx]
|
|
401
|
+
para = cell.paragraphs[0]
|
|
402
|
+
para.paragraph_format.space_before = Pt(2)
|
|
403
|
+
para.paragraph_format.space_after = Pt(2)
|
|
404
|
+
|
|
405
|
+
if align:
|
|
406
|
+
para.alignment = align
|
|
407
|
+
|
|
408
|
+
if inline_tok and inline_tok.children:
|
|
409
|
+
self._process_inline(para, inline_tok.children)
|
|
410
|
+
|
|
411
|
+
if is_header or is_hdr:
|
|
412
|
+
set_table_header_shading(cell)
|
|
413
|
+
for run in para.runs:
|
|
414
|
+
run.bold = True
|
|
415
|
+
|
|
416
|
+
return i
|
|
417
|
+
|
|
418
|
+
def _handle_footnote_block(self, tokens, i):
|
|
419
|
+
"""Handle footnote block."""
|
|
420
|
+
i += 1 # Skip footnote_block_open
|
|
421
|
+
|
|
422
|
+
# Add separator
|
|
423
|
+
add_horizontal_rule(self.doc)
|
|
424
|
+
title = self.doc.add_paragraph()
|
|
425
|
+
run = title.add_run("Chú thích")
|
|
426
|
+
run.bold = True
|
|
427
|
+
run.font.size = Pt(10)
|
|
428
|
+
|
|
429
|
+
depth = 1
|
|
430
|
+
while i < len(tokens) and depth > 0:
|
|
431
|
+
if tokens[i].type == "footnote_block_open":
|
|
432
|
+
depth += 1
|
|
433
|
+
elif tokens[i].type == "footnote_block_close":
|
|
434
|
+
depth -= 1
|
|
435
|
+
if depth == 0:
|
|
436
|
+
i += 1
|
|
437
|
+
break
|
|
438
|
+
elif tokens[i].type == "footnote_open":
|
|
439
|
+
i += 1
|
|
440
|
+
continue
|
|
441
|
+
elif tokens[i].type == "footnote_close":
|
|
442
|
+
i += 1
|
|
443
|
+
continue
|
|
444
|
+
elif tokens[i].type == "paragraph_open":
|
|
445
|
+
i += 1
|
|
446
|
+
if i < len(tokens) and tokens[i].type == "inline":
|
|
447
|
+
inline_tok = tokens[i]
|
|
448
|
+
i += 1
|
|
449
|
+
para = self.doc.add_paragraph()
|
|
450
|
+
para.paragraph_format.left_indent = Inches(0.3)
|
|
451
|
+
if inline_tok.children:
|
|
452
|
+
self._process_inline(para, inline_tok.children)
|
|
453
|
+
for run in para.runs:
|
|
454
|
+
run.font.size = Pt(9)
|
|
455
|
+
if i < len(tokens) and tokens[i].type == "paragraph_close":
|
|
456
|
+
i += 1
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
i += 1
|
|
460
|
+
|
|
461
|
+
return i
|
|
462
|
+
|
|
463
|
+
# ════════════════════════════════════════════════════════════════
|
|
464
|
+
# Inline content processor
|
|
465
|
+
# ════════════════════════════════════════════════════════════════
|
|
466
|
+
|
|
467
|
+
def _process_inline(self, paragraph, children):
|
|
468
|
+
"""
|
|
469
|
+
Process inline tokens and add formatted runs to a paragraph.
|
|
470
|
+
Handles: text, bold, italic, strikethrough, code, math, links, images.
|
|
471
|
+
"""
|
|
472
|
+
if not children:
|
|
473
|
+
return
|
|
474
|
+
|
|
475
|
+
bold = False
|
|
476
|
+
italic = False
|
|
477
|
+
strikethrough = False
|
|
478
|
+
link_href = None
|
|
479
|
+
|
|
480
|
+
idx = 0
|
|
481
|
+
while idx < len(children):
|
|
482
|
+
child = children[idx]
|
|
483
|
+
ct = child.type
|
|
484
|
+
|
|
485
|
+
# ── Formatting toggles ───────────────────────────
|
|
486
|
+
if ct == "strong_open":
|
|
487
|
+
bold = True
|
|
488
|
+
idx += 1
|
|
489
|
+
continue
|
|
490
|
+
elif ct == "strong_close":
|
|
491
|
+
bold = False
|
|
492
|
+
idx += 1
|
|
493
|
+
continue
|
|
494
|
+
elif ct == "em_open":
|
|
495
|
+
italic = True
|
|
496
|
+
idx += 1
|
|
497
|
+
continue
|
|
498
|
+
elif ct == "em_close":
|
|
499
|
+
italic = False
|
|
500
|
+
idx += 1
|
|
501
|
+
continue
|
|
502
|
+
elif ct == "s_open":
|
|
503
|
+
strikethrough = True
|
|
504
|
+
idx += 1
|
|
505
|
+
continue
|
|
506
|
+
elif ct == "s_close":
|
|
507
|
+
strikethrough = False
|
|
508
|
+
idx += 1
|
|
509
|
+
continue
|
|
510
|
+
|
|
511
|
+
# ── Links ────────────────────────────────────────
|
|
512
|
+
elif ct == "link_open":
|
|
513
|
+
href = child.attrGet("href") if hasattr(child, 'attrGet') else None
|
|
514
|
+
if not href and child.attrs:
|
|
515
|
+
href = child.attrs.get("href", "")
|
|
516
|
+
link_href = href or ""
|
|
517
|
+
idx += 1
|
|
518
|
+
continue
|
|
519
|
+
elif ct == "link_close":
|
|
520
|
+
link_href = None
|
|
521
|
+
idx += 1
|
|
522
|
+
continue
|
|
523
|
+
|
|
524
|
+
# ── Plain text ───────────────────────────────────
|
|
525
|
+
elif ct == "text":
|
|
526
|
+
text = child.content
|
|
527
|
+
if link_href:
|
|
528
|
+
self._add_hyperlink(paragraph, link_href, text,
|
|
529
|
+
bold=bold, italic=italic)
|
|
530
|
+
else:
|
|
531
|
+
run = paragraph.add_run(text)
|
|
532
|
+
self._apply_run_format(run, bold, italic, strikethrough)
|
|
533
|
+
idx += 1
|
|
534
|
+
continue
|
|
535
|
+
|
|
536
|
+
# ── Inline code ──────────────────────────────────
|
|
537
|
+
elif ct == "code_inline":
|
|
538
|
+
run = paragraph.add_run(child.content)
|
|
539
|
+
run.font.name = FONT_CODE
|
|
540
|
+
run.font.size = FONT_SIZE_CODE
|
|
541
|
+
set_run_shading(run, COLOR_INLINE_CODE_BG)
|
|
542
|
+
# East Asian font
|
|
543
|
+
rPr = run._element.get_or_add_rPr()
|
|
544
|
+
rFonts = rPr.find(qn("w:rFonts"))
|
|
545
|
+
if rFonts is None:
|
|
546
|
+
rFonts = OxmlElement("w:rFonts")
|
|
547
|
+
rPr.insert(0, rFonts)
|
|
548
|
+
rFonts.set(qn("w:eastAsia"), FONT_CODE)
|
|
549
|
+
if bold:
|
|
550
|
+
run.bold = True
|
|
551
|
+
if italic:
|
|
552
|
+
run.italic = True
|
|
553
|
+
idx += 1
|
|
554
|
+
continue
|
|
555
|
+
|
|
556
|
+
# ── Inline math ──────────────────────────────────
|
|
557
|
+
elif ct == "math_inline":
|
|
558
|
+
latex_str = child.content.strip()
|
|
559
|
+
omml = latex_to_omml(latex_str)
|
|
560
|
+
if omml is not None:
|
|
561
|
+
paragraph._element.append(omml)
|
|
562
|
+
else:
|
|
563
|
+
# Fallback: show as styled text
|
|
564
|
+
run = paragraph.add_run(f"${latex_str}$")
|
|
565
|
+
run.font.name = FONT_CODE
|
|
566
|
+
run.font.size = FONT_SIZE_CODE
|
|
567
|
+
run.italic = True
|
|
568
|
+
idx += 1
|
|
569
|
+
continue
|
|
570
|
+
|
|
571
|
+
# ── Inline math (display in inline context) ──────
|
|
572
|
+
elif ct == "math_inline_double":
|
|
573
|
+
latex_str = child.content.strip()
|
|
574
|
+
omml = latex_to_omml(latex_str)
|
|
575
|
+
if omml is not None:
|
|
576
|
+
paragraph._element.append(omml)
|
|
577
|
+
else:
|
|
578
|
+
run = paragraph.add_run(f"$${latex_str}$$")
|
|
579
|
+
run.font.name = FONT_CODE
|
|
580
|
+
run.italic = True
|
|
581
|
+
idx += 1
|
|
582
|
+
continue
|
|
583
|
+
|
|
584
|
+
# ── Image ────────────────────────────────────────
|
|
585
|
+
elif ct == "image":
|
|
586
|
+
self._handle_image(paragraph, child)
|
|
587
|
+
idx += 1
|
|
588
|
+
continue
|
|
589
|
+
|
|
590
|
+
# ── Line breaks ──────────────────────────────────
|
|
591
|
+
elif ct == "softbreak":
|
|
592
|
+
run = paragraph.add_run("\n")
|
|
593
|
+
idx += 1
|
|
594
|
+
continue
|
|
595
|
+
elif ct == "hardbreak":
|
|
596
|
+
run = paragraph.add_run()
|
|
597
|
+
run.add_break()
|
|
598
|
+
idx += 1
|
|
599
|
+
continue
|
|
600
|
+
|
|
601
|
+
# ── HTML inline (skip) ───────────────────────────
|
|
602
|
+
elif ct == "html_inline":
|
|
603
|
+
# Try to handle <br> tags
|
|
604
|
+
content = child.content.strip().lower()
|
|
605
|
+
if content in ("<br>", "<br/>", "<br />"):
|
|
606
|
+
run = paragraph.add_run()
|
|
607
|
+
run.add_break()
|
|
608
|
+
idx += 1
|
|
609
|
+
continue
|
|
610
|
+
|
|
611
|
+
# ── Footnote reference ───────────────────────────
|
|
612
|
+
elif ct == "footnote_ref":
|
|
613
|
+
ref_id = child.meta.get("id", "?") if hasattr(child, "meta") and child.meta else "?"
|
|
614
|
+
run = paragraph.add_run(f"[{int(ref_id) + 1}]")
|
|
615
|
+
run.font.size = Pt(8)
|
|
616
|
+
run.font.superscript = True
|
|
617
|
+
run.font.color.rgb = COLOR_LINK
|
|
618
|
+
idx += 1
|
|
619
|
+
continue
|
|
620
|
+
|
|
621
|
+
else:
|
|
622
|
+
# Unknown inline token - skip
|
|
623
|
+
logger.debug(f"Unknown inline token: {ct}")
|
|
624
|
+
idx += 1
|
|
625
|
+
continue
|
|
626
|
+
|
|
627
|
+
def _apply_run_format(self, run, bold=False, italic=False, strikethrough=False):
|
|
628
|
+
"""Apply formatting to a run."""
|
|
629
|
+
if bold:
|
|
630
|
+
run.bold = True
|
|
631
|
+
if italic:
|
|
632
|
+
run.italic = True
|
|
633
|
+
if strikethrough:
|
|
634
|
+
run.font.strike = True
|
|
635
|
+
|
|
636
|
+
# ════════════════════════════════════════════════════════════════
|
|
637
|
+
# Hyperlinks
|
|
638
|
+
# ════════════════════════════════════════════════════════════════
|
|
639
|
+
|
|
640
|
+
def _add_hyperlink(self, paragraph, url, text, bold=False, italic=False):
|
|
641
|
+
"""Add a clickable hyperlink to a paragraph."""
|
|
642
|
+
try:
|
|
643
|
+
part = paragraph.part
|
|
644
|
+
r_id = part.relate_to(url, RT.HYPERLINK, is_external=True)
|
|
645
|
+
|
|
646
|
+
hyperlink = OxmlElement("w:hyperlink")
|
|
647
|
+
hyperlink.set(qn("r:id"), r_id)
|
|
648
|
+
|
|
649
|
+
new_run = OxmlElement("w:r")
|
|
650
|
+
rPr = OxmlElement("w:rPr")
|
|
651
|
+
|
|
652
|
+
# Color
|
|
653
|
+
color_el = OxmlElement("w:color")
|
|
654
|
+
color_el.set(qn("w:val"), "0563C1")
|
|
655
|
+
rPr.append(color_el)
|
|
656
|
+
|
|
657
|
+
# Underline
|
|
658
|
+
u_el = OxmlElement("w:u")
|
|
659
|
+
u_el.set(qn("w:val"), "single")
|
|
660
|
+
rPr.append(u_el)
|
|
661
|
+
|
|
662
|
+
# Font
|
|
663
|
+
rFonts = OxmlElement("w:rFonts")
|
|
664
|
+
rFonts.set(qn("w:ascii"), FONT_BODY)
|
|
665
|
+
rFonts.set(qn("w:hAnsi"), FONT_BODY)
|
|
666
|
+
rFonts.set(qn("w:eastAsia"), FONT_BODY)
|
|
667
|
+
rPr.append(rFonts)
|
|
668
|
+
|
|
669
|
+
# Bold / Italic
|
|
670
|
+
if bold:
|
|
671
|
+
b_el = OxmlElement("w:b")
|
|
672
|
+
rPr.append(b_el)
|
|
673
|
+
if italic:
|
|
674
|
+
i_el = OxmlElement("w:i")
|
|
675
|
+
rPr.append(i_el)
|
|
676
|
+
|
|
677
|
+
new_run.append(rPr)
|
|
678
|
+
|
|
679
|
+
text_el = OxmlElement("w:t")
|
|
680
|
+
text_el.set(qn("xml:space"), "preserve")
|
|
681
|
+
text_el.text = text
|
|
682
|
+
new_run.append(text_el)
|
|
683
|
+
|
|
684
|
+
hyperlink.append(new_run)
|
|
685
|
+
paragraph._element.append(hyperlink)
|
|
686
|
+
|
|
687
|
+
except Exception as e:
|
|
688
|
+
logger.warning(f"Failed to create hyperlink for {url}: {e}")
|
|
689
|
+
run = paragraph.add_run(text)
|
|
690
|
+
run.font.color.rgb = COLOR_LINK
|
|
691
|
+
run.underline = True
|
|
692
|
+
if bold:
|
|
693
|
+
run.bold = True
|
|
694
|
+
if italic:
|
|
695
|
+
run.italic = True
|
|
696
|
+
|
|
697
|
+
# ════════════════════════════════════════════════════════════════
|
|
698
|
+
# Images
|
|
699
|
+
# ════════════════════════════════════════════════════════════════
|
|
700
|
+
|
|
701
|
+
def _handle_image(self, paragraph, token):
|
|
702
|
+
"""Handle an image token."""
|
|
703
|
+
src = token.attrGet("src") if hasattr(token, 'attrGet') else None
|
|
704
|
+
if not src and token.attrs:
|
|
705
|
+
src = token.attrs.get("src", "")
|
|
706
|
+
alt = ""
|
|
707
|
+
if token.children:
|
|
708
|
+
alt = "".join(c.content for c in token.children if c.type == "text")
|
|
709
|
+
elif hasattr(token, 'attrGet'):
|
|
710
|
+
alt = token.attrGet("alt") or ""
|
|
711
|
+
|
|
712
|
+
if not src:
|
|
713
|
+
run = paragraph.add_run(f"[Image: {alt}]")
|
|
714
|
+
run.italic = True
|
|
715
|
+
return
|
|
716
|
+
|
|
717
|
+
# Resolve image path
|
|
718
|
+
image_path = src
|
|
719
|
+
if not os.path.isabs(image_path):
|
|
720
|
+
image_path = os.path.join(self.base_dir, image_path)
|
|
721
|
+
|
|
722
|
+
if os.path.exists(image_path):
|
|
723
|
+
try:
|
|
724
|
+
run = paragraph.add_run()
|
|
725
|
+
run.add_picture(image_path, width=self.image_max_width)
|
|
726
|
+
|
|
727
|
+
# Add alt text as caption if available
|
|
728
|
+
if alt:
|
|
729
|
+
cap_para = self.doc.add_paragraph()
|
|
730
|
+
cap_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
731
|
+
cap_run = cap_para.add_run(alt)
|
|
732
|
+
cap_run.font.size = Pt(9)
|
|
733
|
+
cap_run.italic = True
|
|
734
|
+
cap_run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
|
|
735
|
+
except Exception as e:
|
|
736
|
+
logger.warning(f"Failed to add image {src}: {e}")
|
|
737
|
+
run = paragraph.add_run(f"[Image: {alt or src}]")
|
|
738
|
+
run.italic = True
|
|
739
|
+
else:
|
|
740
|
+
logger.warning(f"Image not found: {image_path}")
|
|
741
|
+
run = paragraph.add_run(f"[Image not found: {src}]")
|
|
742
|
+
run.italic = True
|
|
743
|
+
run.font.color.rgb = RGBColor(0xCC, 0x00, 0x00)
|