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 d78ede5..4352a0c 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,6 +19,11 @@ import kotlin.math.min * - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action); * 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。 * 关键词 旁白/内心 → narration,其余 → action。 + * - 行内标签(`InlineMarkup.kt`):``/``、``/``、``、`
`/`
` 四类, + * 成对才认、只有小写、未配对或嵌套错位一律字面回落(口径与理由见 `parseInlineMarkup`)。 + * 它在**段落定稿之后**才做([toBubble] 里对最终正文做一次),所以既不参与、也改不动上面的 + * 分段与归属:`
` 只换行、**不分气泡**。渲染文本落 `body` + `runs`,而片段 `parts` 仍是 + * 逐字原文——编辑框、搜索片段、通知文案读的都是 `parts`,那些地方一个字都不许改。 * - 落单标签容错:服务端确实会产出不配对的段落标签(实测形态 `

正文

`:多一个 `

`、 * 只有一个 `

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

` 开标签(从该开标签起扣留); + * 行内开标签同样入栈扣留(词表见 [TAG_NAMES]):不扣的话中间态会把 `` 当字面吐上屏 + * (`日落 18`)、等 `` 到了又被吃掉,屏幕上已经显示过的两个字就改口了。 + * `
` 是自闭合的,**不入栈**(入了就永远弹不出来),它只在残缺(`, val head: List, val tail: List, + /** + * `body` 的行内样式切分(`` / `` / ``,见 [parseInlineMarkup]):非空时拼起来 + * **就是** [body]。空列表 = 这段正文没有可渲染的行内标签,按字面画——**绝大多数消息都走这一档**。 + * + * 有默认值,是因为只有 [toBubble] 才该填它:其余构造点要么本来就该按字面画(`plainBubble`、 + * 分享预览的兜底 `sharePreviewBlocks`),要么是测试里的手搓气泡。这也让「加一个字段」不必 + * 惊动任何一个既有调用方。 + */ + val runs: List = emptyList(), ) { val isEmpty: Boolean get() = body.isEmpty() && head.isEmpty() && tail.isEmpty() @@ -116,8 +135,14 @@ 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] 真正消费用的那组)。 + * 任缺一半,中间态就会把标签当正文吐上屏、收口时才吃掉——屏幕上已经显示过的字就改口了。 */ -private val TAG_NAMES = listOf("action", "narration", "p") +private val TAG_NAMES = listOf("action", "narration", "p") + INLINE_TAG_NAMES 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("^]*)?>$") @@ -481,9 +506,20 @@ private fun toBubble(tokens: List): ParsedBubble? { tail.add(part) } } - val bodyText = body.toString().trim() + // 行内标签在**段落定稿之后**才消费(就在这一行):上面那套分段与归属一个字都不动,分段判据 + // 因此完全看不见行内标签(`
` 造不出新气泡)。runs 为空 = 这段正文没有可渲染的行内标签, + // 正文按字面画(绝大多数消息都走这一档),一个字都不会变。 + val rawBody = body.toString().trim() + val runs = parseInlineMarkup(rawBody) + val bodyText = if (runs.isEmpty()) rawBody else runs.joinToString("") { it.text } if (bodyText.isEmpty() && head.isEmpty() && tail.isEmpty()) return null - return ParsedBubble(body = bodyText, parts = parts.toList(), head = head.toList(), tail = tail.toList()) + return ParsedBubble( + body = bodyText, + parts = parts.toList(), + head = head.toList(), + tail = tail.toList(), + runs = runs, + ) } // --------------------------------------------------------------------------- @@ -528,12 +564,16 @@ private fun stablePrefixEnd(buf: String): Int { private fun openTagName(tag: String): String? { OPEN_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] } - return if (OPEN_P_TAG_RE.matches(tag)) "p" else null + if (OPEN_P_TAG_RE.matches(tag)) return "p" + // 行内开标签也入栈。`
` 是自闭合的,**不入栈**(入了就永远弹不出来,扣留点会永久停在 + // 它前面,后面的话再也显示不出来)——[openInlineTagName] 的名单里本来就没有它。 + return openInlineTagName(tag) } private fun closeTagName(tag: String): String? { CLOSE_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] } - return if (CLOSE_P_TAG_RE.matches(tag)) "p" else null + if (CLOSE_P_TAG_RE.matches(tag)) return "p" + return closeInlineTagName(tag) } /** 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。 */ 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 new file mode 100644 index 0000000..f5cd6b6 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/parse/InlineMarkup.kt @@ -0,0 +1,339 @@ +package top.yeij.cyrene.domain.parse + +/* + * InlineMarkup —— 气泡正文里「一小撮行内标签」的解析(纯 Kotlin,无 Android / Compose 依赖)。 + * + * 做四类,**成对才认**,只有小写: + * - `` / `` → 粗体;`` / `` → 斜体; + * - `` → 等宽 + 轻微底色(`` 里不再解析,见下); + * - `
` / `
` → 换行(自闭合,不成对)。 + * + * ## 为什么只有小写 + * `parse` 只认小写的 `

` / `` / ``(`MessageEditText` 同一口径,既有测试 + * `plainAngleBracketsAreNeverEaten` 钉着 `

大小写不认

` 原样显示)。行内标签若认大小写, + * 流式的扣留侧也得跟着认,而那边的标签名扫描只认 `a..z`(`isPotentialTagPrefix`):两边一分叉, + * 中间态就会把 `` 当正文吐上屏、等 `` 到了再吃掉——那正是「字会跳」。所以大小写口径与 + * `

` 那套**逐字对齐**。证据也只有小写:真后端最近 100 条里命中的 2 条都是 ``。 + * + * ## 为什么侵入只在 `ParsedBubble` 上加一个字段 + * `ContentPart.Text` 的 `text` 必须保持**逐字原文**:编辑框(`MessageEditText.visibleTextOf`)、 + * 搜索片段(`SessionSearchModel.plainSearchText`)、通知文案(`ProactiveNotify`)读的都是 `parts`, + * 在那里把标签改掉就是**静默改用户内容**。所以行内解析不改片段,只在 `toBubble` 里对**已经定稿的 + * 正文**算一次,结果落在 `ParsedBubble.runs`;`body` 变成正文的**渲染文本**(四个标签已消费), + * 而 `parts` 仍是原文——两侧各拿自己该拿的那一份。 + * + * ## 与段落解析的分工(为什么不在 `tokenize` 里做) + * 行内解析发生在**段落边界已经定稿之后**(`toBubble` 里,对最终 `body` 做一次):`

` 分气泡、 + * 空行回落、旁白归属、落单标签剥离全部照旧,行内标签既不参与、也改不动它们。`
` 因此**绝不会** + * 造出新的气泡边界——分气泡这件事只认 `

` 与空行。这也让 W63 在 `tokenize` 里去掉的两处 O(N²) + * 一行都不受影响:这里是对最终正文的一趟线性扫描。 + * + * ## 明确不做(逐条理由) + * 1. **Markdown 的 `*` / `_` 一律不做**:星号这个字形在本项目**已经被占走了**——`ROLE_RE` 把全角 + * `*…*` 与半角 `*(动作|旁白|内心|神态|轻声)…*` 判成旁白/动作(`*笑*` 是动作,不是斜体), + * 既有测试钉着这条语义。再给同两个星号一个「斜体」的说法,同一条消息就有两种读法,还要先猜 + * 哪一种是她的本意——那是改既有语义,不是加渲染。 + * 2. **`#` 标题 / `- ` 列表 / 表格 / ``` 围栏 / `[链接](url)` / `~~删除~~` 一律不做**:真后端最近 + * 100 条实测 0 命中;而且这些是**行级**规则,会把「正文里真有一行以 `- ` 开头」这种普通文本 + * 改写成别的东西——0 命中 + 有改写风险 = 不做。 + * 3. **`` 不做**:它不是一个样式,是一个**点击行为**(要唤浏览器),必须配一套安全策略 + * (只认 http/https、不许 `intent://` / `javascript:` 之类)。实测 0 命中,这件事要单独拍板。 + * 4. **不认识的标签(`` / `

` / `` …)一律保持现状(原样显示)**:剥掉它们是**另一件事** + * (判据怎么定?`<3`、`a` 这一档。 + * + * 契约与边界口径见 [parseInlineMarkup]。 + */ + +/** + * 一段行内样式,可叠加(`粗斜` 同时是粗与斜)。 + * + * 只有这三种:换行不是样式,它是**字符**(`
` → `\n`),落在片段的文本里。 + */ +enum class InlineStyle { + Bold, + Italic, + Code, +} + +/** + * 正文里**样式一致的一段连续文本**:片段按顺序拼起来就是渲染文本,样式区间由「起始下标 + 长度」 + * 就地累加得到(第 n 段的区间 = 前面各段长度之和 until 加上自己)。 + * + * 相邻且样式相同的片段由 [parseInlineMarkup] 合并,所以同一段文本只有一种片段形状—— + * 断言起来就是在断言「哪段字是什么样式」。 + */ +data class InlineRun(val text: String, val styles: Set) + +/** `` 的标签名(解析里要单独判它:里面的标签不再解析)。 */ +private const val CODE = "code" + +/** 成对标签的名字(`strong` / `em` 是 `b` / `i` 的同义写法)。只写这一处,下面的正则由它生成。 */ +private val PAIRED_TAG_NAMES = listOf("b", "strong", "i", "em", CODE) + +/** 自闭合的换行标签名(不成对,所以它不在 [PAIRED_TAG_NAMES] 里)。 */ +private const val BR = "br" + +/** + * 行内标签词表。**流式扣留侧(`ChatContentParser.stablePrefixEnd`)与这里必须同源**: + * 同一组正则既判「这是个行内标签」(消费侧),也判「这个标签名会不会继续补全」(扣留侧)。 + * 两边一分叉,中间态就会把标签当正文吐上屏、收口时才吃掉——屏幕上已经显示过的那几个字就改口了。 + */ +internal val INLINE_TAG_NAMES = PAIRED_TAG_NAMES + BR + +/** 认成对开标签的**完整**形状:`` 这种,不带属性、不带空白(`` 保持原样显示)。 */ +internal val OPEN_INLINE_TAG_RE = Regex("^<(${PAIRED_TAG_NAMES.joinToString("|")})>$") + +/** 同上,闭标签:``。 */ +internal val CLOSE_INLINE_TAG_RE = Regex("^$") + +/** 自闭合换行标签:`
` / `
`(容 `
` 这种带空白的写法,与 `

` 同一口径)。 */ +internal val BR_TAG_RE = Regex("^<$BR\\s*/?>$") + +/** 标签名 → 样式;`strong` / `em` 落到与 `b` / `i` 同一个样式上。 */ +internal fun inlineStyleOf(name: String): InlineStyle = when (name) { + "i", "em" -> InlineStyle.Italic + CODE -> InlineStyle.Code + else -> InlineStyle.Bold +} + +/** 开标签名(不是行内开标签则 null);`
` 是自闭合的,**不入栈**,所以它不在这里。 */ +internal fun openInlineTagName(tag: String): String? = + OPEN_INLINE_TAG_RE.find(tag)?.groupValues?.get(1) + +/** 闭标签名(不是行内闭标签则 null)。 */ +internal fun closeInlineTagName(tag: String): String? = + CLOSE_INLINE_TAG_RE.find(tag)?.groupValues?.get(1) + +/** + * 把一段**已经定稿的正文文本**解析成行内片段(纯函数)。 + * + * 三条契约(都由单测钉着): + * 1. **返回空列表 = 这段文本没有任何配成对的行内标签**,调用方按**字面**渲染(正文一个字符都不动)。 + * 这是绝大多数消息的路径(实测 100 条里只有 2 条带 ``),也是 `plainBubble` 那条路。 + * 2. 返回非空 ⇒ 片段拼起来**就是**渲染文本:`
` 已经变成 `\n`、标签字符已经被消费掉,且两端 + * 空白已去(见下)。 + * 3. **绝不把一段非空正文渲染成空**:整段只有标记没有字(`` / `
`)时回落成第 1 条, + * 把标记原样显示——宁可让她的话里多几个尖括号,也不能给出一条空白气泡。 + * + * ## 口径:配成对 + 正确嵌套 + * - **未配对的标签一律字面显示**(`没有闭合`、`没有闭合`):与既有「未闭合/畸形标签回落 + * 普通文本」同一口径(`

第一`、`好吧笑` 都钉着字面)。剥落单标签那条容错(W55)只针对 + * **段落/旁白**标签、且只在段落结构已被完整块定死时才动手;正文里的 `ac` 是既有测试钉死的 + * 字面文本,行内标签没有那种「结构已定死」的语境,所以走字面回落这一档。 + * - **嵌套错位也字面显示**:`甲乙丙` 里的 `` 关不掉 `i`(严格嵌套),于是它连同 + * `` 一起按字面留着,只有配对成功的 `…` 生效。判据只有一条,不发明「宽容关闭」。 + * 两条合起来给出**绝不丢字**:只有配成对且正确嵌套的标签会被吃掉,其余一个字符都不动 + * (`1 < 2`、`a不是标签`、`` 全都原样)。 + * - **`` 里不再解析**:`甲` 显示字面的 `甲`。理由是 HTML 语义上 + * `` 就是「原样文本」的容器,而且这一档正好是「想显示尖括号怎么办」的答案;代价是代码里 + * 的 `` 不生效——实测 0 命中,也不存在「代码里也要粗体」这个需求。 + * - 相邻同样式的片段合并:`甲
乙
` 只有一段 `甲\n乙`(样式 {Bold})。 + * + * ## 为什么两端空白要去掉 + * `
` 落在开头/结尾会凭空多出一行空白(Compose 里末尾换行就是一行空行),而正文两端的空白本来 + * 就被去掉了(`toBubble` 里那句 `body.trim()`)。这一步只吃空白——一个可见字符都不会掉。 + * + * ## `
` 与既有空行分段的关系 + * `
` 就是**一个换行符**,不与相邻的 `\n` 去重:`甲

乙` 是「甲、空一行、乙」, + * `甲
\n乙` 也是空一行——这是 markup 的直译(去掉哪一个都算我们替她改话)。它**不分气泡**: + * 分气泡的判据(`

$text

").single().body) + } + } + + // ------------------------------------------------------------------ + // 边界 6:HTML 实体(不做) + // ------------------------------------------------------------------ + + @Test + fun htmlEntitiesStayLiteralBecauseTheyAreNotDecoded() { + for (text in listOf("a & b", "1 < 2", "空 格", "'引号'")) { + assertTrue(parseInlineMarkup(text).isEmpty()) + assertEquals(text, parse("

$text

").single().body) + } + } + + // ------------------------------------------------------------------ + // 边界 4:`
` 与既有空行分段的关系 + // ------------------------------------------------------------------ + + /** `
` 就是一个换行符,不与相邻的 `\n` 去重——`甲
\n乙` 渲染出来就是空一行(markup 直译)。 */ + @Test + fun brIsJustANewlineAndIsNotDeduplicated() { + assertEquals(listOf(run("甲\n\n乙")), parseInlineMarkup("甲
\n乙")) + assertEquals(listOf(run("甲\n\n乙")), parseInlineMarkup("甲

乙")) + assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲
乙")) + } + + /** 但它**不分气泡**:分气泡的判据(`

`、空行)在行内解析之前就已经算完了。 */ + @Test + fun brNeverCreatesANewBubble() { + val bubble = parse("甲
\n乙").single() + assertEquals("甲\n\n乙", bubble.body) + assertEquals(listOf(run("甲\n\n乙")), bubble.runs) + } + + /** 落在两端的 `
` 不留空行:正文两端的空白本来就被去掉了,这里只吃空白。 */ + @Test + fun brAtEitherEndLeavesNoBlankLine() { + assertEquals("甲", parse("

甲

").single().body) + assertEquals("甲", parse("


甲

").single().body) + assertEquals(listOf(run("甲")), parseInlineMarkup("甲
")) + assertEquals(listOf(run("甲")), parseInlineMarkup("
甲")) + } + + // ------------------------------------------------------------------ + // 契约第 3 条:绝不把一段非空正文渲染成空 + // ------------------------------------------------------------------ + + @Test + fun aBodyMadeOfTagsOnlyFallsBackToItsLiteralText() { + for (raw in listOf("", "
", " ", "")) { + assertTrue("「$raw」没有可渲染的字,必须整段回落字面", parseInlineMarkup(raw).isEmpty()) + assertEquals(raw, parse("

$raw

").single().body) + } + } + + // ------------------------------------------------------------------ + // 与既有的解析结果怎么接:body / runs / parts 各拿自己那一份 + // ------------------------------------------------------------------ + + @Test + fun bodyIsTheRenderedTextAndRunsSpellItOut() { + val bubble = parse("

今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。

").single() + assertEquals("今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。", bubble.body) + assertEquals( + listOf(run("今天那儿是晴,13~25℃,日落 "), run("18:35", InlineStyle.Bold), run(",比上海晚了整整一小时呢。")), + bubble.runs, + ) + assertEquals(bubble.body, bubble.runs.concat()) + } + + /** `parts` 保持**逐字原文**:编辑框、搜索片段、通知文案读的都是它,一个字都不许改。 */ + @Test + fun partsKeepTheRawTextForEditingAndSearch() { + val bubble = parse("

日落 18:35

").single() + assertEquals(listOf(ContentPart.Text("日落 18:35")), bubble.parts) + assertEquals("日落 18:35", bubble.parts.joinToString("") { it.text }) + } + + /** + * `parse` 的其它调用点一律照旧——这条是「本次改动只碰渲染」的证据,也是它们的护栏: + * 编辑框读到的是**原文**(行内标签一个都不掉)、搜索照样搜得到那句话、后台那段结论预览 + * 跟着拿到渲染文本(标签不再露出来)。谁哪天想让这些地方换一套文本,必须先删掉这里的断言。 + */ + @Test + fun theOtherConsumersOfTheBubbleStillSeeWhatTheySawBefore() { + val plain = "日落 18:35,比上海晚了整整一小时呢。" + // 编辑框:原文不含 `

`/`` 时是**恒等**变换(一个字节都不动,也不谎称有标记被替换) + val editable = editableMessageOf(plain) + assertEquals(plain, editable.text) + assertFalse(editable.hasMarkup) + + // 编辑框:外层的 `

` 照旧被剥(既有语义),而行内标签**原样留着**——它是要被编辑的源码本身 + val wrapped = editableMessageOf("

$plain

") + assertTrue(wrapped.hasMarkup) + assertEquals(plain, wrapped.text) + + // 搜索:按片段原文拼纯文本,那句话照旧搜得到(`` 夹在中间不影响那几个字连在一起) + val searchable = plainSearchText("

$plain

") + assertTrue(searchable.contains("日落")) + assertTrue(searchable.contains("18:35")) + } + + /** 分享预览走的是同一个 [parse](W65)⇒ 它拿到的正文与气泡逐字一致:标签同样不再露出来。 */ + @Test + fun theSharePreviewSeesTheSameBodyAsTheBubble() { + val content = "

日落 18:35,比上海晚了整整一小时呢。

" + assertEquals(parse(content).map { it.body }, sharePreviewBlocks(content).map { it.body }) + } + + /** 旁白/动作行不做行内解析(理由见 `MessageBubble.AnnotationLine` 的 KDoc):原文进 head,一个字不动。 */ + @Test + fun narrationTextIsLeftExactlyAsWritten() { + val bubble = parse("

笑了

").single() + assertEquals(listOf(ContentPart.Action("笑了")), bubble.head) + assertEquals("", bubble.body) + assertTrue(bubble.runs.isEmpty()) + } + + @Test + fun inlineTagsDoNotChangeBubbleSplitting() { + val bubbles = parse("

甲乙

丙

") + assertEquals(listOf("甲乙", "丙"), bubbles.map { it.body }) + assertEquals(listOf(ContentPart.Text("甲乙"), ContentPart.Text("丙")), bubbles.flatMap { it.parts }) + } + + /** 未闭合的 `` 在一次性解析里同样是字面(与 `` 那条既有契约同一口径)。 */ + @Test + fun unclosedInlineTagFallsBackToLiteralText() { + val bubble = parse("

日落 18:35

").single() + assertEquals("日落 18:35", bubble.body) + assertTrue(bubble.runs.isEmpty()) + } + + // ------------------------------------------------------------------ + // 反证:星号语义一个字都没被动过 + // + // 行内解析发生在**段落/旁白解析之后**(`toBubble` 里对最终正文做一次),而星号那一套 + // 在 `appendAnnotations` 里就已经定稿并入 `parts` 了。所以「星号不产生斜体/粗体」这条 + // 语义在结构上不可能被行内解析改了——下面这几条既是反证,也是它与它相邻的样子。 + // ------------------------------------------------------------------ + + @Test + fun starsKeepTheirExistingMeaningInsteadOfBecomingMarkdown() { + // 半角 `*…*` 没有关键词 ⇒ 保持普通文本,**不**变成斜体(既有用例:halfWidthStarWithoutKeywordStaysMarkdownText) + val emphasis = parse("

这是*强调*文字

").single() + assertEquals("这是*强调*文字", emphasis.body) + assertTrue(emphasis.runs.isEmpty()) + + // 半角 `**…**` 同理:**不**变成粗体(既有用例:halfWidthDoubleStarStaysMarkdownText) + val double = parse("

这是**加粗**文字

").single() + assertEquals("这是**加粗**文字", double.body) + assertTrue(double.runs.isEmpty()) + + // 全角 `*…*` 仍是「动作」(既有用例:shortFullWidthStarWithoutKeywordBecomesAction) + val starred = parse("

你好*弯起眼睛*

").single() + assertEquals("你好", starred.body) + assertEquals(listOf(ContentPart.Action("弯起眼睛")), starred.tail) + + // 带关键词的半角星号仍是旁白(既有用例:halfWidthStarWithNarrationKeywordBecomesNarration) + val narration = parse("

*内心:有点开心*你好

").single() + assertEquals("你好", narration.body) + assertEquals(listOf(ContentPart.Narration("有点开心")), narration.head) + } + + /** 同一段正文里两种机制各干各的:`*笑*` 照旧是旁白,`` 照旧是粗体。 */ + @Test + fun starsAndInlineTagsCoexistWithoutTouchingEachOther() { + val bubble = parse("

日落 18:35*笑*

").single() + assertEquals("日落 18:35", bubble.body) + assertEquals(listOf(ContentPart.Action("笑")), bubble.tail) + assertEquals(listOf(run("日落 "), run("18:35", InlineStyle.Bold)), bubble.runs) + } + + // ------------------------------------------------------------------ + // 流式:未闭合的行内标签同样扣留(否则屏幕上已经显示过的字会改口) + // ------------------------------------------------------------------ + + /** + * 逐字符喂一条带 `` 的消息:中间态**一帧都不许**把标签当正文吐上屏,且已经显示过的正文 + * 只许「加」不许「改口」(与 `ChatContentParserTest` 那条同名的性质同源)。 + * + * 这条消息刻意没有 `

`:有 `

` 的话整个段落本来就会被扣留,测不到行内标签这一条。 + */ + @Test + fun streamingWithholdsUnclosedInlineTags() { + val text = "日落 18:35,比上海晚了整整一小时呢。
记住了。" + val parser = StreamingContentParser() + var shown = emptyList() + for (ch in text) { + val bubbles = parser.onDelta(ch.toString()) + val bodies = bubbles.map { it.body } + // 只看**渲染文本**(`body`):`parts` 是逐字原文,配成对的标签本来就该留在里面 + // (编辑框/搜索读的就是它),所以那一边不能用「有没有尖括号」来判。 + for (bubble in bubbles) { + assertFalse("标签漏上了屏:${bubble.body}", bubble.body.contains("= shown.size) + shown.forEachIndexed { i, body -> + assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body)) + } + shown = bodies + } + assertEquals(parse(text), parser.finish()) + assertEquals("日落 18:35,比上海晚了整整一小时呢。\n记住了。", parser.finish().single().body) + } + + /** 逐字符 / 任意切分的流式结果必须与一次性解析完全一致(含行内标签)。 */ + @Test + fun streamingAnyChunkingMatchesOneShot() { + val sample = """ +

今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。

+

记住 abc、斜体 与 加粗 哦
还有下一行

+

这是*强调*文字*弯起眼睛*

+ """.trimIndent() + val expected = parse(sample) + + val charByChar = StreamingContentParser() + for (ch in sample) charByChar.onDelta(ch.toString()) + assertEquals(expected, charByChar.finish()) + + val random = Random(20261007) + repeat(25) { + val parser = StreamingContentParser() + var index = 0 + while (index < sample.length) { + val end = minOf(index + 1 + random.nextInt(7), sample.length) + parser.onDelta(sample.substring(index, end)) + index = end + } + assertEquals(expected, parser.finish()) + } + } + + /** 一整段只有标记时,收口也必须是字面(与一次性解析一致;未闭合的 `

` 同样照字面留着)。 */ + @Test + fun streamingUnclosedInlineTagFallsBackOnFinish() { + val parser = StreamingContentParser() + parser.onDelta("

日落 18:35") + assertEquals(listOf("

日落 18:35"), parser.finish().map { it.body }) + } + + // ------------------------------------------------------------------ + // 回归:正文里一串尖括号不许把后面的真标签「遮住」,也不许把扫描拖成 O(N²) + // ------------------------------------------------------------------ + + @Test + fun angleBracketsBeforeATagDoNotHideIt() { + assertEquals( + listOf(run("1 < 2 且 3 > 2,日落 "), run("18:35", InlineStyle.Bold)), + parseInlineMarkup("1 < 2 且 3 > 2,日落 18:35"), + ) + // 半路那个 `<` 与它后面那个 `>` 之间还夹着一个真标签:不能「从 `<` 一路找到下一个 `>`」 + // 就把整段跳过(那样 `` 会被连同它一起跳过,粗体永远不生效)。 + assertEquals( + listOf(run("1 < 2 "), run("甲", InlineStyle.Bold)), + parseInlineMarkup("1 < 2 甲"), + ) + } + + /** + * ⚠️ 与 `ChatContentParserTest` 那条复杂度回归同一性质:阈值只用来区分「线性」与「平方」, + * 不是耗时指标。正文里成串的 `<` 若用 `indexOf('>', lt)` 逐个找配对(找不到就一路扫到串尾), + * 这里就会退回 O(N²)——W63 刚在同一片区域修掉两处同形状的问题。 + */ + @Test + fun aHugeRunOfAngleBracketsStaysWithinALinearBudget() { + val count = 20_000 + for (body in listOf("<".repeat(count), "a$body

").single() + val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000 + assertEquals(body, bubble.body) + assertTrue(bubble.runs.isEmpty()) + assertTrue( + "${body.length} 字符(一串普通尖括号)用了 ${elapsedMs}ms——复杂度退回 O(N²) 了?", + elapsedMs < 2_000, + ) + } + } + + // ------------------------------------------------------------------ + // 工具 + // ------------------------------------------------------------------ + + private fun run(text: String, vararg styles: InlineStyle) = InlineRun(text, styles.toSet()) + + private fun List.concat(): String = joinToString("") { it.text } +}

`、空行 `\n\s*\n`)在行内解析**之前**就已经算完了,所以这里造的换行不会 + * 变成新的气泡边界,也不会与空行分段叠加出「双倍」的东西。 + * + * 复杂度 O(n):两趟线性扫描(每个 `<` 只看一扇固定宽度的窗,绝不 `indexOf('>')` 一路找配对); + * 正文里没有 `<` 时连集合都不建,一次就返回。 + */ +fun parseInlineMarkup(text: String): List { + if (text.indexOf('<') < 0) return emptyList() + val claimed = claimTags(text) + if (!claimed.any) return emptyList() + // 整段被吃空(`` / `
` 这类只有标记没有字的形状)⇒ 回落字面路径,见契约第 3 条。 + return runsOf(text, claimed).trimEdges().takeIf { it.isNotEmpty() } ?: emptyList() +} + +/** 一趟扫下来的「哪些标签被认领」:开/闭标签的起始下标,以及被消费的 `
` 的起始下标。 */ +private class ClaimedTags { + val openStarts = HashSet() + val closeStarts = HashSet() + val breakStarts = HashSet() + + val any: Boolean + get() = openStarts.isNotEmpty() || closeStarts.isNotEmpty() || breakStarts.isNotEmpty() +} + +/** + * 第一趟:扫出**配成对且正确嵌套**的那些标签(起始下标落在 [ClaimedTags] 的三个集合里)。 + * + * 用栈判配对,且**闭标签只关得掉栈顶那个同名开标签**(严格嵌套);剩下的(没闭合的开标签、 + * 找不到伴的闭标签、错位的闭标签)一概不进集合,第二趟自然把它们原样吐回文本。 + * + * `` 开着的期间**整段跳过**:里面的标签一个都不认(见 [parseInlineMarkup] 的口径)。 + * 因为跳过的是「认不认」,所以 `` 自己那一对仍照常被认领。 + */ +private fun claimTags(text: String): ClaimedTags { + val claimed = ClaimedTags() + val open = ArrayList>() + var i = 0 + while (true) { + val lt = text.indexOf('<', i) + if (lt < 0) break + val gt = inlineTagEnd(text, lt) + if (gt < 0) { + // 这一处起头的东西不是行内标签:往后挪**一个字符**再找。不能跳到某个「下一个 `>`」—— + // 那会连同中间真正的标签一起跳过(`1 < 2 甲` 里的 `` 就是这么丢的)。 + i = lt + 1 + continue + } + val tag = text.substring(lt, gt + 1) + if (open.lastOrNull()?.first == CODE) { + if (closeInlineTagName(tag) == CODE) { + claimed.openStarts.add(open.removeAt(open.lastIndex).second) + claimed.closeStarts.add(lt) + } + } else { + val openName = openInlineTagName(tag) + when { + openName != null -> open.add(openName to lt) + BR_TAG_RE.matches(tag) -> claimed.breakStarts.add(lt) + else -> { + val closeName = closeInlineTagName(tag) + if (closeName != null && open.lastOrNull()?.first == closeName) { + claimed.openStarts.add(open.removeAt(open.lastIndex).second) + claimed.closeStarts.add(lt) + } + } + } + } + i = gt + 1 + } + return claimed +} + +/** 行内标签的最长形态(`
` = 名字 + 3);扫的时候只看这么宽一扇窗。 */ +private val MAX_INLINE_TAG_LEN = INLINE_TAG_NAMES.maxOf { it.length } + 3 + +/** + * [lt] 处这个 `<` 起头的**行内**标签在哪结束(不是行内标签则 -1)。 + * + * 刻意**不用** `indexOf('>', lt)` 一路找过去:正文里 N 个落单的 `<` 时那是 O(N²) + * (W63 在同一片区域刚踩过这个坑)。行内标签都是定长形态,所以只看一扇 [MAX_INLINE_TAG_LEN] + * 宽的窗就够;窗里再冒出一个 `<`,也说明这一处起头的不是标签——扫描因此是线性的。 + */ +private fun inlineTagEnd(text: String, lt: Int): Int { + val limit = minOf(text.length, lt + MAX_INLINE_TAG_LEN) + var i = lt + 1 + while (i < limit) { + when (text[i]) { + '>' -> return i + '<' -> return -1 + } + i++ + } + return -1 +} + +/** + * 第二趟:把文本切成片段——被认领的标签消费掉(开/闭标签不入字、`
` 变成 `\n`),其余**逐字** + * 进片段。因为第一趟只收严格嵌套的配对,这里的栈与它逐字对应,不会弹错人。 + */ +private fun runsOf(text: String, claimed: ClaimedTags): List { + val runs = ArrayList() + val piece = StringBuilder() + val open = ArrayList() + var styles = emptySet() + + fun flush() { + if (piece.isEmpty()) return + val text0 = piece.toString() + piece.setLength(0) + val last = runs.lastOrNull() + if (last != null && last.styles == styles) { + runs[runs.lastIndex] = last.copy(text = last.text + text0) + } else { + runs.add(InlineRun(text0, styles)) + } + } + + var i = 0 + while (true) { + val lt = text.indexOf('<', i) + if (lt < 0) { + piece.append(text, i, text.length) + break + } + if (lt > i) piece.append(text, i, lt) + val gt = inlineTagEnd(text, lt) + if (gt < 0) { + // 这一处起头的东西不是行内标签(`1 < 2` 里那个 `<`、残缺的 `', lt)` 找标签 + // 边界,第一趟认下的那对标签就会在这里被当成一个字面整段吞掉(`1 < 2 甲`)。 + piece.append('<') + i = lt + 1 + continue + } + val tag = text.substring(lt, gt + 1) + when { + claimed.openStarts.contains(lt) -> { + flush() + open.add(openInlineTagName(tag) ?: closeInlineTagName(tag).orEmpty()) + styles = stylesOf(open) + } + + claimed.closeStarts.contains(lt) -> { + flush() + open.removeAt(open.lastIndex) + styles = stylesOf(open) + } + + claimed.breakStarts.contains(lt) -> { + flush() + piece.append('\n') + } + + else -> piece.append(tag) + } + i = gt + 1 + } + flush() + return runs +} + +private fun stylesOf(open: List): Set = + if (open.isEmpty()) emptySet() else open.mapTo(LinkedHashSet(), ::inlineStyleOf) + +/** 去掉渲染文本两端的空白,并同步裁掉因此变空的片段——只吃空白,一个可见字符都不会掉。 */ +private fun List.trimEdges(): List { + var head = 0 + for (run in this) { + val leading = run.text.takeWhile { it.isWhitespace() }.length + head += leading + if (leading < run.text.length) break + } + var tail = 0 + for (i in indices.reversed()) { + val trailing = this[i].text.takeLastWhile { it.isWhitespace() }.length + tail += trailing + if (trailing < this[i].text.length) break + } + val total = sumOf { it.text.length } + val from = minOf(head, total) + val to = total - minOf(tail, total - from) + return cut(from, to) +} + +/** 按字符下标从两端裁掉(只用于裁空白,所以裁点必然落在片段边界或空白中间)。 */ +private fun List.cut(from: Int, to: Int): List { + if (from == 0 && to == sumOf { it.text.length }) return this + val out = ArrayList(size) + var pos = 0 + for (run in this) { + val start = pos + pos += run.text.length + val head = maxOf(start, from) + val end = minOf(pos, to) + if (head < end) out.add(run.copy(text = run.text.substring(head - start, end - start))) + } + return out +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt index e269791..7eb67ff 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt @@ -24,10 +24,17 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import top.yeij.cyrene.core.prefs.LocalAppPrefs @@ -43,6 +50,8 @@ import top.yeij.cyrene.domain.chat.isVoiceMessage import top.yeij.cyrene.domain.chat.voiceNote import top.yeij.cyrene.domain.enums.LocalEnumCatalog import top.yeij.cyrene.domain.parse.ContentPart +import top.yeij.cyrene.domain.parse.InlineRun +import top.yeij.cyrene.domain.parse.InlineStyle import top.yeij.cyrene.domain.parse.ParsedBubble import top.yeij.cyrene.domain.parse.parse import top.yukonga.miuix.kmp.basic.Icon @@ -582,8 +591,18 @@ private fun Bubble( gray: Boolean = false, ) { AnnotationLine(bubble.head) - val body = if (showCursor && isLast && bubble.body.isNotEmpty()) bubble.body + CURSOR else bubble.body - if (body.isNotEmpty()) BubbleBody(body, isUser, onLongPress, gray) + val withCursor = showCursor && isLast && bubble.body.isNotEmpty() + val body = if (withCursor) bubble.body + CURSOR else bubble.body + if (body.isNotEmpty()) { + BubbleBody( + text = body, + runs = bubble.runs, + isUser = isUser, + onLongPress = onLongPress, + gray = gray, + trailingCursor = withCursor, + ) + } AnnotationLine(bubble.tail) } @@ -613,7 +632,14 @@ private fun LegBadge(text: String, onClick: (() -> Unit)?) { } } -/** 旁白/动作:灰斜体小字,依附在气泡上/下。 */ +/** + * 旁白/动作:灰斜体小字,依附在气泡上/下。 + * + * 这一行**不做行内解析**(正文那四类标签在这里照原样显示)。理由是它读的是 `parts` 里的原文, + * 而分享预览那行读的是同一批原文(`ShareConfirmPanel.ShareConfirmAnnotation`)——那边是红线、 + * 本次一个字都不能动。只在这边渲染,就等于让「预览里看到的 == 发出去之后看到的」在旁白上又裂一次, + * 而 W65 刚把这道缝补上。要连带做,得两处一起改并单独拍板(实测 `` 里带 `` 的 0 命中)。 + */ @Composable private fun AnnotationLine(parts: List) { if (!LocalAppPrefs.current.showNarration) return @@ -679,14 +705,28 @@ private fun ContentlessNote(onLongPress: (() -> Unit)? = null) { * 这一态的语义是「这条已经在被处理,但还没有答案」,它既不是用户说的话(不该是主人的青色), * 也不是她的回答(不该是助手的正常底色)。正文一个字都不改,只是**褪色**,让人一眼看出 * 「没丢,只是在等」。 + * + * [runs] 非空时**只画 [runs]**(拼起来就是 `text` 去掉末尾光标那一截,见 [inlineBody]): + * 同一段正文不在这里再拼一遍,免得拼出两个版本的字。[runs] 为空 = 这段正文没有可渲染的行内标签, + * 走原来的 `Text(text)` 那条路(**绝大多数消息都是这一档,一个像素都不变**)。 + * + * [trailingCursor] 说的是「`text` 末尾那枚光标」。光标必须**在片段之外**补上去:它不是正文, + * 不该继承她最后那一段的粗体/等宽底色。 */ @Composable private fun BubbleBody( text: String, + runs: List, isUser: Boolean, onLongPress: (() -> Unit)? = null, gray: Boolean = false, + trailingCursor: Boolean = false, ) { + val contentColor = when { + gray -> MiuixTheme.colorScheme.onSurfaceVariantSummary + isUser -> MiuixTheme.colorScheme.onPrimary + else -> MiuixTheme.colorScheme.onSurfaceContainer + } Box( modifier = Modifier .widthIn(max = 300.dp) @@ -716,19 +756,68 @@ private fun BubbleBody( .padding(horizontal = 14.dp, vertical = 10.dp), ) { SelectionContainer { - Text( - text = text, - style = MiuixTheme.textStyles.main, - color = when { - gray -> MiuixTheme.colorScheme.onSurfaceVariantSummary - isUser -> MiuixTheme.colorScheme.onPrimary - else -> MiuixTheme.colorScheme.onSurfaceContainer - }, - ) + if (runs.isEmpty()) { + Text( + text = text, + style = MiuixTheme.textStyles.main, + color = contentColor, + ) + } else { + Text( + text = inlineBody( + runs = runs, + codeBackground = if (isUser) { + MiuixTheme.colorScheme.onPrimary.copy(alpha = INLINE_CODE_ON_USER_BACKGROUND) + } else { + MiuixTheme.colorScheme.surfaceContainerHigh + }, + trailingCursor = trailingCursor, + ), + style = MiuixTheme.textStyles.main, + color = contentColor, + ) + } } } } +/** + * `` 那点底色在**主人那侧**(青色气泡)的透明度:`onPrimary` 压一层浅底。 + * 昔涟那侧直接用 `surfaceContainerHigh`——它本来就是比气泡底(`surfaceContainer`)高一档的填充色, + * 同一套配色里现成的一档,不另造颜色。 + */ +private const val INLINE_CODE_ON_USER_BACKGROUND = 0.18f + +/** + * 行内片段 → 一段 `AnnotatedString`:一个 `Text` 就同时承载粗体 / 斜体 / 等宽底色 / 换行。 + * + * **为什么不拼一串 `Text` 节点**:换行与折行得由同一个排版器决定,手拼 `Row` 会让折行位置跟她原话里 + * 那个换行打架(`
` 造出来的换行必须真的换行,而不是把后半段挪到另一个节点里);而且一个 `Text` + * 天然是一整段可选中的文本([SelectionContainer] 的行为一个字都不用改)。 + * + * 样式只给**有**的那几项(`null` = 继承气泡自己的字重/字体),所以非 `` 的片段与原来逐像素相同。 + */ +private fun inlineBody( + runs: List, + codeBackground: Color, + trailingCursor: Boolean, +): AnnotatedString = buildAnnotatedString { + for (run in runs) { + val code = InlineStyle.Code in run.styles + withStyle( + SpanStyle( + fontWeight = if (InlineStyle.Bold in run.styles) FontWeight.Bold else null, + fontStyle = if (InlineStyle.Italic in run.styles) FontStyle.Italic else null, + fontFamily = if (code) FontFamily.Monospace else null, + background = if (code) codeBackground else Color.Unspecified, + ), + ) { + append(run.text) + } + } + if (trailingCursor) append(CURSOR) +} + /** * 气泡里的图片(缩略)。 * diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/parse/InlineMarkupTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/parse/InlineMarkupTest.kt new file mode 100644 index 0000000..2110e85 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/parse/InlineMarkupTest.kt @@ -0,0 +1,461 @@ +package top.yeij.cyrene.domain.parse + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.domain.chat.editableMessageOf +import top.yeij.cyrene.ui.chat.plainSearchText +import top.yeij.cyrene.ui.chat.sharePreviewBlocks +import kotlin.random.Random + +/** + * 行内标签(`InlineMarkup.kt`)单测:四类标签各自渲染成什么、六条边界口径、以及 + * 「星号语义一个字都没被动过」的反证。 + * + * 断言的是**纯函数的结果**(片段文本 + 样式),不碰 Compose:`runs` 拼起来就是渲染文本, + * 所以「哪段字是什么样式」在这里是直接可断言的。 + */ +class InlineMarkupTest { + + // ------------------------------------------------------------------ + // 四类标签各自渲染成什么 + // ------------------------------------------------------------------ + + @Test + fun boldTagBecomesABoldRun() { + assertEquals(listOf(run("18:35", InlineStyle.Bold)), parseInlineMarkup("18:35")) + } + + @Test + fun strongIsJustAnotherWayToWriteBold() { + assertEquals(listOf(run("18:35", InlineStyle.Bold)), parseInlineMarkup("18:35")) + } + + @Test + fun iAndEmBothBecomeItalicRuns() { + assertEquals(listOf(run("斜", InlineStyle.Italic)), parseInlineMarkup("斜")) + assertEquals(listOf(run("斜", InlineStyle.Italic)), parseInlineMarkup("斜")) + } + + @Test + fun codeTagBecomesACodeRun() { + assertEquals(listOf(run("abc", InlineStyle.Code)), parseInlineMarkup("abc")) + } + + @Test + fun brTagsBecomeANewline() { + assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲
乙")) + assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲
乙")) + assertEquals(listOf(run("甲\n乙")), parseInlineMarkup("甲
乙")) + } + + @Test + fun mixedTagsSplitTheTextIntoStyledRuns() { + assertEquals( + listOf(run("记住 "), run("abc", InlineStyle.Code), run(" 与 "), run("斜体", InlineStyle.Italic), run(" 哦")), + parseInlineMarkup("记住 abc 与 斜体 哦"), + ) + } + + /** 线上实测的那一条:只有 `` 一种标签,命中的正是她给的那个时间点。 */ + @Test + fun theRealMessageRendersAsThreeRuns() { + val runs = parseInlineMarkup("日落 18:35,比上海晚了整整一小时呢。") + assertEquals( + listOf( + run("日落 "), + run("18:35", InlineStyle.Bold), + run(",比上海晚了整整一小时呢。"), + ), + runs, + ) + assertEquals("日落 18:35,比上海晚了整整一小时呢。", runs.concat()) + } + + /** 没有标签时返回空列表 = 调用方走原来的字面路径(这是绝大多数消息的那一档)。 */ + @Test + fun plainTextYieldsNoRunsAtAll() { + assertTrue(parseInlineMarkup("今天那儿是晴,13~25℃").isEmpty()) + assertTrue(parseInlineMarkup("").isEmpty()) + assertTrue(parseInlineMarkup("多行\n文本\n也是普通的").isEmpty()) + } + + // ------------------------------------------------------------------ + // 边界 2:嵌套;边界 5:code 里的内容不再解析 + // ------------------------------------------------------------------ + + @Test + fun nestedBoldItalicMergesTheTwoStyles() { + assertEquals( + listOf(run("粗", InlineStyle.Bold), run("粗斜", InlineStyle.Bold, InlineStyle.Italic)), + parseInlineMarkup("粗粗斜"), + ) + } + + @Test + fun boldOutsideItalicKeepsBothStyles() { + assertEquals( + listOf(run("甲", InlineStyle.Bold), run("乙", InlineStyle.Bold, InlineStyle.Code), run("丙", InlineStyle.Bold)), + parseInlineMarkup("甲乙丙"), + ) + } + + @Test + fun aNewlineInsideBoldStaysBold() { + assertEquals(listOf(run("甲\n乙", InlineStyle.Bold)), parseInlineMarkup("甲
乙
")) + } + + @Test + fun codeContentIsNotParsed() { + // `` 里想显示什么就显示什么——包括尖括号(这也是「想显示尖括号怎么办」的答案)。 + assertEquals(listOf(run("甲", InlineStyle.Code)), parseInlineMarkup("甲")) + assertEquals(listOf(run("1 < 2 && a1 < 2 && a")) + } + + // ------------------------------------------------------------------ + // 边界 1:未配对的标签(一条口径:配成对 + 正确嵌套,否则字面) + // ------------------------------------------------------------------ + + @Test + fun unpairedTagsStayLiteral() { + // 没有闭合 ⇒ 整段字面(列表为空 = 按字面渲染,正文一个字符都不动) + assertTrue(parseInlineMarkup("没有闭合").isEmpty()) + assertTrue(parseInlineMarkup("没有闭合").isEmpty()) + assertTrue(parseInlineMarkup("日落 18:35").isEmpty()) + assertTrue(parseInlineMarkup("斜").isEmpty()) + assertTrue(parseInlineMarkup("看星星").isEmpty()) + } + + /** 配对的不受影响:半个标签字面留着,配成对的那半个照常生效。 */ + @Test + fun thePairedHalfStillWorksWhenTheOtherHalfIsMissing() { + assertEquals( + listOf(run("甲"), run("乙", InlineStyle.Italic)), + parseInlineMarkup("甲乙"), + ) + } + + @Test + fun mismatchedNestingStaysLiteral() { + // `` 关不掉 `i`(严格嵌套),于是它连同 `` 一起按字面留着,只有 `…` 生效。 + assertEquals( + listOf(run("甲"), run("乙丙", InlineStyle.Italic)), + parseInlineMarkup("甲乙丙"), + ) + } + + @Test + fun aStrayCloseTagNeverClosesAnything() { + assertEquals(listOf(run("甲", InlineStyle.Bold), run("乙")), parseInlineMarkup("甲乙")) + } + + // ------------------------------------------------------------------ + // 边界 3:正文里本来就想显示的尖括号 + // ------------------------------------------------------------------ + + @Test + fun plainAngleBracketsAreNeverEatenAndNeverRendered() { + for (text in listOf( + "1 < 2 且 3 > 2", + "ac", // 没有 `` ⇒ 字面(既有测试同样钉着这一条) + "你好` + "这不关 p 标签的事", + "不认识的标签", + "大写不认", + "带属性的也不认", + "不认识的自闭合", + )) { + val runs = parseInlineMarkup(text) + assertTrue("「$text」不该被判成行内标签", runs.isEmpty()) + assertEquals("「$text」必须逐字保留", text, parse("