diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/MessageEditText.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/MessageEditText.kt index 43791c0..741939c 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/MessageEditText.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/MessageEditText.kt @@ -47,9 +47,14 @@ data class EditableMessage( * 判成标记就会顺手把它们当标签吃掉——那才是真正的静默丢东西。大小写同样照解析器来: * `parse` 只认小写的 `
` / ` ` 在这里也是普通文本。
*
- * **行内标签(`` / ` ` / ` ]*)?>| … ` 时回退按空行 `\n\s*\n` 切分;
* 单个 `\n` 是句内换行,绝不切分。未闭合的 ` ` 不构成块,按普通文本处理(流式下会先缓存)。
* ` ` 之外的非空白正文不会被丢弃:并入相邻气泡(若只是旁白/空白则依附之,否则自成一气泡)。
+ * - ` ` 同级
+ * 的气泡/消息分隔符**(契约 v0.89 的更正:`回执三十一`「对消息内的 ` 甲 `)⇒ 同样两条;
+ * 中间的空气泡照旧丢弃(` 甲 正文 `、
* 只有一个 ` ]*)?>$")
private val CLOSE_P_TAG_RE = Regex("^ ` 同级的「气泡/消息分隔符」)。
+ *
+ * 认下来的形状:` ` 开标签是唯一例外,因为它在更早一层就被 [P_BLOCK_RE] 整体吃掉了,
+ * 属性里的 ` ` 块(决定气泡边界),再按区剥落单标签,最后在每个文本区做旁白/星号解析。 */
+/**
+ * 先切 ` ` 块(决定气泡边界),再按区剥落单标签与切 ` ` 一路回溯到串尾——正文里 N 个落单标签时
@@ -215,36 +246,75 @@ private fun tokenize(raw: String): List ` 及其后内容按既有契约整段回落字面文本,` ` 族:` ` 同级的分隔符**
+ * (`回执三十一`「对消息内的 ` ` 同级,就该与 ` `
+ * 走**同一套机制、同一个粒度**:产生 [Tok.Break]、由 `buildBubbles` 分组、空气泡照旧丢弃。
+ * 行内层(`InlineMarkup`)因此完全看不见 ` ` 同级」这句话本身就规定了层序——分隔符先于行内解析,行内解析永远只看到**一段**;
+ * 跨过分隔符的标签在结构上不可能成立(一个气泡 = 一条消息,IM 出站也是先按气泡切开再剥标签,
+ * 后端 `sanitize.go` 的「成对平衡 ⇒ 剥标签留文本 / 不平衡 ⇒ 原样保留」同样是在**单条消息内**
+ * 判平衡的)⇒ 两端对同一段文本的结论天然一致;
+ * 2. 反过来(先整体行内解析、再按 ` ` 后面还有没有正文」看的是
+ * 整段原文,` ` / `` / `
` …)故意不在**这份容差里:它们是渲染用的标记,但编辑框
+ * **行内标签(`` / `` …)故意不在**这份容差里:它们是渲染用的标记,但编辑框
* 编辑的是**源码本身**,而 `parts`(这一份的来源)是**逐字原文**——所以 `日落 18:35` 在
* 编辑框里连 `` 一起显示、也一起保存,这里一个字符都不动它。
+ *
+ * **`
` 同理**(契约 v0.89 更正后它是**气泡分隔符**、不是行内标签):只含 `
` 的消息在这份
+ * 容差里仍是纯文本 ⇒ 编辑框里逐字恒等(`
` 原样显示、原样保存);只有原文**另有**已知标记时
+ * 才走剥标记那条路,那时 `
` 与 `
`(`
` / `
` / `` 三种写法等价,大小写不敏感,属性不解析):**与 `
` 始终处理为多条消息
+ * 隔断」)。它**不是行内标签**:`
乙
乙`(无 `
乙`、``、
- * ``、`
`(`
`/`
`/`` 等价)六类,成对才认、**标签名大小写不敏感**、
+ * - 行内标签(`InlineMarkup.kt`,契约 v0.89):``/``、``/``、`` 三类
+ * (五项标签名,`u`/`s` 已按 `回执三十` 收窄撤掉),成对才认、**标签名大小写不敏感**、
* **属性一律不解析**(`` 就是粗体)、白名单外按纯文本原样显示、
* **结构不平衡(未闭合 / 落单闭标签 / 交错)⇒ 该气泡的正文整段降级为原文**
* (口径与理由见 `parseInlineMarkup`)。
- * 它在**段落定稿之后**才做([toBubble] 里对最终正文做一次),所以既不参与、也改不动上面的
- * 分段与归属:`
` 只换行、**不分气泡**。渲染文本落 `body` + `runs`,而片段 `parts` 仍是
+ * 它在**段落与 `
` 都定稿之后**才做([toBubble] 里对最终正文做一次,**逐气泡各自一份**),
+ * 所以既不参与、也改不动上面的分段与归属。渲染文本落 `body` + `runs`,而片段 `parts` 仍是
* 逐字原文——编辑框、搜索片段、通知文案读的都是 `parts`,那些地方一个字都不许改。
* - 落单标签容错:服务端确实会产出不配对的段落标签(实测形态 `
` 已变成换行、白名单标签字符已去掉,白名单外与属性照旧逐字留着),已剔除全部旁白/动作块;
+ * 白名单标签字符已去掉,白名单外与属性照旧逐字留着),已剔除全部旁白/动作块;
* 整段只有标记没有字、或行内标签**结构不平衡**时回落/降级为字面原文;
* - `runs` :`body` 的行内样式切分(非空时拼起来就是 `body`;空列表 = 按字面画);
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
@@ -62,8 +67,10 @@ import kotlin.math.min
* 中间态会把 `` 当字面吐上屏(`日落 18`)、等 `` 到了又被吃掉,屏幕上已经显示过的
* 两个字就改口了。**大小写与属性也走同一份判据**(v0.89):`` 在扣留侧同样是
* 开标签,否则 `日落 18` 会先按字面吐上屏、收口时再变成粗体。
- * `
` 是自闭合的,**不入栈**(入了就永远弹不出来),它只在残缺(`
` 是分隔符、不成对,**不入栈**(入了就永远弹不出来);它只在残缺(`
` 直接放行
+ * ——分隔符一出现,前缀就已经可解析了。
+ * 2) 尾部残缺标签(如 `` / `` / ``,见 [parseInlineMarkup]):
- * 非空时拼起来**就是** [body]。空列表 = 这段正文按字面画——**绝大多数消息都走这一档**(没有标签、
+ * `body` 的行内样式切分(`` / `` / ``,见 [parseInlineMarkup]):非空时拼起来
+ * **就是** [body]。空列表 = 这段正文按字面画——**绝大多数消息都走这一档**(没有标签、
* 或者结构不平衡被整段降级、或者整段只有标记没有字)。
*
* 有默认值,是因为只有 [toBubble] 才该填它:其余构造点要么本来就该按字面画(`plainBubble`、
@@ -142,6 +149,9 @@ private const val FULL_STAR_MAX_LEN = 40
/**
* 段落/旁白标签名(**只有小写**,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
* 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面那组同词表的正则,两边必须同源。
+ *
+ * `
` 不在这个名单里:它是分隔符、且**大小写不敏感**(见 [BR_TAG_RE]),判据单独走
+ * [couldBeLineBreakNamePrefix],否则 `
$")
@@ -149,6 +159,25 @@ private val CLOSE_ROLE_TAG_RE = Regex("^\\s*(action|narration)\\s*>$")
private val OPEN_P_TAG_RE = Regex("^
` 分隔符的标签名([BR_TAG_RE] 与流式扣留侧共用,别在两处各写一遍)。 */
+private const val LINE_BREAK_TAG_NAME = "br"
+
+/**
+ * `
` **分隔符**(契约 v0.89 更正:`
` 不是行内标签,而是与 `
` / `
` / `
` / `` / `
` …——
+ * - **三种写法等价**,前导 `/` 与自闭合 `/` 都照收(HTML5 口径,契约写死);
+ * - **大小写不敏感**(`
` / `
` 同样算):v0.89 那句「标签名大小写不敏感」把 `br` 一起罩住了,
+ * 且后端 IM 出站 `channelbridge/sanitize.go` 的正则本来就是 `(?is)`(`回执三十` 的原话);
+ * - **属性不解析**(`
` 照样分隔),但名字后面必须紧跟 `>`、空白或 `/`:
+ * `
`」的
+ * 文本会被切开。`
` 根本走不到这里。真实产出里没有这种形状(属性本身就不是产出侧的东西)。
+ */
+private val BR_TAG_RE = Regex("<\\s*/?\\s*${LINE_BREAK_TAG_NAME}(\\s[^<>]*|/\\s*)?>", RegexOption.IGNORE_CASE)
+
/** 一次性解析:把一条消息的原始文本拆成若干独立气泡(含旁白依附关系)。 */
fun parse(raw: String): List
` 分隔符,最后在每个文本区做旁白/星号解析。
+ */
private fun tokenize(raw: String): List
` 分隔符切成若干段,每段各自做旁白/星号解析([appendAnnotations]),段与段之间补一个
+ * [Tok.Break](气泡边界)。
+ *
+ * **为什么 `
` 在这一层切**:契约 v0.89 的更正把 `
` 定成**与 `
` 始终处理为多条消息隔断」)。它既然与 `
` —— 那里只剩五个白名单标签。
+ *
+ * **顺序:先切分、再逐段各自做行内解析**(`甲
乙` ⇒ 两段各按「不平衡 ⇒ 不渲染」处理,
+ * 两段都回落原文)。理由:
+ * 1. 「与 `
` 切)会让「渲染文本」与「气泡」互相依赖:`甲
乙`
+ * 会先变成一个粗体片段再被切开,切点落在片段内部,`body` / `runs` 的一致性(`runs` 拼起来
+ * **就是** `body`)当场就没了;
+ * 3. 实现上也最省:行内那层一个字节都不用改(它本来就逐气泡跑一次)。
+ *
+ * 剥标签**先于**切分(判据逐字不变):[stripLooseTags] 里「这个落单 `
` 在那里只是一个普通字符,所以同一输入下它的判定与 W68 完全相同。
+ */
+private fun appendRegion(
+ text: String,
+ out: MutableList
` 造不出新气泡)。runs 为空 = 这段正文按字面画(没有可渲染的
- // 行内标签、或行内标签结构不平衡被整段降级),一个字都不会变。
+ // 行内标签在**段落与 `
` 都定稿之后**才消费(就在这一行,且是**逐气泡各自一份**):上面那套
+ // 分段与归属一个字都不动,分段判据因此完全看不见行内标签(`
` 已经在 [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? {
*
* 词表分两半,每一半都与**自己的消费侧**同源,两条口径都在这里逐字对齐:
* - 段落/旁白只认小写(`
`、`
` 分隔符的标签名前缀——`
` 到了再变成两条气泡。
+ */
+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
diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/parse/InlineMarkup.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/parse/InlineMarkup.kt
index d9455af..679d89f 100644
--- a/app/src/main/kotlin/top/yeij/cyrene/domain/parse/InlineMarkup.kt
+++ b/app/src/main/kotlin/top/yeij/cyrene/domain/parse/InlineMarkup.kt
@@ -3,20 +3,30 @@ package top.yeij.cyrene.domain.parse
/*
* InlineMarkup —— 气泡正文里「一小撮行内标签」的解析(纯 Kotlin,无 Android / Compose 依赖)。
*
- * 口径 = 后端 2026-10-07 傍晚定下的**契约 v0.89**(跨端必须逐字一致,否则同一个 `` 在她那台
- * 设备上粗、在这台上不粗)。四条硬口径:
- * 1. 白名单 = `b` / `strong` · `i` / `em` · `code` · `u` · `s`(成对)· `br`(void,
- * `
` / `
` / `` 按 HTML5 口径等价);标签名**大小写不敏感**;
+ * 口径 = 后端 2026-10-07 定下的**契约 v0.89** 及其同日更正(跨端必须逐字一致,否则同一个 ``
+ * 在她那台设备上粗、在这台上不粗)。四条硬口径:
+ * 1. 白名单 = `b` / `strong` · `i` / `em` · `code`(**成对**,五项标签名);标签名**大小写不敏感**;
* 2. **属性一律不解析**:`` 只取标签名,属性整段丢掉、绝不落成任何视图属性;
* 3. **白名单外一律按纯文本原样显示**:`