Browse Source

feat(services): 添加图片提取和自定义样式保留功能

- 从 Word 文档中提取图片,包含位置跟踪和元数据
- 使用 HTML 注释在 Markdown 输出中保留自定义段落样式
- 添加标准样式检测,避免不必要的样式注释
- 实现带有 data URL 和样式的图片 Markdown 生成
- 增强表格单元格处理,捕获并保留自定义样式
- 从 JSON 注入编号格式,用于恢复标题编号
- 改进文档转换,在往返转换过程中保持格式保真度
chensiyu 2 months ago
parent
commit
f15dc6cbc1
6 changed files with 3146 additions and 2612 deletions
  1. 92 2
      app/services/document_service.py
  2. 287 11
      app/services/export_service.py
  3. 103 0
      app/services/image_service.py
  4. BIN
      tmp/default.docx
  5. 2628 2597
      tmp/default.json
  6. 36 2
      tmp/styles.py

+ 92 - 2
app/services/document_service.py

@@ -20,6 +20,27 @@ from app.schemas.document import CreateDocumentRequest, UpdateDocumentRequest
20
 # Word → Markdown 解析(完整实现,支持往返转换)
20
 # Word → Markdown 解析(完整实现,支持往返转换)
21
 # ------------------------------------------------------------------ #
21
 # ------------------------------------------------------------------ #
22
 
22
 
23
+# 标准样式列表(不需要添加样式注释)
24
+STANDARD_STYLES = {
25
+    "Normal",
26
+    "Heading 1", "Heading 2", "Heading 3", "Heading 4", "Heading 5", "Heading 6",
27
+    "List Bullet", "List Bullet 2", "List Bullet 3",
28
+    "List Number", "List Number 2", "List Number 3",
29
+    "Quote", "Quote Char",
30
+    "No Spacing",
31
+    "Table Grid",
32
+}
33
+
34
+
35
+def _is_standard_style(style_name: str) -> bool:
36
+    """判断是否为标准样式"""
37
+    if not style_name or style_name in STANDARD_STYLES:
38
+        return True
39
+    if style_name.startswith("Heading ") or "List Bullet" in style_name or "List Number" in style_name:
40
+        return True
41
+    return False
42
+
43
+
23
 def _render_run_with_format(run) -> str:
44
 def _render_run_with_format(run) -> str:
24
     """将单个 Run 转换为带格式的 Markdown 文本"""
45
     """将单个 Run 转换为带格式的 Markdown 文本"""
25
     text = run.text
46
     text = run.text
@@ -94,6 +115,7 @@ def _convert_table_to_markdown(table) -> str:
94
         # 对单元格内容也应用格式解析
115
         # 对单元格内容也应用格式解析
95
         cell_text = []
116
         cell_text = []
96
         for para in cell.paragraphs:
117
         for para in cell.paragraphs:
118
+            para_style = para.style.name if para.style else "Normal"
97
             para_text = _render_para_with_inline_format(para).strip()
119
             para_text = _render_para_with_inline_format(para).strip()
98
             # 移除表头自动添加的粗体格式
120
             # 移除表头自动添加的粗体格式
99
             # 因为导出时会自动给表头加粗,但原始Markdown可能没有
121
             # 因为导出时会自动给表头加粗,但原始Markdown可能没有
@@ -101,6 +123,9 @@ def _convert_table_to_markdown(table) -> str:
101
             if para_text.startswith("**") and para_text.endswith("**") and para_text.count("**") == 2:
123
             if para_text.startswith("**") and para_text.endswith("**") and para_text.count("**") == 2:
102
                 para_text = para_text[2:-2]
124
                 para_text = para_text[2:-2]
103
             if para_text:
125
             if para_text:
126
+                # 如果表头单元格使用自定义样式,添加注释
127
+                if not _is_standard_style(para_style) and para_style not in ("Normal", "Table Grid"):
128
+                    para_text = f"<!-- style: {para_style} -->{para_text}"
104
                 cell_text.append(para_text)
129
                 cell_text.append(para_text)
105
         header_cells.append(" ".join(cell_text))
130
         header_cells.append(" ".join(cell_text))
106
     
131
     
@@ -116,8 +141,13 @@ def _convert_table_to_markdown(table) -> str:
116
             # 对单元格内容也应用格式解析
141
             # 对单元格内容也应用格式解析
117
             cell_text = []
142
             cell_text = []
118
             for para in cell.paragraphs:
143
             for para in cell.paragraphs:
144
+                para_style = para.style.name if para.style else "Normal"
119
                 para_text = _render_para_with_inline_format(para).strip()
145
                 para_text = _render_para_with_inline_format(para).strip()
146
+                
120
                 if para_text:
