Qt调用Word COM实现文档自动化:QAxObject实战指南
1. 先确定方案为什么是QAxObject而不是直接解析docx做Qt开发这么久最不缺的就是“客户想要个Word导出功能”这种需求。合同生成、检测报告、批量回执、数据汇总十有八九得落到Word里。一开始我也想着找个类似QtXlsx那种纯Qt方案但翻遍GitHub和开源社区结果很现实压根没有一款成熟稳定、能直接读写Word纯Qt库。Word文档本质上是OLE复合文档或者OpenXML压缩包格式复杂度摆在那里比xlsx难啃得多。所以在Windows平台上最靠谱的一条路就是走COM接口。Office本身对COM暴露了一套完整的自动化接口Qt里通过ActiveQt模块的QAxObject可以直接调用Word.Application的各类方法。换句话讲你自己写的程序就是在幕后“指挥”Word干活。你让Word打开文档、遍历内容、替换文字、插入表格、另存为PDF它都能干而且干出来的效果和你在Word界面里手动操作完全一致。这篇文章适合谁适合已经能写基础Qt界面、但没碰过COM或者QAxObject的开发者。读完你可以独立实现“读取Word段落和表格”“模板占位符替换”“动态插入Word表格”“批量生成docx并转PDF”这些高频需求。同时也整理了我在实际项目中踩过的坑尤其是Word关闭卡顿、进程残留、中文乱码、WPS兼容性这些绕不开的问题。1.1 动手之前先分清需求你是要“读”还是要“写”“Word读写”听起来很简单但真实项目里差别非常大。只读场景通常是程序要解析合同文档里的关键字段、从评审报告中提取意见、读取Word表格里的清单数据。这种需求用COM能读也可以用QuaZip加上自己解析document.xml的方式去读但自己解析XML要考虑命名空间、页眉页脚、分节符、样式继承工作量不小。用COM就简单很多直接拿到Paragraphs、Tables这两个集合对象像操作Word宏一样遍历即可。写场景则更复杂一些。从零新建文档往里敲字看似简单但排版控制非常麻烦更常见的需求是根据模板生成文档比如把模板里的“{{客户名称}}”替换成真正的客户名再在指定位置插入表格。这种操作用COM的Find和Tables接口反而很顺手比直接XML替换稳得多因为格式和样式都不用自己维护。所以我给团队定了个原则能接受Windows专属环境且目标机器装有Office或WPS的优先用COM方案要跨平台、要部署在Linux服务器的就别碰这条路老老实实研究docx模板引擎或者用LibreOffice无头模式转换。1.2 四条技术路线的横向对比我在选型时把市面上的方案都盘了一遍整理成一张表方便你们对号入座方案跨平台依赖环境复杂文档还原度开发成本适用场景QAxObject调Word COM仅Windows需要Office或WPS高和手动操作一致低客户端工具、富文本模板生成直接解析docx XML跨平台无中低格式容易丢失高简单结构数据导出QAxObject调WPS COM仅Windows需要WPS中高接口部分不全中没有Office的机器LibreOffice无头转换全平台需要LibreOffice中复杂样式有偏差中服务器批量转换我自己在Windows客户端项目里选的是第一项。理由很简单开发速度最快遇到客户反馈“样式不对”时我只需要在Word里手动操作一遍把那套操作翻译成COM调用就行排障思路特别清晰。2. 环境准备让项目真正跑起来的最小配置2.1 pro文件与模块依赖在Qt工程里用COM对象不需要额外安装第三方库只需要在.pro文件里加上一行QT axcontainer注意这个模块的名字是axcontainer包含的是QAxObject、QAxWidget这些类。Qt5和Qt6都能用但只有Windows平台有这个模块Linux和macOS上编译直接报模块不存在。在源码里引入头文件#include QAxObject工程有这两样就够了。接下来我们不需要做任何额外初始化QAxObject内部会自己处理COM的启动。不过如果你打算把COM调用放到子线程里去跑那就必须先手动调用CoInitializeEx(NULL, COINIT_APARTMENTTHREADED)这点后面专题说。2.2 启动Word.Application实例的最小代码下面这段代码是我最常用的启动外壳务必原样保留QAxObject* word new QAxObject(Word.Application, this); if (word-isNull()) { qCritical() 无法启动Word COM组件请确认已安装Office或WPS; delete word; return; } word-setProperty(Visible, false); word-setProperty(DisplayAlerts, 0);isNull()用于判断COM对象是否创建成功。如果返回true最常见的两个原因目标机器没有安装Office/WPS或者安装的是精简绿色版导致COM组件未注册。Visible设为false让Word在后台运行程序处理完成前用户完全看不到窗口。DisplayAlerts必须设为0不然Word弹出“是否保存”这类对话框时程序会一直卡住等你点确定这在后台自动化里等于死循环。拿到Word实例后再拿Documents集合QAxObject* docs word-querySubObject(Documents);这里有个关键概念querySubObject返回的也是QAxObject指针但它没有父对象需要你自己管理生命周期。凡是这种子对象用完必须delete否则COM引用计数不释放最典型的后果就是WINWORD.EXE进程一直赖在后台不走。2.3 先写一个“打开然后关闭”的冒烟测试刚搭好环境不要急着写业务逻辑。先做一个最小验证程序里打开一个测试docx读一下文档段落数量然后关闭Word最后确认进程被干净退出。QAxObject* word new QAxObject(Word.Application, this); if (word-isNull()) { /* 错误处理 */ } word-setProperty(Visible, false); word-setProperty(DisplayAlerts, 0); QAxObject* docs word-querySubObject(Documents); QString nativePath QDir::toNativeSeparators(D:/test.docx); QAxObject* doc docs-querySubObject(Open(const QString, bool), nativePath, false); if (doc !doc-isNull()) { // 读取段落对象 QAxObject* paragraphs doc-querySubObject(Paragraphs); int count paragraphs-property(Count).toInt(); qInfo() 段落总数: count; delete paragraphs; // 关闭文档参数false表示不保存修改 doc-dynamicCall(Close(bool), false); delete doc; } delete docs; // 退出Word word-dynamicCall(Quit()); delete word;注意Open方法里我把路径先用QDir::toNativeSeparators转成了反斜杠格式。这是个常见坑Word COM的Open方法对正斜杠路径经常不认传进去就是打开失败或路径错误转成\就稳了。运行这个测试如果输出“段落总数”正常且任务管理器里没有残留的WINWORD.EXE说明你的开发环境已经合格可以进行下面的正题。3. 读Word的核心实现从段落、表格到选区3.1 遍历段落文本的正确姿势读取所有段落是最基础的功能但别看简单代码里有个很重要的细节段落索引从1开始不是0。QAxObject* paragraphs doc-querySubObject(Paragraphs); int count paragraphs-property(Count).toInt(); for (int i 1; i count; i) { QAxObject* para paragraphs-querySubObject(Item(int), i); if (para !para-isNull()) { QAxObject* range para-querySubObject(Range); QString text range-property(Text).toString(); qInfo() 第 i 段: text; delete range; delete para; } } delete paragraphs;这里注意Range这个对象。在Word对象模型里Range表示文档中的一段连续区域它可以是整个段落也可以是任意选中的范围。所有文本内容最终都是从Range.Text里取出来的。上面的代码每取到一个段落就通过querySubObject(Range)拿到它对应的区域。实测下来有个必须提醒的点段落文本末尾通常带一个换行符\r有些段落末尾还会跟一个奇怪的控制字符\x07尤其是从网页复制过来的内容。读取后建议做一次清理text text.trimmed(); if (text.endsWith(\x07)) { text.chop(1); }3.2 表格内容的读取与踩坑项目中处理Word表格频率极高。我做过一个评审表提取功能要把每个评审项和得分填进数据库这个就是典型的表格读取场景。读取表格的核心代码QAxObject* tables doc-querySubObject(Tables); int tableCount tables-property(Count).toInt(); qInfo() 表格数量: tableCount; for (int t 1; t tableCount; t) { QAxObject* table tables-querySubObject(Item(int), t); QAxObject* rowsObj table-querySubObject(Rows); QAxObject* colsObj table-querySubObject(Columns); int rows rowsObj-property(Count).toInt(); int cols colsObj-property(Count).toInt(); delete rowsObj; delete colsObj; for (int r 1; r rows; r) { QStringList rowData; for (int c 1; c cols; c) { QAxObject* cell table-querySubObject(Cell(int,int), r, c); QAxObject* cellRange cell-querySubObject(Range); QString cellText cellRange-property(Text).toString(); rowData cellText; delete cellRange; delete cell; } qInfo() 第 t 张表 第 r 行: rowData.join(|); } delete table; } delete tables;这段代码看起来简单实际跑起来才会发现坑读取单元格文本时每个单元格末尾必然带\r\x07这两个字符这是Word在表格单元格里的段标记和单元格结束标记。如果你直接拿去和数据库比对永远比对不上。所以单元格文本也要做同样的清理cellText.replace(\r, ).replace(\x07, ).trimmed();如果碰到合并单元格Rows和Columns的计数逻辑会被打乱。比如某行合并了三个单元格在Word对象模型里它仍然占用三列索引但访问第二列时会抛异常或返回空。这种情况最稳妥的解法是捕获异常后用前后列的坐标做推算但这个属于非常复杂的解析逻辑实际项目中我建议先让客户保证模板表格不合并或在表格里加标志字段能省掉一大半的麻烦。3.3 中文编码与字体和Qt国际化必须搞清的关系很多人在Qt里调用COM读写Word第一次遇到中文乱码就懵了。问题根源通常不是COM而是Qt字符串编码和Word内部编码不一致。Word COM接口内部接收的字符串是UnicodeBSTRQAxObject在调用时会把QString自动转成BSTR那么只要你的源码里字符串本身是正确的Unicode传进去就不会乱码。问题往往出现在源码编码和读取编码上。我在项目里的规范是所有源文件统一保存为UTF-8格式所有硬编码中文一律用QString::fromUtf8()包起来不用默认的窄字符串字面量也不滥用tr()。tr()是给界面翻译用的你把它用在COM字符串上一旦启用Qt国际化翻译字符串会变成翻译后的内容这和Word读写逻辑混在一起会非常难排查。还有一种情况即使字符串传对了Word显示出来还是乱码。这是字体问题。Word文档里的字体如果设置了不支持中文的字体中文会以方块或乱码形态出现。运行时设置一下字体就好QAxObject* selection word-querySubObject(Selection); QAxObject* font selection-querySubObject(Font); font-setProperty(NameFarEast, QString::fromUtf8(宋体)); font-setProperty(NameAscii, Times New Roman); font-setProperty(Size, 12); delete font; delete selection;3.4 Selection和Range到底该用哪个刚开始接触Word COM时最容易混淆的就是Selection和Range。Selection代表当前光标所在的区域受光标位置影响。你调用TypeText时就是往Selection里插文字Word会先选中当前位置再替换或插入。Range则是一个独立的区域对象不依赖于光标你给它绑定哪个段落它就是哪个段落。读取文档内容时优先用Range因为它不需要移动光标且不会影响后续的插入位置。写入时如果用Selection要先通过StartOf、EndOf等方法调整光标位置如果用Range则要通过SetRange(起始位置, 结束位置)精确控制区域。我的经验是读取统一用Range插入和替换统一用Selection。这个分工能让代码逻辑清晰很多避免出现“明明读到了文本结果一写入位置全乱套”的现象。4. 写Word的核心实现追加内容、替换占位符和表格生成4.1 从零新建文档并写入内容读Word得心应手之后接下来是写。先看从零新建一个文档的完整流程QAxObject* docs word-querySubObject(Documents); QAxObject* doc docs-querySubObject(Add()); // 拿全局Selection对象相当于鼠标光标在文档里待命 QAxObject* selection word-querySubObject(Selection); // 写第一行文字并换行 selection-dynamicCall(TypeText(const QString), QString::fromUtf8(这是第一行)); selection-dynamicCall(TypeParagraph()); // 再写一行 selection-dynamicCall(TypeText(const QString), QString::fromUtf8(这是第二行)); selection-dynamicCall(TypeParagraph()); // 保存为docx12表示wdFormatXMLDocument QString savePath QDir::toNativeSeparators(D:/output.docx); doc-dynamicCall(SaveAs(const QString, int), savePath, 12); doc-dynamicCall(Close(bool), false); delete selection; delete doc; delete docs;这里SaveAs的第二个参数是文件格式编号我把常用的列在下面数值含义说明0wdFormatDocument旧版.doc格式12wdFormatXMLDocument.docx格式应用最广16wdFormatDocumentDefaultWord 2007默认格式也是docx17wdFormatPDF直接导出PDF实际生产环境中建议统一用12。旧版.doc格式在老机器上有兼容优势但已经在退化没什么必要坚持。4.2 模板占位符替换真实项目里最刚需的功能从零写内容在真实业务中用得不多真正高频的是“拿着客户发来的模板往里面替换占位符”。模板还是一个有公司落款、有签章区域的Word程序只在特定位置替换数据。Word COM里做替换本质是调用查找替换功能核心是Find.Execute方法。这个方法参数很多但常用的就那么几个。我封装了一个函数bool replaceWordText(QAxObject* selection, const QString oldText, const QString newText) { QAxObject* find selection-querySubObject(Find); // Execute参数对照 // 1 要查找的文本 // 2 MatchCase: 是否区分大小写 // 3 MatchWholeWord: 是否全字匹配 // 4 MatchWildcards: 是否使用通配符 // 5 MatchSoundsLike: 是否匹配同音字 // 6 MatchAllWordForms: 是否匹配词形变化 // 7 Forward: 是否向前查找 // 8 Wrap: 查找模式1表示继续2表示停止 // 9 Format: 是否保留格式 // 10 ReplaceWith: 替换成什么 // 11 Replace: 1表示全部替换2表示逐个替换 bool ok find-dynamicCall( Execute(const QString, bool, bool, bool, bool, bool, bool, int, bool, const QString, int), oldText, false, false, false, false, false, true, 1, false, newText, 1 ).toBool(); delete find; return ok; }调用方式QAxObject* selection word-querySubObject(Selection); selection-dynamicCall(HomeKey(int), 6); // 光标移到文首wdStory6 replaceWordText(selection, QString::fromUtf8({{客户名称}}), realName); replaceWordText(selection, QString::fromUtf8({{合同编号}}), contractNo); replaceWordText(selection, QString::fromUtf8({{日期}}), dateStr);有人会问为什么不直接用Selection.Find一次替换所有占位符因为Word的Replace方法每次只能执行一种查找条件多个占位符就得循环调用。这里要特别注意替换是从当前光标位置开始向后逐段扫描的所以每次查找前最好把光标移到文首否则可能跳过前面的占位符。还有一点经验如果模板是在客户机器上用WPS做的占位符文本可能带有特殊格式比如{{和}}被自动套用了英文字体而客户名称是宋体。这种“多段格式”会导致整体匹配失败。稳妥做法是让客户在模板里全部选中占位符后设为统一字体或者把占位符改成不包含空格的短字符串比如{customerName}这类纯英文中文环境下的兼容性会好很多。4.3 动态表格列宽调整、内容填充一气呵成生成Word报表时表格是重头戏。我以前遇到过一个很典型的反馈“生成的表格列宽无法拖动在Word里手动拖也拖不动。”原因是我们创建的表格列宽被设置为固定值且禁止了自动调整。解决这个问题有两种方式要么在代码里直接设置列宽和表格属性要么检查生成的表格是否被设置成了固定布局。在代码里插入一张表格并设置列宽的流程如下// 光标先放到文末 QAxObject* selection word-querySubObject(Selection); selection-dynamicCall(EndKey(int), 6); // wdStory6 移到文末 // 在光标处插入一个5行3列的表格 QAxObject* range selection-querySubObject(Range); QAxObject* tables doc-querySubObject(Tables); QAxObject* table tables-querySubObject( Add(QAxObject*, int, int, QVariant, QVariant), range-asVariant(), 5, 3, 1, 1 ); delete range; if (table !table-isNull()) { // 设置表格边框等属性 table-setProperty(Borders, true); // 按列设置宽度强制每列80Pt QAxObject* columns table-querySubObject(Columns); int colCount columns-property(Count).toInt(); for (int c 1; c colCount; c) { QAxObject* column columns-querySubObject(Item(int), c); column-dynamicCall(SetWidth(float, int), 80.0, 1); // 1wdAdjustNone delete column; } delete columns; // 逐个填充单元格 for (int r 1; r 5; r) { for (int c 1; c 3; c) { QAxObject* cell table-querySubObject(Cell(int,int), r, c); QAxObject* cellRange cell-querySubObject(Range); cellRange-dynamicCall(SetText(const QString), QString::fromUtf8(第%1行第%2列).arg(r).arg(c)); delete cellRange; delete cell; } } delete table; } delete tables;SetWidth(float, int)方法的第一个参数是列宽数值单位是磅Pt第二个参数是调整方式。1表示不调整其他列宽度wdAdjustNone。用这个方法设置完列宽在Word里就会固定客户再去拖列宽时就会发现“拖不动”这是固定布局的正常表现。如果希望用户后续能自由拖动就要保留默认的自动调整或者把第二参数改为2wdAdjustSameWidth调整后保持所有列等宽。插入表格后表格下方会多出一个空段落。这与Word的结构有关表格后面必须跟一个段落标记否则文档结构不合法。所以如果你追加内容是在表格之后要从表格下方那个段落开始而不是紧贴表格最后一行写。4.4 保存导出与资源释放的顺序感写完内容保存和导出是收尾动作。整个代码里最需要培养的“手感”是顺序顺序错了轻则文件被占用重则Word进程崩溃。我固定的收尾顺序是// 1. 保存/另存为 doc-dynamicCall(Save()); // 2. 关闭文档false表示不弹确认框 doc-dynamicCall(Close(bool), false); // 3. 释放子对象 delete doc; delete docs; // 4. 退出Word word-dynamicCall(Quit()); delete word;这个顺序讲究在哪Close必须在Quit之前否则Word会认为还有未保存的文档可能弹出保存提示或者拒绝退出。关闭后再Quit进程才能正常结束不会变成僵尸WINWORD.EXE。5. 高频问题排查实录关闭卡顿、进程残留、兼容性5.1 Word关闭时卡顿的根源注意标题里我写的是“关闭时卡顿”。这个现象极其常见程序跑到word-dynamicCall(Quit())时界面卡住或者要等几十秒才退出甚至直接没反应。我总结下来卡顿的原因基本逃不出这四类第一还有未关闭的文档弹了保存提示。如果你没有把DisplayAlerts设为0Word会在关闭前弹窗而弹窗在程序里是不可见的于是整个调用被挂起。解决方式就是开篇那两条属性word-setProperty(Visible, false); word-setProperty(DisplayAlerts, 0);第二调用了Quit但仍有COM对象引用未释放。Word在退出前会去检查所有接口引用计数只要有一个子对象还活着它就不敢退出一直等着。而等多久取决于Word版本有时30秒有时更长。这就是卡顿的主要幕后黑手。第三Office软件本身的加载项拖慢退出速度。客户机器上装了一堆Word加载项比如某个输入法插件、PDF转换插件Quit时Word会先让加载项卸载加载项如果卡住退出就慢。代码层面我们能做的是设置AutomationSecurityword-setProperty(AutomationSecurity, 3); // msoAutomationSecurityForceDisable第四上一次程序崩溃残留了多个WINWORD.EXE进程。每个残留进程占着文档锁新的Word实例启动时就容易慢Quit时也会被之前的进程状态影响。开发调试期间遇到这种问题直接在任务管理器里清掉它们就行taskkill /f /im WINWORD.EXE5.2 子对象释放顺序99%进程残留的原因我可以直接下个结论凡是“Word进程不退出”或者“第二次运行时能打开但第一次关不掉”的问题基本都是子对象没有释放干净。看一段常见错误示范QAxObject* doc docs-querySubObject(Open(...)); QAxObject* paragraphs doc-querySubObject(Paragraphs); // 一顿操作 // 忘记delete paragraphs忘了delete doc直接调用Quit word-dynamicCall(Quit()); delete word;在这种情况下doc和paragraphs的COM引用计数都不是0Word认为自己还被外部程序使用于是拒绝退出。程序这边已经删除了Word主对象但WINWORD.EXE像孤魂野鬼一样留在后台。正确做法是每个子对象用完后立即delete。我甚至总结出一套“用局部变量块”的习惯把操作逻辑包在花括号里保证作用域结束立刻释放{ QAxObject* paragraphs doc-querySubObject(Paragraphs); // 操作 } // 这里paragraphs已经失效要立刻delete但QAxObject不会自动管理所以更稳的做法是配合QScopedPointerQScopedPointerQAxObject paragraphs(doc-querySubObject(Paragraphs)); // 作用域结束时自动delete这个方法看着简单但在多人协作或紧急排障时特别管用。我后来把读Word、读表格、写段落都封装成独立函数每个函数内部创建的子对象全部用QScopedPointer彻底根治了进程残留问题。5.3 WPS环境下的兼容性处理很多客户机器不装Office只装WPS。这种情况下用Word.Application创建COM对象会失败因为WPS注册的ProgID是KWps.Application有时是WPS.Application不同版本还有差异。我的做法是做一个探测函数依次尝试不同的ProgIDQAxObject* createWordApp(QObject* parent) { QStringList progIds { Word.Application, KWps.Application, WPS.Application }; for (const QString progId : progIds) { QAxObject* app new QAxObject(progId, parent); if (!app-isNull()) { return app; } delete app; } return nullptr; }还有一个麻烦在于WPS对某些COM接口的实现有差异。比如Find.Execute的参数支持不够好或SetWidth的行为和Word不同。遇到这种情况我没有太好的根治办法只能建议目标机器统一装Office或者在功能设计上避开差异较大的接口比如用“整段重新生成”代替“查替换”。5.4 权限、无界面服务器和宏安全部署场景里还有三个隐藏问题。一是保存路径没有写权限。Word是充当外部进程如果指定保存到C:\Program Files\xxx这类系统保护目录Word会弹出错误甚至直接崩。程序里先做可写检查比等COM异常靠谱得多QFileInfo fi(savePath); if (!fi.dir().exists() || !fi.dir().isWritable()) { // 反馈给用户而不是让COM炸掉 return; }二是在服务器或Windows Service环境下运行。Windows的Session 0隔离会阻止普通服务和应用访问桌面Word COM在这种环境里经常启动失败或者看起来没反应。如果必须部署建议给服务配置“允许服务与桌面交互”或者干脆换LibreOffice无头模式。三是宏安全弹窗。有些客户的模板自带宏打开时会弹出安全警告同样会让程序挂起。通过设置AutomationSecurity可以强制禁用宏word-setProperty(AutomationSecurity, 3);5.5 高频问题速查表问题原因快速处理中文乱码源码编码或字体问题源文件UTF-8传参用QString::fromUtf8设置Font.NameFarEastWord关闭卡顿弹窗、子对象未释放DisplayAlerts0QScopedPointer管理子对象进程残留WINWORD.EXECOM引用计数未归零找漏网子对象逐层delete调试期taskkill处理找不到Word.Application没装Office或组件未注册用createWordApp探测WPS或装Office打开路径失败路径用了正斜杠QDir::toNativeSeparators转成反斜杠表格列宽拖不动固定布局且SetWidth传了wdAdjustNone要允许拖动就改自动调整或SetWidth第二参数传2保存失败目录无权限提前用QFileInfo检查可写性替换占位符失败光标不在文首、格式不一致先HomeKey移到文首模板占位符统一字体6. 批量生产场景的工程化建议6.1 批量生成文档时怎么保证界面不卡死真实项目里很少只生成一个文档动不动就是几百份合同。如果直接在UI线程里循环调用COM程序界面必然卡死用户还以为程序崩溃了。最朴素的方案是循环里手动处理事件让进度条动起来QProgressDialog progress(QString::fromUtf8(正在生成合同...), QString::fromUtf8(取消), 0, totalCount, this); progress.setWindowModality(Qt::WindowModal); for (int i 0; i totalCount; i) { if (progress.wasCanceled()) { break; } generateOneContract(list.at(i)); progress.setValue(i 1); QApplication::processEvents(); // 强制刷新UI }注意QApplication::processEvents()必须放在每次COM调用的间隙不能放在COM调用中间。因为Word COM操作是同步阻塞的在它执行期间界面事件循环根本跑不到processEvents这一行。如果想彻底不卡就得把生成任务丢到子线程。但QAxObject在线程里使用有个门槛每个使用COM的线程必须单独调用CoInitializeEx。而且同一个Word实例跨线程调用很危险建议每个线程各自创建独立的Word实例各干各的。这块代码要写好线程同步和COM初始化都容易出错不适合没有多线程经验的人直接上手。6.2 建议封装成独立的WordDocument类做了三四个这类项目后我把读写逻辑全部从界面代码里剥出来单独封装成一个类对外只暴露几个方法class WordDocument : public QObject { Q_OBJECT public: explicit WordDocument(QObject* parent nullptr); ~WordDocument() override; bool open(const QString filePath); bool newDocument(); QStringList readParagraphs(); QListQStringList readTable(int tableIndex); bool replace(const QString oldText, const QString newText); bool insertTable(int rows, int cols, const QListQStringList data, const QListdouble colWidthsPt); bool saveAs(const QString filePath, int format 12); void closeAll(); private: QScopedPointerQAxObject m_word; QScopedPointerQAxObject m_doc; QScopedPointerQAxObject m_docs; };封装的好处是界面逻辑里只需要调用replace、insertTable一旦出问题只需要在单个类里排查COM生命周期而不必在十几个窗口的代码里翻。这里有个很重要的经验m_word和m_doc用QScopedPointer管理后类析构时会自动释放COM对象但释放顺序很关键。因为QScopedPointer按成员被声明的逆序析构所以声明顺序必须是m_word最后声明、最先析构。这样能保证先释放doc和docs最后释放word主对象正好符合Word自动化的收尾顺序。实际实现中我随手记一下构造函数里的初始化和析构函数里的清理WordDocument::WordDocument(QObject* parent) : QObject(parent) { m_word.reset(new QAxObject(Word.Application, this)); if (!m_word-isNull()) { m_word-setProperty(Visible, false); m_word-setProperty(DisplayAlerts, 0); m_docs.reset(m_word-querySubObject(Documents)); } } WordDocument::~WordDocument() { if (m_doc) { m_doc-dynamicCall(Close(bool), false); m_doc.reset(); // 先释放doc } if (m_docs) { m_docs.reset(); // 再释放docs } if (m_word !m_word-isNull()) { m_word-dynamicCall(Quit()); m_word.reset(); // 最后释放word } }6.3 再提一嘴导出PDF是强需求很多生成流程最后一步是“顺便转成PDF”客户拿去打印、审批、盖章签名。QAxObject实现这个功能非常轻松一句话的事doc-dynamicCall(SaveAs(const QString, int), pdfPath, 17);但要注意如果你的Word文档里有中文自定义字体而目标机器没有安装这个字体转出来的PDF会缺字或者用默认字体替换排版可能乱。所以在交给客户之前先在目标机器上测试一遍字体是否完整。这是做过批量导出踩过坑的人才会特别留意的点。以我个人的项目经验来说PDF导出和Word生成不要放在同一个耗时操作里做否则用户等待时间翻倍。一般是先生成全部docx再排队转PDF中间用进度条提示用户体验会好很多。整个流程跑通之后剩下的就是反复测试边界情况空文档、纯图片文档、超长表格、合并单元格、页眉页脚里带占位符。这些情况我全遇到过但处理逻辑万变不离其宗都是围绕QAxObject把Word对象模型调明白。只要COM生命周期管干净Word读写这条路基本就稳了。