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

Htmlhelp 取证

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.87/5 (48投票s)

2003年2月4日

CPOL

13分钟阅读

viewsIcon

192534

downloadIcon

3547

破解 htmlhelp .chm 存储格式以消除恼人的文件锁定 bug,纯粹为了好玩!

Sample Image - Htmlhelp lockfile tester

引言

最初只是对 htmlhelp api 进行简单的 bug 查找,结果很多天后,我对 htmlhelp api 和 .chm 文件格式的内部工作原理有了更深的理解。最初的问题是有一个恼人的功能,即使在关闭 htmlhelp 后,它仍会锁定基于 htmlhelp 的文件,阻止文件更新,直到您手动关闭所有正在访问该文件的进程。只有当您使用上下文 ID 并且在 htmlhelp 关闭后不立即关闭应用程序时,才会出现此问题。奇怪的是,基于主题的查找不会引起相同的锁定。 

那么,如果您简单地将所有基于上下文的查找切换为基于主题的查找,会怎样呢?需要更改的代码行数将迅速阻止此方法——但您如何在运行时访问上下文/主题映射?解决方案相当棘手:破解 chm 文件格式,自己解码内部流,构建上下文和主题之间的映射,然后使用此映射来翻译所有基于上下文的查找为基于主题的查找。

所有示例均以 C++ 显示,因为硬核的 ITStorage 破解在不支持原生 COM 和 Windows API 的任何其他语言中都很难甚至不可能实现——请随意证明我错了!

但首先,对压缩 HTML 帮助文件进行非常简短的介绍。

入门

htmlhelp 的基本思想是将多个 html 和图形文件放入一个扩展名为 chm(或 its)的单个压缩文件中。当用户查看文件时,内容将在需要时即时解压缩内部文件来显示。查看器是已安装的 Internet Explorer,由 hh.exe 或更晦涩的控件 shdocvw.ocx 巧妙调用。扩展名为 CHM 的文件将使用 hh.exe 查看器启动,而扩展名为 ITS 的文件将直接在 Internet Explorer 中启动。这两种扩展名具有相同的格式,由 ITStorage COM 接口控制,稍后将进行描述。

您可以使用 Microsoft 提供的免费 HTML Help Workshop 创建您的 chm 文件。该程序勉强够用,需要一个单独的 HTML 编辑器以及对 htmlhelp 格式的良好理解。如果您对帮助创建有任何认真考虑,请务必搜索以下链接以获取替代方案。为了测试目的,我使用此工具创建了一个小型帮助项目,以便有一个已知的内容可供反向工程。关于反向工程自己简单文件的想法,比尝试反向工程具有未知和未映射内容的大文件要容易一些。

使用两个主题和上下文别名创建您自己的 chm 文件——或者直接窃取本文源代码中的那个。

调用帮助 - HtmlHelp API

要将您的程序实际链接到 chm 文件,您需要使用 MSDN 库中记录的 HtmlHelp API。该 API 实际上只有一个函数 HtmlHelp,其签名如下:

// remember to #include <htmlhelp.h>
HWND WINAPI HtmlHelp(
  HWND hwndCaller,
  LPCSTR pszFile,
  UINT uCommand,
  DWORD_PTR dwData
);

第三个参数中的命令控制 API 调用的行为。有大约 19 种不同的命令可用,在以下示例中仅使用了其中一些最重要的命令:

HWND res;
LPCSTR pszFile = "sample.chm";
// show specific topic
res = HtmlHelp(NULL, pszFile, HH_DISPLAY_TOPIC, (DWORD) "first.htm");
// show specific context
res = HtmlHelp(NULL, pszFile, HH_HELP_CONTEXT, (DWORD) 1001);
// try to show invalid context
res = HtmlHelp(NULL, pszFile, HH_HELP_CONTEXT, (DWORD) 1002);
if (res==0)
{
  // grab that error
  HH_LAST_ERROR hherr;
  hherr.cbStruct = sizeof(HH_LAST_ERROR);
  HtmlHelp(NULL, pszFile, HH_GET_LAST_ERROR, (DWORD) &hherr);
  if (FAILED(hr) && hherr.description!=NULL)
  {
    printf("ERROR %08X\n%S", hherr.hr, hherr.description);
    // free the string after use
  ::SysFreeString(hherr.description);
  }
}
// close the htmlhelp window
res = HtmlHelp(NULL, NULL, HH_CLOSE_ALL, NULL);

