feat(W71): 按契约 v0.89 最终版返工 —— u 加回白名单(六项)+ <br> 只在普通文本层分隔
后端 `回执三十三`(v0.89 收口)更正了 W68/W69 的两处实现,本端照改。
一、`u` **加回**白名单(最终**六项**)
`回执三十三 §一` 明说:`回执三十一` 那句「收窄到五项」**是他们的疏漏** —— 主人随后裁定
下划线保留,后端已把 `u` 加回契约(`1107b2a`)与 IM 出站(`74c9f2d`),只是**忘了发回执登记**。
最终白名单 = `b`/`strong`/`i`/`em`/`code`/**`u`**,`s` 仍不在内(逐字原样)。
1. `InlineMarkup`:`PAIRED_TAG_NAMES` 加回 `"u"`、`InlineStyle` 加回 `Underline`、
`inlineStyleOf` 落到它。**只加回 `Underline`**:`s` 依旧不做(枚举里没有 `Strikethrough`,
不留走不到的渲染分支)。加回词表同时让流式扣留侧重新认 `<u` / `<U`(两边同一份判据)。
2. `MessageBubble.inlineBody`:`textDecoration = TextDecoration.Underline`(重新引入
`androidx.compose.ui.text.style.TextDecoration`;仍然只是 `AnnotatedString` + `SpanStyle`)。
3. 渲染仍是「样式只给有的那几项」,非 `<u>` 的片段逐像素未变。
二、`<br>` **不再「始终分隔」**:只有**普通文本层**的 `<br>` 才分隔(`回执三十三 §三②`),
`<code>` 与 `<action>` / `<narration>` **内部**的不分隔。后端给的两条理由我们同样成立:
① `<code>` 是**成对**元素,从中间切开必然两半不平衡 ⇒ 按硬口径「不平衡 ⇒ 不渲染」整段降级为
字面量(用户看到裸标签);② `<action>她<br>点点头</action>` 切开就是「她」「点点头」两条**聊天
消息** = 把注记正文当正文发了出去(信息泄露级)。
1. 新增 `protectedSpans`(保护区扫描)+ `appendRegion` 里跳过落在区间内的 `<br>`:
一趟线性扫描、**两个独立状态** —— `code` 一支用行内层的判据(`inlineTagEnd` +
`openInlineTagName`/`closeInlineTagName`:大小写不敏感、可带属性),角色注记一支用 `ROLE_RE`
的判据(只认小写、不带属性、找**同名**闭合)。两支各算各的,因为那本来就是两个消费侧各自的
尺子(行内层在 `<code>` 里只认自己的闭合;`ROLE_RE` 完全不看 `<code>`)。区间按**起点**升序,
切分处用游标 O(1) 判定 ⇒ 整体仍然线性(新增一条 20k 作用域的复杂度预算用例)。为此把
行内层的 `inlineTagEnd` 与 `CODE` 提为 `internal`:**标签边界与 `code` 这个名字只该有一把尺子**。
2. 作用域里的 `<br>` 算什么(**我们定的口径,写进 KDoc**):**普通字符、逐字原样** —— 不换行、
也不消掉。`<code>` 是它既有口径(里面连白名单标签都是内容,`<code><b>甲</b></code>` 显示字面
`<b>甲</b>`,由 `codeContentIsNotParsed` 钉着);注记那侧则是**强迫**的:旁白那一行画的正是
`parts` 的逐字原文(编辑框/搜索/通知同源,`parts` 是红线),换 `\n` 或删掉就是静默改用户内容。
两个作用域取同一条规矩,免得多出一种读法。「改成换行」因此被否:要么在这里改掉片段文本
(`parts` 当场不再是原文),要么在行内层给 `<br>` 单开一条规则(W68 刚从那里整体删掉)。
3. **未闭合的作用域不算作用域**(里面的 `<br>` 照旧分隔):判据只看「成对」,而消费侧本来就不认
未闭合的那个(行内层整段降级、`ROLE_RE` 配不出旁白);且「未闭合 ⇒ 保护到结尾」会让一个漏掉
`</code>` 的笔误把后面**所有** `<br>` 一并吞掉——一个畸形标签的爆炸半径不该放大到整段。
两种读法都不渲染、都不丢字,差别只在气泡怎么切,我们选与消费侧一致的那种。
4. 其余一律未动:`<br>` 大小写不敏感(两端口径一致,`回执三十三 §三①`)、`<br>` 出现在别的标签
属性里照样分隔(`§三③` 登记不修)、`<p>` 语义、星号(旁白/动作)语义、`stripLooseTags` /
`ContentAfterQuery` / `P_BLOCK_RE` / `ROLE_RE`、W63 的线性特性、`parts` 逐字原文。
测试:`InlineMarkupTest` 41→43 条(`<u>` 六项之一渲染下划线且大小写/属性/叠加/未闭合各断言、
`s` 每种形态都仍逐字字面且枚举里没有 Strikethrough、`<code>` 里那个 `<br>` 是内容);
`ChatContentParserTest` 71→77 条:W69 那条「`<code>`/`<action>` 内也分隔」换成 7 条新口径用例
(code 作用域存活且无裸标签、注记不产生气泡且 `parts` 逐字、只有 code/注记算保护区、嵌套、
未闭合、两支各算各的、流式扣留残缺 `<br`),另加一条保护区扫描的复杂度预算用例;
流式一致性用例的样本补上 `<u>` 与保护区两形状。
其余既有断言(含 W68 那批 `<br>` 用例、复杂度预算)一条都没放松。
验收:`assembleDebug` 通过(app-debug.apk 47 330 538 B);`testDebugUnitTest` 全量
**127 suites / 1589 tests / 0 failures / 0 errors**(基线 1580,+9 条本次新增)。
This commit is contained in:
1 parent
cf00af7b42
commit
9da25b0895
5 files changed
+538
-62
No files matched your search
@@ -18,14 +18,18 @@ import kotlin.math.min
|
||||
* 的气泡/消息分隔符**(契约 v0.89 的更正:`回执三十一`「对消息内的 `<br>` 始终处理为多条消息
|
||||
* 隔断」)。它**不是行内标签**:`<p>甲<br>乙</p>` ⇒ 两条气泡,`甲<br>乙`(无 `<p>`)⇒ 同样两条;
|
||||
* 中间的空气泡照旧丢弃(`<p>甲<br><br>乙</p>` ⇒ `甲`、`乙`)。切分在 [tokenize] 的**段/块这一层**
|
||||
* 完成([appendRegion]),所以行内解析层(`InlineMarkup`)完全看不见它。
|
||||
* 完成([appendRegion]),所以行内解析层(`InlineMarkup`)几乎看不见它。
|
||||
* ⚠️ `回执三十三 §三②` 把「**始终**分隔」收紧了:**只有普通文本层的 `<br>` 才分隔**——
|
||||
* `<code>` / `<action>` / `<narration>` **内部**的不分隔([appendRegion] 的保护区,
|
||||
* 口径与理由写在它和 [protectedSpans] 的 KDoc 里)。
|
||||
* - `<action>…</action>` / `<narration>…</narration>`:旁白/动作,不独立成气泡,依附气泡:
|
||||
* 首部连续块 → 挂气泡上方(head);尾部连续块 → 挂下方(tail);夹在正文中间的块 → 上提到 head。
|
||||
* 注记内部**不做行内渲染**(既有口径),所以里面的 `<br>` 与 `<b>` 一样只是字面文本。
|
||||
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
|
||||
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
|
||||
* 关键词 旁白/内心 → narration,其余 → action。
|
||||
* - 行内标签(`InlineMarkup.kt`,契约 v0.89):`<b>`/`<strong>`、`<i>`/`<em>`、`<code>` 三类
|
||||
* (五项标签名,`u`/`s` 已按 `回执三十` 收窄撤掉),成对才认、**标签名大小写不敏感**、
|
||||
* - 行内标签(`InlineMarkup.kt`,契约 v0.89):`<b>`/`<strong>`、`<i>`/`<em>`、`<code>`、`<u>`
|
||||
* (**六项**标签名——`u` 由 `回执三十三 §一` 加回、`s` 仍不在内),成对才认、**标签名大小写不敏感**、
|
||||
* **属性一律不解析**(`<b class=x>` 就是粗体)、白名单外按纯文本原样显示、
|
||||
* **结构不平衡(未闭合 / 落单闭标签 / 交错)⇒ 该气泡的正文整段降级为原文**
|
||||
* (口径与理由见 `parseInlineMarkup`)。
|
||||
@@ -70,6 +74,9 @@ import kotlin.math.min
|
||||
* `<br>` 是分隔符、不成对,**不入栈**(入了就永远弹不出来);它只在残缺(`<br`、`<br/`、
|
||||
* `</br`)时按词表扣留([isPotentialTagPrefix] → [couldBeTagName]),完整的 `<br>` 直接放行
|
||||
* ——分隔符一出现,前缀就已经可解析了。
|
||||
* **保护区内的 `<br` 另外还有一道**:`<code>` / `<action>` / `<narration>` 的开标签本来就在
|
||||
* 栈里,所以整个作用域(连同里面还没写完的 `<br`)一起被扣到闭合为止,残缺的 `<br` 不会
|
||||
* 在半截作用域里先露一次脸(由 `streamingWithholdsPartialLineBreakInsideProtectedScopes` 钉着)。
|
||||
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`、`<B`、`<br`),仅当它可能继续补全为合法标签时才扣留;
|
||||
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
|
||||
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
|
||||
@@ -107,8 +114,8 @@ data class ParsedBubble(
|
||||
val head: List<ContentPart>,
|
||||
val tail: List<ContentPart>,
|
||||
/**
|
||||
* `body` 的行内样式切分(`<b>` / `<i>` / `<code>`,见 [parseInlineMarkup]):非空时拼起来
|
||||
* **就是** [body]。空列表 = 这段正文按字面画——**绝大多数消息都走这一档**(没有标签、
|
||||
* `body` 的行内样式切分(`<b>` / `<i>` / `<code>` / `<u>`,见 [parseInlineMarkup]):非空时
|
||||
* 拼起来**就是** [body]。空列表 = 这段正文按字面画——**绝大多数消息都走这一档**(没有标签、
|
||||
* 或者结构不平衡被整段降级、或者整段只有标记没有字)。
|
||||
*
|
||||
* 有默认值,是因为只有 [toBubble] 才该填它:其余构造点要么本来就该按字面画(`plainBubble`、
|
||||
@@ -173,8 +180,13 @@ private const val LINE_BREAK_TAG_NAME = "br"
|
||||
* `<br2>` / `<boolean>` / `<br-x>` 因此**不是**分隔符,只是普通文本(这一条是「字符一个不丢」的闸门)。
|
||||
*
|
||||
* ⚠️ 已知边界:它**不看外层标签的属性结构**——所以写成 `<b title="<br>">甲</b>` 这种「属性里带 `<br>`」的
|
||||
* 文本会被切开。`<p …>` 开标签是唯一例外,因为它在更早一层就被 [P_BLOCK_RE] 整体吃掉了,
|
||||
* 属性里的 `<br>` 根本走不到这里。真实产出里没有这种形状(属性本身就不是产出侧的东西)。
|
||||
* 文本会被切开(`回执三十三 §三③` 登记为两端口径相同、不修)。`<p …>` 开标签是唯一例外,因为它在更早
|
||||
* 一层就被 [P_BLOCK_RE] 整体吃掉了,属性里的 `<br>` 根本走不到这里。真实产出里没有这种形状
|
||||
* (属性本身就不是产出侧的东西)。
|
||||
*
|
||||
* ⚠️ 它只认**普通文本层**的那些:`<code>` / `<action>` / `<narration>` **内部**的 `<br>` 由
|
||||
* [appendRegion] 按保护区跳过(`回执三十三 §三②`)——所以 `findAll` 的结果**不再是**「所有分隔点」,
|
||||
* 用它时要么走 [appendRegion](会跳保护区),要么自己判一遍(别处没第二个消费者)。
|
||||
*/
|
||||
private val BR_TAG_RE = Regex("<\\s*/?\\s*${LINE_BREAK_TAG_NAME}(\\s[^<>]*|/\\s*)?>", RegexOption.IGNORE_CASE)
|
||||
|
||||
@@ -280,7 +292,8 @@ private fun tokenize(raw: String): List<Tok> {
|
||||
* **为什么 `<br>` 在这一层切**:契约 v0.89 的更正把 `<br>` 定成**与 `<p>` 同级的分隔符**
|
||||
* (`回执三十一`「对消息内的 `<br>` 始终处理为多条消息隔断」)。它既然与 `<p>` 同级,就该与 `<p>`
|
||||
* 走**同一套机制、同一个粒度**:产生 [Tok.Break]、由 `buildBubbles` 分组、空气泡照旧丢弃。
|
||||
* 行内层(`InlineMarkup`)因此完全看不见 `<br>` —— 那里只剩五个白名单标签。
|
||||
* 行内层(`InlineMarkup`)因此几乎看不见 `<br>` —— 那里只剩六个白名单标签(唯一例外见下面的保护区:
|
||||
* `<code>` 内部的那个 `<br>` 会随正文交到行内层,在那里按「内容」字面显示)。
|
||||
*
|
||||
* **顺序:先切分、再逐段各自做行内解析**(`<b>甲<br>乙</b>` ⇒ 两段各按「不平衡 ⇒ 不渲染」处理,
|
||||
* 两段都回落原文)。理由:
|
||||
@@ -295,6 +308,52 @@ private fun tokenize(raw: String): List<Tok> {
|
||||
*
|
||||
* 剥标签**先于**切分(判据逐字不变):[stripLooseTags] 里「这个落单 `<p>` 后面还有没有正文」看的是
|
||||
* 整段原文,`<br>` 在那里只是一个普通字符,所以同一输入下它的判定与 W68 完全相同。
|
||||
*
|
||||
* ## 保护区:`<code>` 与角色注记**内部**的 `<br>` 不分隔(`回执三十三 §三②`)
|
||||
* `回执三十一` 说的是「**始终**分隔」,`回执三十三` 把它收紧了:**只有普通文本层的 `<br>` 才分隔**。
|
||||
* 后端给的两条理由在我们这边一字不差地同样成立,所以照改:
|
||||
* 1. **会漏注记正文**:`<action>她<br>点点头</action>` 若切成两段,就成了「她」「点点头」**两条消息**
|
||||
* ——注记本来是不该当正文露出来的那一层,切开等于把它摆到台面上(后端原话:「那等于把注记正文
|
||||
* 发给了对方」);
|
||||
* 2. **`<code>` 会被切坏**:它是**成对**元素,从中间切开必然两半都不平衡,按「不平衡 ⇒ 不渲染」的
|
||||
* 硬口径**整段降级为字面量**,用户直接看到裸标签。
|
||||
*
|
||||
* 实现:先扫出保护区区间([protectedSpans]),切分时**跳过**落在区间里的 `<br>`(它留在原文里,随该
|
||||
* 作用域自己的语义走)。扫的是**已经剥过落单标签**的那一份文本,与切分用的是同一个对象。
|
||||
*
|
||||
* ### 作用域内部算什么语义(我们定的口径):**普通字符、逐字原样**(不换行、也不消掉)
|
||||
* - `<code>`:这是它**既有的**口径——「里面连白名单标签也按内容处理」,`<code><b>甲</b></code>`
|
||||
* 显示字面的 `<b>甲</b>`(`codeContentIsNotParsed` 钉着)。`<br>` 在这一层本来不是标签,用不着
|
||||
* 另立规矩;`<code>` 也正好是「想原样显示尖括号怎么办」的现成答案。
|
||||
* - `<action>` / `<narration>`:**必须是字面**。注记那一行画的是 `parts` 里的原文
|
||||
* (`MessageBubble.AnnotationLine` 直接 `parts.joinToString`),而 `parts` = **逐字原文**是红线
|
||||
* ——换成 `\n` 或删掉,就是在静默改用户内容(编辑框、搜索、通知读的都是它)。顺带也就与既有
|
||||
* 口径对齐:`<action>笑<b>了</b></action>` 里的 `<b>` 同样是字面(`narrationTextIsLeftExactlyAsWritten`)。
|
||||
* - 两个作用域取**同一条**规矩:它们是同一件事(`<br>` 落在作用域内部),各给一种读法才是真的
|
||||
* 「各端各说各话」。
|
||||
* - 为什么不把它当换行(`<code>甲<br>乙</code>` ⇒ `甲\n乙`):那必须在一处破例——要么在这里改掉
|
||||
* 片段文本(`parts` 当场不再是原文),要么在行内层给 `<br>` 单开一条「代码里它算换行」的规则
|
||||
* (W68 刚把「`<br>` → `\n`」从行内层整体删掉,而且那会让**同两个字符**在 `<code>` 与 `<action>`
|
||||
* 里各是一种意思)。两条都不值当,所以不做。
|
||||
*
|
||||
* ### 未闭合的作用域:**不算作用域**,里面的 `<br>` 照旧分隔
|
||||
* 判据只看「**成对**」,且与各自的消费侧同一把尺子:`<code>` 要找到它的 `</code>`(大小写不敏感、
|
||||
* 可带属性——行内层就这么认),`<action>` / `<narration>` 要找到**同名**闭合(只认小写、不带属性
|
||||
* ——`ROLE_RE` 就这么认)。找不到闭合的开标签不是作用域,于是 `<code>甲<br>乙` 照旧切成两条。
|
||||
* - 理由一:**消费侧本来就不认它**。未闭合的 `<code>` 在行内层不是作用域(整段降级为原文),
|
||||
* 未闭合的 `<action>` 在 `ROLE_RE` 那里也配不出旁白。去保护一个**消费侧根本不认**的作用域,
|
||||
* 等于让「切分层」与「消费层」对同一段文本有两种结构判断——正是本项目一路在防的那种分叉
|
||||
* (W68/W69 的教训:消费侧与扣留侧的词表必须同源)。
|
||||
* - 理由二:**爆炸半径**。若把「未闭合 ⇒ 保护到这一段文本结尾」也算上,一个漏掉 `</code>` 的笔误
|
||||
* 就会把**后面所有** `<br>` 一起吞掉、把本该分开的多条消息并成一条。「一个畸形标签改变整段的
|
||||
* 分段」比「照旧分隔」坏得多。
|
||||
* - 两种读法都不渲染、都不丢字(未闭合那一档本来就是「不平衡 ⇒ 原样显示」),差别只在气泡怎么切;
|
||||
* 我们选与消费侧一致的那种。
|
||||
* - **两支各算各的**([protectedSpans] 用两个独立状态):`<code>` 与角色注记各自配对,因为那本来
|
||||
* 就是两个消费侧各自的尺子——行内层在 `<code>` 里只认自己的闭合、别的一律当内容;`ROLE_RE`
|
||||
* 则完全不看 `<code>`(它在代码里照样配对)。所以「某一支没闭合」只影响**那一支**:
|
||||
* `<code>甲<action>她<br>点点头</action>乙` 里那个**已经配对好**的 `<action>` 照样保护它的
|
||||
* `<br>`(不产生区间的只是没闭合的 `<code>` 那一支)。
|
||||
*/
|
||||
private fun appendRegion(
|
||||
text: String,
|
||||
@@ -304,9 +363,16 @@ private fun appendRegion(
|
||||
splitBlankLines: Boolean,
|
||||
) {
|
||||
val stripped = stripLooseTags(text, markupMode, stripStrayRoleTags)
|
||||
// 保护区只在「这一区里真有作用域」时才扫:绝大多数消息连 `<` 都没有(一个字节都不多花)。
|
||||
val protected = if (stripped.indexOf('<') < 0) emptyList() else protectedSpans(stripped)
|
||||
var last = 0
|
||||
var span = 0
|
||||
for (m in BR_TAG_RE.findAll(stripped)) {
|
||||
val piece = stripped.substring(last, m.range.first)
|
||||
val at = m.range.first
|
||||
// 区间按起点升序,跟着 `<br>` 的推进往前走一个游标就够(不回头、不重扫)。
|
||||
while (span < protected.size && protected[span].last < at) span++
|
||||
if (span < protected.size && at in protected[span]) continue
|
||||
val piece = stripped.substring(last, at)
|
||||
if (piece.isNotEmpty()) appendAnnotations(piece, out, splitBlankLines)
|
||||
out.add(Tok.Break)
|
||||
last = m.range.last + 1
|
||||
@@ -315,6 +381,73 @@ private fun appendRegion(
|
||||
if (tail.isNotEmpty()) appendAnnotations(tail, out, splitBlankLines)
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫出这一区里**已配对**的保护区区间(左闭右开,按**起点**升序):`<code>…</code>` 与
|
||||
* `<action>…</action>` / `<narration>…</narration>`——[appendRegion] 据此跳过落在里面的 `<br>`
|
||||
* (口径与理由写在它的 KDoc 里)。
|
||||
*
|
||||
* 一趟线性扫描,判据全部借现成的尺子:「哪儿是一个标签」用行内层的 [inlineTagEnd];`code` 用
|
||||
* 行内层的 [openInlineTagName] / [closeInlineTagName](大小写不敏感、可带属性);角色注记用
|
||||
* [openNoteTagName] / [closeNoteTagName](与 `ROLE_RE` 同口径:只认小写、不带属性、找**同名**闭合)。
|
||||
*
|
||||
* **两支各自独立配对**,因为那本来就是两个消费侧各自的尺子:`<code>` 那一支只认自己的开/闭
|
||||
* (里面别的标签都是内容,与行内层一致);角色那一支同理(与 `ROLE_RE` 的非贪婪配对一致——它
|
||||
* 在 `<code>` 里照样配对,两边各算各的)。任一支没闭合 ⇒ **那一支**不产生区间。
|
||||
*
|
||||
* `indexOf('<')` + 逐段推进的扫描区间互不重叠(见 [inlineTagEnd]),每个 `<br>` 的判定是两个游标
|
||||
* 各走一次,因此整体仍是 O(n):成串的尖括号或成串的作用域都不会让它退回 O(N²)。
|
||||
*/
|
||||
private fun protectedSpans(text: String): List<IntRange> {
|
||||
val codeSpans = ArrayList<IntRange>(2)
|
||||
val noteSpans = ArrayList<IntRange>(2)
|
||||
var openCode = -1
|
||||
var openNoteName: String? = null
|
||||
var openNote = -1
|
||||
var i = 0
|
||||
while (true) {
|
||||
val lt = text.indexOf('<', i)
|
||||
if (lt < 0) break
|
||||
val gt = inlineTagEnd(text, lt)
|
||||
if (gt < 0) {
|
||||
// 这一处起头的东西不是标签:往后挪**一个字符**(与 `claimTags` 同一把尺子,
|
||||
// 不能跳到某个「下一个 `>`」——那会把中间真正的标签一起跳过)。
|
||||
i = lt + 1
|
||||
continue
|
||||
}
|
||||
val tag = text.substring(lt, gt + 1)
|
||||
val end = gt + 1
|
||||
if (openCode < 0) {
|
||||
if (openInlineTagName(tag) == CODE) openCode = lt
|
||||
} else if (closeInlineTagName(tag) == CODE) {
|
||||
codeSpans.add(openCode until end)
|
||||
openCode = -1
|
||||
}
|
||||
val noteOpen = if (openNoteName == null) openNoteTagName(tag) else null
|
||||
if (noteOpen != null) {
|
||||
openNoteName = noteOpen
|
||||
openNote = lt
|
||||
} else if (openNoteName != null && closeNoteTagName(tag) == openNoteName) {
|
||||
noteSpans.add(openNote until end)
|
||||
openNoteName = null
|
||||
}
|
||||
i = end
|
||||
}
|
||||
// 两支各自升序,合起来也按**起点**升序——[appendRegion] 的包含判定(「第一个右端 ≥ 这个 `<br>`
|
||||
// 的区间」)以「按起点升序」为前提。合并后两支的区间可能相互嵌套(`<code>` 套注记、注记套
|
||||
// `<code>`),那不影响判定:嵌套的那些都在同一个升序序列里。
|
||||
return when {
|
||||
noteSpans.isEmpty() -> codeSpans
|
||||
codeSpans.isEmpty() -> noteSpans
|
||||
else -> (codeSpans + noteSpans).sortedBy { it.first }
|
||||
}
|
||||
}
|
||||
|
||||
/** 角色注记的**开**标签名(只认小写的 `action` / `narration`、不带属性——与 `ROLE_RE` 同口径)。 */
|
||||
private fun openNoteTagName(tag: String): String? = OPEN_ROLE_TAG_RE.find(tag)?.groupValues?.get(1)
|
||||
|
||||
/** 角色注记的**闭**标签名(同上;配对要求**同名**,与 `ROLE_RE` 的 `</\s*\1\s*>` 一致)。 */
|
||||
private fun closeNoteTagName(tag: String): String? = CLOSE_ROLE_TAG_RE.find(tag)?.groupValues?.get(1)
|
||||
|
||||
/**
|
||||
* 剥掉「落单标签」,其余字符逐字保留(返回原对象:没剥到东西时不做新分配)。
|
||||
*
|
||||
|
||||
@@ -5,24 +5,30 @@ package top.yeij.cyrene.domain.parse
|
||||
*
|
||||
* 口径 = 后端 2026-10-07 定下的**契约 v0.89** 及其同日更正(跨端必须逐字一致,否则同一个 `<b>`
|
||||
* 在她那台设备上粗、在这台上不粗)。四条硬口径:
|
||||
* 1. 白名单 = `b` / `strong` · `i` / `em` · `code`(**成对**,五项标签名);标签名**大小写不敏感**;
|
||||
* 1. 白名单 = `b` / `strong` · `i` / `em` · `code` · `u`(**成对**,六项标签名);标签名**大小写不敏感**;
|
||||
* 2. **属性一律不解析**:`<b class=x onclick=y>` 只取标签名,属性整段丢掉、绝不落成任何视图属性;
|
||||
* 3. **白名单外一律按纯文本原样显示**:`<script>` 显示成字面量,字符一个不丢;
|
||||
* 4. **结构不平衡 ⇒ 整段降级为原文**(未闭合 / 落单闭标签 / 交错)——粗粒度是有意的,换来
|
||||
* 「字符一个不丢」可以被逐字断言。
|
||||
* 样式映射:`b`/`strong` → 粗体,`i`/`em` → 斜体,`code` → 等宽 + 轻微底色(且**里面的标签不解析**)。
|
||||
* 样式映射:`b`/`strong` → 粗体,`i`/`em` → 斜体,`code` → 等宽 + 轻微底色(且**里面的标签不解析**),
|
||||
* `u` → 下划线。`s` **不在白名单里**:它按第 3 条逐字原样显示(见下)。
|
||||
*
|
||||
* ## `br` 不在这里(契约同日更正)
|
||||
* `回执三十一`:「对消息内的 `<br>` **始终处理为多条消息隔断**」⇒ `<br>` **不是行内标签**,而是与
|
||||
* `<p>` 同级的**气泡/消息分隔符**,切分在 [tokenize](ChatContentParser.kt) 的段/块那一层
|
||||
* (`ChatContentParser.appendRegion`)完成,`<br>` / `<br/>` / `</br>` 三种写法等价。
|
||||
* 这一层因此**只认成对标签**:`<br>` 若出现在这里的文本里(真实链路上不会——它已经被切走了),
|
||||
* 它只是几个普通字符,与 `<br2>`、`<boolean>` 同类。
|
||||
* 这一层因此**只认成对标签**:`<br>` 到了这里只是几个普通字符,与 `<br2>`、`<boolean>` 同类。
|
||||
* 它**真的会**走到这里,只是只有一条路:`<code>` 作用域内部——分隔符切分层有一个**保护区**
|
||||
* (`ChatContentParser.protectedSpans`,`回执三十三 §三②`),落在 `<code>` / `<action>` / `<narration>`
|
||||
* 内部的 `<br>` 不分隔,于是 `<code>` 里的那个 `<br>` 会随正文一起交到这一层,在代码作用域里按
|
||||
* 「内容」处理(字面显示,与 `<code><b>甲</b></code>` 里的 `<b>` 同一规矩)。
|
||||
*
|
||||
* ## `u` / `s` 已撤掉(契约收窄)
|
||||
* `回执三十`:白名单**收窄到五项**,去掉 `u`/`s`(它们在她 100 条输出里 0 命中,不值得让各端返工)。
|
||||
* 多支持反而是**制造**跨端不一致(Web 不渲染、我们渲染 ⇒ 同一条消息两端显示不同),所以这里连同
|
||||
* 样式、渲染、测试一起撤干净:`<u>甲</u>` 现在走「白名单外 ⇒ 原样显示」,显示成字面的 `<u>甲</u>`。
|
||||
* ## 白名单六项(`u` 加回、`s` 不做)
|
||||
* `回执三十` 先把白名单收窄到五项、撤掉 `u`/`s`(理由是「多支持 = 制造跨端不一致」),但
|
||||
* `回执三十三 §一` 更正:**那份「收窄到五项」是后端的疏漏**——主人随后裁定下划线保留、后端把 `u`
|
||||
* 加回了契约(`1107b2a`)与 IM 出站(`74c9f2d`),只是**忘了发回执登记**。最终白名单 = **六项**:
|
||||
* `b` / `strong` / `i` / `em` / `code` / **`u`**,`u` 在这里渲染成下划线。
|
||||
* **`s` 仍不在内**(逐字原样)——「多支持反而制造跨端不一致」这条理由对它依然成立:Web 不渲染它。
|
||||
*
|
||||
* ## 为什么是「整段降级」而不是「只降级那一处」
|
||||
* W67 那版是**局部**降级:`甲<b>粗</b>乙<b>没闭合` 里 `粗` 照旧加粗,只有后面那半个 `<b>` 留着字面。
|
||||
@@ -91,13 +97,16 @@ package top.yeij.cyrene.domain.parse
|
||||
/**
|
||||
* 一段行内样式,可叠加(`<b><i>粗斜</i></b>` 同时是粗与斜)。
|
||||
*
|
||||
* 只有这三种:换行不是样式——`<br>` 现在连「行内标签」都不是,它是气泡分隔符
|
||||
* (切在 `ChatContentParser.tokenize` 那一层),根本走不到这里。
|
||||
* 只有这四种:换行不是样式——`<br>` 是气泡分隔符(切在 `ChatContentParser.tokenize` 那一层),
|
||||
* 它只会在 `<code>` 作用域内部作为**内容**走到这里(按文件头那条口径字面显示),永远不成样式。
|
||||
*/
|
||||
enum class InlineStyle {
|
||||
Bold,
|
||||
Italic,
|
||||
Code,
|
||||
|
||||
/** `<u>`:下划线(渲染成 `TextDecoration.Underline`)。`s` 不在白名单里,所以没有对应项。 */
|
||||
Underline,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,15 +118,20 @@ enum class InlineStyle {
|
||||
*/
|
||||
data class InlineRun(val text: String, val styles: Set<InlineStyle>)
|
||||
|
||||
/** `<code>` 的标签名(解析里要单独判它:里面的标签不再解析)。 */
|
||||
private const val CODE = "code"
|
||||
/**
|
||||
* `<code>` 的标签名(解析里要单独判它:里面的标签不再解析)。
|
||||
* `internal` 是因为 `ChatContentParser.protectedSpans` 也要判它(保护区扫描),
|
||||
* 名字只写这一处、两边不许各写一遍。
|
||||
*/
|
||||
internal const val CODE = "code"
|
||||
|
||||
/**
|
||||
* 成对标签的名字(契约收窄后的**五项**):`strong` / `em` 是 `b` / `i` 的同义写法。
|
||||
* 成对标签的名字(契约 v0.89 最终版的**六项**,见文件头):`strong` / `em` 是 `b` / `i` 的同义写法,
|
||||
* `u` 是 `回执三十三 §一` 加回来的下划线。
|
||||
* 只写这一处,[parseInlineTag] 与 [couldBeInlineTagNamePrefix] 都由它推出来。
|
||||
* `br` 不在这里(它是分隔符,见文件头);`u` / `s` 已按契约收窄撤掉。
|
||||
* `br` 不在这里(它是分隔符,见文件头);`s` 也不在(逐字原样)。
|
||||
*/
|
||||
private val PAIRED_TAG_NAMES = listOf("b", "strong", "i", "em", CODE)
|
||||
private val PAIRED_TAG_NAMES = listOf("b", "strong", "i", "em", CODE, "u")
|
||||
|
||||
/**
|
||||
* 行内标签词表。**流式扣留侧(`ChatContentParser.stablePrefixEnd`)与这里必须同源**:
|
||||
@@ -155,7 +169,8 @@ internal fun isTagNameLetter(c: Char): Boolean = c in 'a'..'z' || c in 'A'..'Z'
|
||||
* 丢掉的只是白名单标签自己那对尖括号里的东西,正文一个字符都不动。
|
||||
*
|
||||
* `<br>` 在这里返回 null(它不再是行内标签,是气泡分隔符)——所以它落到「白名单外」那条路:
|
||||
* 万一真被送到这一层(真实链路上不会,[appendRegion] 已经把它切走了),它只是几个普通字符。
|
||||
* 它只是几个普通字符。真实链路上**只有一条路**能把它送到这一层:`<code>` 作用域内部
|
||||
* (分隔符切分层的保护区不分隔它,见 [appendRegion] 与 `ChatContentParser.protectedSpans`)。
|
||||
*/
|
||||
internal fun parseInlineTag(tag: String): InlineTag? {
|
||||
val end = tag.length - 1
|
||||
@@ -192,6 +207,7 @@ internal fun couldBeInlineTagNamePrefix(name: String): Boolean =
|
||||
internal fun inlineStyleOf(name: String): InlineStyle = when (name) {
|
||||
"i", "em" -> InlineStyle.Italic
|
||||
CODE -> InlineStyle.Code
|
||||
"u" -> InlineStyle.Underline
|
||||
else -> InlineStyle.Bold
|
||||
}
|
||||
|
||||
@@ -327,8 +343,11 @@ private fun claimTags(text: String): ClaimedTags? {
|
||||
* 仍然刻意**不用** `indexOf('>', lt)` 一路找过去:正文里 N 个落单的 `<` 时那是 O(N²)
|
||||
* (W63 在同一片区域刚踩过这个坑),而且它还会把中间真正的标签一起跳过。
|
||||
* 逐段推进仍是线性的:每次从 `lt + 1` 扫到「下一个 `<`」或串尾,这些区间互不重叠。
|
||||
*
|
||||
* `internal` 是因为**标签边界只该有一把尺子**:分隔符切分层扫 `<br>` 保护区
|
||||
* (`ChatContentParser.protectedSpans`)时也用它,两边若各写一套,「哪儿是一个标签」就会分叉。
|
||||
*/
|
||||
private fun inlineTagEnd(text: String, lt: Int): Int {
|
||||
internal fun inlineTagEnd(text: String, lt: Int): Int {
|
||||
var i = lt + 1
|
||||
while (i < text.length) {
|
||||
when (text[i]) {
|
||||
|
||||
@@ -35,6 +35,7 @@ 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.rememberTextMeasurer
|
||||
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
|
||||
@@ -990,14 +991,18 @@ private fun BubbleBody(
|
||||
private const val INLINE_CODE_ON_USER_BACKGROUND = 0.18f
|
||||
|
||||
/**
|
||||
* 行内片段 → 一段 `AnnotatedString`:一个 `Text` 就同时承载粗体 / 斜体 / 等宽底色。
|
||||
* 行内片段 → 一段 `AnnotatedString`:一个 `Text` 就同时承载粗体 / 斜体 / 下划线 / 等宽底色。
|
||||
*
|
||||
* **为什么不拼一串 `Text` 节点**:一条气泡本来就是**一整段可选中的文本**([SelectionContainer]
|
||||
* 的行为一个字都不用改),折行位置也由同一个排版器一次定完——拆成多个节点会让可选性与折行
|
||||
* 各自为政。`<br>` 已经不在这一层(它是气泡分隔符,切在解析层),所以这里不再有「插一个换行」
|
||||
* 这种活。
|
||||
*
|
||||
* 样式只给**有**的那几项(`null` = 继承气泡自己的字重/字体),所以非 `<code>` 的片段与原来逐像素相同。
|
||||
* 样式只给**有**的那几项(`null` = 继承气泡自己的字重/字体/装饰),所以非 `<code>` 的片段与原来逐像素相同。
|
||||
*
|
||||
* `u` 落到 `TextDecoration.Underline`(六项白名单里的那一项,见 `InlineMarkup.kt` 的文件头);
|
||||
* `s` 不在白名单里,所以这里**没有**删除线分支——写 `<s>甲</s>` 时那四个字符是按字面显示的正文,
|
||||
* 走的是 `runs` 为空的那条路(`BubbleBody` 里的裸 `Text`)。
|
||||
*/
|
||||
private fun inlineBody(
|
||||
runs: List<InlineRun>,
|
||||
@@ -1010,6 +1015,7 @@ private fun inlineBody(
|
||||
SpanStyle(
|
||||
fontWeight = if (InlineStyle.Bold in run.styles) FontWeight.Bold else null,
|
||||
fontStyle = if (InlineStyle.Italic in run.styles) FontStyle.Italic else null,
|
||||
textDecoration = if (InlineStyle.Underline in run.styles) TextDecoration.Underline else null,
|
||||
fontFamily = if (code) FontFamily.Monospace else null,
|
||||
background = if (code) codeBackground else Color.Unspecified,
|
||||
),
|
||||
|
||||
@@ -137,19 +137,204 @@ class ChatContentParserTest {
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// `<br>` 的**保护区**(`回执三十三 §三②`:只有普通文本层的 `<br>` 才分隔)
|
||||
//
|
||||
// `回执三十一` 说的是「**始终**处理为多条消息隔断」,`回执三十三` 把它收紧了:`<code>` 与角色
|
||||
// 注记**内部**的不分隔。后端给的两条理由在我们这边同样成立:
|
||||
// 1. `<code>` 是**成对**元素,从中间切开必然两半都不平衡 ⇒ 按硬口径「不平衡 ⇒ 不渲染」
|
||||
// **整段降级为字面量**,用户直接看到裸标签;
|
||||
// 2. `<action>她<br>点点头</action>` 若切成两段,就成了「她」「点点头」**两条聊天消息**
|
||||
// ——等于把注记正文当正文发了出去。
|
||||
// 口径与理由的全文写在 `ChatContentParser.appendRegion` / `protectedSpans` 的 KDoc 里。
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `<br>` 在 `<code>` / `<action>` 里**也照样分隔**——契约原话是「对消息内的 `<br>` **始终**处理为
|
||||
* 多条消息隔断」,而分隔符切在更早的那一层:行内层的 `<code>` 作用域、旁白层的 `<action>` 配对
|
||||
* 都管不到它(`<p>` 同理:它切开一个 `<action>` 块时也一样不留情)。
|
||||
* `回执三十三 §三②` 第一半:**`<code>` 内部的 `<br>` 不分隔**,且作用域不能被切坏。
|
||||
*
|
||||
* 代价是切完以后两半各自的标签**不成对**,于是按既有口径回落字面(`<code>a` / `b</code>`;
|
||||
* `<action>笑` / `了</action>`)——这正是「未配对的旁白标签 ⇒ 字面」那条既有契约,不是 br 新造的规则。
|
||||
* 我们定的口径:作用域里的 `<br>` 就是**普通字符、逐字原样**——与 `<code><b>甲</b></code>` 里的
|
||||
* `<b>` 同一规矩(`codeContentIsNotParsed` 钉着)。所以这条同时钉三件事:**只有一条气泡**、
|
||||
* `<code>` 那一对**被消费掉**(正文里没有裸 `<code>` ⇒ 作用域没被降级)、内容**逐字保留**
|
||||
* (含那个字面的 `<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 })
|
||||
fun lineBreakInsideCodeDoesNotSplitAndTheScopeSurvives() {
|
||||
val bubbles = parse("<p><code>甲<br>乙</code></p>")
|
||||
assertEquals("`<code>` 里的 `<br>` 不该分隔", 1, bubbles.size)
|
||||
assertEquals("甲<br>乙", bubbles.single().body)
|
||||
assertEquals(listOf(run("甲<br>乙", InlineStyle.Code)), bubbles.single().runs)
|
||||
assertFalse("`<code>` 作用域不该被降级成裸标签", bubbles.single().body.contains("<code"))
|
||||
// 无 `<p>` 时同理(分隔符本来就与 `<p>` 同级)
|
||||
assertEquals(listOf("甲<br>乙"), parse("<code>甲<br>乙</code>").map { it.body })
|
||||
// 分隔符的三种写法 + 大小写 + 属性,在保护区里同样都拦下来(它们先要是分隔符候选)
|
||||
for (form in listOf("<br>", "<br/>", "<br />", "</br>", "<BR>", "<br class=x>")) {
|
||||
val one = parse("<p><code>甲${form}乙</code></p>")
|
||||
assertEquals("「$form」在 `<code>` 里不该分隔", 1, one.size)
|
||||
assertEquals("「$form」必须逐字留在代码里", "甲${form}乙", one.single().body)
|
||||
}
|
||||
// 保护区**只管**作用域内部:`<code>` 外面的 `<br>` 照旧分隔
|
||||
assertEquals(listOf("丙", "甲<br>乙"), parse("<p>丙<br><code>甲<br>乙</code></p>").map { it.body })
|
||||
}
|
||||
|
||||
/**
|
||||
* `回执三十三 §三②` 第二半:**角色注记内部的 `<br>` 不分隔、不产生气泡**。
|
||||
*
|
||||
* 「不产生气泡」正是后端点名的信息泄露:切成两段等于把注记正文当两条聊天消息发出去。
|
||||
* 我们这边的落点是**既有旁白语义**——注记整体被 `ROLE_RE` 认下、进 `head` / `tail`、正文为空;
|
||||
* 注记文本**逐字原文**(`parts` 是红线:编辑框/搜索/通知都读它,而旁白那一行画的正是它),
|
||||
* 所以里面那个 `<br>` 与 `<b>` 一样只是字面文本(`narrationTextIsLeftExactlyAsWritten` 同款)。
|
||||
*/
|
||||
@Test
|
||||
fun lineBreakInsideNoteBlocksDoesNotSplitAndProducesNoBubble() {
|
||||
for (raw in listOf("<p><action>她<br>点点头</action></p>", "<action>她<br>点点头</action>")) {
|
||||
val bubbles = parse(raw)
|
||||
assertEquals("「$raw」不该切成两条消息", 1, bubbles.size)
|
||||
assertEquals("注记仍然不产生正文气泡", "", bubbles.single().body)
|
||||
assertTrue("注记不做行内渲染 ⇒ runs 为空", bubbles.single().runs.isEmpty())
|
||||
assertEquals(listOf(ContentPart.Action("她<br>点点头")), bubbles.single().head)
|
||||
assertEquals(
|
||||
"注记文本逐字原文(`parts` 红线)",
|
||||
listOf(ContentPart.Action("她<br>点点头")),
|
||||
bubbles.single().parts,
|
||||
)
|
||||
}
|
||||
|
||||
// `<narration>` 同理,且**附着语义一个字没动**:尾部 → tail,首部 → head
|
||||
val tailed = parse("<p>你好<narration>她<br>点点头</narration></p>").single()
|
||||
assertEquals("你好", tailed.body)
|
||||
assertEquals(listOf(ContentPart.Narration("她<br>点点头")), tailed.tail)
|
||||
val headed = parse("<p><narration>她<br>点点头</narration>你好</p>").single()
|
||||
assertEquals("你好", headed.body)
|
||||
assertEquals(listOf(ContentPart.Narration("她<br>点点头")), headed.head)
|
||||
|
||||
// 保护区只管作用域内部:注记**外面**的 `<br>` 照旧分隔,旁白照旧附着到下一段
|
||||
val across = parse("<p><action>她<br>点点头</action><br>你好</p>")
|
||||
assertEquals(listOf("你好"), across.map { it.body })
|
||||
assertEquals(listOf(ContentPart.Action("她<br>点点头")), across.single().head)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保护区**只管 `<br>` 算不算分隔符**,别的语义一个字都不动;而且只有 `<code>` 与角色注记算
|
||||
* 保护区——**白名单行内元素(`<b>` / `<i>` / `<u>`)不算**(W68 登记的「跨分隔符的标签各自回落
|
||||
* 原文」因此逐字保留,见 [inlineTagsCannotSpanALineBreak])。
|
||||
*/
|
||||
@Test
|
||||
fun onlyCodeAndNoteScopesProtectLineBreaks() {
|
||||
// `<b>` 里那个 `<br>`:照样分隔(口径没变)
|
||||
assertEquals(listOf("<b>甲", "乙</b>"), parse("<p><b>甲<br>乙</b></p>").map { it.body })
|
||||
// `<u>` 一样(W71 刚加回白名单,但它不是作用域)
|
||||
assertEquals(listOf("<u>甲", "乙</u>"), parse("<p><u>甲<br>乙</u></p>").map { it.body })
|
||||
// 一个 `<code>` 里几个 `<br>` 全都不分隔
|
||||
assertEquals(listOf("甲<br>乙<br>丙"), parse("<code>甲<br>乙<br>丙</code>").map { it.body })
|
||||
// 保护区**不**改注记/旁白/星号的任何既有语义:注记照样进 tail、`*笑*` 照样是动作,
|
||||
// 正文里只剩 `<code>` 那一段(它没被切开,`<br>` 是代码里的内容)
|
||||
val mixed = parse("<p><code>甲<br>乙</code>与<action>她<br>点点头</action>*笑*</p>").single()
|
||||
assertEquals("甲<br>乙与", mixed.body)
|
||||
assertEquals(
|
||||
listOf(ContentPart.Action("她<br>点点头"), ContentPart.Action("笑")),
|
||||
mixed.tail,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* **嵌套**:`<code>` 里再套白名单标签时,里面的东西仍是「内容」(不解析、不计结构),
|
||||
* 保护区也照样罩着它;`<code>` 套在 `<b>` 里时两边的边界各自成立。
|
||||
*/
|
||||
@Test
|
||||
fun nestedTagsInsideACodeZoneAreContentAndTheirLineBreakStillDoesNotSplit() {
|
||||
val codeInCode = parse("<p><code>甲<b>乙<br>丙</b></code></p>")
|
||||
assertEquals(1, codeInCode.size)
|
||||
assertEquals("甲<b>乙<br>丙</b>", codeInCode.single().body)
|
||||
assertEquals(listOf(run("甲<b>乙<br>丙</b>", InlineStyle.Code)), codeInCode.single().runs)
|
||||
|
||||
val boldAroundCode = parse("<p><b>甲<code>乙<br>丙</code>丁</b></p>")
|
||||
assertEquals(1, boldAroundCode.size)
|
||||
assertEquals("甲乙<br>丙丁", boldAroundCode.single().body)
|
||||
assertEquals(
|
||||
listOf(
|
||||
run("甲", InlineStyle.Bold),
|
||||
run("乙<br>丙", InlineStyle.Bold, InlineStyle.Code),
|
||||
run("丁", InlineStyle.Bold),
|
||||
),
|
||||
boldAroundCode.single().runs,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **未闭合的作用域不算作用域**(我们定的口径,理由写在 `appendRegion` 的 KDoc 里):
|
||||
* 判据只看「**成对**」,而消费侧本来就不认未闭合的那个(未闭合的 `<code>` 在行内层不是作用域、
|
||||
* 整段降级为原文;未闭合的 `<action>` 在 `ROLE_RE` 那里也配不出旁白)——去保护一个消费侧不认的
|
||||
* 作用域,等于让切分层与消费层对同一段文本有两种结构判断。
|
||||
* 另一条理由是**爆炸半径**:若「未闭合 ⇒ 保护到这段文本结尾」,一个漏掉 `</code>` 的笔误就会把
|
||||
* 后面所有 `<br>` 一起吞掉、把本该分开的消息并成一条。
|
||||
* 两种读法都不渲染、都不丢字(未闭合那一档本来就是「不平衡 ⇒ 原样显示」),差别只在气泡怎么切。
|
||||
*/
|
||||
@Test
|
||||
fun unclosedProtectedScopesDoNotProtectLineBreaks() {
|
||||
assertEquals(listOf("<code>甲", "乙"), parse("<code>甲<br>乙").map { it.body })
|
||||
assertEquals(listOf("<action>她", "点点头"), parse("<action>她<br>点点头").map { it.body })
|
||||
assertEquals(listOf("<code>甲", "乙"), parse("<p><code>甲<br>乙</p>").map { it.body })
|
||||
// 闭合写错了名字(`</code>` 写成别的)同样不算成对
|
||||
assertEquals(listOf("<code>甲", "乙</action>"), parse("<code>甲<br>乙</action>").map { it.body })
|
||||
// 而且它仍然是**字面**(未闭合那一档照旧「不平衡 ⇒ 不渲染」),一个字都没丢
|
||||
assertEquals("<code>甲", parse("<code>甲<br>乙").first().body)
|
||||
}
|
||||
|
||||
/**
|
||||
* **两支各算各的**(我们定的口径,见 `protectedSpans`):`<code>` 与角色注记各自配对,
|
||||
* 因为那本来就是两个消费侧各自的尺子。所以「某一支没闭合」只影响**那一支**——里面那个已经
|
||||
* 配对好的作用域照样保护它自己的 `<br>`,而没闭合的那一支里的 `<br>` 照旧分隔。
|
||||
*/
|
||||
@Test
|
||||
fun aPairedZoneStillProtectsItsLineBreakWhileAnotherZoneIsUnclosed() {
|
||||
// 外层 `<code>` 没闭合(不保护),里面那个配对好的 `<action>` 照样保护它的 `<br>`
|
||||
val bubbles = parse("<code>甲<br>乙<action>她<br>点点头</action>丙")
|
||||
assertEquals(2, bubbles.size)
|
||||
assertEquals(listOf("<code>甲", "乙丙"), bubbles.map { it.body })
|
||||
assertEquals(
|
||||
"注记整体被认下(语序照原文:`parts` 为准)",
|
||||
listOf(ContentPart.Text("乙"), ContentPart.Action("她<br>点点头"), ContentPart.Text("丙")),
|
||||
bubbles[1].parts,
|
||||
)
|
||||
assertEquals(listOf(ContentPart.Action("她<br>点点头")), bubbles[1].head)
|
||||
assertTrue("注记不产生气泡正文 ⇒ 第二条气泡的 runs 为空", bubbles[1].runs.isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式:**保护区**里的残缺 `<br` 一样不许把字吐上屏。
|
||||
*
|
||||
* 中间态靠的是既有那条扣留规则——`<code>` / `<action>` / `<narration>` 的开标签本来就在栈里,
|
||||
* 所以整个作用域(连同里面还没写完的 `<br`)一起被扣到闭合为止。收口后与一次性解析逐字一致,
|
||||
* 且作用域真的没被切开:`<code>` 里的那个 `<br>` 是字面内容。
|
||||
*/
|
||||
@Test
|
||||
fun streamingWithholdsPartialLineBreakInsideProtectedScopes() {
|
||||
// 作用域还没闭合时,屏幕上只该有它前面那一段(里面的字面内容一个字都不许先露脸)
|
||||
for (partial in listOf(
|
||||
"甲<code", "甲<code>乙", "甲<code>乙<br", "甲<CODE>乙<br",
|
||||
"甲<action>她", "甲<action>她<br", "甲<narration>她<br",
|
||||
)) {
|
||||
assertEquals(
|
||||
"「$partial」残缺时必须扣住,只显示作用域前的正文",
|
||||
listOf("甲"),
|
||||
StreamingContentParser().onDelta(partial).map { it.body },
|
||||
)
|
||||
}
|
||||
|
||||
val text = "甲<code>乙<br>丙</code>丁"
|
||||
val parser = StreamingContentParser()
|
||||
var shown = emptyList<String>()
|
||||
for (ch in text) {
|
||||
val bodies = parser.onDelta(ch.toString()).map { it.body }
|
||||
assertTrue("`<code>` 漏上了屏:$bodies", bodies.none { it.contains("<code") })
|
||||
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
|
||||
shown.forEachIndexed { i, body ->
|
||||
assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body))
|
||||
}
|
||||
shown = bodies
|
||||
}
|
||||
assertEquals(listOf("甲乙<br>丙丁"), parser.finish().map { it.body })
|
||||
assertEquals(parse(text), parser.finish())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -730,6 +915,59 @@ class ChatContentParserTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* W71 新增的那趟**保护区扫描**([protectedSpans])同样不许把 `parse` 拖成平方:一串只有作用域
|
||||
* 开标签、或一串「像作用域」的尖括号,每个 `<` 都只允许常数级尝试;成对作用域那一趟靠
|
||||
* 「区间按起点升序 + 游标不回头」,整体仍是线性的。判据与上一条同源(同样只是「线性 or 平方」)。
|
||||
*
|
||||
* ⚠️ 刻意**不**喂 `"<action>".repeat(n)`:那个形状会先踩到 `ROLE_RE` 自己的回溯(一串没有闭合的
|
||||
* 角色开标签,正则引擎每处都要 `([\s\S]*?)` 一路试到串尾),那是**既有**的平方点、与本次改动无关,
|
||||
* 拿它来当预算用例只会把一个既存问题记到新代码头上。这里用的是 `<code>`(不是 `ROLE_RE` 的词)。
|
||||
*/
|
||||
@Test
|
||||
fun hugeRunOfScopeLikeTagsStaysWithinALinearBudget() {
|
||||
val count = 20_000
|
||||
val budget = { input: String, label: String, elapsedMs: Long ->
|
||||
assertTrue(
|
||||
"${input.length} 字符($label)用了 ${elapsedMs}ms," +
|
||||
"超出线性预算 ${looseTagBudgetMs}ms——复杂度退回 O(N²) 了?",
|
||||
elapsedMs < looseTagBudgetMs,
|
||||
)
|
||||
}
|
||||
|
||||
// 一串只有开标签:未闭合 ⇒ 不产生区间
|
||||
val unclosed = "<code>".repeat(count)
|
||||
val unclosedAt = System.nanoTime()
|
||||
val unclosedBubbles = parse(unclosed)
|
||||
budget(unclosed, "一串未闭合的 `<code>`", (System.nanoTime() - unclosedAt) / 1_000_000)
|
||||
assertEquals(1, unclosedBubbles.size)
|
||||
assertEquals(unclosed, unclosedBubbles.single().body)
|
||||
|
||||
// 一串「开标签 + `<br>`」:每个 `<br>` 都不在保护区里 ⇒ 每个都分隔
|
||||
val split = "<code>甲<br>".repeat(count)
|
||||
val splitAt = System.nanoTime()
|
||||
val splitBubbles = parse(split)
|
||||
budget(split, "$count 个未闭合的 `<code>` + `<br>`", (System.nanoTime() - splitAt) / 1_000_000)
|
||||
assertEquals(count, splitBubbles.size)
|
||||
|
||||
// 一串**成对**的作用域(每个里面一个 `<br>`):全部被保护 ⇒ 一条气泡、逐字内容
|
||||
val paired = "<code>甲<br>乙</code>".repeat(count / 4)
|
||||
val pairedAt = System.nanoTime()
|
||||
val pairedBubbles = parse(paired)
|
||||
budget(paired, "$count 个成对作用域", (System.nanoTime() - pairedAt) / 1_000_000)
|
||||
assertEquals("每个 `<br>` 都在保护区里 ⇒ 一条气泡", 1, pairedBubbles.size)
|
||||
assertEquals("甲<br>乙".repeat(count / 4), pairedBubbles.single().body)
|
||||
|
||||
// 角色注记那一支:一串成对的注记,各自保护自己的 `<br>`(一条气泡、注记全认下)
|
||||
val noted = "<action>她<br>点点头</action>".repeat(2_000)
|
||||
val notedAt = System.nanoTime()
|
||||
val notedBubbles = parse(noted)
|
||||
budget(noted, "2000 条成对注记", (System.nanoTime() - notedAt) / 1_000_000)
|
||||
assertEquals(1, notedBubbles.size)
|
||||
assertEquals(2_000, notedBubbles.single().head.size)
|
||||
assertEquals("", notedBubbles.single().body)
|
||||
}
|
||||
|
||||
/** 产出的任何片段里都不许出现段落标签本身——这是落单标签容错的底线。 */
|
||||
private fun assertNoLooseTagInRenderedText(bubbles: List<ParsedBubble>) {
|
||||
for (bubble in bubbles) {
|
||||
|
||||
@@ -10,13 +10,13 @@ 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 的「只坏那一对」)。⚠️ 大小写那条**消费侧与流式扣留侧必须一起改**:
|
||||
@@ -26,19 +26,27 @@ import kotlin.random.Random
|
||||
* **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` 逐字原文、星号语义、流式一致性、复杂度)
|
||||
* **W71 按 v0.89 最终版(`回执三十三`,收口)**:
|
||||
* 1. **`u` 加回**:`回执三十三 §一` 明说「收窄到五项」是**后端的疏漏** —— 主人裁定下划线保留,
|
||||
* 后端把 `u` 加回契约(`1107b2a`)与 IM 出站(`74c9f2d`),只是**忘了发回执登记**。
|
||||
* 最终白名单 = **六项**,`u` 在这一层渲染成下划线([underlineBecomesAnUnderlineRun]);
|
||||
* **`s` 仍不在内**、逐字原样([sEveryCaseAndShapeIsStillShownLiterally])。
|
||||
* 2. **`<br>` 的「始终分隔」收紧成「只有普通文本层才分隔」**(`回执三十三 §三②`):`<code>` 与
|
||||
* 角色注记**内部**的不分隔。那两处的切分在 `ChatContentParser`(保护区),这一层只是因此**真的**
|
||||
* 会收到 `<br>` —— `<code>` 里的那个会走到这里,按「内容」字面显示
|
||||
* ([lineBreakInsideCodeStaysLiteralContent])。
|
||||
*
|
||||
* 凡是不与这三轮契约冲突的老断言(绝不丢字、`parts` 逐字原文、星号语义、流式一致性、复杂度)
|
||||
* 一条都没放松。
|
||||
*/
|
||||
class InlineMarkupTest {
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 五项白名单各自渲染成什么
|
||||
// 六项白名单各自渲染成什么
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@@ -62,6 +70,33 @@ class InlineMarkupTest {
|
||||
assertEquals(listOf(run("abc", InlineStyle.Code)), parseInlineMarkup("<code>abc</code>"))
|
||||
}
|
||||
|
||||
/**
|
||||
* W71 按 `回执三十三 §一` 新增(`u` 加回白名单,六项):`<u>x</u>` 渲染成**下划线**
|
||||
* (`InlineStyle.Underline` → `MessageBubble.inlineBody` 里的 `TextDecoration.Underline`)。
|
||||
*
|
||||
* 加回的原因写在类 KDoc:`回执三十` 那次「收窄到五项」是后端的疏漏,主人裁定下划线保留。
|
||||
*/
|
||||
@Test
|
||||
fun underlineBecomesAnUnderlineRun() {
|
||||
assertEquals(listOf(run("重点", InlineStyle.Underline)), parseInlineMarkup("<u>重点</u>"))
|
||||
// 大小写不敏感、属性同样整段丢掉(与其余五项同一套判据)
|
||||
assertEquals(listOf(run("重点", InlineStyle.Underline)), parseInlineMarkup("<U>重点</U>"))
|
||||
assertEquals(listOf(run("重点", InlineStyle.Underline)), parseInlineMarkup("<u class=x>重点</u>"))
|
||||
// 与别的样式叠加、与别的标签混排
|
||||
assertEquals(
|
||||
listOf(run("加粗又下划线", InlineStyle.Bold, InlineStyle.Underline)),
|
||||
parseInlineMarkup("<b><u>加粗又下划线</u></b>"),
|
||||
)
|
||||
val bubble = parse("<p><b>粗</b>与<u>下划线</u></p>").single()
|
||||
assertEquals("粗与下划线", bubble.body)
|
||||
assertEquals(
|
||||
listOf(run("粗", InlineStyle.Bold), run("与"), run("下划线", InlineStyle.Underline)),
|
||||
bubble.runs,
|
||||
)
|
||||
// 未配成对时仍是「整段降级」那一档,不因为多了一项而放松
|
||||
assertTrue(parseInlineMarkup("<u>没有闭合").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mixedTagsSplitTheTextIntoStyledRuns() {
|
||||
assertEquals(
|
||||
@@ -98,8 +133,8 @@ class InlineMarkupTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* W68 按契约 v0.89 新增(原口径「只认小写」与它冲突,见类 KDoc):五项标签的大小写任意写法都认,
|
||||
* 且改不成别的样式——`<B>` 就是 `<b>`。
|
||||
* W68 按契约 v0.89 新增(原口径「只认小写」与它冲突,见类 KDoc):**六项**标签的大小写任意写法
|
||||
* 都认,且改不成别的样式——`<B>` 就是 `<b>`(`u` 由 W71 按 `回执三十三 §一` 加回)。
|
||||
*/
|
||||
@Test
|
||||
fun tagNamesAreCaseInsensitive() {
|
||||
@@ -109,6 +144,7 @@ class InlineMarkupTest {
|
||||
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(
|
||||
@@ -196,9 +232,10 @@ class InlineMarkupTest {
|
||||
* 它不换行、也不影响旁边的成对标签(`<b>甲<br>乙</b>` 在这里是一对正常的粗体,文本里带着
|
||||
* 字面的 `<br>`)。
|
||||
*
|
||||
* 真实链路上这一层根本看不到 `<br>`:`ChatContentParser.appendRegion` 已经先把它切成气泡边界了,
|
||||
* 所以上面的形状只可能出现在「函数被单独调用」的场景里。气泡切分那一侧的用例在
|
||||
* `ChatContentParserTest`(含三种写法等价、空气泡丢弃、跨分隔符的标签各自回落原文)。
|
||||
* 真实链路上这一层**只有一条路**会看到 `<br>`:`<code>` 作用域内部的那些(`回执三十三 §三②`
|
||||
* 起,分隔符切分层有保护区,`<code>` / `<action>` / `<narration>` 里的 `<br>` 不分隔)。
|
||||
* 其余形状只可能出现在「函数被单独调用」的场景里。气泡切分那一侧的用例在
|
||||
* `ChatContentParserTest`(含三种写法等价、空气泡丢弃、保护区、跨分隔符的标签各自回落原文)。
|
||||
*/
|
||||
@Test
|
||||
fun lineBreakTagIsNotAnInlineTagHere() {
|
||||
@@ -209,6 +246,28 @@ class InlineMarkupTest {
|
||||
assertTrue(parseInlineMarkup("甲<br2>乙</br2>").isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* W71(`回执三十三 §三②`):`<code>` 作用域**内部**的 `<br>` 走到这一层时,就是代码里的几个
|
||||
* 普通字符——**逐字原样**(不换行、也不消掉),与 `<code><b>甲</b></code>` 里的 `<b>` 同一规矩。
|
||||
*
|
||||
* 口径的理由写在 `ChatContentParser.appendRegion` 的 KDoc 里(注记那一侧要显示 `parts` 的逐字
|
||||
* 原文 = 红线,所以同一个形状不能在 `<code>` 里是一种意思、在 `<action>` 里又是另一种;
|
||||
* 「换成 `\n`」还得在这一层破例给 `<br>` 单开一条规则——W68 刚从这一层把它整体删掉)。
|
||||
*/
|
||||
@Test
|
||||
fun lineBreakInsideCodeStaysLiteralContent() {
|
||||
assertEquals(listOf(run("甲<br>乙", InlineStyle.Code)), parseInlineMarkup("<code>甲<br>乙</code>"))
|
||||
// `<code>` 里再套 `<b>`、`<code>` 套在 `<b>` 里:内容一律字面,`<br>` 也是内容的一部分
|
||||
assertEquals(
|
||||
listOf(run("甲<b>乙<br>丙</b>", InlineStyle.Code)),
|
||||
parseInlineMarkup("<code>甲<b>乙<br>丙</b></code>"),
|
||||
)
|
||||
assertEquals(
|
||||
listOf(run("甲", InlineStyle.Bold), run("乙<br>丙", InlineStyle.Bold, InlineStyle.Code), run("丁", InlineStyle.Bold)),
|
||||
parseInlineMarkup("<b>甲<code>乙<br>丙</code>丁</b>"),
|
||||
)
|
||||
}
|
||||
|
||||
/** 而它作为**气泡分隔符**的作用由气泡切分层负责:`<br>` 现在真的分成两条气泡。 */
|
||||
@Test
|
||||
fun lineBreakTagSplitsBubblesAtTheParserLevel() {
|
||||
@@ -312,7 +371,7 @@ class InlineMarkupTest {
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 契约之一:白名单外一律按纯文本原样显示(字符一个不丢)
|
||||
// W69 起 `u` / `s` 也在这一档里(契约收窄到五项)
|
||||
// W71 起 `s` 在这一档里(六项白名单不含它;`u` 曾经短暂撤出、已加回)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@@ -338,12 +397,13 @@ class InlineMarkupTest {
|
||||
* 契约第 1 条点名的形状:`<script>` 之类**白名单外**的标签显示成字面量,字符一个不丢——
|
||||
* 渲染侧是 `AnnotatedString` + `SpanStyle`(结构化),正文从来不经过任何 HTML 解析/注入。
|
||||
*
|
||||
* W69 起 **`u` / `s` 也落在这一档**(`回执三十`:白名单收窄到五项):它们不再是样式,
|
||||
* 写出来就是字面文本。撤掉的正是「会渲染」这件事——Web 端不渲染,我们多渲染就是制造跨端不一致。
|
||||
* **`s` 落在这一档**:白名单 v0.89 最终版是六项(`b`/`strong`/`i`/`em`/`code`/`u`),**`s` 不在内**
|
||||
* ——`回执三十` 撤它、`回执三十三 §一` 加回 `u` 时明确「`s` 仍不在内」,所以它逐字原样。
|
||||
* (`u` 在这一档里只待了 W69 一轮,见 [underlineBecomesAnUnderlineRun] 与类 KDoc。)
|
||||
*
|
||||
* ⚠️ 我们的读法(**待与后端确认**):白名单外的标签**不参与结构判定**——它们按纯文本算,
|
||||
* 所以既不会因为「没闭合」把整段拖成字面(下面第二条),配对与否也不改变别的标签;
|
||||
* 只有白名单那五个名字才构成契约说的「结构」。
|
||||
* 只有白名单那**六个**名字才构成契约说的「结构」。
|
||||
*/
|
||||
@Test
|
||||
fun tagsOutsideTheWhitelistAreShownLiterallyWithoutLosingACharacter() {
|
||||
@@ -366,21 +426,36 @@ class InlineMarkupTest {
|
||||
)
|
||||
}
|
||||
|
||||
/** W69:`<u>` / `<s>` 已按契约收窄撤掉 ⇒ 它们**不再产生任何样式**,只按原文显示。 */
|
||||
/**
|
||||
* W71 按 `回执三十三 §一` 改写:**`s` 仍然不在白名单里**(「`s` 保持逐字原样不变」是原话),
|
||||
* 所以 `<s>` 的**每一种形态**都不产生样式、只按原文显示——这条与 [underlineBecomesAnUnderlineRun]
|
||||
* 是一对:`u` 加回来了,`s` 没有。
|
||||
*
|
||||
* 枚举里也**不该**有 `Strikethrough`:本项目不留「白名单外却有渲染分支」的死代码
|
||||
* (W69 撤 `u`/`s` 时连枚举、渲染分支、import 一起删净,这次只把 `Underline` 加回来)。
|
||||
*/
|
||||
@Test
|
||||
fun uAndSAreNoLongerInTheWhitelistSoTheyRenderAsPlainText() {
|
||||
for (raw in listOf("<u>重点</u>", "<s>改口</s>", "<U>重点</U>", "<S>改口</S>", "<u class=x>重点</u>")) {
|
||||
assertTrue("「$raw」已不在白名单里,不该产出任何样式", parseInlineMarkup(raw).isEmpty())
|
||||
fun sEveryCaseAndShapeIsStillShownLiterally() {
|
||||
for (raw in listOf(
|
||||
"<s>改口</s>",
|
||||
"<S>改口</S>",
|
||||
"<s class=x>改口</s>",
|
||||
"<s>没闭合",
|
||||
"只有闭标签</s>",
|
||||
)) {
|
||||
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)
|
||||
// 与真样式混在一起时同理:白名单那六项起作用,`s` 的字面留在文本里
|
||||
val bubble = parse("<p><b>粗</b>与<s>删除线</s></p>").single()
|
||||
assertEquals("粗与<s>删除线</s>", bubble.body)
|
||||
assertEquals(
|
||||
listOf(run("粗", InlineStyle.Bold), run("与<u>下划线</u>")),
|
||||
listOf(run("粗", InlineStyle.Bold), run("与<s>删除线</s>")),
|
||||
bubble.runs,
|
||||
)
|
||||
assertTrue(InlineStyle.entries.map { it.name }.none { it == "Underline" || it == "Strikethrough" })
|
||||
// `u` 回来了、`s` 没有:枚举里只有前者,绝不留一条走不到的渲染分支
|
||||
assertTrue(InlineStyle.entries.contains(InlineStyle.Underline))
|
||||
assertTrue(InlineStyle.entries.map { it.name }.none { it == "Strikethrough" })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -588,7 +663,10 @@ class InlineMarkupTest {
|
||||
assertEquals("日落 18:35 记住了。", parser.finish().single().body)
|
||||
}
|
||||
|
||||
/** 逐字符 / 任意切分的流式结果必须与一次性解析完全一致(含行内标签与 `<br>` 分隔符)。 */
|
||||
/**
|
||||
* 逐字符 / 任意切分的流式结果必须与一次性解析完全一致(含行内标签、`<br>` 分隔符、
|
||||
* 以及 W71 起新增的两处:`u` 与**保护区**内的 `<br>`)。
|
||||
*/
|
||||
@Test
|
||||
fun streamingAnyChunkingMatchesOneShot() {
|
||||
val sample = """
|
||||
@@ -599,6 +677,8 @@ class InlineMarkupTest {
|
||||
<p>日落 <B>18:35</B></p>
|
||||
<p>上一行<br>下一行</p>
|
||||
<p>甲<br/>乙</br>丙</p>
|
||||
<p><code>甲<br>乙</code>与<u>下划线</u></p>
|
||||
<p>她<action>她<br>点点头</action></p>
|
||||
""".trimIndent()
|
||||
val expected = parse(sample)
|
||||
|
||||
|
||||
Reference in new issue
Block a user