65.9K
CodeProject 正在变化。 阅读更多。
Home

将 PowerPoint 演示播放器嵌入 WPF 应用程序

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.86/5 (20投票s)

2010年10月15日

CPOL

29分钟阅读

viewsIcon

225930

在本文中, 我将解释如何将演示播放器嵌入 WPF 应用程序, 并描述找到此解决方案的方法

引言

有一次,我遇到了一个有趣甚至可以说是具有挑战性的任务,即构建一个定制的PowerPoint演示文稿播放器。这项任务是我和我的团队在Reliable Systems开发项目的一部分。我们的客户希望有一个应用程序,允许用户通过设计具有条件点和分支的工作流来定制幻灯片显示顺序。 

一些读者可能会合理地建议,幻灯片显示顺序的定制可以通过在PowerPoint中引入一个附加模块来实现。我确信他们是对的,因为最新版本的Microsoft Office应用程序提供了丰富的API来扩展其功能;此外,如今Office应用程序的附加组件可以用C#、VB.NET或任何其他符合CLR的语言开发。但我们的客户想要一个独立的应用程序,而不是定制版的PowerPoint。他还希望应用程序能够同时在两个独立的窗口中显示演示文稿;一个窗口作为控制器窗体用于切换幻灯片,另一个窗口仅作为非交互式幻灯片视图。我们不确定这个功能是否可以通过为PowerPoint引入附加组件来实现(你可能会反驳,但我仍然不确定),对我们来说,这是支持构建一个独立应用程序的另一个重要论据。

我们选择创建一个WPF应用程序,以便访问该技术提供的丰富GUI开发功能。我们还决定在我们的应用程序中重新托管Visual Studio工作流设计器,以及我们自定义的工作流及其活动类。实际上,创建自定义工作流活动类的工作相当简单,没有什么可讨论的。重新托管工作流设计器并非那么简单,但Bruce Bukovics在他的书中已经详细描述过,我想我无法添加任何有价值的信息。我们项目真正的挑战是如何导入,更重要的是,如何播放演示文稿。

直接COM互操作性问题

找到一种方法获取给定演示文稿的相关信息(幻灯片数量、名称和缩略图)以构建工作流非常重要。乍一看,这项任务似乎相当简单,因为有用于COM接口的.NET包装程序集,可以用于实现与Office应用程序的交互,如以编程方式打开演示文稿、从演示文稿获取数据、启动幻灯片放映等。虽然这是绝对正确的,但有一些特殊的功能最终使得寻找替代解决方案成为必要。

首先,为了从演示文稿中获取数据,它必须在PowerPoint中打开。显然,对于我们应用程序的目的来说,最好的方法是静默地执行此操作,这样用户就不会看到PowerPoint应用程序窗口在后台打开然后几秒钟后关闭。令人非常失望的是,尽管PowerPoint的COM接口及其托管包装器似乎提供了隐形运行应用程序的能力,但PowerPoint 2007实际上不支持此功能。如果你运行以下代码

 
using Microsoft.Office.Core;
using Microsoft.Office.Interop.PowerPoint;
……
Application app = new ApplicationClass {Visible = MsoTriState.msoFalse};

您将得到以下异常

 
System.Runtime.InteropServices.COMException 
  Message="Application (unknown member) : Invalid request.  Hiding the application window is not allowed."
  Source="Microsoft Office PowerPoint 2007"
  ErrorCode=-2147188160
  StackTrace:
       at Microsoft.Office.Interop.PowerPoint.ApplicationClass.set_Visible(MsoTriState Visible)
       ....

因此,ApplicationClass 的构造函数必须始终以参数 MsoTriState.msoTrue 调用。我绝不满足于这种状况,并开始寻找解决方案来解决问题,但所有提出的解决方案都未能提供令人满意的结果,最终我不得不放弃直接通过COM与PowerPoint互操作的想法。

在应用程序窗口中托管演示播放器

还有一个大问题需要回答:如何将演示播放器嵌入到我们的应用程序中?我想你们大多数人都记得古老的OLE技术,它允许将Excel电子表格嵌入到Word文档等。随着技术的发展和新功能的增加,它被重新命名为活动文档托管。当我们在Internet Explorer中打开PDF文档时,仍然可以看到这种强大技术的一个应用示例。当我们这样做时,浏览器窗口会获得Adobe Reader GUI的一些功能,并且文档会在浏览器窗口内渲染(图1)。这是因为Internet Explorer充当活动文档宿主,而Adobe Reader充当活动文档服务器。实际上,在这种情况下,Adobe Reader确实为Internet Explorer服务,它在后台运行,并允许Internet Explorer重新托管其大部分GUI,提供所有功能。从用户的角度来看,似乎Internet Explorer足够智能,能够独立渲染PDF文档;但是,如果您的机器上没有安装Adobe Reader,它将无法做到这一点。

Figure1.png

图1. Internet Explorer重新托管的PDF文档。

在MS Office 2007之前,同样的场景适用于所有MS Office文档,包括PowerPoint演示文稿。如果您的机器上安装了MS Office 2000、XP或2003,并尝试从Internet Explorer中打开MS Office文档,它将以与我们刚刚看到的PDF文档相同的方式在浏览器窗口中呈现。在MS Office 2007之前,我们所要做的就是将浏览器组件放置在表单上(或者如果我们正在开发非托管应用程序,则使用浏览器ActiveX),然后编写代码在其中打开所需的演示文稿,我们将拥有相同的活动文档托管魔术。

