Office COM 加载项开发笔记
一、实现接口 IDTExtensibility2
这是实现 Office COM 加载项最基本的接口
添加 COM 引用 Microsoft Add-In Designer 即可
对应文件 Extensibility.dll 只包含 IDTExtensibility2 接口其中和用到的枚举 ext_ConnectMode、ext_DisconnectMode,
可以减少模块引用自行复制代码到自己项目中,注意 IDTExtensibility2 不可被混淆
⚠ 注意:开发 Office 或 WPS COM 加载项添加 COM 引用时,需要安装对应的套件才能找到相关的 COM 组件,添加 WPS COM 引用时会受到两者安装的先后顺序和管理员权限影响,导致无法添加引用,若 VS 报错无法添加,需要卸载 Office 才能顺利添加。但下文会提到仅需引用其中一套 COM 组件即可同时兼容 Office 和 WPS
csharp
#if BrandName1
namespace BrandName1 // 品牌1
#else
namespace BrandName2 // 品牌2
#endif
{
[Obfuscation] // 不可被混淆
[ComVisible(true)] // COM 组件类可见, 并且类型要设为公开 public
[Guid("XXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX")] // CLSID
// [ProgId("BrandName1.OfficeAddIn")] // Office COM 加载项的 ProgID 须与类全名一致
public class OfficeAddIn : IDTExtensibility2
{
public void OnConnection(
object application, ext_ConnectMode connectMode,
object addInInst, ref Array custom)
{
MessageBox.Show("OnConnection"); // 注册成功的加载项将会在对应应用启动时弹窗
}
// 其他 IDTExtensibility2 的接口方法...
}
}
⚠ 注意:Office COM 加载项须保证类的 ProgID 与类全名完全匹配, ProgID 特性未设置时默认使用类的全名,故也无需设置;并且类不可被混淆,被其继承的接口也不可被混淆
ProgID 与产品和对应功能相关,文件关联也会用到,建议名称是 . ,故示例中以 BrandName1 为名称空间,OfficeAddIn 为类名,那么 ProgID 就是 BrandName1.OfficeAddIn。为区分品牌,在品牌条件编译中使用不同的名称空间,而不是不同的类名,这样更符合规范,也更好编写注册的代码
二、注册 Office COM 加载项
COM CLSID 和 Office 产品的注册表都有 HKCU、HKLM 和 64、32位的项,为了提高兼容性,可在这些注册表项下都添加上注册信息
注册 COM 组件
C# 注册 COM 组件一般通过调用 RegAsm.exe 文件来注册,区分位数和运行时版本
%windir%\Microsoft.NET\Framework[64]"ver"\RegAsm.exe MyCOM.dll /codebase [/u]
RegAsm.exe 作用就是添加注册表项,避免系统缺失该文件,也为了添加日志输出,可自行写注册表实现
{HKCU|HKLM}\Software\Classes
ProgID
● "" = 'ProgID'
CLSID
● "" = 'CLSID'
[Wow6432Node\]CLSID\'CLSID'
● "" = 'ProgID'
Implemented Categories\{62C8FE65-4EBB-45e7-B440-6E39B2CDBF29}
InprocServer32
● "" = "mscoree.dll"
● "Assembly" = 'Assembly.FullName'
● "Class" = 'ProgID'
● "CodeBase" = 'Assembly.CodeBase'
● "RuntimeVersion" = 'Environment.Version'
● "ThreadingModel" = "Both"
ProgId
● "" = 'ProgID'
⚠ 注意:RegAsm.exe 注册方式会用到反射,如果不将引用到的程序集文件放到同目录下,并且系统未注册 COM 类继承的接口时,会出错导致注册失败。如果注册方式和 RegAsm.exe 一样会用到类型本身,为避免用户未安装 COM 组件相关的应用,需要打包引用到的程序集
⚠ 注意:同一个 COM 组件项目创建不同品牌的程序集并注册时,如果两个程序集文件名相同、签名相同、版本相同,则会导致两者程序集全名相同,导致系统无法区分。区分方式是三者至少有一个不同,最简单的方式就是条件编译设置不同版本号
⚠ 注意:为提高兼容性,目标平台选择 AnyCPU 即可兼容 64/32 位系统和软件。作为 COM 组件运行时,是作为被 .NET 虚拟机进程引用的程序集来运行的,只需将目标框架设为 .NET Framework 3.5, 不需要 app.config 文件就可以兼容 3.5 和 4.0,无需编译多个框架版本。支持 .dll 和 .exe 文件,只要是 .NET 程序集就可以,如果是可将自身注册为 COM 组件 exe,那在注册时还是需要 app.config 的
添加到 Office 加载项列表
需要在加载项列表下新建加载项类 ProgID 同名子项,并添加三个必要的注册表键值
{HKCU|HKLM}\Software\[Wow6432Node\]Microsoft\Office
<app>
AddIns
'ProgID'
● FriendlyName = "加载项列表中显示的友好名称"
● Description = "加载项列表中显示的描述"
● LoadBehavior = 3 (启动时连接和加载)
另外还可以添加 CommandLineSafe = 1, 指示命令行操作安全,可能可以减少弹窗警告
经过这两步注册示例插件后,启动对应的 Office 应用时,就会弹出消息框,验证注册成功了
三、实现接口 IRibbonExtensibility
这个接口用于在 Office 应用的 Ribbon 中添加自定义 UI
添加 COM 引用 Microsoft Office Object Library 即可, 是 Office 版本号
为提高兼容性,可以安装 Office 2007 获取到 12.0 版本的 COM,并将对应的文件 Office.dll 复制到项目目录中,并修改引用为相对文件,避免在其他未安装 Office 2007 的电脑上无法生成。注意此接口也要被加载项类继承,故不可被混淆
此接口只有一个 GetCutsomUI
的方法,需要返回 XML 格式的字符串
为了代码可读性,建议使用编写和加载 XML 资源文件的方式
并且在 VS 中编写 XML 添加名称空间后在编写元素属性时将会有候选词列表,十分方便
xml
<?xml version="1.0" encoding="utf-8"?>
<customUI xmlns="http://schemas.microsoft.com/office/2006/01/customui">
<ribbon>
<tabs>
<tab id="TestTab" getLabel="GetLabel">
<group id="TestGroup" getLabel="GetLabel">
<button id="TestButton" size="large"
onAction="OnButtonPressed"
getLabel="GetLabel"
getImage="GetImage"/>
</group>
</tab>
</tabs>
</ribbon>
</customUI>
c#
public class OfficeAddIn : IDTExtensibility2, IRibbonExtensibility
{
public string GetLabel(IRibbonControl control)
{
switch(control.ID)
{
case "TestTab": return "Test Tab";
case "TestGroup": return "Test Group";
case "TestButton": return "Test Button";
}
return null;
}
public {Bitmap|IPictureDisp} GetImage(IRibbonControl control)
{
// 返回控件图像,支持 Bitmap 或 IPictureDisp 类型返回值
}
public void OnButtonPressed(IRibbonControl control)
{
MessageBox.Show("Test Button Clicked!");
}
}
CustomUI 注意事项
建议携带 XML 声明部分指定utf-8
编码,否则如果有中文会乱码
customUI
元素中的名称空间,年月可以用2009/07
或2006/01
,但 Office 2007 不支持解析前者
控件的文本、图像、悬浮提示语等,都可使用对应的属性label
、image
在 XML 中直接设置。也可以使用对应的回调方法getLabel
、getImage
。使用回调方法的方式需要在加载项类中声明同名公开方法,比如 XML 中编写getLabel="GetLabel"
,C# 中就须编写对应的public string GetLabel (IRibbonControl control)
方法,类似于 WPF XAML 的事件绑定,支持多个控件使用同一个方法并根据控件的 id 返回合适的值
第 3 条中属性和回调方法互斥,只允许使用其中一个。另外图像还可使用内置图像属性imageMso
,与image
和getImage
也是互斥的,比如 imageMso="FileSaveAs"
使用内置的另存为图像
如果使用了 dynamicMenu
控件,其 getContent
方法也需要返回 XML 格式字符串,但与第 1 条不同,不能有 XML 声明部分,否则解析失败
大小写敏感,大小写错误会导致解析失败
使用透明背景图像
CustomUI 中控件的getImage
方法支持直接返回Bitmap
类型,Office 支持透明背景的Bitmap
,但 WPS 不支持,会用浅灰色的背景填充。这里可以转换并返回IPictureDisp
类型
另外值得一提的是,Office 在切换深色主题后,黑灰单色图像还会自动转换为白色图像,WPS 没有这个机制
IPictureDisp 在 COM 组件 OLE Automation 中定义,一般在添加 Microsoft Office Object Library 引用时会自动添加上,对应文件是 stdole.dll,我们只需要用到 IPictureDisp 接口,同样可以减少模块引用自行复制代码到自己项目中
csharp
[DllImport("oleaut32.dll", ExactSpelling = true, PreserveSig = false)]
static extern IPictureDisp OleCreatePictureIndirect(
ref PictDesc pictdesc,
[MarshalAs(UnmanagedType.LPStruct)] Guid iid,
[MarshalAs(UnmanagedType.Bool)] bool fOwn);
struct PictDesc
{
public int cbSizeofstruct;
public int picType;
public IntPtr hbitmap;
public IntPtr hpal;
public int unused;
}
public static IPictureDisp CreatePictureIndirect(Bitmap bitmap)
{
var picture = new PictDesc
{
cbSizeofstruct = Marshal.SizeOf(typeof(PictDesc)),
picType = 1,
hbitmap = bitmap.GetHbitmap(Color.Black), // 创建纯透明底色位图
hpal = IntPtr.Zero,
unused = 0,
};
return OleCreatePictureIndirect(ref picture, typeof(IPictureDisp).GUID, true);
}
Bitmap.GetHbitmap
有无参和传参Color
两个重载,无参重载在内部传参Color.LightGray
调用另一重载,这应该和直接返回Bitmap
在 WPS 中会有浅灰色填充相关。需要注意的是,
GetHbitmap
方法内部不会使用到颜色的 Alpha 值, 创建纯透明背景图像句柄,应该使用Color.Black
255,0,0,0
而不是Color.Transparent``0,255,255,255
CustomUI 刷新控件
- 利用
customUI
元素的onload
回调方法,在 C# 中记录IRibbonUI
对象,可调用其Invalidate
方法刷新整个 UI,或者调用InvalidateControl(string id)
根据 id 刷新指定控件
csharp
public class OfficeAddIn : IDTExtensibility2, IRibbonExtensibility
{
IRibbonUI ribbon;
public void OnCustomUILoad(IRibbonUI ribbon)
{
this.ribbon = ribbon;
}
internal void Invalidate()
{
ribbon?.Invalidate();
}
internal void InvalidateControl(string id)
{
ribbon?.InvalidateControl(id);
}
}
dynamicMenu
控件invalidateContentOnDrop="true"
可在每次展开时重新触发getContent
刷新内容
四、Office 互操作能力
需要添加引用对应 Office 应用的互操作库,在 VS 中可以很方便的跳转 MSDN 文档
添加 COM 引用 Microsoft Object Library 即可
下文演示 Office 导出 PDF 能力,仅作演示,另外需要释放 COM 对象
ExportAsFixedFormat 方法有很多可选参数,支持设置打印页数、包含文档信息、生成书签等
⚠ 注意:Office 2007(只有 32 位版本)导出 PDF/XPS 会提示未安装此功能时,需要用到 Office 2010 才有的 EXP_PDF.dll 和 EXP_XPS.dll 文件,复制到 Office 2007 的共享目录即可
%CommonProgramFiles[(x86)]%\Microsoft Shared\OFFICE12
Word 导出 PDF
csharp
using Microsoft.Office.Interop.Word;
public class OfficeAddIn : IDTExtensibility2, IRibbonExtensibility
{
Application app;
public void OnConnection(
object application, ext_ConnectMode connectMode,
object addInInst, ref Array custom)
{
if (application is Application)
app = (Application)Application;
}
public void OnButtonPressed(IRibbonControl control)
{
app?.ActiveDocument?.ExportAsFixedFormat(fileName, WdExportFormat.wdExportFormatPDF);
}
}
Excel 导出 PDF
csharp
using Microsoft.Office.Interop.Excel;
// 工作簿
app?.ActiveWorkbook?.ExportAsFixedFormat(XlFixedFormatType.xlTypePDF, fileName);
// 工作表
(app?.ActiveSheet as Worksheet)?.ExportAsFixedFormat(XlFixedFormatType.xlTypePDF, fileName);
// 图表, WPS 不支持
app.ActiveChart?.ExportAsFixedFormat(XlFixedFormatType.xlTypePDF, fileName);
// 框选区域
var range = app.Selection as Range;
var sheet = range.Worksheet;
var area = sheet.PageSetup.PrintArea;
sheet.PageSetup.PrintArea = range.Address; // 设置打印区域为选定区域
sheet.ExportAsFixedFormat(XlFixedFormatType.xlTypePDF, fileName);
sheet.PageSetup.PrintArea = area; // 还原打印区域
PowerPoint 导出 PDF
csharp
using Microsoft.Office.Interop.PowerPoint;
app?.ActivePresentation?.ExportAsFixedFormat(fileName, PpFixedFormatType.ppFixedFormatTypePDF);
Publisher 导出 PDF
csharp
using Microsoft.Office.Interop.Publisher;
app?.ActiveDocument?.ExportAsFixedFormat(PbFixedFormatType.pbFixedFormatTypePDF, fileName);
Outlook 导出邮件为 PDF
csharp
using Microsoft.Office.Interop.Outlook;
using Microsoft.Office.Interop.Word;
var mailItem = outlook?.ActiveExplorer()?.Selection?.OfType<MailItem>()?.FirstOrDefault();
var inspector = mailItem?.GetInspector;
var document = inspector?.WordEditor as Document;
document?.ExportAsFixedFormat(fileName, WdExportFormat.wdExportFormatPDF);
// GetInspector 会打开一个隐藏窗口,比较吃内存,需要及时关闭
inspector?.Close(OlInspectorClose.olPromptForSave);
五、实现 WPS COM 加载项
须在注册 Office COM 加载项基础上(包括添加到 Office 加载项列表),另外添加到 WPS 加载项列表
添加到 WPS 加载项列表
Word 对应 WPS,Excel 对应 ET,PowerPoint 对应 WPP,不区分 64/32位
HKCU\Software\kingsoft\office
{WPS|ET|WPP}
AddinsWL
'ProgID' = ""
Office 与 WPS COM 组件对应表
⚠ 注意:WPS 和 Office 官方为了互相兼容,Office、Word、Excel、PowerPoint 相关的 COM 接口使用相同的 CLSID。如果插件需要兼容两者,对应的互操作库文件只需要一组,因程序集内名称空间不同,且用户基本只会安装其中一套,须复制互操作库文件到运行目录,否则无法同时兼容
#### Office | #### WPS |
---|---|
Microsoft Add-In Designer Extensibility.dll | Kingsoft Add-In Designer Interop.AddInDesignerObjects.dll |
Microsoft Office Object Library Office.dll | Upgrage WPS Office Object Library Interop.Office.dll |
Microsoft Word Object Library Microsoft.Office.Interop.Word.dll | Upgrade Kingsoft WPS Object Library Interop.Word.dll |
Microsoft Excel Object Library Microsoft.Office.Interop.Excel.dll | Upgrage WPS Spreadsheets Object Library Interop.Excel.dll |
Microsoft PowerPoint Object Library Microsoft.Office.Interop.PowerPoint.dll | Upgrage WPS Presentation Object Library Interop.PowerPoint.dll |
六、卸载清理注册表
除了清理上文中添加的 COM 组件和加载项的注册表,还可以清理以下相关的注册表:
-
HKCU\Software\Microsoft\Office\<app>\AddinsData
插件数据 -
HKCU\Software\Microsoft\Office\<ver>\Common\CustomUIValidationCache
CustomUI 校验缓存 -
HKCU\Software\Microsoft\Office\<ver>\<app>\Addins
版本插件列表 -
HKCU\Software\Microsoft\Office\<ver>\<app>\AddInLoadTimes
版本加载次数 -
HKCU\Software\Microsoft\Office\<ver>\<app>\Resiliency\NotificationReminderAddinData
Office 禁用通知
七、其他问题
未加载,加载 COM 加载项时出现运行错误
这是一个比较令人头疼的问题,可能原因有很多,但 Office 没有报错日志,导致很难排查问题
微软官方博客给出了一些解答,个人也复现了一些情况:
-
部署问题:COM 组件注册表内容缺失项或键值,需要注意 COM 组件与 Office 加载项注册表的位数
-
运行问题:在 Outlook 中比较明显,本身就启动缓慢卡顿,切忌在启动时调用 Sleep 函数,轻则 Office 直接提示建议禁用插件,重则直接出现未加载的问题
Outlook 退出前操作
Outlook 16.0(其他版本未测试)退出时不会触发 OnBeginShutdown
和OnDisconnection
,原因未知,应该是 Outlook 自己的 Bug,故 Outlook 插件不要在这两个方法中进行退出前操作
经过测试,程序退出时会触发System.Windows.Forms.Application.ThreadExit
,但是不会触发(来不及?)AppDomain.CurrentDomain.ProcessExit
,可以利用前者来进行退出前操作,比如保存配置和释放资源
Office 应用关闭后进程不结束
出现此问题一般是 COM 对象资源未释放干净,但是频繁使用 Office 互操作很难保证所有 COM 对象都及时正确释放。为了让进程正确退出,不可使用Process.Kill
等强制方法手动结束进程,一是强制结束进程可能会导致下次打开文档时会提示文档保存异常,二是插件可在程序运行中被手动卸载,可以使用卸载当前应用程序域的方式友好解决问题
csharp
public void OnDisconnection(ext_DisconnectMode removeMode, ref Array custom)
{
try
{
AppDomain.Unload(AppDomain.CurrentDomain);
}
catch (CannotUnloadAppDomainException)
{
// ignored
}
}
相关资料
如何使用 Visual C# .NET 生成 Office COM 加载项 - Office