feat(W68): 行内标签按契约 v0.89 对齐 —— 大小写不敏感、属性一律丢弃、结构不平衡整段降级、补 u/s 与 </br>

后端 2026-10-07 傍晚把行内标签升成契约 v0.89(推库 `2dae469`),本端四处按它对齐。

1. 标签名大小写不敏感(`<B>` / `<STRONG>` / `<Br>` 都认)
   消费侧与流式**扣留**侧一起改,两边同源:`parseInlineTag` 与
   `couldBeInlineTagNamePrefix` / `isTagNameLetter` 收在 `InlineMarkup.kt` 一份,
   `isPotentialTagPrefix` 引用它。原来扣留侧只扫 `a..z`,若只改消费侧,`<B` / `<b class`
   这种残缺形态就会在中间态被当正文吐上屏、等 `>` 到了再吃掉——屏幕上显示过的字当场改口。
   段落/旁白标签(`<p>` / `<action>` / `<narration>`)仍只认小写,既有契约逐字未改。

2. 补 `u` / `s`:`InlineStyle` 加 Underline / Strikethrough;`MessageBubble.inlineBody`
   落到 `TextDecoration.Underline` / `LineThrough`(两者叠加时给两条线,不二选一)。

3. 属性一律不解析:白名单标签只取标签名,`<b class="x" onclick="y">` 就是粗体,属性整段丢掉。
   形状判据:名字后必须是 `>`、空白或 `/`,所以 `<boolean>` / `<br2>` 仍是普通文本。
   `inlineTagEnd` 那扇定宽窗换成「扫到 `>` 或下一个 `<` 为止」(属性没有长度上界),仍是线性。

4. 结构不平衡 ⇒ **该气泡正文整段降级为原文**(未闭合 / 落单闭标签 / 交错;`<br>` 也一起放弃)。
   取代 W67 的局部降级。粒度 = 该气泡的 `body`(不是整条消息、也不是单个 `<p>` 块),
   理由写进 `parseInlineMarkup` 的 KDoc,由 `theFallbackCoversOnlyTheBubbleThatIsBroken` 钉住。
   白名单外的尖括号(`<script>` 等)不参与结构判定,按契约第 1 条原样显示、字符一个不丢。

另:`</br>` 与 `<br>` / `<br/>` 等价(HTML5);`<code>` 内的内容仍不解析、也不算结构,
与契约第 3 条方向一致但不是同一条规则(原因写在 KDoc 里)。
渲染仍是 `AnnotatedString` + `SpanStyle`,不引入任何 HTML 注入路径、不新增依赖。
`parts`(编辑/搜索读的逐字原文)、`runs` 为空时 UI 走原路、`tokenize` / `stripLooseTags` /
`ContentAfterQuery` / `P_BLOCK_RE` / `ROLE_RE` / 星号语义 全部未动。

验收:`assembleDebug` 通过;`testDebugUnitTest` 全量 126 suites / 1553 tests / 0 failures / 0 errors。
`InlineMarkupTest` 新增 11 条(大小写、属性、四种不平衡形状整段降级、气泡粒度、白名单外字符、
`u`/`s`、`</br>`、流式大小写+属性不吐标签),与 v0.89 冲突的老断言按新口径改写并在 KDoc 里
写明「口径在 W68 按契约 v0.89 变更、为什么」,不冲突的一条都没放松。
This commit is contained in:
AskaEth committed 2026-10-07 17:25:26 +08:00
1 parent 3fe55e8d0c
commit 4b19278733
5 files changed
+525 -146

No files matched your search