坏消息是,我们不能使用这种方法,因为我们需要处理PowerPoint 2007演示文稿,而MS Office 2007的情况则完全不同。如果我们尝试从Internet Explorer中打开任何MS Office 2007应用程序的文档,我们会发现文档会在外部窗口中打开。MS Office团队决定在2007及更高版本中放弃对活动文档托管的支持,原因是兼容性问题;这样做的原因此处有解释。本文包括对该问题的一种变通解决方案的描述,该方案需要更改注册表设置,实际上不建议使用。为了一个应用程序而全局更改注册表设置的想法看起来并不吸引人,而且关于使用变通方案可能出现问题的令人沮丧的说明让我放弃了使用浏览器组件托管演示文稿的想法。可能这样做更好,因为最终找到了一个更好的解决方案,它不需要对系统注册表进行任何修改,因此不会影响其他应用程序。

使用Office Viewer组件

在互联网上寻找可能的替代方案时,我发现了Office Viewer Component。从技术上讲,这是一个ActiveX控件,它能够托管MS Office文档。使用这个组件,我成功地将PowerPoint演示文稿播放器嵌入到我们的原型应用程序中,方法是将Office Viewer的一个实例放置在主窗体上。用于打开演示文稿并切换到幻灯片放映模式的C#代码如下所示:

 
        private AxOfficeViewer.AxOfficeViewer axOfficeViewer1;
        private Microsoft.Office.Interop.PowerPoint.Presentation _presentation;
 
        public void OpenDocument(string fileName)
        {
            axOfficeViewer1.Visible = false;
 
            axOfficeViewer1.Open(fileName);
            axOfficeViewer1.Visible = true;
            axOfficeViewer1.SlideShowPlay(false, false, false, false);
 
            _presentation =
                axOfficeViewer1.ActiveDocument as Presentation;
        }

我必须在打开演示文稿之前将Office Viewer实例设置为不可见,并在开始幻灯片放映之前再次显示它,这样用户就看不到PowerPoint GUI的所有工具栏和功能区的中间状态。这保证了Office Viewer在运行幻灯片放映时已经可见,而没有不必要的GUI元素可见。从代码中可以看出,Office Viewer有一个特殊的启动幻灯片放映方法,它有四个布尔参数,指示幻灯片放映是否必须以全屏模式打开,Web工具栏是否必须可见,幻灯片放映是否必须“循环”以及垂直滚动条是否必须可见。

幻灯片切换将非常直接

 
        public void SlideGotoPage(int pageNumber)
        {
            if (axOfficeViewer1.IsOpened)
                axOfficeViewer1.SlideGotoPage(pageNumber);
        }
 
        public void SlideGotoNext()
        {
            if (axOfficeViewer1.IsOpened)
                axOfficeViewer1.SlideGotoNext();
        }

这使得我能够在幻灯片放映期间按特定顺序显示某些幻灯片。此外,为了防止PowerPoint对鼠标点击做出反应,我在Office Viewer实例的顶部放置了一个透明面板,它会拦截鼠标事件并“吞噬”它们。

从代码中可以看出,打开演示文稿后可以立即获取Presentation接口的引用。这使我能够在将演示文稿导入新项目时获取其属性。获取幻灯片数量和名称非常容易,因为`Presentation`接口具有`Slides`属性,该属性获取一组幻灯片描述符,其中包括`Name`属性。至于缩略图,我必须通过将每张幻灯片图像导出到临时文件来实施捕获过程,该文件在应用程序不再需要时将被删除。捕获缩略图的代码如下所示:

 
        public Image[] GetSlideImages()
        {
            if (_presentation == null)
                return null;
 
            var imageList = new List<Image>();
 
            foreach (Slide slide in _presentation.Slides)
            {
                var fileName = Path.Combine(
                    Path.GetTempPath(), 
                    string.Format("Slide{0:00}.jpg", slide.SlideNumber));
 
                slide.Export(fileName, "JPG", 800, 600);
 
                imageList.Add(Image.FromFile(fileName));
            }
 
            return imageList.ToArray();
        }

到目前为止一切都很好。然后我决定在POC版本中实现同时在两个窗口中显示幻灯片放映的功能,正是在这一点上我意识到了Office Viewer控件的一些限制。该组件实际上不允许同时有多个实例工作。您可以创建多个实例,但只有一个实例真正工作;其余的实例将变得“死掉”,甚至无法正确重绘。我检查了几个类似的组件,它们也允许在ActiveX控件内托管PowerPoint和其他活动文档服务器应用程序的GUI,结果它们具有相同的限制。

