merge: W67 —— 行内 HTML 标签基本渲染(b/strong、i/em、code、br;只认小写、未配对保持字面、code 内不解析;不碰星号语义)

This commit is contained in:
AskaEth committed 2026-10-07 17:15:48 +08:00
commit 3d4a9f288a
4 files changed
+947 -18

No files matched your search

@@ -19,6 +19,11 @@ import kotlin.math.min
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
* 关键词 旁白/内心 → narration,其余 → action。
* - 行内标签(`InlineMarkup.kt`):`<b>`/`<strong>`、`<i>`/`<em>`、`<code>`、`<br>`/`<br/>` 四类,
* 成对才认、只有小写、未配对或嵌套错位一律字面回落(口径与理由见 `parseInlineMarkup`)。
* 它在**段落定稿之后**才做([toBubble] 里对最终正文做一次),所以既不参与、也改不动上面的
* 分段与归属:`<br>` 只换行、**不分气泡**。渲染文本落 `body` + `runs`,而片段 `parts` 仍是
* 逐字原文——编辑框、搜索片段、通知文案读的都是 `parts`,那些地方一个字都不许改。
* - 落单标签容错:服务端确实会产出不配对的段落标签(实测形态 `<p><p>正文</p>`:多一个 `<p>`、
* 只有一个 `</p>`),块外也可能出现孤立的旁白标签。这类标签一律**剥掉**,绝不落进正文——
* 它们本就是服务端产出的标记、不是她说的话;渲染成正文等于往气泡里塞一串用户既看不懂、
@@ -35,7 +40,9 @@ import kotlin.math.min
* 也保持字面文本,这是既有契约(`unclosedActionFallsBackToText` / `strayClosingTagStaysText`)。
*
* `ParsedBubble` 结构(每条气泡渲染一个):
* - `body` :气泡正文(仅 Text 部分按原顺序拼接后 trim),已剔除全部旁白/动作块;
* - `body` :气泡正文的**渲染文本**(仅 Text 部分按原顺序拼接后 trim,再消费掉四种行内标签:
* `<br>` 已变成换行、标签字符已去掉),已剔除全部旁白/动作块;整段只有标记没有字时回落字面原文;
* - `runs` :`body` 的行内样式切分(非空时拼起来就是 `body`;空列表 = 按字面画);
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
* - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」);
* - `tail` :要渲染在气泡下方的旁白/动作块(尾部连续块)。
@@ -47,6 +54,9 @@ import kotlin.math.min
* 因此 `finish() == parse(全部 delta 拼接)` 恒成立(逐字符/任意切分均一致)。
* - 未确定片段从以下最早位置开始整体扣留,不产出半截结果:
* 1) 未闭合的 `<action>` / `<narration>` / `<p>` 开标签(从该开标签起扣留);
* 行内开标签同样入栈扣留(词表见 [TAG_NAMES]):不扣的话中间态会把 `<b>` 当字面吐上屏
* (`日落 <b>18`)、等 `</b>` 到了又被吃掉,屏幕上已经显示过的两个字就改口了。
* `<br>` 是自闭合的,**不入栈**(入了就永远弹不出来),它只在残缺(`<br`)时按词表扣留。
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`),仅当它可能继续补全为合法标签时才扣留;
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
@@ -83,6 +93,15 @@ data class ParsedBubble(
val parts: List<ContentPart>,
val head: List<ContentPart>,
val tail: List<ContentPart>,
/**
* `body` 的行内样式切分(`<b>` / `<i>` / `<code>`,见 [parseInlineMarkup]):非空时拼起来
* **就是** [body]。空列表 = 这段正文没有可渲染的行内标签,按字面画——**绝大多数消息都走这一档**。
*
* 有默认值,是因为只有 [toBubble] 才该填它:其余构造点要么本来就该按字面画(`plainBubble`、
* 分享预览的兜底 `sharePreviewBlocks`),要么是测试里的手搓气泡。这也让「加一个字段」不必
* 惊动任何一个既有调用方。
*/
val runs: List<InlineRun> = emptyList(),
) {
val isEmpty: Boolean
get() = body.isEmpty() && head.isEmpty() && tail.isEmpty()
@@ -116,8 +135,14 @@ private const val FULL_STAR_MAX_LEN = 40
/**
* 已知标签词表(只有小写,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
* 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面这组同词表的正则,两边必须同源。
*
* 词表分两半,每一半都与**自己的消费侧**同源:段落/旁白那半([OPEN_P_TAG_RE] / [CLOSE_P_TAG_RE] /
* [OPEN_ROLE_TAG_RE] / [CLOSE_ROLE_TAG_RE],[stripLooseTags] 剥落单标签用的就是这组)、行内那半
* ([INLINE_TAG_NAMES],`InlineMarkup` 里由同一份名单生成的 [OPEN_INLINE_TAG_RE] /
* [CLOSE_INLINE_TAG_RE] / [BR_TAG_RE] 是 [parseInlineMarkup] 真正消费用的那组)。
* 任缺一半,中间态就会把标签当正文吐上屏、收口时才吃掉——屏幕上已经显示过的字就改口了。
*/
private val TAG_NAMES = listOf("action", "narration", "p")
private val TAG_NAMES = listOf("action", "narration", "p") + INLINE_TAG_NAMES
private val OPEN_ROLE_TAG_RE = Regex("^<\\s*(action|narration)\\s*>$")
private val CLOSE_ROLE_TAG_RE = Regex("^</\\s*(action|narration)\\s*>$")
private val OPEN_P_TAG_RE = Regex("^<p(\\s[^>]*)?>$")
@@ -481,9 +506,20 @@ private fun toBubble(tokens: List<Tok>): ParsedBubble? {
tail.add(part)
}
}
val bodyText = body.toString().trim()
// 行内标签在**段落定稿之后**才消费(就在这一行):上面那套分段与归属一个字都不动,分段判据
// 因此完全看不见行内标签(`<br>` 造不出新气泡)。runs 为空 = 这段正文没有可渲染的行内标签,
// 正文按字面画(绝大多数消息都走这一档),一个字都不会变。
val rawBody = body.toString().trim()
val runs = parseInlineMarkup(rawBody)
val bodyText = if (runs.isEmpty()) rawBody else runs.joinToString("") { it.text }
if (bodyText.isEmpty() && head.isEmpty() && tail.isEmpty()) return null
return ParsedBubble(body = bodyText, parts = parts.toList(), head = head.toList(), tail = tail.toList())
return ParsedBubble(
body = bodyText,
parts = parts.toList(),
head = head.toList(),
tail = tail.toList(),
runs = runs,
)
}
// ---------------------------------------------------------------------------
@@ -528,12 +564,16 @@ private fun stablePrefixEnd(buf: String): Int {
private fun openTagName(tag: String): String? {
OPEN_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
return if (OPEN_P_TAG_RE.matches(tag)) "p" else null
if (OPEN_P_TAG_RE.matches(tag)) return "p"
// 行内开标签也入栈。`<br>` 是自闭合的,**不入栈**(入了就永远弹不出来,扣留点会永久停在
// 它前面,后面的话再也显示不出来)——[openInlineTagName] 的名单里本来就没有它。
return openInlineTagName(tag)
}
private fun closeTagName(tag: String): String? {
CLOSE_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
return if (CLOSE_P_TAG_RE.matches(tag)) "p" else null
if (CLOSE_P_TAG_RE.matches(tag)) return "p"
return closeInlineTagName(tag)
}
/** 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。 */
@@ -0,0 +1,339 @@
package top.yeij.cyrene.domain.parse
/*
* InlineMarkup —— 气泡正文里「一小撮行内标签」的解析(纯 Kotlin,无 Android / Compose 依赖)。
*
* 做四类,**成对才认**,只有小写:
* - `<b>` / `<strong>` → 粗体;`<i>` / `<em>` → 斜体;
* - `<code>` → 等宽 + 轻微底色(`<code>` 里不再解析,见下);
* - `<br>` / `<br/>` → 换行(自闭合,不成对)。
*
* ## 为什么只有小写
* `parse` 只认小写的 `<p>` / `<action>` / `<narration>`(`MessageEditText` 同一口径,既有测试
* `plainAngleBracketsAreNeverEaten` 钉着 `<P>大小写不认</P>` 原样显示)。行内标签若认大小写,
* 流式的扣留侧也得跟着认,而那边的标签名扫描只认 `a..z`(`isPotentialTagPrefix`):两边一分叉,
* 中间态就会把 `<B>` 当正文吐上屏、等 `</B>` 到了再吃掉——那正是「字会跳」。所以大小写口径与
* `<p>` 那套**逐字对齐**。证据也只有小写:真后端最近 100 条里命中的 2 条都是 `<b>`。
*
* ## 为什么侵入只在 `ParsedBubble` 上加一个字段
* `ContentPart.Text` 的 `text` 必须保持**逐字原文**:编辑框(`MessageEditText.visibleTextOf`)、
* 搜索片段(`SessionSearchModel.plainSearchText`)、通知文案(`ProactiveNotify`)读的都是 `parts`,
* 在那里把标签改掉就是**静默改用户内容**。所以行内解析不改片段,只在 `toBubble` 里对**已经定稿的
* 正文**算一次,结果落在 `ParsedBubble.runs`;`body` 变成正文的**渲染文本**(四个标签已消费),
* 而 `parts` 仍是原文——两侧各拿自己该拿的那一份。
*
* ## 与段落解析的分工(为什么不在 `tokenize` 里做)
* 行内解析发生在**段落边界已经定稿之后**(`toBubble` 里,对最终 `body` 做一次):`<p>` 分气泡、
* 空行回落、旁白归属、落单标签剥离全部照旧,行内标签既不参与、也改不动它们。`<br>` 因此**绝不会**
* 造出新的气泡边界——分气泡这件事只认 `<p>` 与空行。这也让 W63 在 `tokenize` 里去掉的两处 O(N²)
* 一行都不受影响:这里是对最终正文的一趟线性扫描。
*
* ## 明确不做(逐条理由)
* 1. **Markdown 的 `*` / `_` 一律不做**:星号这个字形在本项目**已经被占走了**——`ROLE_RE` 把全角
* `*…*` 与半角 `*(动作|旁白|内心|神态|轻声)…*` 判成旁白/动作(`*笑*` 是动作,不是斜体),
* 既有测试钉着这条语义。再给同两个星号一个「斜体」的说法,同一条消息就有两种读法,还要先猜
* 哪一种是她的本意——那是改既有语义,不是加渲染。
* 2. **`#` 标题 / `- ` 列表 / 表格 / ``` 围栏 / `[链接](url)` / `~~删除~~` 一律不做**:真后端最近
* 100 条实测 0 命中;而且这些是**行级**规则,会把「正文里真有一行以 `- ` 开头」这种普通文本
* 改写成别的东西——0 命中 + 有改写风险 = 不做。
* 3. **`<a href>` 不做**:它不是一个样式,是一个**点击行为**(要唤浏览器),必须配一套安全策略
* (只认 http/https、不许 `intent://` / `javascript:` 之类)。实测 0 命中,这件事要单独拍板。
* 4. **不认识的标签(`<span>` / `<div>` / `<p2>` …)一律保持现状(原样显示)**:剥掉它们是**另一件事**
* (判据怎么定?`<3`、`a<b`、`1 < 2` 都长着尖括号),有误伤正文的风险,不顺手做。
* 5. **HTML 实体(`&lt;` `&amp;` `&nbsp;` …)不做**:实测 0 命中;实体是一张**开放的清单**
* (`&#39;` / `&#x27;` / 各种命名实体……),做一半比不做更糟——会出现「`&amp;` 解了、`&#x27;`
* 却原样显示」这种谁也说不清的口径。想在正文里显示尖括号,今天有 `<code>` 这一档。
*
* 契约与边界口径见 [parseInlineMarkup]。
*/
/**
* 一段行内样式,可叠加(`<b><i>粗斜</i></b>` 同时是粗与斜)。
*
* 只有这三种:换行不是样式,它是**字符**(`<br>` → `\n`),落在片段的文本里。
*/
enum class InlineStyle {
Bold,
Italic,
Code,
}
/**
* 正文里**样式一致的一段连续文本**:片段按顺序拼起来就是渲染文本,样式区间由「起始下标 + 长度」
* 就地累加得到(第 n 段的区间 = 前面各段长度之和 until 加上自己)。
*
* 相邻且样式相同的片段由 [parseInlineMarkup] 合并,所以同一段文本只有一种片段形状——
* 断言起来就是在断言「哪段字是什么样式」。
*/
data class InlineRun(val text: String, val styles: Set<InlineStyle>)
/** `<code>` 的标签名(解析里要单独判它:里面的标签不再解析)。 */
private const val CODE = "code"
/** 成对标签的名字(`strong` / `em` 是 `b` / `i` 的同义写法)。只写这一处,下面的正则由它生成。 */
private val PAIRED_TAG_NAMES = listOf("b", "strong", "i", "em", CODE)
/** 自闭合的换行标签名(不成对,所以它不在 [PAIRED_TAG_NAMES] 里)。 */
private const val BR = "br"
/**
* 行内标签词表。**流式扣留侧(`ChatContentParser.stablePrefixEnd`)与这里必须同源**:
* 同一组正则既判「这是个行内标签」(消费侧),也判「这个标签名会不会继续补全」(扣留侧)。
* 两边一分叉,中间态就会把标签当正文吐上屏、收口时才吃掉——屏幕上已经显示过的那几个字就改口了。
*/
internal val INLINE_TAG_NAMES = PAIRED_TAG_NAMES + BR
/** 认成对开标签的**完整**形状:`<b>` 这种,不带属性、不带空白(`<b class="x">` 保持原样显示)。 */
internal val OPEN_INLINE_TAG_RE = Regex("^<(${PAIRED_TAG_NAMES.joinToString("|")})>$")
/** 同上,闭标签:`</b>`。 */
internal val CLOSE_INLINE_TAG_RE = Regex("^</(${PAIRED_TAG_NAMES.joinToString("|")})>$")
/** 自闭合换行标签:`<br>` / `<br/>`(容 `<br >` 这种带空白的写法,与 `<p 属性>` 同一口径)。 */
internal val BR_TAG_RE = Regex("^<$BR\\s*/?>$")
/** 标签名 → 样式;`strong` / `em` 落到与 `b` / `i` 同一个样式上。 */
internal fun inlineStyleOf(name: String): InlineStyle = when (name) {
"i", "em" -> InlineStyle.Italic
CODE -> InlineStyle.Code
else -> InlineStyle.Bold
}
/** 开标签名(不是行内开标签则 null);`<br>` 是自闭合的,**不入栈**,所以它不在这里。 */
internal fun openInlineTagName(tag: String): String? =
OPEN_INLINE_TAG_RE.find(tag)?.groupValues?.get(1)
/** 闭标签名(不是行内闭标签则 null)。 */
internal fun closeInlineTagName(tag: String): String? =
CLOSE_INLINE_TAG_RE.find(tag)?.groupValues?.get(1)
/**
* 把一段**已经定稿的正文文本**解析成行内片段(纯函数)。
*
* 三条契约(都由单测钉着):
* 1. **返回空列表 = 这段文本没有任何配成对的行内标签**,调用方按**字面**渲染(正文一个字符都不动)。
* 这是绝大多数消息的路径(实测 100 条里只有 2 条带 `<b>`),也是 `plainBubble` 那条路。
* 2. 返回非空 ⇒ 片段拼起来**就是**渲染文本:`<br>` 已经变成 `\n`、标签字符已经被消费掉,且两端
* 空白已去(见下)。
* 3. **绝不把一段非空正文渲染成空**:整段只有标记没有字(`<b></b>` / `<br>`)时回落成第 1 条,
* 把标记原样显示——宁可让她的话里多几个尖括号,也不能给出一条空白气泡。
*
* ## 口径:配成对 + 正确嵌套
* - **未配对的标签一律字面显示**(`<b>没有闭合`、`没有闭合</b>`):与既有「未闭合/畸形标签回落
* 普通文本」同一口径(`<p>第一`、`好吧<action>笑` 都钉着字面)。剥落单标签那条容错(W55)只针对
* **段落/旁白**标签、且只在段落结构已被完整块定死时才动手;正文里的 `a<b>c` 是既有测试钉死的
* 字面文本,行内标签没有那种「结构已定死」的语境,所以走字面回落这一档。
* - **嵌套错位也字面显示**:`<b>甲<i>乙</b>丙</i>` 里的 `</b>` 关不掉 `i`(严格嵌套),于是它连同
* `<b>` 一起按字面留着,只有配对成功的 `<i>…</i>` 生效。判据只有一条,不发明「宽容关闭」。
* 两条合起来给出**绝不丢字**:只有配成对且正确嵌套的标签会被吃掉,其余一个字符都不动
* (`1 < 2`、`a<b`、`<b`、`<p2>不是标签</p2>`、`<B>` 全都原样)。
* - **`<code>` 里不再解析**:`<code><b>甲</b></code>` 显示字面的 `<b>甲</b>`。理由是 HTML 语义上
* `<code>` 就是「原样文本」的容器,而且这一档正好是「想显示尖括号怎么办」的答案;代价是代码里
* 的 `<b>` 不生效——实测 0 命中,也不存在「代码里也要粗体」这个需求。
* - 相邻同样式的片段合并:`<b>甲<br>乙</b>` 只有一段 `甲\n乙`(样式 {Bold})。
*
* ## 为什么两端空白要去掉
* `<br>` 落在开头/结尾会凭空多出一行空白(Compose 里末尾换行就是一行空行),而正文两端的空白本来
* 就被去掉了(`toBubble` 里那句 `body.trim()`)。这一步只吃空白——一个可见字符都不会掉。
*
* ## `<br>` 与既有空行分段的关系
* `<br>` 就是**一个换行符**,不与相邻的 `\n` 去重:`甲<br><br>乙` 是「甲、空一行、乙」,
* `甲<br>\n乙` 也是空一行——这是 markup 的直译(去掉哪一个都算我们替她改话)。它**不分气泡**:
* 分气泡的判据(`<p>`、空行 `\n\s*\n`)在行内解析**之前**就已经算完了,所以这里造的换行不会
* 变成新的气泡边界,也不会与空行分段叠加出「双倍」的东西。
*
* 复杂度 O(n):两趟线性扫描(每个 `<` 只看一扇固定宽度的窗,绝不 `indexOf('>')` 一路找配对);
* 正文里没有 `<` 时连集合都不建,一次就返回。
*/
fun parseInlineMarkup(text: String): List<InlineRun> {
if (text.indexOf('<') < 0) return emptyList()
val claimed = claimTags(text)
if (!claimed.any) return emptyList()
// 整段被吃空(`<b></b>` / `<br>` 这类只有标记没有字的形状)⇒ 回落字面路径,见契约第 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()
}
/**
* 第一趟:扫出**配成对且正确嵌套**的那些标签(起始下标落在 [ClaimedTags] 的三个集合里)。
*
* 用栈判配对,且**闭标签只关得掉栈顶那个同名开标签**(严格嵌套);剩下的(没闭合的开标签、
* 找不到伴的闭标签、错位的闭标签)一概不进集合,第二趟自然把它们原样吐回文本。
*
* `<code>` 开着的期间**整段跳过**:里面的标签一个都不认(见 [parseInlineMarkup] 的口径)。
* 因为跳过的是「认不认」,所以 `<code>` 自己那一对仍照常被认领。
*/
private fun claimTags(text: String): ClaimedTags {
val claimed = ClaimedTags()
val open = ArrayList<Pair<String, Int>>()
var i = 0
while (true) {
val lt = text.indexOf('<', i)
if (lt < 0) break
val gt = inlineTagEnd(text, lt)
if (gt < 0) {
// 这一处起头的东西不是行内标签:往后挪**一个字符**再找。不能跳到某个「下一个 `>`」——
// 那会连同中间真正的标签一起跳过(`1 < 2 <b>甲</b>` 里的 `<b>` 就是这么丢的)。
i = lt + 1
continue
}
val tag = text.substring(lt, gt + 1)
if (open.lastOrNull()?.first == CODE) {
if (closeInlineTagName(tag) == CODE) {
claimed.openStarts.add(open.removeAt(open.lastIndex).second)
claimed.closeStarts.add(lt)
}
} else {
val openName = openInlineTagName(tag)
when {
openName != null -> open.add(openName to lt)
BR_TAG_RE.matches(tag) -> claimed.breakStarts.add(lt)
else -> {
val closeName = closeInlineTagName(tag)
if (closeName != null && open.lastOrNull()?.first == closeName) {
claimed.openStarts.add(open.removeAt(open.lastIndex).second)
claimed.closeStarts.add(lt)
}
}
}
}
i = gt + 1
}
return claimed
}
/** 行内标签的最长形态(`</strong>` = 名字 + 3);扫的时候只看这么宽一扇窗。 */
private val MAX_INLINE_TAG_LEN = INLINE_TAG_NAMES.maxOf { it.length } + 3
/**
* [lt] 处这个 `<` 起头的**行内**标签在哪结束(不是行内标签则 -1)。
*
* 刻意**不用** `indexOf('>', lt)` 一路找过去:正文里 N 个落单的 `<` 时那是 O(N²)
* (W63 在同一片区域刚踩过这个坑)。行内标签都是定长形态,所以只看一扇 [MAX_INLINE_TAG_LEN]
* 宽的窗就够;窗里再冒出一个 `<`,也说明这一处起头的不是标签——扫描因此是线性的。
*/
private fun inlineTagEnd(text: String, lt: Int): Int {
val limit = minOf(text.length, lt + MAX_INLINE_TAG_LEN)
var i = lt + 1
while (i < limit) {
when (text[i]) {
'>' -> return i
'<' -> return -1
}
i++
}
return -1
}
/**
* 第二趟:把文本切成片段——被认领的标签消费掉(开/闭标签不入字、`<br>` 变成 `\n`),其余**逐字**
* 进片段。因为第一趟只收严格嵌套的配对,这里的栈与它逐字对应,不会弹错人。
*/
private fun runsOf(text: String, claimed: ClaimedTags): List<InlineRun> {
val runs = ArrayList<InlineRun>()
val piece = StringBuilder()
val open = ArrayList<String>()
var styles = emptySet<InlineStyle>()
fun flush() {
if (piece.isEmpty()) return
val text0 = piece.toString()
piece.setLength(0)
val last = runs.lastOrNull()
if (last != null && last.styles == styles) {
runs[runs.lastIndex] = last.copy(text = last.text + text0)
} else {
runs.add(InlineRun(text0, styles))
}
}
var i = 0
while (true) {
val lt = text.indexOf('<', i)
if (lt < 0) {
piece.append(text, i, text.length)
break
}
if (lt > i) piece.append(text, i, lt)
val gt = inlineTagEnd(text, lt)
if (gt < 0) {
// 这一处起头的东西不是行内标签(`1 < 2` 里那个 `<`、残缺的 `<b`…):把这个字符原样收下、
// 从下一个字符继续。**必须与第一趟用同一把尺子**——这里若改用 `indexOf('>', lt)` 找标签
// 边界,第一趟认下的那对标签就会在这里被当成一个字面整段吞掉(`1 < 2 <b>甲</b>`)。
piece.append('<')
i = lt + 1
continue
}
val tag = text.substring(lt, gt + 1)
when {
claimed.openStarts.contains(lt) -> {
flush()
open.add(openInlineTagName(tag) ?: closeInlineTagName(tag).orEmpty())
styles = stylesOf(open)
}
claimed.closeStarts.contains(lt) -> {
flush()
open.removeAt(open.lastIndex)
styles = stylesOf(open)
}
claimed.breakStarts.contains(lt) -> {
flush()
piece.append('\n')
}
else -> piece.append(tag)
}
i = gt + 1
}
flush()
return runs
}
private fun stylesOf(open: List<String>): Set<InlineStyle> =
if (open.isEmpty()) emptySet() else open.mapTo(LinkedHashSet(), ::inlineStyleOf)
/** 去掉渲染文本两端的空白,并同步裁掉因此变空的片段——只吃空白,一个可见字符都不会掉。 */
private fun List<InlineRun>.trimEdges(): List<InlineRun> {
var head = 0
for (run in this) {
val leading = run.text.takeWhile { it.isWhitespace() }.length
head += leading
if (leading < run.text.length) break
}
var tail = 0
for (i in indices.reversed()) {
val trailing = this[i].text.takeLastWhile { it.isWhitespace() }.length
tail += trailing
if (trailing < this[i].text.length) break
}
val total = sumOf { it.text.length }
val from = minOf(head, total)
val to = total - minOf(tail, total - from)
return cut(from, to)
}
/** 按字符下标从两端裁掉(只用于裁空白,所以裁点必然落在片段边界或空白中间)。 */
private fun List<InlineRun>.cut(from: Int, to: Int): List<InlineRun> {
if (from == 0 && to == sumOf { it.text.length }) return this
val out = ArrayList<InlineRun>(size)
var pos = 0
for (run in this) {
val start = pos
pos += run.text.length
val head = maxOf(start, from)
val end = minOf(pos, to)
if (head < end) out.add(run.copy(text = run.text.substring(head - start, end - start)))
}
return out
}
@@ -24,10 +24,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
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.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import top.yeij.cyrene.core.prefs.LocalAppPrefs
@@ -43,6 +50,8 @@ import top.yeij.cyrene.domain.chat.isVoiceMessage
import top.yeij.cyrene.domain.chat.voiceNote
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.InlineRun
import top.yeij.cyrene.domain.parse.InlineStyle
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse
import top.yukonga.miuix.kmp.basic.Icon
@@ -582,8 +591,18 @@ private fun Bubble(
gray: Boolean = false,
) {
AnnotationLine(bubble.head)
val body = if (showCursor && isLast && bubble.body.isNotEmpty()) bubble.body + CURSOR else bubble.body
if (body.isNotEmpty()) BubbleBody(body, isUser, onLongPress, gray)
val withCursor = showCursor && isLast && bubble.body.isNotEmpty()
val body = if (withCursor) bubble.body + CURSOR else bubble.body
if (body.isNotEmpty()) {
BubbleBody(
text = body,
runs = bubble.runs,
isUser = isUser,
onLongPress = onLongPress,
gray = gray,
trailingCursor = withCursor,
)
}
AnnotationLine(bubble.tail)
}
@@ -613,7 +632,14 @@ private fun LegBadge(text: String, onClick: (() -> Unit)?) {
}
}
/** 旁白/动作:灰斜体小字,依附在气泡上/下。 */
/**
* 旁白/动作:灰斜体小字,依附在气泡上/下。
*
* 这一行**不做行内解析**(正文那四类标签在这里照原样显示)。理由是它读的是 `parts` 里的原文,
* 而分享预览那行读的是同一批原文(`ShareConfirmPanel.ShareConfirmAnnotation`)——那边是红线、
* 本次一个字都不能动。只在这边渲染,就等于让「预览里看到的 == 发出去之后看到的」在旁白上又裂一次,
* 而 W65 刚把这道缝补上。要连带做,得两处一起改并单独拍板(实测 `<action>` 里带 `<b>` 的 0 命中)。
*/
@Composable
private fun AnnotationLine(parts: List<ContentPart>) {
if (!LocalAppPrefs.current.showNarration) return
@@ -679,14 +705,28 @@ private fun ContentlessNote(onLongPress: (() -> Unit)? = null) {
* 这一态的语义是「这条已经在被处理,但还没有答案」,它既不是用户说的话(不该是主人的青色),
* 也不是她的回答(不该是助手的正常底色)。正文一个字都不改,只是**褪色**,让人一眼看出
* 「没丢,只是在等」。
*
* [runs] 非空时**只画 [runs]**(拼起来就是 `text` 去掉末尾光标那一截,见 [inlineBody]):
* 同一段正文不在这里再拼一遍,免得拼出两个版本的字。[runs] 为空 = 这段正文没有可渲染的行内标签,
* 走原来的 `Text(text)` 那条路(**绝大多数消息都是这一档,一个像素都不变**)。
*
* [trailingCursor] 说的是「`text` 末尾那枚光标」。光标必须**在片段之外**补上去:它不是正文,
* 不该继承她最后那一段的粗体/等宽底色。
*/
@Composable
private fun BubbleBody(
text: String,
runs: List<InlineRun>,
isUser: Boolean,
onLongPress: (() -> Unit)? = null,
gray: Boolean = false,
trailingCursor: Boolean = false,
) {
val contentColor = when {
gray -> MiuixTheme.colorScheme.onSurfaceVariantSummary
isUser -> MiuixTheme.colorScheme.onPrimary
else -> MiuixTheme.colorScheme.onSurfaceContainer
}
Box(
modifier = Modifier
.widthIn(max = 300.dp)
@@ -716,19 +756,68 @@ private fun BubbleBody(
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
SelectionContainer {
Text(
text = text,
style = MiuixTheme.textStyles.main,
color = when {
gray -> MiuixTheme.colorScheme.onSurfaceVariantSummary
isUser -> MiuixTheme.colorScheme.onPrimary
else -> MiuixTheme.colorScheme.onSurfaceContainer
},
)
if (runs.isEmpty()) {
Text(
text = text,
style = MiuixTheme.textStyles.main,
color = contentColor,
)
} else {
Text(
text = inlineBody(
runs = runs,
codeBackground = if (isUser) {
MiuixTheme.colorScheme.onPrimary.copy(alpha = INLINE_CODE_ON_USER_BACKGROUND)
} else {
MiuixTheme.colorScheme.surfaceContainerHigh
},
trailingCursor = trailingCursor,
),
style = MiuixTheme.textStyles.main,
color = contentColor,
)
}
}
}
}
/**
* `<code>` 那点底色在**主人那侧**(青色气泡)的透明度:`onPrimary` 压一层浅底。
* 昔涟那侧直接用 `surfaceContainerHigh`——它本来就是比气泡底(`surfaceContainer`)高一档的填充色,
* 同一套配色里现成的一档,不另造颜色。
*/
private const val INLINE_CODE_ON_USER_BACKGROUND = 0.18f
/**
* 行内片段 → 一段 `AnnotatedString`:一个 `Text` 就同时承载粗体 / 斜体 / 等宽底色 / 换行。
*
* **为什么不拼一串 `Text` 节点**:换行与折行得由同一个排版器决定,手拼 `Row` 会让折行位置跟她原话里
* 那个换行打架(`<br>` 造出来的换行必须真的换行,而不是把后半段挪到另一个节点里);而且一个 `Text`
* 天然是一整段可选中的文本([SelectionContainer] 的行为一个字都不用改)。
*
* 样式只给**有**的那几项(`null` = 继承气泡自己的字重/字体),所以非 `<code>` 的片段与原来逐像素相同。
*/
private fun inlineBody(
runs: List<InlineRun>,
codeBackground: Color,
trailingCursor: Boolean,
): AnnotatedString = buildAnnotatedString {
for (run in runs) {
val code = InlineStyle.Code 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,
fontFamily = if (code) FontFamily.Monospace else null,
background = if (code) codeBackground else Color.Unspecified,
),
) {
append(run.text)
}
}
if (trailingCursor) append(CURSOR)
}
/**
* 气泡里的图片(缩略)。
*
@@ -0,0 +1,461 @@
package top.yeij.cyrene.domain.parse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.domain.chat.editableMessageOf
import top.yeij.cyrene.ui.chat.plainSearchText
import top.yeij.cyrene.ui.chat.sharePreviewBlocks
import kotlin.random.Random
/**
* 行内标签(`InlineMarkup.kt`)单测:四类标签各自渲染成什么、六条边界口径、以及
* 「星号语义一个字都没被动过」的反证。
*
* 断言的是**纯函数的结果**(片段文本 + 样式),不碰 Compose:`runs` 拼起来就是渲染文本,
* 所以「哪段字是什么样式」在这里是直接可断言的。
*/
class InlineMarkupTest {
// ------------------------------------------------------------------
// 四类标签各自渲染成什么
// ------------------------------------------------------------------
@Test
fun boldTagBecomesABoldRun() {
assertEquals(listOf(run("18:35", InlineStyle.Bold)), parseInlineMarkup("<b>18:35</b>"))
}
@Test
fun strongIsJustAnotherWayToWriteBold() {
assertEquals(listOf(run("18:35", InlineStyle.Bold)), parseInlineMarkup("<strong>18:35</strong>"))
}
@Test
fun iAndEmBothBecomeItalicRuns() {
assertEquals(listOf(run("斜", InlineStyle.Italic)), parseInlineMarkup("<i>斜</i>"))
assertEquals(listOf(run("斜", InlineStyle.Italic)), parseInlineMarkup("<em>斜</em>"))
}
@Test
fun codeTagBecomesACodeRun() {
assertEquals(listOf(run("abc", InlineStyle.Code)), parseInlineMarkup("<code>abc</code>"))
}
@Test
fun brTagsBecomeANewline() {
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲<br>乙"))
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲<br/>乙"))
assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲<br />乙"))
}
@Test
fun mixedTagsSplitTheTextIntoStyledRuns() {
assertEquals(
listOf(run("记住 "), run("abc", InlineStyle.Code), run(" 与 "), run("斜体", InlineStyle.Italic), run(" 哦")),
parseInlineMarkup("记住 <code>abc</code> 与 <i>斜体</i> 哦"),
)
}
/** 线上实测的那一条:只有 `<b>` 一种标签,命中的正是她给的那个时间点。 */
@Test
fun theRealMessageRendersAsThreeRuns() {
val runs = parseInlineMarkup("日落 <b>18:35</b>,比上海晚了整整一小时呢。")
assertEquals(
listOf(
run("日落 "),
run("18:35", InlineStyle.Bold),
run(",比上海晚了整整一小时呢。"),
),
runs,
)
assertEquals("日落 18:35,比上海晚了整整一小时呢。", runs.concat())
}
/** 没有标签时返回空列表 = 调用方走原来的字面路径(这是绝大多数消息的那一档)。 */
@Test
fun plainTextYieldsNoRunsAtAll() {
assertTrue(parseInlineMarkup("今天那儿是晴,13~25℃").isEmpty())
assertTrue(parseInlineMarkup("").isEmpty())
assertTrue(parseInlineMarkup("多行\n文本\n也是普通的").isEmpty())
}
// ------------------------------------------------------------------
// 边界 2:嵌套;边界 5:code 里的内容不再解析
// ------------------------------------------------------------------
@Test
fun nestedBoldItalicMergesTheTwoStyles() {
assertEquals(
listOf(run("粗", InlineStyle.Bold), run("粗斜", InlineStyle.Bold, InlineStyle.Italic)),
parseInlineMarkup("<b>粗<i>粗斜</i></b>"),
)
}
@Test
fun boldOutsideItalicKeepsBothStyles() {
assertEquals(
listOf(run("甲", InlineStyle.Bold), run("乙", InlineStyle.Bold, InlineStyle.Code), run("丙", InlineStyle.Bold)),
parseInlineMarkup("<b>甲<code>乙</code>丙</b>"),
)
}
@Test
fun aNewlineInsideBoldStaysBold() {
assertEquals(listOf(run("甲\n乙", InlineStyle.Bold)), parseInlineMarkup("<b>甲<br>乙</b>"))
}
@Test
fun codeContentIsNotParsed() {
// `<code>` 里想显示什么就显示什么——包括尖括号(这也是「想显示尖括号怎么办」的答案)。
assertEquals(listOf(run("<b>甲</b>", InlineStyle.Code)), parseInlineMarkup("<code><b>甲</b></code>"))
assertEquals(listOf(run("1 < 2 && a<b", InlineStyle.Code)), parseInlineMarkup("<code>1 < 2 && a<b</code>"))
}
// ------------------------------------------------------------------
// 边界 1:未配对的标签(一条口径:配成对 + 正确嵌套,否则字面)
// ------------------------------------------------------------------
@Test
fun unpairedTagsStayLiteral() {
// 没有闭合 ⇒ 整段字面(列表为空 = 按字面渲染,正文一个字符都不动)
assertTrue(parseInlineMarkup("<b>没有闭合").isEmpty())
assertTrue(parseInlineMarkup("没有闭合</b>").isEmpty())
assertTrue(parseInlineMarkup("日落 <b>18:35").isEmpty())
assertTrue(parseInlineMarkup("<i>斜").isEmpty())
assertTrue(parseInlineMarkup("<code>看星星").isEmpty())
}
/** 配对的不受影响:半个标签字面留着,配成对的那半个照常生效。 */
@Test
fun thePairedHalfStillWorksWhenTheOtherHalfIsMissing() {
assertEquals(
listOf(run("<b>甲"), run("乙", InlineStyle.Italic)),
parseInlineMarkup("<b>甲<i>乙</i>"),
)
}
@Test
fun mismatchedNestingStaysLiteral() {
// `</b>` 关不掉 `i`(严格嵌套),于是它连同 `<b>` 一起按字面留着,只有 `<i>…</i>` 生效。
assertEquals(
listOf(run("<b>甲"), run("乙</b>丙", InlineStyle.Italic)),
parseInlineMarkup("<b>甲<i>乙</b>丙</i>"),
)
}
@Test
fun aStrayCloseTagNeverClosesAnything() {
assertEquals(listOf(run("甲", InlineStyle.Bold), run("乙</b>")), parseInlineMarkup("<b>甲</b>乙</b>"))
}
// ------------------------------------------------------------------
// 边界 3:正文里本来就想显示的尖括号
// ------------------------------------------------------------------
@Test
fun plainAngleBracketsAreNeverEatenAndNeverRendered() {
for (text in listOf(
"1 < 2 且 3 > 2",
"a<b",
"a<b>c", // 没有 `</b>` ⇒ 字面(既有测试同样钉着这一条)
"你好<b", // 残缺,没有 `>`
"<p2>这不关 p 标签的事</p2>",
"<span>不认识的标签</span>",
"<B>大写不认</B>",
"<b class=\"x\">带属性的也不认</b>",
"<br2>不认识的自闭合</br2>",
)) {
val runs = parseInlineMarkup(text)
assertTrue("「$text」不该被判成行内标签", runs.isEmpty())
assertEquals("「$text」必须逐字保留", text, parse("<p>$text</p>").single().body)
}
}
// ------------------------------------------------------------------
// 边界 6:HTML 实体(不做)
// ------------------------------------------------------------------
@Test
fun htmlEntitiesStayLiteralBecauseTheyAreNotDecoded() {
for (text in listOf("a &amp; b", "1 &lt; 2", "空&nbsp;格", "&#39;引号&#39;")) {
assertTrue(parseInlineMarkup(text).isEmpty())
assertEquals(text, parse("<p>$text</p>").single().body)
}
}
// ------------------------------------------------------------------
// 边界 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>")) {
assertTrue("「$raw」没有可渲染的字,必须整段回落字面", parseInlineMarkup(raw).isEmpty())
assertEquals(raw, parse("<p>$raw</p>").single().body)
}
}
// ------------------------------------------------------------------
// 与既有的解析结果怎么接:body / runs / parts 各拿自己那一份
// ------------------------------------------------------------------
@Test
fun bodyIsTheRenderedTextAndRunsSpellItOut() {
val bubble = parse("<p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>").single()
assertEquals("今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。", bubble.body)
assertEquals(
listOf(run("今天那儿是晴,13~25℃,日落 "), run("18:35", InlineStyle.Bold), run(",比上海晚了整整一小时呢。")),
bubble.runs,
)
assertEquals(bubble.body, bubble.runs.concat())
}
/** `parts` 保持**逐字原文**:编辑框、搜索片段、通知文案读的都是它,一个字都不许改。 */
@Test
fun partsKeepTheRawTextForEditingAndSearch() {
val bubble = parse("<p>日落 <b>18:35</b></p>").single()
assertEquals(listOf(ContentPart.Text("日落 <b>18:35</b>")), bubble.parts)
assertEquals("日落 <b>18:35</b>", bubble.parts.joinToString("") { it.text })
}
/**
* `parse` 的其它调用点一律照旧——这条是「本次改动只碰渲染」的证据,也是它们的护栏:
* 编辑框读到的是**原文**(行内标签一个都不掉)、搜索照样搜得到那句话、后台那段结论预览
* 跟着拿到渲染文本(标签不再露出来)。谁哪天想让这些地方换一套文本,必须先删掉这里的断言。
*/
@Test
fun theOtherConsumersOfTheBubbleStillSeeWhatTheySawBefore() {
val plain = "日落 <b>18:35</b>,比上海晚了整整一小时呢。"
// 编辑框:原文不含 `<p>`/`<action>` 时是**恒等**变换(一个字节都不动,也不谎称有标记被替换)
val editable = editableMessageOf(plain)
assertEquals(plain, editable.text)
assertFalse(editable.hasMarkup)
// 编辑框:外层的 `<p>` 照旧被剥(既有语义),而行内标签**原样留着**——它是要被编辑的源码本身
val wrapped = editableMessageOf("<p>$plain</p>")
assertTrue(wrapped.hasMarkup)
assertEquals(plain, wrapped.text)
// 搜索:按片段原文拼纯文本,那句话照旧搜得到(`<b>` 夹在中间不影响那几个字连在一起)
val searchable = plainSearchText("<p>$plain</p>")
assertTrue(searchable.contains("日落"))
assertTrue(searchable.contains("18:35"))
}
/** 分享预览走的是同一个 [parse](W65)⇒ 它拿到的正文与气泡逐字一致:标签同样不再露出来。 */
@Test
fun theSharePreviewSeesTheSameBodyAsTheBubble() {
val content = "<p>日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>"
assertEquals(parse(content).map { it.body }, sharePreviewBlocks(content).map { it.body })
}
/** 旁白/动作行不做行内解析(理由见 `MessageBubble.AnnotationLine` 的 KDoc):原文进 head,一个字不动。 */
@Test
fun narrationTextIsLeftExactlyAsWritten() {
val bubble = parse("<p><action>笑<b>了</b></action></p>").single()
assertEquals(listOf(ContentPart.Action("笑<b>了</b>")), bubble.head)
assertEquals("", bubble.body)
assertTrue(bubble.runs.isEmpty())
}
@Test
fun inlineTagsDoNotChangeBubbleSplitting() {
val bubbles = parse("<p>甲<b>乙</b></p><p>丙</p>")
assertEquals(listOf("甲乙", "丙"), bubbles.map { it.body })
assertEquals(listOf(ContentPart.Text("甲<b>乙</b>"), ContentPart.Text("丙")), bubbles.flatMap { it.parts })
}
/** 未闭合的 `<b>` 在一次性解析里同样是字面(与 `<action>` 那条既有契约同一口径)。 */
@Test
fun unclosedInlineTagFallsBackToLiteralText() {
val bubble = parse("<p>日落 <b>18:35</p>").single()
assertEquals("日落 <b>18:35", bubble.body)
assertTrue(bubble.runs.isEmpty())
}
// ------------------------------------------------------------------
// 反证:星号语义一个字都没被动过
//
// 行内解析发生在**段落/旁白解析之后**(`toBubble` 里对最终正文做一次),而星号那一套
// 在 `appendAnnotations` 里就已经定稿并入 `parts` 了。所以「星号不产生斜体/粗体」这条
// 语义在结构上不可能被行内解析改了——下面这几条既是反证,也是它与它相邻的样子。
// ------------------------------------------------------------------
@Test
fun starsKeepTheirExistingMeaningInsteadOfBecomingMarkdown() {
// 半角 `*…*` 没有关键词 ⇒ 保持普通文本,**不**变成斜体(既有用例:halfWidthStarWithoutKeywordStaysMarkdownText)
val emphasis = parse("<p>这是*强调*文字</p>").single()
assertEquals("这是*强调*文字", emphasis.body)
assertTrue(emphasis.runs.isEmpty())
// 半角 `**…**` 同理:**不**变成粗体(既有用例:halfWidthDoubleStarStaysMarkdownText)
val double = parse("<p>这是**加粗**文字</p>").single()
assertEquals("这是**加粗**文字", double.body)
assertTrue(double.runs.isEmpty())
// 全角 `*…*` 仍是「动作」(既有用例:shortFullWidthStarWithoutKeywordBecomesAction)
val starred = parse("<p>你好*弯起眼睛*</p>").single()
assertEquals("你好", starred.body)
assertEquals(listOf(ContentPart.Action("弯起眼睛")), starred.tail)
// 带关键词的半角星号仍是旁白(既有用例:halfWidthStarWithNarrationKeywordBecomesNarration)
val narration = parse("<p>*内心:有点开心*你好</p>").single()
assertEquals("你好", narration.body)
assertEquals(listOf(ContentPart.Narration("有点开心")), narration.head)
}
/** 同一段正文里两种机制各干各的:`*笑*` 照旧是旁白,`<b>` 照旧是粗体。 */
@Test
fun starsAndInlineTagsCoexistWithoutTouchingEachOther() {
val bubble = parse("<p>日落 <b>18:35</b>*笑*</p>").single()
assertEquals("日落 18:35", bubble.body)
assertEquals(listOf(ContentPart.Action("笑")), bubble.tail)
assertEquals(listOf(run("日落 "), run("18:35", InlineStyle.Bold)), bubble.runs)
}
// ------------------------------------------------------------------
// 流式:未闭合的行内标签同样扣留(否则屏幕上已经显示过的字会改口)
// ------------------------------------------------------------------
/**
* 逐字符喂一条带 `<b>` 的消息:中间态**一帧都不许**把标签当正文吐上屏,且已经显示过的正文
* 只许「加」不许「改口」(与 `ChatContentParserTest` 那条同名的性质同源)。
*
* 这条消息刻意没有 `<p>`:有 `<p>` 的话整个段落本来就会被扣留,测不到行内标签这一条。
*/
@Test
fun streamingWithholdsUnclosedInlineTags() {
val text = "日落 <b>18:35</b>,比上海晚了整整一小时呢。<br>记住了。"
val parser = StreamingContentParser()
var shown = emptyList<String>()
for (ch in text) {
val bubbles = parser.onDelta(ch.toString())
val bodies = bubbles.map { it.body }
// 只看**渲染文本**(`body`):`parts` 是逐字原文,配成对的标签本来就该留在里面
// (编辑框/搜索读的就是它),所以那一边不能用「有没有尖括号」来判。
for (bubble in bubbles) {
assertFalse("标签漏上了屏:${bubble.body}", bubble.body.contains("<b") || bubble.body.contains("<br"))
}
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
shown.forEachIndexed { i, body ->
assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body))
}
shown = bodies
}
assertEquals(parse(text), parser.finish())
assertEquals("日落 18:35,比上海晚了整整一小时呢。\n记住了。", parser.finish().single().body)
}
/** 逐字符 / 任意切分的流式结果必须与一次性解析完全一致(含行内标签)。 */
@Test
fun streamingAnyChunkingMatchesOneShot() {
val sample = """
<p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>
<p>记住 <code>abc</code>、<i>斜体</i> 与 <strong>加粗</strong> 哦<br>还有下一行</p>
<p>这是*强调*文字*弯起眼睛*</p>
""".trimIndent()
val expected = parse(sample)
val charByChar = StreamingContentParser()
for (ch in sample) charByChar.onDelta(ch.toString())
assertEquals(expected, charByChar.finish())
val random = Random(20261007)
repeat(25) {
val parser = StreamingContentParser()
var index = 0
while (index < sample.length) {
val end = minOf(index + 1 + random.nextInt(7), sample.length)
parser.onDelta(sample.substring(index, end))
index = end
}
assertEquals(expected, parser.finish())
}
}
/** 一整段只有标记时,收口也必须是字面(与一次性解析一致;未闭合的 `<p>` 同样照字面留着)。 */
@Test
fun streamingUnclosedInlineTagFallsBackOnFinish() {
val parser = StreamingContentParser()
parser.onDelta("<p>日落 <b>18:35")
assertEquals(listOf("<p>日落 <b>18:35"), parser.finish().map { it.body })
}
// ------------------------------------------------------------------
// 回归:正文里一串尖括号不许把后面的真标签「遮住」,也不许把扫描拖成 O(N²)
// ------------------------------------------------------------------
@Test
fun angleBracketsBeforeATagDoNotHideIt() {
assertEquals(
listOf(run("1 < 2 且 3 > 2,日落 "), run("18:35", InlineStyle.Bold)),
parseInlineMarkup("1 < 2 且 3 > 2,日落 <b>18:35</b>"),
)
// 半路那个 `<` 与它后面那个 `>` 之间还夹着一个真标签:不能「从 `<` 一路找到下一个 `>`」
// 就把整段跳过(那样 `<b>` 会被连同它一起跳过,粗体永远不生效)。
assertEquals(
listOf(run("1 < 2 "), run("甲", InlineStyle.Bold)),
parseInlineMarkup("1 < 2 <b>甲</b>"),
)
}
/**
* ⚠️ 与 `ChatContentParserTest` 那条复杂度回归同一性质:阈值只用来区分「线性」与「平方」,
* 不是耗时指标。正文里成串的 `<` 若用 `indexOf('>', lt)` 逐个找配对(找不到就一路扫到串尾),
* 这里就会退回 O(N²)——W63 刚在同一片区域修掉两处同形状的问题。
*/
@Test
fun aHugeRunOfAngleBracketsStaysWithinALinearBudget() {
val count = 20_000
for (body in listOf("<".repeat(count), "a<b".repeat(count / 3))) {
val startedAt = System.nanoTime()
val bubble = parse("<p>$body</p>").single()
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000
assertEquals(body, bubble.body)
assertTrue(bubble.runs.isEmpty())
assertTrue(
"${body.length} 字符(一串普通尖括号)用了 ${elapsedMs}ms——复杂度退回 O(N²) 了?",
elapsedMs < 2_000,
)
}
}
// ------------------------------------------------------------------
// 工具
// ------------------------------------------------------------------
private fun run(text: String, vararg styles: InlineStyle) = InlineRun(text, styles.toSet())
private fun List<InlineRun>.concat(): String = joinToString("") { it.text }
}