@@ -40,12 +40,16 @@ 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>` / `<br>` …)故意不在**这份容差里:它们是渲染用的标记,但编辑框
* 编辑的是**源码本身**,而 `parts`(这一份的来源)是**逐字原文**——所以 `日落 <b>18:35</b>` 在
* 编辑框里连 `<b>` 一起显示、也一起保存,这里一个字符都不动它。
*/
private val KNOWN_TAG_RE = Regex(
"<p(\\s[^>]*)?>|</p>|<\\s*(action|narration)\\s*>|</\\s*(action|narration)\\s*>",
@@ -19,8 +19,11 @@ import kotlin.math.min
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
* 关键词 旁白/内心 → narration,其余 → action。
* - 行内标签(`InlineMarkup.kt`):`<b>`/`<strong>`、`<i>`/`<em>`、`<code>`、`<br>`/`<br/>` 四类,
* 成对才认、只有小写、未配对或嵌套错位一律字面回落(口径与理由见 `parseInlineMarkup`)。
* - 行内标签(`InlineMarkup.kt`,契约 v0.89):`<b>`/`<strong>`、`<i>`/`<em>`、`<code>`、`<u>`、
* `<s>`、`<br>`(`<br>`/`<br/>`/`</br>` 等价)六类,成对才认、**标签名大小写不敏感**、
* **属性一律不解析**(`<b class=x>` 就是粗体)、白名单外按纯文本原样显示、
* **结构不平衡(未闭合 / 落单闭标签 / 交错)⇒ 该气泡的正文整段降级为原文**
* (口径与理由见 `parseInlineMarkup`)。
* 它在**段落定稿之后**才做([toBubble] 里对最终正文做一次),所以既不参与、也改不动上面的
* 分段与归属:`<br>` 只换行、**不分气泡**。渲染文本落 `body` + `runs`,而片段 `parts` 仍是
* 逐字原文——编辑框、搜索片段、通知文案读的都是 `parts`,那些地方一个字都不许改。
@@ -40,8 +43,9 @@ import kotlin.math.min
* 也保持字面文本,这是既有契约(`unclosedActionFallsBackToText` / `strayClosingTagStaysText`)。
*
* `ParsedBubble` 结构(每条气泡渲染一个):
* - `body` :气泡正文的**渲染文本**(仅 Text 部分按原顺序拼接后 trim,再消费掉四种行内标签:
* `<br>` 已变成换行、标签字符已去掉),已剔除全部旁白/动作块;整段只有标记没有字时回落字面原文;
* - `body` :气泡正文的**渲染文本**(仅 Text 部分按原顺序拼接后 trim,再消费掉行内标签:
* `<br>` 已变成换行、白名单标签字符已去掉,白名单外与属性照旧逐字留着),已剔除全部旁白/动作块;
* 整段只有标记没有字、或行内标签**结构不平衡**时回落/降级为字面原文;
* - `runs` :`body` 的行内样式切分(非空时拼起来就是 `body`;空列表 = 按字面画);
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
* - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」);
@@ -54,10 +58,12 @@ 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/`)时按词表扣留。
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`、`<B`),仅当它可能继续补全为合法标签时才扣留;
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
* - 落单标签的剥离**不需要**额外的扣留规则,这是「剥标签与扣留共用同一组标签正则」的直接结果:
@@ -94,8 +100,9 @@ data class ParsedBubble(
val head: List<ContentPart>,
val tail: List<ContentPart>,
/**
* `body` 的行内样式切分(`<b>` / `<i>` / `<code>`,见 [parseInlineMarkup]):非空时拼起来
* **就是** [body]。空列表 = 这段正文没有可渲染的行内标签,按字面画——**绝大多数消息都走这一档**。
* `body` 的行内样式切分(`<b>` / `<i>` / `<code>` / `<u>` / `<s>`,见 [parseInlineMarkup]):
* 非空时拼起来**就是** [body]。空列表 = 这段正文按字面画——**绝大多数消息都走这一档**(没有标签、
* 或者结构不平衡被整段降级、或者整段只有标记没有字)。
*
* 有默认值,是因为只有 [toBubble] 才该填它:其余构造点要么本来就该按字面画(`plainBubble`、
* 分享预览的兜底 `sharePreviewBlocks`),要么是测试里的手搓气泡。这也让「加一个字段」不必
@@ -133,16 +140,10 @@ private val BLANK_LINE_RE = Regex("\\n\\s*\\n")
private const val FULL_STAR_MAX_LEN = 40
/**
* 已知标签词表(只有小写,与 `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] 真正消费用的那组)。
* 任缺一半,中间态就会把标签当正文吐上屏、收口时才吃掉——屏幕上已经显示过的字就改口了。
* 段落/旁白标签名(**只有小写**,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
* 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面那组同词表的正则,两边必须同源。
*/
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[^>]*)?>$")
@@ -507,8 +508,8 @@ private fun toBubble(tokens: List<Tok>): ParsedBubble? {
}
}
// 行内标签在**段落定稿之后**才消费(就在这一行):上面那套分段与归属一个字都不动,分段判据
// 因此完全看不见行内标签(`<br>` 造不出新气泡)。runs 为空 = 这段正文没有可渲染的行内标签,
// 正文按字面画(绝大多数消息都走这一档),一个字都不会变。
// 因此完全看不见行内标签(`<br>` 造不出新气泡)。runs 为空 = 这段正文按字面画(没有可渲染的
// 行内标签、或行内标签结构不平衡被整段降级),一个字都不会变。
val rawBody = body.toString().trim()
val runs = parseInlineMarkup(rawBody)
val bodyText = if (runs.isEmpty()) rawBody else runs.joinToString("") { it.text }
@@ -565,8 +566,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,18 +578,33 @@ private fun closeTagName(tag: String): String? {
return closeInlineTagName(tag)
}
/** 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。 */
/**
* 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。
*
* 词表分两半,每一半都与**自己的消费侧**同源,两条口径都在这里逐字对齐:
* - 段落/旁白只认小写(`<P>` / `<Action>` 原样显示,既有契约)——名字里出现大写就与它无关;
* - 行内标签**大小写不敏感**(契约 v0.89,[parseInlineTag])——`<B` 与 `<b` 一样要扣留。
* 少扣了就是「中间态把标签当正文吐上屏、收口时才吃掉」,屏幕上显示过的字会改口。
* 名字之后是空白或 `/` 都算「还可能补全」(属性 / 自闭合,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])。 */
private fun couldBeTagName(name: String): Boolean {
if (name.all { it in 'a'..'z' } && STRUCTURE_TAG_NAMES.any { it.startsWith(name) }) return true
return couldBeInlineTagNamePrefix(name.lowercase())
}
/** 自由文本区内孤立的全角 `*`(计数为奇)从最后一个起扣留。 */
@@ -3,23 +3,42 @@ 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` · `u` · `s`(成对)· `br`(void,
* `<br>` / `<br/>` / `</br>` 按 HTML5 口径等价);标签名**大小写不敏感**;
* 2. **属性一律不解析**:`<b class=x onclick=y>` 只取标签名,属性整段丢掉、绝不落成任何视图属性;
* 3. **白名单外一律按纯文本原样显示**:`<script>` 显示成字面量,字符一个不丢;
* 4. **结构不平衡 ⇒ 整段降级为原文**(未闭合 / 落单闭标签 / 交错)——粗粒度是有意的,换来
* 「字符一个不丢」可以被逐字断言。
* 样式映射:`b`/`strong` → 粗体,`i`/`em` → 斜体,`u` → 下划线,`s` → 删除线,
* `code` → 等宽 + 轻微底色(且**里面的标签不再解析**),`br`/`<br/>`/`</br>` → 一个换行符。
*
* ## 为什么只有小写
* `parse` 只认小写的 `<p>` / `<action>` / `<narration>`(`MessageEditText` 同一口径,既有测试
* `plainAngleBracketsAreNeverEaten` 钉着 `<P>大小写不认</P>` 原样显示)。行内标签若认大小写,
* 流式的扣留侧也得跟着认,而那边的标签名扫描只认 `a..z`(`isPotentialTagPrefix`):两边一分叉,
* 中间态就会把 `<B>` 当正文吐上屏、等 `</B>` 到了再吃掉——那正是「字会跳」。所以大小写口径与
* `<p>` 那套**逐字对齐**。证据也只有小写:真后端最近 100 条里命中的 2 条都是 `<b>`。
* ## 为什么是「整段降级」而不是「只降级那一处」
* W67 那版是**局部**降级:`甲<b>粗</b>乙<b>没闭合` 里 `粗` 照旧加粗,只有后面那半个 `<b>` 留着字面。
* 契约 v0.89 把它换成了**整段**降级:一处不平衡,这一整段就一个标签都不认、原文逐字显示。
* 代价是「好好的一对 `<b>` 会被邻居的落单标签连累」,换来的是**可以逐字断言**的粗粒度:
* 「渲染文本 == 原文」或者「渲染文本 == 原文消费掉全部配对标签」,只有这两种形状,没有中间态
* (局部降级那种「一半认、一半留」在跨端对齐时最容易各说各话)。见 [parseInlineMarkup]。
*
* ## 为什么侵入只在 `ParsedBubble` 上加一个字段
* ## 「整段」是哪一段(我们采用的口径 + 理由)
* **整段 = 该气泡的正文**,也就是 [toBubble](ChatContentParser.kt) 里 `body.toString().trim()`
* 出来的那一段(`ParsedBubble.body` 对应的文本,通常正好是一个 `<p>…</p>` 块的正文,也可能是
* 块外文本并入相邻气泡后的那一份)。不是整条消息,也不是单个 `<p>` 块:
* - **不是整条消息**:一条消息可以有多个 `<p>` 块(= 多条气泡),一个气泡里写坏了一个标签,
* 不该把**别的**气泡一起按字面画出来——那既不是契约想说的「这一段」,也会让「她写坏了一个标签」
* 这种局部事故看起来像整条消息都坏了。
* - **不是单个 `<p>` 块**(虽然大多数时候两者重合):本端渲染的**最小单位就是气泡**,
* `body` / `runs` 也都是按气泡算的。降级粒度与渲染粒度取同一个,实现上才有一条清晰的边界
* (`toBubble` 一次调用 = 一次判定),断言也才写得出「这条气泡是原文、那条气泡照常渲染」。
* - 若后端回的粒度是别的(例如整个 `<p>` 块或整条消息),改的只是 [parseInlineMarkup] 的
* 调用点(把判定提到 `tokenize` 之前),解析口径本身一个字都不用动。
*
* ## 为什么不改 `parts`,只在 `ParsedBubble` 上加一个字段
* `ContentPart.Text` 的 `text` 必须保持**逐字原文**:编辑框(`MessageEditText.visibleTextOf`)、
* 搜索片段(`SessionSearchModel.plainSearchText`)、通知文案(`ProactiveNotify`)读的都是 `parts`,
* 在那里把标签改掉就是**静默改用户内容**。所以行内解析不改片段,只在 `toBubble` 里对**已经定稿的
* 正文**算一次,结果落在 `ParsedBubble.runs`;`body` 变成正文的**渲染文本**(四个标签已消费),
* 正文**算一次,结果落在 `ParsedBubble.runs`;`body` 变成正文的**渲染文本**(白名单标签已消费),
* 而 `parts` 仍是原文——两侧各拿自己该拿的那一份。
*
* ## 与段落解析的分工(为什么不在 `tokenize` 里做)
@@ -28,21 +47,32 @@ package top.yeij.cyrene.domain.parse
* 造出新的气泡边界——分气泡这件事只认 `<p>` 与空行。这也让 W63 在 `tokenize` 里去掉的两处 O(N²)
* 一行都不受影响:这里是对最终正文的一趟线性扫描。
*
* ## 大小写不敏感(v0.89)与「扣留侧必须一起改」
* `<B>` / `<STRONG>` / `<Br>` 都认——但**消费侧与流式扣留侧([ChatContentParser.stablePrefixEnd])
* 必须同源**,否则两边分叉:`<B` 这种残缺形态若只在消费侧认,中间态就会把它当正文吐上屏、等 `</B>`
* 到了再吃掉,**屏幕上已经显示过的字就改口了**。所以标签名的字母判据([isTagNameLetter])与
* 「这个名字还可能补全吗」([ChatContentParser.isPotentialTagPrefix] 里的
* [couldBeInlineTagNamePrefix])都收在**这一份文件**里,两边引用同一份名单。
* 段落/旁白标签(`<p>` / `<action>` / `<narration>`)仍然**只认小写**:这是既有契约
* (`plainAngleBracketsAreNeverEaten` 钉着 `<P>` 原样、`MessageEditText` 同一口径),
* 且契约 v0.89 明确「`<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,11 +80,17 @@ package top.yeij.cyrene.domain.parse
/**
* 一段行内样式,可叠加(`<b><i>粗斜</i></b>` 同时是粗与斜)。
*
* 只有这三种:换行不是样式,它是**字符**(`<br>` → `\n`),落在片段的文本里。
* 只有这五种:换行不是样式,它是**字符**(`<br>` → `\n`),落在片段的文本里。
*/
enum class InlineStyle {
Bold,
Italic,
/** `<u>`:下划线(渲染成 `TextDecoration.Underline`)。 */
Underline,
/** `<s>`:删除线(渲染成 `TextDecoration.LineThrough`)。 */
Strikethrough,
Code,
}
@@ -70,84 +106,153 @@ data class InlineRun(val text: String, val styles: Set<InlineStyle>)
/** `<code>` 的标签名(解析里要单独判它:里面的标签不再解析)。 */
private const val CODE = "code"
/** 成对标签的名字(`strong` / `em` 是 `b` / `i` 的同义写法)。只写这一处,下面的正则由它生成。 */
private val PAIRED_TAG_NAMES = listOf("b", "strong", "i", "em", CODE)
/** 自闭合的换行标签名(不成对,所以它不在 [PAIRED_TAG_NAMES] 里)。 */
private const val BR = "br"
/**
* 成对标签的名字。`strong` / `em` 是 `b` / `i` 的同义写法,`u` / `s` 是 v0.89 新增的两档。
* 只写这一处,[parseInlineTag] 与 [couldBeInlineTagNamePrefix] 都由它推出来。
*/
private val PAIRED_TAG_NAMES = listOf("b", "strong", "i", "em", CODE, "u", "s")
/**
* 行内标签词表。**流式扣留侧(`ChatContentParser.stablePrefixEnd`)与这里必须同源**:
* 同一组正则既判「这是个行内标签」(消费侧),也判「这个标签名会不会继续补全」(扣留侧)。
* 同一份名单既判「这是个行内标签」(消费侧),也判「这个标签名会不会继续补全」(扣留侧)。
* 两边一分叉,中间态就会把标签当正文吐上屏、收口时才吃掉——屏幕上已经显示过的那几个字就改口了。
*/
internal val INLINE_TAG_NAMES = PAIRED_TAG_NAMES + BR
/** 认成对开标签的**完整**形状:`<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
/** 标签名 → 样式;`strong` / `em` 落到与 `b` / `i` 同一个样式上。 */
internal fun inlineStyleOf(name: String): InlineStyle = when (name) {
"i", "em" -> InlineStyle.Italic
CODE -> InlineStyle.Code
else -> InlineStyle.Bold
/** void 换行:`<br>` / `<br/>` / `</br>` 三者等价(HTML5 口径),不成对、不入栈。 */
object Break : InlineTag {
override val name: String = BR
}
}
/** 标签名里允许出现的字母: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 条,只取标签名。
* 丢掉的只是白名单标签自己那对尖括号里的东西,正文一个字符都不动。
*/
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 == BR) return InlineTag.Break
if (name !in PAIRED_TAG_NAMES) return null
return if (closing) InlineTag.Close(name) else InlineTag.Open(name)
}
/** 开标签名(不是行内开标签则 null);`<br>` 是自闭合的,**不入栈**,所以它不在这里。 */
internal fun openInlineTagName(tag: String): String? =
OPEN_INLINE_TAG_RE.find(tag)?.groupValues?.get(1)
(parseInlineTag(tag) as? InlineTag.Open)?.name
/** 闭标签名(不是行内闭标签则 null)。 */
internal fun closeInlineTagName(tag: String): String? =
CLOSE_INLINE_TAG_RE.find(tag)?.groupValues?.get(1)
(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) {
"i", "em" -> InlineStyle.Italic
"u" -> InlineStyle.Underline
"s" -> InlineStyle.Strikethrough
CODE -> InlineStyle.Code
else -> InlineStyle.Bold
}
/**
* 把一段**已经定稿的正文文本**解析成行内片段(纯函数)。
*
* 三条契约(都由单测钉着):
* 1. **返回空列表 = 这段文本没有任何配成对的行内标签**,调用方按**字面**渲染(正文一个字符都不动)。
* 契约(都由单测钉着):
* 1. **返回空列表 = 这段文本按字面渲染**(正文一个字符都不动)。三种情形都落在这里:
* 没有可渲染的行内标签、**结构不平衡**(契约第 4 条,整段降级)、以及整段只有标记没有字。
* 这是绝大多数消息的路径(实测 100 条里只有 2 条带 `<b>`),也是 `plainBubble` 那条路。
* 2. 返回非空 ⇒ 片段拼起来**就是**渲染文本:`<br>` 已经变成 `\n`、标签字符已经被消费掉,且两端
* 空白已去(见下)。
* 2. 返回非空 ⇒ 片段拼起来**就是**渲染文本:白名单标签已被消费(`<br>` 变成 `\n`),且两端空白
* 已去(见下)。**除此之外逐字等于原文**——白名单外的标签(`<script>`)、属性、`<code>` 里的
* 内容全都原样在片段里。
* 3. **绝不把一段非空正文渲染成空**:整段只有标记没有字(`<b></b>` / `<br>`)时回落成第 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>没闭合` 里的 `粗` 也不再加粗(粗粒度),
* 换来的是渲染文本与原文之间只有两种关系(相等 / 消费掉全部配对标签),跨端可以逐字对齐。
* - **`<br>` 也一起被放弃**:整段降级是「原文逐字显示」,所以 `甲<b>没闭合<br>乙` 里的 `<br>`
* 不换行、原样显示。这是契约措辞的直译,不额外发明「换行照旧生效」的例外。
* - 判据只有这一条,不发明「宽容关闭」(`</b>` 顺手把里面没关的 `<i>` 也关掉)、也不按行/按句
* 局部生效:那种写法每端都能自圆其说,合起来就是跨端不一致。
*
* ## 为什么两端空白要去掉
* `<br>` 落在开头/结尾会凭空多出一行空白(Compose 里末尾换行就是一行空行),而正文两端的空白本来
* 就被去掉了(`toBubble` 里那句 `body.trim()`)。这一步只吃空白——一个可见字符都不会掉。
* ## 口径二:`<code>` 里不再解析(与契约第 3 条一致,不是例外)
* `<code><b>甲</b></code>` 显示字面的 `<b>甲</b>`(等宽 + 底色),`<code>1 < 2 && a<b</code>`
* 原样显示。它与契约第 3 条(白名单外按纯文本显示)**不冲突、方向一致**,但**不是同一条规则**:
* - 第 3 条管的是**白名单外的标签名**(`<script>`、`<div>`)——它们从来不是「标签」;
* - `<code>` 是一条**作用域**规则:在白名单标签 `<code>` 内部,连白名单标签也按内容处理。
* `code` 表达的是「这段是代码」,代码里的 `<b>` 就是四个字符——这是 W67 就定下的口径,
* 契约 v0.89 没有就「`<code>` 内部」单独措辞,我们**保留原行为**(换 HTML 那种「元素照常
* 嵌套解析」的语义,改动只是删掉 [claimTags] 里的跳过分支,等后端确权再动);
* - 两者的结果都落在同一条硬口径上:**字符一个不丢**。`<code>` 也正好是「想原样显示尖括号
* 怎么办」的现成答案。
* 内部的东西**不参与结构判定**:`<code><b>甲</code>` 里的 `<b>` 不算未闭合(它只是内容),
* 所以不会因为它把整段拖成字面——判定与显示用的都是同一套「里面不算标签」的口径。
* 代价是代码里的 `<b>` 不生效(实测 0 命中,也不存在「代码里也要粗体」这个需求)。
*
* ## `<br>` 与既有空行分段的关系
* ## 口径三:`<br>` 与既有空行分段的关系
* `<br>` 就是**一个换行符**,不与相邻的 `\n` 去重:`甲<br><br>乙` 是「甲、空一行、乙」,
* `甲<br>\n乙` 也是空一行——这是 markup 的直译(去掉哪一个都算我们替她改话)。它**不分气泡**:
* 分气泡的判据(`<p>`、空行 `\n\s*\n`)在行内解析**之前**就已经算完了,所以这里造的换行不会
* 变成新的气泡边界,也不会与空行分段叠加出「双倍」的东西。
*
* 复杂度 O(n):两趟线性扫描(每个 `<` 只看一扇固定宽度的窗,绝不 `indexOf('>')` 一路找配对);
* 正文里没有 `<` 时连集合都不建,一次就返回。
* ## 为什么两端空白要去掉
* `<br>` 落在开头/结尾会凭空多出一行空白(Compose 里末尾换行就是一行空行),而正文两端的空白本来
* 就被去掉了(`toBubble` 里那句 `body.trim()`)。这一步只吃空白——一个可见字符都不会掉。
* 注意降级路径给到调用方的是**已经 trim 过的原文**(trim 在行内解析之前,不是这里吃的)。
*
* 复杂度 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 条。
return runsOf(text, claimed).trimEdges().takeIf { it.isNotEmpty() } ?: emptyList()
@@ -164,15 +269,19 @@ private class ClaimedTags {
}
/**
* 第一趟:扫出**配成对且正确嵌套**的那些标签(起始下标落在 [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 +296,48 @@ 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>` 是四个字符、`<br>` 是四个字符,也都不算结构
// (所以里面那个「未闭合的 `<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)
InlineTag.Break -> claimed.breakStarts.add(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]
* 宽的窗就够;窗里再冒出一个 `<`,也说明这一处起头的不是标签——扫描因此是线性的。
* 一路走到 `>` 或**下一个 `<`** 为止:契约 v0.89 要求「属性一律不解析」,标签多长因此不再有上界
* (`<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
@@ -236,7 +349,8 @@ 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 +380,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
@@ -33,6 +33,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
@@ -789,13 +790,16 @@ private fun BubbleBody(
private const val INLINE_CODE_ON_USER_BACKGROUND = 0.18f
/**
* 行内片段 → 一段 `AnnotatedString`:一个 `Text` 就同时承载粗体 / 斜体 / 等宽底色 / 换行。
* 行内片段 → 一段 `AnnotatedString`:一个 `Text` 就同时承载粗体 / 斜体 / 下划线 / 删除线 / 等宽底色 / 换行。
*
* **为什么不拼一串 `Text` 节点**:换行与折行得由同一个排版器决定,手拼 `Row` 会让折行位置跟她原话里
* 那个换行打架(`<br>` 造出来的换行必须真的换行,而不是把后半段挪到另一个节点里);而且一个 `Text`
* 天然是一整段可选中的文本([SelectionContainer] 的行为一个字都不用改)。
*
* 样式只给**有**的那几项(`null` = 继承气泡自己的字重/字体),所以非 `<code>` 的片段与原来逐像素相同。
* 样式只给**有**的那几项(`null` = 继承气泡自己的字重/字体/装饰),所以非 `<code>` 的片段与原来逐像素相同。
*
* `u` / `s` 落到 `textDecoration`:两者叠起来(`<u><s>甲</s></u>`)时按 `TextDecoration` 自己的
* 合并写法给出下划线 + 删除线两条线,而不是二选一——那是标记的直译。
*/
private fun inlineBody(
runs: List<InlineRun>,
@@ -804,10 +808,18 @@ private fun inlineBody(
): AnnotatedString = buildAnnotatedString {
for (run in runs) {
val code = InlineStyle.Code in run.styles
val underline = InlineStyle.Underline in run.styles
val strikeThrough = InlineStyle.Strikethrough in run.styles
withStyle(
SpanStyle(
fontWeight = if (InlineStyle.Bold in run.styles) FontWeight.Bold else null,
fontStyle = if (InlineStyle.Italic in run.styles) FontStyle.Italic else null,
textDecoration = when {
underline && strikeThrough -> TextDecoration.Underline + TextDecoration.LineThrough
underline -> TextDecoration.Underline
strikeThrough -> TextDecoration.LineThrough
else -> null
},
fontFamily = if (code) FontFamily.Monospace else null,
background = if (code) codeBackground else Color.Unspecified,
),
@@ -10,16 +10,33 @@ import top.yeij.cyrene.ui.chat.sharePreviewBlocks
import kotlin.random.Random
/**
* 行内标签(`InlineMarkup.kt`)单测:四类标签各自渲染成什么、六条边界口径、以及
* 行内标签(`InlineMarkup.kt`)单测:六类标签各自渲染成什么、边界口径、以及
* 「星号语义一个字都没被动过」的反证。
*
* 断言的是**纯函数的结果**(片段文本 + 样式),不碰 Compose:`runs` 拼起来就是渲染文本,
* 所以「哪段字是什么样式」在这里是直接可断言的。
*
* ## W68:口径按后端契约 v0.89 变更(三处,各自与原口径冲突所以必须改)
* 1. **标签名大小写不敏感**(`<B>` / `<STRONG>` / `<Br>` 都认)。原口径「只认小写」是从
* `<p>` / `<action>` 那套推出来的(`<P>` 原样显示有既有测试钉着),但 v0.89 明确要求行内标签
* 大小写不敏感——段落/旁白那边一个字都没变。⚠️ 消费侧改了,流式**扣留**侧必须一起改
* (`isPotentialTagPrefix` 的词表扫描原来只认 `a..z`):否则 `<B` 这种残缺形态在中间态会被当
* 正文吐上屏、等 `</B>` 到了再吃掉,屏幕上的字就改口了。两边的词表与判据现在同源。
* 2. **属性一律不解析**:`<b class="x" onclick="y">` 就是粗体,属性整段丢掉。原口径是
* 「带属性的原样保留」,与 v0.89 冲突(契约原话:属性不得变成任何视图属性)。
* 3. **结构不平衡 ⇒ 整段降级为原文**(未闭合 / 落单闭标签 / 交错)。原口径是**局部**降级
* (`甲<b>粗</b>乙<b>没闭合` 里 `粗` 照旧加粗),与 v0.89 冲突。粗粒度是契约有意的选择:
* 换来「渲染文本 == 原文」或「渲染文本 == 原文消费掉全部配对标签」这两种可逐字断言的形状。
* 粒度 = **该气泡的正文**(`ParsedBubble.body` 对应的那段文本),理由写在 `parseInlineMarkup`
* 的 KDoc 里,也由 [theFallbackCoversOnlyTheBubbleThatIsBroken] 钉着。
* 4. 顺带补齐:`u` / `s` 两档样式、`</br>` 与 `<br>` / `<br/>` 等价(HTML5 口径)。
* 凡是不与 v0.89 冲突的老断言(绝不丢字、`parts` 逐字原文、星号语义、流式一致性、复杂度)
* 一条都没放松。
*/
class InlineMarkupTest {
// ------------------------------------------------------------------
// 四类标签各自渲染成什么
// 六类标签各自渲染成什么
// ------------------------------------------------------------------
@Test
@@ -38,16 +55,26 @@ class InlineMarkupTest {
assertEquals(listOf(run("斜", InlineStyle.Italic)), parseInlineMarkup("<em>斜</em>"))
}
/** W68 新增两档:`<u>` 下划线、`<s>` 删除线(渲染侧给 `TextDecoration`,见 `MessageBubble.inlineBody`)。 */
@Test
fun uAndSBecomeUnderlineAndStrikeThroughRuns() {
assertEquals(listOf(run("重点", InlineStyle.Underline)), parseInlineMarkup("<u>重点</u>"))
assertEquals(listOf(run("改口", InlineStyle.Strikethrough)), parseInlineMarkup("<s>改口</s>"))
}
@Test
fun codeTagBecomesACodeRun() {
assertEquals(listOf(run("abc", InlineStyle.Code)), parseInlineMarkup("<code>abc</code>"))
}
/** `<br>` / `<br/>` / `</br>` 三者按 HTML5 口径等价(W68 补上 `</br>`)。 */
@Test
fun brTagsBecomeANewline() {
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲<br>乙"))
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲<br/>乙"))
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲<br />乙"))
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲</br>乙"))
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲</br />乙"))
}
@Test
@@ -81,6 +108,77 @@ class InlineMarkupTest {
assertTrue(parseInlineMarkup("多行\n文本\n也是普通的").isEmpty())
}
// ------------------------------------------------------------------
// 契约 v0.89 之一:标签名大小写不敏感
// ------------------------------------------------------------------
/**
* W68 按契约 v0.89 新增(原口径「只认小写」与它冲突,见类 KDoc 第 1 条):
* 六类标签的大小写任意写法都认,且改不成别的样式——`<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.Underline)), parseInlineMarkup("<U>线</U>"))
assertEquals(listOf(run("删", InlineStyle.Strikethrough)), parseInlineMarkup("<S>删</S>"))
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲<BR>乙"))
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲<Br/>乙"))
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲</BR>乙"))
// 大小写混着嵌套也照常叠加
assertEquals(
listOf(run("粗斜", InlineStyle.Bold, InlineStyle.Italic)),
parseInlineMarkup("<B><i>粗斜</i></B>"),
)
}
/**
* 大写**只对行内标签**成立:段落/旁白标签仍只认小写(既有契约 `<P>大小写不认</P>`、
* `MessageEditText` 同一口径;契约 v0.89 也写明「`<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 第 2 条):
* 白名单标签带任何属性都按标签名处理,属性**整段丢掉**,绝不落进正文、更不落成任何视图属性。
*/
@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.Underline)), parseInlineMarkup("<U style=\"color:red\">线</U>"))
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲<br class=\"x\">乙"))
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲<br style=x />乙"))
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲</br class=\"x\">乙"))
// 属性里那串字一个都不许留在渲染文本里(`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 里的内容不再解析
// ------------------------------------------------------------------
@@ -106,17 +204,30 @@ class InlineMarkupTest {
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> 尾"))
}
// ------------------------------------------------------------------
// 边界 1:未配对的标签(一条口径:配成对 + 正确嵌套,否则字面)
// 契约 v0.89 之三:结构不平衡 ⇒ 整段降级为原文
// (W68 的粒度是「该气泡的正文」;W67 的局部降级口径与它冲突,已按契约改掉)
// ------------------------------------------------------------------
/**
* W68 按契约 v0.89 改(原口径见下面各自的老断言):**未闭合**的开标签 ⇒ 整段原文。
* 返回空列表 = 调用方按字面渲染,正文一个字符都不动。
*/
@Test
fun unpairedTagsStayLiteral() {
// 没有闭合 ⇒ 整段字面(列表为空 = 按字面渲染,正文一个字符都不动)
@@ -125,33 +236,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())
assertTrue(parseInlineMarkup("甲<s>删").isEmpty())
}
/** 配对的不受影响:半个标签字面留着,配成对的那半个照常生效。 */
/**
* W68 按契约 v0.89 改:原断言是「配成对的另一半照常生效,只有落单那半个留着字面」
* (`<b>甲<i>乙</i>` ⇒ `<b>甲` 字面 + `乙` 斜体)。契约把它换成了**整段降级**:
* 一处不平衡,这一整段一个标签都不认——粗粒度换来「可逐字断言」,理由见类 KDoc 第 3 条。
*/
@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 新增:**四种不平衡形状**各自的整段降级,且降级的是「整段原文」——
* 连 `<br>` 也不换行(契约措辞是原文逐字显示,我们不额外发明「换行照旧生效」的例外)。
*/
@Test
fun everyImbalancedShapeFallsBackToTheOriginalTextCharacterForCharacter() {
for (raw in listOf(
"日落 <b>18:35", // 未闭合
"甲<b>粗</b>乙<b>没闭合", // 一半配好、后一半未闭合
"甲<b>粗</b>乙</b>", // 落单闭标签
"</b>", // 只有闭标签
"<b>甲<i>乙</b>丙</i>", // 交错
"甲<b>粗<br>乙", // 未闭合 + `<br>`:`<br>` 也一起放弃
"<U>线", // 大写 + 未闭合
)) {
assertTrue("「$raw」结构不平衡,必须整段降级", parseInlineMarkup(raw).isEmpty())
assertEquals("「$raw」必须逐字原文", raw, parse("<p>$raw</p>").single().body)
assertTrue(parse("<p>$raw</p>").single().runs.isEmpty())
}
}
/**
* 契约 v0.89 说的「整段」= **该气泡的正文**(粒度口径与理由写在 `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:正文里本来就想显示的尖括号
// 契约 v0.89 之四:白名单外一律按纯文本原样显示(字符一个不丢)
// ------------------------------------------------------------------
@Test
@@ -159,13 +323,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>",
"<b2>名字后面不是空白</b2>",
"<boolean>以 b 开头的普通单词</boolean>",
)) {
val runs = parseInlineMarkup(text)
assertTrue("「$text」不该被判成行内标签", runs.isEmpty())
@@ -173,6 +337,35 @@ class InlineMarkupTest {
}
}
/**
* 契约第 1 条点名的形状:`<script>` 之类**白名单外**的标签显示成字面量,字符一个不丢——
* 渲染侧是 `AnnotatedString` + `SpanStyle`(结构化),正文从来不经过任何 HTML 解析/注入。
*
* ⚠️ 我们的读法(**待与后端确认**):白名单外的标签**不参与结构判定**——它们按纯文本算,
* 所以既不会因为「没闭合」把整段拖成字面(下面第二条),配对与否也不改变别的标签;
* 只有白名单那六个名字才构成契约第 4 条说的「结构」。
*/
@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>乙"),
)
}
// ------------------------------------------------------------------
// 边界 6:HTML 实体(不做)
// ------------------------------------------------------------------
@@ -220,7 +413,7 @@ class InlineMarkupTest {
@Test
fun aBodyMadeOfTagsOnlyFallsBackToItsLiteralText() {
for (raw in listOf("<b></b>", "<br>", "<b> </b>", "<i></i><b></b>")) {
for (raw in listOf("<b></b>", "<br>", "<b> </b>", "<i></i><b></b>", "<u></u><s></s>")) {
assertTrue("「$raw」没有可渲染的字,必须整段回落字面", parseInlineMarkup(raw).isEmpty())
assertEquals(raw, parse("<p>$raw</p>").single().body)
}
@@ -377,13 +570,48 @@ class InlineMarkupTest {
assertEquals("日落 18:35,比上海晚了整整一小时呢。\n记住了。", parser.finish().single().body)
}
/**
* W68 按契约 v0.89 新增(这条是本次改动最要紧的护栏):**大小写混用与带属性**的标签,
* 流式中间态同样一个字符都不许漏上屏。
*
* 消费侧与扣留侧的词表一旦分叉(W67 只有消费侧认小写、扣留侧只扫 `a..z`),`<B` / `<b class`
* 这种残缺形态就会在中间态被当正文吐上屏、等 `>` 到了再被吃掉——屏幕上显示过的字当场改口。
* 所以这条同时钉住三件事:中间态不吐字面标签、已显示的正文只增不改、收口逐字等于一次性解析。
*/
@Test
fun streamingWithholdsMixedCaseAndAttributeTagsToo() {
val text = "日落 <B class=\"x\">18:35</B>,<U>下划线</U> 与 <S>删除线</S>,<BR>记住了。"
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,下划线 与 删除线,\n记住了。",
parser.finish().single().body,
)
}
/** 逐字符 / 任意切分的流式结果必须与一次性解析完全一致(含行内标签)。 */
@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> 哦<br>还有下一行</p>
<p>这是<U>下划线</U>与<S>删除线</S>,*弯起眼睛*</p>
<p>1 < 2 且 <span class="y">白名单外的标签原样</span> 收尾</p>
<p>日落 <B>18:35</B> 与 <br/> 换行</p>
""".trimIndent()
val expected = parse(sample)
@@ -434,11 +662,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