File size: 5,134 Bytes
3913f6f
 
 
 
 
6ce52dc
939527c
3913f6f
6ce52dc
 
 
 
 
3913f6f
 
 
 
 
67cd891
3913f6f
 
939527c
 
e0a8d80
 
3913f6f
 
e0a8d80
 
 
3913f6f
 
e0a8d80
 
 
 
 
 
3913f6f
 
939527c
 
e0a8d80
 
3913f6f
 
 
 
939527c
3913f6f
2e3717c
e0a8d80
 
3913f6f
 
 
e0a8d80
 
 
2e3717c
 
69fee5e
e0a8d80
 
 
3913f6f
e0a8d80
 
 
67cd891
3913f6f
e0a8d80
3913f6f
 
 
 
e0a8d80
 
3913f6f
 
 
67cd891
939527c
 
3913f6f
 
 
 
 
e0a8d80
 
 
 
3913f6f
 
e0a8d80
 
3913f6f
 
 
e0a8d80
 
3913f6f
 
 
 
 
 
e0a8d80
 
 
3913f6f
e0a8d80
 
 
 
 
 
 
 
 
3913f6f
67cd891
e0a8d80
 
3913f6f
 
 
 
e0a8d80
 
3913f6f
 
e0a8d80
 
3913f6f
 
6ce52dc
3913f6f
 
6ce52dc
3913f6f
6ce52dc
 
 
 
 
 
 
 
 
 
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
# -*- coding: utf-8 -*-
import io
import mistune
from bs4 import BeautifulSoup
from docx import Document
from docx.shared import Pt
from config import APP_CONFIG


class DocxBuildError(RuntimeError):
    """Raised when markdown cannot be rendered into a valid DOCX document."""


class DocxRenderer(mistune.HTMLRenderer):
    def __init__(self):
        super().__init__()
        self.doc = Document()
        self.h_counters = [0] * 6
        self.is_first_h1 = True
        try:
            style = self.doc.styles['Normal']
            style.font.name = APP_CONFIG["docx_styles"]["font_name"]
            style.font.size = Pt(APP_CONFIG["docx_styles"]["font_size_pt"])
        except: 
            pass

    def _render_rich_text(self, p, html_text):
        if not html_text.strip(): 
            return
            
        soup = BeautifulSoup(html_text, 'html.parser')
        for element in soup.children:
            if isinstance(element, str): 
                p.add_run(element)
            elif element.name in ['strong', 'b']: 
                p.add_run(element.get_text()).bold = True
            elif element.name in ['em', 'i']: 
                p.add_run(element.get_text()).italic = True
            elif element.name == 'code':
                run = p.add_run(element.get_text())
                run.font.name = APP_CONFIG["docx_styles"]["code_font"]
                run.font.size = Pt(APP_CONFIG["docx_styles"]["code_size_pt"])
            else: 
                p.add_run(element.get_text())

    def heading(self, text, level):
        clean_text = BeautifulSoup(text, 'html.parser').get_text().strip()
        lower_text = clean_text.lower()
        skip_keywords = APP_CONFIG["blacklists"]["meta_sections"]
        
        if any(kw in lower_text for kw in skip_keywords) or (level == 1 and self.is_first_h1):
            if level == 1: 
                self.is_first_h1 = False
            self.doc.add_heading(clean_text, level=1)
            return ''

        if level == 1: 
            self.is_first_h1 = False
            
        idx = level - 1
        if 0 <= idx < 6:
            for j in range(idx):
                if self.h_counters[j] == 0: 
                    self.h_counters[j] = 1
                    
            self.h_counters[idx] += 1
            for i in range(idx + 1, 6): 
                self.h_counters[i] = 0
                
            num_str = ".".join(map(str, self.h_counters[0:idx+1]))
            self.doc.add_heading(f"{num_str} {clean_text}", level=level)
        else: 
            self.doc.add_heading(clean_text, level=level)
        return ''

    def paragraph(self, text):
        if text.strip(): 
            self._render_rich_text(self.doc.add_paragraph(), text)
        return ''

    def block_code(self, code, info=None):
        run = self.doc.add_paragraph().add_run(code)
        run.font.name = APP_CONFIG["docx_styles"]["code_font"]
        run.font.size = Pt(APP_CONFIG["docx_styles"]["code_size_pt"])
        return ''

    def block_quote(self, text):
        p = self.doc.add_paragraph()
        self._render_rich_text(p, text)
        try: 
            p.style = 'Intense Quote' if 'Intense Quote' in self.doc.styles else 'Normal'
        except: 
            pass
        return ''

    def list(self, text, ordered, **attrs): 
        return text

    def list_item(self, text):
        if text.strip():
            p = self.doc.add_paragraph()
            p.style = 'List Bullet'
            self._render_rich_text(p, text)
        return ''

    def table(self, text):
        soup = BeautifulSoup(text, 'html.parser')
        rows = soup.find_all('tr')
        if not rows: 
            return ''
            
        max_cols = max(len(row.find_all(['td', 'th'])) for row in rows)
        if max_cols == 0: 
            return ''
            
        table = self.doc.add_table(rows=len(rows), cols=max_cols)
        try: 
            table.style = 'Table Grid'
        except: 
            pass
            
        for i, row in enumerate(rows):
            for j, cell in enumerate(row.find_all(['td', 'th'])):
                if j < max_cols: 
                    table.cell(i, j).text = cell.get_text(strip=True)
        self.doc.add_paragraph()
        return ''

    def thematic_break(self):
        self.doc.add_paragraph('_' * 50)
        return ''

    def image(self, src, alt="", title=None):
        self.doc.add_paragraph(f"[Image: {alt or src}]")
        return ''

def text_to_docx(markdown_content):
    """Render markdown strictly; never disguise renderer failures as success."""
    try:
        renderer = DocxRenderer()
        mistune.create_markdown(renderer=renderer, plugins=['table'])(str(markdown_content or ''))
        doc = renderer.doc
        if not doc.paragraphs and not doc.tables:
            raise DocxBuildError("DOCX renderer produced no document content.")
        stream = io.BytesIO()
        doc.save(stream)
        stream.seek(0)
        return stream
    except DocxBuildError:
        raise
    except Exception as exc:
        raise DocxBuildError(f"DOCX rendering failed: {exc}") from exc