上面的示例使用了我的 HtmlHelp API 版本中未找到的 typedef HH_LAST_ERROR,但可以轻松地从 MSDN 库中复制。

typedef struct tagHH_LAST_ERROR { 
  int cbStruct; 
  HRESULT hr;
  BSTR description;
} HH_LAST_ERROR;

大声说,什么也没说!

上一章中的所有交互都是单向的,消息仅从应用程序发送到帮助系统。但 API 支持从 chm 文件到您自己的应用程序的两种“回传”方式——训练卡和通知消息。

训练卡(Training Cards)实际上是用户交互时发送到您的主消息循环的 WM_TCARD 消息,wParam 是一个 32 位无符号整数,lParam 是可选的 LPCSTR。您可以使用 HTML Help Workshop 的 ActiveX 控件命令向导轻松地将它们插入您的 html 文件中。

<OBJECT type="application/x-oleobject" 
  classid="clsid:adb880a6-d8ff-11cf-9377-00aa003b7a11" 
  codebase="hhctrl.ocx#Version=4,74,8793,0" width=100 height=100
>
  <PARAM name="Command" value="TCard">
  <PARAM name="Button" value="Text:Train">
  <PARAM name="Item1" value="9999, SendText">
</OBJECT>

请注意可选的文本参数,它在我的应用程序版本中错误地插入为 <PARAM Name="Item2"...>,而文档明确指出它应该按照上面的方式插入。当用户单击帮助文件中生成的按钮时,您将在消息循环中捕获此事件,并可将其用于您自己险恶的目的。

