word-template-engine 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- word_template_engine-0.1.0/PKG-INFO +262 -0
- word_template_engine-0.1.0/README.md +248 -0
- word_template_engine-0.1.0/pyproject.toml +30 -0
- word_template_engine-0.1.0/utils/__init__.py +16 -0
- word_template_engine-0.1.0/utils/common.py +153 -0
- word_template_engine-0.1.0/utils/image_processor.py +98 -0
- word_template_engine-0.1.0/utils/table_processor.py +217 -0
- word_template_engine-0.1.0/utils/text_processor.py +216 -0
- word_template_engine-0.1.0/word_template_engine.py +102 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: word-template-engine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Word template engine similar to docx-template, implemented with python-docx and lxml.
|
|
5
|
+
Project-URL: Homepage, https://github.com/yourname/word-template-engine
|
|
6
|
+
Project-URL: Repository, https://github.com/yourname/word-template-engine
|
|
7
|
+
Author-email: Your Name <you@example.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Requires-Dist: lxml>=4.9.0
|
|
11
|
+
Requires-Dist: python-docx>=1.1.0
|
|
12
|
+
Requires-Dist: requests>=2.28.0
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Word模板引擎
|
|
16
|
+
|
|
17
|
+
一个类似于docx-template的Word模板引擎,使用Python直接解析Word文档的XML结构实现,**完全保留所有样式信息**。
|
|
18
|
+
|
|
19
|
+
## 功能特性
|
|
20
|
+
|
|
21
|
+
1. **字符串文本替换**: `{{data.diagnose}}` - 替换为对应的数据值(**不需要结束标签**)
|
|
22
|
+
|
|
23
|
+
2. **表格行循环**: `{{#data.mutationSiteZero}}` - 在表格头部标记,根据数据循环生成表格行,使用`[field_name]`作为字段占位符(**不需要结束标签**)
|
|
24
|
+
|
|
25
|
+
3. **表格列循环**: `{{$data.mutationSiteZero}}` - 在表格头部标记,根据数据循环生成表格列(**不需要结束标签**)
|
|
26
|
+
|
|
27
|
+
4. **条件区块**: `{{?data.field != null && data.field.size() > 0}}...{{/}}` - 根据条件显示或隐藏内容块(**需要结束标签 {{/}}**)
|
|
28
|
+
|
|
29
|
+
5. **图片插入**: `{{@var}}` - 插入图片,var格式: `{'url': '', 'conf': {'w': '180px', 'h': '180px'}}`(**不需要结束标签**)
|
|
30
|
+
|
|
31
|
+
6. **列表循环**: `{{*data.conclusion_C}}` - 循环显示列表项(**不需要结束标签**)
|
|
32
|
+
|
|
33
|
+
## 标签说明
|
|
34
|
+
|
|
35
|
+
### 有结束标签的标签
|
|
36
|
+
|
|
37
|
+
- **区块**: `{{?condition}}...{{/}}`
|
|
38
|
+
- 只有区块需要结束标签
|
|
39
|
+
- 支持嵌套
|
|
40
|
+
- 示例:
|
|
41
|
+
```
|
|
42
|
+
{{?data.conclusion_C == ''}}无{{/}}
|
|
43
|
+
{{?data.conclusion_C != ''}}
|
|
44
|
+
{{*data.conclusion_C}}
|
|
45
|
+
{{/}}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 不需要结束标签的标签
|
|
49
|
+
|
|
50
|
+
- **文本替换**: `{{data.field}}`
|
|
51
|
+
- **表格行循环**: `{{#data.field}}` - 后面直接跟字段如`[gene_transcript]`
|
|
52
|
+
- **表格列循环**: `{{$data.field}}`
|
|
53
|
+
- **图片**: `{{@var}}`
|
|
54
|
+
- **列表**: `{{*data.field}}`
|
|
55
|
+
|
|
56
|
+
## 安装
|
|
57
|
+
|
|
58
|
+
使用pip安装依赖:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install wps_temp_engine
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**注意**: 本引擎直接解析Word文档的XML结构,这样可以完整保留所有样式信息。
|
|
65
|
+
|
|
66
|
+
## 使用方法
|
|
67
|
+
|
|
68
|
+
### 1. 创建Word模板
|
|
69
|
+
|
|
70
|
+
在Word文档中使用以下占位符:
|
|
71
|
+
|
|
72
|
+
#### 文本替换
|
|
73
|
+
- `{{data.diagnose}}`
|
|
74
|
+
|
|
75
|
+
#### 表格行循环
|
|
76
|
+
在表格行的单元格中写入 `{{#data.mutationSiteZero}}`,其他单元格使用方括号占位符:
|
|
77
|
+
- `[gene_transcript]`
|
|
78
|
+
- `[value]`
|
|
79
|
+
- `[description]`
|
|
80
|
+
|
|
81
|
+
#### 表格列循环
|
|
82
|
+
在表格列的第一个单元格写入 `{{$data.mutationSiteZero}}`
|
|
83
|
+
|
|
84
|
+
#### 条件区块
|
|
85
|
+
```text
|
|
86
|
+
{{?data.mutationSiteZero.size() > 0}}
|
|
87
|
+
这部分内容只有在条件为真时才会显示
|
|
88
|
+
{{/}}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
支持的条件:
|
|
92
|
+
- `size() > 0`
|
|
93
|
+
|
|
94
|
+
#### 图片
|
|
95
|
+
方式1: 使用变量名
|
|
96
|
+
```
|
|
97
|
+
{{@data.image_config}}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
方式2: 直接写JSON
|
|
101
|
+
```
|
|
102
|
+
{{@{'url':'path/to/image.jpg','conf':{'w':'180px','h':'180px'}}}}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
支持的尺寸单位: px, cm, in, mm
|
|
106
|
+
|
|
107
|
+
#### 列表
|
|
108
|
+
```
|
|
109
|
+
{{*data.conclusion_C}}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 2. 使用Python代码渲染
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from word_template_engine import WordTemplateEngine
|
|
116
|
+
|
|
117
|
+
# 创建引擎实例
|
|
118
|
+
engine = WordTemplateEngine('template.docx')
|
|
119
|
+
|
|
120
|
+
# 准备数据
|
|
121
|
+
data = {
|
|
122
|
+
'data': {
|
|
123
|
+
'diagnose': '诊断结果',
|
|
124
|
+
'mutationSiteZero': [
|
|
125
|
+
{'gene_transcript': 'Gene1', 'value': 'Value1', 'description': 'Desc1'},
|
|
126
|
+
{'gene_transcript': 'Gene2', 'value': 'Value2', 'description': 'Desc2'},
|
|
127
|
+
],
|
|
128
|
+
'conclusion_C': ['结论1', '结论2', '结论3'],
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# 渲染并保存
|
|
133
|
+
engine.render(data, 'output.docx')
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## 详细说明
|
|
137
|
+
|
|
138
|
+
### 1. 字符串文本替换
|
|
139
|
+
|
|
140
|
+
模板中写入: `{{data.diagnose}}`
|
|
141
|
+
|
|
142
|
+
数据中提供: `{'data': {'diagnose': '测试结果'}}`
|
|
143
|
+
|
|
144
|
+
结果: 替换为 "测试结果"
|
|
145
|
+
|
|
146
|
+
### 2. 表格行循环
|
|
147
|
+
|
|
148
|
+
在表格的某一行的单元格中写入 `{{#data.mutationSiteZero}}`,该行将成为模板行。
|
|
149
|
+
|
|
150
|
+
其他单元格使用方括号占位符,如 `[gene_transcript]`、`[value]`。
|
|
151
|
+
|
|
152
|
+
根据 `data.mutationSiteZero` 数组的长度,会自动生成对应数量的表格行。
|
|
153
|
+
|
|
154
|
+
**注意**: 表格行循环**不需要结束标签**,循环标记后直接跟字段占位符。
|
|
155
|
+
|
|
156
|
+
### 3. 表格列循环
|
|
157
|
+
|
|
158
|
+
在表格某一列的单元格中写入 `{{$data.mutationSiteZero}}`,该列将成为模板列。
|
|
159
|
+
|
|
160
|
+
根据数据数组长度,会自动为所有行添加新列。
|
|
161
|
+
|
|
162
|
+
### 4. 条件区块
|
|
163
|
+
|
|
164
|
+
支持条件判断,**需要结束标签**:
|
|
165
|
+
|
|
166
|
+
```text
|
|
167
|
+
{{?data.conclusion_C == ''}}无{{/}}
|
|
168
|
+
{{?data.conclusion_C != ''}}
|
|
169
|
+
{{*data.conclusion_C}}
|
|
170
|
+
{{/}}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
支持的操作符:
|
|
174
|
+
- `!= null` / `== null`
|
|
175
|
+
- `== ''` / `!= ''` (检查空字符串)
|
|
176
|
+
- `size() > 0`
|
|
177
|
+
- `&&` (AND)
|
|
178
|
+
- `||` (OR)
|
|
179
|
+
|
|
180
|
+
支持嵌套区块。
|
|
181
|
+
|
|
182
|
+
### 5. 图片插入
|
|
183
|
+
|
|
184
|
+
两种方式:
|
|
185
|
+
|
|
186
|
+
方式1: 使用变量名
|
|
187
|
+
```
|
|
188
|
+
{{@data.image_config}}
|
|
189
|
+
```
|
|
190
|
+
其中 `data.image_config` 是字典格式的配置。
|
|
191
|
+
|
|
192
|
+
方式2: 直接写JSON
|
|
193
|
+
```
|
|
194
|
+
{{@{'url':'https://example.com/image.jpg','conf':{'w':'180px','h':'180px'}}}}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
支持的尺寸单位: px, cm, in, mm
|
|
198
|
+
|
|
199
|
+
图片可以是HTTP/HTTPS链接或本地文件路径。
|
|
200
|
+
|
|
201
|
+
### 6. 列表循环
|
|
202
|
+
|
|
203
|
+
模板中写入: `{{*data.conclusion_C}}`
|
|
204
|
+
|
|
205
|
+
数据中提供: `{'data': {'conclusion_C': ['项目1', '项目2', '项目3']}}`
|
|
206
|
+
|
|
207
|
+
结果: 展开为多行文本,每行一个项目
|
|
208
|
+
|
|
209
|
+
## 处理顺序
|
|
210
|
+
|
|
211
|
+
引擎按以下顺序处理模板:
|
|
212
|
+
|
|
213
|
+
1. **区块** - 先处理条件区块(因为它有结束标签,可能包含其他标签)
|
|
214
|
+
2. **列表** - 处理列表循环
|
|
215
|
+
3. **文本** - 处理文本占位符
|
|
216
|
+
4. **图片** - 处理图片占位符
|
|
217
|
+
5. **表格** - 最后处理表格(行循环和列循环)
|
|
218
|
+
|
|
219
|
+
## 项目结构
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
.
|
|
223
|
+
├── pyproject.toml # 项目配置
|
|
224
|
+
├── word_template_engine.py # 主引擎类(XML版本)
|
|
225
|
+
├── example.py # 使用示例
|
|
226
|
+
├── README.md # 详细文档
|
|
227
|
+
├── template.docx # 模板文件
|
|
228
|
+
└── utils/ # 工具模块
|
|
229
|
+
├── __init__.py
|
|
230
|
+
├── xml_processor.py # XML处理工具
|
|
231
|
+
├── text_processor.py # 文本处理
|
|
232
|
+
├── table_processor.py # 表格处理(保留兼容)
|
|
233
|
+
├── block_processor.py # 区块处理
|
|
234
|
+
├── image_processor.py # 图片处理
|
|
235
|
+
└── list_processor.py # 列表处理
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## 技术实现
|
|
239
|
+
|
|
240
|
+
本引擎直接解析Word文档的XML结构(.docx文件实际上是一个ZIP压缩包,包含XML文件),这样可以:
|
|
241
|
+
|
|
242
|
+
- **完全保留样式信息**:所有格式、字体、颜色、对齐等样式都会保留
|
|
243
|
+
- **精确控制**:直接操作XML节点,可以精确处理复杂的文档结构
|
|
244
|
+
- **高性能**:无需通过中间库转换,直接操作XML
|
|
245
|
+
|
|
246
|
+
主要处理流程:
|
|
247
|
+
1. 解压.docx文件(ZIP格式)
|
|
248
|
+
2. 解析 `word/document.xml` 主文档
|
|
249
|
+
3. 在XML层面进行占位符替换和循环处理
|
|
250
|
+
4. 重新打包为.docx文件
|
|
251
|
+
|
|
252
|
+
## 注意事项
|
|
253
|
+
|
|
254
|
+
1. 模板文件必须是有效的.docx格式
|
|
255
|
+
2. 表格行循环标记需要放在表格的单元格中
|
|
256
|
+
3. 表格行循环**不需要结束标签**,直接在标记后使用`[field]`占位符
|
|
257
|
+
4. 只有**区块**需要结束标签 `{{/}}`
|
|
258
|
+
5. 图片URL可以是HTTP/HTTPS链接或本地文件路径(图片功能在XML版本中暂未完整实现)
|
|
259
|
+
6. 条件表达式支持基本的逻辑运算
|
|
260
|
+
7. 嵌套的字段访问使用点号,如 `data.sub_field.value`
|
|
261
|
+
8. 支持嵌套的条件区块
|
|
262
|
+
9. **所有样式都会完整保留**,包括字体、颜色、大小、对齐等,这是使用XML解析的最大优势
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Word模板引擎
|
|
2
|
+
|
|
3
|
+
一个类似于docx-template的Word模板引擎,使用Python直接解析Word文档的XML结构实现,**完全保留所有样式信息**。
|
|
4
|
+
|
|
5
|
+
## 功能特性
|
|
6
|
+
|
|
7
|
+
1. **字符串文本替换**: `{{data.diagnose}}` - 替换为对应的数据值(**不需要结束标签**)
|
|
8
|
+
|
|
9
|
+
2. **表格行循环**: `{{#data.mutationSiteZero}}` - 在表格头部标记,根据数据循环生成表格行,使用`[field_name]`作为字段占位符(**不需要结束标签**)
|
|
10
|
+
|
|
11
|
+
3. **表格列循环**: `{{$data.mutationSiteZero}}` - 在表格头部标记,根据数据循环生成表格列(**不需要结束标签**)
|
|
12
|
+
|
|
13
|
+
4. **条件区块**: `{{?data.field != null && data.field.size() > 0}}...{{/}}` - 根据条件显示或隐藏内容块(**需要结束标签 {{/}}**)
|
|
14
|
+
|
|
15
|
+
5. **图片插入**: `{{@var}}` - 插入图片,var格式: `{'url': '', 'conf': {'w': '180px', 'h': '180px'}}`(**不需要结束标签**)
|
|
16
|
+
|
|
17
|
+
6. **列表循环**: `{{*data.conclusion_C}}` - 循环显示列表项(**不需要结束标签**)
|
|
18
|
+
|
|
19
|
+
## 标签说明
|
|
20
|
+
|
|
21
|
+
### 有结束标签的标签
|
|
22
|
+
|
|
23
|
+
- **区块**: `{{?condition}}...{{/}}`
|
|
24
|
+
- 只有区块需要结束标签
|
|
25
|
+
- 支持嵌套
|
|
26
|
+
- 示例:
|
|
27
|
+
```
|
|
28
|
+
{{?data.conclusion_C == ''}}无{{/}}
|
|
29
|
+
{{?data.conclusion_C != ''}}
|
|
30
|
+
{{*data.conclusion_C}}
|
|
31
|
+
{{/}}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### 不需要结束标签的标签
|
|
35
|
+
|
|
36
|
+
- **文本替换**: `{{data.field}}`
|
|
37
|
+
- **表格行循环**: `{{#data.field}}` - 后面直接跟字段如`[gene_transcript]`
|
|
38
|
+
- **表格列循环**: `{{$data.field}}`
|
|
39
|
+
- **图片**: `{{@var}}`
|
|
40
|
+
- **列表**: `{{*data.field}}`
|
|
41
|
+
|
|
42
|
+
## 安装
|
|
43
|
+
|
|
44
|
+
使用pip安装依赖:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install wps_temp_engine
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**注意**: 本引擎直接解析Word文档的XML结构,这样可以完整保留所有样式信息。
|
|
51
|
+
|
|
52
|
+
## 使用方法
|
|
53
|
+
|
|
54
|
+
### 1. 创建Word模板
|
|
55
|
+
|
|
56
|
+
在Word文档中使用以下占位符:
|
|
57
|
+
|
|
58
|
+
#### 文本替换
|
|
59
|
+
- `{{data.diagnose}}`
|
|
60
|
+
|
|
61
|
+
#### 表格行循环
|
|
62
|
+
在表格行的单元格中写入 `{{#data.mutationSiteZero}}`,其他单元格使用方括号占位符:
|
|
63
|
+
- `[gene_transcript]`
|
|
64
|
+
- `[value]`
|
|
65
|
+
- `[description]`
|
|
66
|
+
|
|
67
|
+
#### 表格列循环
|
|
68
|
+
在表格列的第一个单元格写入 `{{$data.mutationSiteZero}}`
|
|
69
|
+
|
|
70
|
+
#### 条件区块
|
|
71
|
+
```text
|
|
72
|
+
{{?data.mutationSiteZero.size() > 0}}
|
|
73
|
+
这部分内容只有在条件为真时才会显示
|
|
74
|
+
{{/}}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
支持的条件:
|
|
78
|
+
- `size() > 0`
|
|
79
|
+
|
|
80
|
+
#### 图片
|
|
81
|
+
方式1: 使用变量名
|
|
82
|
+
```
|
|
83
|
+
{{@data.image_config}}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
方式2: 直接写JSON
|
|
87
|
+
```
|
|
88
|
+
{{@{'url':'path/to/image.jpg','conf':{'w':'180px','h':'180px'}}}}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
支持的尺寸单位: px, cm, in, mm
|
|
92
|
+
|
|
93
|
+
#### 列表
|
|
94
|
+
```
|
|
95
|
+
{{*data.conclusion_C}}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 2. 使用Python代码渲染
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from word_template_engine import WordTemplateEngine
|
|
102
|
+
|
|
103
|
+
# 创建引擎实例
|
|
104
|
+
engine = WordTemplateEngine('template.docx')
|
|
105
|
+
|
|
106
|
+
# 准备数据
|
|
107
|
+
data = {
|
|
108
|
+
'data': {
|
|
109
|
+
'diagnose': '诊断结果',
|
|
110
|
+
'mutationSiteZero': [
|
|
111
|
+
{'gene_transcript': 'Gene1', 'value': 'Value1', 'description': 'Desc1'},
|
|
112
|
+
{'gene_transcript': 'Gene2', 'value': 'Value2', 'description': 'Desc2'},
|
|
113
|
+
],
|
|
114
|
+
'conclusion_C': ['结论1', '结论2', '结论3'],
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# 渲染并保存
|
|
119
|
+
engine.render(data, 'output.docx')
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## 详细说明
|
|
123
|
+
|
|
124
|
+
### 1. 字符串文本替换
|
|
125
|
+
|
|
126
|
+
模板中写入: `{{data.diagnose}}`
|
|
127
|
+
|
|
128
|
+
数据中提供: `{'data': {'diagnose': '测试结果'}}`
|
|
129
|
+
|
|
130
|
+
结果: 替换为 "测试结果"
|
|
131
|
+
|
|
132
|
+
### 2. 表格行循环
|
|
133
|
+
|
|
134
|
+
在表格的某一行的单元格中写入 `{{#data.mutationSiteZero}}`,该行将成为模板行。
|
|
135
|
+
|
|
136
|
+
其他单元格使用方括号占位符,如 `[gene_transcript]`、`[value]`。
|
|
137
|
+
|
|
138
|
+
根据 `data.mutationSiteZero` 数组的长度,会自动生成对应数量的表格行。
|
|
139
|
+
|
|
140
|
+
**注意**: 表格行循环**不需要结束标签**,循环标记后直接跟字段占位符。
|
|
141
|
+
|
|
142
|
+
### 3. 表格列循环
|
|
143
|
+
|
|
144
|
+
在表格某一列的单元格中写入 `{{$data.mutationSiteZero}}`,该列将成为模板列。
|
|
145
|
+
|
|
146
|
+
根据数据数组长度,会自动为所有行添加新列。
|
|
147
|
+
|
|
148
|
+
### 4. 条件区块
|
|
149
|
+
|
|
150
|
+
支持条件判断,**需要结束标签**:
|
|
151
|
+
|
|
152
|
+
```text
|
|
153
|
+
{{?data.conclusion_C == ''}}无{{/}}
|
|
154
|
+
{{?data.conclusion_C != ''}}
|
|
155
|
+
{{*data.conclusion_C}}
|
|
156
|
+
{{/}}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
支持的操作符:
|
|
160
|
+
- `!= null` / `== null`
|
|
161
|
+
- `== ''` / `!= ''` (检查空字符串)
|
|
162
|
+
- `size() > 0`
|
|
163
|
+
- `&&` (AND)
|
|
164
|
+
- `||` (OR)
|
|
165
|
+
|
|
166
|
+
支持嵌套区块。
|
|
167
|
+
|
|
168
|
+
### 5. 图片插入
|
|
169
|
+
|
|
170
|
+
两种方式:
|
|
171
|
+
|
|
172
|
+
方式1: 使用变量名
|
|
173
|
+
```
|
|
174
|
+
{{@data.image_config}}
|
|
175
|
+
```
|
|
176
|
+
其中 `data.image_config` 是字典格式的配置。
|
|
177
|
+
|
|
178
|
+
方式2: 直接写JSON
|
|
179
|
+
```
|
|
180
|
+
{{@{'url':'https://example.com/image.jpg','conf':{'w':'180px','h':'180px'}}}}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
支持的尺寸单位: px, cm, in, mm
|
|
184
|
+
|
|
185
|
+
图片可以是HTTP/HTTPS链接或本地文件路径。
|
|
186
|
+
|
|
187
|
+
### 6. 列表循环
|
|
188
|
+
|
|
189
|
+
模板中写入: `{{*data.conclusion_C}}`
|
|
190
|
+
|
|
191
|
+
数据中提供: `{'data': {'conclusion_C': ['项目1', '项目2', '项目3']}}`
|
|
192
|
+
|
|
193
|
+
结果: 展开为多行文本,每行一个项目
|
|
194
|
+
|
|
195
|
+
## 处理顺序
|
|
196
|
+
|
|
197
|
+
引擎按以下顺序处理模板:
|
|
198
|
+
|
|
199
|
+
1. **区块** - 先处理条件区块(因为它有结束标签,可能包含其他标签)
|
|
200
|
+
2. **列表** - 处理列表循环
|
|
201
|
+
3. **文本** - 处理文本占位符
|
|
202
|
+
4. **图片** - 处理图片占位符
|
|
203
|
+
5. **表格** - 最后处理表格(行循环和列循环)
|
|
204
|
+
|
|
205
|
+
## 项目结构
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
.
|
|
209
|
+
├── pyproject.toml # 项目配置
|
|
210
|
+
├── word_template_engine.py # 主引擎类(XML版本)
|
|
211
|
+
├── example.py # 使用示例
|
|
212
|
+
├── README.md # 详细文档
|
|
213
|
+
├── template.docx # 模板文件
|
|
214
|
+
└── utils/ # 工具模块
|
|
215
|
+
├── __init__.py
|
|
216
|
+
├── xml_processor.py # XML处理工具
|
|
217
|
+
├── text_processor.py # 文本处理
|
|
218
|
+
├── table_processor.py # 表格处理(保留兼容)
|
|
219
|
+
├── block_processor.py # 区块处理
|
|
220
|
+
├── image_processor.py # 图片处理
|
|
221
|
+
└── list_processor.py # 列表处理
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## 技术实现
|
|
225
|
+
|
|
226
|
+
本引擎直接解析Word文档的XML结构(.docx文件实际上是一个ZIP压缩包,包含XML文件),这样可以:
|
|
227
|
+
|
|
228
|
+
- **完全保留样式信息**:所有格式、字体、颜色、对齐等样式都会保留
|
|
229
|
+
- **精确控制**:直接操作XML节点,可以精确处理复杂的文档结构
|
|
230
|
+
- **高性能**:无需通过中间库转换,直接操作XML
|
|
231
|
+
|
|
232
|
+
主要处理流程:
|
|
233
|
+
1. 解压.docx文件(ZIP格式)
|
|
234
|
+
2. 解析 `word/document.xml` 主文档
|
|
235
|
+
3. 在XML层面进行占位符替换和循环处理
|
|
236
|
+
4. 重新打包为.docx文件
|
|
237
|
+
|
|
238
|
+
## 注意事项
|
|
239
|
+
|
|
240
|
+
1. 模板文件必须是有效的.docx格式
|
|
241
|
+
2. 表格行循环标记需要放在表格的单元格中
|
|
242
|
+
3. 表格行循环**不需要结束标签**,直接在标记后使用`[field]`占位符
|
|
243
|
+
4. 只有**区块**需要结束标签 `{{/}}`
|
|
244
|
+
5. 图片URL可以是HTTP/HTTPS链接或本地文件路径(图片功能在XML版本中暂未完整实现)
|
|
245
|
+
6. 条件表达式支持基本的逻辑运算
|
|
246
|
+
7. 嵌套的字段访问使用点号,如 `data.sub_field.value`
|
|
247
|
+
8. 支持嵌套的条件区块
|
|
248
|
+
9. **所有样式都会完整保留**,包括字体、颜色、大小、对齐等,这是使用XML解析的最大优势
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "word-template-engine"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Word template engine similar to docx-template, implemented with python-docx and lxml."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.8"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Your Name", email = "you@example.com" },
|
|
9
|
+
]
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
dependencies = [
|
|
12
|
+
"requests>=2.28.0",
|
|
13
|
+
"python-docx>=1.1.0",
|
|
14
|
+
"lxml>=4.9.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/yourname/word-template-engine"
|
|
19
|
+
Repository = "https://github.com/yourname/word-template-engine"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.sdist]
|
|
26
|
+
include = ["word_template_engine.py", "utils", "README.md", "pyproject.toml"]
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["."]
|
|
30
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utils package for word-template-engine.
|
|
3
|
+
|
|
4
|
+
This package contains the internal processors used by `WordTemplateEngine`:
|
|
5
|
+
- text_processor: text and block placeholders
|
|
6
|
+
- table_processor: table loops
|
|
7
|
+
- image_processor: image placeholders
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"text_processor",
|
|
12
|
+
"table_processor",
|
|
13
|
+
"image_processor",
|
|
14
|
+
"block_processor",
|
|
15
|
+
"list_processor",
|
|
16
|
+
]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Common utilities: nested value lookup and condition evaluation.
|
|
3
|
+
"""
|
|
4
|
+
import re
|
|
5
|
+
import ast
|
|
6
|
+
import copy
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_nested_value(data: Dict[str, Any], path: str) -> Any:
|
|
11
|
+
"""Get nested value from dict/list using dot path."""
|
|
12
|
+
if not path:
|
|
13
|
+
return None
|
|
14
|
+
copy_data = copy.deepcopy(data)
|
|
15
|
+
# dot_data = DotDict(data)
|
|
16
|
+
keys = path.split('.')
|
|
17
|
+
if not keys:
|
|
18
|
+
return copy_data[keys]
|
|
19
|
+
for i in range(len(keys)):
|
|
20
|
+
copy_data = copy_data.get(keys[i])
|
|
21
|
+
|
|
22
|
+
# value = dot_data.get_path(path)
|
|
23
|
+
# keys = path.split(".")
|
|
24
|
+
# value: Any = data
|
|
25
|
+
# for key in keys:
|
|
26
|
+
# if isinstance(value, dict):
|
|
27
|
+
# value = value.get(key)
|
|
28
|
+
# elif isinstance(value, list) and key.isdigit():
|
|
29
|
+
# idx = int(key)
|
|
30
|
+
# value = value[idx] if idx < len(value) else None
|
|
31
|
+
# else:
|
|
32
|
+
# return None
|
|
33
|
+
# if value is None:
|
|
34
|
+
# return None
|
|
35
|
+
return copy_data
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _value_len(val: Any) -> int:
|
|
39
|
+
if hasattr(val, "__len__"):
|
|
40
|
+
try:
|
|
41
|
+
return len(val) # type: ignore[arg-type]
|
|
42
|
+
except Exception:
|
|
43
|
+
return 0
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
def safe_eval(expr, variables=None):
|
|
47
|
+
if variables is None:
|
|
48
|
+
variables = {}
|
|
49
|
+
|
|
50
|
+
# 检查AST是否包含危险节点(如函数调用、属性访问)
|
|
51
|
+
tree = ast.parse(expr, mode='eval')
|
|
52
|
+
for node in ast.walk(tree):
|
|
53
|
+
if isinstance(node, (ast.Call, ast.Attribute)):
|
|
54
|
+
raise ValueError("Unsafe expression")
|
|
55
|
+
|
|
56
|
+
# 编译并执行
|
|
57
|
+
code = compile(tree, '<string>', 'eval')
|
|
58
|
+
return eval(code, {'__builtins__': {}}, variables)
|
|
59
|
+
|
|
60
|
+
def evaluate_condition(condition: str, data: Dict[str, Any]) -> bool:
|
|
61
|
+
"""
|
|
62
|
+
Evaluate simple condition expressions used in templates.
|
|
63
|
+
|
|
64
|
+
Supported:
|
|
65
|
+
- == null / != null
|
|
66
|
+
- == '' / != ''
|
|
67
|
+
- size() > 0
|
|
68
|
+
- && / ||
|
|
69
|
+
- truthy check of a field
|
|
70
|
+
"""
|
|
71
|
+
condition = condition.strip()
|
|
72
|
+
if not condition:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
# Logical AND / OR
|
|
76
|
+
if "&&" in condition:
|
|
77
|
+
return all(evaluate_condition(part, data) for part in condition.split("&&"))
|
|
78
|
+
if "||" in condition:
|
|
79
|
+
return any(evaluate_condition(part, data) for part in condition.split("||"))
|
|
80
|
+
|
|
81
|
+
# size() > 0
|
|
82
|
+
size_match = re.match(r"(.+)\.size(.*?)$", condition)
|
|
83
|
+
if size_match:
|
|
84
|
+
field = size_match.group(1).strip()
|
|
85
|
+
field2 = size_match.group(2).strip()
|
|
86
|
+
val = get_nested_value(data, field)
|
|
87
|
+
return safe_eval(f"{_value_len(val)}{field2}")
|
|
88
|
+
|
|
89
|
+
# == '' / != ''
|
|
90
|
+
empty_eq = re.match(r"(.+)==\s*['\"]\s*['\"]", condition)
|
|
91
|
+
if empty_eq:
|
|
92
|
+
field = empty_eq.group(1).strip()
|
|
93
|
+
val = get_nested_value(data, field)
|
|
94
|
+
return val == "" or val is None
|
|
95
|
+
|
|
96
|
+
empty_neq = re.match(r"(.+)!=\s*['\"]\s*['\"]", condition)
|
|
97
|
+
if empty_neq:
|
|
98
|
+
field = empty_neq.group(1).strip()
|
|
99
|
+
val = get_nested_value(data, field)
|
|
100
|
+
return val not in ("", None)
|
|
101
|
+
|
|
102
|
+
# == null / != null
|
|
103
|
+
null_eq = re.match(r"(.+)==\s*null", condition, re.IGNORECASE)
|
|
104
|
+
if null_eq:
|
|
105
|
+
field = null_eq.group(1).strip()
|
|
106
|
+
val = get_nested_value(data, field)
|
|
107
|
+
return val is None
|
|
108
|
+
|
|
109
|
+
null_neq = re.match(r"(.+)!=( )?null", condition, re.IGNORECASE)
|
|
110
|
+
if null_neq:
|
|
111
|
+
field = null_neq.group(1).strip()
|
|
112
|
+
val = get_nested_value(data, field)
|
|
113
|
+
return val is not None
|
|
114
|
+
|
|
115
|
+
# Direct truthy check
|
|
116
|
+
val = get_nested_value(data, condition)
|
|
117
|
+
return bool(val)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def process_block(text: str, data: Dict[str, Any]) -> str:
|
|
121
|
+
"""
|
|
122
|
+
Process conditional blocks {{?condition}}...{{/}}.
|
|
123
|
+
Removes blocks when condition is False; strips tags when True.
|
|
124
|
+
"""
|
|
125
|
+
start_pat = r"\{\{\?([^}]+)\}\}"
|
|
126
|
+
end_pat = r"\{\{/\}\}"
|
|
127
|
+
|
|
128
|
+
def find_blocks(s: str):
|
|
129
|
+
starts = list(re.finditer(start_pat, s))
|
|
130
|
+
ends = list(re.finditer(end_pat, s))
|
|
131
|
+
return starts, ends
|
|
132
|
+
|
|
133
|
+
result = text
|
|
134
|
+
while True:
|
|
135
|
+
starts, ends = find_blocks(result)
|
|
136
|
+
if not starts or not ends:
|
|
137
|
+
break
|
|
138
|
+
# pair first start with nearest end after it (no deep nesting handling)
|
|
139
|
+
start = starts[0]
|
|
140
|
+
end_candidates = [e for e in ends if e.start() > start.end()]
|
|
141
|
+
if not end_candidates:
|
|
142
|
+
break
|
|
143
|
+
end = end_candidates[0]
|
|
144
|
+
inner = result[start.end(): end.start()]
|
|
145
|
+
cond = start.group(1).strip()
|
|
146
|
+
if evaluate_condition(cond, data):
|
|
147
|
+
# keep inner, drop tags
|
|
148
|
+
replacement = inner
|
|
149
|
+
else:
|
|
150
|
+
replacement = ""
|
|
151
|
+
result = result[: start.start()] + replacement + result[end.end():]
|
|
152
|
+
return result
|
|
153
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Image placeholder processing: {{@var}}
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
from io import BytesIO
|
|
8
|
+
import requests
|
|
9
|
+
from docx.shared import Inches, Cm
|
|
10
|
+
from docx.text.paragraph import Paragraph
|
|
11
|
+
|
|
12
|
+
from .common import get_nested_value
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
IMAGE_PATTERN = re.compile(r"\{\{@([\s\S]+?)\}\}", re.DOTALL)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_size(value: str):
|
|
19
|
+
"""Parse size string into a python-docx length (Inches/Cm)."""
|
|
20
|
+
if not value:
|
|
21
|
+
return None
|
|
22
|
+
value = value.lower().strip()
|
|
23
|
+
if value.endswith("px"):
|
|
24
|
+
num = float(value[:-2])
|
|
25
|
+
return Inches(num / 96.0) # assume 96 DPI
|
|
26
|
+
if value.endswith("cm"):
|
|
27
|
+
num = float(value[:-2])
|
|
28
|
+
return Cm(num)
|
|
29
|
+
if value.endswith("mm"):
|
|
30
|
+
num = float(value[:-2])
|
|
31
|
+
return Cm(num / 10.0)
|
|
32
|
+
if value.endswith("in") or value.endswith("inch"):
|
|
33
|
+
num = float(value[:-2]) if value.endswith("in") else float(value[:-4])
|
|
34
|
+
return Inches(num)
|
|
35
|
+
# no unit: treat as inches
|
|
36
|
+
try:
|
|
37
|
+
num = float(value)
|
|
38
|
+
return Inches(num)
|
|
39
|
+
except Exception:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _fetch_image(url: str) -> BytesIO:
|
|
44
|
+
if url.startswith("http://") or url.startswith("https://"):
|
|
45
|
+
resp = requests.get(url, timeout=10)
|
|
46
|
+
resp.raise_for_status()
|
|
47
|
+
return BytesIO(resp.content)
|
|
48
|
+
with open(url, "rb") as f:
|
|
49
|
+
return BytesIO(f.read())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def process_paragraph_images(paragraph: Paragraph, data: Dict[str, Any]) -> None:
|
|
53
|
+
"""Replace {{@var}} with image, tolerant of line breaks across runs, and append image name."""
|
|
54
|
+
full_text = "".join(run.text or "" for run in paragraph.runs)
|
|
55
|
+
if not full_text:
|
|
56
|
+
return
|
|
57
|
+
matches = list(IMAGE_PATTERN.finditer(full_text))
|
|
58
|
+
if not matches:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
cleaned_text = full_text
|
|
62
|
+
image_configs = []
|
|
63
|
+
|
|
64
|
+
for match in matches:
|
|
65
|
+
placeholder = match.group(0)
|
|
66
|
+
raw_path = match.group(1)
|
|
67
|
+
var_path = "".join(raw_path.split())
|
|
68
|
+
cfg = get_nested_value(data, var_path)
|
|
69
|
+
image_configs.append((placeholder, cfg))
|
|
70
|
+
|
|
71
|
+
# Remove placeholders from text first (to keep layout consistent)
|
|
72
|
+
for placeholder, cfg in image_configs:
|
|
73
|
+
cleaned_text = cleaned_text.replace(placeholder, "", 1)
|
|
74
|
+
|
|
75
|
+
# Rewrite paragraph text without placeholders
|
|
76
|
+
for run in paragraph.runs:
|
|
77
|
+
run.text = ""
|
|
78
|
+
if paragraph.runs:
|
|
79
|
+
paragraph.runs[0].text = cleaned_text
|
|
80
|
+
else:
|
|
81
|
+
paragraph.add_run(cleaned_text)
|
|
82
|
+
|
|
83
|
+
# Append images (and their file names) at the end in order of appearance
|
|
84
|
+
for placeholder, cfg in image_configs:
|
|
85
|
+
if not isinstance(cfg, dict):
|
|
86
|
+
continue
|
|
87
|
+
url = cfg.get("url", "")
|
|
88
|
+
conf = cfg.get("conf", {}) or {}
|
|
89
|
+
width = _parse_size(conf.get("w", "12cm"))
|
|
90
|
+
height = _parse_size(conf.get("h", "8cm"))
|
|
91
|
+
try:
|
|
92
|
+
img_stream = _fetch_image(url)
|
|
93
|
+
pic_run = paragraph.add_run()
|
|
94
|
+
pic_run.add_picture(img_stream, width=width, height=height)
|
|
95
|
+
# append image file name after picture
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
error_run = paragraph.add_run(f"[Image Error: {exc}]")
|
|
98
|
+
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Table row loop processing.
|
|
3
|
+
"""
|
|
4
|
+
import re
|
|
5
|
+
import copy
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Dict, List
|
|
8
|
+
from docx.table import _Row, Table
|
|
9
|
+
|
|
10
|
+
from .common import get_nested_value
|
|
11
|
+
from .text_processor import render_text
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _rewrite_paragraph_text(paragraph, new_text: str) -> None:
|
|
15
|
+
"""Rewrite paragraph runs sequentially to preserve styles as much as possible."""
|
|
16
|
+
runs = paragraph.runs
|
|
17
|
+
remaining = new_text
|
|
18
|
+
for run in runs:
|
|
19
|
+
if not remaining:
|
|
20
|
+
run.text = ""
|
|
21
|
+
continue
|
|
22
|
+
run_len = len(run.text or "")
|
|
23
|
+
if run_len == 0:
|
|
24
|
+
run.text = remaining
|
|
25
|
+
remaining = ""
|
|
26
|
+
else:
|
|
27
|
+
run.text = remaining[:run_len]
|
|
28
|
+
remaining = remaining[run_len:]
|
|
29
|
+
if remaining and runs:
|
|
30
|
+
# append to last run
|
|
31
|
+
runs[-1].text = (runs[-1].text or "") + remaining
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _strip_loop_marker_in_cell(cell) -> None:
|
|
35
|
+
"""Remove {{#...}} marker from a cell while keeping existing run styles (handles split runs)."""
|
|
36
|
+
pattern = re.compile(r"\{\{#([^}]+)\}\}")
|
|
37
|
+
for p in cell.paragraphs:
|
|
38
|
+
full_text = "".join(r.text or "" for r in p.runs)
|
|
39
|
+
new_text = pattern.sub("", full_text)
|
|
40
|
+
if new_text != full_text:
|
|
41
|
+
_rewrite_paragraph_text(p, new_text)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _clone_row(table: Table, row_idx: int):
|
|
45
|
+
"""Clone a row preserving styling."""
|
|
46
|
+
template_row = table.rows[row_idx]
|
|
47
|
+
new_row = template_row._element.clone()
|
|
48
|
+
table._tbl.insert(row_idx + 1, new_row)
|
|
49
|
+
return table.rows[row_idx + 1]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def process_table(table: Table, data: Dict[str, Any]) -> None:
|
|
53
|
+
"""
|
|
54
|
+
表格行循环处理:
|
|
55
|
+
1. 表头:{{#mydata}} - 遍历 input_data['mydata']
|
|
56
|
+
- 如果 mydata 是 list: input_data['mydata'] = [{...}, {...}]
|
|
57
|
+
- 如果 mydata 是 dict: input_data['mydata'] = {'data1': [...], 'data2': [...]}
|
|
58
|
+
|
|
59
|
+
2. 表体行:
|
|
60
|
+
- 如果第一个单元格有 {{+data1}}[name],第二列是 [age],找到这行中的 {{/data1}}
|
|
61
|
+
数据来源:input_data['mydata']['data1'] = [{name:1, age:2}, ...]
|
|
62
|
+
- 如果第一个单元格没有 {{+}},数据来源:input_data['mydata'] = [{name:1, age:2}, ...]
|
|
63
|
+
"""
|
|
64
|
+
rows = list(table.rows)
|
|
65
|
+
if not rows:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
# 1) 解析表头 {{#mydata}}
|
|
69
|
+
loop_match = None
|
|
70
|
+
for cell in rows[0].cells:
|
|
71
|
+
m = re.search(r"\{\{#([^}]+)\}\}", cell.text)
|
|
72
|
+
if m:
|
|
73
|
+
loop_match = m
|
|
74
|
+
break
|
|
75
|
+
if not loop_match:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
field_path = loop_match.group(1).strip()
|
|
79
|
+
main_obj = get_nested_value(data, field_path)
|
|
80
|
+
|
|
81
|
+
# 去掉表头标记
|
|
82
|
+
for cell in rows[0].cells:
|
|
83
|
+
_strip_loop_marker_in_cell(cell)
|
|
84
|
+
|
|
85
|
+
# 2) 处理表体行:扫描每一行,识别是否有 {{+xxx}}...{{/xxx}}
|
|
86
|
+
nested_start_pat = re.compile(r"\{\{\+([^}]+)\}\}")
|
|
87
|
+
nested_end_pat = re.compile(r"\{\{/([^}]+)\}\}")
|
|
88
|
+
|
|
89
|
+
# 保存每行的模板和嵌套字段信息
|
|
90
|
+
body_templates: List[tuple[Any, str | None]] = []
|
|
91
|
+
for i in range(1, len(rows)):
|
|
92
|
+
row = rows[i]
|
|
93
|
+
first_cell_text = row.cells[0].text if row.cells else ""
|
|
94
|
+
nested_match = nested_start_pat.search(first_cell_text)
|
|
95
|
+
nested_field = nested_match.group(1).strip() if nested_match else None
|
|
96
|
+
body_templates.append((copy.deepcopy(row._element), nested_field))
|
|
97
|
+
|
|
98
|
+
# 3) 删除所有表体行
|
|
99
|
+
while len(table.rows) > 1:
|
|
100
|
+
table._tbl.remove(table.rows[1]._tr)
|
|
101
|
+
|
|
102
|
+
# 4) 处理 main_obj:可能是 list 或 dict
|
|
103
|
+
if isinstance(main_obj, dict):
|
|
104
|
+
# detectionResult 这种情况:{'data1': [...], 'data2': [...]}
|
|
105
|
+
# 遍历每一行模板,根据 {{+xxx}} 从 main_obj 中获取对应的数据列表
|
|
106
|
+
for template_el, nested_field in body_templates:
|
|
107
|
+
if nested_field:
|
|
108
|
+
# 这行有 {{+nested_field}},从 main_obj[nested_field] 获取数据
|
|
109
|
+
nested_items = main_obj.get(nested_field)
|
|
110
|
+
if not isinstance(nested_items, list):
|
|
111
|
+
nested_items = []
|
|
112
|
+
|
|
113
|
+
for nested_item in nested_items:
|
|
114
|
+
new_el = copy.deepcopy(template_el)
|
|
115
|
+
table._tbl.append(new_el)
|
|
116
|
+
new_row = table.rows[-1]
|
|
117
|
+
|
|
118
|
+
for cell in new_row.cells:
|
|
119
|
+
cell_text = cell.text
|
|
120
|
+
# 移除 {{+nested_field}} 和 {{/nested_field}} 标记
|
|
121
|
+
escaped = re.escape(nested_field)
|
|
122
|
+
cell_text = re.sub(rf"{{{{\+{escaped}}}}}", "", cell_text)
|
|
123
|
+
cell_text = re.sub(rf"{{{{/{escaped}}}}}", "", cell_text)
|
|
124
|
+
|
|
125
|
+
# 替换 [field] 占位符
|
|
126
|
+
if isinstance(nested_item, dict):
|
|
127
|
+
for k, v in nested_item.items():
|
|
128
|
+
placeholder = f"[{k}]"
|
|
129
|
+
cell_text = cell_text.replace(placeholder, "" if v is None else str(v))
|
|
130
|
+
|
|
131
|
+
cell_text = render_text(cell_text, data)
|
|
132
|
+
|
|
133
|
+
# 更新单元格文本
|
|
134
|
+
for p in cell.paragraphs:
|
|
135
|
+
for r in p.runs:
|
|
136
|
+
r.text = ""
|
|
137
|
+
if p.runs:
|
|
138
|
+
p.runs[0].text = cell_text
|
|
139
|
+
else:
|
|
140
|
+
p.add_run(cell_text)
|
|
141
|
+
else:
|
|
142
|
+
# 没有 {{+}} 的行,跳过(因为 main_obj 是 dict,没有直接数据)
|
|
143
|
+
pass
|
|
144
|
+
elif isinstance(main_obj, list):
|
|
145
|
+
# 普通 list 情况:input_data['mydata'] = [{...}, {...}]
|
|
146
|
+
for main_item in main_obj:
|
|
147
|
+
for template_el, nested_field in body_templates:
|
|
148
|
+
if nested_field:
|
|
149
|
+
# 嵌套循环:从 main_item[nested_field] 获取数据
|
|
150
|
+
nested_items = main_item.get(nested_field) if isinstance(main_item, dict) else None
|
|
151
|
+
if not isinstance(nested_items, list):
|
|
152
|
+
nested_items = []
|
|
153
|
+
|
|
154
|
+
for nested_item in nested_items:
|
|
155
|
+
new_el = copy.deepcopy(template_el)
|
|
156
|
+
table._tbl.append(new_el)
|
|
157
|
+
new_row = table.rows[-1]
|
|
158
|
+
|
|
159
|
+
for cell in new_row.cells:
|
|
160
|
+
cell_text = cell.text
|
|
161
|
+
# 移除 {{+nested_field}} 和 {{/nested_field}} 标记
|
|
162
|
+
escaped = re.escape(nested_field)
|
|
163
|
+
cell_text = re.sub(rf"{{{{\+{escaped}}}}}", "", cell_text)
|
|
164
|
+
cell_text = re.sub(rf"{{{{/{escaped}}}}}", "", cell_text)
|
|
165
|
+
|
|
166
|
+
# 替换 [field] 占位符(优先使用 nested_item)
|
|
167
|
+
if isinstance(nested_item, dict):
|
|
168
|
+
for k, v in nested_item.items():
|
|
169
|
+
placeholder = f"[{k}]"
|
|
170
|
+
cell_text = cell_text.replace(placeholder, "" if v is None else str(v))
|
|
171
|
+
|
|
172
|
+
# 如果 nested_item 中没有,尝试从 main_item 获取
|
|
173
|
+
if isinstance(main_item, dict):
|
|
174
|
+
for k, v in main_item.items():
|
|
175
|
+
placeholder = f"[{k}]"
|
|
176
|
+
if placeholder in cell_text:
|
|
177
|
+
cell_text = cell_text.replace(placeholder, "" if v is None else str(v))
|
|
178
|
+
|
|
179
|
+
cell_text = render_text(cell_text, data)
|
|
180
|
+
|
|
181
|
+
# 更新单元格文本
|
|
182
|
+
for p in cell.paragraphs:
|
|
183
|
+
for r in p.runs:
|
|
184
|
+
r.text = ""
|
|
185
|
+
if p.runs:
|
|
186
|
+
p.runs[0].text = cell_text
|
|
187
|
+
else:
|
|
188
|
+
p.add_run(cell_text)
|
|
189
|
+
else:
|
|
190
|
+
# 没有 {{+}} 的行,直接使用 main_item
|
|
191
|
+
new_el = copy.deepcopy(template_el)
|
|
192
|
+
table._tbl.append(new_el)
|
|
193
|
+
new_row = table.rows[-1]
|
|
194
|
+
|
|
195
|
+
for cell in new_row.cells:
|
|
196
|
+
cell_text = cell.text
|
|
197
|
+
|
|
198
|
+
# 替换 [field] 占位符
|
|
199
|
+
if isinstance(main_item, dict):
|
|
200
|
+
for k, v in main_item.items():
|
|
201
|
+
placeholder = f"[{k}]"
|
|
202
|
+
cell_text = cell_text.replace(placeholder, "" if v is None else str(v))
|
|
203
|
+
|
|
204
|
+
cell_text = render_text(cell_text, data)
|
|
205
|
+
|
|
206
|
+
# 更新单元格文本
|
|
207
|
+
for p in cell.paragraphs:
|
|
208
|
+
for r in p.runs:
|
|
209
|
+
r.text = ""
|
|
210
|
+
if p.runs:
|
|
211
|
+
p.runs[0].text = cell_text
|
|
212
|
+
else:
|
|
213
|
+
p.add_run(cell_text)
|
|
214
|
+
else:
|
|
215
|
+
# main_obj 既不是 list 也不是 dict,删除所有表体行
|
|
216
|
+
pass
|
|
217
|
+
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Text placeholder processing.
|
|
3
|
+
"""
|
|
4
|
+
import re
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from typing import Any, Dict, List
|
|
7
|
+
from docx.text.paragraph import Paragraph
|
|
8
|
+
|
|
9
|
+
from .common import get_nested_value, process_block, evaluate_condition
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
PLACEHOLDER_PATTERN = re.compile(r"\{\{([^@#?/$*}]+)\}\}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _rewrite_paragraph_text(paragraph: Paragraph, new_text: str) -> None:
|
|
16
|
+
"""Rewrite text into existing runs to preserve styles as much as possible."""
|
|
17
|
+
runs = paragraph.runs
|
|
18
|
+
remaining = new_text
|
|
19
|
+
for run in runs:
|
|
20
|
+
orig_len = len(run.text or "")
|
|
21
|
+
if not remaining:
|
|
22
|
+
run.text = ""
|
|
23
|
+
continue
|
|
24
|
+
if orig_len > 0:
|
|
25
|
+
run.text = remaining[:orig_len]
|
|
26
|
+
remaining = remaining[orig_len:]
|
|
27
|
+
else:
|
|
28
|
+
run.text = ""
|
|
29
|
+
if remaining:
|
|
30
|
+
# append using last run's style if available
|
|
31
|
+
base = runs[-1] if runs else paragraph.add_run()
|
|
32
|
+
new_run = paragraph.add_run(remaining)
|
|
33
|
+
if runs:
|
|
34
|
+
new_run.style = base.style
|
|
35
|
+
new_run.bold = base.bold
|
|
36
|
+
new_run.italic = base.italic
|
|
37
|
+
new_run.underline = base.underline
|
|
38
|
+
new_run.font.size = base.font.size
|
|
39
|
+
new_run.font.color.rgb = base.font.color.rgb
|
|
40
|
+
new_run.font.name = base.font.name
|
|
41
|
+
# ensure no None texts
|
|
42
|
+
for run in runs:
|
|
43
|
+
if run.text is None:
|
|
44
|
+
run.text = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _paragraph_text(paragraph: Paragraph) -> str:
|
|
48
|
+
return "".join(run.text or "" for run in paragraph.runs)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _replace_bracket_placeholders(text: str, item: Any) -> str:
|
|
52
|
+
if isinstance(item, dict):
|
|
53
|
+
for k, v in item.items():
|
|
54
|
+
text = text.replace(f"[{k}]", "" if v is None else str(v))
|
|
55
|
+
else:
|
|
56
|
+
text = re.sub(r"\[text\]", "" if item is None else str(item), text)
|
|
57
|
+
return text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def process_paragraph_loops(paragraphs: List[Paragraph], data: Dict[str, Any]) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Handle non-table loops:
|
|
63
|
+
{{#data.list}}
|
|
64
|
+
[field] ...
|
|
65
|
+
If marker paragraph has no [ ], use the next paragraph with [ ] as template.
|
|
66
|
+
"""
|
|
67
|
+
loop_pat = re.compile(r"\{\{#([^}]+)\}\}")
|
|
68
|
+
bracket_pat = re.compile(r"\[[^\]]+\]")
|
|
69
|
+
idx = 0
|
|
70
|
+
while idx < len(paragraphs):
|
|
71
|
+
para = paragraphs[idx]
|
|
72
|
+
full_text = _paragraph_text(para)
|
|
73
|
+
m = loop_pat.search(full_text)
|
|
74
|
+
if not m:
|
|
75
|
+
idx += 1
|
|
76
|
+
continue
|
|
77
|
+
field_path = m.group(1).strip()
|
|
78
|
+
items = get_nested_value(data, field_path)
|
|
79
|
+
parent = para._element.getparent()
|
|
80
|
+
|
|
81
|
+
# choose template: marker line if it contains [ ], else next line with [ ]
|
|
82
|
+
template_el = para._element
|
|
83
|
+
template_idx = idx
|
|
84
|
+
if not bracket_pat.search(full_text):
|
|
85
|
+
look = idx + 1
|
|
86
|
+
while look < len(paragraphs):
|
|
87
|
+
cand_text = _paragraph_text(paragraphs[look])
|
|
88
|
+
if bracket_pat.search(cand_text):
|
|
89
|
+
template_el = paragraphs[look]._element
|
|
90
|
+
template_idx = look
|
|
91
|
+
break
|
|
92
|
+
if loop_pat.search(cand_text):
|
|
93
|
+
break
|
|
94
|
+
look += 1
|
|
95
|
+
|
|
96
|
+
# remove marker paragraph
|
|
97
|
+
parent.remove(para._element)
|
|
98
|
+
paragraphs.pop(idx)
|
|
99
|
+
|
|
100
|
+
# if template is different and still present, remove it too (after marker removal, index shifts)
|
|
101
|
+
if template_idx != idx and template_idx < len(paragraphs) + 1:
|
|
102
|
+
parent.remove(template_el)
|
|
103
|
+
paragraphs.pop(idx)
|
|
104
|
+
|
|
105
|
+
if not isinstance(items, list) or len(items) == 0:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
new_paras: List[Paragraph] = []
|
|
109
|
+
insert_pos = idx
|
|
110
|
+
for item in items:
|
|
111
|
+
new_el = deepcopy(template_el)
|
|
112
|
+
parent.insert(insert_pos, new_el)
|
|
113
|
+
new_para = Paragraph(new_el, para._parent)
|
|
114
|
+
new_text = loop_pat.sub("", _paragraph_text(new_para))
|
|
115
|
+
new_text = _replace_bracket_placeholders(new_text, item)
|
|
116
|
+
_rewrite_paragraph_text(new_para, new_text)
|
|
117
|
+
new_paras.append(new_para)
|
|
118
|
+
insert_pos += 1
|
|
119
|
+
|
|
120
|
+
paragraphs[idx:idx] = new_paras
|
|
121
|
+
idx += len(new_paras)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def process_paragraph_blocks(paragraphs: list[Paragraph], data: Dict[str, Any]) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Handle block tags that may span multiple paragraphs:
|
|
127
|
+
{{?cond}}
|
|
128
|
+
...
|
|
129
|
+
{{/}}
|
|
130
|
+
If condition false, clear text in range; if true, remove only the tags.
|
|
131
|
+
"""
|
|
132
|
+
start_pat = re.compile(r"\{\{\?([^}]+)\}\}")
|
|
133
|
+
end_pat = re.compile(r"\{\{/\}\}")
|
|
134
|
+
|
|
135
|
+
i = 0
|
|
136
|
+
while i < len(paragraphs):
|
|
137
|
+
text = _paragraph_text(paragraphs[i])
|
|
138
|
+
start_match = start_pat.search(text)
|
|
139
|
+
if not start_match:
|
|
140
|
+
i += 1
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
cond = start_match.group(1).strip()
|
|
144
|
+
depth = 1
|
|
145
|
+
j = i + 1
|
|
146
|
+
end_idx = None
|
|
147
|
+
while j < len(paragraphs):
|
|
148
|
+
t = _paragraph_text(paragraphs[j])
|
|
149
|
+
if start_pat.search(t):
|
|
150
|
+
depth += 1
|
|
151
|
+
if end_pat.search(t):
|
|
152
|
+
depth -= 1
|
|
153
|
+
if depth == 0:
|
|
154
|
+
end_idx = j
|
|
155
|
+
break
|
|
156
|
+
j += 1
|
|
157
|
+
|
|
158
|
+
if end_idx is None:
|
|
159
|
+
# no matching end; skip
|
|
160
|
+
i += 1
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if evaluate_condition(cond, data):
|
|
164
|
+
# condition true: remove tags; if tag-only paragraphs, delete them
|
|
165
|
+
new_start = start_pat.sub("", text)
|
|
166
|
+
new_end = end_pat.sub("", _paragraph_text(paragraphs[end_idx]))
|
|
167
|
+
|
|
168
|
+
to_delete = []
|
|
169
|
+
if new_start.strip() == "":
|
|
170
|
+
to_delete.append(i)
|
|
171
|
+
else:
|
|
172
|
+
_rewrite_paragraph_text(paragraphs[i], new_start)
|
|
173
|
+
|
|
174
|
+
if new_end.strip() == "":
|
|
175
|
+
to_delete.append(end_idx)
|
|
176
|
+
elif end_idx not in to_delete:
|
|
177
|
+
_rewrite_paragraph_text(paragraphs[end_idx], new_end)
|
|
178
|
+
|
|
179
|
+
# delete paragraphs marked (from end to start to keep indices valid)
|
|
180
|
+
for idx_del in sorted(to_delete, reverse=True):
|
|
181
|
+
p_el = paragraphs[idx_del]._element
|
|
182
|
+
p_el.getparent().remove(p_el)
|
|
183
|
+
paragraphs.pop(idx_del)
|
|
184
|
+
|
|
185
|
+
# adjust end_idx if paragraphs were removed
|
|
186
|
+
i = max(i, 0)
|
|
187
|
+
else:
|
|
188
|
+
# condition false: remove entire block paragraphs
|
|
189
|
+
for k in range(end_idx, i - 1, -1):
|
|
190
|
+
p_el = paragraphs[k]._element
|
|
191
|
+
p_el.getparent().remove(p_el)
|
|
192
|
+
paragraphs.pop(k)
|
|
193
|
+
# i remains the same position (now next paragraph)
|
|
194
|
+
# continue from current index
|
|
195
|
+
i = max(i, 0)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def render_text(text: str, data: Dict[str, Any]) -> str:
|
|
199
|
+
"""Process blocks then replace {{path}} placeholders."""
|
|
200
|
+
text = process_block(text, data)
|
|
201
|
+
|
|
202
|
+
def repl(match: re.Match):
|
|
203
|
+
path = match.group(1).strip()
|
|
204
|
+
val = get_nested_value(data, path)
|
|
205
|
+
return "" if val is None else str(val)
|
|
206
|
+
|
|
207
|
+
return PLACEHOLDER_PATTERN.sub(repl, text)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def process_paragraph(paragraph: Paragraph, data: Dict[str, Any]) -> None:
|
|
211
|
+
"""Render placeholders in a paragraph while preserving run styles."""
|
|
212
|
+
full_text = "".join(run.text for run in paragraph.runs)
|
|
213
|
+
rendered = render_text(full_text, data)
|
|
214
|
+
|
|
215
|
+
_rewrite_paragraph_text(paragraph, rendered)
|
|
216
|
+
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Word template engine implemented with python-docx + lxml helpers.
|
|
3
|
+
Features:
|
|
4
|
+
- Text placeholders {{data.field}}
|
|
5
|
+
- Table row loops {{#data.list}} with [field] inside row
|
|
6
|
+
- Conditional blocks {{?cond}}...{{/}}
|
|
7
|
+
- Image placeholders {{@var}}
|
|
8
|
+
"""
|
|
9
|
+
import tempfile
|
|
10
|
+
import shutil
|
|
11
|
+
from typing import Any, Dict
|
|
12
|
+
from docx import Document
|
|
13
|
+
|
|
14
|
+
from utils.text_processor import (
|
|
15
|
+
process_paragraph,
|
|
16
|
+
process_paragraph_blocks,
|
|
17
|
+
process_paragraph_loops,
|
|
18
|
+
)
|
|
19
|
+
from utils.table_processor import process_table
|
|
20
|
+
from utils.image_processor import process_paragraph_images
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WordTemplateEngine:
|
|
24
|
+
def __init__(self, template_path: str):
|
|
25
|
+
self.template_path = template_path
|
|
26
|
+
|
|
27
|
+
def render(self, data: Dict[str, Any], output_path: str) -> None:
|
|
28
|
+
# work in temp copy to avoid mutating original
|
|
29
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".docx") as tmp:
|
|
30
|
+
shutil.copyfile(self.template_path, tmp.name)
|
|
31
|
+
tmp_path = tmp.name
|
|
32
|
+
|
|
33
|
+
doc = Document(tmp_path)
|
|
34
|
+
|
|
35
|
+
# process tables (row loops + inner placeholders)
|
|
36
|
+
for table in doc.tables:
|
|
37
|
+
process_table(table, data)
|
|
38
|
+
for row in table.rows:
|
|
39
|
+
for cell in row.cells:
|
|
40
|
+
# handle paragraph-level loops and blocks in cells
|
|
41
|
+
process_paragraph_loops(cell.paragraphs, data)
|
|
42
|
+
process_paragraph_blocks(cell.paragraphs, data)
|
|
43
|
+
for para in cell.paragraphs:
|
|
44
|
+
process_paragraph(para, data)
|
|
45
|
+
process_paragraph_images(para, data)
|
|
46
|
+
|
|
47
|
+
# process body paragraphs: loops -> blocks -> placeholders/images
|
|
48
|
+
process_paragraph_loops(doc.paragraphs, data)
|
|
49
|
+
process_paragraph_blocks(doc.paragraphs, data)
|
|
50
|
+
for para in doc.paragraphs:
|
|
51
|
+
process_paragraph(para, data)
|
|
52
|
+
process_paragraph_images(para, data)
|
|
53
|
+
|
|
54
|
+
doc.save(output_path)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main():
|
|
58
|
+
sample_data = {
|
|
59
|
+
"data": {
|
|
60
|
+
"diagnose": "测试诊断结果",
|
|
61
|
+
"mutationSiteZero": [
|
|
62
|
+
{"gene_transcript": "BRCA1", "value": "c.1234A>G"},
|
|
63
|
+
{"gene_transcript": "TP53", "value": "c.5678C>T"},
|
|
64
|
+
],
|
|
65
|
+
"conclusion_A": "",
|
|
66
|
+
"image_config": {
|
|
67
|
+
"url": "123.png",
|
|
68
|
+
|
|
69
|
+
},
|
|
70
|
+
"detectionResult":{
|
|
71
|
+
'data1':[
|
|
72
|
+
{
|
|
73
|
+
"gene":"gene1",
|
|
74
|
+
'rate':'rate1',
|
|
75
|
+
'age':'age1',
|
|
76
|
+
'symptoms':'symptoms1'
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"gene":"gene2",
|
|
80
|
+
'rate':'rate1',
|
|
81
|
+
'age':'age2',
|
|
82
|
+
'symptoms':'symptoms1'
|
|
83
|
+
}
|
|
84
|
+
],'data2':[
|
|
85
|
+
{
|
|
86
|
+
"gene":"gene3",
|
|
87
|
+
'rate':'rate3',
|
|
88
|
+
'age':'age3',
|
|
89
|
+
'symptoms':'symptoms3'
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
engine = WordTemplateEngine("template.docx")
|
|
96
|
+
engine.render(sample_data, "output.docx")
|
|
97
|
+
print("done")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
main()
|
|
102
|
+
|