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

使用 GDI+ 将 IHTMLElement 渲染到图像文件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.73/5 (10投票s)

2006 年 12 月 4 日

8分钟阅读

viewsIcon

114891

downloadIcon

1165

捕获 HTML 文档并将其保存为图像。

A sample snap shot.

引言

我最近看到了一篇题为“捕获 HTML 文档并将其保存为图像”的文章。我非常喜欢这篇文章。然而,有两点我不太喜欢。第一点是我不喜欢它渲染图像到文件的实现方式。这篇文章提供了一个使用第三方 GDI 包装类来处理图像渲染的解决方案。我认为通过 GDI+ 来实现会更简洁。第二点是我不喜欢的是,文章没有说明如何计算快照的宽度和高度。实际上,在我进行实验性实现时,我遇到了这个问题。我找到了一个解决办法。

我遇到的最大挑战是我不记得如何使用 GDI+ 将图像渲染到文件。然后 Image 对象可以使用其 Save() 方法将数据存储到文件中。我又回去进行在线研究。两个小时后,我找到了另一篇讨论如何向图像添加水印,然后将图像保存回文件的文章。这篇文章题为“使用 GDI+ 为 .NET 创建带水印的照片”。结合这两篇文章的理解,我能够创建一个简洁的解决方案。我现在准备将它分享给大家。

本文的优点

这取决于你的观点。我认为这项代码的最佳用途是截取网页的快照并将其存储为图像以供以后查看。这个用途可以应用于自动化 Web 测试,这样,自动化测试就可以不时地截取网页快照,供测试人员验证自动化测试的进度。

设计原理

每当我编写代码时,尤其是在尝试解决一些复杂的开发问题时,我会编写包含解决问题所需所有代码的方法,然后将代码重构为更小的块。重构代码的目的是使原始代码块成为松耦合、高内聚的对象。不再浪费时间,让我们来看第一个实现。

第一个实现的源代码

void CMainFrame::OnBnClickedButtonSnapshot()
{
   // TODO: Add your control notification handler code here
   _TCHAR BASED_CODE szFilter[] = 
      _T("JPEG Files (*.jpg;*.jpeg)" ) 
      _T("|*.jpg; *.jpeg|All Files (*.*)|*.*||");
   CFileDialog dlg(FALSE, _T("*.jpg; *.jpeg"), _T(""), 
                   OFN_HIDEREADONLY | OFN_OVERWRITEPROMPT, 
                   szFilter, this, 0);
   if (dlg.DoModal() != IDOK)
   {
      return;
   }

   CString sFileName = dlg.GetPathName();

   CHtmlView* pView = (CHtmlView*)this->GetActiveView();
   // should use reinterpret_cast.

   CComPtr<IDispatch> spDisp(pView->GetHtmlDocument());
   CComPtr<IHTMLDocument2> spDoc;
   if (FAILED(spDisp->QueryInterface(IID_IHTMLDocument2, 
                                    (void**)&spDoc)))
   {
      AfxMessageBox(_T("Unable to get the HTML Document off the browser."));
      return;
   }

   CComPtr<IHTMLElement> spBody;
   if (FAILED(spDoc->get_body(&spBody)))
   {
      AfxMessageBox(_T("Unable to get the body of the HTML Document."));
      return;
   }

   CComPtr<IHTMLElementRender> spElemRender;
   if (FAILED(spBody->QueryInterface(IID_IHTMLElementRender, 
             (void**)&spElemRender)))
   {
      AfxMessageBox(_T("Unable to create render of the body element."));
      return;
   }

   long cx=0, cy=0;
   spBody->get_offsetWidth(&cx);
   spBody->get_offsetHeight(&cy);

   Bitmap myBmp(cx, cy);
   Graphics g(&myBmp);
   HDC mydc = g.GetHDC();
   if (mydc != NULL)
   {
      spElemRender->DrawToDC(mydc);
      g.ReleaseHDC(mydc);
   }

   CLSID jpegClsid;
   GetEncoderClsid(_T("image/jpeg"), &jpegClsid);
   myBmp.Save((LPCTSTR)sFileName, &jpegClsid, NULL);
}

使用的附加函数

我还使用了一个名为 GetEncoderClsid() 的函数。这个函数是我自己编写的。我从 MSDN 上找到的。它是这样的:

int GetEncoderClsid(const WCHAR* format, CLSID* pClsid)
{
   UINT  num = 0;  // number of image encoders
   UINT  size = 0; // size of the image encoder array in bytes

   ImageCodecInfo* pImageCodecInfo = NULL;

   GetImageEncodersSize(&num, &size);
   if(size == 0)
      return -1;  // Failure

   pImageCodecInfo = (ImageCodecInfo*)(malloc(size));
   if(pImageCodecInfo == NULL)
      return -1;  // Failure

   GetImageEncoders(num, size, pImageCodecInfo);

   for(UINT j = 0; j < num; ++j)
   {
      if( wcscmp(pImageCodecInfo[j].MimeType, format) == 0 )
      {
         *pClsid = pImageCodecInfo[j].Clsid;
         free(pImageCodecInfo);
         return j;  // Success
      }
   }

   free(pImageCodecInfo);
   return -1;  // Failure
}

代码演练

在我阅读了找到的两篇(在“引言”中列出的)文章后,我进行了一些试错式的编码。那些代码非常糟糕。我不会在这里展示它们,因为它们已经湮灭了(它们被第一个迭代的实现取代了)。最后,我弄清楚了整个过程是如何工作的。下面是它的工作原理:

  1. 使用 MFC 项目中的 CHTMLView。这有助于我们访问网页。
  2. 进行一些 COM 相关操作,以获取对整个网页的 IHTMLElement 的引用。如果还没有弄清楚,我稍后会解释如何做到这一点。
  3. 获取整个网页作为 IHTMLElement 对象的 IHTMLelementRender 的引用。
  4. 初始化 GDI+。
  5. 创建一个 GDI+ Bitmap 对象。该对象的大小应该是网页的大小。创建时,Bitmap 对象是空的。如果保存它,您会看到它是一个黑色填充的位图。
  6. 创建一个 GDI+ Graphics 对象。创建时,您可以将 Bitmap 对象的地址传递给 Graphics 对象的构造函数。这样做是为了将 Bitmap 对象与 Graphics 对象关联起来。稍后我将对此进行更详细的解释。
  7. 获取 Graphics 对象设备上下文的直接访问。这可以通过调用 Graphics 对象的 GetDC() 方法来实现。
  8. 在设备上下文中,使用 IHTMLelementRender 对象的 DrawToDC() 方法将 HTML 元素渲染到设备上下文。
  9. 程序释放设备上下文;绘图完成。
  10. 最后,使用 Bitmap 对象的 Save() 方法将绘图保存到文件。操作完成。

请注意,GDI+ 类中的所有方法都使用宽字符。在上面的示例代码中,我使用了 _T("string value")。基本上,我所做的是将我的项目设置为 UNICODE 而不是多字节。这会强制编译器和链接器为我的应用程序使用宽字符而不是多字节字符。

为什么将 Bitmap 与 Graphics 对象关联

我将粗略地解释一下为什么需要将位图与 Graphics 对象关联。您应该将这两个对象视为具有两个独立的职责。这有助于一些形象化的联想。首先,您可以将 Bitmap 对象视为画布,将 Graphics 对象视为人类画家。Bitmap 对象提供了一个渲染上下文,可以在其中应用、存储和稍后查看绘图。Graphics 对象向 Bitmap 对象(画布)提供渲染操作。除了提供渲染上下文外,Bitmap 对象还提供了将渲染上下文保存到磁盘的手段。

测试应用程序的示例快照

让我向您展示一个我截取的 Yahoo! 主页的示例快照

代码重构后的实现

老实说,重构代码后,我对实现细节并不满意。由于这是一个教程,我认为我不需要将代码清理到生产就绪的程度。我所做的是将第一个实现分离成几部分,使每个部分独立。虽然各部分之间存在依赖关系,但每个部分不像以前那样紧密耦合。现在我将逐一解释它们。第一部分是可以用于生成不同图像类型的 CLSID 的伪工厂。它看起来像这样:

// header ImageRender.h
BOOL GetEncoderClsid(LPCWSTR format, CLSID* pClsid);

class ImageFormatFactory
{
public:
    static enum IMAGEFORMAT { JPEG, GIF, TIFF, BMP };
    static BOOL GetFormatCLSID(IMAGEFORMAT fmt,
        CLSID* CLSIDVal);
};

....
// source file: ImageRender.cpp
BOOL GetEncoderClsid(LPCWSTR format, CLSID* pClsid)
{
   UINT  num = 0;          // number of image encoders
   UINT  size = 0;         // size of the image encoder array in bytes

   ImageCodecInfo* pImageCodecInfo = NULL;

   GetImageEncodersSize(&num, &size);
   if(size == 0)
   {
      return FALSE;
   }

   pImageCodecInfo = (ImageCodecInfo*)(malloc(size));
   if(pImageCodecInfo == NULL)
   {
      return FALSE;
   }

   GetImageEncoders(num, size, pImageCodecInfo);

   for(UINT j = 0; j < num; ++j)
   {
      if(wcscmp(pImageCodecInfo[j].MimeType, format) == 0)
      {
         *pClsid = pImageCodecInfo[j].Clsid;
         free(pImageCodecInfo);
         return TRUE;  // Success
      }    
   }

   free(pImageCodecInfo);
   return FALSE;  // Failure
}

