Kaynağa Gözat

feat(export):添加页面设置配置和文档网格支持
添加页边距配置(上、下、左、右、装订线),支持 twips 到 EMU 单位转换
添加纸张尺寸设置(页面宽度、页面高度)支持
添加页面方向控制(纵向/横向)
添加页眉页脚距离配置
添加首页页眉页脚不同设置支持
添加文档网格设置(网格类型、每行字符数、每页行数)
实现基于 XML 的文档网格配置功能

chensiyu 1 ay önce
ebeveyn
işleme
6f4760054a
4 değiştirilmiş dosya ile 353 ekleme ve 5 silme
  1. 158 1
      app/services/export_service.py
  2. BIN
      tmp/default.docx
  3. 23 2
      tmp/default.json
  4. 172 2
      tmp/styles.py

+ 158 - 1
app/services/export_service.py

@@ -13,7 +13,8 @@ from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT
13 13
 from docx.enum.text import WD_ALIGN_PARAGRAPH
14 14
 from docx.oxml import OxmlElement
15 15
 from docx.oxml.ns import qn
16
-from docx.shared import Pt, RGBColor
16
+from docx.shared import Pt, RGBColor, Inches
17
+from docx.enum.section import WD_ORIENT
17 18
 from lxml import etree
18 19
 
19 20
 from app.config import settings
@@ -272,6 +273,157 @@ def _apply_run_style(run, style: dict):
272 273
 
273 274
 
274 275
 # ------------------------------------------------------------------ #
276
+# 页面设置应用
277
+# ------------------------------------------------------------------ #
278
+
279
+def twips_to_emu(twips: int) -> int:
280
+    """twips 转 EMU (1 twips = 635 EMU)"""
281
+    if twips is None:
282
+        return None
283
+    return int(twips * 635)
284
+
285
+
286
+def apply_page_setup(doc: Document, style_data: dict) -> None:
287
+    """应用页面设置到文档
288
+    
289
+    从 style_data 中读取 page_setup,应用到文档的第一个 section
290
+    
291
+    Args:
292
+        doc: python-docx Document 对象
293
+        style_data: 样式数据(包含 page_setup)
294
+    """
295
+    page_setup = style_data.get("page_setup")
296
+    if not page_setup:
297
+        return
298
+    
299
+    sections = page_setup.get("sections", [])
300
+    if not sections:
301
+        return
302
+    
303
+    # 应用第一节的设置
304
+    section_data = sections[0]
305
+    
306
+    # 检查文档是否有 section
307
+    if not doc.sections:
308
+        return
309
+    
310
+    section = doc.sections[0]
311
+    
312
+    try:
313
+        # 页边距(twips → EMU)
314
+        top_margin = section_data.get("top_margin")
315
+        if top_margin is not None:
316
+            section.top_margin = twips_to_emu(top_margin)
317
+        
318
+        bottom_margin = section_data.get("bottom_margin")
319
+        if bottom_margin is not None:
320
+            section.bottom_margin = twips_to_emu(bottom_margin)
321
+        
322
+        left_margin = section_data.get("left_margin")
323
+        if left_margin is not None:
324
+            section.left_margin = twips_to_emu(left_margin)
325
+        
326
+        right_margin = section_data.get("right_margin")
327
+        if right_margin is not None:
328
+            section.right_margin = twips_to_emu(right_margin)
329
+        
330
+        gutter = section_data.get("gutter")
331
+        if gutter is not None and gutter > 0:
332
+            section.gutter = twips_to_emu(gutter)
333
+        
334
+        # 纸张尺寸(twips → EMU)
335
+        page_width = section_data.get("page_width")
336
+        if page_width is not None:
337
+            section.page_width = twips_to_emu(page_width)
338
+        
339
+        page_height = section_data.get("page_height")
340
+        if page_height is not None:
341
+            section.page_height = twips_to_emu(page_height)
342
+        
343
+        # 方向
344
+        orientation = section_data.get("orientation")
345
+        if orientation == "landscape":
346
+            section.orientation = WD_ORIENT.LANDSCAPE
347
+        elif orientation == "portrait":
348
+            section.orientation = WD_ORIENT.PORTRAIT
349
+        
350
+        # 页眉页脚距离(twips → EMU)
351
+        header_distance = section_data.get("header_distance")
352
+        if header_distance is not None:
353
+            section.header_distance = twips_to_emu(header_distance)
354
+        
355
+        footer_distance = section_data.get("footer_distance")
356
+        if footer_distance is not None:
357
+            section.footer_distance = twips_to_emu(footer_distance)
358
+        
359
+        # 首页页眉页脚不同
360
+        different_first_page = section_data.get("different_first_page")
361
+        if different_first_page is not None:
362
+            section.different_first_page_header_footer = different_first_page
363
+        
364
+        # 文档网格(需要通过 XML 操作)
365
+        grid_type = section_data.get("grid_type")
366
+        chars_per_line = section_data.get("chars_per_line")
367
+        lines_per_page = section_data.get("lines_per_page")
368
+        
369
+        if grid_type or chars_per_line or lines_per_page:
370
+            _apply_document_grid(section, grid_type, chars_per_line, lines_per_page)
371
+        
372
+    except Exception as e:
373
+        # 如果应用页面设置失败,不影响文档生成,只是可能使用默认设置
374
+        print(f"警告: 应用页面设置失败: {e}")
375
+        pass
376
+
377
+
378
+def _apply_document_grid(section, grid_type: str = None, chars_per_line: int = None, lines_per_page: int = None) -> None:
379
+    """应用文档网格设置(通过 XML 操作)
380
+    
381
+    Args:
382
+        section: python-docx Section 对象
383
+        grid_type: 网格类型(default/lines/linesAndChars/snapToChars)
384
+        chars_per_line: 每行字符数
385
+        lines_per_page: 每页行数
386
+    """
387
+    try:
388
+        # 获取 section 的 XML 元素
389
+        sectPr = None
390
+        if hasattr(section, '_sectPr'):
391
+            sectPr = section._sectPr
392
+        elif hasattr(section, '_element'):
393
+            sectPr = section._element
394
+        
395
+        if sectPr is None:
396
+            return
397
+        
398
+        # 查找或创建 docGrid 元素
399
+        docGrid = sectPr.find(qn('w:docGrid'))
400
+        
401
+        if docGrid is None:
402
+            # 如果不存在,创建新的 docGrid 元素
403
+            docGrid = OxmlElement('w:docGrid')
404
+            # 插入到合适的位置(在 sectPr 的子元素中)
405
+            sectPr.append(docGrid)
406
+        
407
+        # 设置网格类型
408
+        if grid_type:
409
+            docGrid.set(qn('w:type'), grid_type)
410
+        
411
+        # 设置每页行数(linePitch)
412
+        # 注意:Word XML 中 linePitch 表示行间距,用于控制每页行数
413
+        if lines_per_page is not None and lines_per_page > 0:
414
+            docGrid.set(qn('w:linePitch'), str(lines_per_page))
415
+        
416
+        # 设置每行字符数(charSpace)
417
+        # 注意:Word XML 中 charSpace 表示字符间距,用于控制每行字符数
418
+        if chars_per_line is not None and chars_per_line > 0:
419
+            docGrid.set(qn('w:charSpace'), str(chars_per_line))
420
+        
421
+    except Exception as e:
422
+        print(f"警告: 应用文档网格失败: {e}")
423
+        pass
424
+
425
+
426
+# ------------------------------------------------------------------ #
275 427
 # Blocks → Word 转换
