win32com在WPS中做填空题
场景
WPS 文档中有大量模板填空项,比如日期行、供应商信息、金额等:
(供应商全称)参加贵方组织的采购编号为
号采购项目活动。按磋商文件规定,已递交人民币 元的磋商保证金。
法定代表人: (签字或签章)
日 期: 年 月 日
需要自动识别空格区域,按规则填写内容。
核心原则
- 填空区统一保留6个空格(多了删,少了补)
- 在第3个空格位置插入内容(即前3空格 + 内容 + 后3空格)
效果示意:
操作前: 法定代表人: (签字或签章)
操作后: 法定代表人: 王帅 (签字或签章)
前置条件
1. Python 环境
需要 Python 3.11 + pywin32:
# 检查 pywin32 是否安装
"C:\Users\Administrator\AppData\Local\Programs\Python\Python311\python.exe" -c "import win32com"
如果报错,安装:
"C:\Users\Administrator\AppData\Local\Programs\Python\Python311\python.exe" -m pip install pywin32
2. COM 调用方式(取决于电脑环境)
先试直连:大部分情况下可以直连 COM,无需桥接:
$env:PYTHONIOENCODING='utf-8'; python your_script.py
桥接兜底:如果直连报 (-2147221005, '无效的类字符串'),说明当前进程 COM 上下文不完整,才需要通过 explorer.exe 桥接。详见 win32com读取WPS指定段落 第3节。
桥接文件 D:\temp\wps_bridge.bat(仅在直连失败时用):
@echo off
chcp 65001 >nul
set PYTHONIOENCODING=utf-8
"C:\Users\Administrator\AppData\Local\Programs\Python\Python311\python.exe" D:\temp\wps_fill.py > D:\temp\wps_output.txt 2>&1
3. COM 连接验证
确认 WPS 的 COM ProgID 是 kwps.Application:
import win32com.client as wc
app = wc.Dispatch("kwps.Application")
print(app.ActiveDocument.Name) # 打印当前文档名
通用填空脚本
以下脚本保存为 D:\temp\wps_fill.py,配合桥接 bat 使用,无需每次改代码,只需修改参数文件。
wps_fill.py
"""
WPS 填空脚本 - 通用版
从 D:\temp\wps_args.json 读取参数, 结果输出到 D:\temp\wps_output.txt
wps_args.json 格式:
{
"paragraph": 11,
"fills": [
{"space_index": 1, "content": "2026"},
{"space_index": 2, "content": "7"},
{"space_index": 3, "content": "4"}
]
}
space_index: 段落中第几个空格段(从1开始), 按出现顺序编号
可选参数:
keep_spaces: 保留空格数, 默认6
insert_at: 第几个空格后插入, 默认3
"""
import json
import re
import win32com.client as wc
def fill_paragraph(doc, para_index, fill_data):
p = doc.Paragraphs(para_index)
rng = p.Range
clean = rng.Text.rstrip("\r\n\x0d\x0a\x07")
print(f"段落{para_index} 操作前: {clean}")
start = rng.Start
matches = list(re.finditer(r' +', clean))
print(f"找到 {len(matches)} 个空格段:")
for i, m in enumerate(matches):
print(f" [{i+1}] 位置{m.start()}-{m.end()-1}, {len(m.group())}空格")
# 按 space_index 匹配填空区
fill_matches = []
for fd in fill_data:
idx = fd['space_index'] - 1 # 转为0-based
if 0 <= idx < len(matches):
fill_matches.append((matches[idx], fd))
else:
print(f" 警告: space_index {fd['space_index']} 超出范围")
if not fill_matches:
print("未找到匹配的填空区")
return
print(f"\n将处理 {len(fill_matches)} 个填空区")
# 从后往前处理
for m, fd in reversed(fill_matches):
content = fd['content']
keep = fd.get('keep_spaces', 6)
insert_at = fd.get('insert_at', 3)
space_count = len(m.group())
print(f"\n处理第{fd['space_index']}个空格段: {space_count}空格, 保留{keep}空格, 第{insert_at}空格后插入'{content}'")
# 调整空格数
if space_count < keep:
diff = keep - space_count
pos = start + m.start()
for _ in range(diff):
doc.Range(pos, pos).InsertAfter(" ")
print(f" 补了{diff}个空格")
elif space_count > keep:
diff = space_count - keep
doc.Range(start + m.start(), start + m.start() + diff).Delete()
print(f" 删了{diff}个空格")
else:
print(f" 空格数刚好{keep}个")
# 重新读取
p = doc.Paragraphs(para_index)
rng = p.Range
start = rng.Start
clean2 = rng.Text.rstrip("\r\n\x0d\x0a\x07")
matches2 = list(re.finditer(r' +', clean2))
# 用 space_index 重新定位
idx = fd['space_index'] - 1
if 0 <= idx < len(matches2):
m2 = matches2[idx]
insert_pos = start + m2.start() + insert_at
doc.Range(insert_pos, insert_pos).InsertAfter(content)
print(f" 插入'{content}'")
p3 = doc.Paragraphs(para_index)
result = p3.Range.Text.rstrip("\r\n\x0d\x0a\x07")
print(f"\n最终结果: {result}")
if __name__ == '__main__':
with open(r'D:\temp\wps_args.json', 'r', encoding='utf-8') as f:
args = json.load(f)
app = wc.Dispatch("kwps.Application")
doc = app.ActiveDocument
fill_paragraph(doc, args['paragraph'], args['fills'])
参数说明
wps_args.json 格式
{
"paragraph": 10,
"fills": [
{"space_index": 1, "content": "王帅"},
{"space_index": 2, "content": "123", "keep_spaces": 8, "insert_at": 4}
]
}
| 参数 | 必填 | 默认值 | 说明 |
|---|---|---|---|
paragraph |
是 | - | 段落号(从1开始) |
fills |
是 | - | 填空数组 |
fills[].space_index |
是 | - | 第几个空格段(从1开始,按出现顺序) |
fills[].content |
是 | - | 要填入的内容 |
fills[].keep_spaces |
否 | 6 | 填空区保留的空格总数 |
fills[].insert_at |
否 | 3 | 第几个空格后插入内容 |
如何确定 space_index
先用一段简单的脚本查看段落结构和空格段编号:
import win32com.client as wc
import re
app = wc.Dispatch("kwps.Application")
doc = app.ActiveDocument
p = doc.Paragraphs(6) # 改成你要看的段落号
rng = p.Range
clean = rng.Text.rstrip("\r\n\x0d\x0a\x07")
matches = list(re.finditer(r' +', clean))
print(f"段落内容: {repr(clean)}")
for i, m in enumerate(matches):
before = clean[m.start()-1] if m.start() > 0 else ''
after = clean[m.end()] if m.end() < len(clean) else ''
print(f" 空格段[{i+1}] 位置{m.start()}-{m.end()-1}, {len(m.group())}空格, 前='{before}' 后='{after}'")
输出示例:
段落内容: ' (供应商全称)参加贵方组织的采购编号为 '
空格段[1] 位置0-11, 12空格, 前='' 后='('
空格段[2] 位置31-42, 12空格, 前='为' 后=''
space_index 就是方括号里的编号。空格段[1]填公司名,空格段[2]填编号。
完整使用流程
第一步:查看段落结构
先试直连:
$env:PYTHONIOENCODING='utf-8'; python wps_task.py
直连失败才走桥接——写 wps_task.py(临时探查脚本):
# D:\temp\wps_task.py
import win32com.client as wc
import re
app = wc.Dispatch("kwps.Application")
doc = app.ActiveDocument
for pi in [6, 7, 10, 11]:
p = doc.Paragraphs(pi)
rng = p.Range
clean = rng.Text.rstrip("\r\n\x0d\x0a\x07")
matches = list(re.finditer(r' +', clean))
print(f"\n段落{pi}: {repr(clean)}")
for i, m in enumerate(matches):
before = clean[m.start()-1] if m.start() > 0 else ''
after = clean[m.end()] if m.end() < len(clean) else ''
print(f" 空格段[{i+1}] 位置{m.start()}-{m.end()-1}, {len(m.group())}空格, 前='{before}' 后='{after}'")
桥接时,wps_bridge.bat 临时改为跑 wps_task.py:
@echo off
chcp 65001 >nul
set PYTHONIOENCODING=utf-8
"C:\Users\Administrator\AppData\Local\Programs\Python\Python311\python.exe" D:\temp\wps_task.py > D:\temp\wps_output.txt 2>&1
explorer.exe "D:\temp\wps_bridge.bat"
Start-Sleep -Seconds 3; Get-Content "D:\temp\wps_output.txt" -Encoding UTF8
第二步:写参数文件
根据探查结果,写 D:\temp\wps_args.json:
{
"paragraph": 6,
"fills": [
{"space_index": 1, "content": "测试公司"},
{"space_index": 2, "content": "123"}
]
}
第三步:执行填空
先试直连:
$env:PYTHONIOENCODING='utf-8'; python D:\temp\wps_fill.py
直连失败才走桥接——把 wps_bridge.bat 切回跑 wps_fill.py:
@echo off
chcp 65001 >nul
set PYTHONIOENCODING=utf-8
"C:\Users\Administrator\AppData\Local\Programs\Python\Python311\python.exe" D:\temp\wps_fill.py > D:\temp\wps_output.txt 2>&1
通过 explorer 启动:
explorer.exe "D:\temp\wps_bridge.bat"
Start-Sleep -Seconds 3; Get-Content "D:\temp\wps_output.txt" -Encoding UTF8
第四步:验证结果
输出文件会显示操作前后的对比:
段落6 操作前: (供应商全称)参加贵方组织的采购编号为
找到 2 个空格段:
[1] 位置0-11, 12空格
[2] 位置31-42, 12空格
将处理 2 个填空区
处理第2个空格段: 12空格, 保留6空格, 第3空格后插入'123'
删了6个空格
插入'123'
处理第1个空格段: 12空格, 保留6空格, 第3空格后插入'测试公司'
删了6个空格
插入'测试公司'
最终结果: 测试公司 (供应商全称)参加贵方组织的采购编号为 123
实战案例
案例1:填写日期(第11段)
段落结构:
日 期: 年 月 日
空格段[1] 4空格, 日-期之间(非填空区)
空格段[2] 6空格, 期:-年之间(填年份)
空格段[3] 5空格, 年-月之间(填月份)
空格段[4] 5空格, 月-日之间(填日)
参数文件:
{
"paragraph": 11,
"fills": [
{"space_index": 2, "content": "2026"},
{"space_index": 3, "content": "7"},
{"space_index": 4, "content": "5"}
]
}
结果:
操作前: 日 期: 年 月 日
操作后: 日 期: 2026 年 7 月 5 日
案例2:填写供应商信息(第6段)
段落结构:
(供应商全称)参加贵方组织的采购编号为
空格段[1] 12空格, 填公司名
空格段[2] 12空格, 填编号
参数文件:
{
"paragraph": 6,
"fills": [
{"space_index": 1, "content": "测试公司"},
{"space_index": 2, "content": "123"}
]
}
结果:
操作前: (供应商全称)参加贵方组织的采购编号为
操作后: 测试公司 (供应商全称)参加贵方组织的采购编号为 123
案例3:填写金额(第7段)
段落结构:
号采购项目活动。按磋商文件规定,已递交人民币 元的磋商保证金。
空格段[1] 13空格, 币-元之间(填金额)
参数文件:
{
"paragraph": 7,
"fills": [
{"space_index": 1, "content": "789456.12"}
]
}
结果:
操作前: 号采购项目活动。按磋商文件规定,已递交人民币 元的磋商保证金。
操作后: 号采购项目活动。按磋商文件规定,已递交人民币 789456.12 元的磋商保证金。
案例4:填写法定代表人(第10段)
段落结构:
法定代表人: (签字或签章)
空格段[1] 10空格, :-(之间(填人名)
参数文件:
{
"paragraph": 10,
"fills": [
{"space_index": 1, "content": "王"}
]
}
结果:
操作前: 法定代表人: (签字或签章)
操作后: 法定代表人: 王 (签字或签章)
关键设计点
1. 插入法而非替换法
替换法(range.Text = "新内容")依赖空格段的精确匹配,文档状态一变就全乱,不稳定。
插入法(doc.Range(pos, pos).InsertAfter("内容"))只在指定位置插入,不动原有内容,稳定可靠。
# ❌ 替换法 - 不稳定
sub = doc.Range(start, end)
sub.Text = "新内容"
# ✅ 插入法 - 稳定
pos = start + offset
doc.Range(pos, pos).InsertAfter("内容")
2. 用 space_index 定位而非 anchor 匹配
早期版本用"空格后面的字"来识别填空区(比如 anchor="年"),但这不可靠——同一段落里可能有多个空格后面跟着相同的字。
用 space_index(第几个空格段)定位,简单直接,不会误判。先跑探查脚本看空格段编号,再写参数。
3. 从后往前处理
填空区有多个时,必须从后往前处理。因为每次插入/删除都会改变后续字符的位置,从后往前就不会影响前面已处理的部分。
for m, fd in reversed(fill_matches):
...
4. 每次操作后重新读取
调整空格或插入内容后,段落的位置信息会变化,必须重新 doc.Paragraphs(n).Range 获取最新位置,不能用旧的位置继续操作。
# 调整空格后,重新读取
p = doc.Paragraphs(para_index)
rng = p.Range
start = rng.Start
5. 段落标记字符处理
Paragraphs(i).Range.Text 尾部一定带段落标记,需要清理:
clean = text.rstrip("\r\n\x0d\x0a\x07")
| 字符 | 含义 |
|---|---|
\r (0x0D) |
普通段落结束符 |
\n (0x0A) |
软回车 / 换行 |
\x07 |
单元格结束符(在表格里) |
文件清单
| 文件 | 用途 |
|---|---|
D:\temp\wps_fill.py |
通用填空脚本(读参数文件执行填空) |
D:\temp\wps_args.json |
参数文件(指定段落号和填空数据) |
D:\temp\wps_bridge.bat |
explorer 桥接启动器 |
D:\temp\wps_task.py |
临时探查脚本(查看段落结构,非填空时用) |
D:\temp\wps_output.txt |
输出结果文件 |
局限性
- 只支持纯空格填空:如果填空区已有旧内容,脚本会直接插入导致重复。使用前确保模板填空区是纯空格。
- 单段处理:一次调用只处理一个段落。多段需要多次调用。
- COM 直连不保证:取决于具体电脑环境,部分机器可能报
无效的类字符串,此时需 explorer 桥接(见前置条件第2节)。 - 无回滚:操作直接修改文档,建议操作前 Ctrl+S 保存,方便撤销。
win32com操作WPS实战
读取指定段落的内容
脚本
import win32com.client as wc
app = wc.Dispatch("kwps.Application") # 连上当前 WPS
doc = app.ActiveDocument # 当前活动文档
para6_text = doc.Paragraphs(6).Range.Text # 第6段(1-based)
print(para6_text.rstrip("\r\n\x0d\x0a\x07"))
1. Python 环境选择(managed vs system)
这是最隐蔽的坑:
| Python | 路径 | pywin32 |
|---|---|---|
| system 3.11.2 | C:\Users\Administrator\AppData\Local\Programs\Python\Python311\python.exe |
✅ 已装 |
"C:\Users\Administrator\AppData\Local\Programs\Python\Python311\python.exe" your_script.py
2. 控制台中文乱码
pywin32 读出来的中文直接 print 乱码(显示成 ???):
=== ??6??ԭ? ===
' ?????????????? \r'
根本原因:不是 Python 的锅,是 管道编码不一致。WorkBuddy 的 Git Bash 管道默认 GBK(CP936),Python stdout 输出的是 UTF-8,被管道按 GBK 解码就炸了。
一行解法:运行时设环境变量 PYTHONIOENCODING=utf-8:
PYTHONIOENCODING=utf-8 python your_script.py
WorkBuddy 里完整的调用命令:
PYTHONIOENCODING=utf-8 "C:\Users\Administrator\AppData\Local\Programs\Python\Python311\python.exe" your_script.py
3. QClaw / OpenClaw 无法直接调用 COM 的问题
现象: 同样的代码、同样的 Python、同样的用户和 Session ID,在手动开的 PowerShell 窗口里能成功连上 WPS COM,但通过 QClaw 的 exec 执行就报 (-2147221005, '无效的类字符串')。
根因: QClaw / OpenClaw 是后台 Node.js 服务,它 spawn 出来的子进程虽然运行在同一个 Session 1 和 WinSta0 下,但 COM 基础设施认为这些子进程不是真正的交互式桌面进程,拒绝激活 kwps.Application(LocalServer32 类型 COM 服务器)。具体表现是 Windows API CLSIDFromProgID 直接返回「无效的类字符串」,尽管注册表里 ProgID 和 CLSID 都完好。
解决办法:通过 explorer.exe 桥接。
explorer.exe 启动的子进程会继承真正的交互式桌面上下文,COM 就能正常激活。具体做法是写一个 bat 文件,用 explorer 打开:
@echo off
chcp 65001 >nul
set PYTHONIOENCODING=utf-8
"C:\Users\Administrator\AppData\Local\Programs\Python\Python311\python.exe" your_script.py > output.txt 2>&1
然后:
explorer.exe "D:\temp\run_wps.bat"
等待几秒后读取 output.txt 即可拿到结果。实测稳定可用。
6. 段落标记字符
Paragraphs(i).Range.Text 尾部一定带段落标记,常见三种:
| 字符 | 含义 | rstrip 写法 |
|---|---|---|
\r (0x0D) |
普通段落结束符 | \r |
\n (0x0A) |
软回车 / 换行 | \n |
\x07 |
单元格结束符(在表格里) | \x07 |
一条 rstrip("\r\n\x0d\x0a\x07") 全收。别只 rstrip \r,否则在表格段落里会漏掉 \x07。
COM粘贴文档块——标书拼装不求人
问题
标书中有很多固定板块——营业执照、资质证书、完税证明、社保记录——每次投标都要手动从其他文件复制粘贴进来,重复劳动且容易遗漏。
解法
把每个板块保存为独立的 .docx,放到 .workbuddy/blocks/。需要时,AI 读目标页内容,找到合适位置,COM 一键粘贴。
from get_table import insert_block
# 找到「下面放营业执照」这行,下一段粘贴
insert_block('营业执照.docx', after='下面放营业执照')
# 不指定位置则贴到文档末尾
insert_block('资质证书.docx')
实现细节
def insert_block(block_path, after=None):
# 1. 后台打开块文件
src = app.Documents.Open(block_path)
src.Content.Copy() # 全选复制
src.Close()
# 2. 在目标文档中定位
rng = doc.Content
if rng.Find.Execute(after):
sel.Collapse(0) # 到段落末尾
sel.TypeParagraph() # 另起一段
# 3. 粘贴(保持源格式)
sel.Paste()
三行核心逻辑:Copy → Find → Paste。
与 AI 的分工
| 谁 | 做什么 |
|---|---|
| AI | 读页 → 理解上下文 → 决定插入位置(after='xxx') |
| COM | 找到文字 → 另起一段 → 粘贴内容 |
AI 不操作具体位置,COM 不懂语义——各打各的工。
封装位置
get_table.py 新增 insert_block(),与 get_current_table() 共用同一套 COM 连接。
from get_table import get_current_table, insert_block
可扩展
after='文字':在匹配段落的下一段插入before='文字':在匹配段落之前插入- 不指定位置:贴到文档末尾
- 块文件路径:支持相对路径(默认从
.workbuddy/blocks/查找)
COM直接定位Word表格——消灭盲猜表序号
问题
word-mcp-live 操作表格需要 table_index(1-based 序号)。但 AI 不知道文档里第几个表是「人员汇总表」,只能从 1 开始逐个 get_table_info 遍历,匹配表头文字——慢、蠢、容易改错表。
解法
用户鼠标点进目标表格,Python 通过 COM 直接问 WPS:「光标在哪个表里?」
import win32com.client
app = win32com.client.GetActiveObject('KWPS.Application') # 连上 WPS
sel = app.Selection
doc = app.ActiveDocument
for i in range(1, doc.Tables.Count + 1):
if sel.Range.InRange(doc.Tables(i).Range):
print(f'光标在表 {i}')
break
三行核心逻辑,不需要 VBA 宏,不需要加载模板,一个 COM 连接搞定。
与 mcp_client 配合
get_table.py 封装了上述逻辑,与 mcp_client.py 搭配使用:
from get_table import get_current_table
from mcp_client import MCPClient
ti = get_current_table() # 光标点一下 → 拿到表序号
c = MCPClient()
c.get_table_info(ti) # 读这个表
c.set_cell(ti, 3, 2, "张三") # 改这个表
c.close()
工作流:用户鼠标点表 → Python 拿序号 → MCP 读写表。不再遍历盲猜。
为什么比 VBA 更好
之前考虑过写一个 Public Function GetCurrentTableIndex() 嵌在 .docm 里,用 Application.Run 调用。但后来发现 COM 直接能拿到 Tables 集合和 Selection.Range,完全不需要 VBA 这层中间人。
| 方案 | 依赖 | 分发 |
|---|---|---|
| VBA Function + Application.Run | 需要 .docm 模板始终打开 | 每份文档要嵌入或加载模板 |
| COM 直连 | pywin32(一次安装) | 零配置,打开文档即可 |
集成到公共库
文件位置:.workbuddy/scripts/get_table.py
from get_table import get_current_table
ti = get_current_table() # → 6
前提:WPS/Word 已打开目标文档 + 安装了 pywin32。
效果
之前填表6时,我在13-25的范围内挨个猜哪个表是人员汇总表,猜错了两次。现在用户光标点进去,一行 get_current_table() 直接拿到 6,再也不改错表。