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] 逐字对齐(只认解析器真正认识的那几个标签): - * `
`(可带属性)/ `
` / ``(可带属性)/ `
` / `` 在这里也是普通文本。
+ *
+ * **行内标签(`` / ` ]*)?>| ` 开标签(从该开标签起扣留);
- * 行内开标签同样入栈扣留(词表见 [TAG_NAMES]):不扣的话中间态会把 `` 当字面吐上屏
- * (`日落 18`)、等 `` 到了又被吃掉,屏幕上已经显示过的两个字就改口了。
- * ` ]*)?>$")
@@ -507,8 +508,8 @@ private fun toBubble(tokens: List ` / ` $raw 甲粗乙` / `
` …)故意不在**这份容差里:它们是渲染用的标记,但编辑框
+ * 编辑的是**源码本身**,而 `parts`(这一份的来源)是**逐字原文**——所以 `日落 18:35` 在
+ * 编辑框里连 `` 一起显示、也一起保存,这里一个字符都不动它。
*/
private val KNOWN_TAG_RE = Regex(
"`、`
`/`
` 四类,
- * 成对才认、只有小写、未配对或嵌套错位一律字面回落(口径与理由见 `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) 未闭合的 `
` 是自闭合的,**不入栈**(入了就永远弹不出来),它只在残缺(`
` 当字面吐上屏(`日落 18`)、等 `` 到了又被吃掉,屏幕上已经显示过的
+ * 两个字就改口了。**大小写与属性也走同一份判据**(v0.89):`` 在扣留侧同样是
+ * 开标签,否则 `日落 18` 会先按字面吐上屏、收口时再变成粗体。
+ * `
` 是自闭合的,**不入栈**(入了就永远弹不出来),它只在残缺(`
,
val tail: List`,见 [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("^\\s*(action|narration)\\s*>$")
private val OPEN_P_TAG_RE = Regex("^
` 造不出新气泡)。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)
}
-/** 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。 */
+/**
+ * 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。
+ *
+ * 词表分两半,每一半都与**自己的消费侧**同源,两条口径都在这里逐字对齐:
+ * - 段落/旁白只认小写(`
`、`` → 等宽 + 轻微底色(`` 里不再解析,见下);
- * - `
` / `
` → 换行(自闭合,不成对)。
+ * 口径 = 后端 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("