ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Tidy HTML5 扩展标签机制全解析:从 tidyenum.h 枚举到 tag_defs[] 字典的二次开发实战

Tidy HTML5 扩展标签机制全解析:从 tidyenum.h 枚举到 tag_defs[] 字典的二次开发实战

【免费下载链接】foo_openlyrics

An open-source lyric display panel for foobar2000

项目地址:https://gitcode.com/gh_mirrors/fo/foo_openlyrics
点击查看免费下载

本指南以 HTML Tidy 5.8.0 的官方开发文档 TAGS.md 为骨架,结合本仓库中完整携带的 Tidy 源码,系统讲解如何为 libTidy 新增一个 HTML 标签(tag):包括tidyenum.h中的枚举登记、tags.c中tag_defs[]字典表的字段语义、W3C 版本位掩码与内容模型(CM_*)位标志,以及面向"尚未被 W3C 批准"标签的运行时扩展方案(new-blocklevel-tags、new-empty-tags、new-inline-tags、new-pre-tags)。读完本文,你将具备直接修改 Tidy 源码添加新标签、并通过配置文件在单次会话内声明自定义标签的完整实战能力。

背景说明:本仓库(foo_openlyrics,foobar2000 的开源歌词面板组件)在src/sources/lyric_source.cpp的LyricSourceRemote::load_html_document()中调用 libTidy 将各歌词网站返回的 HTML 清洗并转换为 XHTML,再交给 pugixml 做 XPath 解析。因此,Tidy 能"认识"哪些标签、如何扩展这些标签,直接影响歌词抓取解析的稳定性与覆盖率——这正是理解本文主题在真实项目中的价值。

一、为什么需要"添加新标签":Tidy 的标签字典模型

Tidy 的目标是尽量支持 W3C 定义的所有 HTML 标签。在 Tidy 内部,每一个它认识的标签都对应一条字典记录(Dict),由tags.c中的静态数组tag_defs[]统一维护:

  • tag_defs[]声明于 src/tags.c#L169,数组起始处固定放置TidyTag_UNKNOWN("unknown!")哨兵项,末尾以{ (TidyTagId)0, NULL, 0, NULL, (0), NULL, NULL }作为终止项;
  • 每条记录的数据结构Dict定义于 src/tags.h#L77-L87,字段包括:id(标签枚举值)、name(标签名字符串)、versions(支持的 HTML 版本位掩码)、attrvers(属性版本表指针)、model(内容模型位标志)、parser(解析函数指针)、chkattrs(属性检查函数指针)、next(链表指针)。

也就是说,Tidy 对"某个标签是否存在、属于什么版本、内部可以装什么内容、如何解析、如何校验属性"的全部认知,都浓缩在这张表里。新增标签的本质,就是向这张表注册一条新记录。

二、两步添加 W3C 标准标签:官方推荐流程

原文档明确给出两条核心步骤,这两处改动即可让 libTidy 把新元素当作"W3C 已批准"的标签处理:

第一步:在tidyenum.h中登记枚举值

在 include/tidyenum.h#L843-L1001 的typedef enum { ... } TidyTagId;中为新元素增加一个内部名称,例如TidyTag_XXXX,从而获得一个唯一整数值。

