Kaynağa Gözat

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

- 从 Word 文档中提取图片,包含位置跟踪和元数据
- 使用 HTML 注释在 Markdown 输出中保留自定义段落样式
- 添加标准样式检测,避免不必要的样式注释
- 实现带有 data URL 和样式的图片 Markdown 生成
- 增强表格单元格处理,捕获并保留自定义样式
- 从 JSON 注入编号格式,用于恢复标题编号
- 改进文档转换,在往返转换过程中保持格式保真度
chensiyu 2 ay önce
ebeveyn
işleme
f15dc6cbc1

+ 92 - 2
app/services/document_service.py

@@ -20,6 +20,27 @@ from app.schemas.document import CreateDocumentRequest, UpdateDocumentRequest
20 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 44
 def _render_run_with_format(run) -> str:
24 45
     """将单个 Run 转换为带格式的 Markdown 文本"""
25 46
     text = run.text
@@ -94,6 +115,7 @@ def _convert_table_to_markdown(table) -> str:
94 115
         # 对单元格内容也应用格式解析
95 116
         cell_text = []
96 117
         for para in cell.paragraphs:
118
+            para_style = para.style.name if para.style else "Normal"
97 119
             para_text = _render_para_with_inline_format(para).strip()
98 120
             # 移除表头自动添加的粗体格式
99 121
             # 因为导出时会自动给表头加粗,但原始Markdown可能没有
@@ -101,6 +123,9 @@ def _convert_table_to_markdown(table) -> str:
101 123
             if para_text.startswith("**") and para_text.endswith("**") and para_text.count("**") == 2:
102 124
                 para_text = para_text[2:-2]
103 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 129
                 cell_text.append(para_text)
105 130
         header_cells.append(" ".join(cell_text))
106 131
     
@@ -116,8 +141,13 @@ def _convert_table_to_markdown(table) -> str:
116 141
             # 对单元格内容也应用格式解析
117 142
             cell_text = []
118 143
             for para in cell.paragraphs:
144
+                para_style = para.style.name if para.style else "Normal"
119 145
                 para_text = _render_para_with_inline_format(para).strip()
146
+                
120 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 151
                     cell_text.append(para_text)
122 152
             cells.append(" ".join(cell_text))
123 153
         md_lines.append("| " + " | ".join(cells) + " |")
@@ -176,6 +206,17 @@ def _docx_to_markdown(path: Path) -> str:
176 206
     """将 .docx 文件解析为完整 Markdown 文本(支持往返转换)"""
177 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 221
     elements = []
181 222
     
@@ -200,7 +241,15 @@ def _docx_to_markdown(path: Path) -> str:
200 241
     in_code_block = False
201 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 253
         if elem_type == "para":
205 254
             para = elem
206 255
             style_name = para.style.name if para.style else ""
@@ -234,6 +283,24 @@ def _docx_to_markdown(path: Path) -> str:
234 283
                 # 检测是否是分隔线(只有底部边框的空段落)
235 284
                 if _has_bottom_border(para):
236 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 304
                 continue
238 305
             
239 306
             # 标题
@@ -260,7 +327,30 @@ def _docx_to_markdown(path: Path) -> str:
260 327
             
261 328
             # 普通段落
262 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 355
         elif elem_type == "table":
266 356
             # 如果之前在代码块中,先结束

+ 287 - 11
app/services/export_service.py

@@ -1,5 +1,6 @@
1 1
 """export_service.py — 将文档 Markdown 内容转换为 .doc 文件并返回永久下载链接。"""
2 2
 
3
+import base64
3 4
 import io
4 5
 import json
5 6
 import time
@@ -10,6 +11,7 @@ from typing import Optional
10 11
 
11 12
 import mistune
12 13
 from docx import Document
14
+from docx.enum.text import WD_ALIGN_PARAGRAPH
13 15
 from docx.oxml import OxmlElement
14 16
 from docx.oxml.ns import qn
15 17
 from docx.shared import Pt, RGBColor
@@ -91,6 +93,42 @@ def inject_styles_from_json(doc: Document, style_data: dict) -> None:
91 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 132
 def _resolve_style_id(style_map: dict, *keys: str) -> Optional[str]:
95 133
     for key in keys:
96 134
         entry = style_map.get(key)
@@ -111,13 +149,53 @@ class DocxRenderer(mistune.BaseRenderer):
111 149
         self.style_map = style_map
112 150
         self.doc = Document()
113 151
         inject_styles_from_json(self.doc, style_data)
152
+        inject_numbering_from_json(self.doc, style_data)  # 新增:注入编号格式
114 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 157
     def _get_style_by_id(self, style_id: str):
117 158
         for style in self.doc.styles:
118 159
             if style.style_id == style_id:
119 160
                 return style
120 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 200
     @staticmethod
123 201
     def _extract_text(children: list) -> str:
@@ -139,6 +217,8 @@ class DocxRenderer(mistune.BaseRenderer):
139 217
             para = self.doc.add_paragraph(text)
140 218
             try:
141 219
                 para.style = self._get_style_by_id(style_id)
