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 e65a8c0..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 @@ -40,12 +40,21 @@ data class EditableMessage( ) /** - * 已知标签的容差,与 [parse] 逐字对齐(只认解析器真正认识的那几个标签): - * `
`(可带属性)/ `
` / ``(可带属性)/ `
` / `` 在这里也是普通文本。
+ *
+ * **行内标签(`` / ` ` / ` ]*)?>| … ` 时回退按空行 `\n\s*\n` 切分;
* 单个 `\n` 是句内换行,绝不切分。未闭合的 ` ` 不构成块,按普通文本处理(流式下会先缓存)。
* ` ` 之外的非空白正文不会被丢弃:并入相邻气泡(若只是旁白/空白则依附之,否则自成一气泡)。
+ * - ` ` 同级
+ * 的气泡/消息分隔符**(契约 v0.89 的更正:`回执三十一`「对消息内的 ` 甲 `)⇒ 同样两条;
+ * 中间的空气泡照旧丢弃(` 甲 正文 `、
* 只有一个 ` ` 开标签(从该开标签起扣留);
- * 行内开标签同样入栈扣留(词表见 [TAG_NAMES]):不扣的话中间态会把 `` 当字面吐上屏
- * (`日落 18`)、等 `` 到了又被吃掉,屏幕上已经显示过的两个字就改口了。
- * ` ]*)?>$")
private val CLOSE_P_TAG_RE = Regex("^ ` 同级的「气泡/消息分隔符」)。
+ *
+ * 认下来的形状:` ` 开标签是唯一例外,因为它在更早一层就被 [P_BLOCK_RE] 整体吃掉了,
+ * 属性里的 ` ` 块(决定气泡边界),再按区剥落单标签,最后在每个文本区做旁白/星号解析。 */
+/**
+ * 先切 ` ` 块(决定气泡边界),再按区剥落单标签与切 ` ` 一路回溯到串尾——正文里 N 个落单标签时
@@ -214,36 +246,75 @@ private fun tokenize(raw: String): List ` 及其后内容按既有契约整段回落字面文本,` ` 族:` ` 同级的分隔符**
+ * (`回执三十一`「对消息内的 ` ` 同级,就该与 ` `
+ * 走**同一套机制、同一个粒度**:产生 [Tok.Break]、由 `buildBubbles` 分组、空气泡照旧丢弃。
+ * 行内层(`InlineMarkup`)因此完全看不见 ` ` 同级」这句话本身就规定了层序——分隔符先于行内解析,行内解析永远只看到**一段**;
+ * 跨过分隔符的标签在结构上不可能成立(一个气泡 = 一条消息,IM 出站也是先按气泡切开再剥标签,
+ * 后端 `sanitize.go` 的「成对平衡 ⇒ 剥标签留文本 / 不平衡 ⇒ 原样保留」同样是在**单条消息内**
+ * 判平衡的)⇒ 两端对同一段文本的结论天然一致;
+ * 2. 反过来(先整体行内解析、再按 ` ` 后面还有没有正文」看的是
+ * 整段原文,` ` / ` $raw 甲粗乙` …)故意不在**这份容差里:它们是渲染用的标记,但编辑框
+ * 编辑的是**源码本身**,而 `parts`(这一份的来源)是**逐字原文**——所以 `日落 18:35` 在
+ * 编辑框里连 `` 一起显示、也一起保存,这里一个字符都不动它。
+ *
+ * **`
` 同理**(契约 v0.89 更正后它是**气泡分隔符**、不是行内标签):只含 `
` 的消息在这份
+ * 容差里仍是纯文本 ⇒ 编辑框里逐字恒等(`
` 原样显示、原样保存);只有原文**另有**已知标记时
+ * 才走剥标记那条路,那时 `
` 与 `
`(`
` / `
` / `` 三种写法等价,大小写不敏感,属性不解析):**与 `
` 始终处理为多条消息
+ * 隔断」)。它**不是行内标签**:`
乙
乙`(无 `
乙`、`
`/`
` 四类,
- * 成对才认、只有小写、未配对或嵌套错位一律字面回落(口径与理由见 `parseInlineMarkup`)。
- * 它在**段落定稿之后**才做([toBubble] 里对最终正文做一次),所以既不参与、也改不动上面的
- * 分段与归属:`
` 只换行、**不分气泡**。渲染文本落 `body` + `runs`,而片段 `parts` 仍是
+ * - 行内标签(`InlineMarkup.kt`,契约 v0.89):``/``、``/``、`` 三类
+ * (五项标签名,`u`/`s` 已按 `回执三十` 收窄撤掉),成对才认、**标签名大小写不敏感**、
+ * **属性一律不解析**(`` 就是粗体)、白名单外按纯文本原样显示、
+ * **结构不平衡(未闭合 / 落单闭标签 / 交错)⇒ 该气泡的正文整段降级为原文**
+ * (口径与理由见 `parseInlineMarkup`)。
+ * 它在**段落与 `
` 都定稿之后**才做([toBubble] 里对最终正文做一次,**逐气泡各自一份**),
+ * 所以既不参与、也改不动上面的分段与归属。渲染文本落 `body` + `runs`,而片段 `parts` 仍是
* 逐字原文——编辑框、搜索片段、通知文案读的都是 `parts`,那些地方一个字都不许改。
* - 落单标签容错:服务端确实会产出不配对的段落标签(实测形态 `
` 已变成换行、标签字符已去掉),已剔除全部旁白/动作块;整段只有标记没有字时回落字面原文;
+ * - `body` :气泡正文的**渲染文本**(仅 Text 部分按原顺序拼接后 trim,再消费掉行内标签:
+ * 白名单标签字符已去掉,白名单外与属性照旧逐字留着),已剔除全部旁白/动作块;
+ * 整段只有标记没有字、或行内标签**结构不平衡**时回落/降级为字面原文;
* - `runs` :`body` 的行内样式切分(非空时拼起来就是 `body`;空列表 = 按字面画);
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
* - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」);
@@ -54,10 +63,14 @@ import kotlin.math.min
* 因此 `finish() == parse(全部 delta 拼接)` 恒成立(逐字符/任意切分均一致)。
* - 未确定片段从以下最早位置开始整体扣留,不产出半截结果:
* 1) 未闭合的 `
` 是自闭合的,**不入栈**(入了就永远弹不出来),它只在残缺(`
` 当字面吐上屏(`日落 18`)、等 `` 到了又被吃掉,屏幕上已经显示过的
+ * 两个字就改口了。**大小写与属性也走同一份判据**(v0.89):`` 在扣留侧同样是
+ * 开标签,否则 `日落 18` 会先按字面吐上屏、收口时再变成粗体。
+ * `
` 是分隔符、不成对,**不入栈**(入了就永远弹不出来);它只在残缺(`
` 直接放行
+ * ——分隔符一出现,前缀就已经可解析了。
+ * 2) 尾部残缺标签(如 ``,见 [parseInlineMarkup]):非空时拼起来
- * **就是** [body]。空列表 = 这段正文没有可渲染的行内标签,按字面画——**绝大多数消息都走这一档**。
+ * **就是** [body]。空列表 = 这段正文按字面画——**绝大多数消息都走这一档**(没有标签、
+ * 或者结构不平衡被整段降级、或者整段只有标记没有字)。
*
* 有默认值,是因为只有 [toBubble] 才该填它:其余构造点要么本来就该按字面画(`plainBubble`、
* 分享预览的兜底 `sharePreviewBlocks`),要么是测试里的手搓气泡。这也让「加一个字段」不必
@@ -133,21 +147,37 @@ private val BLANK_LINE_RE = Regex("\\n\\s*\\n")
private const val FULL_STAR_MAX_LEN = 40
/**
- * 已知标签词表(只有小写,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
- * 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面这组同词表的正则,两边必须同源。
+ * 段落/旁白标签名(**只有小写**,与 `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] 真正消费用的那组)。
- * 任缺一半,中间态就会把标签当正文吐上屏、收口时才吃掉——屏幕上已经显示过的字就改口了。
+ * `
` 不在这个名单里:它是分隔符、且**大小写不敏感**(见 [BR_TAG_RE]),判据单独走
+ * [couldBeLineBreakNamePrefix],否则 `
$")
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 }
@@ -565,8 +637,9 @@ private fun stablePrefixEnd(buf: String): Int {
private fun openTagName(tag: String): String? {
OPEN_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
if (OPEN_P_TAG_RE.matches(tag)) return "p"
- // 行内开标签也入栈。`
` 是自闭合的,**不入栈**(入了就永远弹不出来,扣留点会永久停在
- // 它前面,后面的话再也显示不出来)——[openInlineTagName] 的名单里本来就没有它。
+ // 行内开标签也入栈(`` 这类形态由 [parseInlineTag] 判,与解析侧同源)。
+ // `
` 是自闭合的,**不入栈**(入了就永远弹不出来,扣留点会永久停在它前面,后面的话再也
+ // 显示不出来)——[openInlineTagName] 对 `
` / `
` / `` 一律返回 null。
return openInlineTagName(tag)
}
@@ -576,20 +649,49 @@ private fun closeTagName(tag: String): String? {
return closeInlineTagName(tag)
}
-/** 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。 */
+/**
+ * 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。
+ *
+ * 词表分两半,每一半都与**自己的消费侧**同源,两条口径都在这里逐字对齐:
+ * - 段落/旁白只认小写(`
` 分隔符**大小写不敏感**(契约 v0.89,[parseInlineTag] / [BR_TAG_RE])
+ * ——``
+ * 与 `
`、`
` 与行内标签大小写不敏感。
+ */
+private fun couldBeTagName(name: String): Boolean {
+ if (name.all { it in 'a'..'z' } && STRUCTURE_TAG_NAMES.any { it.startsWith(name) }) return true
+ val lower = name.lowercase()
+ if (couldBeLineBreakNamePrefix(lower)) return true
+ return couldBeInlineTagNamePrefix(lower)
+}
+
+/**
+ * [name](已小写)是否可能是 `
` 分隔符的标签名前缀——`
` 到了再变成两条气泡。
+ */
+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 f5cd6b6..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,46 +3,87 @@ package top.yeij.cyrene.domain.parse
/*
* InlineMarkup —— 气泡正文里「一小撮行内标签」的解析(纯 Kotlin,无 Android / Compose 依赖)。
*
- * 做四类,**成对才认**,只有小写:
- * - `` / `` → 粗体;`` / `` → 斜体;
- * - `` → 等宽 + 轻微底色(`` 里不再解析,见下);
- * - `
` / `
` → 换行(自闭合,不成对)。
+ * 口径 = 后端 2026-10-07 定下的**契约 v0.89** 及其同日更正(跨端必须逐字一致,否则同一个 ``
+ * 在她那台设备上粗、在这台上不粗)。四条硬口径:
+ * 1. 白名单 = `b` / `strong` · `i` / `em` · `code`(**成对**,五项标签名);标签名**大小写不敏感**;
+ * 2. **属性一律不解析**:`` 只取标签名,属性整段丢掉、绝不落成任何视图属性;
+ * 3. **白名单外一律按纯文本原样显示**:`"
+ assertTrue(parseInlineMarkup(raw).isEmpty())
+ assertEquals(raw, parse("