147
                 if para_text:
148
+                    # 如果单元格段落使用自定义样式,添加注释
149
+                    if not _is_standard_style(para_style) and para_style not in ("Normal", "Table Grid"):
150
+                        para_text = f"<!-- style: {para_style} -->{para_text}"
121
                     cell_text.append(para_text)
151
                     cell_text.append(para_text)
122
             cells.append(" ".join(cell_text))
152
             cells.append(" ".join(cell_text))
123
         md_lines.append("| " + " | ".join(cells) + " |")
153
         md_lines.append("| " + " | ".join(cells) + " |")
@@ -176,6 +206,17 @@ def _docx_to_markdown(path: Path) -> str:
176
     """将 .docx 文件解析为完整 Markdown 文本(支持往返转换)"""
206
     """将 .docx 文件解析为完整 Markdown 文本(支持往返转换)"""
177
     doc = DocxDocument(str(path))
207
     doc = DocxDocument(str(path))
178
     
208
     
209
+    # 提取所有图片及其位置信息
210
+    from app.services.image_service import extract_images_from_word
211
+    images = extract_images_from_word(doc)
212
+    # 按段落索引建立映射,方便查找
213
+    image_map = {}
214
+    for img in images:
215
+        para_idx = img['paragraph_index']
216
+        if para_idx not in image_map:
217
+            image_map[para_idx] = []
218
+        image_map[para_idx].append(img)
219
+    
179
     # 收集所有元素(段落和表格)并按文档顺序排列
220
     # 收集所有元素(段落和表格)并按文档顺序排列
180
     elements = []
221
     elements = []
181
     
222
     
@@ -200,7 +241,15 @@ def _docx_to_markdown(path: Path) -> str:
200
     in_code_block = False
241
     in_code_block = False
201
     code_block_lines = []
242
     code_block_lines = []
202
     
243
     
203
-    for elem_type, elem in elements:
244
+    # 记录实际的段落索引到元素索引的映射
245
+    para_idx_in_elements = {}
246
+    actual_para_idx = 0
247
+    for elem_idx, (elem_type, elem) in enumerate(elements):
248
+        if elem_type == "para":
249
+            para_idx_in_elements[actual_para_idx] = elem_idx
250
+            actual_para_idx += 1
251
+    
252
+    for elem_idx, (elem_type, elem) in enumerate(elements):
204
         if elem_type == "para":
253
         if elem_type == "para":
205
             para = elem
254
             para = elem
206
             style_name = para.style.name if para.style else ""
255
             style_name = para.style.name if para.style else ""
@@ -234,6 +283,24 @@ def _docx_to_markdown(path: Path) -> str:
234
                 # 检测是否是分隔线(只有底部边框的空段落)
283
                 # 检测是否是分隔线(只有底部边框的空段落)
235
                 if _has_bottom_border(para):
284
                 if _has_bottom_border(para):
236
                     lines.append("---")
285
                     lines.append("---")
286
+                
287
+                # 空段落也要检查是否有图片
288
+                current_para_idx = None
289
+                for para_idx, e_idx in para_idx_in_elements.items():
290
+                    if e_idx == elem_idx:
291
+                        current_para_idx = para_idx
292
+                        break
293
+                
294
+                if current_para_idx is not None and current_para_idx in image_map:
295
+                    from app.services.image_service import create_image_markdown
296
+                    for img in image_map[current_para_idx]:
297
+                        img_md = create_image_markdown(
298
+                            img['data_url'],
299
+                            img['style'],
300
+                            alt=f"图片"
301
+                        )
302
+                        lines.append(img_md.strip())
303
+                
237
                 continue
304
                 continue
238
             
305
             
239
             # 标题
306
             # 标题
@@ -260,7 +327,30 @@ def _docx_to_markdown(path: Path) -> str:
260
             
327
             
261
             # 普通段落
328
             # 普通段落
262
             else:
329
             else:
263
-                lines.append(text)
330
+                # 如果是自定义样式,添加样式注释(和文本在同一行)
331
+                if not _is_standard_style(style_name) and style_name != "Normal":
332
+                    lines.append(f"<!-- style: {style_name} -->{text}")
333
+                else:
334
+                    lines.append(text)
335
+            
336
+            # 段落处理完成后,检查是否有图片
337
+            # 找到当前 elem_idx 对应的实际段落索引
338
+            current_para_idx = None
339
+            for para_idx, e_idx in para_idx_in_elements.items():
340
+                if e_idx == elem_idx:
341
+                    current_para_idx = para_idx
342
+                    break
343
+            
344
+            if current_para_idx is not None and current_para_idx in image_map:
345
+                # 在段落后输出图片
346
+                from app.services.image_service import create_image_markdown
347
+                for img in image_map[current_para_idx]:
348
+                    img_md = create_image_markdown(
349
+                        img['data_url'],
350
+                        img['style'],
351
+                        alt=f"图片"
352
+                    )
353
+                    lines.append(img_md.strip())
264
         