220
+                # 应用样式后,复制编号属性到段落
221
+                self._apply_numbering_from_style(para)
142 222
             except KeyError:
143 223
                 pass
144 224
         else:
@@ -146,18 +226,180 @@ class DocxRenderer(mistune.BaseRenderer):
146 226
         return ""
147 227
 
148 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 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 256
             try:
152 257
                 p.style = self._get_style_by_id(self._normal_id)
153 258
             except Exception:
154 259
                 pass
260
+        
155 261
         self._render_inline_children(p, token.get("children", []))
156 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 317
     def blank_line(self, token: dict, state) -> str:
159 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 403
     def thematic_break(self, token: dict, state) -> str:
162 404
         p = self.doc.add_paragraph()
163 405
         pPr = p._p.get_or_add_pPr()
@@ -248,17 +490,14 @@ class DocxRenderer(mistune.BaseRenderer):
248 490
         tbl = self.doc.add_table(rows=1 + len(body_rows), cols=cols)
249 491
         tbl.style = "Table Grid"
250 492
 
251
-        # 表头行(保留内联格式)
493
+        # 表头行(保留内联格式,不强制加粗
252 494
         for c, cell_token in enumerate(head_cells):
253 495
             cell = tbl.rows[0].cells[c]
254 496
             # 清空默认段落
255 497
             cell.text = ""
256 498
             para = cell.paragraphs[0]
257
-            # 渲染内联内容
499
+            # 渲染内联内容(样式由 _render_inline_children 处理)
258 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 503
         for r, row_cells in enumerate(body_rows):
@@ -266,22 +505,54 @@ class DocxRenderer(mistune.BaseRenderer):
266 505
                 if c >= cols:
267 506
                     break
268 507
                 cell = tbl.rows[r + 1].cells[c]
269
-                # 清空默认段落
270 508
                 cell.text = ""
271 509
                 para = cell.paragraphs[0]
272
-                # 渲染内联内容
510
+                # 渲染内联内容(样式注释会在 _render_inline_children 中处理)
273 511
                 self._render_inline_children(para, cell_token.get("children", []))
274 512
 
275 513
         return ""
276 514
 
277
-        return ""
278
-
279 515
     def _render_inline_children(self, paragraph, children: list) -> None:
516
+        """渲染内联子元素,处理粗体、斜体等格式"""
280 517
         for child in children:
281 518
             ctype = child.get("type", "")
282 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 554
                 paragraph.add_run(raw)
555
+            
285 556
             elif ctype == "strong":
286 557
                 paragraph.add_run(self._extract_text(child.get("children", []))).bold = True
287 558
             elif ctype == "emphasis":
@@ -296,6 +567,11 @@ class DocxRenderer(mistune.BaseRenderer):
296 567
                 paragraph.add_run().add_break()
297 568
             elif ctype == "softlinebreak":
298 569
                 paragraph.add_run(" ")
570
+            elif ctype == "image":
571
+                # 处理内联图片
572
+                # 注意:这里的图片是在段落中的,需要特殊处理
573
+                # 我们需要跳过这个段落,让 image() 方法来处理
574
+                pass
299 575
             else:
300 576
                 sub = child.get("children")
301 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


Dosya farkı çok büyük olduğundan ihmal edildi
+ 2628 - 2597
tmp/default.json


+ 36 - 2
tmp/styles.py

@@ -1,6 +1,7 @@
1 1
 """
2 2
 提取 Word 文档中所有样式的完整 XML 定义(不遗漏任何属性)。
3
-目标文件: 2026年Q2季度报告_E-EdHk4r1Lg.doc
3
+同时提取编号格式定义(numbering.xml)以支持标题编号。
4
+目标文件: default.docx
4 5
 """
5 6
 
6 7
 import json
@@ -8,6 +9,7 @@ from pathlib import Path
8 9
 from docx import Document
9 10
 from docx.oxml.ns import qn
10 11
 from lxml import etree
12
+from zipfile import ZipFile
11 13
 
12 14
 DOC_PATH = Path(__file__).parent / "default.docx"
13 15
 OUTPUT_PATH = Path(__file__).parent / "default.json"
@@ -159,17 +161,45 @@ def extract_styles(doc_path: Path) -> list[dict]:
159 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 187
 def main():
163 188
     print(f"读取文件: {DOC_PATH}")
164 189
     if not DOC_PATH.exists():
165 190
         raise FileNotFoundError(f"文件不存在: {DOC_PATH}")
166 191
 
192
+    # 提取样式
167 193
     styles_data = extract_styles(DOC_PATH)
194
+    
195
+    # 提取编号格式
196
+    numbering_data = extract_numbering(DOC_PATH)
168 197
 
169 198
     result = {
170 199
         "source_file": DOC_PATH.name,
171 200
         "total_styles": len(styles_data),
172 201
         "styles": styles_data,
202
+        "numbering": numbering_data,  # 新增:编号格式定义
173 203
     }
174 204
 
175 205
     OUTPUT_PATH.write_text(
@@ -178,7 +208,11 @@ def main():
178 208
     )
179 209
 
180 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 218
     by_type: dict[str, list[str]] = {}