有人建议通过在一个表单上放置Office Viewer组件的一个实例,然后捕获该窗口的内容并通过另一个表单上的图像框显示它来实现演示文稿幻灯片在两个独立窗口中的同时显示。图像捕获是通过使用以下代码调用一些Windows API函数来操作图形来实现的

 
        #region Imported native types and methods 
 
        [StructLayout(LayoutKind.Sequential)]
        public struct Rect
        {
            public int Left;
            public int Top;
            public int Right;
            public int Bottom;
 
            public int Width
            {
                get { return Right - Left; }
            }
 
            public int Height
            {
                get { return Bottom - Top; }
            }
        }
 
        [DllImport("user32.dll")]
        public static extern int GetClientRect(int hwnd, [MarshalAs(UnmanagedType.Struct)] ref Rect lpRect);
 
 
        [DllImport("user32.dll")]
        private static extern int ReleaseDC(int hwnd, IntPtr hdc);
 
        [DllImport("user32.dll")]
        public static extern IntPtr GetDC(int hwnd);
 
        [DllImport("gdi32.dll")]
        public static extern int StretchBlt(IntPtr hdc, int x, int y, int nWidth, int nHeight, IntPtr hSrcDC, int xSrc, int ySrc, int nSrcWidth, int nSrcHeight, int dwRop);
 
        #endregion
 
        public Image GetCapturedImage()
        {
            if (_presentation == null)
                return null;
 
            if (_presentation.SlideShowWindow == null)
                return null;
 
            var slideShowWindowRect = new Rect();
 
            GetClientRect(_presentation.SlideShowWindow.HWND, ref slideShowWindowRect);
 
            var image = new Bitmap(slideShowWindowRect.Width, 
                slideShowWindowRect.Height, PixelFormat.Format32bppArgb);
 
            var sourceHdc = GetDC(_presentation.SlideShowWindow.HWND);
 
            try
            {
                using (Graphics graphics = Graphics.FromImage(image))
                {
                    IntPtr hDestHdc = graphics.GetHdc();
 
                    try
                    {
                        StretchBlt(hDestHdc, 0, 0, slideShowWindowRect.Width, slideShowWindowRect.Height, sourceHdc, 0,
                                   0, slideShowWindowRect.Width, slideShowWindowRect.Height, SrcCopy);
                    }
                    finally
                    {
                        graphics.ReleaseHdc(hDestHdc);
                    }
                }
            }
            finally
            {
                ReleaseDC(_presentation.SlideShowWindow.HWND, sourceHdc);
            }
 
            return image;
        }

在此代码中,我们必须获取显示演示文稿的窗口句柄,即 _presentation.SlideShowWindow.HWND,而不是更简单的 this.Handle。尝试从 this.Handle 获取图像会导致得到一个黑色矩形。

通过频繁调用此方法,我确实在两个窗口中实现了图像高度同步,即使幻灯片涉及一些动画(图2)。 

Figure2.png

图2. 两个窗口中的同步幻灯片放映。

上述代码的问题在于,它在Windows Vista和Windows 7上只有在使用Aero等现代主题时才能正常工作。如果您切换到Windows Classic等简化主题,或者在Windows XP下(使用任何主题)运行应用程序,您可能会看到这样奇怪的图像(图3)。 

Figure3.png

图3. 复制窗口图像的奇特副作用 

问题在于,对于简化主题,系统不会将每个窗口的图像保存在内存中,它只是“记住”所有窗口根据其Z轴顺序在屏幕上的最终投影。在这种情况下执行我们的图像捕获代码,结果是捕获了所有与目标窗口重叠的窗口内容以及窗口本身的内容,这有时会产生一些奇怪的效果。无论如何,该方法被证明不适合我们的任务。

创建混合解决方案

如您所见,此时我们的项目问题多于有效的解决方案,成功创建所需应用程序的机会看起来相当渺茫。当我们查看Visual C++示例项目,寻找通过OLE与活动文档服务器交互的一些想法时,找到了一个有效的解决方案。一个名为MFCBind的特定项目引起了我的注意(它位于Visual C++示例的MFC\OLE文件夹中)。该项目的名称中包含“bind”一词,因为它旨在将多个活动文档绑定到一个复合文档中。当我尝试向这样一个复合文档添加PowerPoint演示文稿时,我发现MFCBind应用程序能够做到我们期望网络浏览器做到的事情——即使在默认注册表设置下,它也支持在其主窗口中托管Office 2007文档(图4)。当托管的演示文稿变为活动状态时,MFCBind的原始工具栏被PowerPoint的功能区取代。然而,在我按下F5键开始幻灯片放映后,功能区变得不可见(图5)。 

Figure4.png

图4. MFCBind托管的PowerPoint演示文稿 

Figure5.png

图5. 幻灯片放映模式下的托管演示文稿 

因此,有一个带有源代码的示例应用程序,它能够以我们所需的方式解决我们一直努力解决的问题。自然而然地,我开始探索MFCBind应用程序的代码。像大多数MFC应用程序一样,MFCBind是根据文档-视图-模板模式设计的,该模式一直被推荐用于基于MFC的应用程序,作为将数据与演示逻辑分离的一种方式。在这种特定情况下,所有的魔法都存在于文档类中:实际上,`CMFCBindDoc`类派生自`COleDocument`,它充当`CMFCBindCntrItem`类(`COleDocObjectItem`的后代)实例的容器;这些类共同提供了托管活动文档所需的所有功能。你看,C++开发人员对于像我们这样的任务有一个现成的解决方案。 

由于我们希望用C#开发产品,我们需要找到一种方法来结合MFC库提供的现成解决方案,同时仍然使用C#、WPF、工作流设计器和.NET Framework的其他功能。我们考虑了以下选项

  1. 使用MFC在C++中创建一个可重用组件(ActiveX),该组件随后可以嵌入到基于.NET的应用程序中;
  2. 学习MFC中活动文档托管基础设施的功能是如何实现的,并在C#中为.NET复制类似的解决方案;
  3. 使用非托管C++应用程序作为宿主,负责创建主窗口和视图来托管活动文档,并使用WPF实现的更复杂复杂的GUI元素对其进行装饰。

