用途
kmp-html-entity-decoder 面向 Kotlin Multiplatform 共享代码:当平台自带的标记辅助函数只存在于某一个目标平台,且这些函数通常还会顺带剥离标签、做超出需求的事情时,可以用纯共享代码实现字符实体解码。资料建议把解码逻辑放在解析器与展示层都已依赖的最低模块中,实现只需一张命名实体表加两条正则表达式。
主要能力
- 解码命名实体:遍历固定的命名实体映射表,对输入文本做替换。
- 解码十六进制数字实体:形如
A、A,正则使用&#[xX]([0-9a-fA-F]+);?。 - 解码十进制数字实体:形如
A,正则使用&#(\d+);?。 - 数字解析使用
toIntOrNull,超长数字溢出时返回 null,匹配内容原样保留,不会中断整体操作。 - 码点转换需覆盖代理对:0..0xFFFF 直接用
Char(cp);0x10000..0x10FFFF 通过charArrayOf(Char(0xD800 + (v shr 10)), Char(0xDC00 + (v and 0x3FF))).concatToString()转为代理对;超出 Unicode 范围返回 null,保留原文。CharArray.concatToString()在共享标准库中,可继续留在 common 代码。
示例(资料中经适配的代码):
fun decodeEntities(text: String): String {
var result = text
for ((entity, char) in NAMED) {
result = result.replace(entity, char, ignoreCase = true)
}
result = Regex("""&#[xX]([0-9a-fA-F]+);?""").replace(result) { m ->
m.groupValues[1].toIntOrNull(16)?.let { codePointToString(it) } ?: m.value
}
result = Regex("""&#(\d+);?""").replace(result) { m ->
m.groupValues[1].toIntOrNull()?.let { codePointToString(it) } ?: m.value
}
return result
}
private fun codePointToString(cp: Int): String? = when {
cp in 0..0xFFFF -> Char(cp).toString()
cp in 0x10000..0x10FFFF -> {
val v = cp - 0x10000
charArrayOf(Char(0xD800 + (v shr 10)), Char(0xDC00 + (v and 0x3FF))).concatToString()
}
else -> null
}
使用方式与实现陷阱
资料以“陷阱”形式给出使用要点,均为实现层面的事实:
- 超过 16 位的码点会中断操作。
Char(code: Int)在码点超出Char.MIN_VALUE.code..Char.MAX_VALUE.code时抛异常。😀(emoji)能正常解析为整数,随后中断解码。所有大于 0xFFFF 的码点必须转成代理对,且需手写转换,因为便捷辅助函数仅限平台。 - 先跑命名实体表会一次解码两层。 表按插入顺序迭代(
mapOf构建的是链式哈希映射),若&实体排在尖括号实体之前,&lt;先变成<,几轮后变成<;数字表达式同理,&#39;会变成'再变成撇号。资料给出两种修复且强调二者不同:把&替换放到所有遍历(包括数字遍历)的最后;或改为单次扫描,逐字符判断并在构建器中输出替换结果,永不回读已输出内容——后者在结构上保证每次调用只解一层,且更快。 - 表与正则的大小写必须一致。 表替换带
ignoreCase = true,而正则若只写小写x,则A匹配不到、原样上屏,A正常解码。应在正则中使用[xX]。 - 可选尾分号让解码比格式更贪婪。
;?配合贪婪数字串意味着A无终止符也会解码,普通文本中该形状会被改写。真实编码器总会输出分号;只有确认存在省略分号的产出方时才接受无终止符形式,且不要把它放进命名表——无终止符的名字没有明确结尾。 - 固定表之外的实体会原样保留。 数字遍历救不了命名实体,因为名字不带数字。应有意识地决定所列名字是否覆盖输入实际内容,并用 grep 在抓取的响应或保存的测试样本上打印幸存者(不要在自身源码上跑):
grep -rEo '&[a-zA-Z][a-zA-Z0-9]{1,10};' <fixtures-dir> | sed 's/.*://' | sort -u
- 只在边界解码一次、且在解析之后。 不要在解析器与上层各解一次,否则用户输入的普通
&会消失;应先解码捕获字段,而非原始行,否则[会先被解码成左方括号,制造出原输入中不存在的记录分隔符。 - 解码不是净化。 该函数把实体文本还原为字符,与转义相反。解码后的文本不得拼进标记、查询或 shell 命令;应在我方模型边缘解码,再在输出路径重新转义。
验证方法
资料给出以下检查命令与断言方式:
确认只有一个解码器、共享代码没有去调平台实现:
grep -rn "fun decodeEntities\|fun decodeHtmlEntities" --include='*.kt' .
查找所有调用点,确认没有叠加解码:
grep -rn "decodeEntities(\|decodeHtmlEntities(" --include='*.kt' . | grep -v "fun decode"
确认数字遍历覆盖十六进制标记的两种大小写:
grep -rn "&#x(\[0-9a-fA-F\]\|&#\[xX\]" --include='*.kt' .
用人工写好的期望值断言单次调用结果,因为一次调用必须恰好去掉一层:decode("&lt;") 必须等于 "<",decode("&#39;") 必须等于 "'",decode("a & b") 必须原样返回。逐实体遍历的实现会在这前两项上失败(返回 < 和 ')。不要用解码器自身做往返测试:decode(decode(s)) == decode(s) 在三组输入上对有缺陷的解码器都成立(过度解码在首次调用内完成,输出已是不动点),而对正确的解码器不成立(每次调用剥一层)。幂等是错误的性质,用它做测试会给该缺陷放行。
还应喂入 emoji 实体(😀)和超出 Unicode 范围的实体(�):前者必须产出对应字符,后者必须原样返回,两者都不能中断操作。
适用场景与限制
适用于 Kotlin Multiplatform 共享代码中需要解码 HTML 实体、又不希望依赖单一平台标记辅助函数(且不想连带剥离标签)的场景。限制:命名实体范围取决于手写固定表;不解码表外实体;不承担净化职责;无终止符形式的支持需谨慎取舍。资料未提供该方案的安装命令、发布形式、许可证或性能基准数据。