354
         
265
         elif elem_type == "table":
355
         elif elem_type == "table":
266
             # 如果之前在代码块中,先结束
356
             # 如果之前在代码块中,先结束

+ 287 - 11
app/services/export_service.py

@@ -1,5 +1,6 @@
1
 """export_service.py — 将文档 Markdown 内容转换为 .doc 文件并返回永久下载链接。"""
1
 """export_service.py — 将文档 Markdown 内容转换为 .doc 文件并返回永久下载链接。"""
2
 
2
 
3
+import base64
3
 import io
4
 import io
4
 import json
5
 import json
5
 import time
6
 import time
@@ -10,6 +11,7 @@ from typing import Optional
10
 
11
 
11
 import mistune
12
 import mistune
12
 from docx import Document
13
 from docx import Document
14
+from docx.enum.text import WD_ALIGN_PARAGRAPH
13
 from docx.oxml import OxmlElement
15
 from docx.oxml import OxmlElement
14
 from docx.oxml.ns import qn
16
 from docx.oxml.ns import qn
15
 from docx.shared import Pt, RGBColor
17
 from docx.shared import Pt, RGBColor
@@ -91,6 +93,42 @@ def inject_styles_from_json(doc: Document, style_data: dict) -> None:
91
         styles_element.append(new_elem)
93
         styles_element.append(new_elem)
92
 
94
 
93
 
95
 