276 428
 # ------------------------------------------------------------------ #
277 429
 
@@ -287,8 +439,13 @@ def blocks_to_docx_bytes(blocks: list[dict], style_map: dict, style_data: dict)
287 439
         Word 文档字节流
288 440
     """
289 441
     doc = Document()
442
+    
443
+    # 注入样式
290 444
     inject_styles_from_json(doc, style_data)
291 445
     
446
+    # 应用页面设置(在注入样式之后)
447
+    apply_page_setup(doc, style_data)
448
+    
292 449
     # 验证并修正 Normal 样式的段后间距
293 450
     # 这是为了确保样式正确应用,避免 python-docx 的默认值覆盖
294 451
     _fix_normal_style_spacing(doc, style_data)

BIN
tmp/default.docx


+ 23 - 2
tmp/default.json

@@ -30,7 +30,7 @@
30 30
         "first_line_indent_pt": 44.0,
31 31
         "space_before_pt": null,
32 32
         "space_after_pt": null,
33
-        "line_spacing": 1.5,
33
+        "line_spacing": 1.0,
34 34
         "keep_together": null,
35 35
         "keep_with_next": null,
36 36
         "page_break_before": null
@@ -72,7 +72,7 @@
72 72
               "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}spacing": {
73 73
                 "@tag": "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}spacing",
74 74
                 "@attrib": {
75
-                  "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}line": "360",
75
+                  "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}line": "240",
76 76
                   "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}lineRule": "auto"
77 77
                 }
78 78
               },
@@ -4799,5 +4799,26 @@
4799 4799
         }
4800 4800
       ]
4801 4801
     }
4802
+  },
4803
+  "page_setup": {
4804
+    "sections": [
4805
+      {
4806
+        "top_margin": 1417,
4807
+        "bottom_margin": 1134,
4808
+        "left_margin": 1417,
4809
+        "right_margin": 1134,
4810
+        "gutter": 0,
4811
+        "page_width": 11906,
4812
+        "page_height": 16838,
4813
+        "orientation": "portrait",
4814
+        "header_distance": 851,
4815
+        "footer_distance": 992,
4816
+        "different_first_page": false,
4817
+        "grid_type": "lines",
4818
+        "chars_per_line": 0,
4819
+        "lines_per_page": 312,
4820
+        "paper_size_inferred": "A4"
4821
+      }
4822
+    ]
4802 4823
   }
4803 4824
 }

+ 172 - 2
tmp/styles.py

@@ -1,6 +1,7 @@
1 1
 """
