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

将 HTML 文档捕获为图像

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (53投票s)

2004 年 4 月 4 日

CPOL

15分钟阅读

viewsIcon

652390

downloadIcon

12911

捕获 HTML 文档为图像。

引言

我的应用程序允许使用 MSHTML 对 HTML 页面进行有限的编辑。每个 HTML 页面都基于一个模板文件,并且用户对该模板文件能够执行的操作范围是有限的。用户永远无法创建空白 HTML 页面。

因此,我的应用程序中必须有一种机制允许用户选择要基于的模板是哪个模板。

我想向用户展示缩略图列表,每个缩略图代表一个模板页面。为了做到这一点,我不得不设计一种方法,将 HTML 页面转换为图像。提供简单的列表框和模板名称的替代方案有点太老土了(20 世纪 90 年代初风格)。

本文就是为此而生。

徒劳的尝试

幸运的是,我的应用程序对页面大小设置了特定的限制。整个页面必须适合 800 x 600 的框架,且不带滚动条。

我最初的方法是使用 MSHTML 渲染页面,创建一个内存位图,获取 MSHTML 显示窗口的句柄,然后将显示窗口的位图使用 `BitBlt` 复制到我的内存位图中,最后缩放并保存结果。

效果很好,但有一个小细节。

为了使用 `BitBlt` 方法将 HTML 页面渲染到图像文件,页面必须在屏幕上可见。`BitBlt` 只能抓取已经绘制过的设备上下文中的位图,如果设备上下文代表的内容实际上在屏幕上不可见,Windows 的 `WM_PAINT` 优化就会生效,并将那些区域从更新区域中排除。结果是 MSHTML 不会在屏幕上不可见的部分的设备上下文中绘制。

如果你想创建屏幕上已有的内容的图像,那很好。否则,要创建图像,你就必须将那个内容呈现在屏幕上。这会导致大量的闪烁,因为页面会短暂地渲染到屏幕上,以便通过 `BitBlt` 抓取其位图。

即便如此,我对结果也几乎满意了。闪烁看起来不算太糟糕。我甚至给几个人看了,向他们展示了更新图像时的样子,他们似乎也不太介意。但它让我烦恼。一定有更好的办法!

第二次尝试

对 MSDN 进行了一些研究,发现了 `IHTMLElementRender` 接口。听起来很有希望。它有一个名为 `DrawToDC()` 的成员函数,听起来很适合。事实上也确实如此。一旦你获得了 `IHTMLElementRender` 接口,你就可以提供自己的设备上下文,让 MSHTML 将元素渲染到其中。一旦你完成了这个,缩放并保存到文件就变得微不足道了。

正如你可能猜到的,事情并没有那么简单。

我这次将以略微不同的方式呈现这个类。我们将从一个简单的类版本开始(不在下载文件中),然后随着遇到问题逐渐增加其复杂性。

CCreateHTMLImage 的简单版本

看起来是这样的。
class CCreateHTMLImage
{
public:
    enum eOutputImageFormat
    {
        eBMP = 0,
        eJPG,
        eGIF,
        eTIFF,
        ePNG,
        eImgSize
    };

                    CCreateHTMLImage();
    virtual         ~CCreateHTMLImage();

    BOOL            SetSaveImageFormat(eOutputImageFormat format);