96
+def inject_numbering_from_json(doc: Document, style_data: dict) -> None:
97
+    """
98
+    将 JSON 中的 numbering 定义注入到文档中。
99
+    这样可以恢复标题的编号格式。
100
+    """
101
+    numbering_def = style_data.get("numbering")
102
+    if not numbering_def:
103
+        return  # 没有编号定义,跳过
104
+    
105
+    try:
106
+        # 将字典转换为 lxml Element
107
+        numbering_elem = dict_to_element(numbering_def)
108
+        
109
+        # 获取文档的 numbering part
110
+        # python-docx 可能没有 numbering part,需要创建
111
+        if doc.part.numbering_part is None:
112
+            # 创建 numbering part
113
+            from docx.opc.constants import CONTENT_TYPE as CT
114
+            from docx.opc.part import XmlPart
115
+            from docx.opc.packuri import PackURI
116
+            
117
+            numbering_part = XmlPart(
118
+                PackURI('/word/numbering.xml'),
119
+                CT.WML_NUMBERING,
120
+                numbering_elem,
121
+                doc.part.package
122
+            )
123
+            doc.part.relate_to(numbering_part, 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering')
124
+        else:
125
+            # 替换现有的 numbering part 内容
126
+            doc.part.numbering_part._element = numbering_elem
127
+    except Exception as e:
128
+        # 编号注入失败,不影响其他功能
129
+        print(f"警告: 编号格式注入失败: {e}")
130
+
131
+
94
 def _resolve_style_id(style_map: dict, *keys: str) -> Optional[str]:
132
 def _resolve_style_id(style_map: dict, *keys: str) -> Optional[str]:
95
     for key in keys:
133
     for key in keys:
96
         entry = style_map.get(key)
134
         entry = style_map.get(key)
@@ -111,13 +149,53 @@ class DocxRenderer(mistune.BaseRenderer):
111
         self.style_map = style_map
149
         self.style_map = style_map
112
         self.doc = Document()
150
         self.doc = Document()
113
         inject_styles_from_json(self.doc, style_data)
151
         inject_styles_from_json(self.doc, style_data)
152
+        inject_numbering_from_json(self.doc, style_data)  # 新增:注入编号格式
114
         self._normal_id: Optional[str] = _resolve_style_id(style_map, "Normal", "1")
153
         self._normal_id: Optional[str] = _resolve_style_id(style_map, "Normal", "1")
154
+        self.pending_style: Optional[str] = None  # 待应用的样式名
155
+        self.pending_image_style: Optional[dict] = None  # 待应用的图片样式
115
 
156
 
116
     def _get_style_by_id(self, style_id: str):
157
     def _get_style_by_id(self, style_id: str):
117
         for style in self.doc.styles:
158
         for style in self.doc.styles:
118
             if style.style_id == style_id:
159
             if style.style_id == style_id:
119
                 return style
160
                 return style
120
         raise KeyError(style_id)
161
         raise KeyError(style_id)
162
+    
163
+    def _apply_numbering_from_style(self, paragraph):
164
+        """
165
+        从段落的样式中提取编号属性并应用到段落。
166
+        这是必需的,因为 python-docx 不会自动继承样式的编号格式。
167
+        """
168
+        if not paragraph.style:
169
+            return
170
+        
171
+        try:
172
+            # 获取样式的 XML 元素
173
+            style_elem = paragraph.style.element
174
+            
175
+            # 查找样式中的编号定义
176
+            pPr = style_elem.find(qn('w:pPr'))
177
+            if pPr is None:
178
+                return
179
+            
180
+            numPr = pPr.find(qn('w:numPr'))
181
+            if numPr is None:
182
+                return
183
+            
184
+            # 复制编号属性到段落
185
+            para_pPr = paragraph._p.get_or_add_pPr()
186
+            
187
+            # 移除现有的 numPr(如果有)
188
+            existing_numPr = para_pPr.find(qn('w:numPr'))
189
+            if existing_numPr is not None:
190
+                para_pPr.remove(existing_numPr)
191
+            
192
+            # 深度复制样式的 numPr 到段落
193
+            from copy import deepcopy
194
+            new_numPr = deepcopy(numPr)
195
+            para_pPr.append(new_numPr)
196
+        except Exception:
197
+            # 编号应用失败,不影响其他功能
198
+            pass
121
 
199
 
122
     @staticmethod
200
     @staticmethod
123
     def _extract_text(children: list) -> str:
201
     def _extract_text(children: list) -> str:
@@ -139,6 +217,8 @@ class DocxRenderer(mistune.BaseRenderer):
139
             para = self.doc.add_paragraph(text)
217
             para = self.doc.add_paragraph(text)
140
             try:
218
             try:
141
                 para.style = self._get_style_by_id(style_id)
219
                 para.style = self._get_style_by_id(style_id)
220
+                # 应用样式后,复制编号属性到段落
221
+                self._apply_numbering_from_style(para)
142
             except KeyError:
222
             except KeyError:
143
                 pass
223
                 pass
144
         else:
224
         else:
@@ -146,18 +226,180 @@ class DocxRenderer(mistune.BaseRenderer):
146
         return ""
226
         return ""
147
 
227
 
148
     def paragraph(self, token: dict, state) -> str:
228
     def paragraph(self, token: dict, state) -> str:
229
+        # 检查是否包含图片
230
+        children = token.get("children", [])
231
+        has_image = any(child.get("type") == "image" for child in children)
232
+        
233
+        if has_image:
234
+            # 如果包含图片,直接调用 image 处理
235
+            for child in children:
236
+                if child.get("type") == "image":
237
+                    self.image(child, state)
238
+            return ""
239
+        
149
         p = self.doc.add_paragraph()
240
         p = self.doc.add_paragraph()
150
-        if self._normal_id:
241
+        
242
+        # 尝试应用待定样式
243
+        style_applied = False
244
+        if self.pending_style:
245
+            style_id = _resolve_style_id(self.style_map, self.pending_style)
246
+            if style_id:
247
+                try:
248
+                    p.style = self._get_style_by_id(style_id)
249
+                    style_applied = True
250
+                except KeyError:
251
+                    pass  # 样式不存在,静默忽略
252
+            self.pending_style = None
253
+        
254
+        # 如果没有应用样式,使用 Normal
255
+        if not style_applied and self._normal_id:
151
             try:
256
             try:
152
                 p.style = self._get_style_by_id(self._normal_id)
257
                 p.style = self._get_style_by_id(self._normal_id)
153
             except Exception:
258
             except Exception:
154
                 pass
259
                 pass
260
+        
155
         self._render_inline_children(p, token.get("children", []))
261
         self._render_inline_children(p, token.get("children", []))
156
         return ""
262
         return ""
263
+    
264
+    def html(self, token: dict, state) -> str:
265
+        """处理内联 HTML 注释(表格单元格中的样式标记)"""
266
+        raw = token.get("raw", "")
267
+        if "<!-- style:" in raw and "-->" in raw:
268
+            try:
269
+                start = raw.index("<!-- style:") + 11
270
+                end = raw.index("-->", start)
271
+                self.pending_style = raw[start:end].strip()
272
+            except (ValueError, IndexError):
273
+                pass
274
+        return ""
275
+    
276
+    def block_html(self, token: dict, state) -> str:
277
+        """处理块级 HTML(样式注释+文本在同一行)"""
278
+        raw = token.get("raw", "")
279
+        
280
+        # 处理图片样式注释
281
+        if "<!-- img-style:" in raw and "-->" in raw:
282
+            try:
283
+                start = raw.index("{")
284
+                end = raw.rindex("}") + 1
285
+                self.pending_image_style = json.loads(raw[start:end])
286
+            except (ValueError, json.JSONDecodeError):
287
+                pass
288
+            return ""
289
+        
290
+        # 处理文本样式注释
291
+        if "<!-- style:" in raw and "-->" in raw:
292
+            try:
293
+                # 提取样式名
294
+                style_start = raw.index("<!-- style:") + 11
295
+                style_end = raw.index("-->", style_start)
296
+                style_name = raw[style_start:style_end].strip()
297
+                
298
+                # 提取文本(注释后面的内容)
299
+                text_start = style_end + 3  # "-->".length = 3
300
+                text = raw[text_start:].strip()
301
+                
302
+                # 创建段落并应用样式
303
+                p = self.doc.add_paragraph(text)
304
+                style_id = _resolve_style_id(self.style_map, style_name)
305
+                if style_id:
306
+                    try:
307
+                        p.style = self._get_style_by_id(style_id)
308
+                        # 应用样式后,复制编号属性到段落
309
+                        self._apply_numbering_from_style(p)
310
+                    except KeyError:
311
+                        pass  # 样式不存在,使用默认
312
+            except (ValueError, IndexError):
313
+                # 解析失败,当作普通 HTML 处理(忽略)
314
+                pass
315
+        return ""
157
 
316
 
158
     def blank_line(self, token: dict, state) -> str:
317
     def blank_line(self, token: dict, state) -> str:
159
         return ""
318
         return ""
160
 
319
 
320
+    def image(self, token: dict, state) -> str:
321
+        """处理图片 token(支持 Base64 Data URL)"""
322
+        url = token['attrs']['url']
323
+        alt = token['attrs'].get('alt', '图片')
324
+        
325
+        # 只处理 Data URL
326
+        if not url.startswith('data:'):
327
+            return ""
328
+        
329
+        try:
330
+            # 解析 data:image/png;base64,xxxxx
331
+            if ',' not in url:
332
+                return ""
333
+            
334
+            header, b64_data = url.split(',', 1)
335
+            image_bytes = base64.b64decode(b64_data)
336
+            
337
+            # 获取样式(来自前面的 HTML 注释)
338
+            style = self.pending_image_style or {}
339
+            self.pending_image_style = None
340
+            
341
+            # 创建段落并设置对齐
342
+            paragraph = self.doc.add_paragraph()
343
+            
344
+            # 应用段落样式:优先使用保存的样式,否则使用 Normal
345
+            para_style = style.get('para_style', 'Normal')
346
+            style_id = _resolve_style_id(self.style_map, para_style)
347
+            
348
+            if style_id:
349
+                try:
350
+                    paragraph.style = self._get_style_by_id(style_id)
351
+                except KeyError:
352
+                    # 如果样式不存在,回退到 Normal
353
+                    if self._normal_id:
354
+                        try:
355
+                            paragraph.style = self._get_style_by_id(self._normal_id)
356
+                        except Exception:
357
+                            pass
358
+            elif self._normal_id:
359
+                # 如果没有找到样式 ID,使用 Normal
360
+                try:
361
+                    paragraph.style = self._get_style_by_id(self._normal_id)
362
+                except Exception:
363
+                    pass
364
+            
365
+            align = style.get('align', 'left')
366
+            if align == 'center':
367
+                paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
368
+            elif align == 'right':
369
+                paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
370
+            
371
+            # 插入图片
372
+            run = paragraph.add_run()
373
+            width = style.get('width', 10.0)
374
+            height = style.get('height', 7.0)
375
+            unit = style.get('unit', 'cm')
376
+            
377
+            # 转换为磅(Word内部单位:1厘米 = 28.35磅,1英寸 = 72磅)
378
+            if unit == 'cm':
379
+                width_pt = width * 28.35
380
+                height_pt = height * 28.35
381
+            else:  # inches
382
+                width_pt = width * 72
383
+                height_pt = height * 72
384
+            
385
+            run.add_picture(
386
+                io.BytesIO(image_bytes),
387
+                width=Pt(width_pt),
388
+                height=Pt(height_pt)
389
+            )
390
+            
391
+        except Exception as e:
392
+            # 失败时添加占位文本
393
+            p = self.doc.add_paragraph(f"[图片加载失败: {alt}]")
394
+            if self._normal_id:
395
+                try:
396
+                    p.style = self._get_style_by_id(self._normal_id)
397
+                except Exception:
398
+                    pass
399
+            p.runs[0].font.color.rgb = RGBColor(255, 0, 0)
400
+        
401
+        return ""
402
+
161
     def thematic_break(self, token: dict, state) -> str:
403
     def thematic_break(self, token: dict, state) -> str:
162
         p = self.doc.add_paragraph()
404
         p = self.doc.add_paragraph()
163
         pPr = p._p.get_or_add_pPr()
405
         pPr = p._p.get_or_add_pPr()
@@ -248,17 +490,14 @@ class DocxRenderer(mistune.BaseRenderer):
248
         tbl = self.doc.add_table(rows=1 + len(body_rows), cols=cols)
490
         tbl = self.doc.add_table(rows=1 + len(body_rows), cols=cols)
249
         tbl.style = "Table Grid"
491
         tbl.style = "Table Grid"
250
 
492
 
251
-        # 表头行(保留内联格式)
493
+        # 表头行(保留内联格式,不强制加粗
252
         for c, cell_token in enumerate(head_cells):
494
         for c, cell_token in enumerate(head_cells):
253
             cell = tbl.rows[0].cells[c]
495
             cell = tbl.rows[0].cells[c]
254
             # 清空默认段落
496
             # 清空默认段落
255
             cell.text = ""
497
             cell.text = ""
256
             para = cell.paragraphs[0]
498
             para = cell.paragraphs[0]
257
-            # 渲染内联内容
499
+            # 渲染内联内容(样式由 _render_inline_children 处理)
258
             self._render_inline_children(para, cell_token.get("children", []))
500
             self._render_inline_children(para, cell_token.get("children", []))
259
-            # 设置粗体
260
-            for run in para.runs:
261
-                run.bold = True
262
 
501
 
263
         # 数据行(保留内联格式)
502
         # 数据行(保留内联格式)
264
         for r, row_cells in enumerate(body_rows):
503
         for r, row_cells in enumerate(body_rows):
@@ -266,22 +505,54 @@ class DocxRenderer(mistune.BaseRenderer):
266
                 if c >= cols:
505
                 if c >= cols:
267
                     break
506
                     break
268
                 cell = tbl.rows[r + 1].cells[c]
507
                 cell = tbl.rows[r + 1].cells[c]
269
-                # 清空默认段落
270
                 cell.text = ""
508
                 cell.text = ""
271
                 para = cell.paragraphs[0]
509
                 para = cell.paragraphs[0]
272
-                # 渲染内联内容
510
+                # 渲染内联内容(样式注释会在 _render_inline_children 中处理)
273
                 self._render_inline_children(para, cell_token.get("children", []))
511
                 self._render_inline_children(para, cell_token.get("children", []))
274
 
512
 
275
         return ""
513
         return ""
276
 
514
 
277
-        return ""
278
-
279
     def _render_inline_children(self, paragraph, children: list) -> None:
515
     def _render_inline_children(self, paragraph, children: list) -> None:
516
+        """渲染内联子元素,处理粗体、斜体等格式"""
280
         for child in children:
517
         for child in children:
281
             ctype = child.get("type", "")
518
             ctype = child.get("type", "")
282
             raw = child.get("raw", "")
519
             raw = child.get("raw", "")
283
-            if ctype == "text":
520
+            
521
+            if ctype == "inline_html":
522
+                # 处理图片样式注释
523
+                if "<!-- img-style:" in raw and "-->" in raw:
524
+                    try:
525
+                        start = raw.index("{")
526
+                        end = raw.rindex("}") + 1
527
+                        self.pending_image_style = json.loads(raw[start:end])
528
+                    except (ValueError, json.JSONDecodeError):
529
+                        pass
530
+                    # 注释不输出
531
+                    continue
532
+                
533
+                # 处理文本样式注释
534
+                if "<!-- style:" in raw and "-->" in raw:
535
+                    try:
536
+                        start = raw.index("<!-- style:") + 11
537
+                        end = raw.index("-->", start)
538
+                        self.pending_style = raw[start:end].strip()
539
+                    except (ValueError, IndexError):
540
+                        pass
541
+                # 注释不输出
542
+                continue
543
+            
544
+            elif ctype == "text":
545
+                # 应用待定样式(来自前一个 inline_html)
546
+                if self.pending_style:
547
+                    style_id = _resolve_style_id(self.style_map, self.pending_style)
548
+                    if style_id:
549
+                        try:
550
+                            paragraph.style = self._get_style_by_id(style_id)
551
+                        except KeyError:
552
+                            pass
553
+                    self.pending_style = None
284
                 paragraph.add_run(raw)
554
                 paragraph.add_run(raw)
555
+            
285
             elif ctype == "strong":
556
             elif ctype == "strong":
286
                 paragraph.add_run(self._extract_text(child.get("children", []))).bold = True
557
                 paragraph.add_run(self._extract_text(child.get("children", []))).bold = True
287
             elif ctype == "emphasis":
558
             elif ctype == "emphasis":
@@ -296,6 +567,11 @@ class DocxRenderer(mistune.BaseRenderer):
296
                 paragraph.add_run().add_break()
567
                 paragraph.add_run().add_break()
297
             elif ctype == "softlinebreak":
568
             elif ctype == "softlinebreak":
298
                 paragraph.add_run(" ")
569
                 paragraph.add_run(" ")
570
+            elif ctype == "image":
571
+                # 处理内联图片
572
+                # 注意:这里的图片是在段落中的,需要特殊处理
573
+                # 我们需要跳过这个段落,让 image() 方法来处理
574
+                pass
299
             else:
575
             else:
300
                 sub = child.get("children")
576
                 sub = child.get("children")
301
                 if sub:
577
                 if sub:

+ 103 - 0
app/services/image_service.py

@@ -0,0 +1,103 @@
1
+"""image_service.py — 图片提取和处理服务"""
2
+
3
+import base64
4
+import json
5
+from typing import Dict, List
6
+
7
+from docx import Document
8
+from docx.enum.text import WD_ALIGN_PARAGRAPH
9
+
10
+
11
+def extract_images_from_word(doc: Document) -> List[Dict]:
12
+    """从 Word 文档提取图片及基本样式信息
13
+    
14
+    Args:
15
+        doc: python-docx Document 对象
16
+        
17
+    Returns:
18
+        图片列表,每个元素包含:
19
+        - paragraph_index: 图片所在段落索引
20
+        - data_url: Base64 编码的 Data URL
21
+        - style: 样式信息(宽度、高度、对齐方式、段落样式)
22
+    """
23
+    images = []
24
+    
25
+    # 建立 rel_id -> 图片数据映射
26
+    image_parts = {}
27
+    for rel in doc.part.rels.values():
28
+        if "image" in rel.target_ref:
29
+            image_parts[rel.rId] = {
30
+                "blob": rel.target_part.blob,
31
+                "content_type": rel.target_part.content_type
32
+            }
33
+    
34
+    # 遍历段落查找图片
35
+    for para_idx, paragraph in enumerate(doc.paragraphs):
36
+        for run in paragraph.runs:
37
+            # 查找 inline 图片(w:drawing 元素)
38
+            for drawing in run._element.findall('.//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}drawing'):
39
+                # 提取图片引用
40
+                blip = drawing.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}blip')
41
+                if blip is None:
42
+                    continue
43
+                    
44
+                rel_id = blip.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed')
45
+                if not rel_id or rel_id not in image_parts:
46
+                    continue
47
+                
48
+                # 提取尺寸(EMU 转厘米,1厘米 = 360000 EMU)
49
+                extent = drawing.find('.//{http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing}extent')
50
+                width_emu = int(extent.get('cx')) if extent is not None else 360000 * 10  # 默认 10cm
51
+                height_emu = int(extent.get('cy')) if extent is not None else 360000 * 7   # 默认 7cm
52
+                
53
+                width_cm = round(width_emu / 360000, 2)
54
+                height_cm = round(height_emu / 360000, 2)
55
+                
56
+                # 获取对齐方式
57
+                align = 'left'
58
+                if paragraph.alignment == WD_ALIGN_PARAGRAPH.CENTER:
59
+                    align = 'center'
60
+                elif paragraph.alignment == WD_ALIGN_PARAGRAPH.RIGHT:
61
+                    align = 'right'
62
+                
63
+                # 获取段落样式名称
64
+                para_style = paragraph.style.name if paragraph.style else "Normal"
65
+                
66
+                # Base64 编码
67
+                image_blob = image_parts[rel_id]['blob']
68
+                content_type = image_parts[rel_id]['content_type']
69
+                b64_data = base64.b64encode(image_blob).decode('utf-8')
70
+                data_url = f"data:{content_type};base64,{b64_data}"
71
+                
72
+                images.append({
73
+                    "paragraph_index": para_idx,
74
+                    "data_url": data_url,
75
+                    "style": {
76
+                        "width": width_cm,
77
+                        "height": height_cm,
78
+                        "align": align,
79
+                        "unit": "cm",
80
+                        "para_style": para_style  # 新增:段落样式
81
+                    }
82
+                })
83
+    
84
+    return images
85
+
86
+
87
+def create_image_markdown(data_url: str, style: Dict, alt: str = "图片") -> str:
88
+    """生成带样式注释的图片 Markdown
89
+    
90
+    Args:
91
+        data_url: Base64 编码的 Data URL
92
+        style: 样式字典(width, height, align)
93
+        alt: 图片替代文本
94
+        
95
+    Returns:
96
+        格式化的 Markdown 字符串
97
+        
98
+    Example:
99
+        <!-- img-style: {"width": 4.0, "height": 3.0, "align": "center"} -->
100
+        ![图片](data:image/png;base64,...)
101
+    """
102
+    style_json = json.dumps(style, ensure_ascii=False)
103
+    return f'<!-- img-style: {style_json} -->\n![{alt}]({data_url})\n'

