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] 逐字对齐(只认解析器真正认识的那几个标签): - * `

`(可带属性)/ `

` / `` / `` / `` / ``。 + * 已知标签的容差,与 [parse] 逐字对齐(只认解析器会**改掉**的那几个标签——行内标签不在其中, + * 见下):`

`(可带属性)/ `

` / `` / `` / `` / ``。 * * 绝不能「见到一个 `<` 就当标记」:用户打的 `1 < 2`、`
`、`<3`、`a` / `` / ``,所以 `

` 在这里也是普通文本。 + * + * **行内标签(`` / `` …)故意不在**这份容差里:它们是渲染用的标记,但编辑框 + * 编辑的是**源码本身**,而 `parts`(这一份的来源)是**逐字原文**——所以 `日落 18:35` 在 + * 编辑框里连 `` 一起显示、也一起保存,这里一个字符都不动它。 + * + * **`
` 同理**(契约 v0.89 更正后它是**气泡分隔符**、不是行内标签):只含 `
` 的消息在这份 + * 容差里仍是纯文本 ⇒ 编辑框里逐字恒等(`
` 原样显示、原样保存);只有原文**另有**已知标记时 + * 才走剥标记那条路,那时 `
` 与 `

` / `

` 一样被换成它本来就在表示的**空行分隔** + * (保存后渲染结果不变,且弹层已经明说标记会被替换)。 */ private val KNOWN_TAG_RE = Regex( "]*)?>|

|<\\s*(action|narration)\\s*>|", diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/parse/ChatContentParser.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/parse/ChatContentParser.kt index 4352a0c..a4c60fe 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/parse/ChatContentParser.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/parse/ChatContentParser.kt @@ -14,15 +14,23 @@ import kotlin.math.min * - `

…

`:每个完整块 = 一条独立气泡;无完整 `

` 时回退按空行 `\n\s*\n` 切分; * 单个 `\n` 是句内换行,绝不切分。未闭合的 `

` 不构成块,按普通文本处理(流式下会先缓存)。 * `

` 之外的非空白正文不会被丢弃:并入相邻气泡(若只是旁白/空白则依附之,否则自成一气泡)。 + * - `
`(`
` / `
` / `
` 三种写法等价,大小写不敏感,属性不解析):**与 `

` 同级 + * 的气泡/消息分隔符**(契约 v0.89 的更正:`回执三十一`「对消息内的 `
` 始终处理为多条消息 + * 隔断」)。它**不是行内标签**:`

甲
乙

` ⇒ 两条气泡,`甲
乙`(无 `

`)⇒ 同样两条; + * 中间的空气泡照旧丢弃(`

甲

乙

` ⇒ `甲`、`乙`)。切分在 [tokenize] 的**段/块这一层** + * 完成([appendRegion]),所以行内解析层(`InlineMarkup`)完全看不见它。 * - `…` / `…`:旁白/动作,不独立成气泡,依附气泡: * 首部连续块 → 挂气泡上方(head);尾部连续块 → 挂下方(tail);夹在正文中间的块 → 上提到 head。 * - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action); * 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。 * 关键词 旁白/内心 → narration,其余 → action。 - * - 行内标签(`InlineMarkup.kt`):``/``、``/``、``、`
`/`
` 四类, - * 成对才认、只有小写、未配对或嵌套错位一律字面回落(口径与理由见 `parseInlineMarkup`)。 - * 它在**段落定稿之后**才做([toBubble] 里对最终正文做一次),所以既不参与、也改不动上面的 - * 分段与归属:`
` 只换行、**不分气泡**。渲染文本落 `body` + `runs`,而片段 `parts` 仍是 + * - 行内标签(`InlineMarkup.kt`,契约 v0.89):``/``、``/``、`` 三类 + * (五项标签名,`u`/`s` 已按 `回执三十` 收窄撤掉),成对才认、**标签名大小写不敏感**、 + * **属性一律不解析**(`` 就是粗体)、白名单外按纯文本原样显示、 + * **结构不平衡(未闭合 / 落单闭标签 / 交错)⇒ 该气泡的正文整段降级为原文** + * (口径与理由见 `parseInlineMarkup`)。 + * 它在**段落与 `
` 都定稿之后**才做([toBubble] 里对最终正文做一次,**逐气泡各自一份**), + * 所以既不参与、也改不动上面的分段与归属。渲染文本落 `body` + `runs`,而片段 `parts` 仍是 * 逐字原文——编辑框、搜索片段、通知文案读的都是 `parts`,那些地方一个字都不许改。 * - 落单标签容错:服务端确实会产出不配对的段落标签(实测形态 `

正文

`:多一个 `

`、 * 只有一个 `

`),块外也可能出现孤立的旁白标签。这类标签一律**剥掉**,绝不落进正文—— @@ -40,8 +48,9 @@ import kotlin.math.min * 也保持字面文本,这是既有契约(`unclosedActionFallsBackToText` / `strayClosingTagStaysText`)。 * * `ParsedBubble` 结构(每条气泡渲染一个): - * - `body` :气泡正文的**渲染文本**(仅 Text 部分按原顺序拼接后 trim,再消费掉四种行内标签: - * `
` 已变成换行、标签字符已去掉),已剔除全部旁白/动作块;整段只有标记没有字时回落字面原文; + * - `body` :气泡正文的**渲染文本**(仅 Text 部分按原顺序拼接后 trim,再消费掉行内标签: + * 白名单标签字符已去掉,白名单外与属性照旧逐字留着),已剔除全部旁白/动作块; + * 整段只有标记没有字、或行内标签**结构不平衡**时回落/降级为字面原文; * - `runs` :`body` 的行内样式切分(非空时拼起来就是 `body`;空列表 = 按字面画); * - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用; * - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」); @@ -54,10 +63,14 @@ import kotlin.math.min * 因此 `finish() == parse(全部 delta 拼接)` 恒成立(逐字符/任意切分均一致)。 * - 未确定片段从以下最早位置开始整体扣留,不产出半截结果: * 1) 未闭合的 `` / `` / `

` 开标签(从该开标签起扣留); - * 行内开标签同样入栈扣留(词表见 [TAG_NAMES]):不扣的话中间态会把 `` 当字面吐上屏 - * (`日落 18`)、等 `` 到了又被吃掉,屏幕上已经显示过的两个字就改口了。 - * `
` 是自闭合的,**不入栈**(入了就永远弹不出来),它只在残缺(`` 当字面吐上屏(`日落 18`)、等 `` 到了又被吃掉,屏幕上已经显示过的 + * 两个字就改口了。**大小写与属性也走同一份判据**(v0.89):`` 在扣留侧同样是 + * 开标签,否则 `日落 18` 会先按字面吐上屏、收口时再变成粗体。 + * `
` 是分隔符、不成对,**不入栈**(入了就永远弹不出来);它只在残缺(`` 直接放行 + * ——分隔符一出现,前缀就已经可解析了。 + * 2) 尾部残缺标签(如 `, /** * `body` 的行内样式切分(`` / `` / ``,见 [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("^$") private val OPEN_P_TAG_RE = Regex("^]*)?>$") private val CLOSE_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)`(`回执三十` 的原话); + * - **属性不解析**(`
` 照样分隔),但名字后面必须紧跟 `>`、空白或 `/`: + * `` / `` / `` 因此**不是**分隔符,只是普通文本(这一条是「字符一个不丢」的闸门)。 + * + * ⚠️ 已知边界:它**不看外层标签的属性结构**——所以写成 `甲` 这种「属性里带 `
`」的 + * 文本会被切开。`

` 开标签是唯一例外,因为它在更早一层就被 [P_BLOCK_RE] 整体吃掉了, + * 属性里的 `
` 根本走不到这里。真实产出里没有这种形状(属性本身就不是产出侧的东西)。 + */ +private val BR_TAG_RE = Regex("<\\s*/?\\s*${LINE_BREAK_TAG_NAME}(\\s[^<>]*|/\\s*)?>", RegexOption.IGNORE_CASE) + /** 一次性解析:把一条消息的原始文本拆成若干独立气泡(含旁白依附关系)。 */ fun parse(raw: String): List { if (raw.isEmpty()) return emptyList() @@ -195,7 +225,9 @@ private sealed interface Tok { object Break : Tok } -/** 先切 `

` 块(决定气泡边界),再按区剥落单标签,最后在每个文本区做旁白/星号解析。 */ +/** + * 先切 `

` 块(决定气泡边界),再按区剥落单标签与切 `
` 分隔符,最后在每个文本区做旁白/星号解析。 + */ private fun tokenize(raw: String): List { // 完整块必然以 `

` 收尾,所以最后一个 `

` 之后不可能再有块:切掉尾巴再让正则去扫。不切的话, // 每个 `` 一路回溯到串尾——正文里 N 个落单标签时 @@ -214,36 +246,75 @@ private fun tokenize(raw: String): List { // 一条完整块都没有:`

` 及其后内容按既有契约整段回落字面文本,`` 这类旁白标签 // 落单时同理(`MessageEditTextTest.没闭合的 action 同样原样留着`)。此处只剥掉连「被截断的 // 结构」都算不上的东西:落单的 `

`,与后面什么都没有的空开标签。判据见 [stripLooseTags]。 - appendAnnotations( - stripLooseTags(raw, markupMode = false, stripStrayRoleTags = false), - out, - splitBlankLines = true, - ) + appendRegion(raw, out, markupMode = false, stripStrayRoleTags = false, splitBlankLines = true) return out } var last = 0 for (m in pMatches) { val lead = raw.substring(last, m.range.first) if (lead.isNotEmpty()) { - appendAnnotations(stripLooseTags(lead, true, stripStrayRoleTags = true), out, splitBlankLines = false) + appendRegion(lead, out, markupMode = true, stripStrayRoleTags = true, splitBlankLines = false) } out.add(Tok.Break) val inner = m.groupValues[2] if (inner.isNotEmpty()) { // 块内只剥 `

` 族:`` / `` 落单时保留字面文本,是既有契约 // (unclosedActionFallsBackToText / strayClosingTagStaysText 钉住的行为)。 - appendAnnotations(stripLooseTags(inner, true, stripStrayRoleTags = false), out, splitBlankLines = false) + appendRegion(inner, out, markupMode = true, stripStrayRoleTags = false, splitBlankLines = false) } out.add(Tok.Break) last = m.range.last + 1 } val trail = raw.substring(last) if (trail.isNotEmpty()) { - appendAnnotations(stripLooseTags(trail, true, stripStrayRoleTags = true), out, splitBlankLines = false) + appendRegion(trail, out, markupMode = true, stripStrayRoleTags = true, splitBlankLines = false) } return out } +/** + * 解析一个文本区:先剥落单标签([stripLooseTags],`markupMode` / `stripStrayRoleTags` 由调用方给), + * 再按 `
` 分隔符切成若干段,每段各自做旁白/星号解析([appendAnnotations]),段与段之间补一个 + * [Tok.Break](气泡边界)。 + * + * **为什么 `
` 在这一层切**:契约 v0.89 的更正把 `
` 定成**与 `

` 同级的分隔符** + * (`回执三十一`「对消息内的 `
` 始终处理为多条消息隔断」)。它既然与 `

` 同级,就该与 `

` + * 走**同一套机制、同一个粒度**:产生 [Tok.Break]、由 `buildBubbles` 分组、空气泡照旧丢弃。 + * 行内层(`InlineMarkup`)因此完全看不见 `
` —— 那里只剩五个白名单标签。 + * + * **顺序:先切分、再逐段各自做行内解析**(`甲
乙
` ⇒ 两段各按「不平衡 ⇒ 不渲染」处理, + * 两段都回落原文)。理由: + * 1. 「与 `

` 同级」这句话本身就规定了层序——分隔符先于行内解析,行内解析永远只看到**一段**; + * 跨过分隔符的标签在结构上不可能成立(一个气泡 = 一条消息,IM 出站也是先按气泡切开再剥标签, + * 后端 `sanitize.go` 的「成对平衡 ⇒ 剥标签留文本 / 不平衡 ⇒ 原样保留」同样是在**单条消息内** + * 判平衡的)⇒ 两端对同一段文本的结论天然一致; + * 2. 反过来(先整体行内解析、再按 `
` 切)会让「渲染文本」与「气泡」互相依赖:`甲
乙
` + * 会先变成一个粗体片段再被切开,切点落在片段内部,`body` / `runs` 的一致性(`runs` 拼起来 + * **就是** `body`)当场就没了; + * 3. 实现上也最省:行内那层一个字节都不用改(它本来就逐气泡跑一次)。 + * + * 剥标签**先于**切分(判据逐字不变):[stripLooseTags] 里「这个落单 `

` 后面还有没有正文」看的是 + * 整段原文,`
` 在那里只是一个普通字符,所以同一输入下它的判定与 W68 完全相同。 + */ +private fun appendRegion( + text: String, + out: MutableList, + markupMode: Boolean, + stripStrayRoleTags: Boolean, + splitBlankLines: Boolean, +) { + val stripped = stripLooseTags(text, markupMode, stripStrayRoleTags) + var last = 0 + for (m in BR_TAG_RE.findAll(stripped)) { + val piece = stripped.substring(last, m.range.first) + if (piece.isNotEmpty()) appendAnnotations(piece, out, splitBlankLines) + out.add(Tok.Break) + last = m.range.last + 1 + } + val tail = stripped.substring(last) + if (tail.isNotEmpty()) appendAnnotations(tail, out, splitBlankLines) +} + /** * 剥掉「落单标签」,其余字符逐字保留(返回原对象:没剥到东西时不做新分配)。 * @@ -506,9 +577,10 @@ private fun toBubble(tokens: List): ParsedBubble? { tail.add(part) } } - // 行内标签在**段落定稿之后**才消费(就在这一行):上面那套分段与归属一个字都不动,分段判据 - // 因此完全看不见行内标签(`
` 造不出新气泡)。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]) + * ——`` + * 与 `
`、`= buf.length) return name.isEmpty() || TAG_NAMES.any { it.startsWith(name) } - if (name.isEmpty()) return false - if (!buf[j].isWhitespace()) return false - return TAG_NAMES.any { it.startsWith(name) } + if (name.isEmpty()) return j >= buf.length + if (!couldBeTagName(name)) return false + if (j >= buf.length) return true + return buf[j].isWhitespace() || buf[j] == '/' } +/** + * [name] 是否可能是某个已知标签名的前缀;三半各按自己的大小写口径(见 [isPotentialTagPrefix]): + * 段落/旁白小写、`
` 与行内标签大小写不敏感。 + */ +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("

$raw

").single().body) + + // 白名单外的标签不参与结构判定:旁边那对 `` 照常生效,尖括号原样留着 + val mixed = parse("

甲粗乙

").single() + assertEquals("甲粗乙", mixed.body) + assertEquals( + listOf(run("甲"), run("粗", InlineStyle.Bold), run("乙")), + mixed.runs, + ) + + // 落单的白名单外标签同理:它只是几个字符,不把整段拖成字面 + assertEquals( + listOf(run("甲"), run("粗", InlineStyle.Bold), run("