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 a4c60fe..555ce9d 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
@@ -18,14 +18,18 @@ import kotlin.math.min
* 的气泡/消息分隔符**(契约 v0.89 的更正:`回执三十一`「对消息内的 `
` 始终处理为多条消息
* 隔断」)。它**不是行内标签**:`
甲
乙
`)⇒ 同样两条; * 中间的空气泡照旧丢弃(`
甲
乙
` / `` / `` **内部**的不分隔([appendRegion] 的保护区,
+ * 口径与理由写在它和 [protectedSpans] 的 KDoc 里)。
* - `… ` / `… `:旁白/动作,不独立成气泡,依附气泡:
* 首部连续块 → 挂气泡上方(head);尾部连续块 → 挂下方(tail);夹在正文中间的块 → 上提到 head。
+ * 注记内部**不做行内渲染**(既有口径),所以里面的 `
` 与 `` 一样只是字面文本。
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
* 关键词 旁白/内心 → narration,其余 → action。
- * - 行内标签(`InlineMarkup.kt`,契约 v0.89):``/``、``/``、`` 三类
- * (五项标签名,`u`/`s` 已按 `回执三十` 收窄撤掉),成对才认、**标签名大小写不敏感**、
+ * - 行内标签(`InlineMarkup.kt`,契约 v0.89):``/``、``/``、``、``
+ * (**六项**标签名——`u` 由 `回执三十三 §一` 加回、`s` 仍不在内),成对才认、**标签名大小写不敏感**、
* **属性一律不解析**(`` 就是粗体)、白名单外按纯文本原样显示、
* **结构不平衡(未闭合 / 落单闭标签 / 交错)⇒ 该气泡的正文整段降级为原文**
* (口径与理由见 `parseInlineMarkup`)。
@@ -70,6 +74,9 @@ import kotlin.math.min
* `
` 是分隔符、不成对,**不入栈**(入了就永远弹不出来);它只在残缺(`
` 直接放行
* ——分隔符一出现,前缀就已经可解析了。
+ * **保护区内的 `
` / `` / `` 的开标签本来就在
+ * 栈里,所以整个作用域(连同里面还没写完的 `
,
val tail: List,
/**
- * `body` 的行内样式切分(`` / `` / ``,见 [parseInlineMarkup]):非空时拼起来
- * **就是** [body]。空列表 = 这段正文按字面画——**绝大多数消息都走这一档**(没有标签、
+ * `body` 的行内样式切分(`` / `` / `` / ``,见 [parseInlineMarkup]):非空时
+ * 拼起来**就是** [body]。空列表 = 这段正文按字面画——**绝大多数消息都走这一档**(没有标签、
* 或者结构不平衡被整段降级、或者整段只有标记没有字)。
*
* 有默认值,是因为只有 [toBubble] 才该填它:其余构造点要么本来就该按字面画(`plainBubble`、
@@ -173,8 +180,13 @@ private const val LINE_BREAK_TAG_NAME = "br"
* `` / `` / `` 因此**不是**分隔符,只是普通文本(这一条是「字符一个不丢」的闸门)。
*
* ⚠️ 已知边界:它**不看外层标签的属性结构**——所以写成 `甲` 这种「属性里带 `
`」的
- * 文本会被切开。`` 开标签是唯一例外,因为它在更早一层就被 [P_BLOCK_RE] 整体吃掉了,
- * 属性里的 `
` 根本走不到这里。真实产出里没有这种形状(属性本身就不是产出侧的东西)。
+ * 文本会被切开(`回执三十三 §三③` 登记为两端口径相同、不修)。`
` 开标签是唯一例外,因为它在更早
+ * 一层就被 [P_BLOCK_RE] 整体吃掉了,属性里的 `
` 根本走不到这里。真实产出里没有这种形状
+ * (属性本身就不是产出侧的东西)。
+ *
+ * ⚠️ 它只认**普通文本层**的那些:`` / `` / `` **内部**的 `
` 由
+ * [appendRegion] 按保护区跳过(`回执三十三 §三②`)——所以 `findAll` 的结果**不再是**「所有分隔点」,
+ * 用它时要么走 [appendRegion](会跳保护区),要么自己判一遍(别处没第二个消费者)。
*/
private val BR_TAG_RE = Regex("<\\s*/?\\s*${LINE_BREAK_TAG_NAME}(\\s[^<>]*|/\\s*)?>", RegexOption.IGNORE_CASE)
@@ -280,7 +292,8 @@ private fun tokenize(raw: String): List {
* **为什么 `
` 在这一层切**:契约 v0.89 的更正把 `
` 定成**与 `` 同级的分隔符**
* (`回执三十一`「对消息内的 `
` 始终处理为多条消息隔断」)。它既然与 `
` 同级,就该与 `
`
* 走**同一套机制、同一个粒度**:产生 [Tok.Break]、由 `buildBubbles` 分组、空气泡照旧丢弃。
- * 行内层(`InlineMarkup`)因此完全看不见 `
` —— 那里只剩五个白名单标签。
+ * 行内层(`InlineMarkup`)因此几乎看不见 `
` —— 那里只剩六个白名单标签(唯一例外见下面的保护区:
+ * `` 内部的那个 `
` 会随正文交到行内层,在那里按「内容」字面显示)。
*
* **顺序:先切分、再逐段各自做行内解析**(`甲
乙` ⇒ 两段各按「不平衡 ⇒ 不渲染」处理,
* 两段都回落原文)。理由:
@@ -295,6 +308,52 @@ private fun tokenize(raw: String): List {
*
* 剥标签**先于**切分(判据逐字不变):[stripLooseTags] 里「这个落单 `` 后面还有没有正文」看的是
* 整段原文,`
` 在那里只是一个普通字符,所以同一输入下它的判定与 W68 完全相同。
+ *
+ * ## 保护区:`` 与角色注记**内部**的 `
` 不分隔(`回执三十三 §三②`)
+ * `回执三十一` 说的是「**始终**分隔」,`回执三十三` 把它收紧了:**只有普通文本层的 `
` 才分隔**。
+ * 后端给的两条理由在我们这边一字不差地同样成立,所以照改:
+ * 1. **会漏注记正文**:`她
点点头 ` 若切成两段,就成了「她」「点点头」**两条消息**
+ * ——注记本来是不该当正文露出来的那一层,切开等于把它摆到台面上(后端原话:「那等于把注记正文
+ * 发给了对方」);
+ * 2. **`` 会被切坏**:它是**成对**元素,从中间切开必然两半都不平衡,按「不平衡 ⇒ 不渲染」的
+ * 硬口径**整段降级为字面量**,用户直接看到裸标签。
+ *
+ * 实现:先扫出保护区区间([protectedSpans]),切分时**跳过**落在区间里的 `
`(它留在原文里,随该
+ * 作用域自己的语义走)。扫的是**已经剥过落单标签**的那一份文本,与切分用的是同一个对象。
+ *
+ * ### 作用域内部算什么语义(我们定的口径):**普通字符、逐字原样**(不换行、也不消掉)
+ * - ``:这是它**既有的**口径——「里面连白名单标签也按内容处理」,`甲`
+ * 显示字面的 `甲`(`codeContentIsNotParsed` 钉着)。`
` 在这一层本来不是标签,用不着
+ * 另立规矩;`` 也正好是「想原样显示尖括号怎么办」的现成答案。
+ * - `` / ``:**必须是字面**。注记那一行画的是 `parts` 里的原文
+ * (`MessageBubble.AnnotationLine` 直接 `parts.joinToString`),而 `parts` = **逐字原文**是红线
+ * ——换成 `\n` 或删掉,就是在静默改用户内容(编辑框、搜索、通知读的都是它)。顺带也就与既有
+ * 口径对齐:`笑了 ` 里的 `` 同样是字面(`narrationTextIsLeftExactlyAsWritten`)。
+ * - 两个作用域取**同一条**规矩:它们是同一件事(`
` 落在作用域内部),各给一种读法才是真的
+ * 「各端各说各话」。
+ * - 为什么不把它当换行(`甲
乙` ⇒ `甲\n乙`):那必须在一处破例——要么在这里改掉
+ * 片段文本(`parts` 当场不再是原文),要么在行内层给 `
` 单开一条「代码里它算换行」的规则
+ * (W68 刚把「`
` → `\n`」从行内层整体删掉,而且那会让**同两个字符**在 `` 与 ``
+ * 里各是一种意思)。两条都不值当,所以不做。
+ *
+ * ### 未闭合的作用域:**不算作用域**,里面的 `
` 照旧分隔
+ * 判据只看「**成对**」,且与各自的消费侧同一把尺子:`` 要找到它的 ``(大小写不敏感、
+ * 可带属性——行内层就这么认),`` / `` 要找到**同名**闭合(只认小写、不带属性
+ * ——`ROLE_RE` 就这么认)。找不到闭合的开标签不是作用域,于是 `甲
乙` 照旧切成两条。
+ * - 理由一:**消费侧本来就不认它**。未闭合的 `` 在行内层不是作用域(整段降级为原文),
+ * 未闭合的 `` 在 `ROLE_RE` 那里也配不出旁白。去保护一个**消费侧根本不认**的作用域,
+ * 等于让「切分层」与「消费层」对同一段文本有两种结构判断——正是本项目一路在防的那种分叉
+ * (W68/W69 的教训:消费侧与扣留侧的词表必须同源)。
+ * - 理由二:**爆炸半径**。若把「未闭合 ⇒ 保护到这一段文本结尾」也算上,一个漏掉 ` ` 的笔误
+ * 就会把**后面所有** `
` 一起吞掉、把本该分开的多条消息并成一条。「一个畸形标签改变整段的
+ * 分段」比「照旧分隔」坏得多。
+ * - 两种读法都不渲染、都不丢字(未闭合那一档本来就是「不平衡 ⇒ 原样显示」),差别只在气泡怎么切;
+ * 我们选与消费侧一致的那种。
+ * - **两支各算各的**([protectedSpans] 用两个独立状态):`` 与角色注记各自配对,因为那本来
+ * 就是两个消费侧各自的尺子——行内层在 `` 里只认自己的闭合、别的一律当内容;`ROLE_RE`
+ * 则完全不看 ``(它在代码里照样配对)。所以「某一支没闭合」只影响**那一支**:
+ * `甲她
点点头 乙` 里那个**已经配对好**的 `` 照样保护它的
+ * `
`(不产生区间的只是没闭合的 `` 那一支)。
*/
private fun appendRegion(
text: String,
@@ -304,9 +363,16 @@ private fun appendRegion(
splitBlankLines: Boolean,
) {
val stripped = stripLooseTags(text, markupMode, stripStrayRoleTags)
+ // 保护区只在「这一区里真有作用域」时才扫:绝大多数消息连 `<` 都没有(一个字节都不多花)。
+ val protected = if (stripped.indexOf('<') < 0) emptyList() else protectedSpans(stripped)
var last = 0
+ var span = 0
for (m in BR_TAG_RE.findAll(stripped)) {
- val piece = stripped.substring(last, m.range.first)
+ val at = m.range.first
+ // 区间按起点升序,跟着 `
` 的推进往前走一个游标就够(不回头、不重扫)。
+ while (span < protected.size && protected[span].last < at) span++
+ if (span < protected.size && at in protected[span]) continue
+ val piece = stripped.substring(last, at)
if (piece.isNotEmpty()) appendAnnotations(piece, out, splitBlankLines)
out.add(Tok.Break)
last = m.range.last + 1
@@ -315,6 +381,73 @@ private fun appendRegion(
if (tail.isNotEmpty()) appendAnnotations(tail, out, splitBlankLines)
}
+/**
+ * 扫出这一区里**已配对**的保护区区间(左闭右开,按**起点**升序):`…` 与
+ * `… ` / `… `——[appendRegion] 据此跳过落在里面的 `
`
+ * (口径与理由写在它的 KDoc 里)。
+ *
+ * 一趟线性扫描,判据全部借现成的尺子:「哪儿是一个标签」用行内层的 [inlineTagEnd];`code` 用
+ * 行内层的 [openInlineTagName] / [closeInlineTagName](大小写不敏感、可带属性);角色注记用
+ * [openNoteTagName] / [closeNoteTagName](与 `ROLE_RE` 同口径:只认小写、不带属性、找**同名**闭合)。
+ *
+ * **两支各自独立配对**,因为那本来就是两个消费侧各自的尺子:`` 那一支只认自己的开/闭
+ * (里面别的标签都是内容,与行内层一致);角色那一支同理(与 `ROLE_RE` 的非贪婪配对一致——它
+ * 在 `` 里照样配对,两边各算各的)。任一支没闭合 ⇒ **那一支**不产生区间。
+ *
+ * `indexOf('<')` + 逐段推进的扫描区间互不重叠(见 [inlineTagEnd]),每个 `
` 的判定是两个游标
+ * 各走一次,因此整体仍是 O(n):成串的尖括号或成串的作用域都不会让它退回 O(N²)。
+ */
+private fun protectedSpans(text: String): List {
+ val codeSpans = ArrayList(2)
+ val noteSpans = ArrayList(2)
+ var openCode = -1
+ var openNoteName: String? = null
+ var openNote = -1
+ var i = 0
+ while (true) {
+ val lt = text.indexOf('<', i)
+ if (lt < 0) break
+ val gt = inlineTagEnd(text, lt)
+ if (gt < 0) {
+ // 这一处起头的东西不是标签:往后挪**一个字符**(与 `claimTags` 同一把尺子,
+ // 不能跳到某个「下一个 `>`」——那会把中间真正的标签一起跳过)。
+ i = lt + 1
+ continue
+ }
+ val tag = text.substring(lt, gt + 1)
+ val end = gt + 1
+ if (openCode < 0) {
+ if (openInlineTagName(tag) == CODE) openCode = lt
+ } else if (closeInlineTagName(tag) == CODE) {
+ codeSpans.add(openCode until end)
+ openCode = -1
+ }
+ val noteOpen = if (openNoteName == null) openNoteTagName(tag) else null
+ if (noteOpen != null) {
+ openNoteName = noteOpen
+ openNote = lt
+ } else if (openNoteName != null && closeNoteTagName(tag) == openNoteName) {
+ noteSpans.add(openNote until end)
+ openNoteName = null
+ }
+ i = end
+ }
+ // 两支各自升序,合起来也按**起点**升序——[appendRegion] 的包含判定(「第一个右端 ≥ 这个 `
`
+ // 的区间」)以「按起点升序」为前提。合并后两支的区间可能相互嵌套(`` 套注记、注记套
+ // ``),那不影响判定:嵌套的那些都在同一个升序序列里。
+ return when {
+ noteSpans.isEmpty() -> codeSpans
+ codeSpans.isEmpty() -> noteSpans
+ else -> (codeSpans + noteSpans).sortedBy { it.first }
+ }
+}
+
+/** 角色注记的**开**标签名(只认小写的 `action` / `narration`、不带属性——与 `ROLE_RE` 同口径)。 */
+private fun openNoteTagName(tag: String): String? = OPEN_ROLE_TAG_RE.find(tag)?.groupValues?.get(1)
+
+/** 角色注记的**闭**标签名(同上;配对要求**同名**,与 `ROLE_RE` 的 `\s*\1\s*>` 一致)。 */
+private fun closeNoteTagName(tag: String): String? = CLOSE_ROLE_TAG_RE.find(tag)?.groupValues?.get(1)
+
/**
* 剥掉「落单标签」,其余字符逐字保留(返回原对象:没剥到东西时不做新分配)。
*
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 679d89f..16d5ce0 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
@@ -5,24 +5,30 @@ package top.yeij.cyrene.domain.parse
*
* 口径 = 后端 2026-10-07 定下的**契约 v0.89** 及其同日更正(跨端必须逐字一致,否则同一个 ``
* 在她那台设备上粗、在这台上不粗)。四条硬口径:
- * 1. 白名单 = `b` / `strong` · `i` / `em` · `code`(**成对**,五项标签名);标签名**大小写不敏感**;
+ * 1. 白名单 = `b` / `strong` · `i` / `em` · `code` · `u`(**成对**,六项标签名);标签名**大小写不敏感**;
* 2. **属性一律不解析**:`` 只取标签名,属性整段丢掉、绝不落成任何视图属性;
* 3. **白名单外一律按纯文本原样显示**:`