BIN
tmp/default.docx


File diff suppressed because it is too large
+ 2628 - 2597
tmp/default.json


+ 36 - 2
tmp/styles.py

@@ -1,6 +1,7 @@
1
 """
1
 """
2
 提取 Word 文档中所有样式的完整 XML 定义(不遗漏任何属性)。
2
 提取 Word 文档中所有样式的完整 XML 定义(不遗漏任何属性)。
3
-目标文件: 2026年Q2季度报告_E-EdHk4r1Lg.doc
3
+同时提取编号格式定义(numbering.xml)以支持标题编号。
4
+目标文件: default.docx
4
 """
5
 """
5
 
6
 
6
 import json
7
 import json
@@ -8,6 +9,7 @@ from pathlib import Path
8
 from docx import Document
9
 from docx import Document
9
 from docx.oxml.ns import qn
10
 from docx.oxml.ns import qn
10
 from lxml import etree
11
 from lxml import etree
12
+from zipfile import ZipFile
11
 
13
 
12
 DOC_PATH = Path(__file__).parent / "default.docx"
14
 DOC_PATH = Path(__file__).parent / "default.docx"
13
 OUTPUT_PATH = Path(__file__).parent / "default.json"
15
 OUTPUT_PATH = Path(__file__).parent / "default.json"
