From 9da25b08956e90ddffd339e08faf7bedacbd157a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 19:10:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(W71):=20=E6=8C=89=E5=A5=91=E7=BA=A6=20v0.8?= =?UTF-8?q?9=20=E6=9C=80=E7=BB=88=E7=89=88=E8=BF=94=E5=B7=A5=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20`u`=20=E5=8A=A0=E5=9B=9E=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=EF=BC=88=E5=85=AD=E9=A1=B9=EF=BC=89+=20`
`=20=E5=8F=AA?= =?UTF-8?q?=E5=9C=A8=E6=99=AE=E9=80=9A=E6=96=87=E6=9C=AC=E5=B1=82=E5=88=86?= =?UTF-8?q?=E9=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 `回执三十三`(v0.89 收口)更正了 W68/W69 的两处实现,本端照改。 一、`u` **加回**白名单(最终**六项**) `回执三十三 §一` 明说:`回执三十一` 那句「收窄到五项」**是他们的疏漏** —— 主人随后裁定 下划线保留,后端已把 `u` 加回契约(`1107b2a`)与 IM 出站(`74c9f2d`),只是**忘了发回执登记**。 最终白名单 = `b`/`strong`/`i`/`em`/`code`/**`u`**,`s` 仍不在内(逐字原样)。 1. `InlineMarkup`:`PAIRED_TAG_NAMES` 加回 `"u"`、`InlineStyle` 加回 `Underline`、 `inlineStyleOf` 落到它。**只加回 `Underline`**:`s` 依旧不做(枚举里没有 `Strikethrough`, 不留走不到的渲染分支)。加回词表同时让流式扣留侧重新认 `` 的片段逐像素未变。 二、`
` **不再「始终分隔」**:只有**普通文本层**的 `
` 才分隔(`回执三十三 §三②`), `` 与 `` / `` **内部**的不分隔。后端给的两条理由我们同样成立: ① `` 是**成对**元素,从中间切开必然两半不平衡 ⇒ 按硬口径「不平衡 ⇒ 不渲染」整段降级为 字面量(用户看到裸标签);② `她
点点头
` 切开就是「她」「点点头」两条**聊天 消息** = 把注记正文当正文发了出去(信息泄露级)。 1. 新增 `protectedSpans`(保护区扫描)+ `appendRegion` 里跳过落在区间内的 `
`: 一趟线性扫描、**两个独立状态** —— `code` 一支用行内层的判据(`inlineTagEnd` + `openInlineTagName`/`closeInlineTagName`:大小写不敏感、可带属性),角色注记一支用 `ROLE_RE` 的判据(只认小写、不带属性、找**同名**闭合)。两支各算各的,因为那本来就是两个消费侧各自的 尺子(行内层在 `` 里只认自己的闭合;`ROLE_RE` 完全不看 ``)。区间按**起点**升序, 切分处用游标 O(1) 判定 ⇒ 整体仍然线性(新增一条 20k 作用域的复杂度预算用例)。为此把 行内层的 `inlineTagEnd` 与 `CODE` 提为 `internal`:**标签边界与 `code` 这个名字只该有一把尺子**。 2. 作用域里的 `
` 算什么(**我们定的口径,写进 KDoc**):**普通字符、逐字原样** —— 不换行、 也不消掉。`` 是它既有口径(里面连白名单标签都是内容,`甲` 显示字面 `甲`,由 `codeContentIsNotParsed` 钉着);注记那侧则是**强迫**的:旁白那一行画的正是 `parts` 的逐字原文(编辑框/搜索/通知同源,`parts` 是红线),换 `\n` 或删掉就是静默改用户内容。 两个作用域取同一条规矩,免得多出一种读法。「改成换行」因此被否:要么在这里改掉片段文本 (`parts` 当场不再是原文),要么在行内层给 `
` 单开一条规则(W68 刚从那里整体删掉)。 3. **未闭合的作用域不算作用域**(里面的 `
` 照旧分隔):判据只看「成对」,而消费侧本来就不认 未闭合的那个(行内层整段降级、`ROLE_RE` 配不出旁白);且「未闭合 ⇒ 保护到结尾」会让一个漏掉 `
` 的笔误把后面**所有** `
` 一并吞掉——一个畸形标签的爆炸半径不该放大到整段。 两种读法都不渲染、都不丢字,差别只在气泡怎么切,我们选与消费侧一致的那种。 4. 其余一律未动:`
` 大小写不敏感(两端口径一致,`回执三十三 §三①`)、`
` 出现在别的标签 属性里照样分隔(`§三③` 登记不修)、`

` 语义、星号(旁白/动作)语义、`stripLooseTags` / `ContentAfterQuery` / `P_BLOCK_RE` / `ROLE_RE`、W63 的线性特性、`parts` 逐字原文。 测试:`InlineMarkupTest` 41→43 条(`` 六项之一渲染下划线且大小写/属性/叠加/未闭合各断言、 `s` 每种形态都仍逐字字面且枚举里没有 Strikethrough、`` 里那个 `
` 是内容); `ChatContentParserTest` 71→77 条:W69 那条「``/`` 内也分隔」换成 7 条新口径用例 (code 作用域存活且无裸标签、注记不产生气泡且 `parts` 逐字、只有 code/注记算保护区、嵌套、 未闭合、两支各算各的、流式扣留残缺 `` 与保护区两形状。 其余既有断言(含 W68 那批 `
` 用例、复杂度预算)一条都没放松。 验收:`assembleDebug` 通过(app-debug.apk 47 330 538 B);`testDebugUnitTest` 全量 **127 suites / 1589 tests / 0 failures / 0 errors**(基线 1580,+9 条本次新增)。 --- .../cyrene/domain/parse/ChatContentParser.kt | 151 ++++++++++- .../yeij/cyrene/domain/parse/InlineMarkup.kt | 53 ++-- .../top/yeij/cyrene/ui/chat/MessageBubble.kt | 10 +- .../domain/parse/ChatContentParserTest.kt | 256 +++++++++++++++++- .../cyrene/domain/parse/InlineMarkupTest.kt | 130 +++++++-- 5 files changed, 538 insertions(+), 62 deletions(-) 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 的更正:`回执三十一`「对消息内的 `
` 始终处理为多条消息 * 隔断」)。它**不是行内标签**:`

甲
乙

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

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

甲

乙

` ⇒ `甲`、`乙`)。切分在 [tokenize] 的**段/块这一层** - * 完成([appendRegion]),所以行内解析层(`InlineMarkup`)完全看不见它。 + * 完成([appendRegion]),所以行内解析层(`InlineMarkup`)几乎看不见它。 + * ⚠️ `回执三十三 §三②` 把「**始终**分隔」收紧了:**只有普通文本层的 `
` 才分隔**—— + * `` / `` / `` **内部**的不分隔([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` 的 `` 一致)。 */ +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. **白名单外一律按纯文本原样显示**:`