File size: 5,746 Bytes
31fa536
 
 
 
 
 
 
 
 
 
 
 
 
2559985
31fa536
 
 
 
 
 
 
 
2559985
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31fa536
 
2559985
31fa536
 
 
 
 
 
 
2559985
31fa536
2559985
31fa536
2559985
31fa536
 
 
 
 
 
 
 
 
 
2559985
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31fa536
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
"""Step 8: assemble the Markdown post + images + captions into a .docx file."""
from __future__ import annotations

import re
from pathlib import Path
from typing import List

from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.shared import Inches, Pt, RGBColor

_IMAGE_MARKER = re.compile(r"\[IMAGE:\s*.+?\]", re.DOTALL)
_INLINE = re.compile(r"(\*\*.+?\*\*|\*.+?\*|`.+?`)")
_SEP_CELL = re.compile(r"^:?-{2,}:?$")
_MAX_IMG_WIDTH = Inches(6.0)


def build_docx(markdown: str, images: List[dict], out_path: Path) -> Path:
    """Render `markdown` to a Word document, inserting `images` at [IMAGE:] markers in order."""
    doc = Document()
    img_iter = iter([im for im in images if im.get("path")])

    blocks = _split_blocks(markdown)
    i, n = 0, len(blocks)
    while i < n:
        block = blocks[i]

        # Markdown pipe table: a header row followed by a separator row (| --- | --- |).
        if _is_table_row(block) and i + 1 < n and _is_separator_row(blocks[i + 1]):
            j = i + 2
            rows = [block]
            while j < n and _is_table_row(blocks[j]):
                rows.append(blocks[j])
                j += 1
            _add_table(doc, rows)
            i = j
            continue

        if _IMAGE_MARKER.fullmatch(block.strip()):
            _insert_next_image(doc, img_iter)
            i += 1
            continue
        # a block may still contain an inline marker mixed with text
        if _IMAGE_MARKER.search(block):
            for piece in _IMAGE_MARKER.split(block):
                if piece.strip():
                    _render_line(doc, piece.strip())
            _insert_next_image(doc, img_iter)
            i += 1
            continue

        _render_line(doc, block)
        i += 1

    # any leftover images that never got placed → append at the end
    for im in img_iter:
        _add_picture(doc, im)

    out_path.parent.mkdir(parents=True, exist_ok=True)
    doc.save(out_path)
    return out_path


def _is_table_row(line: str) -> bool:
    s = line.strip()
    return "|" in s and s.count("|") >= 2


def _is_separator_row(line: str) -> bool:
    cells = _parse_row(line)
    return bool(cells) and all(_SEP_CELL.match(c.replace(" ", "")) for c in cells)


def _parse_row(line: str) -> List[str]:
    s = line.strip()
    if s.startswith("|"):
        s = s[1:]
    if s.endswith("|"):
        s = s[:-1]
    return [c.strip() for c in s.split("|")]


def _add_table(doc: Document, rows: List[str]) -> None:
    header = _parse_row(rows[0])
    body = [_parse_row(r) for r in rows[1:]]
    ncols = max([len(header)] + [len(r) for r in body]) if (header or body) else 0
    if ncols == 0:
        return
    table = doc.add_table(rows=1, cols=ncols)
    try:
        table.style = "Table Grid"
    except Exception:
        pass
    # header (cells start empty, so add_run yields runs[0])
    for c in range(ncols):
        run = table.rows[0].cells[c].paragraphs[0].add_run(header[c] if c < len(header) else "")
        run.bold = True
    # body
    for r in body:
        cells = table.add_row().cells
        for c in range(ncols):
            _add_runs(cells[c].paragraphs[0], r[c] if c < len(r) else "")


def _split_blocks(md: str) -> List[str]:
    """Split into logical blocks (headings/paragraphs/markers), preserving order."""
    blocks: List[str] = []
    for raw in md.split("\n"):
        line = raw.rstrip()
        if line.strip() == "":
            continue
        blocks.append(line)
    return blocks


def _render_line(doc: Document, line: str) -> None:
    stripped = line.strip()
    if stripped.startswith("#"):
        level = len(stripped) - len(stripped.lstrip("#"))
        text = stripped[level:].strip()
        doc.add_heading(text, level=min(max(level, 1), 4))
    elif re.match(r"^[-*]\s+", stripped):
        text = re.sub(r"^[-*]\s+", "", stripped)
        p = doc.add_paragraph(style="List Bullet")
        _add_runs(p, text)
    elif re.match(r"^\d+\.\s+", stripped):
        text = re.sub(r"^\d+\.\s+", "", stripped)
        p = doc.add_paragraph(style="List Number")
        _add_runs(p, text)
    elif stripped.startswith(">"):
        p = doc.add_paragraph(style="Intense Quote")
        _add_runs(p, stripped.lstrip("> ").strip())
    else:
        p = doc.add_paragraph()
        _add_runs(p, stripped)


def _add_runs(paragraph, text: str) -> None:
    """Add text to a paragraph, honoring **bold**, *italic*, and `code`."""
    for token in _INLINE.split(text):
        if not token:
            continue
        if token.startswith("**") and token.endswith("**"):
            paragraph.add_run(token[2:-2]).bold = True
        elif token.startswith("*") and token.endswith("*"):
            paragraph.add_run(token[1:-1]).italic = True
        elif token.startswith("`") and token.endswith("`"):
            run = paragraph.add_run(token[1:-1])
            run.font.name = "Consolas"
        else:
            paragraph.add_run(token)


def _insert_next_image(doc: Document, img_iter) -> None:
    im = next(img_iter, None)
    if im is not None:
        _add_picture(doc, im)


def _add_picture(doc: Document, im: dict) -> None:
    path = im.get("path")
    if not path or not Path(path).exists():
        return
    try:
        p = doc.add_paragraph()
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        p.add_run().add_picture(str(path), width=_MAX_IMG_WIDTH)
    except Exception:
        return
    caption = (im.get("caption") or "").strip()
    if caption:
        cap = doc.add_paragraph()
        cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
        run = cap.add_run(caption)
        run.italic = True
        run.font.size = Pt(9)
        run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)