feat(W69): 按契约 v0.89 的 <br> 更正与白名单收窄 —— <br> 改做气泡分隔符(与 <p> 同级)、撤掉 u/s
后端 `回执三十` / `回执三十一` 把 v0.89 两处更正了,本端照改。
一、`<br>` 不再是行内标签,而是与 `<p>` 同级的**气泡/消息分隔符**
(`回执三十一` 原话:「对消息内的 `<br>` 始终处理为多条消息隔断」)
1. 行内层(`InlineMarkup.kt`)整体移出:词表少一项、`InlineTag.Break` 与「`<br>` → `\n`」
那套解析/裁剪逻辑全删;白名单只剩五项 `b`/`strong`/`i`/`em`/`code`。
2. 改在气泡切分层:新增 `appendRegion`(`ChatContentParser.kt`),把每个文本区按 `BR_TAG_RE`
切开、段与段之间补 `Tok.Break`(既有 `<p>` 同款机制)。`<p>甲<br>乙</p>` / `甲<br>乙`
都是两条气泡;`<p>甲<br><br>乙</p>` 的中间空气泡照旧丢弃;`<br>` 两侧是空白不产多余气泡;
`<br>` / `<br/>` / `</br>`(含 `<br />` / `</br/>` / `<br class=x>` / 大写)等价。
3. 层序采用「**先按 `<p>`/`<br>` 切分,再对每一段各自做行内解析**」:`<b>甲<br>乙</b>` 两段各自
不平衡 ⇒ 两段都回落原文。理由写进 `appendRegion` 的 KDoc(「同级」本身就规定了层序;
一个气泡 = 一条消息,跨分隔符的标签在结构上不可能成立,后端剥标签同样是按单条消息判平衡;
实现上行内层逐气泡跑一次,一个字节都不用改)。
4. 流式侧跟上:`<br` / `<BR` / `</br` / `<br/` / `<br class=x` 这些残缺形态由
`couldBeLineBreakNamePrefix` 扣留(与 `BR_TAG_RE` 同口径);完整的 `<br>` 不入栈、直接放行。
不扣的话中间态会先把字面 `<br` 吐上屏、收口时再变成两条气泡——屏幕上的字会改口。
二、白名单**收窄到五项**(`回执三十`):`u` / `s` 从 `InlineStyle`、解析、渲染、测试里全撤
(`Underline` / `Strikethrough` 与 `MessageBubble` 的 `TextDecoration` 一并删净,不留死代码)。
现在 `<u>甲</u>` 走「白名单外 ⇒ 原样显示」,与 Web 端一致——多支持反而是制造跨端不一致。
其余口径一律不动:大小写不敏感、属性整段丢弃、不平衡整段降级为原文(粒度 = 该气泡正文)、
`<code>` 内不解析、白名单外字符一个不丢、`parts` 逐字原文、`runs` 为空时 UI 走原路。
`tokenize` 的 `<p>` 块切分 / `stripLooseTags` / `ContentAfterQuery` / `P_BLOCK_RE` / `ROLE_RE` /
星号语义 / `MessageEditText`(`<br>` 不进容差,只含 `<br>` 的消息仍是编辑框恒等路径)行为未改。
渲染仍是 `AnnotatedString` + `SpanStyle`,无新依赖、无 HTML 注入。
验收:`assembleDebug` 通过;`testDebugUnitTest` 全量 126 suites / 1564 tests / 0 failures / 0 errors。
`ChatContentParserTest` 新增 14 条(`<br>` 四情形逐个断言气泡数与文本、三种写法等价、大小写、
像标签但不是的 `<br2>`/`<boolean>`、跨分隔符标签两段各自回落、`parts` 不含结构、旁白跨分隔符依附、
流式完整/残缺两态、`<code>`/`<action>` 内也分隔、行内解析不看 `<br>`、复杂度预算);
`InlineMarkupTest` 删掉 6 条与 v0.89 冲突的(`<br>` 换行 4 条 + `u`/`s` 渲染 + `<b>甲<br>乙</b>` 单气泡
一条),新增 3 条(`u`/`s` 撤掉后走字面、`<br>` 在本层只是普通字符、`<br>` 在解析层真分成两条气泡)。
This commit is contained in:
1 parent
4b19278733
commit
9dee236778
6 files changed
+460
-227
No files matched your search
@@ -47,9 +47,14 @@ data class EditableMessage(
|
||||
* 判成标记就会顺手把它们当标签吃掉——那才是真正的静默丢东西。大小写同样照解析器来:
|
||||
* `parse` 只认小写的 `<p>` / `<action>` / `<narration>`,所以 `<P>` 在这里也是普通文本。
|
||||
*
|
||||
* **行内标签(`<b>` / `<code>` / `<br>` …)故意不在**这份容差里:它们是渲染用的标记,但编辑框
|
||||
* **行内标签(`<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,18 +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`,契约 v0.89):`<b>`/`<strong>`、`<i>`/`<em>`、`<code>`、`<u>`、
|
||||
* `<s>`、`<br>`(`<br>`/`<br/>`/`</br>` 等价)六类,成对才认、**标签名大小写不敏感**、
|
||||
* - 行内标签(`InlineMarkup.kt`,契约 v0.89):`<b>`/`<strong>`、`<i>`/`<em>`、`<code>` 三类
|
||||
* (五项标签名,`u`/`s` 已按 `回执三十` 收窄撤掉),成对才认、**标签名大小写不敏感**、
|
||||
* **属性一律不解析**(`<b class=x>` 就是粗体)、白名单外按纯文本原样显示、
|
||||
* **结构不平衡(未闭合 / 落单闭标签 / 交错)⇒ 该气泡的正文整段降级为原文**
|
||||
* (口径与理由见 `parseInlineMarkup`)。
|
||||
* 它在**段落定稿之后**才做([toBubble] 里对最终正文做一次),所以既不参与、也改不动上面的
|
||||
* 分段与归属:`<br>` 只换行、**不分气泡**。渲染文本落 `body` + `runs`,而片段 `parts` 仍是
|
||||
* 它在**段落与 `<br>` 都定稿之后**才做([toBubble] 里对最终正文做一次,**逐气泡各自一份**),
|
||||
* 所以既不参与、也改不动上面的分段与归属。渲染文本落 `body` + `runs`,而片段 `parts` 仍是
|
||||
* 逐字原文——编辑框、搜索片段、通知文案读的都是 `parts`,那些地方一个字都不许改。
|
||||
* - 落单标签容错:服务端确实会产出不配对的段落标签(实测形态 `<p><p>正文</p>`:多一个 `<p>`、
|
||||
* 只有一个 `</p>`),块外也可能出现孤立的旁白标签。这类标签一律**剥掉**,绝不落进正文——
|
||||
@@ -44,7 +49,7 @@ import kotlin.math.min
|
||||
*
|
||||
* `ParsedBubble` 结构(每条气泡渲染一个):
|
||||
* - `body` :气泡正文的**渲染文本**(仅 Text 部分按原顺序拼接后 trim,再消费掉行内标签:
|
||||
* `<br>` 已变成换行、白名单标签字符已去掉,白名单外与属性照旧逐字留着),已剔除全部旁白/动作块;
|
||||
* 白名单标签字符已去掉,白名单外与属性照旧逐字留着),已剔除全部旁白/动作块;
|
||||
* 整段只有标记没有字、或行内标签**结构不平衡**时回落/降级为字面原文;
|
||||
* - `runs` :`body` 的行内样式切分(非空时拼起来就是 `body`;空列表 = 按字面画);
|
||||
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
|
||||
@@ -62,8 +67,10 @@ import kotlin.math.min
|
||||
* 中间态会把 `<b>` 当字面吐上屏(`日落 <b>18`)、等 `</b>` 到了又被吃掉,屏幕上已经显示过的
|
||||
* 两个字就改口了。**大小写与属性也走同一份判据**(v0.89):`<B class=x>` 在扣留侧同样是
|
||||
* 开标签,否则 `日落 <B class=x>18` 会先按字面吐上屏、收口时再变成粗体。
|
||||
* `<br>` 是自闭合的,**不入栈**(入了就永远弹不出来),它只在残缺(`<br`、`<br/`)时按词表扣留。
|
||||
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`、`<B`),仅当它可能继续补全为合法标签时才扣留;
|
||||
* `<br>` 是分隔符、不成对,**不入栈**(入了就永远弹不出来);它只在残缺(`<br`、`<br/`、
|
||||
* `</br`)时按词表扣留([isPotentialTagPrefix] → [couldBeTagName]),完整的 `<br>` 直接放行
|
||||
* ——分隔符一出现,前缀就已经可解析了。
|
||||
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`、`<B`、`<br`),仅当它可能继续补全为合法标签时才扣留;
|
||||
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
|
||||
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
|
||||
* - 落单标签的剥离**不需要**额外的扣留规则,这是「剥标签与扣留共用同一组标签正则」的直接结果:
|
||||
@@ -100,8 +107,8 @@ data class ParsedBubble(
|
||||
val head: List<ContentPart>,
|
||||
val tail: List<ContentPart>,
|
||||
/**
|
||||
* `body` 的行内样式切分(`<b>` / `<i>` / `<code>` / `<u>` / `<s>`,见 [parseInlineMarkup]):
|
||||
* 非空时拼起来**就是** [body]。空列表 = 这段正文按字面画——**绝大多数消息都走这一档**(没有标签、
|
||||
* `body` 的行内样式切分(`<b>` / `<i>` / `<code>`,见 [parseInlineMarkup]):非空时拼起来
|
||||
* **就是** [body]。空列表 = 这段正文按字面画——**绝大多数消息都走这一档**(没有标签、
|
||||
* 或者结构不平衡被整段降级、或者整段只有标记没有字)。
|
||||
*
|
||||
* 有默认值,是因为只有 [toBubble] 才该填它:其余构造点要么本来就该按字面画(`plainBubble`、
|
||||
@@ -142,6 +149,9 @@ private const val FULL_STAR_MAX_LEN = 40
|
||||
/**
|
||||
* 段落/旁白标签名(**只有小写**,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
|
||||
* 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面那组同词表的正则,两边必须同源。
|
||||
*
|
||||
* `<br>` 不在这个名单里:它是分隔符、且**大小写不敏感**(见 [BR_TAG_RE]),判据单独走
|
||||
* [couldBeLineBreakNamePrefix],否则 `<BR` 会被漏扣、中间态把 `<BR` 当正文吐上屏。
|
||||
*/
|
||||
private val STRUCTURE_TAG_NAMES = listOf("action", "narration", "p")
|
||||
private val OPEN_ROLE_TAG_RE = Regex("^<\\s*(action|narration)\\s*>$")
|
||||
@@ -149,6 +159,25 @@ 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()
|
||||
@@ -196,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 个落单标签时
|
||||
@@ -215,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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 剥掉「落单标签」,其余字符逐字保留(返回原对象:没剥到东西时不做新分配)。
|
||||
*
|
||||
@@ -507,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 }
|
||||
@@ -583,7 +654,8 @@ private fun closeTagName(tag: String): String? {
|
||||
*
|
||||
* 词表分两半,每一半都与**自己的消费侧**同源,两条口径都在这里逐字对齐:
|
||||
* - 段落/旁白只认小写(`<P>` / `<Action>` 原样显示,既有契约)——名字里出现大写就与它无关;
|
||||
* - 行内标签**大小写不敏感**(契约 v0.89,[parseInlineTag])——`<B` 与 `<b` 一样要扣留。
|
||||
* - 行内标签与 `<br>` 分隔符**大小写不敏感**(契约 v0.89,[parseInlineTag] / [BR_TAG_RE])
|
||||
* ——`<B` 与 `<b`、`<BR` 与 `<br` 一样要扣留。
|
||||
* 少扣了就是「中间态把标签当正文吐上屏、收口时才吃掉」,屏幕上显示过的字会改口。
|
||||
* 名字之后是空白或 `/` 都算「还可能补全」(属性 / 自闭合,v0.89 允许带属性,所以 `<b class=x>`
|
||||
* 与 `<br/` 这两个中间态也必须扣住);其它字符说明它压根不是标签(`<boolean>`、`<b2`)。
|
||||
@@ -601,12 +673,25 @@ private fun isPotentialTagPrefix(buf: String, lt: Int): Boolean {
|
||||
return buf[j].isWhitespace() || buf[j] == '/'
|
||||
}
|
||||
|
||||
/** [name] 是否可能是某个已知标签名的前缀;两半各按自己的大小写口径(见 [isPotentialTagPrefix])。 */
|
||||
/**
|
||||
* [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
|
||||
return couldBeInlineTagNamePrefix(name.lowercase())
|
||||
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,20 +3,30 @@ package top.yeij.cyrene.domain.parse
|
||||
/*
|
||||
* InlineMarkup —— 气泡正文里「一小撮行内标签」的解析(纯 Kotlin,无 Android / Compose 依赖)。
|
||||
*
|
||||
* 口径 = 后端 2026-10-07 傍晚定下的**契约 v0.89**(跨端必须逐字一致,否则同一个 `<b>` 在她那台
|
||||
* 设备上粗、在这台上不粗)。四条硬口径:
|
||||
* 1. 白名单 = `b` / `strong` · `i` / `em` · `code` · `u` · `s`(成对)· `br`(void,
|
||||
* `<br>` / `<br/>` / `</br>` 按 HTML5 口径等价);标签名**大小写不敏感**;
|
||||
* 口径 = 后端 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` → 斜体,`u` → 下划线,`s` → 删除线,
|
||||
* `code` → 等宽 + 轻微底色(且**里面的标签不再解析**),`br`/`<br/>`/`</br>` → 一个换行符。
|
||||
* 样式映射:`b`/`strong` → 粗体,`i`/`em` → 斜体,`code` → 等宽 + 轻微底色(且**里面的标签不解析**)。
|
||||
*
|
||||
* ## `br` 不在这里(契约同日更正)
|
||||
* `回执三十一`:「对消息内的 `<br>` **始终处理为多条消息隔断**」⇒ `<br>` **不是行内标签**,而是与
|
||||
* `<p>` 同级的**气泡/消息分隔符**,切分在 [tokenize](ChatContentParser.kt) 的段/块那一层
|
||||
* (`ChatContentParser.appendRegion`)完成,`<br>` / `<br/>` / `</br>` 三种写法等价。
|
||||
* 这一层因此**只认成对标签**:`<br>` 若出现在这里的文本里(真实链路上不会——它已经被切走了),
|
||||
* 它只是几个普通字符,与 `<br2>`、`<boolean>` 同类。
|
||||
*
|
||||
* ## `u` / `s` 已撤掉(契约收窄)
|
||||
* `回执三十`:白名单**收窄到五项**,去掉 `u`/`s`(它们在她 100 条输出里 0 命中,不值得让各端返工)。
|
||||
* 多支持反而是**制造**跨端不一致(Web 不渲染、我们渲染 ⇒ 同一条消息两端显示不同),所以这里连同
|
||||
* 样式、渲染、测试一起撤干净:`<u>甲</u>` 现在走「白名单外 ⇒ 原样显示」,显示成字面的 `<u>甲</u>`。
|
||||
*
|
||||
* ## 为什么是「整段降级」而不是「只降级那一处」
|
||||
* W67 那版是**局部**降级:`甲<b>粗</b>乙<b>没闭合` 里 `粗` 照旧加粗,只有后面那半个 `<b>` 留着字面。
|
||||
* 契约 v0.89 把它换成了**整段**降级:一处不平衡,这一整段就一个标签都不认、原文逐字显示。
|
||||
* 契约把「结构不平衡」定成**整段不渲染**:一处不平衡,这一整段就一个标签都不认、原文逐字显示。
|
||||
* 代价是「好好的一对 `<b>` 会被邻居的落单标签连累」,换来的是**可以逐字断言**的粗粒度:
|
||||
* 「渲染文本 == 原文」或者「渲染文本 == 原文消费掉全部配对标签」,只有这两种形状,没有中间态
|
||||
* (局部降级那种「一半认、一半留」在跨端对齐时最容易各说各话)。见 [parseInlineMarkup]。
|
||||
@@ -25,14 +35,16 @@ package top.yeij.cyrene.domain.parse
|
||||
* **整段 = 该气泡的正文**,也就是 [toBubble](ChatContentParser.kt) 里 `body.toString().trim()`
|
||||
* 出来的那一段(`ParsedBubble.body` 对应的文本,通常正好是一个 `<p>…</p>` 块的正文,也可能是
|
||||
* 块外文本并入相邻气泡后的那一份)。不是整条消息,也不是单个 `<p>` 块:
|
||||
* - **不是整条消息**:一条消息可以有多个 `<p>` 块(= 多条气泡),一个气泡里写坏了一个标签,
|
||||
* 不该把**别的**气泡一起按字面画出来——那既不是契约想说的「这一段」,也会让「她写坏了一个标签」
|
||||
* 这种局部事故看起来像整条消息都坏了。
|
||||
* - **不是整条消息**:一条消息可以有多个分隔(`<p>` / `<br>`)= 多条气泡,一个气泡里写坏了一个
|
||||
* 标签,不该把**别的**气泡一起按字面画出来——那既不是契约想说的「这一段」,也会让「她写坏了
|
||||
* 一个标签」这种局部事故看起来像整条消息都坏了。
|
||||
* - **不是单个 `<p>` 块**(虽然大多数时候两者重合):本端渲染的**最小单位就是气泡**,
|
||||
* `body` / `runs` 也都是按气泡算的。降级粒度与渲染粒度取同一个,实现上才有一条清晰的边界
|
||||
* (`toBubble` 一次调用 = 一次判定),断言也才写得出「这条气泡是原文、那条气泡照常渲染」。
|
||||
* - 若后端回的粒度是别的(例如整个 `<p>` 块或整条消息),改的只是 [parseInlineMarkup] 的
|
||||
* 调用点(把判定提到 `tokenize` 之前),解析口径本身一个字都不用动。
|
||||
* - 后端已认可这一点(`回执三十`:「契约只承诺『不平衡 ⇒ 不渲染、字符一个不丢』,**具体粒度算
|
||||
* 实现细节**」)。
|
||||
* - 因为切分(`<p>` / `<br>`)**先于**行内解析,跨分隔符的标签天然分属两段:
|
||||
* `<b>甲<br>乙</b>` ⇒ 两段各不平衡 ⇒ 两段都回落原文。
|
||||
*
|
||||
* ## 为什么不改 `parts`,只在 `ParsedBubble` 上加一个字段
|
||||
* `ContentPart.Text` 的 `text` 必须保持**逐字原文**:编辑框(`MessageEditText.visibleTextOf`)、
|
||||
@@ -42,20 +54,19 @@ package top.yeij.cyrene.domain.parse
|
||||
* 而 `parts` 仍是原文——两侧各拿自己该拿的那一份。
|
||||
*
|
||||
* ## 与段落解析的分工(为什么不在 `tokenize` 里做)
|
||||
* 行内解析发生在**段落边界已经定稿之后**(`toBubble` 里,对最终 `body` 做一次):`<p>` 分气泡、
|
||||
* 空行回落、旁白归属、落单标签剥离全部照旧,行内标签既不参与、也改不动它们。`<br>` 因此**绝不会**
|
||||
* 造出新的气泡边界——分气泡这件事只认 `<p>` 与空行。这也让 W63 在 `tokenize` 里去掉的两处 O(N²)
|
||||
* 一行都不受影响:这里是对最终正文的一趟线性扫描。
|
||||
* 行内解析发生在**段落/分隔符边界已经定稿之后**(`toBubble` 里,对最终 `body` 做一次):`<p>` 与
|
||||
* `<br>` 分气泡、空行回落、旁白归属、落单标签剥离全部照旧,行内标签既不参与、也改不动它们。
|
||||
* 这也让 W63 在 `tokenize` 里去掉的两处 O(N²) 一行都不受影响:这里是对最终正文的一趟线性扫描。
|
||||
*
|
||||
* ## 大小写不敏感(v0.89)与「扣留侧必须一起改」
|
||||
* `<B>` / `<STRONG>` / `<Br>` 都认——但**消费侧与流式扣留侧([ChatContentParser.stablePrefixEnd])
|
||||
* `<B>` / `<STRONG>` 都认——但**消费侧与流式扣留侧([ChatContentParser.stablePrefixEnd])
|
||||
* 必须同源**,否则两边分叉:`<B` 这种残缺形态若只在消费侧认,中间态就会把它当正文吐上屏、等 `</B>`
|
||||
* 到了再吃掉,**屏幕上已经显示过的字就改口了**。所以标签名的字母判据([isTagNameLetter])与
|
||||
* 「这个名字还可能补全吗」([ChatContentParser.isPotentialTagPrefix] 里的
|
||||
* [couldBeInlineTagNamePrefix])都收在**这一份文件**里,两边引用同一份名单。
|
||||
* 「这个名字还可能补全吗」([couldBeInlineTagNamePrefix],以及 `ChatContentParser.isPotentialTagPrefix`
|
||||
* 里的 `couldBeTagName`)都收在**这一份文件**里,两边引用同一份名单。
|
||||
* 段落/旁白标签(`<p>` / `<action>` / `<narration>`)仍然**只认小写**:这是既有契约
|
||||
* (`plainAngleBracketsAreNeverEaten` 钉着 `<P>` 原样、`MessageEditText` 同一口径),
|
||||
* 且契约 v0.89 明确「`<action>`/`<narration>` 的既有行为逐字未改」。
|
||||
* 且契约明确「`<action>`/`<narration>` 的既有行为逐字未改」。
|
||||
*
|
||||
* ## 明确不做(逐条理由)
|
||||
* 1. **块级(`<p>` 之外的 `<div>` / `<h1>` / 列表…)不做**:契约写死「段落仍走 `<p>`」。
|
||||
@@ -80,17 +91,12 @@ package top.yeij.cyrene.domain.parse
|
||||
/**
|
||||
* 一段行内样式,可叠加(`<b><i>粗斜</i></b>` 同时是粗与斜)。
|
||||
*
|
||||
* 只有这五种:换行不是样式,它是**字符**(`<br>` → `\n`),落在片段的文本里。
|
||||
* 只有这三种:换行不是样式——`<br>` 现在连「行内标签」都不是,它是气泡分隔符
|
||||
* (切在 `ChatContentParser.tokenize` 那一层),根本走不到这里。
|
||||
*/
|
||||
enum class InlineStyle {
|
||||
Bold,
|
||||
Italic,
|
||||
|
||||
/** `<u>`:下划线(渲染成 `TextDecoration.Underline`)。 */
|
||||
Underline,
|
||||
|
||||
/** `<s>`:删除线(渲染成 `TextDecoration.LineThrough`)。 */
|
||||
Strikethrough,
|
||||
Code,
|
||||
}
|
||||
|
||||
@@ -106,24 +112,22 @@ data class InlineRun(val text: String, val styles: Set<InlineStyle>)
|
||||
/** `<code>` 的标签名(解析里要单独判它:里面的标签不再解析)。 */
|
||||
private const val CODE = "code"
|
||||
|
||||
/** 自闭合的换行标签名(不成对,所以它不在 [PAIRED_TAG_NAMES] 里)。 */
|
||||
private const val BR = "br"
|
||||
|
||||
/**
|
||||
* 成对标签的名字。`strong` / `em` 是 `b` / `i` 的同义写法,`u` / `s` 是 v0.89 新增的两档。
|
||||
* 成对标签的名字(契约收窄后的**五项**):`strong` / `em` 是 `b` / `i` 的同义写法。
|
||||
* 只写这一处,[parseInlineTag] 与 [couldBeInlineTagNamePrefix] 都由它推出来。
|
||||
* `br` 不在这里(它是分隔符,见文件头);`u` / `s` 已按契约收窄撤掉。
|
||||
*/
|
||||
private val PAIRED_TAG_NAMES = listOf("b", "strong", "i", "em", CODE, "u", "s")
|
||||
private val PAIRED_TAG_NAMES = listOf("b", "strong", "i", "em", CODE)
|
||||
|
||||
/**
|
||||
* 行内标签词表。**流式扣留侧(`ChatContentParser.stablePrefixEnd`)与这里必须同源**:
|
||||
* 同一份名单既判「这是个行内标签」(消费侧),也判「这个标签名会不会继续补全」(扣留侧)。
|
||||
* 两边一分叉,中间态就会把标签当正文吐上屏、收口时才吃掉——屏幕上已经显示过的那几个字就改口了。
|
||||
*/
|
||||
internal val INLINE_TAG_NAMES = PAIRED_TAG_NAMES + BR
|
||||
internal val INLINE_TAG_NAMES = PAIRED_TAG_NAMES
|
||||
|
||||
/**
|
||||
* 白名单行内标签认下来的三种形态。**认不出来的(白名单外、形状不对)一律返回 null**,
|
||||
* 白名单行内标签认下来的两种形态。**认不出来的(白名单外、形状不对)一律返回 null**,
|
||||
* 由调用方当普通文本原样显示(契约第 3 条:字符一个不丢)。
|
||||
*/
|
||||
internal sealed interface InlineTag {
|
||||
@@ -134,14 +138,9 @@ internal sealed interface InlineTag {
|
||||
|
||||
/** 成对的闭标签(`</b>`、`</EM>`…)。 */
|
||||
data class Close(override val name: String) : InlineTag
|
||||
|
||||
/** void 换行:`<br>` / `<br/>` / `</br>` 三者等价(HTML5 口径),不成对、不入栈。 */
|
||||
object Break : InlineTag {
|
||||
override val name: String = BR
|
||||
}
|
||||
}
|
||||
|
||||
/** 标签名里允许出现的字母:ASCII 大小写,两边(消费侧 / 扣留侧)共用这一把尺子。 */
|
||||
/** 标签名里允许出现的字母:ASCII 大小写,消费侧与扣留侧共用这一把尺子。 */
|
||||
internal fun isTagNameLetter(c: Char): Boolean = c in 'a'..'z' || c in 'A'..'Z'
|
||||
|
||||
/**
|
||||
@@ -154,6 +153,9 @@ internal fun isTagNameLetter(c: Char): Boolean = c in 'a'..'z' || c in 'A'..'Z'
|
||||
* (否则 `b` 会把一大票以 b 开头的普通单词吃成粗体),这是「字符一个不丢」的第一道闸门;
|
||||
* - 再往后到 `>` 之间的一切(属性、`/`、空白)**整段丢掉**:契约第 2 条,只取标签名。
|
||||
* 丢掉的只是白名单标签自己那对尖括号里的东西,正文一个字符都不动。
|
||||
*
|
||||
* `<br>` 在这里返回 null(它不再是行内标签,是气泡分隔符)——所以它落到「白名单外」那条路:
|
||||
* 万一真被送到这一层(真实链路上不会,[appendRegion] 已经把它切走了),它只是几个普通字符。
|
||||
*/
|
||||
internal fun parseInlineTag(tag: String): InlineTag? {
|
||||
val end = tag.length - 1
|
||||
@@ -167,12 +169,11 @@ internal fun parseInlineTag(tag: String): InlineTag? {
|
||||
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>` 是自闭合的,**不入栈**,所以它不在这里。 */
|
||||
/** 开标签名(不是行内开标签则 null)。 */
|
||||
internal fun openInlineTagName(tag: String): String? =
|
||||
(parseInlineTag(tag) as? InlineTag.Open)?.name
|
||||
|
||||
@@ -190,8 +191,6 @@ internal fun couldBeInlineTagNamePrefix(name: String): Boolean =
|
||||
/** 标签名 → 样式;`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
|
||||
}
|
||||
@@ -203,10 +202,10 @@ internal fun inlineStyleOf(name: String): InlineStyle = when (name) {
|
||||
* 1. **返回空列表 = 这段文本按字面渲染**(正文一个字符都不动)。三种情形都落在这里:
|
||||
* 没有可渲染的行内标签、**结构不平衡**(契约第 4 条,整段降级)、以及整段只有标记没有字。
|
||||
* 这是绝大多数消息的路径(实测 100 条里只有 2 条带 `<b>`),也是 `plainBubble` 那条路。
|
||||
* 2. 返回非空 ⇒ 片段拼起来**就是**渲染文本:白名单标签已被消费(`<br>` 变成 `\n`),且两端空白
|
||||
* 已去(见下)。**除此之外逐字等于原文**——白名单外的标签(`<script>`)、属性、`<code>` 里的
|
||||
* 内容全都原样在片段里。
|
||||
* 3. **绝不把一段非空正文渲染成空**:整段只有标记没有字(`<b></b>` / `<br>`)时回落成第 1 条,
|
||||
* 2. 返回非空 ⇒ 片段拼起来**就是**渲染文本:白名单标签已被消费,且两端空白已去(见下)。
|
||||
* **除此之外逐字等于原文**——白名单外的标签(`<script>`)、属性、`<code>` 里的内容全都原样在
|
||||
* 片段里。
|
||||
* 3. **绝不把一段非空正文渲染成空**:整段只有标记没有字(`<b></b>`)时回落成第 1 条,
|
||||
* 把标记原样显示——宁可让她的话里多几个尖括号,也不能给出一条空白气泡。
|
||||
*
|
||||
* ## 口径一:配成对且正确嵌套,否则**整段**降级为原文(契约第 4 条)
|
||||
@@ -216,10 +215,11 @@ internal fun inlineStyleOf(name: String): InlineStyle = when (name) {
|
||||
* 这一整段**一个标签都不认**、原文逐字显示。
|
||||
* - 代价与收益都写清:`甲<b>粗</b>乙<b>没闭合` 里的 `粗` 也不再加粗(粗粒度),
|
||||
* 换来的是渲染文本与原文之间只有两种关系(相等 / 消费掉全部配对标签),跨端可以逐字对齐。
|
||||
* - **`<br>` 也一起被放弃**:整段降级是「原文逐字显示」,所以 `甲<b>没闭合<br>乙` 里的 `<br>`
|
||||
* 不换行、原样显示。这是契约措辞的直译,不额外发明「换行照旧生效」的例外。
|
||||
* - 判据只有这一条,不发明「宽容关闭」(`</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>`
|
||||
@@ -227,24 +227,19 @@ internal fun inlineStyleOf(name: String): InlineStyle = when (name) {
|
||||
* - 第 3 条管的是**白名单外的标签名**(`<script>`、`<div>`)——它们从来不是「标签」;
|
||||
* - `<code>` 是一条**作用域**规则:在白名单标签 `<code>` 内部,连白名单标签也按内容处理。
|
||||
* `code` 表达的是「这段是代码」,代码里的 `<b>` 就是四个字符——这是 W67 就定下的口径,
|
||||
* 契约 v0.89 没有就「`<code>` 内部」单独措辞,我们**保留原行为**(换 HTML 那种「元素照常
|
||||
* 嵌套解析」的语义,改动只是删掉 [claimTags] 里的跳过分支,等后端确权再动);
|
||||
* 契约没有就「`<code>` 内部」单独措辞,我们**保留原行为**(换 HTML 那种「元素照常嵌套解析」的
|
||||
* 语义,改动只是删掉 [claimTags] 里的跳过分支,等后端确权再动);
|
||||
* - 两者的结果都落在同一条硬口径上:**字符一个不丢**。`<code>` 也正好是「想原样显示尖括号
|
||||
* 怎么办」的现成答案。
|
||||
* 内部的东西**不参与结构判定**:`<code><b>甲</code>` 里的 `<b>` 不算未闭合(它只是内容),
|
||||
* 所以不会因为它把整段拖成字面——判定与显示用的都是同一套「里面不算标签」的口径。
|
||||
* 代价是代码里的 `<b>` 不生效(实测 0 命中,也不存在「代码里也要粗体」这个需求)。
|
||||
*
|
||||
* ## 口径三:`<br>` 与既有空行分段的关系
|
||||
* `<br>` 就是**一个换行符**,不与相邻的 `\n` 去重:`甲<br><br>乙` 是「甲、空一行、乙」,
|
||||
* `甲<br>\n乙` 也是空一行——这是 markup 的直译(去掉哪一个都算我们替她改话)。它**不分气泡**:
|
||||
* 分气泡的判据(`<p>`、空行 `\n\s*\n`)在行内解析**之前**就已经算完了,所以这里造的换行不会
|
||||
* 变成新的气泡边界,也不会与空行分段叠加出「双倍」的东西。
|
||||
*
|
||||
* ## 为什么两端空白要去掉
|
||||
* `<br>` 落在开头/结尾会凭空多出一行空白(Compose 里末尾换行就是一行空行),而正文两端的空白本来
|
||||
* 就被去掉了(`toBubble` 里那句 `body.trim()`)。这一步只吃空白——一个可见字符都不会掉。
|
||||
* 注意降级路径给到调用方的是**已经 trim 过的原文**(trim 在行内解析之前,不是这里吃的)。
|
||||
* 被标签让出来的空白(`<b> 甲 </b>` 里那两个字)留着的唯一效果是「气泡里看起来多了两个空格」,
|
||||
* 而正文两端的空白本来就被去掉了(`toBubble` 里那句 `body.trim()`)。这一步只吃空白——一个可见
|
||||
* 字符都不会掉;它同时兜住契约第 3 条:整段只有空白(`<b> </b>`)时回落字面,绝不给出一条
|
||||
* 看起来空白的怪气泡。
|
||||
*
|
||||
* 复杂度 O(n):两趟线性扫描(每个 `<` 只看一扇「到 `>` 或下一个 `<` 为止」的窗,绝不
|
||||
* `indexOf('>')` 一路找配对);正文里没有 `<` 时连集合都不建,一次就返回。
|
||||
@@ -254,22 +249,21 @@ fun parseInlineMarkup(text: String): List<InlineRun> {
|
||||
// 结构不平衡 ⇒ 整段降级为原文(消费侧返回空列表 = 调用方按字面渲染)。
|
||||
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 = 结构不平衡**(未闭合 / 落单闭标签 / 交错),调用方据此整段降级为原文。
|
||||
* 因为不平衡时整段都不认,所以有返回值时**每一个**白名单成对标签都是配好的(闭标签只关得掉栈顶
|
||||
@@ -298,8 +292,8 @@ private fun claimTags(text: String): ClaimedTags? {
|
||||
val tag = text.substring(lt, gt + 1)
|
||||
val parsed = parseInlineTag(tag)
|
||||
if (open.lastOrNull()?.first == CODE) {
|
||||
// `<code>` 里的一切都是它的内容:`<b>` 是四个字符、`<br>` 是四个字符,也都不算结构
|
||||
// (所以里面那个「未闭合的 `<b>`」不会把整段拖成字面)。只有闭合它的 `</code>` 例外。
|
||||
// `<code>` 里的一切都是它的内容:`<b>` 是四个字符,也都不算结构(所以里面那个「未闭合的
|
||||
// `<b>`」不会把整段拖成字面)。只有闭合它的 `</code>` 例外。
|
||||
if (parsed == InlineTag.Close(CODE)) {
|
||||
claimed.openStarts.add(open.removeAt(open.lastIndex).second)
|
||||
claimed.closeStarts.add(lt)
|
||||
@@ -307,7 +301,6 @@ private fun claimTags(text: String): ClaimedTags? {
|
||||
} else {
|
||||
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
|
||||
@@ -329,7 +322,7 @@ private fun claimTags(text: String): ClaimedTags? {
|
||||
/**
|
||||
* [lt] 处这个 `<` 起头的**行内**标签在哪结束(不是行内标签则 -1)。
|
||||
*
|
||||
* 一路走到 `>` 或**下一个 `<`** 为止:契约 v0.89 要求「属性一律不解析」,标签多长因此不再有上界
|
||||
* 一路走到 `>` 或**下一个 `<`** 为止:契约要求「属性一律不解析」,标签多长因此不再有上界
|
||||
* (`<b class="…" onclick="…">` 可以很长),W67 那版「只看一扇定宽窗」的做法行不通了。
|
||||
* 仍然刻意**不用** `indexOf('>', lt)` 一路找过去:正文里 N 个落单的 `<` 时那是 O(N²)
|
||||
* (W63 在同一片区域刚踩过这个坑),而且它还会把中间真正的标签一起跳过。
|
||||
@@ -348,8 +341,8 @@ private fun inlineTagEnd(text: String, lt: Int): Int {
|
||||
}
|
||||
|
||||
/**
|
||||
* 第二趟:把文本切成片段——被认领的标签消费掉(开/闭标签不入字、`<br>` 变成 `\n`),其余**逐字**
|
||||
* 进片段(白名单外的标签、`<code>` 里的内容、属性没有被认领时的整段标记,都在这里原样吐回)。
|
||||
* 第二趟:把文本切成片段——被认领的标签消费掉(开/闭标签不入字),其余**逐字**进片段
|
||||
* (白名单外的标签、`<code>` 里的内容、没有被认领的整段标记,都在这里原样吐回)。
|
||||
* 因为第一趟只收严格嵌套的配对,这里的栈与它逐字对应,不会弹错人。
|
||||
*/
|
||||
private fun runsOf(text: String, claimed: ClaimedTags): List<InlineRun> {
|
||||
@@ -402,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
|
||||
|
||||
@@ -33,7 +33,6 @@ 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
|
||||
@@ -790,16 +789,14 @@ 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>` 的片段与原来逐像素相同。
|
||||
*
|
||||
* `u` / `s` 落到 `textDecoration`:两者叠起来(`<u><s>甲</s></u>`)时按 `TextDecoration` 自己的
|
||||
* 合并写法给出下划线 + 删除线两条线,而不是二选一——那是标记的直译。
|
||||
* 样式只给**有**的那几项(`null` = 继承气泡自己的字重/字体),所以非 `<code>` 的片段与原来逐像素相同。
|
||||
*/
|
||||
private fun inlineBody(
|
||||
runs: List<InlineRun>,
|
||||
@@ -808,18 +805,10 @@ 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,
|
||||
),
|
||||
|
||||
@@ -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,33 +10,35 @@ 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` 逐字原文、星号语义、流式一致性、复杂度)
|
||||
* ## 口径沿革(两轮,都按后端契约改的)
|
||||
* **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
|
||||
@@ -55,28 +57,11 @@ 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
|
||||
fun mixedTagsSplitTheTextIntoStyledRuns() {
|
||||
assertEquals(
|
||||
@@ -113,8 +98,8 @@ class InlineMarkupTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* W68 按契约 v0.89 新增(原口径「只认小写」与它冲突,见类 KDoc 第 1 条):
|
||||
* 六类标签的大小写任意写法都认,且改不成别的样式——`<B>` 就是 `<b>`。
|
||||
* W68 按契约 v0.89 新增(原口径「只认小写」与它冲突,见类 KDoc):五项标签的大小写任意写法都认,
|
||||
* 且改不成别的样式——`<B>` 就是 `<b>`。
|
||||
*/
|
||||
@Test
|
||||
fun tagNamesAreCaseInsensitive() {
|
||||
@@ -124,11 +109,6 @@ 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(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(
|
||||
@@ -139,7 +119,7 @@ class InlineMarkupTest {
|
||||
|
||||
/**
|
||||
* 大写**只对行内标签**成立:段落/旁白标签仍只认小写(既有契约 `<P>大小写不认</P>`、
|
||||
* `MessageEditText` 同一口径;契约 v0.89 也写明「`<action>`/`<narration>` 的既有行为逐字未改」)。
|
||||
* `MessageEditText` 同一口径;契约也写明「`<action>`/`<narration>` 的既有行为逐字未改」)。
|
||||
*/
|
||||
@Test
|
||||
fun uppercaseStillMeansNothingForParagraphAndRoleTags() {
|
||||
@@ -152,7 +132,7 @@ class InlineMarkupTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* W68 按契约 v0.89 改(原口径「`<b class="x">` 原样保留」与它冲突,见类 KDoc 第 2 条):
|
||||
* W68 按契约 v0.89 改(原口径「`<b class="x">` 原样保留」与它冲突,见类 KDoc):
|
||||
* 白名单标签带任何属性都按标签名处理,属性**整段丢掉**,绝不落进正文、更不落成任何视图属性。
|
||||
*/
|
||||
@Test
|
||||
@@ -161,10 +141,7 @@ class InlineMarkupTest {
|
||||
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\">乙"))
|
||||
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>,比上海晚了整整一小时呢。")
|
||||
@@ -199,11 +176,6 @@ class InlineMarkupTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNewlineInsideBoldStaysBold() {
|
||||
assertEquals(listOf(run("甲\n乙", InlineStyle.Bold)), parseInlineMarkup("<b>甲<br>乙</b>"))
|
||||
}
|
||||
|
||||
/**
|
||||
* `<code>` 里想显示什么就显示什么——包括尖括号(这也是「想显示尖括号怎么办」的答案)。
|
||||
*
|
||||
@@ -219,13 +191,38 @@ class InlineMarkupTest {
|
||||
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 })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 契约 v0.89 之三:结构不平衡 ⇒ 整段降级为原文
|
||||
// (W68 的粒度是「该气泡的正文」;W67 的局部降级口径与它冲突,已按契约改掉)
|
||||
// (粒度是「该气泡的正文」;W67 的局部降级口径与它冲突,W68 已按契约改掉)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* W68 按契约 v0.89 改(原口径见下面各自的老断言):**未闭合**的开标签 ⇒ 整段原文。
|
||||
* W68 按契约 v0.89 改:**未闭合**的开标签 ⇒ 整段原文。
|
||||
* 返回空列表 = 调用方按字面渲染,正文一个字符都不动。
|
||||
*/
|
||||
@Test
|
||||
@@ -239,13 +236,12 @@ class InlineMarkupTest {
|
||||
// 大小写混用、带属性也一样算未闭合
|
||||
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 条。
|
||||
* 一处不平衡,这一整段一个标签都不认——粗粒度换来「可逐字断言」,理由见类 KDoc。
|
||||
*/
|
||||
@Test
|
||||
fun oneUnclosedTagFallsTheWholeBodyBackToLiteralText() {
|
||||
@@ -276,8 +272,8 @@ class InlineMarkupTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* W68 按契约 v0.89 新增:**四种不平衡形状**各自的整段降级,且降级的是「整段原文」——
|
||||
* 连 `<br>` 也不换行(契约措辞是原文逐字显示,我们不额外发明「换行照旧生效」的例外)。
|
||||
* W68 按契约 v0.89 新增:**四种不平衡形状**各自的整段降级,且降级的是「整段原文」,
|
||||
* 逐字对照。
|
||||
*/
|
||||
@Test
|
||||
fun everyImbalancedShapeFallsBackToTheOriginalTextCharacterForCharacter() {
|
||||
@@ -287,8 +283,8 @@ class InlineMarkupTest {
|
||||
"甲<b>粗</b>乙</b>", // 落单闭标签
|
||||
"</b>", // 只有闭标签
|
||||
"<b>甲<i>乙</b>丙</i>", // 交错
|
||||
"甲<b>粗<br>乙", // 未闭合 + `<br>`:`<br>` 也一起放弃
|
||||
"<U>线", // 大写 + 未闭合
|
||||
"<B>甲", // 大写 + 未闭合
|
||||
"<b class=x>甲", // 属性 + 未闭合
|
||||
)) {
|
||||
assertTrue("「$raw」结构不平衡,必须整段降级", parseInlineMarkup(raw).isEmpty())
|
||||
assertEquals("「$raw」必须逐字原文", raw, parse("<p>$raw</p>").single().body)
|
||||
@@ -297,7 +293,7 @@ class InlineMarkupTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 契约 v0.89 说的「整段」= **该气泡的正文**(粒度口径与理由写在 `parseInlineMarkup` 的 KDoc 里)。
|
||||
* 契约说的「整段」= **该气泡的正文**(粒度口径与理由写在 `parseInlineMarkup` 的 KDoc 里)。
|
||||
* 这条钉住它的边界:**坏掉的那条气泡整段原文,别条气泡照常渲染**——既不是整条消息,
|
||||
* 也不是(更细的)按行/按句局部生效。
|
||||
*/
|
||||
@@ -315,7 +311,8 @@ class InlineMarkupTest {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 契约 v0.89 之四:白名单外一律按纯文本原样显示(字符一个不丢)
|
||||
// 契约之一:白名单外一律按纯文本原样显示(字符一个不丢)
|
||||
// W69 起 `u` / `s` 也在这一档里(契约收窄到五项)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@@ -327,7 +324,7 @@ class InlineMarkupTest {
|
||||
"你好<b", // 残缺,没有 `>`
|
||||
"<p2>这不关 p 标签的事</p2>",
|
||||
"<span>不认识的标签</span>",
|
||||
"<br2>不认识的自闭合</br2>",
|
||||
"<br2>名字后面不是空白</br2>",
|
||||
"<b2>名字后面不是空白</b2>",
|
||||
"<boolean>以 b 开头的普通单词</boolean>",
|
||||
)) {
|
||||
@@ -341,9 +338,12 @@ class InlineMarkupTest {
|
||||
* 契约第 1 条点名的形状:`<script>` 之类**白名单外**的标签显示成字面量,字符一个不丢——
|
||||
* 渲染侧是 `AnnotatedString` + `SpanStyle`(结构化),正文从来不经过任何 HTML 解析/注入。
|
||||
*
|
||||
* W69 起 **`u` / `s` 也落在这一档**(`回执三十`:白名单收窄到五项):它们不再是样式,
|
||||
* 写出来就是字面文本。撤掉的正是「会渲染」这件事——Web 端不渲染,我们多渲染就是制造跨端不一致。
|
||||
*
|
||||
* ⚠️ 我们的读法(**待与后端确认**):白名单外的标签**不参与结构判定**——它们按纯文本算,
|
||||
* 所以既不会因为「没闭合」把整段拖成字面(下面第二条),配对与否也不改变别的标签;
|
||||
* 只有白名单那六个名字才构成契约第 4 条说的「结构」。
|
||||
* 只有白名单那五个名字才构成契约说的「结构」。
|
||||
*/
|
||||
@Test
|
||||
fun tagsOutsideTheWhitelistAreShownLiterallyWithoutLosingACharacter() {
|
||||
@@ -366,6 +366,23 @@ class InlineMarkupTest {
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 实体(不做)
|
||||
// ------------------------------------------------------------------
|
||||
@@ -378,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>", "<u></u><s></s>")) {
|
||||
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)
|
||||
}
|
||||
@@ -500,7 +488,7 @@ class InlineMarkupTest {
|
||||
// ------------------------------------------------------------------
|
||||
// 反证:星号语义一个字都没被动过
|
||||
//
|
||||
// 行内解析发生在**段落/旁白解析之后**(`toBubble` 里对最终正文做一次),而星号那一套
|
||||
// 行内解析发生在**段落/分隔符解析之后**(`toBubble` 里对最终正文做一次),而星号那一套
|
||||
// 在 `appendAnnotations` 里就已经定稿并入 `parts` 了。所以「星号不产生斜体/粗体」这条
|
||||
// 语义在结构上不可能被行内解析改了——下面这几条既是反证,也是它与它相邻的样子。
|
||||
// ------------------------------------------------------------------
|
||||
@@ -549,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) {
|
||||
@@ -558,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 ->
|
||||
@@ -567,11 +555,11 @@ 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 新增(这条是本次改动最要紧的护栏):**大小写混用与带属性**的标签,
|
||||
* W68 按契约 v0.89 新增(这条是那一轮最要紧的护栏):**大小写混用与带属性**的标签,
|
||||
* 流式中间态同样一个字符都不许漏上屏。
|
||||
*
|
||||
* 消费侧与扣留侧的词表一旦分叉(W67 只有消费侧认小写、扣留侧只扫 `a..z`),`<B` / `<b class`
|
||||
@@ -580,7 +568,7 @@ class InlineMarkupTest {
|
||||
*/
|
||||
@Test
|
||||
fun streamingWithholdsMixedCaseAndAttributeTagsToo() {
|
||||
val text = "日落 <B class=\"x\">18:35</B>,<U>下划线</U> 与 <S>删除线</S>,<BR>记住了。"
|
||||
val text = "日落 <B class=\"x\">18:35</B> 记住了。"
|
||||
val parser = StreamingContentParser()
|
||||
var shown = emptyList<String>()
|
||||
for (ch in text) {
|
||||
@@ -597,21 +585,20 @@ class InlineMarkupTest {
|
||||
shown = bodies
|
||||
}
|
||||
assertEquals(parse(text), parser.finish())
|
||||
assertEquals(
|
||||
"日落 18:35,下划线 与 删除线,\n记住了。",
|
||||
parser.finish().single().body,
|
||||
)
|
||||
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 class="x">加粗</strong> 哦<br>还有下一行</p>
|
||||
<p>这是<U>下划线</U>与<S>删除线</S>,*弯起眼睛*</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> 与 <br/> 换行</p>
|
||||
<p>日落 <B>18:35</B></p>
|
||||
<p>上一行<br>下一行</p>
|
||||
<p>甲<br/>乙</br>丙</p>
|
||||
""".trimIndent()
|
||||
val expected = parse(sample)
|
||||
|
||||
|
||||
Reference in new issue
Block a user