我逐一研究了这些选项的可行性。第一个选项不得不放弃,因为提供活动文档托管基本功能的MFC类`COleDocument`是根据文档-视图-模板模式设计的,这与创建ActiveX控件无关。第二个选项也被放弃了,因为我发现活动文档托管基础设施文档非常糟糕;实际上,我所能找到的只有这本书中关于活动文档服务器和活动文档宿主分别必须实现的COM接口列表,以及MSDN库中对这些接口过于简短的描述,没有具体的实现细节。检查MFC库的源代码也没有增加任何有价值的信息,因为接口的实现分散在众多类中,如果没有适当的文档,将难以理解。此外,在合理的时间内,对于我们这种规模的团队来说,实现整个活动文档托管基础设施将是一项巨大的任务。因此,我认为第三个选项,尽管乍一看可能很奇怪,但这是我们唯一能将MFC/C++和WPF/C#统一到一个应用程序中的方法。

为了证明这个选项对我有用,我必须确保我们可以实现以下场景

  1. 我们需要能够在PowerPoint演示文稿打开后获取`Presentation`接口的引用。拥有此引用将使我们能够获取导入演示文稿到新项目所需的演示文稿属性;
  2. 我们需要能够以编程方式启动幻灯片放映,就像我们使用Office Viewer组件一样;
  3. 打开演示文稿时,我们希望它立即以幻灯片放映模式显示,这样用户就不会被观看GUI状态的中间转换(例如绘制和随后隐藏功能区以及这些操作引起的布局转换)所困扰;
  4. 我们需要阻止PowerPoint对用户事件(如按热键和鼠标点击)做出反应,使其完全受我们应用程序的控制
  5. 最后,我们需要将托管演示文稿的子窗口与WPF UI元素集成。

第一个任务很容易解决,如以下代码所示

 
	// Create new item connected to this document.
	pItem = new CMFCBindCntrItem(this);
	ASSERT_VALID(pItem);
 
	CString fileName = openFileDialog.GetPathName();
	ASSERT(fileName.GetLength() > 0);
 
	if (!pItem->CreateFromFile(fileName))
	{
		delete pItem;
		return;
	}
 
	ASSERT_VALID(pItem);
 
	// make sure we deactivate any active items first.
	COleClientItem* pActiveItem = GetInPlaceActiveItem(pViewObjCont);
	if (pActiveItem != NULL)
		pActiveItem->Deactivate();
 
	pItem->Activate(OLEIVERB_SHOW, pViewObjCont);
	ASSERT_VALID(pItem);
 
	// set selection to last inserted item
	pViewObjCont->m_pSelection = (CMFCBindCntrItem*) pItem;
	UpdateAllViews(NULL);
 
	PowerPoint::_PresentationPtr presentation = pItem->m_lpObject;

将 `CMFCBindCntrItem` 的实例绑定到 PowerPoint 演示文稿文件后,我们将在该类的 `m_lpObject` 成员变量中存储对已打开演示文稿的引用。此变量的类型为 `LPOLEOBJECT`,实际上是指向 `IOleObject` 接口的指针。使用智能指针,我们可以非常直接地将此实例转换为 Presentation 接口。因此,第一个任务已解决。 

解决第二个问题并不那么简单和直接。你可能还记得Office Viewer组件有一个有用的`SlideShowPlay`方法,它允许以全屏或就地模式启动幻灯片放映。PowerPoint的COM接口也有一些成员用于启动幻灯片放映;这里有一段代码演示了如何通过它们来完成:

 
    PowerPoint::SlideShowSettingsPtr ssSettings = 
    presentation->GetSlideShowSettings();
	    ssSettings->PutShowType(PowerPoint::PpSlideShowType::ppShowTypeKiosk);
	    ssSettings->Run();

我们得到一个实现`SlideShowSettings`接口的对象,然后我们可以通过更改一些参数的值来配置幻灯片放映,这些参数指示幻灯片放映是否必须是全屏模式,水平滚动条是否必须可见等(这与`SlideShowPlay`方法的参数非常相似)。在此示例代码中,我们更改了`ShowType`属性的值,该属性指示幻灯片放映是否将以全屏模式显示。此属性是枚举类型`PpSlideShowType`幻灯片放映,它可以接受以下值:`ppShowTypeSpeaker`、`ppShowTypeWindow`和`ppShowTypeKiosk`。当`ppShowTypeSpeaker`和`ppShowTypeKiosk`值时,幻灯片放映以全屏模式启动;当`ppShowTypeWindow`时,幻灯片放映在新建的PowerPoint应用程序窗口中启动;通过COM接口没有其他可用选项。

在Spy++应用程序的帮助下,我找到了一个变通方案来达到与Office Viewer组件中相同的行为。我注意到幻灯片放映是响应F5键按下或点击功能区中相应按钮而启动的。我尝试生成一个类似的键盘事件,但这不起作用。至于模拟点击功能区按钮,这似乎不可能,因为功能区可能已实现为WPF控件,其按钮从操作系统的角度来看不是窗口。实际上,找到了一个更优雅的解决方案。使用Spy++,我可以检查在我们的视图中托管演示文稿后创建的子窗口树(图6),并监控我在启动幻灯片放映时发送给它们的消息。在这两种情况下,我都发现幻灯片放映开始前,向窗口(在图6中由值`000F1418`标识)发送了`WM_COMMAND`消息,其`wParam=0x100AC`。我得出结论,此消息是子窗口切换到幻灯片放映模式的信号。(关于该窗口还有一件值得注意的事情:作为我们应用程序窗口的后代,它由PowerPoint进程拥有。)