BOOL CALLBACK DialogProc(HWND HwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
:
  if (uMsg==WM_TCARD)
  {
    int idAction = (int) wParam;
    LPCSTR pText = (LPCSTR) lParam;
    printf("TRAIN: %d - %s", idAction, pText);
  }
:

使用起来非常简单,如果您需要将预定义的消息从帮助文件发送到您的应用程序( upon user action ),则可以轻松集成。

如果您需要一个跟踪机制,仔细跟踪用户与帮助文件的交互,那么请开始查看通知消息(Notification Messages)。它们服务于一个复杂的需求,即跟踪帮助文件中的每一次用户交互,并且显然设置和使用起来有点困难。首先,您需要更改默认的窗口类型设置才能启用消息跟踪。鉴于文档的状态,这并不像听起来那么容易——但这是我的做法:

#define ID_NOTIFICATION 4242
HH_WINTYPE wt = {0}, *pwt;
LPCSTR pszFile = "test.chm";
LPCSTR pszWin = "test.chm>main";
// get the wintype
HtmlHelp(HwndDlg, pszWin, HH_GET_WIN_TYPE, (DWORD) &pwt);
// copy contents of returned wintype
wt = *pwt;
// set notification id
wt.idNotify = ID_NOTIFICATION;
// rememeber to force the correct size
wt.cbStruct = sizeof(HH_WINTYPE);
// now send the new window type
htmp = HtmlHelp(HwndDlg, szFile, HH_SET_WIN_TYPE, (DWORD) &wt);

显然,可以通过重复该过程并留下 idNotify 为 null 来关闭通知。

启用来自 chm 文件的通知后,消息将开始出现在您的消息循环中,正确地用 wParam 参数中的给定 idNotify 常量标识自己。 lParam 将指向一个 HHN_NOTIFY 结构,并且可以在 code 属性中读取通知类型。如果 code 的类型是 HHN_TRACK,则指针可以强制转换为指向 HHNTRACK 结构的指针,并且可以在 idAction 属性中提取特定的操作。用可读的详细代码表示,看起来是这样的:

// Make sure it's the correct notification id
if (uMsg == WM_NOTIFY && wParam == ID_NOTIFICATION)
{
  // get notify info
  HHN_NOTIFY* pno = (HHN_NOTIFY*) lParam; 
  // check notification type
  if (pno->hdr.code == HHN_WINDOW_CREATE)
    printf("HHN_WINDOW_CREATE");
  else if (pno->hdr.code == HHN_NAVCOMPLETE)
    printf("HHN_NAVCOMPLETE");
  else if (pno->hdr.code == HHN_TRACK)
  {
    printf("HHN_TRACT");
    // stringized versions of the HHACT_ enum
    LPCSTR pActions[] = { "HHACT_TAB_CONTENTS", "HHACT_TAB_INDEX", ---, "HHACT_NOTES" };
    // cast pointer to a HHNTRACK struct
    HHNTRACK* ptr = (HHNTRACK*) lParam;
    // get correct action as string is possible
    if (ptr->idAction >= HHACT_LAST_ENUM)
      printf(", Custom=%08X", ptr->idAction);
    else
      printf(", Action=%s", pActions[ptr->idAction]);
  }
  else
    printf("Unknown code");
  printf(", url=%s\n", pno->pszUrl)
}

现在您将能够跟踪 chm 文件中的每一次用户点击。可能性可能是无限的,但我只能想到一种使用该信息的方法——创建您自己的 chm 文件统计数据,巧妙地收集每个主题的点击次数,以及可能导致每个主题的最常用路径……

我没看到 bug

足够多的交互和演示——让我们来看一下触发本文的实际 bug,使用几行代码,您尝试在发出 HH_CLOSE_ALL 命令后立即以追加模式打开 chm 文件:

LPCSTR pszFile = "test\\sample.chm";
// show specific existing context
res = HtmlHelp(NULL, pszFile, HH_HELP_CONTEXT, (DWORD) 1001);
// close the htmlhelp window
res = HtmlHelp(NULL, NULL, HH_CLOSE_ALL, NULL);
// open file in append mode
FILE* fp = fopen(pszFile, "a");
if (fp!=NULL)
  fclose(fp);
else
  printf("Failure, err=%d\n", GetLastError());

失败的 fopen 会使 GetLastError() 返回 ERROR_SHARING_VIOLATION,它以纯文本形式告诉我们“The process cannot access the file because it is being used by another process”(进程无法访问文件,因为它正在被另一个进程使用)。到底是谁在操作 chm 文件?为什么?现在尝试重复测试,但将 HH_HELP_CONTEXT 命令替换为 HH_DISPLAY_TOPIC 和相应的受主题:

LPCSTR pszFile = "test\\sample.chm";
// show specific existing topic
res = HtmlHelp(NULL, pszFile, HH_DISPLAY_TOPIC, (DWORD) "first.htm");
// close the htmlhelp window
res = HtmlHelp(NULL, NULL, HH_CLOSE_ALL, NULL);
// test the file state
FILE* fp = fopen(pszFile, "a");
if (fp!=NULL)
  fclose(fp);
else
  printf("Failure, err=%d\n", GetLastError());

在发出 HH_CLOSE_ALL 后,chm 文件上的锁定已神秘消失。

使用进程管理器,您实际上可以看到 Internet Explorer 的 DLL 即使在 HH_CLOSE_ALL 关闭帮助窗口后仍保留在内存中——但这仅在您使用 HH_HELP_CONTEXT 命令时!坚持使用 HH_LOOKUP_TOPIC,DLL 将按预期卸载,chm 文件锁定消失。我的最佳猜测是,在读取上下文映射信息后,“某人”忘记关闭 chm 文件中的内部流……

总之——一个相当恼人的 bug——但我们对此能做什么呢?

针与销 - 存储与流

要对 chm 文件进行肮脏的操作,您需要访问位于您的 windows 目录下的“Microsoft® InfoTech Storage System Library”托管接口 ITStorage,该接口在 itss.dll 中实现。接口定义并不是公开的,但我提取了以下内容,并得到了 Keyworks Software 的一些帮助:

DECLARE_INTERFACE_(IITStorage, IUnknown)
{
  STDMETHOD(StgCreateDocfile)
    (const WCHAR* pwcsName, DWORD grfMode, DWORD reserved, IStorage** ppstgOpen) PURE;
  STDMETHOD(StgCreateDocfileOnILockBytes) 
    (ILockBytes * plkbyt, DWORD grfMode, DWORD reserved, IStorage ** ppstgOpen) PURE;
  STDMETHOD(StgIsStorageFile) 
    (const WCHAR * pwcsName) PURE;
  STDMETHOD(StgIsStorageILockBytes) 
    (ILockBytes * plkbyt) PURE;
  STDMETHOD(StgOpenStorage) 
    (const WCHAR * pwcsName, IStorage * pstgPriority, DWORD grfMode, SNB snbExclude, 
       DWORD reserved, IStorage ** ppstgOpen) PURE;
  STDMETHOD(StgOpenStorageOnILockBytes)
    (ILockBytes * plkbyt, IStorage * pStgPriority, DWORD grfMode, SNB snbExclude, 
       DWORD reserved, IStorage ** ppstgOpen ) PURE;
  STDMETHOD(StgSetTimes)
    (WCHAR const * lpszName, FILETIME const * pctime, FILETIME const * patime, 
       FILETIME const * pmtime) PURE;
  STDMETHOD(SetControlData)
    (PITS_Control_Data pControlData) PURE;
  STDMETHOD(DefaultControlData)
    (PITS_Control_Data *ppControlData) PURE;
  STDMETHOD(Compact)
    (const WCHAR* pwcsName, ECompactionLev iLev) PURE;
};

这个声明以及一些结构/枚举和匹配的静态 GUID 拼写足以让您开始——要实际打开一个 ITStorage 文件,您需要这样做:

IITStorage* pITStorage;
IStorage* pStorage;
PWCHAR pwzFile = L"sample.chm";
// don't forget to init COM
CoInitialize(NULL);
// get ITStorage interface
hr = CoCreateInstance(CLSID_ITStorage, NULL, CLSCTX_INPROC_SERVER, IID_ITStorage, 
                      (void **) &pITStorage);
// open the chm-file
hr = pITStorage->StgOpenStorage(pwzFile, NULL, STGM_READ | STGM_SHARE_DENY_WRITE, 
                                NULL, 0, &pStorage);

使用枚举来发现新打开的 ITStorage 的内容——这将转储存储的根级别内容以及每个元素的类型和大小:

IEnumSTATSTG* pEnum = NULL;
STATSTG entry = {0};
LPCSTR typnam[] = { "STGTY_STORAGE", "STGTY_STREAM", "STGTY_LOCKBYTES", 
                    "STGTY_PROPERTY" };
hr = pStorage->EnumElements(0, NULL, 0, &pEnum);
while (pEnum->Next(1, &entry, NULL)==S_OK)
  printf("%S, type=%s, size=%I64u\n", entry.pwcsName, typnam[entry.type-1], 
         entry.cbSize.QuadPart);
pEnum->Release();

typnam 数组只是为了提高可读性而添加的,它指示元素的类型。请记住,流大小是 64 位无符号整数。

类型为 STGTY_STORAGE 的每个元素都可以递归打开和枚举(类似于目录),类型为 STGTY_STREAM 的元素可以读入内存(类似于纯文件)。读取特定流(如内部 #STRINGS)到内存可以这样做:

IStream* pStream = NULL;
PWCHAR pwzStream = L"#STRINGS";
// open stream
hr = pStorage->OpenStream(pwzStream, NULL, STGM_READ, 0, &pStream);
// get size
hr = pStream->Stat(&entry, STATFLAG_NONAME);
ULONG cbRead, cbSize = (ULONG) entry.cbSize.QuadPart;
// allocate buffer
LPVOID pBuffer = (LPVOID) LocalAlloc(LPTR, cbSize);
// read stream
hr = pStream->Read(pBuffer, cbSize, &cbRead);
// write buffer to file
FILE* fp = _wfopen(pwzStream, L"w");
size_t cbWrote = fwrite(pBuffer, sizeof(char), cbRead, fp);
// cleanup
fclose(fp);
LocalFree(pBuffer);
pStream->Release();

这里没什么特别的——只需打开流,使用 Stat 调用获取其大小,分配一个匹配的缓冲区,将整个流读入缓冲区,然后将其转储到一个与流同名的文件中。这大致就是您开始破解 .chm 文件格式所需的内容。

破解格式

使用上述技术,您可以构建一个小工具来反编译 htmlhelp 文件的所有内容。对于标准的 htmlhelp 文件,这将显示几个以 # 开头的文件——即有趣的内部流:

#IDXHDR, type=STGTY_STREAM, size=4096
#ITBITS, type=STGTY_STREAM, size=0
#STRINGS, type=STGTY_STREAM, size=827
#SYSTEM, type=STGTY_STREAM, size=4264
#TOPICS, type=STGTY_STREAM, size=544
#URLSTR, type=STGTY_STREAM, size=1399
#URLTBL, type=STGTY_STREAM, size=408
#WINDOWS, type=STGTY_STREAM, size=400

hh.exe 工具实际上能够反编译您的 chm 文件,但会忽略所有有趣的内部文件。使用 hh.exe 的输出反向工程帮助项目是不可能的,因为 hpp 文件的 [alias] 部分在没有 #IVB 信息的情况下无法重现——此时需要破解!

使用 HTML Help Workshop 创建一个带有几个主题并为每个主题添加上下文的小 chm 文件。现在,在 chm 文件上运行您的黑客反编译工具,并使用您喜欢的十六进制编辑器(可能不是 VI)浏览生成的以 # 开头的内部文件。这些流的格式相当棘手,但这是我到目前为止的发现列表:

  • #STRINGS - 所有字符串都以 ansi 格式表示,并以 null 分隔。每个字符串都可以通过其在文件中的偏移量从其他流中引用。
  • #WINDOWS - 所有窗口的定义。
  • #SYSTEM - hh 编译器的版本(纯文本)和默认主题的名称。
  • #URLTBL - 文件中的所有 URL,以 9 个 null 分隔。
  • #IVB - 上下文 ID 和 #STRINGS 中的偏移量之间的链接。

请注意,如果帮助项目文件中省略了 [Alias] 部分,则 #IVB 流将不存在——这揭示了上下文映射信息所在位置的大量信息。

转换上下文到主题   

对于我的目的,#STRINGS 和 #IVB 这两个流是最有趣的——将上下文 ID 转换为主题字符串。在来回琢磨了一段时间后,我发现 #IVB 流的格式如下(所有 32 位值):

<size> <context#1> <offset #1> <context#2> <offset #2> ... <context#n> <offset#n>

  • <size> 是文件的大小。
  • <context> 表示您在别名部分定义的每个上下文(32 位无符号整数)。
  • <offset> 是相应 #STRING 流条目的字节偏移量。

这使得反向工程相当容易——打开这两个流,将内容读入缓冲区,然后再次关闭流:

DWORD cbRead, cbSTRINGS, cbIVB, idxContext, idxString;
LPCSTR pSTRINGS, pTopic;
LPDWORD pIVB;
IStream* pStream = NULL;
// read #STRINGS
hr = pStorage->OpenStream(L"#STRINGS", NULL, STGM_READ, 0, &pStream);
hr = pStream->Stat(&entry, STATFLAG_NONAME);
cbSTRINGS = (DWORD) entry.cbSize.QuadPart;
pSTRINGS = (LPCSTR) LocalAlloc(LPTR, cbSTRINGS);
hr = pStream->Read((LPVOID)pSTRINGS, cbSTRINGS, &cbRead);
pStream->Release();
// read #IVB
hr = pStorage->OpenStream(L"#IVB", NULL, STGM_READ, 0, &pStream);
hr = pStream->Stat(&entry, STATFLAG_NONAME);
cbIVB = (DWORD) entry.cbSize.QuadPart;
pIVB = (LPDWORD) LocalAlloc(LPTR, cbIVB);
hr = pStream->Read((LPVOID)pIVB, cbIVB, &cbRead);
pStream->Release();

现在使用 IVB 缓冲区和 STRINGS 缓冲区来闪烁从上下文 ID 到主题字符串的映射——只需记住在使用后释放您的缓冲区。

// show mapping between context and topic - first DWORD unused (contains size)
int nItems = cbIVB/sizeof(DWORD);
for (int i = 1; i<nItems;i+=2)
{
  idxContext= pIVB[i];
  idxString = pIVB[i+1];
  pTopic = (char*)(pSTRINGS) + idxString;
  printf("%d=%s\n", idxContext, pTopic);
}
// cleanup
LocalFree((LPVOID)pIVB);
LocalFree((LPVOID)pSTRINGS);

整合所有内容

要在大规模 chm 文件上使用上下文/主题映射,最好将实际映射存储在一个比二进制搜索更智能的容器中。我使用了 STL map 模板来创建一个有效的上下文 DWORD 和指向 #STRINGS 内存块的 char 指针之间的映射。只需将上面的上下文映射转储例程替换为以下内容(为了提高可读性,省略了一些内容):

#pragma warning(disable: 4786)
#include <list>
#include <map>
using namespace std;
:
typedef map <DWORD, LPCSTR, less<DWORD>, allocator<LPCSTR> > tMapIntString;
:
// create mapping between context and topic
tMapIntString map;
int nItems = cbIVB/sizeof(DWORD);
for (int i = 1; i<nItems;i+=2)
{
  idxContext= pIVB[i];
  idxString = pIVB[i+1];
  pTopic = (char*)(pSTRINGS) + idxString;
  map[idxContext] = pTopic;
}
// cleanup, leave pSTRINGS in memory (used by map)
LocalFree((LPVOID)pIVB);

如果需要,可以轻松地遍历基于 STL 的映射(使用迭代器类,“first”返回您的键,“second”返回您的数据):

// traverse map
tMapIntString::iterator pos;
for (pos = map.begin(); pos!=map.end(); pos++)
  printf("%d=%s\n", (*pos).first, (*pos).second);

现在使用映射就像这样简单(使用“find”函数返回匹配元素的迭代器,并使用参数“second”提取数据):

// use the map
pos = map.find(1000);
if (pos != map.end())
  HtmlHelp(NULL, pszFile, HH_DISPLAY_TOPIC, (DWORD) (*pos).second);
pos = map.find(1001);
if (pos != map.end())
  HtmlHelp(NULL, pszFile, HH_DISPLAY_TOPIC, (DWORD) (*pos).second);
// invalid context
pos = map.find(1002);
if (pos == map.end())
  printf("invalid context\n");
// close the chmfile
HtmlHelp(NULL, NULL, HH_CLOSE_ALL, NULL);
// free the map & string buffer
map.clear();
LocalFree((LPVOID)pSTRINGS);

测试将表明,创建上下文映射不是一个昂贵的操作,并且从上下文到主题的翻译相当有效,代码行数很少。一个巧妙的部分是让 STRINGS 缓冲区保留在连续内存中,并利用字符串已经由 null 字符分隔的事实。

示例应用程序 - CHM Explorer

源文件是一个单一的 zip 存档,包含三个子目录。

  • Chm - 示例 chm 项目,包含两个主题和上下文,以及训练卡和跟踪所需的所有内容。
  • Chmexp - 一个 VC++ 项目,使用本文中介绍的技术来获取主题/上下文映射并使用它(包括训练卡/跟踪代码)。
  • Sample - 本文中的所有代码示例都合并到一个 VC++ 项目中。

Chmexp 已被填充了大量功能,以演示本文中描述的所有问题。

在 chm 文本框中输入 chm 文件的完整路径,然后按“Open”(打开)按钮。chm 文件的状态将每秒刷新一次,并显示在 chm 文件名旁边,指示文件是否被锁定。 “Close”(关闭)将向 htmlhelp api 发出 HH_CLOSE_ALL 命令,“Dump”(转储)会将所有流提取到指定目录中的物理文件中,“Notif”(通知)将切换跟踪信息。如果启用了通知,帮助查看器窗口中的导航将在状态框中显示。

对话框底部的组合框将在成功打开后包含所有有效的 contextid 和 topic。选择或输入一个上下文,然后按“ConTop”(上下文到主题)来使用上下文/主题映射,或按“Context”(上下文)直接发送上下文(调用锁定错误)。选择或输入一个主题,然后按“Topic”(主题)按钮在该主题显示在帮助窗口中。

再见了,谢谢所有的鱼

那么,这留给我们什么?

  • 找到了一个可以容忍的神秘 chm 文件锁定问题的解决方法——在程序开始时打开 chm 文件,读取内部 IVB 映射,并将所有 HH_HELP_CONTEXT 替换为匹配的 HH_LOOKUP_TOPIC 调用。
  • 获得对 ITStorage 的访问权限足以构建一个巧妙的 chm 文件浏览器,揭示以前隐藏的信息的秘密。
  • 帮助文件以前的内部格式的一部分已被反向工程到您可以实际创建改进的反编译应用程序的程度。您仍然需要弄清楚其余内部流的格式,但至少 [Alias] 部分已被破解。
  • 帮助文件不再是黑盒子材料,对上下文/主题信息的运行时访问打开了与您的标准应用程序进行有趣集成的大门。您实际上可以构建一个“帮助文件一致性工具”,能够测试哪些上下文是有效的,哪些是未使用的。

总之,玩转该格式并想出更多用途——以下链接可能有助于您找到有关 htmlhelp 格式的更多信息:

© . All rights reserved.