///////////////////////////////////////////////////
BOOL ImageFormatFactory::GetFormatCLSID(ImageFormatFactory::IMAGEFORMAT fmt,
   CLSID* CLSIDVal)
{
   // for my own project and for the sake of demonstration, we
   // only support 4 types for now.
   BOOL retVal = FALSE;
   switch(fmt)
   {
   case JPEG:
      retVal = GetEncoderClsid(L"image/jpeg", CLSIDVal);
      break;
   case GIF:
      retVal = GetEncoderClsid(L"image/gif", CLSIDVal);
      break;
   case TIFF:
      retVal = GetEncoderClsid(L"image/tiff", CLSIDVal);
      break;
   case BMP:
      retVal = GetEncoderClsid(L"image/bmp", CLSIDVal);
      break;
   default:
      retVal = FALSE;
      break;
   }

   return retVal;
}

我通过添加支持为不同的图像文件格式返回不同的 CLSID 来扩展了原始设计。第一个实现仅支持 JPEG 图像文件。实现表明,使用 ImageFormatFactory::GetFormatCLSID 为四种图像文件格式选择 CLSID 比使用接受字符串参数的 GetEncoderClsid() 更容易。使用上述实现,我可以测试每个层以确保每个层都能正常工作,并正确集成,从而限制用户使用 ImageFormatFactory::GetFormatCLSID(),使实现更安全。我可能想从头文件中删除 GetEncoderClsid 的声明。但我希望将其提供给用户,以便用户可以选择不使用 ImageFormatFactory::GetFormatCLSID(),而是使用 GetEncoderClsid() 来选择其他格式。这是一个危险的做法。很容易修复。您应该注意,上述工厂的实现不是一个很好的实现。它仅足以达到我需要的结果。

接下来,我编写了一个名为 ImageRender 的类。它包装了 GDI+ 的功能,并且仅公开了足够多的接口供外部类进行渲染。它看起来像这样:

// header ImageRender.h
class ImageRender
{
private:
   Bitmap* bmp;
   Graphics* g;
   HDC bmpHdc;

protected:
   Bitmap* GetBitmap();
   Graphics* GetGraphics();

public:
   ImageRender();
   ImageRender(int cx, int cy);
   virtual ~ImageRender();
   void Destroy();
   BOOL CreateImage(int cx, int cy);
   HDC GetDC();
   void ReleaseDC();
   BOOL SaveToFile(LPCWSTR fileName,
      const CLSID* clsidVal);
};

...

// source file: ImageRender.cpp
ImageRender::ImageRender()
   : bmp(NULL),
   g(NULL),
   bmpHdc(NULL)
{
}

ImageRender::ImageRender(int cx, int cy)
   : bmp(new Bitmap(cx, cy)),
   g(new Graphics(bmp)),
   bmpHdc(NULL)
{
}

ImageRender::~ImageRender()
{
   Destroy();
}

void ImageRender::Destroy()
{
   if (bmpHdc != NULL && bmp != NULL && g != NULL)
   {
      g->ReleaseHDC(bmpHdc);
      bmpHdc = NULL;
   }
    
   if (bmp != NULL)
   {
      delete bmp;
      bmp = NULL;
   }

   if (g != NULL)
   {
      delete g;
      g = NULL;
   }
}

BOOL ImageRender::CreateImage(int cx, int cy)
{
   if (bmp == NULL && g == NULL)
   {
      bmp = new Bitmap(cx, cy);
      g = new Graphics(bmp);
      return TRUE;
   }

   return FALSE;
}

HDC ImageRender::GetDC()
{
   if (g == NULL || bmp == NULL)
   {
      return NULL;
   }
   bmpHdc = g->GetHDC();
   return bmpHdc; 
}

void ImageRender::ReleaseDC()
{
   if (bmpHdc == NULL || g == NULL || bmp == NULL)
   {
      return;
   }
   g->ReleaseHDC(bmpHdc);
   bmpHdc = NULL;
}

BOOL ImageRender::SaveToFile(LPCWSTR fileName,
   const CLSID* clsidVal)
{
   Status retVal = bmp->Save(fileName, clsidVal, NULL);
   return (retVal == Ok);
}

这个实现很有趣。我的意图是让这个包装器仅向调用者公开 HDC。调用者可以对 DC 进行任何绘图,然后在完成后释放它。包装器负责使用 SaveToFile() 方法将图像保存到文件。我认为这很好,因为如果我想扩展设计,我可以向它添加其他方法,并在底层包装 GDI+ 的功能。用户不必担心如何进行 GDI+ 操作。他们只需要找到包装器的正确方法并调用它。我也可以通过编写一个示例应用程序并使用这个包装器来测试这个实现。