Figure6.png

图6. Spy++中的窗口树

我试图在我的代码中重现一条相同的消息并将其发送到托管窗口

 
// locate the host window of the PowerPoint presentation
HWND hPowerPointSiteOwner = FindWindowEx(pView->m_hWnd, NULL, _T("childClass"), NULL);
 
ASSERT(hPowerPointSiteOwner);
 
HWND hPowerPointSite = FindWindowEx(hPowerPointSiteOwner, NULL, _T("childClass"),
   NULL);
 
ASSERT(hPowerPointSite);
 
// start the slideshow
SendMessage(hPowerPointSite, WM_COMMAND, 0x100AC, NULL);

在这段代码中,我拥有视图窗口的句柄(在图6中,其句柄值为`000E1102`),首先定位“childClass”窗口类的匿名窗口,然后通过传递相同的窗口类名定位其唯一的子窗口,并获得其句柄后发送消息。嗯,它成功了!

我需要解决的下一个问题是防止PowerPoint功能区在幻灯片放映开始之前出现在我们的应用程序窗口中。尽管我在我们的窗口中托管演示文稿后立即启动幻灯片放映,但功能区会非常短暂地可见,导致我们的应用程序窗口内部发生布局转换。同样,Spy++在解决此问题方面非常有帮助。我发现,在演示文稿托管到我们的应用程序窗口内部后,PowerPoint在我们的应用程序窗口内部创建了五个辅助子窗口。这些窗口名为“MsoDockLeft”、“MsoDockRight”、“MsoDockTop”、“MsoDockBotton”和“MsoWorkPane”。功能区嵌套在“MsoDockTop”窗口内部(图7)。显然,PowerPoint足够智能,可以在父窗口中执行布局转换,使其面板与其他子窗口并排放置。 

Figure7.png

图7. PowerPoint创建的辅助子窗口 

我需要拦截PowerPoint嵌套这些窗口的时刻,并以某种方式摆脱它们。幸运的是,在Microsoft Windows中,当为窗口创建新子窗口时,会通过`WM_PARENTNOTIFY`消息通知它;这使我能够拦截创建任何这些窗口的时刻,即使构造是在另一个进程中执行的。我决定温柔地摆脱它们的最佳方法是使它们不可见并更改它们的拥有者

 
void CMainFrame::OnParentNotify(UINT message, LPARAM lParam)
{
	if (message == WM_CREATE)
	{
		HWND hWnd = (HWND)lParam;
		
		CString className;
		GetClassName(hWnd, className.GetBufferSetLength(MAX_PATH), MAX_PATH);
		className.ReleaseBuffer();
 
		if (className == _T("MsoCommandBarDock") || className == _T("MsoWorkPane"))
		{
			if (::IsWindowVisible(hWnd))
				::ShowWindow(hWnd, SW_HIDE);
 
			::SetParent(hWnd, HWND_MESSAGE);
		}
	}
	
	CFrameWnd::OnParentNotify(message, lParam);
}

在这段代码中,我们将 `HWND_MESSAGE` 指定为这些窗口新所有者的句柄。此常量标识特殊的消息专用窗口,该窗口根据定义是不可见的。这再次奏效了,我们应用程序中再也看不到任何功能区的迹象。请注意,PowerPoint 将功能区和其他子窗口嵌入到框架窗口中,而不是视图中,因此 `WM_PARENTNOTIFY` 消息必须由框架窗口处理。

我们正在处理第四个任务,即阻止PowerPoint对用户操作(如按下热键和鼠标点击)做出反应。在Spy++的帮助下,我发现PowerPoint拦截了热键的按下,然后将通知分发给我们视图中托管的其中一个子窗口。这些通知以`WM_COMMAND`消息的形式发送;之前我使用类似的通知来启动幻灯片放映。但是,在启动幻灯片放映后,我必须阻止用户停止它,例如通过按下`Esc`键,因为我们需要完全控制托管演示文稿的行为。

Figure8.png

图8. 重新托管的PowerPoint窗口树

使用 Spy++,我探索了幻灯片放映模式下窗口树的结构(图 8)。在此截图中,`00470E2C` 窗口是我们的视图,它是演示文稿窗口的宿主,其所有后代窗口都是 PowerPoint 在演示文稿由我们的视图托管时创建的。监视这些后代窗口所消耗的消息显示,`WM_COMMAND` 消息被发送到在图 7 中标识为 `005F0EE0` 的窗口。因此,为了防止托管演示文稿对热键做出反应,我们必须覆盖该窗口的 `WM_COMMAND` 处理。这可以通过子类化窗口来实现,即用我们的自定义函数替换其消息处理过程,该函数将处理一些特定的消息,并为处理所有其他消息调用默认过程。事实上,当处理同一进程拥有的窗口时,子类化是一种相对简单的技术,但在这种情况下,我必须子类化一个由 PowerPoint 而非我们的进程创建和拥有的窗口;因此,我无法使用直接方法对其进行子类化。但我们可以代表 PowerPoint 来完成。 