@@ -159,17 +161,45 @@ def extract_styles(doc_path: Path) -> list[dict]:
159
     return styles_data
161
     return styles_data
160
 
162
 
161
 
163
 
164
+def extract_numbering(doc_path: Path) -> dict | None:
165
+    """
166
+    提取 numbering.xml 的完整内容(编号格式定义)
167
+    返回字典格式,如果文档中没有编号则返回 None
168
+    """
169
+    try:
170
+        with ZipFile(str(doc_path), 'r') as docx_zip:
171
+            # 尝试读取 numbering.xml
172
+            try:
173
+                numbering_xml = docx_zip.read('word/numbering.xml')
174
+            except KeyError:
175
+                # 文档中没有编号定义
176
+                return None
177
+            
178
+            # 解析 XML 并转换为字典
179
+            root = etree.fromstring(numbering_xml)
180
+            numbering_dict = element_to_dict(root)
181
+            return numbering_dict
182
+    except Exception as e:
183
+        print(f"警告: 提取编号格式失败: {e}")
184
+        return None
185
+
186
+
162
 def main():
187
 def main():
163
     print(f"读取文件: {DOC_PATH}")
188
     print(f"读取文件: {DOC_PATH}")
164
     if not DOC_PATH.exists():
189
     if not DOC_PATH.exists():