2 2
 提取 Word 文档中所有样式的完整 XML 定义(不遗漏任何属性)。
3 3
 同时提取编号格式定义(numbering.xml)以支持标题编号。
4
+同时提取页面设置(页边距、纸张大小、方向、页眉页脚等)。
4 5
 目标文件: default.docx
5 6
 """
6 7
 
@@ -22,6 +23,13 @@ def emu_to_pt(emu) -> float | None:
22 23
     return round(int(emu) / 12700, 2)
23 24
 
24 25
 
26
+def emu_to_twips(emu) -> int | None:
27
+    """EMU 转 twips (1 twips = 635 EMU)"""
28
+    if emu is None:
29
+        return None
30
+    return int(emu / 635)
31
+
32
+
25 33
 def extract_font(style) -> dict | None:
26 34
     """通过 API 提取字体信息(作为便捷摘要)"""
27 35
     try:
@@ -184,6 +192,152 @@ def extract_numbering(doc_path: Path) -> dict | None:
184 192
         return None
185 193
 
186 194
 
195
+def infer_paper_size(width_twips: int, height_twips: int) -> str:
196
+    """根据页面尺寸推断纸张类型"""
197
+    # 常见纸张尺寸(twips)
198
+    # A4 (纵向): 210mm x 297mm = 11906 x 16838 twips
199
+    # A4 (横向): 297mm x 210mm = 16838 x 11906 twips
200
+    # Letter (纵向): 8.5" x 11" = 12240 x 15840 twips
201
+    # A3 (纵向): 297mm x 420mm = 16838 x 23811 twips
202
+    
203
+    # 允许 ±100 twips 的误差
204
+    tolerance = 100
205
+    
206
+    # A4 纵向
207
+    if abs(width_twips - 11906) < tolerance and abs(height_twips - 16838) < tolerance:
208
+        return "A4"
209
+    # A4 横向
210
+    elif abs(width_twips - 16838) < tolerance and abs(height_twips - 11906) < tolerance:
211
+        return "A4 (Landscape)"
212
+    # Letter 纵向
213
+    elif abs(width_twips - 12240) < tolerance and abs(height_twips - 15840) < tolerance:
214
+        return "Letter"
215
+    # A3 纵向
216
+    elif abs(width_twips - 16838) < tolerance and abs(height_twips - 23811) < tolerance:
217
+        return "A3"
218
+    else:
219
+        return "Custom"
220
+
221
+
222
+def extract_page_setup(doc_path: Path) -> dict:
223
+    """
224
+    提取页面设置信息(页边距、纸张大小、方向、页眉页脚等)
225
+    只提取第一个节(section)的设置(大多数文档只有一个节)
226
+    """
227
+    try:
228
+        doc = Document(str(doc_path))
229
+        
230
+        # 检查是否有 section
231
+        if not doc.sections:
232
+            return {"sections": []}
233
+        
234
+        # 只提取第一个 section
235
+        section = doc.sections[0]
236
+        sections_data = []
237
+        # 提取页边距(转换为 twips)
238
+        top_margin = emu_to_twips(section.top_margin)
239
+        bottom_margin = emu_to_twips(section.bottom_margin)
240
+        left_margin = emu_to_twips(section.left_margin)
241
+        right_margin = emu_to_twips(section.right_margin)
242
+        gutter = emu_to_twips(section.gutter)
243
+        
244
+        # 提取纸张尺寸(转换为 twips)
245
+        page_width = emu_to_twips(section.page_width)
246
+        page_height = emu_to_twips(section.page_height)
247
+        
248
+        # 提取方向
249
+        # orientation: 0 = PORTRAIT, 1 = LANDSCAPE
250
+        orientation = "portrait" if section.orientation == 0 else "landscape"
251
+        
252
+        # 提取页眉页脚距离(转换为 twips)
253
+        header_distance = emu_to_twips(section.header_distance)
254
+        footer_distance = emu_to_twips(section.footer_distance)
255
+        
256
+        # 提取首页不同设置
257
+        different_first_page = section.different_first_page_header_footer
258
+        
259
+        # 从 XML 提取文档网格设置
260
+        # 注意:需要通过 section._sectPr 访问 XML 元素
261
+        grid_type = None
262
+        chars_per_line = None
263
+        lines_per_page = None
264
+        
265
+        try:
266
+            # 尝试获取 section 的 XML 元素
267
+            if hasattr(section, '_sectPr'):
268
+                sectPr = section._sectPr
269
+            elif hasattr(section, 'element'):
270
+                sectPr = section.element
271
+            else:
272
+                sectPr = None
273
+            
274
+            if sectPr is not None:
275
+                docGrid = sectPr.find(qn('w:docGrid'))
276
+                
277
+                if docGrid is not None:
278
+                    # 网格类型: default, lines, linesAndChars, snapToChars
279
+                    grid_type = docGrid.get(qn('w:type'))
280
+                    
281
+                    # linePitch: 每行的高度(用于计算行数)
282
+                    # charSpace: 字符间距
283
+                    line_pitch = docGrid.get(qn('w:linePitch'))
284
+                    char_space = docGrid.get(qn('w:charSpace'))
285
+                    
286
+                    # 注意:Word UI 显示的"每页行数"对应 linePitch
287
+                    # "每行字符数"对应 charSpace
288
+                    if line_pitch:
289
+                        lines_per_page = int(line_pitch)
290
+                    if char_space:
291
+                        chars_per_line = int(char_space)
292
+        except Exception as e:
293
+            # 如果提取网格失败,继续(网格不是必需的)
294
+            pass
295
+        
296
+        # 推断纸张大小
297
+        paper_size_inferred = infer_paper_size(page_width, page_height)
298
+        
299
+        section_data = {
300
+            # 页边距(twips)
301
+            "top_margin": top_margin,
302
+            "bottom_margin": bottom_margin,
303
+            "left_margin": left_margin,
304
+            "right_margin": right_margin,
305
+            "gutter": gutter,
306
+            
307
+            # 纸张(twips)
308
+            "page_width": page_width,
309
+            "page_height": page_height,
310
+            "orientation": orientation,
311
+            
312
+            # 版式(twips)
313
+            "header_distance": header_distance,
314
+            "footer_distance": footer_distance,
315
+            "different_first_page": different_first_page,
316
+            
317
+            # 文档网格(可选)
318
+            "grid_type": grid_type,
319
+            "chars_per_line": chars_per_line,
320
+            "lines_per_page": lines_per_page,
321
+            
322
+            # 推断信息
323
+            "paper_size_inferred": paper_size_inferred,
324
+        }
325
+        
326
+        sections_data.append(section_data)
327
+        
328
+        return {
329
+            "sections": sections_data
330
+        }
331
+        
332
+    except Exception as e:
333
+        print(f"警告: 提取页面设置失败: {e}")
334
+        import traceback
335
+        traceback.print_exc()
336
+        return {
337
+            "sections": []
338
+        }
339
+
340
+
187 341
 def main():
188 342
     print(f"读取文件: {DOC_PATH}")
189 343
     if not DOC_PATH.exists():
@@ -194,12 +348,16 @@ def main():
194 348
     
195 349
     # 提取编号格式
196 350
     numbering_data = extract_numbering(DOC_PATH)
351
+    
352
+    # 提取页面设置
353
+    page_setup_data = extract_page_setup(DOC_PATH)
197 354
 
198 355
     result = {
199 356
         "source_file": DOC_PATH.name,
200 357
         "total_styles": len(styles_data),
201 358
         "styles": styles_data,
202
-        "numbering": numbering_data,  # 新增:编号格式定义
359
+        "numbering": numbering_data,  # 编号格式定义
360
+        "page_setup": page_setup_data,  # 页面设置
203 361
     }
204 362
 
205 363
     OUTPUT_PATH.write_text(
@@ -212,9 +370,21 @@ def main():
212 370
         print(f"✅ 已提取编号格式定义")
213 371
     else:
214 372
         print(f"ℹ️  文档中没有编号格式")
373
+    
374
+    # 打印页面设置摘要
375
+    if page_setup_data and page_setup_data.get("sections"):
376
+        section = page_setup_data["sections"][0]
377
+        print(f"✅ 已提取页面设置:")
378
+        print(f"   - 纸张: {section.get('paper_size_inferred')} ({section.get('orientation')})")
379
+        print(f"   - 页边距: 上{section.get('top_margin')} 下{section.get('bottom_margin')} "
380
+              f"左{section.get('left_margin')} 右{section.get('right_margin')} twips")
381
+        if section.get('grid_type'):
382
+            print(f"   - 文档网格: {section.get('grid_type')} "
383
+                  f"(每行{section.get('chars_per_line')}字符, 每页{section.get('lines_per_page')}行)")
384
+    
215 385
     print(f"完整定义已保存至: {OUTPUT_PATH}")
216 386
 
217
-    # 打印摘要
387
+    # 打印样式类型分布
218 388
     by_type: dict[str, list[str]] = {}
219 389
     for s in styles_data:
220 390
         t = s["type"]