为了实现这一点,我必须创建一个DLL,将其加载到PowerPoint进程的地址空间中,然后调用DLL中实现的一个函数,该函数将对指定的窗口进行子类化。这项技术在这篇文章中描述得非常详细,但我想指出的是,我使用的方法与Robert Kuster描述的方法略有不同。他建议将必须在另一个进程上下文中执行的代码放入DllMain函数中,以便代码在DLL加载到进程后立即执行。我将子类化代码放在DLL中的一个单独函数中,并代表另一个进程调用它。我确信这是一种更好的方法,因为DllMain是由操作系统调用的,您不能直接向其传递任何数据,而DLL中的任何其他函数都可以有目的地调用,并传递适当的数据参数。 

一旦DLL加载到另一个进程中,就可以获取它在该进程地址空间中的地址,如Robert所描述的: 

 
	HANDLE hProcess = OpenProcess(
        PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ,
		FALSE, wndProcessId);
	
	// 1. Allocate memory in the remote process for szLibPath
	pLibRemote = ::VirtualAllocEx( hProcess, NULL, sizeof(libPath), MEM_COMMIT, PAGE_READWRITE );
 
	// 2. Write szLibPath to the allocated memory
	::WriteProcessMemory( hProcess, pLibRemote, (void*)libPath, sizeof(libPath), NULL );
 
	// Load our DLL into the remote process (via CreateRemoteThread & LoadLibrary)
	hThread = ::CreateRemoteThread( hProcess, NULL, 0,
		(LPTHREAD_START_ROUTINE) ::GetProcAddress( hKernel32,
#ifndef UNICODE
		"LoadLibraryA" ),
#else 
		"LoadLibraryW" ),
#endif
		pLibRemote, 0, NULL );
 
	::WaitForSingleObject( hThread, INFINITE );
 
	// Get handle of the loaded module
	::GetExitCodeThread( hThread, &hLibModule );

这里我假设 `libPath` 包含加载到另一个进程的 DLL 的完整路径。代码执行后,我们在 `hLibModule` 中获得了 DLL 的地址。不幸的是,在这种情况下,我们不能简单地通过调用 `GetProcAddress` 来获取加载 DLL 中函数的地址,因为它会返回 NULL。我使用了一种变通方法来获取加载到另一个进程的 DLL 中所需函数的地址:首先,我将相同的 DLL 加载到“我的”进程中;其次,通过调用 `GetProcAddress` 获取所需函数的地址;第三,我从函数的地址中减去 DLL 的地址;结果就是函数的偏移量

 
	HMODULE hLocalPWSLib = LoadLibrary(libPath);
	FARPROC doSubclassingProc = GetProcAddress(hLocalPWSLib, "DoSubclassing");
	FARPROC doSubclassingProcOffset = (FARPROC)((int)doSubclassingProc - (int)hLocalPWSLib);
	FreeLibrary(hLocalPWSLib);

有了这个偏移量,我就可以计算出另一个进程中此函数的地址。我代表另一个进程调用函数的代码如下所示:

 
	SubclassData * pData = (SubclassData*)VirtualAllocEx(hProcess, NULL, sizeof(SubclassData), 
								MEM_COMMIT, PAGE_READWRITE);
	WriteProcessMemory(hProcess, &pData->hPowerPointSite, &hPowerPointSite, sizeof(HWND), 
				NULL);
	WriteProcessMemory(hProcess, &pData->hSlideShowWindow, &hSlideShowWindow, sizeof(HWND), 
				NULL);
 
	hThread = CreateRemoteThread(hProcess, NULL, 0, 
		(LPTHREAD_START_ROUTINE)((int)hLibModule + (int)doSubclassingProcOffset),
		pData, 0, NULL);
 
	WaitForSingleObject(hThread, INFINITE);
 
	DWORD result;
	::GetExitCodeThread(hThread, &result);
 
	::CloseHandle(hThread);
	::VirtualFreeEx( hProcess, pData, sizeof(SubclassData), MEM_RELEASE );

因此,我在PowerPoint进程的地址空间中分配了一个结构体,向其字段写入数据,并通过CreateRemoteThread调用所需的函数,并将指向该结构体的指针作为参数传递。当被调用时,DoSubclassing函数通过分配一个新的消息处理过程来子类化指定窗口,该过程将“吞噬”所有WM_COMMAND消息

 
	if (msg == WM_COMMAND) 
		return NULL;

通过这样做,我阻止了托管演示文稿对热键的反应,但我也必须抑制对鼠标消息的反应。我通过使用Spy++发现鼠标消息是由另一个窗口处理的,在图7中该窗口标识为`006F181E`,我对其应用了相同的子类化技术,但这次我不想让鼠标消息被简单地“吞噬”,我希望它们被发布到我的视图窗口,以便我的应用程序可以处理演示文稿窗口上的鼠标点击

 
	if (msg == WM_LBUTTONDOWN || msg == WM_LBUTTONDBLCLK || msg == WM_LBUTTONUP 
		|| msg == WM_RBUTTONDOWN || msg == WM_RBUTTONDBLCLK || msg == WM_RBUTTONUP)
	{
		DWORD powerPointProcessId;
		GetWindowThreadProcessId(hWnd, &powerPointProcessId);
		
		DWORD parentProcessId = powerPointProcessId;
		HWND hCurWnd = hWnd;
		HWND hParent;
		while (parentProcessId == powerPointProcessId)
		{
			hParent = GetParent(hCurWnd);
			
			if (hParent)
				GetWindowThreadProcessId(hParent, &parentProcessId);
			else
				break;
 
			hCurWnd = hParent;
		}
 
		if (hParent)
			PostMessage(hParent, msg, wParam, lParam);
 
		return NULL;
	}

