feat(W67): 正文行内标签渲染 —— <b>/<strong>、<i>/<em>、<code>、<br> 四类(成对才认、只认小写、未配对/嵌套错位一律字面)
改了什么: - 新增 `domain/parse/InlineMarkup.kt`:纯函数 `parseInlineMarkup(text) -> List<InlineRun>` (`InlineRun(text, styles: Set<InlineStyle>)`,样式只有 Bold / Italic / Code;`<br>` 不是样式、 它是字符 `\n`)。相邻同样式的片段合并,两端空白去掉。整段只有标记没有字时(`<b></b>` / `<br>`) 返回空列表 = 回落字面原文。 - `ParsedBubble` 加一个**有默认值**的字段 `runs: List<InlineRun> = emptyList()`(既有构造点无需改动); `toBubble` 在**段落定稿之后**对最终正文调一次 `parseInlineMarkup`:`body` 因此是正文的渲染文本 (标签已消费、`<br>` 已换成换行),而 `parts` 仍是逐字原文——编辑框 / 搜索片段 / 通知文案读的是它。 - `MessageBubble`:`BubbleBody` 多收 `runs` + `trailingCursor`;`runs` 非空时用一个 `AnnotatedString` 承载粗体 / 斜体 / 等宽底色 / 换行(`<code>` 底色:她侧 `surfaceContainerHigh`、主人侧 `onPrimary` 压 18% 浅底),光标在片段**之外**补上(不继承她最后一段的样式)。`runs` 为空走原来的 `Text(text)`, 逐像素不变(绝大多数消息都走这一档)。 - `ChatContentParser.stablePrefixEnd` 的扣留词表补上行内开标签(`TAG_NAMES` = 段落/旁白 + `INLINE_TAG_NAMES`, 两边由同一份名单生成的正则判):未闭合的行内开标签同样入栈扣留;`<br>` 自闭合、**不入栈** (入了就永远弹不出来),只在残缺(`<br`)时按词表扣留。 - 新增 `InlineMarkupTest`:36 条用例(四类标签各自的片段形状、6 条边界逐条、星号语义反证、 流式扣留与「只增不改口」、以及编辑/搜索/分享三个调用点的护栏、复杂度回归)。 为什么: - 只认小写:`parse` 只认小写 `<p>`(`<P>` 字面显示是既有测试钉死的),且流式扣留侧的标签名扫描只认 `a..z`——行内标签若认大小写,扣留与消费两边就分叉,中间态会把 `<B>` 当正文吐上屏、收口时再吃掉 (屏幕上显示过的字会改口)。实测命中的 2 条也都小写。 - **成对 + 正确嵌套才认,其余一律字面**:与既有「未闭合/畸形标签回落普通文本」同一口径,也保证绝不丢字 (`1 < 2`、`a<b`、`<b`、`<p2>`、`<B>` 全部原样)。正文里的 `a<b>c` 是既有测试钉着的字面文本, 行内标签没有「段落结构已被完整块定死」那种语境,所以不套用 W55 的落单标签剥离容错。 - `<code>` 里不再解析:HTML 语义上它就是「原样文本」容器,也正是「想显示尖括号怎么办」的答案。 - 不碰 Markdown 的 `*` / `_`:星号已被 `ROLE_RE` 占成旁白/动作语义(既有测试钉着),再给同两个星号 一个「斜体」的说法就是改既有语义。`#`/列表/表格/围栏/`[链接](url)`/`<a href>`/HTML 实体一律不做, 逐条理由写在 `InlineMarkup.kt` 的文件头 KDoc(0 命中、或需单独拍板)。 - 行内解析刻意**不**放进 `tokenize`:分段、旁白归属、落单标签剥离与 W63 那两处线性优化一个字不动, `<br>` 因此只换行、不分气泡。 - 旁白/动作行不做行内解析:它读的是 `parts` 原文,与分享预览那行同源,而预览侧(`ShareConfirmPanel`) 本次是红线——只改这边就等于让「预览 == 气泡」在旁白上重新裂开。 验收:`assembleDebug` 通过(`app/build/outputs/apk/debug/app-debug.apk`,47,308,566 B); `testDebugUnitTest` 全量 **126 suites / 1545 tests / 0 failures / 0 errors**(基线 125/1509, 本次新增 1 个测试类 + 36 条用例)。
This commit is contained in:
1 parent
6d4a37e484
commit
d7d93b4cb0
4 files changed
+947
-18
No files matched your search
@@ -19,6 +19,11 @@ import kotlin.math.min
|
|||||||
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
|
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
|
||||||
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
|
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
|
||||||
* 关键词 旁白/内心 → narration,其余 → action。
|
* 关键词 旁白/内心 → narration,其余 → action。
|
||||||
|
* - 行内标签(`InlineMarkup.kt`):`<b>`/`<strong>`、`<i>`/`<em>`、`<code>`、`<br>`/`<br/>` 四类,
|
||||||
|
* 成对才认、只有小写、未配对或嵌套错位一律字面回落(口径与理由见 `parseInlineMarkup`)。
|
||||||
|
* 它在**段落定稿之后**才做([toBubble] 里对最终正文做一次),所以既不参与、也改不动上面的
|
||||||
|
* 分段与归属:`<br>` 只换行、**不分气泡**。渲染文本落 `body` + `runs`,而片段 `parts` 仍是
|
||||||
|
* 逐字原文——编辑框、搜索片段、通知文案读的都是 `parts`,那些地方一个字都不许改。
|
||||||
* - 落单标签容错:服务端确实会产出不配对的段落标签(实测形态 `<p><p>正文</p>`:多一个 `<p>`、
|
* - 落单标签容错:服务端确实会产出不配对的段落标签(实测形态 `<p><p>正文</p>`:多一个 `<p>`、
|
||||||
* 只有一个 `</p>`),块外也可能出现孤立的旁白标签。这类标签一律**剥掉**,绝不落进正文——
|
* 只有一个 `</p>`),块外也可能出现孤立的旁白标签。这类标签一律**剥掉**,绝不落进正文——
|
||||||
* 它们本就是服务端产出的标记、不是她说的话;渲染成正文等于往气泡里塞一串用户既看不懂、
|
* 它们本就是服务端产出的标记、不是她说的话;渲染成正文等于往气泡里塞一串用户既看不懂、
|
||||||
@@ -35,7 +40,9 @@ import kotlin.math.min
|
|||||||
* 也保持字面文本,这是既有契约(`unclosedActionFallsBackToText` / `strayClosingTagStaysText`)。
|
* 也保持字面文本,这是既有契约(`unclosedActionFallsBackToText` / `strayClosingTagStaysText`)。
|
||||||
*
|
*
|
||||||
* `ParsedBubble` 结构(每条气泡渲染一个):
|
* `ParsedBubble` 结构(每条气泡渲染一个):
|
||||||
* - `body` :气泡正文(仅 Text 部分按原顺序拼接后 trim),已剔除全部旁白/动作块;
|
* - `body` :气泡正文的**渲染文本**(仅 Text 部分按原顺序拼接后 trim,再消费掉四种行内标签:
|
||||||
|
* `<br>` 已变成换行、标签字符已去掉),已剔除全部旁白/动作块;整段只有标记没有字时回落字面原文;
|
||||||
|
* - `runs` :`body` 的行内样式切分(非空时拼起来就是 `body`;空列表 = 按字面画);
|
||||||
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
|
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
|
||||||
* - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」);
|
* - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」);
|
||||||
* - `tail` :要渲染在气泡下方的旁白/动作块(尾部连续块)。
|
* - `tail` :要渲染在气泡下方的旁白/动作块(尾部连续块)。
|
||||||
@@ -47,6 +54,9 @@ import kotlin.math.min
|
|||||||
* 因此 `finish() == parse(全部 delta 拼接)` 恒成立(逐字符/任意切分均一致)。
|
* 因此 `finish() == parse(全部 delta 拼接)` 恒成立(逐字符/任意切分均一致)。
|
||||||
* - 未确定片段从以下最早位置开始整体扣留,不产出半截结果:
|
* - 未确定片段从以下最早位置开始整体扣留,不产出半截结果:
|
||||||
* 1) 未闭合的 `<action>` / `<narration>` / `<p>` 开标签(从该开标签起扣留);
|
* 1) 未闭合的 `<action>` / `<narration>` / `<p>` 开标签(从该开标签起扣留);
|
||||||
|
* 行内开标签同样入栈扣留(词表见 [TAG_NAMES]):不扣的话中间态会把 `<b>` 当字面吐上屏
|
||||||
|
* (`日落 <b>18`)、等 `</b>` 到了又被吃掉,屏幕上已经显示过的两个字就改口了。
|
||||||
|
* `<br>` 是自闭合的,**不入栈**(入了就永远弹不出来),它只在残缺(`<br`)时按词表扣留。
|
||||||
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`),仅当它可能继续补全为合法标签时才扣留;
|
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`),仅当它可能继续补全为合法标签时才扣留;
|
||||||
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
|
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
|
||||||
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
|
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
|
||||||
@@ -83,6 +93,15 @@ data class ParsedBubble(
|
|||||||
val parts: List<ContentPart>,
|
val parts: List<ContentPart>,
|
||||||
val head: List<ContentPart>,
|
val head: List<ContentPart>,
|
||||||
val tail: List<ContentPart>,
|
val tail: List<ContentPart>,
|
||||||
|
/**
|
||||||
|
* `body` 的行内样式切分(`<b>` / `<i>` / `<code>`,见 [parseInlineMarkup]):非空时拼起来
|
||||||
|
* **就是** [body]。空列表 = 这段正文没有可渲染的行内标签,按字面画——**绝大多数消息都走这一档**。
|
||||||
|
*
|
||||||
|
* 有默认值,是因为只有 [toBubble] 才该填它:其余构造点要么本来就该按字面画(`plainBubble`、
|
||||||
|
* 分享预览的兜底 `sharePreviewBlocks`),要么是测试里的手搓气泡。这也让「加一个字段」不必
|
||||||
|
* 惊动任何一个既有调用方。
|
||||||
|
*/
|
||||||
|
val runs: List<InlineRun> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val isEmpty: Boolean
|
val isEmpty: Boolean
|
||||||
get() = body.isEmpty() && head.isEmpty() && tail.isEmpty()
|
get() = body.isEmpty() && head.isEmpty() && tail.isEmpty()
|
||||||
@@ -116,8 +135,14 @@ private const val FULL_STAR_MAX_LEN = 40
|
|||||||
/**
|
/**
|
||||||
* 已知标签词表(只有小写,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
|
* 已知标签词表(只有小写,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
|
||||||
* 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面这组同词表的正则,两边必须同源。
|
* 补全残缺标签时用;[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 OPEN_ROLE_TAG_RE = Regex("^<\\s*(action|narration)\\s*>$")
|
||||||
private val CLOSE_ROLE_TAG_RE = Regex("^</\\s*(action|narration)\\s*>$")
|
private val CLOSE_ROLE_TAG_RE = Regex("^</\\s*(action|narration)\\s*>$")
|
||||||
private val OPEN_P_TAG_RE = Regex("^<p(\\s[^>]*)?>$")
|
private val OPEN_P_TAG_RE = Regex("^<p(\\s[^>]*)?>$")
|
||||||
@@ -481,9 +506,20 @@ private fun toBubble(tokens: List<Tok>): ParsedBubble? {
|
|||||||
tail.add(part)
|
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
|
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? {
|
private fun openTagName(tag: String): String? {
|
||||||
OPEN_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
|
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? {
|
private fun closeTagName(tag: String): String? {
|
||||||
CLOSE_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
|
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 实体(`<` `&` ` ` …)不做**:实测 0 命中;实体是一张**开放的清单**
|
||||||
|
* (`'` / `'` / 各种命名实体……),做一半比不做更糟——会出现「`&` 解了、`'`
|
||||||
|
* 却原样显示」这种谁也说不清的口径。想在正文里显示尖括号,今天有 `<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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
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.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import top.yeij.cyrene.core.prefs.LocalAppPrefs
|
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.chat.voiceNote
|
||||||
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
|
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
|
||||||
import top.yeij.cyrene.domain.parse.ContentPart
|
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.ParsedBubble
|
||||||
import top.yeij.cyrene.domain.parse.parse
|
import top.yeij.cyrene.domain.parse.parse
|
||||||
import top.yukonga.miuix.kmp.basic.Icon
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
@@ -582,8 +591,18 @@ private fun Bubble(
|
|||||||
gray: Boolean = false,
|
gray: Boolean = false,
|
||||||
) {
|
) {
|
||||||
AnnotationLine(bubble.head)
|
AnnotationLine(bubble.head)
|
||||||
val body = if (showCursor && isLast && bubble.body.isNotEmpty()) bubble.body + CURSOR else bubble.body
|
val withCursor = showCursor && isLast && bubble.body.isNotEmpty()
|
||||||
if (body.isNotEmpty()) BubbleBody(body, isUser, onLongPress, gray)
|
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)
|
AnnotationLine(bubble.tail)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,7 +632,14 @@ private fun LegBadge(text: String, onClick: (() -> Unit)?) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 旁白/动作:灰斜体小字,依附在气泡上/下。 */
|
/**
|
||||||
|
* 旁白/动作:灰斜体小字,依附在气泡上/下。
|
||||||
|
*
|
||||||
|
* 这一行**不做行内解析**(正文那四类标签在这里照原样显示)。理由是它读的是 `parts` 里的原文,
|
||||||
|
* 而分享预览那行读的是同一批原文(`ShareConfirmPanel.ShareConfirmAnnotation`)——那边是红线、
|
||||||
|
* 本次一个字都不能动。只在这边渲染,就等于让「预览里看到的 == 发出去之后看到的」在旁白上又裂一次,
|
||||||
|
* 而 W65 刚把这道缝补上。要连带做,得两处一起改并单独拍板(实测 `<action>` 里带 `<b>` 的 0 命中)。
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AnnotationLine(parts: List<ContentPart>) {
|
private fun AnnotationLine(parts: List<ContentPart>) {
|
||||||
if (!LocalAppPrefs.current.showNarration) return
|
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
|
@Composable
|
||||||
private fun BubbleBody(
|
private fun BubbleBody(
|
||||||
text: String,
|
text: String,
|
||||||
|
runs: List<InlineRun>,
|
||||||
isUser: Boolean,
|
isUser: Boolean,
|
||||||
onLongPress: (() -> Unit)? = null,
|
onLongPress: (() -> Unit)? = null,
|
||||||
gray: Boolean = false,
|
gray: Boolean = false,
|
||||||
|
trailingCursor: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
val contentColor = when {
|
||||||
|
gray -> MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||||
|
isUser -> MiuixTheme.colorScheme.onPrimary
|
||||||
|
else -> MiuixTheme.colorScheme.onSurfaceContainer
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.widthIn(max = 300.dp)
|
.widthIn(max = 300.dp)
|
||||||
@@ -716,19 +756,68 @@ private fun BubbleBody(
|
|||||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
) {
|
) {
|
||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
Text(
|
if (runs.isEmpty()) {
|
||||||
text = text,
|
Text(
|
||||||
style = MiuixTheme.textStyles.main,
|
text = text,
|
||||||
color = when {
|
style = MiuixTheme.textStyles.main,
|
||||||
gray -> MiuixTheme.colorScheme.onSurfaceVariantSummary
|
color = contentColor,
|
||||||
isUser -> MiuixTheme.colorScheme.onPrimary
|
)
|
||||||
else -> MiuixTheme.colorScheme.onSurfaceContainer
|
} 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 & b", "1 < 2", "空 格", "'引号'")) {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
Reference in new issue
Block a user