    BOOL            CreateImage(
                        IHTMLDocument2 *pDoc, 
                        LPCTSTR szDestFilename, 
                        CSize srcSize, 
                        CSize outputSize);

protected:
    int             GetEncoderClsid(const WCHAR* format, CLSID* pClsid);

private:
    static LPCTSTR  m_ImageFormats[eImgSize];
    CLSID           m_encoderClsid;
};
这个版本的类从现有的 HTML 文档创建图像。构造函数将保存的图像格式初始化为 jpeg 文件(你可以通过调用 `SetSaveImageFormat()` 并传入 `eOutputImageFormat` 常量之一来覆盖此设置)。大部分工作在 `CreateImage()` 成员函数中完成,该函数看起来是这样的。
BOOL CCreateHTMLImage::CreateImage(
        IHTMLDocument2 *pDoc, 
        LPCTSTR szDestFilename, 
        CSize srcSize, 
        CSize outputSize)
{
    USES_CONVERSION;
    ASSERT(szDestFilename);
    ASSERT(AfxIsValidString(szDestFilename));
    ASSERT(pDoc);

    //  Get our interfaces before we create anything else
    IHTMLElement       *pElement = (IHTMLElement *) NULL;
    IHTMLElementRender *pRender = (IHTMLElementRender *) NULL;

    //  Let's be paranoid...
    if (pDoc == (IHTMLElement *) NULL
        return FALSE;

    pDoc->get_body(&pElement);

    if (pElement == (IHTMLElement *) NULL)
        return FALSE;

    pElement->QueryInterface(IID_IHTMLElementRender, (void **) &pRender);

    if (pRender == (IHTMLElementRender *) NULL)
        return FALSE;

    CFileSpec fsDest(szDestFilename);
    CBitmapDC destDC(srcSize.cx, srcSize.cy);

    pRender->DrawToDC(destDC);

    CBitmap *pBM = destDC.Close();
    
    Bitmap *gdiBMP = Bitmap::FromHBITMAP(HBITMAP(pBM->GetSafeHandle()), NULL);
    Image  *gdiThumb = gdiBMP->GetimageImage(outputSize.cx, outputSize.cy);

    gdiThumb->Save(T2W(fsDest.GetFullSpec()), &m_encoderClsid);
    delete gdiBMP;
    delete gdiThumb;
    delete pBM;
    return TRUE;
}
该函数接受指向 `IHTMLDocument2` 接口的指针、一个输出文件名以及几个 `CSize` 对象。你会在程序中的某个地方通过加载的 HTML 文档实例获得 `IHTMLDocument2` 接口。例如,如果你想在使用了 `CHtmlView` 的应用程序中创建文档的图像,你可以通过调用该视图的 `GetHTMLDocument()` 来获取接口。

我们对参数进行了一系列的 `ASSERT` 检查。然后,我们获取一个指向 `IHTMLElement` 接口的指针,该接口代表 HTML 文档的正文。一旦我们获得了它,我们就可以通过 `QueryInterface()` 获取 `IHTMLElementRender` 接口,该接口代表文档的所有视觉方面。我们不能直接从文档获取接口,因为文档不是元素,它包含元素。

如果到目前为止没有出现错误,那么就可以创建我们想要将文档绘制到的设备上下文了。为此,我使用了 Anneke Sicherer-Roetman 的优秀的 `CBitmapDC` 类,你可以在这里[^]找到它。`srcSize` 对象用于设置目标设备上下文的大小。`IHTMLElementRender::DrawToDC()` 函数不进行缩放。如果源 HTML 需要 1000 像素的宽度来绘制整个水平范围,但你传递了一个只有 500 像素宽的设备上下文,那么你只会得到 HTML 的左半部分。

一旦 MSHTML 将我们的 `IHTMLElement` 渲染到设备上下文中,我们就使用 `CBitmapDC` 的内容创建一个 GDI+ `Bitmap` 对象,然后使用 `outputSize` 对象指定图像尺寸来从 `Bitmap` 对象创建图像。GDI+ 会负责将完整尺寸的图像缩放到我们想要的尺寸。保存,进行一些清理,我们就完成了。

该类的其他成员负责保存的图像格式的细节,并且由于它们是 `protected`,所以除非我们计划从这个类派生新类,否则它们没什么意义。

`GetEncoderClsid()` 函数是从 MSDN 文档中提取的,用于获取我们想要的图像格式的正确图像编码器。

等一下!

难道这个类不也存在和上面讨论的徒劳尝试方法完全相同的问题吗?它只能从屏幕上已有的 HTML 文档创建图像。没错。但这只是类的简单版本。

如果我们想从存储在别处(硬盘、内网或互联网)的文档创建图像,我们就必须做更多的工作。我们必须使用 MSHTML 加载文档,获取文档的 `IHTMLDocument2` 接口,然后调用我们的类来创建图像。

CCreateHTMLImage 的完整版本

包含在下载文件中,看起来是这样的。
class CCreateHTMLImage : public CWnd
{
protected:
    DECLARE_DYNCREATE(CCreateHTMLImage)
    DECLARE_EVENTSINK_MAP()
    enum eEnums
    {
        CHILDBROWSER = 100,
    };
public:
    enum eOutputImageFormat
    {
        eBMP = 0,
        eJPG,
        eGIF,
        eTIFF,
        ePNG,
        eImgSize
    };

                    CCreateHTMLImage();
    virtual         ~CCreateHTMLImage();

    BOOL            Create(CWnd *pParent);
    BOOL            SetSaveImageFormat(eOutputImageFormat format);

    BOOL            CreateImage(
                        IHTMLDocument2 *pDoc, 
                        LPCTSTR szDestFilename, 
                        CSize srcSize, 
                        CSize outputSize);
    BOOL            CreateImage(
                        LPCTSTR szSrcFilename, 
                        LPCTSTR szDestFilename, 
                        CSize srcSize, 
                        CSize outputSize);

protected:
    CComPtr m_pBrowser;
    CWnd            m_pBrowserWnd;

    virtual BOOL    CreateControlSite(
                        COleControlContainer* pContainer, 
                        COleControlSite** ppSite, 
                        UINT nID, 
                        REFCLSID clsid);
    virtual void    DocumentComplete(LPDISPATCH pDisp, VARIANT* URL);
    int             GetEncoderClsid(const WCHAR* format, CLSID* pClsid);

private:
    static LPCTSTR  m_ImageFormats[eImgSize];
    CLSID           m_encoderClsid;
};
有几个变化应该会让你注意到。首先,类的完整版本是从 `CWnd` 派生的,而简单版本不是。这表明将 HTML 文档转换为图像所做的至少一些更改在某种程度上涉及创建窗口。你还没有知道全部内容!

简单版本中存在的所有函数在完整版本中都没有改变。你会看到我添加了另一个 `CreateImage()` 重载。这个版本接受一个源文档名而不是 `IHTMLDocument2` 接口指针。

这个新函数是我向完整版本添加所有新内容的原因,所以让我们从它开始,然后向外扩展。

加载外部文档

最初,我试图直接使用 `IHTMLDocument2` 接口。大致如下。
IHTMLDocument2 *pDoc = (IHTMLDocument2 *) NULL;

if (CoCreateInstance(
        CLSID_HTMLDocument, 
        NULL, 
        CLSCTX_INPROC_SERVER, 
        IID_IHTMLDocument2, 
        (void**) &pDoc) == S_OK)
{
    if (pDoc != (IHTMLDocument2 *) NULL)
    {
        //  Do stuff
    }
}
这有效,并且我们获得了一个可以使用的文档接口。有一个小问题。没有办法直接加载文档。我们可以调用 `IHTMLDocument2::write()` 来渲染包含 HTML 的字符串,但这意味着我们必须将文档内容加载到一个字符串中。对于本地文件来说这很好,但如果你想对网上的网站进行成像呢?我只想创建图像 - 而不是编写一个完整的 `http:` 协议处理器。

好吧,放弃那种方法。使用 `IWebBrowser2` 接口怎么样?实例化一个 `IWebBrowser2` 的代码与前面的代码片段几乎相同,所以我不会重复它,只需将 `IWebBrowser2` 替换掉 `IHTMLDocument2` 即可。要加载文档,我们只需使用 `IWebBrowser2::Navigate()` 或 `IWebBrowser2::Navigate2()` 导航到它。

于是我写了代码并进行了测试。`Navigate2()` 调用返回成功,但文档没有加载。或者至少,如果加载了,接口的 `ReadyState` 永远不会改变,让我知道它已经完成。显然,在知道文档已加载之前,我们不能将其渲染到设备上下文中,事实上,从 `IWebBrowser2` 接口查询我们需要的 `IHTMLDocument2` 接口总是返回一个 NULL 接口指针,表明文档尚不存在。

在基于 `CHtmlView` 的虚拟应用程序上重复测试,揭示了我们已知的情况。`IWebBrowser2::ReadyState` 在文档加载时会发生变化,并且一旦文档加载完成,我们就可以查询 `IWebBrowser2` 接口以获取 `IHTMLDocument2` 接口,并获得一个有效的接口指针。

嗯,我们有什么不同之处?嗯,第一个也是最明显的变化是,我们实例化了一个 `IWebBrowser2` 实例,但没有匹配的显示窗口。我们稍后会看到,这个窗口对 `IWebBrowser2` 接口非常重要,尽管文档中没有提到这一点。

现在是时候研究 `CHtmlView` 是如何工作的了。

CHtmlView

是一个 MFC 类。幸运的是,我们有 MFC 的源代码。这意味着我们可以查看一个工作示例,找出我们哪里做错了或者根本没做。

我们找到的第一件事(在 `afxhtml.h` 中)是类定义。里面有很多内容,大部分与我们无关。有趣的是一个名为 `m_wndBrowser` 的 `CWnd` 成员变量。啊哈。我们知道 `CHtmlView` 最终是从 `CWnd` 派生的,因此它本身就是一个窗口。那么为什么它需要一个 `CWnd` 类型的成员变量呢?让我们看看 `CHtmlView::Create()` 中的相关代码,看看发生了什么(`viewhtml.cpp`)。

BOOL CHtmlView::Create(LPCTSTR lpszClassName, LPCTSTR lpszWindowName,
                        DWORD dwStyle, const RECT& rect, CWnd* pParentWnd,
                        UINT nID, CCreateContext* pContext)
{
    // create the view window itself
    m_pCreateContext = pContext;

    if (!CView::Create(lpszClassName, lpszWindowName,
                dwStyle, rect, pParentWnd,  nID, pContext))
    {
        return FALSE;
    }

    // assure that control containment is on
    AfxEnableControlContainer();

    RECT rectClient;
    GetClientRect(&rectClient);

    // create the control window
    // AFX_IDW_PANE_FIRST is a safe but arbitrary ID
    if (!m_wndBrowser.CreateControl(CLSID_WebBrowser, lpszWindowName,
                WS_VISIBLE | WS_CHILD, rectClient, this, AFX_IDW_PANE_FIRST))
    {
        DestroyWindow();
        return FALSE;
    }

    // cache the dispinterface
    LPUNKNOWN lpUnk = m_wndBrowser.GetControlUnknown();
    HRESULT hr = lpUnk->QueryInterface(IID_IWebBrowser2, (void**) &m_pBrowserApp);

    if (!SUCCEEDED(hr))
    {
        m_pBrowserApp = NULL;
        m_wndBrowser.DestroyWindow();
        DestroyWindow();
        return FALSE;
    }

    return TRUE;
}
视图窗口创建自身,然后使用 `CLSID_WebBrowser` 标识符创建一个子控件作为 ActiveX 控件。如果成功,它会查询子控件的 Web 浏览器 `IUnknown` 接口,并使用该接口获取一个 `IWebBrowser2` 接口,然后将其缓存起来供以后使用。

好了,事情开始变得明朗了。我们不应该盲目地凭空创建一个 `IWebBrowser2` 接口,而应该创建一个 Web 浏览器控件的实例,并从中获取我们的 `IWebBrowser2` 接口。

从 CHtmlView 吸取的第一个教训

让我们复制 `CHtmlView` 的做法,将我们的 Web 浏览器控件创建为我们类的子控件。我们将稍后在文章中讨论为什么需要额外的间接层

我们的创建顺序是(如果我们想为尚未加载到应用程序某个 MSHTML 实例中的页面创建图像):

  • 创建 `CCreateHTMLImage` 类的一个实例。
  • 调用该类的 `Create()` 方法。
  • 为要创建的每个图像调用一次 `CreateImage()` 方法。
完成后,我们可以调用 Web 浏览器子窗口上的 `Navigate2()`,并期望文档能够加载。事实也确实如此。让我们来看看这个函数。
BOOL CCreateHTMLImage::CreateImage(
            LPCTSTR szSrcFilename, 
            LPCTSTR szDestFilename, 
            CSize srcSize, 
            CSize outputSize)
{
    ASSERT(GetSafeHwnd());
    ASSERT(IsWindow(GetSafeHwnd()));
    ASSERT(szSrcFilename);
    ASSERT(AfxIsValidString(szSrcFilename));
    ASSERT(szDestFilename);
    ASSERT(AfxIsValidString(szDestFilename));

    CRect rect(CPoint(0, 0), srcSize);

    //  The WebBrowswer window size must be set to our srcSize
    //  else it won't render everything
    MoveWindow(&rect);
    m_pBrowserWnd.MoveWindow(&rect);

    COleVariant   vUrl(szSrcFilename, VT_BSTR),
                  vFlags(long(navNoHistory | 
                              navNoReadFromCache | 
                              navNoWriteToCache), VT_I4),
                  vNull(LPCTSTR(NULL), VT_BSTR);
    COleSafeArray vPostData;

    if (m_pBrowser->Navigate2(&vUrl, &vFlags, &vNull, &vPostData, &vNull) == S_OK)
        //  We have to pump messages to ensure the event handler (DocumentComplete)
        //  is called.
        RunModalLoop();
    else
        return FALSE;

    //  We only get here when DocumentComplete has been called, which calls 
    //  EndModalLoop and causes RunModalLoop to exit.
    IDispatch *pDoc = (IDispatch *) NULL;
    HRESULT   hr = m_pBrowser->get_Document(&pDoc);

    if (FAILED(hr))
        return FALSE;

    return CreateImage((IHTMLDocument2 *) pDoc, szDestFilename, srcSize, outputSize);
}
如果我们通过了我通常的输入参数 `ASSERT` 检查,我们就创建一个具有 `srcSize` 参数所暗示的尺寸的矩形,并将 Web 浏览器设置为这些尺寸。如果我们没有正确设置 Web 浏览器的大小,我们就无法获得准确反映 HTML 文档内容的图像。然后,我们设置一些 `COleVariant` 对象,其中包含我们的源文档名、一些标志,并调用 `Navigate2()` 方法。如果该方法成功,我们就调用 `CWnd::RunModalLoop()` 函数。

这非常重要。我最初尝试此解决方案时,使用了 `Sleep()` 和一些轮询的组合来尝试确定文档何时加载完成。结果是死锁。事实证明,一旦你启动了一个 `Navigate2()` 操作(以及 Web 浏览器控件上的许多其他操作),你就必须让消息泵运行。消息泵可以是应用程序的主泵,但如果你使用主泵,你就无法与 `CCreateHTMLImage` 类进行同步交互。如果你只想创建一个图像,这无关紧要。但如果你有一个要逐个创建的图像列表,你就必须等待第一个图像完成,然后才能开始第二个。

所以我们启动导航,然后进入 `RunModalLoop()`。过了一会儿,文档就会加载完成并触发 `DocumentComplete()` 事件。我们类中的那个事件处理程序除了调用 `ExitModalLoop()` 之外什么也不做,这使我们能够跳出 `RunModalLoop()` 函数,并允许 `CCreateHTMLImage::CreateImage()` 函数中的处理继续进行。该处理过程包括获取 `IHTMLDocument2` 接口并调用我们已经讨论过的代码。

那么,为什么我们有一个嵌入的浏览器窗口,而不是我们自己成为浏览器呢?

如果你读到这里,你可能会想,为什么我们的类会模仿 `CHtmlView`,以至于拥有一个嵌入的 `CWnd` 变量,而它才是真正的 Web 浏览器控件。

在我编写代码时,我并不清楚这是否是必需的。我编写这个类是为了它本身成为 Web 浏览器的实例,并且能够创建 HTML 文档的图像,而这些文档永远不会闪现在屏幕上。一切看起来都不错。

快乐时光里的烦恼

但在仔细检查图像时,我突然意识到出现了几个本不应该存在的伪影。滚动条!

如果你使用过 `CHtmlView`,你就知道有一个函数 `OnGetHostInfo()`,它让你有机会修改 Web 浏览器的视觉方面,包括浏览器是否显示滚动条。所以现在是时候再次深入 `CHtmlView` 的实现,看看我如何复制该功能了。

事实证明,`CHtmlView` 嵌入 Web 浏览器实例而不是自身成为实例的原因,是为了能够充当 Web 浏览器的父窗口。这很重要,因为它意味着 `CHtmlView`(或我们的类)可以创建一个 `COleControlSite` 来托管 Web 浏览器控件并响应接口查询。如果我们的类直接是 Web 浏览器的实例,那么来自 Web 浏览器的查询将导向我们的父窗口,导向我们不一定控制的代码,而这些代码几乎肯定不知道我们想通过 `GetHostInfo` 查询的答案来响应“不应该显示滚动条”。

我承认你可能确实控制着父对象,并且可以在父对象中实现一个 `COleControlSite`。但这绝对是错误的地方。为什么父窗口要了解它使用的某个任意类需要一个 `COleControlSite`,更不用说对特定查询的响应细节了?

在不详细讨论 `CWnd` 派生类如何托管 ActiveX 控件的情况下,让我们来看看我们需要在类中实现什么才能让一切正常工作。我们的 `Create()` 调用通过指定我们想要的特定 ActiveX 控件的 `CLSID` 来调用 `CreateControl()`,从而创建子 ActiveX 控件。

`CreateControl()` 执行各种操作,包括调用虚拟函数 `CreateControlSite()`。`CWnd` 中的默认实现不做任何事情,只返回一个 NULL 站点指针。当我们查看 `CHtmlView` 的实现时,我们看到它将站点指针设置为 `CHtmlControlSite` 的一个实例。好的,让我们看看那个类。事实证明它从 `COleControlSite` 派生(这不应令人惊讶),但它还实现了 `IDocHostUIHandler` 接口的所有成员函数。这很重要,因为 Web 浏览器控件通过对其父的自动化接口进行 `QueryInterface()` 来询问其容器以确定 UI 状态(例如是否显示滚动条),它请求一个 `IDocHostUIHandler` 接口。

不幸的是,我们不能在我们的类中使用 `CHtmlControlSite`,原因有两个。首先,该类的定义出现在 `viewhtml.cpp` 中,而不是我们可以包含的头文件中。我们可以很容易地通过将定义复制粘贴到我们自己的头文件中来解决这个问题。但它仍然不起作用,因为该类内置了对 `CHtmlView` 虚拟方法的了解。我们可以尝试在我们的类中修改 vtable 结构来重用 `CHtmlControlSite`,但这根本不值得(更不用说是一个维护噩梦了)。相反,我复制了整个类定义和实现,更改了名称,并修改了成员函数以实现我需要的功能。事实上,我希望所有成员函数都执行操作的只有 `GetHostInfo()` 函数。所有其他函数都不执行任何操作(但仍然必须实现)。

哇!只是为了去掉一个微小的缩略图图像上的一些滚动条,就做了这么多工作。但请记住,通过指定输出图像尺寸,该类可以用于捕获完整尺寸的图像。在完整尺寸的图像上,滚动条可能是不可取的。

使用该类

几乎是微不足道的。包含头文件,声明 `CCreateHTMLImage` 的一个实例并使用它。请记住,有两种使用它的方法。第一种是当你想要捕获你应用程序中已渲染的现有页面的图像时。
CCreateHTMLImage cht;

cht.CreateImage(m_pDoc, csOutputFile, CSize(800, 600), CSize(80, 60));
这假设 `m_pDoc` 是指向 `IHTMLDocument2` 接口的指针。此示例以 800 x 600 的尺寸捕获图像,但保存 80 x 60 的缩略图。

第二种使用类的方法是当你只有要捕获的页面的文件名或 URL 时。

CCreateHTMLImage cht;

cht.Create(this);
cht.CreateImage(csSourceFile, csOutputFile, CSize(800, 600), CSize(80, 60));
这执行相同操作,只不过它负责加载源文件(或 URL),然后捕获图像输出到文件。`Create()` 函数需要一个指向 `CWnd` 派生对象的指针,该对象必须是一个顶层窗口。我使用的是我的 `CMainFrame` 窗口。

哦,别忘了初始化 GDI+ - 你可以在这篇优秀的教程[^]中找到如何操作。

依赖项

该类使用了一些在 CodeProject 上找到的其他代码。

历史

2004 年 4 月 4 日 - 初始版本。

2004 年 4 月 4 日 - 更新了下载文件,包含所需的头文件。

2004 年 4 月 9 日 - 添加了由Jubjub[^]编写的演示项目。

2004 年 5 月 19 日 - 更新了演示项目。

© . All rights reserved.