这段代码会向上遍历窗口树,尝试找到第一个不属于PowerPoint进程的祖先窗口。从图7中可以看到,属于我们进程的第一个祖先是视图窗口,因此视图将接收鼠标消息。

对这个窗口进行子类化还有一个微妙之处。在我的应用程序中,子类化代码在幻灯片放映开始后立即执行,在调试应用程序时,我发现`005F0EE0`窗口当时总是存在,而`006F181E`窗口可能偶尔会丢失,这可能是由于我的应用程序和PowerPoint之间的竞态条件造成的。因此,对视图窗口执行此代码

 
	HWND hPowerPointSiteOwner = ::FindWindowEx(m_hWnd, NULL, _T("childClass"), NULL);
	hPowerPointSite = ::FindWindowEx(hPowerPointSiteOwner, NULL, _T("childClass"), NULL);
	hSlideShowWindow = ::FindWindowEx(hPowerPointSite, NULL, _T("paneClassDC"), NULL);

有时可能导致 `hSlideShowWindow` 为 NULL。为了处理这种情况,我不得不添加一些冗余。首先,我尝试在幻灯片放映开始后立即定位“paneClassDC”类的窗口,如果找到,则调用其子类化代码。但我也必须将处理 `WM_PARENTNOTIFY` 消息的额外逻辑放入分配给 `005F0EE0` 窗口的子类化过程中,该窗口处理 `WM_COMMAND` 消息,同时也是 `006F181E` / “paneClassDC”窗口的父窗口。如果该窗口在我们为 `005F0EE0` 窗口调用子类化函数时尚未创建,则 `005F0EE0` 的新窗口过程将拦截 `006F181E` 稍后创建的时刻,然后为其调用子类化函数。

 
	if (msg == WM_PARENTNOTIFY && wParam == WM_CREATE)
	{
		HWND hChildWindow = (HWND)lParam;
 
		char className[MAX_PATH];
 
		GetClassNameA(hChildWindow, className, MAX_PATH);
 
        	if (strcmp(className, "paneClassDC") == 0)
		{
			SubclassWindow(hChildWindow, (FARPROC)SubclassPresentationWindowProc);
 
			return NULL;
		}
	}

同样的方法用于隐藏默认情况下与幻灯片放映一起可见的垂直滚动条:我尝试在幻灯片放映开始后立即定位并拆除它,同时如果它稍后创建,我拦截 `WM_PARENTNOTIFY` 消息(“拆除”的意思是隐藏并将其拥有者更改为 `HWND_MESSAGE`,就像我处理功能区一样)。

所以第四个任务解决了。

上述列表中的最后一个任务是如何将这个MFC应用程序与WPF控件集成。在讨论解决方案之前,让我们回顾一下到目前为止我们所获得的:有一个基于文档-视图架构的MFC/C++非托管应用程序,它在启动时创建主窗口,而实现主窗口的类负责创建视图,稍后演示文稿将托管在该视图中。创建视图的逻辑在MFC库中实现,不能委托给任何其他类,因为主窗口根据设计负责此任务。但是,一旦视图创建,我们可以对其进行任何我们需要的操作,甚至可以更改其父窗口,该父窗口最初是主窗口。基于这个简单的想法,我提出了以下解决方案:在结合MFC和WPF的混合应用程序中,我们必须将MFC相关部分用作宿主,WPF控件将集成到其中。由C++代码创建的主窗口最初必须是空的——没有菜单,没有工具栏,没有状态栏;所有这些元素都必须使用WPF实现,因为后者提供了更丰富的工具,既用于设计美观复杂的GUI,也用于本地化等辅助任务。因此,显然我们将拥有一个包含菜单、工具栏、状态栏以及我们需要的任何其他内容的复合WPF控件,并且该控件将在启动时嵌套到主窗口中。那么视图呢?嗯,由于主窗口负责创建它,它仍然会创建它;但创建后,视图必须立即嵌套到WPF复合控件内部,并更改其父窗口。

尽管乍一看可能显得庞大而复杂,但这种想法的实现却相当直接。原始的MFC项目配置为使用托管.NET类。在 `WM_CREATE` 消息的处理程序中,添加了创建 `HwndSource` 实例作为主窗口子级的代码。然后实例化了前面提到的复合 WPF 控件(它在单独的程序集中实现,并在宿主应用程序项目中添加了对它的引用),并将新创建的实例指定为 `HwndSource` 的根视觉元素。通过这种方式,我们确保复合控件将嵌套在主窗口中。

新构造的视图被包装在派生自HwndHost的自定义托管类实例中,并且该包装器实例被传递给WPF控件,以嵌套在其子容器控件之一中。HwndHost派生包装器的代码如下所示:

 
#using <PresentationFramework.dll>
#using <PresentationCore.dll>
#using <WindowsBase.dll>
 
using namespace System;
using namespace System::Windows;
using namespace System::Windows::Controls;
using namespace System::Windows::Interop;
using namespace System::Runtime::InteropServices;
 