165
         raise FileNotFoundError(f"文件不存在: {DOC_PATH}")
190
         raise FileNotFoundError(f"文件不存在: {DOC_PATH}")
166
 
191
 
192
+    # 提取样式
167
     styles_data = extract_styles(DOC_PATH)
193
     styles_data = extract_styles(DOC_PATH)
194
+    
195
+    # 提取编号格式
196
+    numbering_data = extract_numbering(DOC_PATH)
168
 
197
 
169
     result = {
198
     result = {
170
         "source_file": DOC_PATH.name,
199
         "source_file": DOC_PATH.name,
171
         "total_styles": len(styles_data),
200
         "total_styles": len(styles_data),
172
         "styles": styles_data,
201
         "styles": styles_data,
202
+        "numbering": numbering_data,  # 新增:编号格式定义
173
     }
203
     }
174
 
204
 
175
     OUTPUT_PATH.write_text(
205
     OUTPUT_PATH.write_text(
@@ -178,7 +208,11 @@ def main():
178
     )
208
     )
179
 
209
 
180
     print(f"共提取 {len(styles_data)} 个样式")
210
     print(f"共提取 {len(styles_data)} 个样式")
181
-    print(f"完整 XML 定义已保存至: {OUTPUT_PATH}")
211
+    if numbering_data:
212
+        print(f"✅ 已提取编号格式定义")
213
+    else:
214
+        print(f"ℹ️  文档中没有编号格式")
215
+    print(f"完整定义已保存至: {OUTPUT_PATH}")
182
 
216
 
183
     # 打印摘要
217
     # 打印摘要
184
     by_type: dict[str, list[str]] = {}
218
     by_type: dict[str, list[str]] = {}