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.
@@ -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)