class CMFCBindView;
 
ref class SlideShowHwndHost : public HwndHost
{
public:
	SlideShowHwndHost(CMFCBindView* pView, int width, int height) 
		: _hParent(NULL)
		, _width(width)
		, _height(height)
	{
		if (pView == NULL)
			throw gcnew ArgumentNullException(gcnew String("pView"));
 
		_pView = pView;
	}
 
protected:
	virtual HandleRef BuildWindowCore(HandleRef hwndParent) override
	{
		_hParent = (HWND)hwndParent.Handle.ToInt32();
		
		::SetParent(_pView->m_hWnd, _hParent);
		::SetWindowPos(_pView->m_hWnd, NULL, 0, 0, _width, _height, SWP_SHOWWINDOW);
 
		return HandleRef(this, IntPtr(_pView->m_hWnd));
	}
 
	virtual void DestroyWindowCore(HandleRef hwnd) override
	{
		::DestroyWindow((HWND)hwnd.Handle.ToInt32());
	}
 
private: 
	CMFCBindView * _pView;
	HWND _hParent;
	int _width;
	int _height;
};

由于 HwndHost 类派生自 `Visual` 类,因此它可以嵌套到任何 WPF 容器控件中。例如,在我的 POC 项目中,我创建了一个复合控件,其中包含菜单、网格和嵌套在网格某个单元格中的框架(图 9)。 

Figure9.png

图9. 示例复合控件 

嵌套HwndHost派生包装器实例的代码非常简单

 
            viewHost.Content = child;

其中 `viewHost` 是框架(参见图9),`child` 是包装器。

正如您从图10中看到的那样,在生成的GUI中,WPF控件和非托管窗口无缝地集成在一起,我们得到了我们想要的结果。 

Figure10.png

图10. 混合应用程序的主窗口,带有托管演示文稿

还有一点:为了实现托管代码和非托管代码之间的通信,我们需要保持对象之间的引用。例如,当用户点击“打开”菜单项时,事件在C#代码中处理,但随后必须执行非托管代码才能打开演示文稿文件并将其嵌套到视图中。为了简化这种通信,我在C++代码中创建了一个托管类,该类实现了与复合WPF控件在同一程序集中定义的托管接口。该类是非托管类的包装器,实现了主窗口;它用作应用程序的托管层和非托管层之间的桥梁,在创建复合WPF控件之前立即实例化,并作为接口的引用传递给控件的构造函数。控件保留此引用,并可在需要时调用包装器中实现的方法。以下是接口及其实现的定义

 
namespace SampleWpfUserControlLibrary
{
    public interface IApplicationHostWindow
    {
        void OpenDocument();
        void Exit();
    }
}
 
using namespace SampleWpfUserControlLibrary;
 
ref class ApplicationHostWrapper : IApplicationHostWindow
{
public:
	ApplicationHostWrapper(CMainFrame * pMainFrame)
	{
		_pMainFrame = pMainFrame;
	}
	
	virtual void __clrcall Exit() sealed
	{
		_pMainFrame->SendMessage(WM_CLOSE);
	}
 
	virtual void __clrcall OpenDocument() sealed
	{
		_pMainFrame->OpenDocument();
	}
 
private:
	CMainFrame * _pMainFrame;
};

到现在为止,我已经实现了我们使用Office Viewer ActiveX所能拥有的所有功能;此外,我还可以运行任意数量的应用程序实例,并在所有实例中同时观看幻灯片放映,这在使用Office Viewer时是不可能的。但是,如何显示第二个与主窗口同步的幻灯片放映窗口呢?如果你还记得,无法实现此功能让我放弃了Office Viewer并开始寻找替代解决方案。好吧,实际上,在这个混合解决方案中,这个任务以一种非常简单直接的方式解决了:当打开演示文稿时,我只需创建第二个框架窗口、第二个OLE文档和第二个视图,然后再次打开演示文稿文件。当用户点击主窗口中的演示文稿视图时,消息处理代码会找到第二个文档的引用,并指示它切换到下一张幻灯片。同时,第二个视图配置为不处理鼠标点击。您可以在附加的示例项目中查看此实现的详细信息。这种两个窗口同时幻灯片放映只有一个缺点:当观看包含动画的演示文稿时,您可能会发现动态幻灯片不同步。这是因为指示PowerPoint切换到下一张幻灯片的函数是同步工作的,并且只有在所有图形过渡到下一张幻灯片完成后才返回。我还没有找到解决此问题的方法。 

演示项目 

本文的演示项目可从此处下载。它包含一个Visual Studio 2008解决方案,其中有两个C++项目(应用程序和用于子类化的dll)和一个C#项目(带WPF复合控件的类库)。您可以构建并运行它。从主窗口菜单中选择“文件\打开”,然后指定一个PowerPoint演示文稿。请注意,您需要在计算机上安装PowerPoint才能运行此演示。 

结论  

本文和演示项目证明,可以将PowerPoint演示播放器嵌入到自定义应用程序中,并对其行为进行完全的编程控制。打开演示文稿时,不总是需要显示它,您可以隐形地打开它以从文件中获取一些数据(在我们的例子中,我们只需要使视图不可见即可实现此目的),而当通过COM接口与PowerPoint交互时,这是不可能的,正如我前面提到的,因为PowerPoint应用程序窗口总是可见的。 

© . All rights reserved.