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..43791c0 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,16 @@ data class EditableMessage( ) /** - * 已知标签的容差,与 [parse] 逐字对齐(只认解析器真正认识的那几个标签): - * `

`(可带属性)/ `

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

`(可带属性)/ `

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

` 在这里也是普通文本。 + * + * **行内标签(`` / `` / `
` …)故意不在**这份容差里:它们是渲染用的标记,但编辑框 + * 编辑的是**源码本身**,而 `parts`(这一份的来源)是**逐字原文**——所以 `日落 18:35` 在 + * 编辑框里连 `` 一起显示、也一起保存,这里一个字符都不动它。 */ 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..a8a3259 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 @@ -19,8 +19,11 @@ import kotlin.math.min * - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action); * 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。 * 关键词 旁白/内心 → narration,其余 → action。 - * - 行内标签(`InlineMarkup.kt`):``/``、``/``、``、`
`/`
` 四类, - * 成对才认、只有小写、未配对或嵌套错位一律字面回落(口径与理由见 `parseInlineMarkup`)。 + * - 行内标签(`InlineMarkup.kt`,契约 v0.89):``/``、``/``、``、``、 + * ``、`
`(`
`/`
`/`
` 等价)六类,成对才认、**标签名大小写不敏感**、 + * **属性一律不解析**(`` 就是粗体)、白名单外按纯文本原样显示、 + * **结构不平衡(未闭合 / 落单闭标签 / 交错)⇒ 该气泡的正文整段降级为原文** + * (口径与理由见 `parseInlineMarkup`)。 * 它在**段落定稿之后**才做([toBubble] 里对最终正文做一次),所以既不参与、也改不动上面的 * 分段与归属:`
` 只换行、**不分气泡**。渲染文本落 `body` + `runs`,而片段 `parts` 仍是 * 逐字原文——编辑框、搜索片段、通知文案读的都是 `parts`,那些地方一个字都不许改。 @@ -40,8 +43,9 @@ import kotlin.math.min * 也保持字面文本,这是既有契约(`unclosedActionFallsBackToText` / `strayClosingTagStaysText`)。 * * `ParsedBubble` 结构(每条气泡渲染一个): - * - `body` :气泡正文的**渲染文本**(仅 Text 部分按原顺序拼接后 trim,再消费掉四种行内标签: - * `
` 已变成换行、标签字符已去掉),已剔除全部旁白/动作块;整段只有标记没有字时回落字面原文; + * - `body` :气泡正文的**渲染文本**(仅 Text 部分按原顺序拼接后 trim,再消费掉行内标签: + * `
` 已变成换行、白名单标签字符已去掉,白名单外与属性照旧逐字留着),已剔除全部旁白/动作块; + * 整段只有标记没有字、或行内标签**结构不平衡**时回落/降级为字面原文; * - `runs` :`body` 的行内样式切分(非空时拼起来就是 `body`;空列表 = 按字面画); * - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用; * - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」); @@ -54,10 +58,12 @@ import kotlin.math.min * 因此 `finish() == parse(全部 delta 拼接)` 恒成立(逐字符/任意切分均一致)。 * - 未确定片段从以下最早位置开始整体扣留,不产出半截结果: * 1) 未闭合的 `` / `` / `

` 开标签(从该开标签起扣留); - * 行内开标签同样入栈扣留(词表见 [TAG_NAMES]):不扣的话中间态会把 `` 当字面吐上屏 - * (`日落 18`)、等 `` 到了又被吃掉,屏幕上已经显示过的两个字就改口了。 - * `
` 是自闭合的,**不入栈**(入了就永远弹不出来),它只在残缺(`` 当字面吐上屏(`日落 18`)、等 `` 到了又被吃掉,屏幕上已经显示过的 + * 两个字就改口了。**大小写与属性也走同一份判据**(v0.89):`` 在扣留侧同样是 + * 开标签,否则 `日落 18` 会先按字面吐上屏、收口时再变成粗体。 + * `
` 是自闭合的,**不入栈**(入了就永远弹不出来),它只在残缺(`, val tail: List, /** - * `body` 的行内样式切分(`` / `` / ``,见 [parseInlineMarkup]):非空时拼起来 - * **就是** [body]。空列表 = 这段正文没有可渲染的行内标签,按字面画——**绝大多数消息都走这一档**。 + * `body` 的行内样式切分(`` / `` / `` / `` / ``,见 [parseInlineMarkup]): + * 非空时拼起来**就是** [body]。空列表 = 这段正文按字面画——**绝大多数消息都走这一档**(没有标签、 + * 或者结构不平衡被整段降级、或者整段只有标记没有字)。 * * 有默认值,是因为只有 [toBubble] 才该填它:其余构造点要么本来就该按字面画(`plainBubble`、 * 分享预览的兜底 `sharePreviewBlocks`),要么是测试里的手搓气泡。这也让「加一个字段」不必 @@ -133,16 +140,10 @@ private val BLANK_LINE_RE = Regex("\\n\\s*\\n") private const val FULL_STAR_MAX_LEN = 40 /** - * 已知标签词表(只有小写,与 `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] 真正消费用的那组)。 - * 任缺一半,中间态就会把标签当正文吐上屏、收口时才吃掉——屏幕上已经显示过的字就改口了。 + * 段落/旁白标签名(**只有小写**,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix] + * 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面那组同词表的正则,两边必须同源。 */ -private val TAG_NAMES = listOf("action", "narration", "p") + INLINE_TAG_NAMES +private val STRUCTURE_TAG_NAMES = listOf("action", "narration", "p") private val OPEN_ROLE_TAG_RE = Regex("^<\\s*(action|narration)\\s*>$") private val CLOSE_ROLE_TAG_RE = Regex("^$") private val OPEN_P_TAG_RE = Regex("^]*)?>$") @@ -507,8 +508,8 @@ private fun toBubble(tokens: List): ParsedBubble? { } } // 行内标签在**段落定稿之后**才消费(就在这一行):上面那套分段与归属一个字都不动,分段判据 - // 因此完全看不见行内标签(`
` 造不出新气泡)。runs 为空 = 这段正文没有可渲染的行内标签, - // 正文按字面画(绝大多数消息都走这一档),一个字都不会变。 + // 因此完全看不见行内标签(`
` 造不出新气泡)。runs 为空 = 这段正文按字面画(没有可渲染的 + // 行内标签、或行内标签结构不平衡被整段降级),一个字都不会变。 val rawBody = body.toString().trim() val runs = parseInlineMarkup(rawBody) val bodyText = if (runs.isEmpty()) rawBody else runs.joinToString("") { it.text } @@ -565,8 +566,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,18 +578,33 @@ private fun closeTagName(tag: String): String? { return closeInlineTagName(tag) } -/** 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。 */ +/** + * 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。 + * + * 词表分两半,每一半都与**自己的消费侧**同源,两条口径都在这里逐字对齐: + * - 段落/旁白只认小写(`

` / `` 原样显示,既有契约)——名字里出现大写就与它无关; + * - 行内标签**大小写不敏感**(契约 v0.89,[parseInlineTag])——`` + * 与 `
`、`= 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 + return couldBeInlineTagNamePrefix(name.lowercase()) } /** 自由文本区内孤立的全角 `*`(计数为奇)从最后一个起扣留。 */ 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..d9455af 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,23 +3,42 @@ 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 口径等价);标签名**大小写不敏感**; + * 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("