深入 Quarkdown 内联实体引用解析:从#与 到 CriticalContent 节点
【免费下载链接】quarkdown🪐 Markdown with superpowers: from ideas to papers, presentations, websites, books, and knowledge bases.项目地址: https://gitcode.com/GitHub_Trending/qu/quarkdown
本文以 Quarkdown 核心解析器的行内实体(entity reference)解析为研究对象,完整还原测试夹具 entity.md 中 11 个实体引用的输入与预期输出,并沿着“词法模式 → 词法记号 → 解析器 → AST 节点”这条真实调用链,讲解 Quarkdown 如何处理十进制实体(#)、十六进制实体(")、HTML 命名实体( )以及�这类带安全风险的特殊输入。读完后你可以理解 Quarkdown 如何安全地把©转成©、为何解析产物不是普通文本节点而是CriticalContent,以及如何用仓库自带的单元测试复现全部结论。
测试夹具 entity.md:11 个实体引用的完整输入输出表
实体解析的“标准答案”存放在测试夹具 entity.md 中。该文件全部内容如下(空行仅用于分隔三组用例):
| # | 源文本 | 实体类别 | 预期解析结果 |
|---|---|---|---|
| 1 | # | 十进制 | #(码点 35) |
| 2 | Ӓ | 十进制 | 码点 1234(U+04D2)对应字符 |
| 3 | Ϡ | 十进制 | 码点 992(U+03E0)对应字符 |
| 4 | � | 十进制 | 替换字符(码点 65533,即 U+FFFF) |
| 5 | " | 十六进制 | "(U+0022) |
| 6 | ആ | 十六进制 | 码点 0xD06 对应字符 |
| 7 | ಫ | 十六进制 | 码点 0xCAB 对应字符 |
| 8 | | HTML 命名实体 | 空格 |
| 9 | & | HTML 命名实体 | & |
| 10 | © | HTML 命名实体 | © |
| 11 | Æ | HTML 命名实体 | Æ |
几个值得注意的边界细节:
- 十六进制前缀
x不区分大小写("与ಫ都要被识别); Æ实体名本身包含大写字母,实体名匹配需要大小写不敏感;�是整份夹具中唯一“结果不是直接按码点取字符”的用例,背后对应一条安全规则(见后文解析层部分)。
这份夹具与测试文件 InlineParserTest.kt 中的entity()测试方法一一对照,构成 Quarkdown 实体解析行为的完整契约。
词法层:InlineEntity 正则模式与 EntityToken
实体识别发生在词法阶段。行内记号的正则模式集中定义在 BaseMarkdownInlineTokenRegexPatterns.kt,其中名为InlineEntity的模式是:
// quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/patterns/BaseMarkdownInlineTokenRegexPatterns.kt (L44-L51) val entity by lazy { TokenRegexPattern( name = "InlineEntity", wrap = ::EntityToken, regex = "&(#(\\d+)|#x([0-9A-Fa-f]+)|\\w+);?", ) }对这个正则可以做如下拆解:
- 整体以字面量
&开头; - 紧随其后是三选一分支:
#(\d+)匹配十进制引用(捕获数字部分)、#x([0-9A-Fa-f]+)匹配十六进制引用(x分支本身不分大小写书写,字符类[0-9A-Fa-f]同时覆盖大小写)、\w+匹配 HTML 命名实体名(如nbsp、copy、AElig); - 末尾的
;?表示分号是可选的,因此夹具中所有写法(含省略分号的场景)都能命中该模式。
命中的记号被包装为 EntityToken:
/** * An entity reference character. * Examples: ` `, `&`, `©`, '#', `"`, ... */ class EntityToken( data: TokenData, ) : Token(data) { override fun <T> accept(visitor: TokenVisitor<T>) = visitor.visit(this) }EntityToken本身不携带任何解码逻辑,它只是词法层与解析层之间的契约,解码推迟到解析阶段完成。
与实体识别形成互补的,是同一文件中的InlineCriticalContent模式([&<>"'])。它负责捕获那些没有构成合法实体的裸字符,例如紧跟的字符不符合实体模式的孤立的&。从词法顺序看,©会先被InlineEntity吃掉,只有无法匹配实体语法的&才会落到CriticalContent分支——这也是后文解析产物统一归入CriticalContent类型的原因。
解析层:InlineTokenParser 的四路分支与�安全规则
真正的解码逻辑在 InlineTokenParser.kt 的visit(token: EntityToken)中:
// quarkdown-core/src/main/kotlin/com/quarkdown/core/parser/InlineTokenParser.kt (L114-L147, 节选) override fun visit(token: EntityToken): Node { val groups = token.data.groups.iterator(consumeAmount = 2) val entity = groups.next().trim().lowercase() fun String.decodeToContent(radix: Int): String { val ascii = toIntOrNull(radix) ?: return "" // CommonMark's security guideline (2.3 Insecure characters) return if (ascii != 0) { ascii.toChar() } else { NULL_CHAR_REPLACEMENT_ASCII.toChar() }.toString() } // Critical because further checks and mappings may be required during the rendering stage. return CriticalContent( when { entity == "colon" -> ":" // Hexadecimal (e.g. ആ) entity.startsWith("#x") -> groups.next().decodeToContent(radix = 16) // Decimal (e.g. #) entity.startsWith("#") -> groups.next().decodeToContent(radix = 10) // HTML entity (e.g. ) else -> Escape.Html.unescape(token.data.text) }, ) }对应夹具 11 个用例,这里体现了四路分支:
- 特判
::实体名归一化为小写后等于colon时直接产出:; - 十六进制:归一化后以
#x开头时,取正则捕获的十六进制数字部分按 16 进制解码——这解释了夹具中"、ആ、ಫ均能命中; - 十进制:以
#开头时按 10 进制解码,覆盖#、Ӓ、Ϡ; - HTML 命名实体:其余情况(
、&、©、Æ)走Escape.Html.unescape(...),交给 HTML 转义库把命名实体还原为对应字符。
其中最有价值的防御细节是decodeToContent中的ascii != 0判断:当数值实体解出码点 0(即�)时,解析器不输出 NUL 字符,而是替换为一个占位字符。源码注释明确标注了这是遵循 “CommonMark's security guideline (2.3 Insecure characters)”——CommonMark 规范把码点 0 归类为不安全字符,要求解析器不得将其直接落入文档。测试端同样锁定了这一行为:InlineParserTest.kt 对�断言的结果是65533.toChar().toString(),即码点 65533(U+FFFF)的字符,而非 NUL。
// quarkdown-core/src/test/kotlin/com/quarkdown/core/InlineParserTest.kt (L73-L93) @Test fun entity() { val nodes = inlineIterator<CriticalContent>(readSource("/parsing/inline/entity.md")) // Decimal assertEquals(35.toChar().toString(), nodes.next().text) assertEquals(1234.toChar().toString(), nodes.next().text) assertEquals(992.toChar().toString(), nodes.next().text) assertEquals(65533.toChar().toString(), nodes.next().text) // Hexadecimal assertEquals(0x22.toChar().toString(), nodes.next().text) assertEquals(0xD06.toChar().toString(), nodes.next().text) assertEquals(0xCAB.toChar().toString(), nodes.next().text) // HTML assertEquals(" ", nodes.next().text) assertEquals("&", nodes.next().text) assertEquals("©", nodes.next().text) assertEquals("Æ", nodes.next().text) }注意该测试的泛型参数是CriticalContent并默认开启类型断言(assertType = true),也就是说这 11 个用例不仅要验证字符值,还验证了每一个实体引用在 AST 中的节点类型都是CriticalContent——这一点对理解下一节至关重要。
AST 层:为什么实体引用产出 CriticalContent 而不是 Text
夹具文件位于parsing/inline/目录下,与 escape.md、codespan.md 等行内解析夹具并列,而实体解析的产物类型定义在 Text.kt 中(CriticalContent与Text同文件定义)。
对比两类节点的处理路径:
- 反斜杠转义(如
\#)由EscapeToken解析,产出的是普通Text节点——解析层直接给出最终字符,渲染层无需再处理; - 实体引用与裸的
& < > " '字符,则统一产出CriticalContent。解析器在创建该节点前有一行注释:// Critical because further checks and mappings may be required during the rendering stage.
也就是说,CriticalContent是一个“待渲染期定夺”的标记节点:&解出的&、©解出的©虽然此刻已是具体字符,但不同渲染目标(HTML、PDF 等)可能需要对它做转义、映射或额外检查。把解码推迟到渲染期,让同一份 AST 可以服务多种输出格式。从 InlineTokenParser.kt 中TextSymbolToken的注释还能看到反向流程:用户在 .qd 中直接书写©这类符号时,会被记为TextSymbol,而 “the HTML renderer converts the symbol to its corresponding HTML entity (© -> ©)”——即输出 HTML 时再转回实体形式。实体解析(输入方向)与符号转义(输出方向)共同保证了特殊字符在文档生命周期内不被意外破坏。面向用户的符号书写方式详见 text-symbols.qd。
如何复现:运行实体解析测试
上述全部行为都可以通过仓库自带测试验证。InlineParserTest中的inlineIterator帮助方法展示了完整的解析链路:
// quarkdown-core/src/test/kotlin/com/quarkdown/core/InlineParserTest.kt (L50-L58, 节选) private inline fun <reified T : Node> inlineIterator( source: CharSequence, assertType: Boolean = true, flavor: MarkdownFlavor = QuarkdownFlavor, ): Iterator<T> { val lexer = flavor.lexerFactory.newInlineLexer(source) val parser = flavor.parserFactory.newParser(MutableContext(flavor)) return nodesIterator(lexer, parser, assertType) }流程即:QuarkdownFlavor的行内词法器对源文本分词(产生EntityToken等)→ 解析器消费记号产出 AST 节点 → 测试对节点序列逐个断言。在仓库根目录执行:
./gradlew :quarkdown-core:test --tests "com.quarkdown.core.InlineParserTest"即可运行包含entity()在内的全部行内解析测试。若你修改了InlineEntity正则或visit(EntityToken)的分支逻辑,夹具 entity.md 的 11 条断言会在第一时间暴露偏差。词法阶段与解析阶段的整体设计背景,可进一步参考仓库文档 pipeline---lexing.qd 与 pipeline---parsing.qd。
小结:实体引用在 Quarkdown 管线中的完整路径
综合本文各节的源码证据,一个实体引用(以ആ为例)在 Quarkdown 中的处理路径为:
- 词法:
InlineEntity正则(BaseMarkdownInlineTokenRegexPatterns.kt)命中并生成 EntityToken; - 解析:InlineTokenParser 按
:/#x十六进制 /#十进制 / HTML 命名实体四路分支解码,�被替换为占位字符以满足 CommonMark 不安全字符规则; - AST:结果封装为
CriticalContent节点(Text.kt),把转义与映射的决策权留给渲染层; - 验证:InlineParserTest.entity() 以 entity.md 为夹具,逐字符锁定上述行为。
这条链路虽然只覆盖一个看似不起眼的语法细节,但完整体现了 Quarkdown 解析器“词法只标记、解析做语义、渲染做适配”的分层思想,也是理解其他行内构造(转义、代码片段、数学片段)解析方式的典型切入点。
【免费下载链接】quarkdown🪐 Markdown with superpowers: from ideas to papers, presentations, websites, books, and knowledge bases.项目地址: https://gitcode.com/GitHub_Trending/qu/quarkdown
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考