merge: W68 —— 行内标签按契约 v0.89 对齐(大小写不敏感 + 属性一律丢弃 + 结构不平衡整段降级 + 白名单收窄到五项去掉 u/s)+ <br> 改为气泡分隔符(与 <p> 同级,三种写法等价)

This commit is contained in:
AskaEth committed 2026-10-07 17:38:04 +08:00
commit 591f298dc0
6 files changed
+839 -227

No files matched your search

@@ -40,12 +40,21 @@ data class EditableMessage(
)
/**
* 已知标签的容差,与 [parse] 逐字对齐(只认解析器真正认识的那几个标签):
* `<p>`(可带属性)/ `</p>` / `<action>` / `</action>` / `<narration>` / `</narration>`。
* 已知标签的容差,与 [parse] 逐字对齐(只认解析器会**改掉**的那几个标签——行内标签不在其中,
* 见下):`<p>`(可带属性)/ `</p>` / `<action>` / `</action>` / `<narration>` / `</narration>`。
*
* 绝不能「见到一个 `<` 就当标记」:用户打的 `1 < 2`、`<div>`、`<3`、`a<b` 都是普通文本,
* 判成标记就会顺手把它们当标签吃掉——那才是真正的静默丢东西。大小写同样照解析器来:
* `parse` 只认小写的 `<p>` / `<action>` / `<narration>`,所以 `<P>` 在这里也是普通文本。
*
* **行内标签(`<b>` / `<code>` …)故意不在**这份容差里:它们是渲染用的标记,但编辑框
* 编辑的是**源码本身**,而 `parts`(这一份的来源)是**逐字原文**——所以 `日落 <b>18:35</b>` 在
* 编辑框里连 `<b>` 一起显示、也一起保存,这里一个字符都不动它。
*
* **`<br>` 同理**(契约 v0.89 更正后它是**气泡分隔符**、不是行内标签):只含 `<br>` 的消息在这份
* 容差里仍是纯文本 ⇒ 编辑框里逐字恒等(`<br>` 原样显示、原样保存);只有原文**另有**已知标记时
* 才走剥标记那条路,那时 `<br>` 与 `<p>` / `</p>` 一样被换成它本来就在表示的**空行分隔**
* (保存后渲染结果不变,且弹层已经明说标记会被替换)。
*/
private val KNOWN_TAG_RE = Regex(
"<p(\\s[^>]*)?>|</p>|<\\s*(action|narration)\\s*>|</\\s*(action|narration)\\s*>",
@@ -14,15 +14,23 @@ import kotlin.math.min
* - `<p>…</p>`:每个完整块 = 一条独立气泡;无完整 `<p>` 时回退按空行 `\n\s*\n` 切分;
* 单个 `\n` 是句内换行,绝不切分。未闭合的 `<p>` 不构成块,按普通文本处理(流式下会先缓存)。
* `<p>` 之外的非空白正文不会被丢弃:并入相邻气泡(若只是旁白/空白则依附之,否则自成一气泡)。
* - `<br>`(`<br>` / `<br/>` / `</br>` 三种写法等价,大小写不敏感,属性不解析):**与 `<p>` 同级
* 的气泡/消息分隔符**(契约 v0.89 的更正:`回执三十一`「对消息内的 `<br>` 始终处理为多条消息
* 隔断」)。它**不是行内标签**:`<p>甲<br>乙</p>` ⇒ 两条气泡,`甲<br>乙`(无 `<p>`)⇒ 同样两条;
* 中间的空气泡照旧丢弃(`<p>甲<br><br>乙</p>` ⇒ `甲`、`乙`)。切分在 [tokenize] 的**段/块这一层**
* 完成([appendRegion]),所以行内解析层(`InlineMarkup`)完全看不见它。
* - `<action>…</action>` / `<narration>…</narration>`:旁白/动作,不独立成气泡,依附气泡:
* 首部连续块 → 挂气泡上方(head);尾部连续块 → 挂下方(tail);夹在正文中间的块 → 上提到 head。
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
* 关键词 旁白/内心 → narration,其余 → action。
* - 行内标签(`InlineMarkup.kt`):`<b>`/`<strong>`、`<i>`/`<em>`、`<code>`、`<br>`/`<br/>` 四类,
* 成对才认、只有小写、未配对或嵌套错位一律字面回落(口径与理由见 `parseInlineMarkup`)。
* 它在**段落定稿之后**才做([toBubble] 里对最终正文做一次),所以既不参与、也改不动上面的
* 分段与归属:`<br>` 只换行、**不分气泡**。渲染文本落 `body` + `runs`,而片段 `parts` 仍是
* - 行内标签(`InlineMarkup.kt`,契约 v0.89):`<b>`/`<strong>`、`<i>`/`<em>`、`<code>` 三类
* (五项标签名,`u`/`s` 已按 `回执三十` 收窄撤掉),成对才认、**标签名大小写不敏感**、
* **属性一律不解析**(`<b class=x>` 就是粗体)、白名单外按纯文本原样显示、
* **结构不平衡(未闭合 / 落单闭标签 / 交错)⇒ 该气泡的正文整段降级为原文**
* (口径与理由见 `parseInlineMarkup`)。
* 它在**段落与 `<br>` 都定稿之后**才做([toBubble] 里对最终正文做一次,**逐气泡各自一份**),
* 所以既不参与、也改不动上面的分段与归属。渲染文本落 `body` + `runs`,而片段 `parts` 仍是
* 逐字原文——编辑框、搜索片段、通知文案读的都是 `parts`,那些地方一个字都不许改。
* - 落单标签容错:服务端确实会产出不配对的段落标签(实测形态 `<p><p>正文</p>`:多一个 `<p>`、
* 只有一个 `</p>`),块外也可能出现孤立的旁白标签。这类标签一律**剥掉**,绝不落进正文——
@@ -40,8 +48,9 @@ import kotlin.math.min
* 也保持字面文本,这是既有契约(`unclosedActionFallsBackToText` / `strayClosingTagStaysText`)。
*
* `ParsedBubble` 结构(每条气泡渲染一个):
* - `body` :气泡正文的**渲染文本**(仅 Text 部分按原顺序拼接后 trim,再消费掉四种行内标签:
* `<br>` 已变成换行、标签字符已去掉),已剔除全部旁白/动作块;整段只有标记没有字时回落字面原文;
* - `body` :气泡正文的**渲染文本**(仅 Text 部分按原顺序拼接后 trim,再消费掉行内标签:
* 白名单标签字符已去掉,白名单外与属性照旧逐字留着),已剔除全部旁白/动作块;
* 整段只有标记没有字、或行内标签**结构不平衡**时回落/降级为字面原文;
* - `runs` :`body` 的行内样式切分(非空时拼起来就是 `body`;空列表 = 按字面画);
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
* - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」);
@@ -54,10 +63,14 @@ import kotlin.math.min
* 因此 `finish() == parse(全部 delta 拼接)` 恒成立(逐字符/任意切分均一致)。
* - 未确定片段从以下最早位置开始整体扣留,不产出半截结果:
* 1) 未闭合的 `<action>` / `<narration>` / `<p>` 开标签(从该开标签起扣留);
* 行内开标签同样入栈扣留(词表见 [TAG_NAMES]):不扣的话中间态会把 `<b>` 当字面吐上屏
* (`日落 <b>18`)、等 `</b>` 到了又被吃掉,屏幕上已经显示过的两个字就改口了。
* `<br>` 是自闭合的,**不入栈**(入了就永远弹不出来),它只在残缺(`<br`)时按词表扣留。
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`),仅当它可能继续补全为合法标签时才扣留;
* 行内开标签同样入栈扣留(词表见 [INLINE_TAG_NAMES],判据见 [parseInlineTag]):不扣的话
* 中间态会把 `<b>` 当字面吐上屏(`日落 <b>18`)、等 `</b>` 到了又被吃掉,屏幕上已经显示过的
* 两个字就改口了。**大小写与属性也走同一份判据**(v0.89):`<B class=x>` 在扣留侧同样是
* 开标签,否则 `日落 <B class=x>18` 会先按字面吐上屏、收口时再变成粗体。
* `<br>` 是分隔符、不成对,**不入栈**(入了就永远弹不出来);它只在残缺(`<br`、`<br/`、
* `</br`)时按词表扣留([isPotentialTagPrefix] → [couldBeTagName]),完整的 `<br>` 直接放行
* ——分隔符一出现,前缀就已经可解析了。
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`、`<B`、`<br`),仅当它可能继续补全为合法标签时才扣留;
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
* - 落单标签的剥离**不需要**额外的扣留规则,这是「剥标签与扣留共用同一组标签正则」的直接结果:
@@ -95,7 +108,8 @@ data class ParsedBubble(
val tail: List<ContentPart>,
/**
* `body` 的行内样式切分(`<b>` / `<i>` / `<code>`,见 [parseInlineMarkup]):非空时拼起来
* **就是** [body]。空列表 = 这段正文没有可渲染的行内标签,按字面画——**绝大多数消息都走这一档**。
* **就是** [body]。空列表 = 这段正文按字面画——**绝大多数消息都走这一档**(没有标签、
* 或者结构不平衡被整段降级、或者整段只有标记没有字)。
*
* 有默认值,是因为只有 [toBubble] 才该填它:其余构造点要么本来就该按字面画(`plainBubble`、
* 分享预览的兜底 `sharePreviewBlocks`),要么是测试里的手搓气泡。这也让「加一个字段」不必
@@ -133,21 +147,37 @@ private val BLANK_LINE_RE = Regex("\\n\\s*\\n")
private const val FULL_STAR_MAX_LEN = 40
/**
* 已知标签词表(只有小写,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
* 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面这组同词表的正则,两边必须同源。
* 段落/旁白标签名(**只有小写**,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
* 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面那组同词表的正则,两边必须同源。
*
* 词表分两半,每一半都与**自己的消费侧**同源:段落/旁白那半([OPEN_P_TAG_RE] / [CLOSE_P_TAG_RE] /
* [OPEN_ROLE_TAG_RE] / [CLOSE_ROLE_TAG_RE],[stripLooseTags] 剥落单标签用的就是这组)、行内那半
* ([INLINE_TAG_NAMES],`InlineMarkup` 里由同一份名单生成的 [OPEN_INLINE_TAG_RE] /
* [CLOSE_INLINE_TAG_RE] / [BR_TAG_RE] 是 [parseInlineMarkup] 真正消费用的那组)。
* 任缺一半,中间态就会把标签当正文吐上屏、收口时才吃掉——屏幕上已经显示过的字就改口了。
* `<br>` 不在这个名单里:它是分隔符、且**大小写不敏感**(见 [BR_TAG_RE]),判据单独走
* [couldBeLineBreakNamePrefix],否则 `<BR` 会被漏扣、中间态把 `<BR` 当正文吐上屏。
*/
private val TAG_NAMES = listOf("action", "narration", "p") + INLINE_TAG_NAMES
private val STRUCTURE_TAG_NAMES = listOf("action", "narration", "p")
private val OPEN_ROLE_TAG_RE = Regex("^<\\s*(action|narration)\\s*>$")
private val CLOSE_ROLE_TAG_RE = Regex("^</\\s*(action|narration)\\s*>$")
private val OPEN_P_TAG_RE = Regex("^<p(\\s[^>]*)?>$")
private val CLOSE_P_TAG_RE = Regex("^</p>$")
/** `<br>` 分隔符的标签名([BR_TAG_RE] 与流式扣留侧共用,别在两处各写一遍)。 */
private const val LINE_BREAK_TAG_NAME = "br"
/**
* `<br>` **分隔符**(契约 v0.89 更正:`<br>` 不是行内标签,而是与 `<p>` 同级的「气泡/消息分隔符」)。
*
* 认下来的形状:`<br>` / `<br/>` / `<br />` / `</br>` / `<br class=x>` …——
* - **三种写法等价**,前导 `/` 与自闭合 `/` 都照收(HTML5 口径,契约写死);
* - **大小写不敏感**(`<BR>` / `<Br>` 同样算):v0.89 那句「标签名大小写不敏感」把 `br` 一起罩住了,
* 且后端 IM 出站 `channelbridge/sanitize.go` 的正则本来就是 `(?is)`(`回执三十` 的原话);
* - **属性不解析**(`<br class=x>` 照样分隔),但名字后面必须紧跟 `>`、空白或 `/`:
* `<br2>` / `<boolean>` / `<br-x>` 因此**不是**分隔符,只是普通文本(这一条是「字符一个不丢」的闸门)。
*
* ⚠️ 已知边界:它**不看外层标签的属性结构**——所以写成 `<b title="<br>">甲</b>` 这种「属性里带 `<br>`」的
* 文本会被切开。`<p …>` 开标签是唯一例外,因为它在更早一层就被 [P_BLOCK_RE] 整体吃掉了,
* 属性里的 `<br>` 根本走不到这里。真实产出里没有这种形状(属性本身就不是产出侧的东西)。
*/
private val BR_TAG_RE = Regex("<\\s*/?\\s*${LINE_BREAK_TAG_NAME}(\\s[^<>]*|/\\s*)?>", RegexOption.IGNORE_CASE)
/** 一次性解析:把一条消息的原始文本拆成若干独立气泡(含旁白依附关系)。 */
fun parse(raw: String): List<ParsedBubble> {
if (raw.isEmpty()) return emptyList()
@@ -195,7 +225,9 @@ private sealed interface Tok {
object Break : Tok
}
/** 先切 `<p>` 块(决定气泡边界),再按区剥落单标签,最后在每个文本区做旁白/星号解析。 */
/**
* 先切 `<p>` 块(决定气泡边界),再按区剥落单标签与切 `<br>` 分隔符,最后在每个文本区做旁白/星号解析。
*/
private fun tokenize(raw: String): List<Tok> {
// 完整块必然以 `</p>` 收尾,所以最后一个 `</p>` 之后不可能再有块:切掉尾巴再让正则去扫。不切的话,
// 每个 `<p` 候选点都会带着懒惰的 `([\s\S]*?)</p>` 一路回溯到串尾——正文里 N 个落单标签时
@@ -214,36 +246,75 @@ private fun tokenize(raw: String): List<Tok> {
// 一条完整块都没有:`<p>` 及其后内容按既有契约整段回落字面文本,`<action>` 这类旁白标签
// 落单时同理(`MessageEditTextTest.没闭合的 action 同样原样留着`)。此处只剥掉连「被截断的
// 结构」都算不上的东西:落单的 `</p>`,与后面什么都没有的空开标签。判据见 [stripLooseTags]。
appendAnnotations(
stripLooseTags(raw, markupMode = false, stripStrayRoleTags = false),
out,
splitBlankLines = true,
)
appendRegion(raw, out, markupMode = false, stripStrayRoleTags = false, splitBlankLines = true)
return out
}
var last = 0
for (m in pMatches) {
val lead = raw.substring(last, m.range.first)
if (lead.isNotEmpty()) {
appendAnnotations(stripLooseTags(lead, true, stripStrayRoleTags = true), out, splitBlankLines = false)
appendRegion(lead, out, markupMode = true, stripStrayRoleTags = true, splitBlankLines = false)
}
out.add(Tok.Break)
val inner = m.groupValues[2]
if (inner.isNotEmpty()) {
// 块内只剥 `<p>` 族:`<action>` / `</action>` 落单时保留字面文本,是既有契约
// (unclosedActionFallsBackToText / strayClosingTagStaysText 钉住的行为)。
appendAnnotations(stripLooseTags(inner, true, stripStrayRoleTags = false), out, splitBlankLines = false)
appendRegion(inner, out, markupMode = true, stripStrayRoleTags = false, splitBlankLines = false)
}
out.add(Tok.Break)
last = m.range.last + 1
}
val trail = raw.substring(last)
if (trail.isNotEmpty()) {
appendAnnotations(stripLooseTags(trail, true, stripStrayRoleTags = true), out, splitBlankLines = false)
appendRegion(trail, out, markupMode = true, stripStrayRoleTags = true, splitBlankLines = false)
}
return out
}
/**
* 解析一个文本区:先剥落单标签([stripLooseTags],`markupMode` / `stripStrayRoleTags` 由调用方给),
* 再按 `<br>` 分隔符切成若干段,每段各自做旁白/星号解析([appendAnnotations]),段与段之间补一个
* [Tok.Break](气泡边界)。
*
* **为什么 `<br>` 在这一层切**:契约 v0.89 的更正把 `<br>` 定成**与 `<p>` 同级的分隔符**
* (`回执三十一`「对消息内的 `<br>` 始终处理为多条消息隔断」)。它既然与 `<p>` 同级,就该与 `<p>`
* 走**同一套机制、同一个粒度**:产生 [Tok.Break]、由 `buildBubbles` 分组、空气泡照旧丢弃。
* 行内层(`InlineMarkup`)因此完全看不见 `<br>` —— 那里只剩五个白名单标签。
*
* **顺序:先切分、再逐段各自做行内解析**(`<b>甲<br>乙</b>` ⇒ 两段各按「不平衡 ⇒ 不渲染」处理,
* 两段都回落原文)。理由:
* 1. 「与 `<p>` 同级」这句话本身就规定了层序——分隔符先于行内解析,行内解析永远只看到**一段**;
* 跨过分隔符的标签在结构上不可能成立(一个气泡 = 一条消息,IM 出站也是先按气泡切开再剥标签,
* 后端 `sanitize.go` 的「成对平衡 ⇒ 剥标签留文本 / 不平衡 ⇒ 原样保留」同样是在**单条消息内**
* 判平衡的)⇒ 两端对同一段文本的结论天然一致;
* 2. 反过来(先整体行内解析、再按 `<br>` 切)会让「渲染文本」与「气泡」互相依赖:`<b>甲<br>乙</b>`
* 会先变成一个粗体片段再被切开,切点落在片段内部,`body` / `runs` 的一致性(`runs` 拼起来
* **就是** `body`)当场就没了;
* 3. 实现上也最省:行内那层一个字节都不用改(它本来就逐气泡跑一次)。
*
* 剥标签**先于**切分(判据逐字不变):[stripLooseTags] 里「这个落单 `<p>` 后面还有没有正文」看的是
* 整段原文,`<br>` 在那里只是一个普通字符,所以同一输入下它的判定与 W68 完全相同。
*/
private fun appendRegion(
text: String,
out: MutableList<Tok>,
markupMode: Boolean,
stripStrayRoleTags: Boolean,
splitBlankLines: Boolean,
) {
val stripped = stripLooseTags(text, markupMode, stripStrayRoleTags)
var last = 0
for (m in BR_TAG_RE.findAll(stripped)) {
val piece = stripped.substring(last, m.range.first)
if (piece.isNotEmpty()) appendAnnotations(piece, out, splitBlankLines)
out.add(Tok.Break)
last = m.range.last + 1
}
val tail = stripped.substring(last)
if (tail.isNotEmpty()) appendAnnotations(tail, out, splitBlankLines)
}
/**
* 剥掉「落单标签」,其余字符逐字保留(返回原对象:没剥到东西时不做新分配)。
*
@@ -506,9 +577,10 @@ private fun toBubble(tokens: List<Tok>): ParsedBubble? {
tail.add(part)
}
}
// 行内标签在**段落定稿之后**才消费(就在这一行):上面那套分段与归属一个字都不动,分段判据
// 因此完全看不见行内标签(`<br>` 造不出新气泡)。runs 为空 = 这段正文没有可渲染的行内标签,
// 正文按字面画(绝大多数消息都走这一档),一个字都不会变。
// 行内标签在**段落与 `<br>` 都定稿之后**才消费(就在这一行,且是**逐气泡各自一份**):上面那套
// 分段与归属一个字都不动,分段判据因此完全看不见行内标签(`<br>` 已经在 [appendRegion] 那层
// 变成气泡边界了)。runs 为空 = 这段正文按字面画(没有可渲染的行内标签、或行内标签结构不平衡
// 被整段降级),一个字都不会变。
val rawBody = body.toString().trim()
val runs = parseInlineMarkup(rawBody)
val bodyText = if (runs.isEmpty()) rawBody else runs.joinToString("") { it.text }
@@ -565,8 +637,9 @@ private fun stablePrefixEnd(buf: String): Int {
private fun openTagName(tag: String): String? {
OPEN_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
if (OPEN_P_TAG_RE.matches(tag)) return "p"
// 行内开标签也入栈。`<br>` 是自闭合的,**不入栈**(入了就永远弹不出来,扣留点会永久停在
// 它前面,后面的话再也显示不出来)——[openInlineTagName] 的名单里本来就没有它。
// 行内开标签也入栈(`<B class=x>` 这类形态由 [parseInlineTag] 判,与解析侧同源)。
// `<br>` 是自闭合的,**不入栈**(入了就永远弹不出来,扣留点会永久停在它前面,后面的话再也
// 显示不出来)——[openInlineTagName] 对 `<br>` / `<br/>` / `</br>` 一律返回 null。
return openInlineTagName(tag)
}
@@ -576,20 +649,49 @@ private fun closeTagName(tag: String): String? {
return closeInlineTagName(tag)
}
/** 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。 */
/**
* 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。
*
* 词表分两半,每一半都与**自己的消费侧**同源,两条口径都在这里逐字对齐:
* - 段落/旁白只认小写(`<P>` / `<Action>` 原样显示,既有契约)——名字里出现大写就与它无关;
* - 行内标签与 `<br>` 分隔符**大小写不敏感**(契约 v0.89,[parseInlineTag] / [BR_TAG_RE])
* ——`<B` 与 `<b`、`<BR` 与 `<br` 一样要扣留。
* 少扣了就是「中间态把标签当正文吐上屏、收口时才吃掉」,屏幕上显示过的字会改口。
* 名字之后是空白或 `/` 都算「还可能补全」(属性 / 自闭合,v0.89 允许带属性,所以 `<b class=x>`
* 与 `<br/` 这两个中间态也必须扣住);其它字符说明它压根不是标签(`<boolean>`、`<b2`)。
*/
private fun isPotentialTagPrefix(buf: String, lt: Int): Boolean {
var j = lt + 1
if (j < buf.length && buf[j] == '/') j++
while (j < buf.length && buf[j].isWhitespace()) j++
val nameStart = j
while (j < buf.length && buf[j] in 'a'..'z') j++
while (j < buf.length && isTagNameLetter(buf[j])) j++
val name = buf.substring(nameStart, j)
if (j >= buf.length) return name.isEmpty() || TAG_NAMES.any { it.startsWith(name) }
if (name.isEmpty()) return false
if (!buf[j].isWhitespace()) return false
return TAG_NAMES.any { it.startsWith(name) }
if (name.isEmpty()) return j >= buf.length
if (!couldBeTagName(name)) return false
if (j >= buf.length) return true
return buf[j].isWhitespace() || buf[j] == '/'
}
/**
* [name] 是否可能是某个已知标签名的前缀;三半各按自己的大小写口径(见 [isPotentialTagPrefix]):
* 段落/旁白小写、`<br>` 与行内标签大小写不敏感。
*/
private fun couldBeTagName(name: String): Boolean {
if (name.all { it in 'a'..'z' } && STRUCTURE_TAG_NAMES.any { it.startsWith(name) }) return true
val lower = name.lowercase()
if (couldBeLineBreakNamePrefix(lower)) return true
return couldBeInlineTagNamePrefix(lower)
}
/**
* [name](已小写)是否可能是 `<br>` 分隔符的标签名前缀——`<br` / `</br` / `<BR` 这种残缺形态要不要
* 扣留由它说了算。判据与 [BR_TAG_RE] 同口径(大小写不敏感的 `br`):两边一分叉,
* `甲<br` 会先把字面 `<br` 吐上屏,等 `>` 到了再变成两条气泡。
*/
private fun couldBeLineBreakNamePrefix(name: String): Boolean =
name.isNotEmpty() && LINE_BREAK_TAG_NAME.startsWith(name)
/** 自由文本区内孤立的全角 `*`(计数为奇)从最后一个起扣留。 */
private fun withholdDanglingFullStar(buf: String, from: Int, cut: Int): Int {
var count = 0
@@ -3,46 +3,87 @@ package top.yeij.cyrene.domain.parse
/*
* InlineMarkup —— 气泡正文里「一小撮行内标签」的解析(纯 Kotlin,无 Android / Compose 依赖)。
*
* 做四类,**成对才认**,只有小写:
* - `<b>` / `<strong>` → 粗体;`<i>` / `<em>` → 斜体;
* - `<code>` → 等宽 + 轻微底色(`<code>` 里不再解析,见下);
* - `<br>` / `<br/>` → 换行(自闭合,不成对)。
* 口径 = 后端 2026-10-07 定下的**契约 v0.89** 及其同日更正(跨端必须逐字一致,否则同一个 `<b>`
* 在她那台设备上粗、在这台上不粗)。四条硬口径:
* 1. 白名单 = `b` / `strong` · `i` / `em` · `code`(**成对**,五项标签名);标签名**大小写不敏感**;
* 2. **属性一律不解析**:`<b class=x onclick=y>` 只取标签名,属性整段丢掉、绝不落成任何视图属性;
* 3. **白名单外一律按纯文本原样显示**:`<script>` 显示成字面量,字符一个不丢;
* 4. **结构不平衡 ⇒ 整段降级为原文**(未闭合 / 落单闭标签 / 交错)——粗粒度是有意的,换来
* 「字符一个不丢」可以被逐字断言。
* 样式映射:`b`/`strong` → 粗体,`i`/`em` → 斜体,`code` → 等宽 + 轻微底色(且**里面的标签不解析**)。
*
* ## 为什么只有小写
* `parse` 只认小写的 `<p>` / `<action>` / `<narration>`(`MessageEditText` 同一口径,既有测试
* `plainAngleBracketsAreNeverEaten` 钉着 `<P>大小写不认</P>` 原样显示)。行内标签若认大小写,
* 流式的扣留侧也得跟着认,而那边的标签名扫描只认 `a..z`(`isPotentialTagPrefix`):两边一分叉,
* 中间态就会把 `<B>` 当正文吐上屏、等 `</B>` 到了再吃掉——那正是「字会跳」。所以大小写口径与
* `<p>` 那套**逐字对齐**。证据也只有小写:真后端最近 100 条里命中的 2 条都是 `<b>`。
* ## `br` 不在这里(契约同日更正)
* `回执三十一`:「对消息内的 `<br>` **始终处理为多条消息隔断**」⇒ `<br>` **不是行内标签**,而是与
* `<p>` 同级的**气泡/消息分隔符**,切分在 [tokenize](ChatContentParser.kt) 的段/块那一层
* (`ChatContentParser.appendRegion`)完成,`<br>` / `<br/>` / `</br>` 三种写法等价。
* 这一层因此**只认成对标签**:`<br>` 若出现在这里的文本里(真实链路上不会——它已经被切走了),
* 它只是几个普通字符,与 `<br2>`、`<boolean>` 同类。
*
* ## 为什么侵入只在 `ParsedBubble` 上加一个字段
* ## `u` / `s` 已撤掉(契约收窄)
* `回执三十`:白名单**收窄到五项**,去掉 `u`/`s`(它们在她 100 条输出里 0 命中,不值得让各端返工)。
* 多支持反而是**制造**跨端不一致(Web 不渲染、我们渲染 ⇒ 同一条消息两端显示不同),所以这里连同
* 样式、渲染、测试一起撤干净:`<u>甲</u>` 现在走「白名单外 ⇒ 原样显示」,显示成字面的 `<u>甲</u>`。
*
* ## 为什么是「整段降级」而不是「只降级那一处」
* W67 那版是**局部**降级:`甲<b>粗</b>乙<b>没闭合` 里 `粗` 照旧加粗,只有后面那半个 `<b>` 留着字面。
* 契约把「结构不平衡」定成**整段不渲染**:一处不平衡,这一整段就一个标签都不认、原文逐字显示。
* 代价是「好好的一对 `<b>` 会被邻居的落单标签连累」,换来的是**可以逐字断言**的粗粒度:
* 「渲染文本 == 原文」或者「渲染文本 == 原文消费掉全部配对标签」,只有这两种形状,没有中间态
* (局部降级那种「一半认、一半留」在跨端对齐时最容易各说各话)。见 [parseInlineMarkup]。
*
* ## 「整段」是哪一段(我们采用的口径 + 理由)
* **整段 = 该气泡的正文**,也就是 [toBubble](ChatContentParser.kt) 里 `body.toString().trim()`
* 出来的那一段(`ParsedBubble.body` 对应的文本,通常正好是一个 `<p>…</p>` 块的正文,也可能是
* 块外文本并入相邻气泡后的那一份)。不是整条消息,也不是单个 `<p>` 块:
* - **不是整条消息**:一条消息可以有多个分隔(`<p>` / `<br>`)= 多条气泡,一个气泡里写坏了一个
* 标签,不该把**别的**气泡一起按字面画出来——那既不是契约想说的「这一段」,也会让「她写坏了
* 一个标签」这种局部事故看起来像整条消息都坏了。
* - **不是单个 `<p>` 块**(虽然大多数时候两者重合):本端渲染的**最小单位就是气泡**,
* `body` / `runs` 也都是按气泡算的。降级粒度与渲染粒度取同一个,实现上才有一条清晰的边界
* (`toBubble` 一次调用 = 一次判定),断言也才写得出「这条气泡是原文、那条气泡照常渲染」。
* - 后端已认可这一点(`回执三十`:「契约只承诺『不平衡 ⇒ 不渲染、字符一个不丢』,**具体粒度算
* 实现细节**」)。
* - 因为切分(`<p>` / `<br>`)**先于**行内解析,跨分隔符的标签天然分属两段:
* `<b>甲<br>乙</b>` ⇒ 两段各不平衡 ⇒ 两段都回落原文。
*
* ## 为什么不改 `parts`,只在 `ParsedBubble` 上加一个字段
* `ContentPart.Text` 的 `text` 必须保持**逐字原文**:编辑框(`MessageEditText.visibleTextOf`)、
* 搜索片段(`SessionSearchModel.plainSearchText`)、通知文案(`ProactiveNotify`)读的都是 `parts`,
* 在那里把标签改掉就是**静默改用户内容**。所以行内解析不改片段,只在 `toBubble` 里对**已经定稿的
* 正文**算一次,结果落在 `ParsedBubble.runs`;`body` 变成正文的**渲染文本**(四个标签已消费),
* 正文**算一次,结果落在 `ParsedBubble.runs`;`body` 变成正文的**渲染文本**(白名单标签已消费),
* 而 `parts` 仍是原文——两侧各拿自己该拿的那一份。
*
* ## 与段落解析的分工(为什么不在 `tokenize` 里做)
* 行内解析发生在**段落边界已经定稿之后**(`toBubble` 里,对最终 `body` 做一次):`<p>` 分气泡、
* 空行回落、旁白归属、落单标签剥离全部照旧,行内标签既不参与、也改不动它们。`<br>` 因此**绝不会**
* 造出新的气泡边界——分气泡这件事只认 `<p>` 与空行。这也让 W63 在 `tokenize` 里去掉的两处 O(N²)
* 一行都不受影响:这里是对最终正文的一趟线性扫描。
* 行内解析发生在**段落/分隔符边界已经定稿之后**(`toBubble` 里,对最终 `body` 做一次):`<p>` 与
* `<br>` 分气泡、空行回落、旁白归属、落单标签剥离全部照旧,行内标签既不参与、也改不动它们。
* 这也让 W63 在 `tokenize` 里去掉的两处 O(N²) 一行都不受影响:这里是对最终正文的一趟线性扫描。
*
* ## 大小写不敏感(v0.89)与「扣留侧必须一起改」
* `<B>` / `<STRONG>` 都认——但**消费侧与流式扣留侧([ChatContentParser.stablePrefixEnd])
* 必须同源**,否则两边分叉:`<B` 这种残缺形态若只在消费侧认,中间态就会把它当正文吐上屏、等 `</B>`
* 到了再吃掉,**屏幕上已经显示过的字就改口了**。所以标签名的字母判据([isTagNameLetter])与
* 「这个名字还可能补全吗」([couldBeInlineTagNamePrefix],以及 `ChatContentParser.isPotentialTagPrefix`
* 里的 `couldBeTagName`)都收在**这一份文件**里,两边引用同一份名单。
* 段落/旁白标签(`<p>` / `<action>` / `<narration>`)仍然**只认小写**:这是既有契约
* (`plainAngleBracketsAreNeverEaten` 钉着 `<P>` 原样、`MessageEditText` 同一口径),
* 且契约明确「`<action>`/`<narration>` 的既有行为逐字未改」。
*
* ## 明确不做(逐条理由)
* 1. **Markdown 的 `*` / `_` 一律不做**:星号这个字形在本项目**已经被占走了**——`ROLE_RE` 把全角
* 1. **块级(`<p>` 之外的 `<div>` / `<h1>` / 列表…)不做**:契约写死「段落仍走 `<p>`」。
* 2. **`<a href>` 不做**:它不是一个样式,是一个**点击行为**(要唤浏览器),必须配一套安全策略
* (只认 http/https、不许 `intent://` / `javascript:` 之类)。契约把它划成独立安全面,实测 0 命中。
* 3. **Markdown 的 `*` / `_` 一律不做**:星号这个字形在本项目**已经被占走了**——`ROLE_RE` 把全角
* `*…*` 与半角 `*(动作|旁白|内心|神态|轻声)…*` 判成旁白/动作(`*笑*` 是动作,不是斜体),
* 既有测试钉着这条语义。再给同两个星号一个「斜体」的说法,同一条消息就有两种读法,还要先猜
* 哪一种是她的本意——那是改既有语义,不是加渲染。
* 2. **`#` 标题 / `- ` 列表 / 表格 / ``` 围栏 / `[链接](url)` / `~~删除~~` 一律不做**:真后端最近
* 4. **`#` 标题 / `- ` 列表 / 表格 / ``` 围栏 / `[链接](url)` / `~~删除~~` 一律不做**:真后端最近
* 100 条实测 0 命中;而且这些是**行级**规则,会把「正文里真有一行以 `- ` 开头」这种普通文本
* 改写成别的东西——0 命中 + 有改写风险 = 不做。
* 3. **`<a href>` 不做**:它不是一个样式,是一个**点击行为**(要唤浏览器),必须配一套安全策略
* (只认 http/https、不许 `intent://` / `javascript:` 之类)。实测 0 命中,这件事要单独拍板。
* 4. **不认识的标签(`<span>` / `<div>` / `<p2>` …)一律保持现状(原样显示)**:剥掉它们是**另一件事**
* (判据怎么定?`<3`、`a<b`、`1 < 2` 都长着尖括号),有误伤正文的风险,不顺手做。
* 5. **HTML 实体(`&lt;` `&amp;` `&nbsp;` …)不做**:实测 0 命中;实体是一张**开放的清单**
* (`&#39;` / `&#x27;` / 各种命名实体……),做一半比不做更糟——会出现「`&amp;` 解了、`&#x27;`
* 却原样显示」这种谁也说不清的口径。想在正文里显示尖括号,今天有 `<code>` 这一档。
* 6. **`<script>` / `<style>` 之类只会「原样显示」,绝不会被当 HTML 注入**:渲染侧是
* `AnnotatedString` + `SpanStyle`(结构化),正文从来没有、也不会走任何 HTML 解析/注入路径。
*
* 契约与边界口径见 [parseInlineMarkup]。
*/
@@ -50,7 +91,8 @@ package top.yeij.cyrene.domain.parse
/**
* 一段行内样式,可叠加(`<b><i>粗斜</i></b>` 同时是粗与斜)。
*
* 只有这三种:换行不是样式,它是**字符**(`<br>` → `\n`),落在片段的文本里。
* 只有这三种:换行不是样式——`<br>` 现在连「行内标签」都不是,它是气泡分隔符
* (切在 `ChatContentParser.tokenize` 那一层),根本走不到这里。
*/
enum class InlineStyle {
Bold,
@@ -70,27 +112,81 @@ data class InlineRun(val text: String, val styles: Set<InlineStyle>)
/** `<code>` 的标签名(解析里要单独判它:里面的标签不再解析)。 */
private const val CODE = "code"
/** 成对标签的名字(`strong` / `em` 是 `b` / `i` 的同义写法)。只写这一处,下面的正则由它生成。 */
/**
* 成对标签的名字(契约收窄后的**五项**):`strong` / `em` 是 `b` / `i` 的同义写法。
* 只写这一处,[parseInlineTag] 与 [couldBeInlineTagNamePrefix] 都由它推出来。
* `br` 不在这里(它是分隔符,见文件头);`u` / `s` 已按契约收窄撤掉。
*/
private val PAIRED_TAG_NAMES = listOf("b", "strong", "i", "em", CODE)
/** 自闭合的换行标签名(不成对,所以它不在 [PAIRED_TAG_NAMES] 里)。 */
private const val BR = "br"
/**
* 行内标签词表。**流式扣留侧(`ChatContentParser.stablePrefixEnd`)与这里必须同源**:
* 同一组正则既判「这是个行内标签」(消费侧),也判「这个标签名会不会继续补全」(扣留侧)。
* 同一份名单既判「这是个行内标签」(消费侧),也判「这个标签名会不会继续补全」(扣留侧)。
* 两边一分叉,中间态就会把标签当正文吐上屏、收口时才吃掉——屏幕上已经显示过的那几个字就改口了。
*/
internal val INLINE_TAG_NAMES = PAIRED_TAG_NAMES + BR
internal val INLINE_TAG_NAMES = PAIRED_TAG_NAMES
/** 认成对开标签的**完整**形状:`<b>` 这种,不带属性、不带空白(`<b class="x">` 保持原样显示)。 */
internal val OPEN_INLINE_TAG_RE = Regex("^<(${PAIRED_TAG_NAMES.joinToString("|")})>$")
/**
* 白名单行内标签认下来的两种形态。**认不出来的(白名单外、形状不对)一律返回 null**,
* 由调用方当普通文本原样显示(契约第 3 条:字符一个不丢)。
*/
internal sealed interface InlineTag {
val name: String
/** 同上,闭标签:`</b>`。 */
internal val CLOSE_INLINE_TAG_RE = Regex("^</(${PAIRED_TAG_NAMES.joinToString("|")})>$")
/** 成对的开标签(`<b>`、`<STRONG>`、`<b class=x onclick=y>`…):要入栈等它的闭合。 */
data class Open(override val name: String) : InlineTag
/** 自闭合换行标签:`<br>` / `<br/>`(容 `<br >` 这种带空白的写法,与 `<p 属性>` 同一口径)。 */
internal val BR_TAG_RE = Regex("^<$BR\\s*/?>$")
/** 成对的闭标签(`</b>`、`</EM>`…)。 */
data class Close(override val name: String) : InlineTag
}
/** 标签名里允许出现的字母:ASCII 大小写,消费侧与扣留侧共用这一把尺子。 */
internal fun isTagNameLetter(c: Char): Boolean = c in 'a'..'z' || c in 'A'..'Z'
/**
* 把一段 `<…>` 文本判成白名单行内标签(不是则 null)。**一处判据,两个消费者**:解析侧用它认标签,
* 流式扣留侧用它算「这个开标签要不要入栈扣留」——同源才不会分叉。
*
* 认下来的形状(`<` 与名字之间可以有空白,与既有 `</\s*(action|narration)\s*>` 同一口径):
* - 名字 = ASCII 字母串,**大小写不敏感**,落成小写后必须在 [INLINE_TAG_NAMES] 里;
* - 名字之后紧跟的必须是 `>`、空白或 `/`——`<boolean>` / `<br2>` / `<b-x>` 因此都不是标签
* (否则 `b` 会把一大票以 b 开头的普通单词吃成粗体),这是「字符一个不丢」的第一道闸门;
* - 再往后到 `>` 之间的一切(属性、`/`、空白)**整段丢掉**:契约第 2 条,只取标签名。
* 丢掉的只是白名单标签自己那对尖括号里的东西,正文一个字符都不动。
*
* `<br>` 在这里返回 null(它不再是行内标签,是气泡分隔符)——所以它落到「白名单外」那条路:
* 万一真被送到这一层(真实链路上不会,[appendRegion] 已经把它切走了),它只是几个普通字符。
*/
internal fun parseInlineTag(tag: String): InlineTag? {
val end = tag.length - 1
if (end < 2 || tag[0] != '<' || tag[end] != '>') return null
var i = 1
val closing = tag[i] == '/'
if (closing) i++
while (i < end && tag[i].isWhitespace()) i++
val nameStart = i
while (i < end && isTagNameLetter(tag[i])) i++
if (i == nameStart) return null
val name = tag.substring(nameStart, i).lowercase()
if (i < end && !tag[i].isWhitespace() && tag[i] != '/') return null
if (name !in PAIRED_TAG_NAMES) return null
return if (closing) InlineTag.Close(name) else InlineTag.Open(name)
}
/** 开标签名(不是行内开标签则 null)。 */
internal fun openInlineTagName(tag: String): String? =
(parseInlineTag(tag) as? InlineTag.Open)?.name
/** 闭标签名(不是行内闭标签则 null)。 */
internal fun closeInlineTagName(tag: String): String? =
(parseInlineTag(tag) as? InlineTag.Close)?.name
/**
* [name](已小写)是否可能是某个行内标签名的**前缀**——流式扣留侧判「残缺的 `<b` 还可能补全吗」
* 用它。大小写不敏感与 [parseInlineTag] 一致,名单也是同一份。
*/
internal fun couldBeInlineTagNamePrefix(name: String): Boolean =
INLINE_TAG_NAMES.any { it.startsWith(name) }
/** 标签名 → 样式;`strong` / `em` 落到与 `b` / `i` 同一个样式上。 */
internal fun inlineStyleOf(name: String): InlineStyle = when (name) {
@@ -99,80 +195,87 @@ internal fun inlineStyleOf(name: String): InlineStyle = when (name) {
else -> InlineStyle.Bold
}
/** 开标签名(不是行内开标签则 null);`<br>` 是自闭合的,**不入栈**,所以它不在这里。 */
internal fun openInlineTagName(tag: String): String? =
OPEN_INLINE_TAG_RE.find(tag)?.groupValues?.get(1)
/** 闭标签名(不是行内闭标签则 null)。 */
internal fun closeInlineTagName(tag: String): String? =
CLOSE_INLINE_TAG_RE.find(tag)?.groupValues?.get(1)
/**
* 把一段**已经定稿的正文文本**解析成行内片段(纯函数)。
*
* 三条契约(都由单测钉着):
* 1. **返回空列表 = 这段文本没有任何配成对的行内标签**,调用方按**字面**渲染(正文一个字符都不动)。
* 契约(都由单测钉着):
* 1. **返回空列表 = 这段文本按字面渲染**(正文一个字符都不动)。三种情形都落在这里:
* 没有可渲染的行内标签、**结构不平衡**(契约第 4 条,整段降级)、以及整段只有标记没有字。
* 这是绝大多数消息的路径(实测 100 条里只有 2 条带 `<b>`),也是 `plainBubble` 那条路。
* 2. 返回非空 ⇒ 片段拼起来**就是**渲染文本:`<br>` 已经变成 `\n`、标签字符已经被消费掉,且两端
* 空白已去(见下)。
* 3. **绝不把一段非空正文渲染成空**:整段只有标记没有字(`<b></b>` / `<br>`)时回落成第 1 条,
* 2. 返回非空 ⇒ 片段拼起来**就是**渲染文本:白名单标签已被消费,且两端空白已去(见下)。
* **除此之外逐字等于原文**——白名单外的标签(`<script>`)、属性、`<code>` 里的内容全都原样在
* 片段里。
* 3. **绝不把一段非空正文渲染成空**:整段只有标记没有字(`<b></b>`)时回落成第 1 条,
* 把标记原样显示——宁可让她的话里多几个尖括号,也不能给出一条空白气泡。
*
* ## 口径:配成对 + 正确嵌套
* - **未配对的标签一律字面显示**(`<b>没有闭合`、`没有闭合</b>`):与既有「未闭合/畸形标签回落
* 普通文本」同一口径(`<p>第一`、`好吧<action>笑` 都钉着字面)。剥落单标签那条容错(W55)只针对
* **段落/旁白**标签、且只在段落结构已被完整块定死时才动手;正文里的 `a<b>c` 是既有测试钉死的
* 字面文本,行内标签没有那种「结构已定死」的语境,所以走字面回落这一档。
* - **嵌套错位也字面显示**:`<b>甲<i>乙</b>丙</i>` 里的 `</b>` 关不掉 `i`(严格嵌套),于是它连同
* `<b>` 一起按字面留着,只有配对成功的 `<i>…</i>` 生效。判据只有一条,不发明「宽容关闭」。
* 两条合起来给出**绝不丢字**:只有配成对且正确嵌套的标签会被吃掉,其余一个字符都不动
* (`1 < 2`、`a<b`、`<b`、`<p2>不是标签</p2>`、`<B>` 全都原样)。
* - **`<code>` 里不再解析**:`<code><b>甲</b></code>` 显示字面的 `<b>甲</b>`。理由是 HTML 语义上
* `<code>` 就是「原样文本」的容器,而且这一档正好是「想显示尖括号怎么办」的答案;代价是代码里
* 的 `<b>` 不生效——实测 0 命中,也不存在「代码里也要粗体」这个需求。
* - 相邻同样式的片段合并:`<b>甲<br>乙</b>` 只有一段 `甲\n乙`(样式 {Bold})。
* ## 口径一:配成对且正确嵌套,否则**整段**降级为原文(契约第 4 条)
* 判定只看白名单标签的结构(白名单外的尖括号一律是普通字符,不参与判定):
* - **未闭合的开标签**(`<b>没有闭合`、`日落 <b>18:35`)、**落单的闭标签**(`没有闭合</b>`、
* 只有 `</b>`)、**交错**(`<b>甲<i>乙</b>丙</i>`:`</b>` 关不掉栈顶的 `i`)——三者任一出现,
* 这一整段**一个标签都不认**、原文逐字显示。
* - 代价与收益都写清:`甲<b>粗</b>乙<b>没闭合` 里的 `粗` 也不再加粗(粗粒度),
* 换来的是渲染文本与原文之间只有两种关系(相等 / 消费掉全部配对标签),跨端可以逐字对齐。
* - 判据只有这一条,不发明「宽容关闭」(`</b>` 顺手把里面没关的 `<i>` 也关掉)、也不按行/按句
* 局部生效:那种写法每端都能自圆其说,合起来就是跨端不一致。
* - **跨分隔符的标签各自算**:`<b>甲<br>乙</b>` 里的 `<br>` 在更早一层就把气泡切开了,
* 到这里是两段文本(`<b>甲` 与 `乙</b>`),各自不平衡 ⇒ 各自回落原文。层序与理由见
* `ChatContentParser.appendRegion` 的 KDoc。
*
* ## 口径二:`<code>` 里不再解析(与契约第 3 条一致,不是例外)
* `<code><b>甲</b></code>` 显示字面的 `<b>甲</b>`(等宽 + 底色),`<code>1 < 2 && a<b</code>`
* 原样显示。它与契约第 3 条(白名单外按纯文本显示)**不冲突、方向一致**,但**不是同一条规则**:
* - 第 3 条管的是**白名单外的标签名**(`<script>`、`<div>`)——它们从来不是「标签」;
* - `<code>` 是一条**作用域**规则:在白名单标签 `<code>` 内部,连白名单标签也按内容处理。
* `code` 表达的是「这段是代码」,代码里的 `<b>` 就是四个字符——这是 W67 就定下的口径,
* 契约没有就「`<code>` 内部」单独措辞,我们**保留原行为**(换 HTML 那种「元素照常嵌套解析」的
* 语义,改动只是删掉 [claimTags] 里的跳过分支,等后端确权再动);
* - 两者的结果都落在同一条硬口径上:**字符一个不丢**。`<code>` 也正好是「想原样显示尖括号
* 怎么办」的现成答案。
* 内部的东西**不参与结构判定**:`<code><b>甲</code>` 里的 `<b>` 不算未闭合(它只是内容),
* 所以不会因为它把整段拖成字面——判定与显示用的都是同一套「里面不算标签」的口径。
* 代价是代码里的 `<b>` 不生效(实测 0 命中,也不存在「代码里也要粗体」这个需求)。
*
* ## 为什么两端空白要去掉
* `<br>` 落在开头/结尾会凭空多出一行空白(Compose 里末尾换行就是一行空行),而正文两端的空白本来
* 就被去掉了(`toBubble` 里那句 `body.trim()`)。这一步只吃空白——一个可见字符都不会掉。
* 被标签让出来的空白(`<b> 甲 </b>` 里那两个字)留着的唯一效果是「气泡里看起来多了两个空格」,
* 而正文两端的空白本来就被去掉了(`toBubble` 里那句 `body.trim()`)。这一步只吃空白——一个可见
* 字符都不会掉;它同时兜住契约第 3 条:整段只有空白(`<b> </b>`)时回落字面,绝不给出一条
* 看起来空白的怪气泡。
*
* ## `<br>` 与既有空行分段的关系
* `<br>` 就是**一个换行符**,不与相邻的 `\n` 去重:`甲<br><br>乙` 是「甲、空一行、乙」,
* `甲<br>\n乙` 也是空一行——这是 markup 的直译(去掉哪一个都算我们替她改话)。它**不分气泡**:
* 分气泡的判据(`<p>`、空行 `\n\s*\n`)在行内解析**之前**就已经算完了,所以这里造的换行不会
* 变成新的气泡边界,也不会与空行分段叠加出「双倍」的东西。
*
* 复杂度 O(n):两趟线性扫描(每个 `<` 只看一扇固定宽度的窗,绝不 `indexOf('>')` 一路找配对);
* 正文里没有 `<` 时连集合都不建,一次就返回。
* 复杂度 O(n):两趟线性扫描(每个 `<` 只看一扇「到 `>` 或下一个 `<` 为止」的窗,绝不
* `indexOf('>')` 一路找配对);正文里没有 `<` 时连集合都不建,一次就返回。
*/
fun parseInlineMarkup(text: String): List<InlineRun> {
if (text.indexOf('<') < 0) return emptyList()
val claimed = claimTags(text)
// 结构不平衡 ⇒ 整段降级为原文(消费侧返回空列表 = 调用方按字面渲染)。
val claimed = claimTags(text) ?: return emptyList()
if (!claimed.any) return emptyList()
// 整段被吃空(`<b></b>` / `<br>` 这类只有标记没有字的形状)⇒ 回落字面路径,见契约第 3 条。
// 整段被吃空(`<b></b>` 这类只有标记没有字的形状)⇒ 回落字面路径,见契约第 3 条。
return runsOf(text, claimed).trimEdges().takeIf { it.isNotEmpty() } ?: emptyList()
}
/** 一趟扫下来的「哪些标签被认领」:开/闭标签的起始下标,以及被消费的 `<br>` 的起始下标。 */
/** 一趟扫下来的「哪些标签被认领」:开/闭标签的起始下标。 */
private class ClaimedTags {
val openStarts = HashSet<Int>()
val closeStarts = HashSet<Int>()
val breakStarts = HashSet<Int>()
val any: Boolean
get() = openStarts.isNotEmpty() || closeStarts.isNotEmpty() || breakStarts.isNotEmpty()
get() = openStarts.isNotEmpty() || closeStarts.isNotEmpty()
}
/**
* 第一趟:扫出**配成对且正确嵌套**的那些标签(起始下标落在 [ClaimedTags] 的三个集合里)。
* 第一趟:扫出配成对且正确嵌套的那些标签(起始下标落在 [ClaimedTags] 的两个集合里)。
*
* 用栈判配对,且**闭标签只关得掉栈顶那个同名开标签**(严格嵌套);剩下的(没闭合的开标签、
* 找不到伴的闭标签、错位的闭标签)一概不进集合,第二趟自然把它们原样吐回文本。
* 返回 **null = 结构不平衡**(未闭合 / 落单闭标签 / 交错),调用方据此整段降级为原文。
* 因为不平衡时整段都不认,所以有返回值时**每一个**白名单成对标签都是配好的(闭标签只关得掉栈顶
* 那个同名开标签,严格嵌套)。
*
* `<code>` 开着的期间**整段跳过**:里面的标签一个都不认(见 [parseInlineMarkup] 的口径)。
* 因为跳过的是「认不认」,所以 `<code>` 自己那一对仍照常被认领。
* 白名单外的尖括号**完全不参与判定**:它们是普通字符(契约第 1、3 条),既不入栈、也不会因为
* 「没闭合」把整段拖成字面——`甲<b>粗</b><script>` 里粗体照常生效,`<script` 原样显示。
*
* `<code>` 开着的期间**整段跳过**:里面的标签一个都不认、也不算结构(见 [parseInlineMarkup] 的
* 口径二)。因为跳过的是「认不认」,所以 `<code>` 自己那一对仍照常被认领。
*/
private fun claimTags(text: String): ClaimedTags {
private fun claimTags(text: String): ClaimedTags? {
val claimed = ClaimedTags()
val open = ArrayList<Pair<String, Int>>()
var i = 0
@@ -187,44 +290,47 @@ private fun claimTags(text: String): ClaimedTags {
continue
}
val tag = text.substring(lt, gt + 1)
val parsed = parseInlineTag(tag)
if (open.lastOrNull()?.first == CODE) {
if (closeInlineTagName(tag) == CODE) {
// `<code>` 里的一切都是它的内容:`<b>` 是四个字符,也都不算结构(所以里面那个「未闭合的
// `<b>`」不会把整段拖成字面)。只有闭合它的 `</code>` 例外。
if (parsed == InlineTag.Close(CODE)) {
claimed.openStarts.add(open.removeAt(open.lastIndex).second)
claimed.closeStarts.add(lt)
}
} else {
val openName = openInlineTagName(tag)
when {
openName != null -> open.add(openName to lt)
BR_TAG_RE.matches(tag) -> claimed.breakStarts.add(lt)
else -> {
val closeName = closeInlineTagName(tag)
if (closeName != null && open.lastOrNull()?.first == closeName) {
when (parsed) {
is InlineTag.Open -> open.add(parsed.name to lt)
is InlineTag.Close ->
// 落单的闭标签(栈空)、或交错(`</b>` 关不掉栈顶的 `i`)⇒ 整段降级。
if (open.lastOrNull()?.first != parsed.name) return null
else {
claimed.openStarts.add(open.removeAt(open.lastIndex).second)
claimed.closeStarts.add(lt)
}
}
null -> Unit
}
}
i = gt + 1
}
// 还有开标签没配到闭合 ⇒ 整段降级。
if (open.isNotEmpty()) return null
return claimed
}
/** 行内标签的最长形态(`</strong>` = 名字 + 3);扫的时候只看这么宽一扇窗。 */
private val MAX_INLINE_TAG_LEN = INLINE_TAG_NAMES.maxOf { it.length } + 3
/**
* [lt] 处这个 `<` 起头的**行内**标签在哪结束(不是行内标签则 -1)。
*
* 刻意**不用** `indexOf('>', lt)` 一路找过去:正文里 N 个落单的 `<` 时那是 O(N²)
* (W63 在同一片区域刚踩过这个坑)。行内标签都是定长形态,所以只看一扇 [MAX_INLINE_TAG_LEN]
* 宽的窗就够;窗里再冒出一个 `<`,也说明这一处起头的不是标签——扫描因此是线性的。
* 一路走到 `>` 或**下一个 `<`** 为止:契约要求「属性一律不解析」,标签多长因此不再有上界
* (`<b class="…" onclick="…">` 可以很长),W67 那版「只看一扇定宽窗」的做法行不通了。
* 仍然刻意**不用** `indexOf('>', lt)` 一路找过去:正文里 N 个落单的 `<` 时那是 O(N²)
* (W63 在同一片区域刚踩过这个坑),而且它还会把中间真正的标签一起跳过。
* 逐段推进仍是线性的:每次从 `lt + 1` 扫到「下一个 `<`」或串尾,这些区间互不重叠。
*/
private fun inlineTagEnd(text: String, lt: Int): Int {
val limit = minOf(text.length, lt + MAX_INLINE_TAG_LEN)
var i = lt + 1
while (i < limit) {
while (i < text.length) {
when (text[i]) {
'>' -> return i
'<' -> return -1
@@ -235,8 +341,9 @@ private fun inlineTagEnd(text: String, lt: Int): Int {
}
/**
* 第二趟:把文本切成片段——被认领的标签消费掉(开/闭标签不入字、`<br>` 变成 `\n`),其余**逐字**
* 进片段。因为第一趟只收严格嵌套的配对,这里的栈与它逐字对应,不会弹错人。
* 第二趟:把文本切成片段——被认领的标签消费掉(开/闭标签不入字),其余**逐字**进片段
* (白名单外的标签、`<code>` 里的内容、没有被认领的整段标记,都在这里原样吐回)。
* 因为第一趟只收严格嵌套的配对,这里的栈与它逐字对应,不会弹错人。
*/
private fun runsOf(text: String, claimed: ClaimedTags): List<InlineRun> {
val runs = ArrayList<InlineRun>()
@@ -266,9 +373,10 @@ private fun runsOf(text: String, claimed: ClaimedTags): List<InlineRun> {
if (lt > i) piece.append(text, i, lt)
val gt = inlineTagEnd(text, lt)
if (gt < 0) {
// 这一处起头的东西不是行内标签(`1 < 2` 里那个 `<`、残缺的 `<b`…):把这个字符原样收下、
// 从下一个字符继续。**必须与第一趟用同一把尺子**——这里若改用 `indexOf('>', lt)` 找标签
// 边界,第一趟认下的那对标签就会在这里被当成一个字面整段吞掉(`1 < 2 <b>甲</b>`)。
// 这一处起头的东西不是行内标签(`1 < 2` 里那个 `<`、残缺的 `<b`、属性里带 `<` 的…):
// 把这个字符原样收下、从下一个字符继续。**必须与第一趟用同一把尺子**——这里若改用
// `indexOf('>', lt)` 找标签边界,第一趟认下的那对标签就会在这里被当成一个字面整段吞掉
// (`1 < 2 <b>甲</b>`)。
piece.append('<')
i = lt + 1
continue
@@ -287,11 +395,6 @@ private fun runsOf(text: String, claimed: ClaimedTags): List<InlineRun> {
styles = stylesOf(open)
}
claimed.breakStarts.contains(lt) -> {
flush()
piece.append('\n')
}
else -> piece.append(tag)
}
i = gt + 1
@@ -789,11 +789,12 @@ private fun BubbleBody(
private const val INLINE_CODE_ON_USER_BACKGROUND = 0.18f
/**
* 行内片段 → 一段 `AnnotatedString`:一个 `Text` 就同时承载粗体 / 斜体 / 等宽底色 / 换行。
* 行内片段 → 一段 `AnnotatedString`:一个 `Text` 就同时承载粗体 / 斜体 / 等宽底色。
*
* **为什么不拼一串 `Text` 节点**:换行与折行得由同一个排版器决定,手拼 `Row` 会让折行位置跟她原话里
* 那个换行打架(`<br>` 造出来的换行必须真的换行,而不是把后半段挪到另一个节点里);而且一个 `Text`
* 天然是一整段可选中的文本([SelectionContainer] 的行为一个字都不用改)。
* **为什么不拼一串 `Text` 节点**:一条气泡本来就是**一整段可选中的文本**([SelectionContainer]
* 的行为一个字都不用改),折行位置也由同一个排版器一次定完——拆成多个节点会让可选性与折行
* 各自为政。`<br>` 已经不在这一层(它是气泡分隔符,切在解析层),所以这里不再有「插一个换行」
* 这种活。
*
* 样式只给**有**的那几项(`null` = 继承气泡自己的字重/字体),所以非 `<code>` 的片段与原来逐像素相同。
*/
@@ -68,6 +68,163 @@ class ChatContentParserTest {
assertEquals(listOf(ContentPart.Action("笑")), bubbles[0].tail)
}
// ------------------------------------------------------------------
// `<br>` 分隔符(契约 v0.89 更正:与 `<p>` 同级的气泡/消息分隔符,不是行内标签)
// ------------------------------------------------------------------
/** 契约表第一行:`<p>甲<br>乙</p>` ⇒ **两条**气泡。 */
@Test
fun lineBreakInsideParagraphSplitsBubbles() {
val bubbles = parse("<p>甲<br>乙</p>")
assertEquals(2, bubbles.size)
assertEquals(listOf("甲", "乙"), bubbles.map { it.body })
assertEquals(listOf(ContentPart.Text("甲"), ContentPart.Text("乙")), bubbles.flatMap { it.parts })
}
/** 契约表第二行:没有 `<p>` 时同理 ⇒ 两条气泡。 */
@Test
fun lineBreakWithoutParagraphAlsoSplitsBubbles() {
assertEquals(listOf("甲", "乙"), parse("甲<br>乙").map { it.body })
assertEquals(listOf("甲", "乙", "丙"), parse("甲<br>乙<br>丙").map { it.body })
}
/** 契约表第三行:`<p>甲<br><br>乙</p>` ⇒ `甲`、`乙`(**空气泡照旧丢弃**,与既有 `<p>` 口径一致)。 */
@Test
fun emptyBubblesBetweenLineBreaksAreDropped() {
assertEquals(listOf("甲", "乙"), parse("<p>甲<br><br>乙</p>").map { it.body })
assertEquals(listOf("甲", "乙"), parse("甲<br><br>乙").map { it.body })
assertEquals(listOf("甲", "乙"), parse("甲<br>\n<br>乙").map { it.body })
assertTrue(parse("<p><br></p>").isEmpty())
assertTrue(parse("<br>").isEmpty())
assertTrue(parse("<br><br>").isEmpty())
}
/** 契约表第四行:`<br>` 两侧都是空白 ⇒ 不产生多余空气泡(只吃空白,正文一个字不掉)。 */
@Test
fun whitespaceAroundLineBreakProducesNoExtraBubble() {
assertEquals(listOf("甲", "乙"), parse("甲 <br> 乙").map { it.body })
assertEquals(listOf("甲", "乙"), parse(" 甲<br>乙 ").map { it.body })
assertEquals(listOf("甲"), parse(" <br>甲").map { it.body })
assertEquals(listOf("甲"), parse("甲<br> ").map { it.body })
assertEquals(listOf("甲", "乙"), parse("<p>甲<br> </p><p>乙</p>").map { it.body })
}
/** 三种写法等价(`<br>` / `<br/>` / `</br>`),带属性一样算,`<br >` 这种空白写法也收。 */
@Test
fun lineBreakFormsAreEquivalent() {
for (form in listOf("<br>", "<br/>", "<br />", "</br>", "</br/>", "<br >", "<br class=x>")) {
assertEquals("「$form」应当是一样的一条分隔", listOf("甲", "乙"), parse("甲${form}乙").map { it.body })
assertEquals("「$form」在 `<p>` 里同理", listOf("甲", "乙"), parse("<p>甲${form}乙</p>").map { it.body })
}
}
/**
* 大小写不敏感(`<BR>` 也分隔):v0.89 那句「标签名大小写不敏感」把 `br` 一起罩住了,且后端 IM
* 出站 `channelbridge/sanitize.go` 的正则本来就是 `(?is)`(`回执三十` 原话)。
*/
@Test
fun lineBreakTagIsCaseInsensitive() {
for (form in listOf("<BR>", "<Br>", "</BR>", "<bR/>", "<BR class=x>")) {
assertEquals("「$form」应当是一样的一条分隔", listOf("甲", "乙"), parse("甲${form}乙").map { it.body })
}
}
/** 名字后面不是 `>`/空白/`/` 的都不是分隔符:`<br2>`、`<boolean>`、`<br-x>` 只是普通文本。 */
@Test
fun lookalikeTagsAreNotLineBreaks() {
for (raw in listOf("<br2>甲</br2>", "<boolean>甲</boolean>", "<br-x>甲</br-x>", "<br/x>甲", "1 < 2")) {
assertEquals("「$raw」不是分隔符", listOf(raw), parse(raw).map { it.body })
}
}
/**
* `<br>` 在 `<code>` / `<action>` 里**也照样分隔**——契约原话是「对消息内的 `<br>` **始终**处理为
* 多条消息隔断」,而分隔符切在更早的那一层:行内层的 `<code>` 作用域、旁白层的 `<action>` 配对
* 都管不到它(`<p>` 同理:它切开一个 `<action>` 块时也一样不留情)。
*
* 代价是切完以后两半各自的标签**不成对**,于是按既有口径回落字面(`<code>a` / `b</code>`;
* `<action>笑` / `了</action>`)——这正是「未配对的旁白标签 ⇒ 字面」那条既有契约,不是 br 新造的规则。
*/
@Test
fun lineBreakAlsoSplitsInsideCodeAndNoteBlocks() {
assertEquals(listOf("<code>a", "b</code>"), parse("<p><code>a<br>b</code></p>").map { it.body })
assertEquals(listOf("<action>笑", "了</action>"), parse("<p><action>笑<br>了</action></p>").map { it.body })
assertEquals(listOf("<action>笑", "了</action>"), parse("<action>笑<br>了</action>").map { it.body })
}
/**
* 跨分隔符的行内标签:**先切分、再逐段各自做行内解析** ⇒ 两段各自「结构不平衡」⇒ 两段都回落原文。
*
* 层序理由写在 `ChatContentParser.appendRegion` 的 KDoc:`<br>` 与 `<p>` 同级 ⇒ 分隔符先于行内
* 解析;而且一个气泡 = 一条消息,跨分隔符的标签在结构上不可能成立(后端剥标签时同样是按单条
* 消息判平衡的)。
*/
@Test
fun inlineTagsCannotSpanALineBreak() {
for (raw in listOf("<p><b>甲<br>乙</b></p>", "<b>甲<br>乙</b>")) {
val bubbles = parse(raw)
assertEquals("「$raw」两段都该回落原文", listOf("<b>甲", "乙</b>"), bubbles.map { it.body })
assertTrue(bubbles.all { it.runs.isEmpty() })
}
// 每一段**各自**平衡时照常渲染:粗体只罩住它自己那一段
val ok = parse("<p><b>甲</b><br><b>乙</b></p>")
assertEquals(listOf("甲", "乙"), ok.map { it.body })
assertEquals(listOf(run("甲", InlineStyle.Bold)), ok[0].runs)
assertEquals(listOf(run("乙", InlineStyle.Bold)), ok[1].runs)
}
/** `parts` 仍是**逐字原文**:分隔符 `<br>` 与 `<p>` / `</p>` 一样属于结构,不进片段。 */
@Test
fun lineBreakIsStructureSoItStaysOutOfParts() {
val bubble = parse("<p>日落 <b>18:35</b><br>记住了。</p>").first()
assertEquals(listOf(ContentPart.Text("日落 <b>18:35</b>")), bubble.parts)
assertEquals("日落 18:35", bubble.body)
assertEquals(listOf(run("日落 "), run("18:35", InlineStyle.Bold)), bubble.runs)
}
/** `<br>` 与旁白依附的关系:纯旁白那一段照旧不独立成气泡,跨分隔符附着到下一段(与 `<p>` 同款)。 */
@Test
fun noteOnlySegmentStillAttachesAcrossALineBreak() {
val bubbles = parse("<action>笑</action><br>你好")
assertEquals(1, bubbles.size)
assertEquals("你好", bubbles[0].body)
assertEquals(listOf(ContentPart.Action("笑")), bubbles[0].head)
}
/** 逐字符流式:中间态一帧都不许把 `<br` 当正文吐上屏,收口后真的变成两条气泡。 */
@Test
fun lineBreakSplitsBubblesWhileStreaming() {
val parser = StreamingContentParser()
var shown = emptyList<String>()
for (ch in "甲<br>乙") {
val bodies = parser.onDelta(ch.toString()).map { it.body }
assertTrue("中间态把标签漏上了屏:$bodies", bodies.none { it.contains('<') })
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
shown.forEachIndexed { i, body ->
assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body))
}
shown = bodies
}
assertEquals(listOf("甲", "乙"), parser.finish().map { it.body })
}
/** 残缺的 `<br` 同样扣留(否则中间态先把字面 `<br` 吐上屏、收口时再变成两条气泡——字会跳)。 */
@Test
fun streamingWithholdsPartialLineBreakTag() {
for (partial in listOf("甲<br", "甲<BR", "甲</br", "甲<br/", "甲<br ", "甲<br class=\"x\"")) {
val mid = StreamingContentParser().onDelta(partial)
assertEquals("「$partial」残缺时必须扣住,只显示分隔符前的正文", listOf("甲"), mid.map { it.body })
}
// 补全之后才产出第二条气泡,且已显示过的正文没有改口
val parser = StreamingContentParser()
assertEquals(listOf("甲"), parser.onDelta("甲<br").map { it.body })
assertEquals(listOf("甲"), parser.onDelta(" class=x").map { it.body })
assertEquals(listOf("甲", "乙"), parser.onDelta(">乙").map { it.body })
assertEquals(listOf("甲", "乙"), parser.finish().map { it.body })
}
// ------------------------------------------------------------------
// 旁白依附:首部 → head,尾部 → tail,中间 → 上提 head
// ------------------------------------------------------------------
@@ -553,6 +710,26 @@ class ChatContentParserTest {
}
}
/**
* `<br>` 那趟扫描([BR_TAG_RE] 的 `findAll`)同样不许把 `parse` 拖成平方:一串只有尖括号、
* 或一串「像分隔符但缺一个字符」的文本,每个 `<` 都只允许常数级尝试。
*/
@Test
fun hugeRunOfLineBreakLookalikesStaysWithinALinearBudget() {
val count = 20_000
for (input in listOf("<br".repeat(count), "<".repeat(count), "<br)x".repeat(count / 3))) {
val startedAt = System.nanoTime()
val bubbles = parse(input)
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000
assertTrue("「${input.take(6)}…」不该分出气泡:${bubbles.size}", bubbles.size == 1)
assertTrue(
"${input.length} 字符(一串像 `<br>` 的文本)用了 ${elapsedMs}ms," +
"超出线性预算 ${looseTagBudgetMs}ms——复杂度退回 O(N²) 了?",
elapsedMs < looseTagBudgetMs,
)
}
}
/** 产出的任何片段里都不许出现段落标签本身——这是落单标签容错的底线。 */
private fun assertNoLooseTagInRenderedText(bubbles: List<ParsedBubble>) {
for (bubble in bubbles) {
@@ -561,4 +738,6 @@ class ChatContentParserTest {
}
}
}
private fun run(text: String, vararg styles: InlineStyle) = InlineRun(text, styles.toSet())
}
@@ -10,16 +10,35 @@ import top.yeij.cyrene.ui.chat.sharePreviewBlocks
import kotlin.random.Random
/**
* 行内标签(`InlineMarkup.kt`)单测:四类标签各自渲染成什么、六条边界口径、以及
* 行内标签(`InlineMarkup.kt`)单测:五项白名单各自渲染成什么、边界口径、以及
* 「星号语义一个字都没被动过」的反证。
*
* 断言的是**纯函数的结果**(片段文本 + 样式),不碰 Compose:`runs` 拼起来就是渲染文本,
* 所以「哪段字是什么样式」在这里是直接可断言的。
*
* ## 口径沿革(两轮,都按后端契约改的)
* **W68 按 v0.89**:标签名**大小写不敏感**(`<B>` = `<b>`);**属性一律不解析**
* (`<b class="x">` 就是粗体,属性整段丢掉);**结构不平衡 ⇒ 该气泡正文整段降级为原文**
* (取代 W67 的「只坏那一对」)。⚠️ 大小写那条**消费侧与流式扣留侧必须一起改**:
* `<B` 这种残缺形态若只在消费侧认,中间态就会把它当正文吐上屏、等 `</B>` 到了再吃掉,
* 屏幕上显示过的字会改口(由 `streamingWithholdsMixedCaseAndAttributeTagsToo` 钉着)。
*
* **W69 按 `回执三十` / `回执三十一`**:
* 1. **白名单收窄到五项**(`b` / `strong` / `i` / `em` / `code`)⇒ **`u` / `s` 撤掉**:
* 它们在她 100 条输出里 0 命中,多支持反而是制造跨端不一致(Web 不渲染、我们渲染)。
* 现在 `<u>甲</u>` 走「白名单外 ⇒ 原样显示」——这条由
* [tagsOutsideTheWhitelistAreShownLiterallyWithoutLosingACharacter] 连着 `<script>` 一起钉住。
* 2. **`<br>` 不是行内标签**,而是与 `<p>` 同级的气泡/消息分隔符 ⇒ 从这一层**整体移出**,
* 切分改在 `ChatContentParser.appendRegion`,用例搬去 `ChatContentParserTest`。
* 这一层里 `<br>` 只剩「普通字符」这一种身份(见 [lineBreakTagIsNotAnInlineTagHere])。
*
* 凡是不与这两轮契约冲突的老断言(绝不丢字、`parts` 逐字原文、星号语义、流式一致性、复杂度)
* 一条都没放松。
*/
class InlineMarkupTest {
// ------------------------------------------------------------------
// 四类标签各自渲染成什么
// 五项白名单各自渲染成什么
// ------------------------------------------------------------------
@Test
@@ -43,13 +62,6 @@ class InlineMarkupTest {
assertEquals(listOf(run("abc", InlineStyle.Code)), parseInlineMarkup("<code>abc</code>"))
}
@Test
fun brTagsBecomeANewline() {
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲<br>乙"))
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲<br/>乙"))
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲<br />乙"))
}
@Test
fun mixedTagsSplitTheTextIntoStyledRuns() {
assertEquals(
@@ -81,6 +93,69 @@ class InlineMarkupTest {
assertTrue(parseInlineMarkup("多行\n文本\n也是普通的").isEmpty())
}
// ------------------------------------------------------------------
// 契约 v0.89 之一:标签名大小写不敏感
// ------------------------------------------------------------------
/**
* W68 按契约 v0.89 新增(原口径「只认小写」与它冲突,见类 KDoc):五项标签的大小写任意写法都认,
* 且改不成别的样式——`<B>` 就是 `<b>`。
*/
@Test
fun tagNamesAreCaseInsensitive() {
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<B>粗</B>"))
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<Strong>粗</Strong>"))
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<STRONG>粗</STRONG>"))
assertEquals(listOf(run("斜", InlineStyle.Italic)), parseInlineMarkup("<I>斜</I>"))
assertEquals(listOf(run("斜", InlineStyle.Italic)), parseInlineMarkup("<Em>斜</Em>"))
assertEquals(listOf(run("码", InlineStyle.Code)), parseInlineMarkup("<CODE>码</CODE>"))
// 大小写混着嵌套也照常叠加
assertEquals(
listOf(run("粗斜", InlineStyle.Bold, InlineStyle.Italic)),
parseInlineMarkup("<B><i>粗斜</i></B>"),
)
}
/**
* 大写**只对行内标签**成立:段落/旁白标签仍只认小写(既有契约 `<P>大小写不认</P>`、
* `MessageEditText` 同一口径;契约也写明「`<action>`/`<narration>` 的既有行为逐字未改」)。
*/
@Test
fun uppercaseStillMeansNothingForParagraphAndRoleTags() {
assertEquals(listOf("<P>大小写不认</P>"), parse("<P>大小写不认</P>").map { it.body })
assertEquals(listOf("甲<ACTION>笑</ACTION>"), parse("<p>甲<ACTION>笑</ACTION></p>").map { it.body })
}
// ------------------------------------------------------------------
// 契约 v0.89 之二:属性一律不解析
// ------------------------------------------------------------------
/**
* W68 按契约 v0.89 改(原口径「`<b class="x">` 原样保留」与它冲突,见类 KDoc):
* 白名单标签带任何属性都按标签名处理,属性**整段丢掉**,绝不落进正文、更不落成任何视图属性。
*/
@Test
fun attributesAreDroppedEntirely() {
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<b class=\"x\">粗</b>"))
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<b onclick=\"y\">粗</b>"))
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<b class=x onclick=y style=z>粗</b>"))
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<b data-甲=一>粗</b>"))
assertEquals(listOf(run("码", InlineStyle.Code)), parseInlineMarkup("<code class=lang>码</code>"))
// 属性里那串字一个都不许留在渲染文本里(`class` / `x` / `onclick` / `y` 全都不见)
val runs = parseInlineMarkup("日落 <b class=\"x\" onclick=\"y\">18:35</b>,比上海晚了整整一小时呢。")
assertEquals(
listOf(run("日落 "), run("18:35", InlineStyle.Bold), run(",比上海晚了整整一小时呢。")),
runs,
)
assertFalse(runs.concat().contains("class"))
assertFalse(runs.concat().contains("onclick"))
// 带属性的标签同样要配成对:`<b class=x>` 没闭合 ⇒ 整段降级(不是「属性被丢、粗体照旧」)
assertEquals("<b class=\"x\">甲", parse("<p><b class=\"x\">甲</p>").single().body)
}
// ------------------------------------------------------------------
// 边界 2:嵌套;边界 5:code 里的内容不再解析
// ------------------------------------------------------------------
@@ -101,22 +176,55 @@ class InlineMarkupTest {
)
}
@Test
fun aNewlineInsideBoldStaysBold() {
assertEquals(listOf(run("甲\n乙", InlineStyle.Bold)), parseInlineMarkup("<b>甲<br>乙</b>"))
}
/**
* `<code>` 里想显示什么就显示什么——包括尖括号(这也是「想显示尖括号怎么办」的答案)。
*
* 它与契约「白名单外按纯文本原样显示」**不冲突、方向一致**,但不是同一条规则:第 1 条管的是
* 白名单外的**标签名**,`<code>` 管的是一条**作用域**(里面连白名单标签也按内容处理)。
* 两者的结果都落在同一条硬口径上:字符一个不丢。理由写在 `parseInlineMarkup` 的 KDoc 里。
*/
@Test
fun codeContentIsNotParsed() {
// `<code>` 里想显示什么就显示什么——包括尖括号(这也是「想显示尖括号怎么办」的答案)。
assertEquals(listOf(run("<b>甲</b>", InlineStyle.Code)), parseInlineMarkup("<code><b>甲</b></code>"))
assertEquals(listOf(run("1 < 2 && a<b", InlineStyle.Code)), parseInlineMarkup("<code>1 < 2 && a<b</code>"))
// 里面的东西也**不算结构**:这个「未闭合的 `<b>`」只是四个字符,不把整段拖成字面
assertEquals(listOf(run("<b>甲", InlineStyle.Code), run(" 尾")), parseInlineMarkup("<code><b>甲</code> 尾"))
}
/**
* W69(`回执三十一`):`<br>` **不再是行内标签**,所以这一层对它只剩「普通字符」这一种身份——
* 它不换行、也不影响旁边的成对标签(`<b>甲<br>乙</b>` 在这里是一对正常的粗体,文本里带着
* 字面的 `<br>`)。
*
* 真实链路上这一层根本看不到 `<br>`:`ChatContentParser.appendRegion` 已经先把它切成气泡边界了,
* 所以上面的形状只可能出现在「函数被单独调用」的场景里。气泡切分那一侧的用例在
* `ChatContentParserTest`(含三种写法等价、空气泡丢弃、跨分隔符的标签各自回落原文)。
*/
@Test
fun lineBreakTagIsNotAnInlineTagHere() {
assertEquals(listOf(run("甲<br>乙", InlineStyle.Bold)), parseInlineMarkup("<b>甲<br>乙</b>"))
assertTrue(parseInlineMarkup("甲<br>乙").isEmpty())
assertTrue(parseInlineMarkup("甲<br/>乙").isEmpty())
assertTrue(parseInlineMarkup("甲</br>乙").isEmpty())
assertTrue(parseInlineMarkup("甲<br2>乙</br2>").isEmpty())
}
/** 而它作为**气泡分隔符**的作用由气泡切分层负责:`<br>` 现在真的分成两条气泡。 */
@Test
fun lineBreakTagSplitsBubblesAtTheParserLevel() {
assertEquals(listOf("甲", "乙"), parse("<p>甲<br>乙</p>").map { it.body })
assertEquals(listOf("甲", "乙"), parse("甲<br>乙").map { it.body })
}
// ------------------------------------------------------------------
// 边界 1:未配对的标签(一条口径:配成对 + 正确嵌套,否则字面)
// 契约 v0.89 之三:结构不平衡 ⇒ 整段降级为原文
// (粒度是「该气泡的正文」;W67 的局部降级口径与它冲突,W68 已按契约改掉)
// ------------------------------------------------------------------
/**
* W68 按契约 v0.89 改:**未闭合**的开标签 ⇒ 整段原文。
* 返回空列表 = 调用方按字面渲染,正文一个字符都不动。
*/
@Test
fun unpairedTagsStayLiteral() {
// 没有闭合 ⇒ 整段字面(列表为空 = 按字面渲染,正文一个字符都不动)
@@ -125,33 +233,86 @@ class InlineMarkupTest {
assertTrue(parseInlineMarkup("日落 <b>18:35").isEmpty())
assertTrue(parseInlineMarkup("<i>斜").isEmpty())
assertTrue(parseInlineMarkup("<code>看星星").isEmpty())
// 大小写混用、带属性也一样算未闭合
assertTrue(parseInlineMarkup("日落 <B>18:35").isEmpty())
assertTrue(parseInlineMarkup("日落 <b class=x>18:35").isEmpty())
}
/** 配对的不受影响:半个标签字面留着,配成对的那半个照常生效。 */
/**
* W68 按契约 v0.89 改:原断言是「配成对的另一半照常生效,只有落单那半个留着字面」
* (`<b>甲<i>乙</i>` ⇒ `<b>甲` 字面 + `乙` 斜体)。契约把它换成了**整段降级**:
* 一处不平衡,这一整段一个标签都不认——粗粒度换来「可逐字断言」,理由见类 KDoc。
*/
@Test
fun thePairedHalfStillWorksWhenTheOtherHalfIsMissing() {
assertEquals(
listOf(run("<b>甲"), run("乙", InlineStyle.Italic)),
parseInlineMarkup("<b>甲<i>乙</i>"),
)
fun oneUnclosedTagFallsTheWholeBodyBackToLiteralText() {
assertTrue(parseInlineMarkup("<b>甲<i>乙</i>").isEmpty())
assertEquals("<b>甲<i>乙</i>", parse("<p><b>甲<i>乙</i></p>").single().body)
assertEquals(emptyList<InlineRun>(), parse("<p><b>甲<i>乙</i></p>").single().runs)
}
/**
* W68 按契约 v0.89 改:原断言是「`</b>` 关不掉 `i`,于是它连同 `<b>` 一起按字面留着,
* 只有配成对的 `<i>…</i>` 生效」。契约把它换成整段降级:**交错**是最容易各端各说各话的形状,
* 一律按原文显示。
*/
@Test
fun mismatchedNestingStaysLiteral() {
// `</b>` 关不掉 `i`(严格嵌套),于是它连同 `<b>` 一起按字面留着,只有 `<i>…</i>` 生效。
assertEquals(
listOf(run("<b>甲"), run("乙</b>丙", InlineStyle.Italic)),
parseInlineMarkup("<b>甲<i>乙</b>丙</i>"),
)
fun interleavedTagsFallTheWholeBodyBackToLiteralText() {
assertTrue(parseInlineMarkup("<b>甲<i>乙</b>丙</i>").isEmpty())
assertEquals("<b>甲<i>乙</b>丙</i>", parse("<p><b>甲<i>乙</b>丙</i></p>").single().body)
}
/** W68 按契约 v0.89 改:原断言是「落在后面的那个 `</b>` 什么都不关,前面的粗体照旧」。 */
@Test
fun aStrayCloseTagNeverClosesAnything() {
assertEquals(listOf(run("甲", InlineStyle.Bold), run("乙</b>")), parseInlineMarkup("<b>甲</b>乙</b>"))
fun aStrayCloseTagFallsTheWholeBodyBackToLiteralText() {
// 落单的闭标签(栈空)与「只有闭标签」都是不平衡 ⇒ 整段原文
assertTrue(parseInlineMarkup("<b>甲</b>乙</b>").isEmpty())
assertTrue(parseInlineMarkup("</b>").isEmpty())
assertTrue(parseInlineMarkup("甲</i>乙</b>").isEmpty())
assertEquals("<b>甲</b>乙</b>", parse("<p><b>甲</b>乙</b></p>").single().body)
}
/**
* W68 按契约 v0.89 新增:**四种不平衡形状**各自的整段降级,且降级的是「整段原文」,
* 逐字对照。
*/
@Test
fun everyImbalancedShapeFallsBackToTheOriginalTextCharacterForCharacter() {
for (raw in listOf(
"日落 <b>18:35", // 未闭合
"甲<b>粗</b>乙<b>没闭合", // 一半配好、后一半未闭合
"甲<b>粗</b>乙</b>", // 落单闭标签
"</b>", // 只有闭标签
"<b>甲<i>乙</b>丙</i>", // 交错
"<B>甲", // 大写 + 未闭合
"<b class=x>甲", // 属性 + 未闭合
)) {
assertTrue("「$raw」结构不平衡,必须整段降级", parseInlineMarkup(raw).isEmpty())
assertEquals("「$raw」必须逐字原文", raw, parse("<p>$raw</p>").single().body)
assertTrue(parse("<p>$raw</p>").single().runs.isEmpty())
}
}
/**
* 契约说的「整段」= **该气泡的正文**(粒度口径与理由写在 `parseInlineMarkup` 的 KDoc 里)。
* 这条钉住它的边界:**坏掉的那条气泡整段原文,别条气泡照常渲染**——既不是整条消息,
* 也不是(更细的)按行/按句局部生效。
*/
@Test
fun theFallbackCoversOnlyTheBubbleThatIsBroken() {
val bubbles = parse("<p>日落 <b>18:35</b></p><p>甲<b>没闭合</p>")
assertEquals(listOf("日落 18:35", "甲<b>没闭合"), bubbles.map { it.body })
assertEquals(listOf(run("日落 "), run("18:35", InlineStyle.Bold)), bubbles[0].runs)
assertTrue(bubbles[1].runs.isEmpty())
// 落单闭标签同理:只影响它自己那条气泡
val strayClose = parse("<p>日落 <b>18:35</b></p><p>乙</b></p>")
assertEquals(listOf("日落 18:35", "乙</b>"), strayClose.map { it.body })
assertTrue(strayClose[1].runs.isEmpty())
}
// ------------------------------------------------------------------
// 边界 3:正文里本来就想显示的尖括号
// 契约之一:白名单外一律按纯文本原样显示(字符一个不丢)
// W69 起 `u` / `s` 也在这一档里(契约收窄到五项)
// ------------------------------------------------------------------
@Test
@@ -159,13 +320,13 @@ class InlineMarkupTest {
for (text in listOf(
"1 < 2 且 3 > 2",
"a<b",
"a<b>c", // 没有 `</b>` ⇒ 字面(既有测试同样钉着这一条)
"a<b>c", // 没有 `</b>` ⇒ 未闭合 ⇒ 整段字面
"你好<b", // 残缺,没有 `>`
"<p2>这不关 p 标签的事</p2>",
"<span>不认识的标签</span>",
"<B>大写不认</B>",
"<b class=\"x\">带属性的也不认</b>",
"<br2>不认识的自闭合</br2>",
"<br2>名字后面不是空白</br2>",
"<b2>名字后面不是空白</b2>",
"<boolean>以 b 开头的普通单词</boolean>",
)) {
val runs = parseInlineMarkup(text)
assertTrue("「$text」不该被判成行内标签", runs.isEmpty())
@@ -173,6 +334,55 @@ class InlineMarkupTest {
}
}
/**
* 契约第 1 条点名的形状:`<script>` 之类**白名单外**的标签显示成字面量,字符一个不丢——
* 渲染侧是 `AnnotatedString` + `SpanStyle`(结构化),正文从来不经过任何 HTML 解析/注入。
*
* W69 起 **`u` / `s` 也落在这一档**(`回执三十`:白名单收窄到五项):它们不再是样式,
* 写出来就是字面文本。撤掉的正是「会渲染」这件事——Web 端不渲染,我们多渲染就是制造跨端不一致。
*
* ⚠️ 我们的读法(**待与后端确认**):白名单外的标签**不参与结构判定**——它们按纯文本算,
* 所以既不会因为「没闭合」把整段拖成字面(下面第二条),配对与否也不改变别的标签;
* 只有白名单那五个名字才构成契约说的「结构」。
*/
@Test
fun tagsOutsideTheWhitelistAreShownLiterallyWithoutLosingACharacter() {
val raw = "<script>alert(1)</script>"
assertTrue(parseInlineMarkup(raw).isEmpty())
assertEquals(raw, parse("<p>$raw</p>").single().body)
// 白名单外的标签不参与结构判定:旁边那对 `<b>` 照常生效,尖括号原样留着
val mixed = parse("<p>甲<b>粗</b><script>x</script>乙</p>").single()
assertEquals("甲粗<script>x</script>乙", mixed.body)
assertEquals(
listOf(run("甲"), run("粗", InlineStyle.Bold), run("<script>x</script>乙")),
mixed.runs,
)
// 落单的白名单外标签同理:它只是几个字符,不把整段拖成字面
assertEquals(
listOf(run("甲"), run("粗", InlineStyle.Bold), run("<script>乙")),
parseInlineMarkup("甲<b>粗</b><script>乙"),
)
}
/** W69:`<u>` / `<s>` 已按契约收窄撤掉 ⇒ 它们**不再产生任何样式**,只按原文显示。 */
@Test
fun uAndSAreNoLongerInTheWhitelistSoTheyRenderAsPlainText() {
for (raw in listOf("<u>重点</u>", "<s>改口</s>", "<U>重点</U>", "<S>改口</S>", "<u class=x>重点</u>")) {
assertTrue("「$raw」已不在白名单里,不该产出任何样式", parseInlineMarkup(raw).isEmpty())
assertEquals("「$raw」必须逐字原文", raw, parse("<p>$raw</p>").single().body)
}
// 与真样式混在一起时同理:只有白名单那五个起作用,u/s 的字面留在文本里
val bubble = parse("<p><b>粗</b>与<u>下划线</u></p>").single()
assertEquals("粗与<u>下划线</u>", bubble.body)
assertEquals(
listOf(run("粗", InlineStyle.Bold), run("与<u>下划线</u>")),
bubble.runs,
)
assertTrue(InlineStyle.entries.map { it.name }.none { it == "Underline" || it == "Strikethrough" })
}
// ------------------------------------------------------------------
// 边界 6:HTML 实体(不做)
// ------------------------------------------------------------------
@@ -185,42 +395,13 @@ class InlineMarkupTest {
}
}
// ------------------------------------------------------------------
// 边界 4:`<br>` 与既有空行分段的关系
// ------------------------------------------------------------------
/** `<br>` 就是一个换行符,不与相邻的 `\n` 去重——`甲<br>\n乙` 渲染出来就是空一行(markup 直译)。 */
@Test
fun brIsJustANewlineAndIsNotDeduplicated() {
assertEquals(listOf(run("甲\n\n乙")), parseInlineMarkup("甲<br>\n乙"))
assertEquals(listOf(run("甲\n\n乙")), parseInlineMarkup("甲<br><br>乙"))
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲<br>乙"))
}
/** 但它**不分气泡**:分气泡的判据(`<p>`、空行)在行内解析之前就已经算完了。 */
@Test
fun brNeverCreatesANewBubble() {
val bubble = parse("甲<br>\n乙").single()
assertEquals("甲\n\n乙", bubble.body)
assertEquals(listOf(run("甲\n\n乙")), bubble.runs)
}
/** 落在两端的 `<br>` 不留空行:正文两端的空白本来就被去掉了,这里只吃空白。 */
@Test
fun brAtEitherEndLeavesNoBlankLine() {
assertEquals("甲", parse("<p>甲<br></p>").single().body)
assertEquals("甲", parse("<p><br>甲</p>").single().body)
assertEquals(listOf(run("甲")), parseInlineMarkup("甲<br>"))
assertEquals(listOf(run("甲")), parseInlineMarkup("<br>甲"))
}
// ------------------------------------------------------------------
// 契约第 3 条:绝不把一段非空正文渲染成空
// ------------------------------------------------------------------
@Test
fun aBodyMadeOfTagsOnlyFallsBackToItsLiteralText() {
for (raw in listOf("<b></b>", "<br>", "<b> </b>", "<i></i><b></b>")) {
for (raw in listOf("<b></b>", "<b> </b>", "<i></i><b></b>", "<code></code>")) {
assertTrue("「$raw」没有可渲染的字,必须整段回落字面", parseInlineMarkup(raw).isEmpty())
assertEquals(raw, parse("<p>$raw</p>").single().body)
}
@@ -307,7 +488,7 @@ class InlineMarkupTest {
// ------------------------------------------------------------------
// 反证:星号语义一个字都没被动过
//
// 行内解析发生在**段落/旁白解析之后**(`toBubble` 里对最终正文做一次),而星号那一套
// 行内解析发生在**段落/分隔符解析之后**(`toBubble` 里对最终正文做一次),而星号那一套
// 在 `appendAnnotations` 里就已经定稿并入 `parts` 了。所以「星号不产生斜体/粗体」这条
// 语义在结构上不可能被行内解析改了——下面这几条既是反证,也是它与它相邻的样子。
// ------------------------------------------------------------------
@@ -356,7 +537,7 @@ class InlineMarkupTest {
*/
@Test
fun streamingWithholdsUnclosedInlineTags() {
val text = "日落 <b>18:35</b>,比上海晚了整整一小时呢。<br>记住了。"
val text = "日落 <b>18:35</b>,比上海晚了整整一小时呢。记住了。"
val parser = StreamingContentParser()
var shown = emptyList<String>()
for (ch in text) {
@@ -365,7 +546,7 @@ class InlineMarkupTest {
// 只看**渲染文本**(`body`):`parts` 是逐字原文,配成对的标签本来就该留在里面
// (编辑框/搜索读的就是它),所以那一边不能用「有没有尖括号」来判。
for (bubble in bubbles) {
assertFalse("标签漏上了屏:${bubble.body}", bubble.body.contains("<b") || bubble.body.contains("<br"))
assertFalse("标签漏上了屏:${bubble.body}", bubble.body.contains("<b"))
}
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
shown.forEachIndexed { i, body ->
@@ -374,16 +555,50 @@ class InlineMarkupTest {
shown = bodies
}
assertEquals(parse(text), parser.finish())
assertEquals("日落 18:35,比上海晚了整整一小时呢。\n记住了。", parser.finish().single().body)
assertEquals("日落 18:35,比上海晚了整整一小时呢。记住了。", parser.finish().single().body)
}
/** 逐字符 / 任意切分的流式结果必须与一次性解析完全一致(含行内标签)。 */
/**
* W68 按契约 v0.89 新增(这条是那一轮最要紧的护栏):**大小写混用与带属性**的标签,
* 流式中间态同样一个字符都不许漏上屏。
*
* 消费侧与扣留侧的词表一旦分叉(W67 只有消费侧认小写、扣留侧只扫 `a..z`),`<B` / `<b class`
* 这种残缺形态就会在中间态被当正文吐上屏、等 `>` 到了再被吃掉——屏幕上显示过的字当场改口。
* 所以这条同时钉住三件事:中间态不吐字面标签、已显示的正文只增不改、收口逐字等于一次性解析。
*/
@Test
fun streamingWithholdsMixedCaseAndAttributeTagsToo() {
val text = "日落 <B class=\"x\">18:35</B> 记住了。"
val parser = StreamingContentParser()
var shown = emptyList<String>()
for (ch in text) {
val bubbles = parser.onDelta(ch.toString())
val bodies = bubbles.map { it.body }
for (bubble in bubbles) {
// 这条样本里渲染文本不该出现任何一个尖括号/属性字:全是标签字符或属性
assertFalse("标签或属性漏上了屏:${bubble.body}", bubble.body.contains('<') || bubble.body.contains('>'))
}
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
shown.forEachIndexed { i, body ->
assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body))
}
shown = bodies
}
assertEquals(parse(text), parser.finish())
assertEquals("日落 18:35 记住了。", parser.finish().single().body)
}
/** 逐字符 / 任意切分的流式结果必须与一次性解析完全一致(含行内标签与 `<br>` 分隔符)。 */
@Test
fun streamingAnyChunkingMatchesOneShot() {
val sample = """
<p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>
<p>记住 <code>abc</code>、<i>斜体</i> 与 <strong>加粗</strong> 哦<br>还有下一行</p>
<p>这是*强调*文字*弯起眼睛*</p>
<p>记住 <CODE>abc</CODE>、<i>斜体</i> 与 <strong class="x">加粗</strong> 哦</p>
<p>这是<S>删除线</S>与<U>下划线</U>,*弯起眼睛*</p>
<p>1 < 2 且 <span class="y">白名单外的标签原样</span> 收尾</p>
<p>日落 <B>18:35</B></p>
<p>上一行<br>下一行</p>
<p>甲<br/>乙</br>丙</p>
""".trimIndent()
val expected = parse(sample)
@@ -434,11 +649,14 @@ class InlineMarkupTest {
* ⚠️ 与 `ChatContentParserTest` 那条复杂度回归同一性质:阈值只用来区分「线性」与「平方」,
* 不是耗时指标。正文里成串的 `<` 若用 `indexOf('>', lt)` 逐个找配对(找不到就一路扫到串尾),
* 这里就会退回 O(N²)——W63 刚在同一片区域修掉两处同形状的问题。
*
* W68 注意:因为契约要求「属性一律不解析」,标签长度不再有上界,`inlineTagEnd` 那扇定宽窗
* 换成了「扫到 `>` 或下一个 `<` 为止」。这条回归因此更要紧——它钉住换完之后仍是线性的。
*/
@Test
fun aHugeRunOfAngleBracketsStaysWithinALinearBudget() {
val count = 20_000
for (body in listOf("<".repeat(count), "a<b".repeat(count / 3))) {
for (body in listOf("<".repeat(count), "a<b".repeat(count / 3), "<b class=x".repeat(count / 9))) {
val startedAt = System.nanoTime()
val bubble = parse("<p>$body</p>").single()
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000