最后,让我们看看将这些组件组合在一起以创建可以截取网页快照的应用程序的片段:

void CMainFrame::OnBnClickedButtonSnapshot()
{
   CString sFileName;
   if (!GetFullPathFileName(this, sFileName))
   {
      return;
   }

   long cx=0, cy=0;
   GetWebBowserCtrlSize(this, cx, cy);

   CComPtr<IHTMLElementRender> spElemRender;
   if (!GetHtmlPageBodyRender(this, &spElemRender))
   {
      return;
   }

   CLSID jpegClsid;
   if (!GetEncoderClsid(_T("image/jpeg"), &jpegClsid))
   {
      AfxMessageBox(_T("Unable to get the CLSID for JPEG."));
      return;
   }

   ImageRender ir;
   if (ir.CreateImage((int)cx, (int)cy))
   {
      HDC renderDC = ir.GetDC();
      if (renderDC != NULL)
      {
         spElemRender->DrawToDC(renderDC);
         ir.ReleaseDC();

         if (!ir.SaveToFile(sFileName, &jpegClsid))
         {
            AfxMessageBox(_T("Unable to save the JPEG image."));
            return;
         }
      }
   }
}

正如您所看到的,在 CMainFrame 类中,我也进行了一些代码重构。我提取了几个不同的操作,并将它们变成了不同的本地函数。它们是;第一个是获取我们要保存的文件名的完整路径的函数:

BOOL GetFullPathFileName(CMainFrame* appFrame, CString& retFileName)
{
   _TCHAR BASED_CODE szFilter[] = 
     _T("JPEG Files (*.jpg;*.jpeg)|*.jpg;") 
     _T(" *.jpeg|All Files (*.*)|*.*||");
   CFileDialog dlg(FALSE, _T("*.jpg; *.jpeg"), _T(""), 
                   OFN_HIDEREADONLY | OFN_OVERWRITEPROMPT, 
                   szFilter, appFrame, 0);
   if (dlg.DoModal() != IDOK)
   {
      return FALSE;
   }

   retFileName = dlg.GetPathName();
   return TRUE;
}

下一个函数将返回 HTML 文档正文使用的渲染的 COM 指针。

BOOL GetHtmlPageBodyRender(CMainFrame* appFrame, IHTMLElementRender** retRender)
{
   CHtmlView* pView = (CHtmlView*)appFrame->GetActiveView();
   // should use reinterpret_cast.

   CComPtr<IDispatch> spDisp(pView->GetHtmlDocument());
   CComPtr<IHTMLDocument2> spDoc;
   if (FAILED(spDisp->QueryInterface(IID_IHTMLDocument2, (void**)&spDoc)))
   {
      AfxMessageBox(_T("Unable to get the HTML Document off the browser."));
      return FALSE;
   }

   CComPtr<IHTMLElement> spBody;
   if (FAILED(spDoc->get_body(&spBody)))
   {
      AfxMessageBox(_T("Unable to get the body of the HTML Document."));
      return FALSE;
   }

   CComPtr<IHTMLElementRender> spElemRender;
   if (FAILED(spBody->QueryInterface(IID_IHTMLElementRender, 
      (void**)&spElemRender)))
   {
      AfxMessageBox(_T("Unable to create render of the body element."));
      return FALSE;
   }

   *retRender = spElemRender;

   return TRUE;
}

最后,我修复了我在原始设计中发现的一个错误。我没有获取整个文档的大小(宽度和高度),而是仅获取文档可见部分的大小。我发现的问题是,如果我尝试截取整个页面的快照,我得到的图像的一部分是黑色的。我认为只能截取网页可见部分的快照。这是一个返回浏览器控件大小的函数:

void GetWebBowserCtrlSize(CMainFrame* appFrame, long& cx, long& cy)
{
   CHtmlView* pView = (CHtmlView*)appFrame->GetActiveView();
                      // should use reinterpret_cast.
   cx = pView->GetWidth();
   cy = pView->GetHeight();
}

最后的想法

就是这样。我希望您喜欢这篇文章。这篇教程的酷之处在于,只要能获取 IHTMLElement,任何网页元素都可以渲染到文件。另一个您学到的酷之处是如何捕获图像并将其通过 GDI+ 保存为文件。在进行这个简单项目时,我肯定学到了这些。

Bug

如果您认为有任何错误,我很想知道。请随时在本文下方发表评论。我会修复它们。谢谢。

历史

  • 初稿 - 2006 年 9 月 15 日。
  • 完成 - 2006 年 11 月 26 日。
© . All rights reserved.