关键约定(可从现有代码直接印证):

  • 该枚举必须保持字母序(源码注释明确要求 "Please keep this list in alphabetical order")。例如现有顺序为TidyTag_A、TidyTag_ABBR、TidyTag_ACRONYM、TidyTag_ADDRESS……依次排列;
  • TidyTag_UNKNOWN必须是第一个成员("Must be first"),N_TIDY_TAGS必须是最后一个成员("Must be last"),它同时充当"标签总数"与数组边界;
  • HTML5 时代新增的元素(TidyTag_ARTICLE至TidyTag_SLOT)独立追加在传统元素之后,同样保持字母序(参见 include/tidyenum.h#L969-L998)。

提示:标签枚举TidyTagId是全局唯一标识,源码中大量逻辑依赖它,例如 src/parser.c 中的TY_(CoerceNode)、TY_(FindNodeWithId)等都以TidyTagId作为参数。

第二步:在tags.c的tag_defs[]表中添加记录

在 src/tags.c#L169-L336 的tag_defs[]中为新的枚举值增加一行,填入五个关键信息:

  1. 唯一的字符串值:即标签名,如"a"、"article";
  2. 支持的 HTML 版本:一个由VERS_*宏按位或组合的掩码,例如VERS_ELEM_ARTICLE (xxxx|xxxx|...|HT50|XH50)表示仅 HTML5/XHTML5 支持;
  3. 属性支持表指针:指向attrdict.c中对应的TY_(W3CAttrsFor_XXX)[]数组首元素;
  4. 内容模型位标志:CM_INLINE、CM_BLOCK、CM_EMPTY等的组合;
  5. 解析器与属性检查器:对应的Parser函数(如TY_(ParseBlock))与可选的CheckAttribs检查函数(如CheckIMG、CheckTABLE)。

下面是一条真实存在的记录(<a>标签,src/tags.c#L174):

{ TidyTag_A, "a", VERS_ELEM_A, &TY_(W3CAttrsFor_A)[0], (CM_INLINE|CM_BLOCK|CM_MIXED), TY_(ParseBlock), NULL },

对照字段逐一解读:

  • TidyTag_A:第一步注册的枚举值,是这条记录在字典中的"身份证";
  • "a":标签的规范化字符串名称,解析器用它做字符串比对与哈希查找;
  • VERS_ELEM_A:版本掩码(定义于 src/tags.c#L24,为HT20|HT32|H40T|H41T|X10T|H40F|H41F|X10F|H40S|H41S|X10S|XH11|XB10|HT50|XH50),表示从 HTML 2.0 到 HTML5 全版本支持;
  • &TY_(W3CAttrsFor_A)[0]:属性列表指针,指向 src/attrdict.c#L175 定义的AttrVersion数组;
  • (CM_INLINE|CM_BLOCK|CM_MIXED):内容模型——<a>既可以出现在行内,也可以作为块级容器,且允许混合内容;
  • TY_(ParseBlock):解析函数;
  • NULL:无专属属性检查函数(通用属性检查由attrs.c统一处理)。

再看一个 HTML5 新标签的真实示例(src/tags.c#L298):

{ TidyTag_ARTICLE, "article", VERS_ELEM_ARTICLE, &TY_(W3CAttrsFor_ARTICLE)[0], (CM_BLOCK), TY_(ParseBlock), NULL },

<article>的版本掩码VERS_ELEM_ARTICLE只包含HT50|XH50(src/tags.c#L129),内容模型为纯CM_BLOCK,对应的属性表 src/attrdict.c#L375 仅由INCLUDE_ARIA、INCLUDE_MICRODATA、INCLUDE_CORE_ATTRIBS、INCLUDE_CORE_EVENTS、INCLUDE_RDFA等公共宏展开而成。

属性表:attrdict.c中的配套登记

虽然原文档聚焦于标签本身,但tag_defs[]记录中的"属性支持表指针"指向的正是 src/attrdict.c 中的const AttrVersion TY_(W3CAttrsFor_XXX)[]数组。以<a>为例(src/attrdict.c#L175-L220),数组逐项列出该标签允许的属性及各自支持的版本,例如:

{ TidyAttr_HREF, HT20|HT32|H40T|H41T|X10T|H40F|H41F|X10F|H40S|H41S|X10S|XH11|XB10|HT50|XH50 }, { TidyAttr_DOWNLOAD, xxxx|xxxx|xxxx|xxxx|xxxx|xxxx|xxxx|xxxx|xxxx|xxxx|xxxx|xxxx|xxxx|HT50|XH50 },
  • TidyAttr_HREF全版本可用;
  • TidyAttr_DOWNLOAD是 HTML5 才引入的新属性,旧版本位置全部填xxxx(0);
  • 数组以{ TidyAttr_UNKNOWN, 0 }结尾作为终止标记。

公共宏INCLUDE_ARIA、INCLUDE_MICRODATA、INCLUDE_CORE_ATTRIBS、INCLUDE_CORE_EVENTS、INCLUDE_RDFA定义于 src/attrdict.c#L13-L139,分别批量注入 ARIA 无障碍属性、HTML5 Microdata 属性、全局属性(class、id、role、slot、translate等)、全局事件处理器属性(onclick、ondrag*等)与 RDFa 属性。因此,新增标签若使用公共宏,可自动获得与 HTML5 规范一致的全部全局属性支持。

三、版本位掩码与内容模型:读懂tag_defs[]的字段语义

要让新增的标签在各类文档模式下行为正确,必须理解两个底层位域体系,两者都定义于 src/lexer.h。

版本掩码(VERS_*)

src/lexer.h#L164-L221 定义了完整的版本位:

宏含义
HT20/HT32HTML 2.0 / 3.2
H40S/H40T/H40FHTML 4.0 Strict / Transitional / Frameset
H41S/H41T/H41FHTML 4.01 Strict / Transitional / Frameset
X10S/X10T/X10FXHTML 1.0 Strict / Transitional / Frameset
XH11/XB10XHTML 1.1 / XHTML Basic
HT50/XH50HTML5 / XHTML5
VERS_NETSCAPE/VERS_MICROSOFT/VERS_SUN浏览器厂商私有(proprietary)扩展
VERS_XMLXML 通用标签

组合宏包括VERS_ALL(全部 W3C 文档类型)、VERS_PROPRIETARY(全部厂商私有类型)、VERS_HTML5(HT50|XH50)、VERS_HTML40、VERS_XHTML等,可直接复用。

值得注意的分区:tag_defs[]中的记录被组织为三块——

  1. W3C 定义元素(src/tags.c#L173-L278):从<a>到<nextid>,全部引用W3CAttrsFor_*属性表;
  2. proprietary 元素(src/tags.c#L280-L295):如<blink>(VERS_PROPRIETARY)、<bgsound>(VERS_MICROSOFT)、<layer>(VERS_NETSCAPE),属性表为NULL;
  3. HTML5 元素(src/tags.c#L297-L332):如<article>、<video>、<details>等。

新增 W3C 标签时应选择对应分区插入,保持代码组织与字母序一致。

内容模型(CM_*)

src/lexer.h#L101-L153 用 22 个位标志描述元素"能装什么内容、出现在哪里":

位标志语义
CM_EMPTY无内容(空元素,如<br>、<img>、<area>)
CM_HTML/CM_HEAD出现在<html>/<head>级别
CM_BLOCK/CM_INLINE块级 / 行内元素
CM_LIST/CM_DEFLIST列表项 / 定义列表项
CM_TABLE/CM_ROWGRP/CM_ROW表格内部元素层级
CM_FIELD表单字段,内容需防止空白移动
CM_OBJECT/CM_PARAM/CM_IMG对象类元素、允许<param>、使用 align 属性
CM_FRAMES框架集元素
CM_HEADING标题元素(h1–h6)
CM_OPT结束标签可省略(如<p>、<li>、<td>)
CM_MIXED行内与块级混合模型
CM_NO_INDENT仅当内容含块级元素时才缩进
CM_OBSOLETE已废弃元素
CM_NEW用户自定义元素
CM_OMITST不可省略

解析器与内容模型的搭配是硬编码约定:块级标签配TY_(ParseBlock)、行内标签配TY_(ParseInline)、空标签配TY_(ParseEmpty)、预格式化标签配TY_(ParsePre)。例如空元素<area>的记录为(CM_BLOCK|CM_EMPTY)+TY_(ParseEmpty)+CheckAREA(src/tags.c#L179)。

运行时版本适配:AdjustTags/ResetTags

由于 Tidy 5.x 默认进入 HTML5 模式,而旧版本文档(如 HTML4)对同一标签的解析规则可能不同,tags.c提供了运行时切换机制:

  • src/tags.c#L772-L818 的TY_(AdjustTags)在检测到 DOCTYPE 非 HTML5 时执行:例如将<a>的解析器从TY_(ParseBlock)改回TY_(ParseInline)、model从CM_INLINE|CM_BLOCK|CM_MIXED收缩为CM_INLINE(Issue #167/#169),将<button>改回块级解析(Issue #461)等;
  • src/tags.c#L832-L864 的TY_(ResetTags)在重新进入 HTML5 模式时恢复默认值,注释明确要求:"对于上面AdjustTags中的每一处修改,都必须在ResetTags中增加对应的重置"。

这解释了为什么tag_defs[]在 5.8.0 中从static const Dict改为可变static Dict(src/tags.c#L163-L169 的注释提到 Issue #167/#169/#232)——正是为了支持这种运行时字段调整。新增标签时若涉及跨版本行为差异,需要同步在这两个函数中成对维护。

四、不修改源码的运行时扩展:new-*-tags配置家族

对于 W3C 尚未批准、但社区已广泛使用的标签(仍处于 Working Draft 阶段),原文档强调不必每次改源码,可以通过一组配置选项在单次 Tidy 会话内动态扩展tag_defs[]的认知范围。这组选项在 src/config.c#L168-L278 的option_defs[]中登记:

配置选项对应枚举用途声明位置
new-blocklevel-tagsTidyBlockTags声明新的块级标签src/config.c#L175
new-empty-tagsTidyEmptyTags声明新的空标签(无内容)src/config.c#L195
new-inline-tagsTidyInlineTags声明新的行内标签src/config.c#L213
new-pre-tagsTidyPreTags声明新的预格式化标签src/config.c#L239

它们全部是字符串列表型选项(ParseList),可接受逗号分隔的多个标签名。

底层实现链路

从源码可以还原完整的执行路径:

  1. 配置解析后,TY_(DeclareUserTag)(src/tags.c#L484-L511)根据选项 ID 决定标签类型(tagtype_inline/tagtype_block/tagtype_empty/tagtype_pre);
  2. 再调用TY_(DefineTag)(src/tags.c#L617-L650),按类型生成model与parser:
case tagtype_empty: cm = CM_EMPTY|CM_NO_INDENT|CM_NEW; parser = TY_(ParseBlock); break; case tagtype_inline: cm = CM_INLINE|CM_NO_INDENT|CM_NEW; parser = TY_(ParseInline); break; case tagtype_block: cm = CM_BLOCK |CM_NO_INDENT|CM_NEW; parser = TY_(ParseBlock); break; case tagtype_pre: cm = CM_BLOCK |CM_NO_INDENT|CM_NEW; parser = TY_(ParsePre); break;

注意:空标签虽然 model 为CM_EMPTY,解析器用的却是TY_(ParseBlock);用户标签统一附带CM_NEW标志(用于决定无值属性如何输出),版本统一为VERS_PROPRIETARY; 3. 最终由declare()(src/tags.c#L456-L480)把新Dict挂入doc->tags.declared_tag_list链表。declare()中有一条重要保护逻辑:"Make sure we are not over-writing predefined tags"——如果待声明的名字已存在于预定义表中(np->id != TidyTag_UNKNOWN),则不会覆盖内建定义。这保证了用户自定义标签永远无法破坏tag_defs[]的内建行为。

使用示例

命令行(tidy可执行文件位于 3rdparty/tidy-html5-5.8.0/console 目录,由tidy.c构建):

tidy -config config.txt input.html

配置文件config.txt示例:

new-blocklevel-tags: mywidget, mypanel new-inline-tags: myinline new-empty-tags: myicon new-pre-tags: mysnippet

也可在 API 层通过tidyOptSetValue()设置(枚举值见 include/tidyenum.h#L566-L632)。

相关选项:new-custom-tags与custom-tags

Tidy 5.x 还提供了一对更通用的自定义标签选项(源码注释标注 20170309 Issue #119):

  • new-custom-tags(TidyCustomTags,src/config.c#L181):声明一批自定义标签;
  • custom-tags(TidyUseCustomTags,src/config.c#L260):指定这批标签按哪种类型处理(blocklevel/empty/inline/pre)。

在 src/tags.c#L494-L503 中,TidyCustomTags分支正是依据TidyUseCustomTags的当前取值切换到tagtype_block/tagtype_empty/tagtype_inline/tagtype_pre之一。多语言消息(src/language_en.h#L1374)明确指出:new-blocklevel-tags等四个选项优先级高于custom-tags,会覆盖对自定义标签的处理方式。

五、标签查找与解析:新增标签如何被"用起来"

在tag_defs[]注册之后,标签查找与解析全链路如下:

  1. 哈希安装:tagsInstall()(src/tags.c#L348-L364)按tagsHash()(src/tags.c#L338-L346,哈希表大小ELEMENT_HASH_SIZE=178,见 src/tags.h#L92-L95)把字典记录挂入哈希桶;
  2. 名称查找:tagsLookup()(src/tags.c#L407-L433)依次遍历哈希表、tag_defs[]内建表、declared_tag_list用户声明链表——这意味着运行时声明的自定义标签与内建标签走同一套查找机制;
  3. 标签指派:TY_(FindTag)(src/tags.c#L563-L596)为节点绑定字典记录;若开启input-xml(TidyXmlTags)则统一绑定 XML 占位字典xml_tags(由TY_(InitTags)创建,src/tags.c#L696-L711);对于 HTML5 模式下的自主自定义标签(autonomous custom tag),还会自动声明并输出CUSTOM_TAG_DETECTED消息;
  4. 解析执行:TY_(FindParser)(src/tags.c#L609-L615)返回记录中的parser函数指针,交由词法/语法层驱动。

六、项目实战:Tidy 标签能力在 OpenLyrics 歌词抓取中的落地

本仓库将 Tidy 作为第三方库编入tidy-static工程(构建工程见 3rdparty/tidy-html5-5.8.0/openlyrics_build/tidy-static.vcxproj),其核心用途位于 src/sources/lyric_source.cpp#L160-L189 的LyricSourceRemote::load_html_document():

TidyDoc tidy_doc = tidyCreate(); tidySetErrorBuffer(tidy_doc, &tidy_error); tidyOptSetBool(tidy_doc, TidyXhtmlOut, yes); tidyOptSetBool(tidy_doc, TidyForceOutput, yes); tidyParseString(tidy_doc, html); tidyCleanAndRepair(tidy_doc); tidyRunDiagnostics(tidy_doc); tidySaveBuffer(tidy_doc, &tidy_output);

这段代码把抓取到的歌词页 HTML 清洗为 XHTML 输出,再交给 pugixml 的doc.load_buffer()解析。其后的 XPath 查询直接依赖 Tidy 成功"认识"页面中的标签——例如 src/sources/darklyrics.cpp#L112 使用//div[@class='lyrics']/h3/a[@name]定位歌曲标题节点。如果在 Tidy 的tag_defs[]中新增或调整了标签定义,将直接改变清洗后的 XHTML 结构,进而影响所有基于load_html_document()的歌词源解析(azlyricscom.cpp、bandcamp.cpp、letras.cpp等均调用同一函数,见 src/sources/lyric_source.h#L91)。这也解释了本仓库为何要随附完整 Tidy 源码——它作为歌词解析管线的前置清洗器,其标签字典的完备性就是抓取成功率的一部分。

七、扩展新标签的检查清单与注意事项

综合原文档与源码实现,为 libTidy 添加 W3C 标准标签的完整步骤与注意事项如下:

标准流程(改 2 个文件):

  1. include/tidyenum.h 的TidyTagId枚举中,按字母序加入TidyTag_XXXX;
  2. src/tags.c 的tag_defs[]中对应分区加入一行:唯一字符串名 +VERS_*版本掩码 +&TY_(W3CAttrsFor_XXXX)[0]属性表指针 +CM_*内容模型 +Parser/CheckAttribs函数指针;
  3. (配套)在 src/attrdict.c 定义该标签的AttrVersion属性数组,并在 src/attrdict.h 声明 extern;
  4. 若该标签在不同 HTML 版本下行为不同,成对更新TY_(AdjustTags)与TY_(ResetTags);
  5. 重新编译 libTidy(本仓库对应工程见 3rdparty/tidy-html5-5.8.0/openlyrics_build 目录,tidy-static.vcxproj)。

无需改源码的运行时方案:

  • 通过new-blocklevel-tags、new-empty-tags、new-inline-tags、new-pre-tags在单次会话内声明自定义标签(适用于 W3C Working Draft 阶段、社区已广泛支持的标签);
  • 注意declare()的防覆盖保护:自定义标签名与内建标签重名时不会覆盖内建定义;
  • new-*-tags系列优先级高于custom-tags选项。

谨慎之处:

  • 原文档坦率指出,逐标签硬编码校验每个属性与取值并非理论上最优的验证方式,但这是 Tidy 一贯的设计取舍("that is how tidy does it");
  • 修改tag_defs[]会改变清洗输出的结构,务必回归验证依赖 Tidy 输出的下游解析逻辑(本仓库中即所有歌词源的 pugixml XPath 查询);
  • tag_defs[]数组终止项{ (TidyTagId)0, NULL, 0, NULL, (0), NULL, NULL }不可删除或移动,遍历依赖N_TIDY_TAGS边界(见tagsLookup与TY_(LookupTagDef)中的tag_defs + N_TIDY_TAGS用法)。

参考源码导航

  • 官方开发文档:TAGS.md
  • 标签枚举:include/tidyenum.h#L843-L1001
  • 标签字典表:src/tags.c#L169-L336
  • 字典结构体与查找接口:src/tags.h#L77-L148
  • 版本掩码与内容模型定义:src/lexer.h#L101-L221
  • 属性表实现与公共宏:src/attrdict.c#L13-L409
  • 配置选项登记表:src/config.c#L168-L278
  • 用户标签声明链路:src/tags.c#L456-L650
  • HTML5/HTML4 模式切换:src/tags.c#L772-L864
  • 项目内调用点:src/sources/lyric_source.cpp#L160-L189、src/sources/darklyrics.cpp#L108-L139

【免费下载链接】foo_openlyrics

An open-source lyric display panel for foobar2000

项目地址:https://gitcode.com/gh_mirrors/fo/foo_openlyrics
点击查看免费下载
上一篇:Akagi麻将AI助手:在3分钟内免费安装并提升你的麻将水平
下一篇:kepler.gl 读取地图点击要素:在宿主应用侧边栏渲染 visState.clicked 的完整实战

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进