win32com在WPS中的应用


win32com在WPS中做填空题

场景

WPS 文档中有大量模板填空项,比如日期行、供应商信息、金额等:

            (供应商全称)参加贵方组织的采购编号为            
号采购项目活动。按磋商文件规定,已递交人民币             元的磋商保证金。
法定代表人:          (签字或签章)
日    期:      年     月     日

需要自动识别空格区域,按规则填写内容。

核心原则

  1. 填空区统一保留6个空格(多了删,少了补)
  2. 在第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 输出结果文件

局限性

  1. 只支持纯空格填空:如果填空区已有旧内容,脚本会直接插入导致重复。使用前确保模板填空区是纯空格。
  2. 单段处理:一次调用只处理一个段落。多段需要多次调用。
  3. COM 直连不保证:取决于具体电脑环境,部分机器可能报 无效的类字符串,此时需 explorer 桥接(见前置条件第2节)。
  4. 无回滚:操作直接修改文档,建议操作前 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,再也不改错表。


文章作者: 摸鱼
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 摸鱼 !
  目录