复审 review-m19-r1 的 p2:`codeProtectedEnd` 的围栏分支只判「下一个 ≥3 反引号
的串」,没判「闭合不短于开启」。CommonMark 要求闭合串**至少与开启串等长**,
因此 ```` 开启的围栏不能被内层 ``` 关闭。复现形态(````markdown 里嵌 ```go,
示例里写 `print("a\nb")`):旧实现把内层 ``` 当闭合 ⇒ 之后的示例正文被当普通
正文渲染,代码里的 `\n` 变成真换行;而这一步跑在**落库之前**,改坏的代码会永久进
历史 —— 反向踩了本文件「宁可少渲染也不改坏代码」的自己的红线。
- literal_breaks.go:围栏分支改为 findBacktickRun(text, body, n, unboundedRun)
(closing ≥ opening);注释写明 CommonMark 口径与「改了会落库」的后果。
- 新增 TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun:嵌套围栏逐字保留、
围栏外仍照常渲染(保护不得溢出)。摘掉这一行 ⇒ 该用例红,还原即绿。
- 新增 TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak:钉住 `\n\n` ⇒ 空行 ⇒
两段 <p> ⇒ 两端各 2 条气泡(复审观察②,语义对但此前无用例覆盖)。
- 措辞收口:样本 455 字符 / 907 字节;IM 侧主动推送的注释补明「Web 活跃客户端那条
主动推送不经 NormalizeParagraphs,另计」。
行内代码那条等长分支未动。全树 go build ./... + go vet ./... + go test -count=1 ./...
全绿(73 包,RC 0)。
144 lines
6.0 KiB
Go
144 lines
6.0 KiB
Go
package turnreply
|
||
|
||
import (
|
||
"math"
|
||
"strings"
|
||
)
|
||
|
||
// 本文件承载助手文本里**字面转义**的渲染:模型偶尔把换行写成**两个字符**
|
||
// `\n`(反斜杠 + n),而不是一个真换行。
|
||
//
|
||
// 缺陷形状(主人 2026-10-24 报障,真机原文):她列了七个中古调式,用字面 `\n`
|
||
// 当分隔,全挤在**同一个 `<p>`** 里 ——
|
||
//
|
||
// <p>伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:…\n弗里吉亚:…</p>
|
||
//
|
||
// 清洗链不认这两个字符(全仓没有任何一处把字面 `\n` 当换行),于是原样发给 QQ,
|
||
// 用户看到的是「F\n多利亚」,Web 气泡里也一样(`whitespace-pre-wrap` 只对**真**
|
||
// 换行生效)。⇒ 这一层是**写入侧规整**:把字面 `\n` 换成真换行,落库文本、WS
|
||
// `message_persisted`、REST reply、IM 出站气泡就是**同一份**(与
|
||
// NormalizeParagraphs 同一条口径,见 paragraph.go 的调用点)。
|
||
//
|
||
// 判据与边界:
|
||
// - 只认**恰好一个**反斜杠紧跟 `n`。`\\n`(双反斜杠 + n,模型在讲转义规则或
|
||
// 写 Windows 路径时 legitimately 会写)**原样保留** —— 它的语义是「一个反斜杠
|
||
// 加字母 n」,把它当换行等于篡改内容。
|
||
// - **代码围栏(```)与行内代码(`…`、``…``)里的 `\n` 逐字不动**:模型讨论
|
||
// 代码时写 `print("a\nb")` 是**代码内容**,全局替换会把代码改坏。围栏的闭合串
|
||
// 按 CommonMark 取「不短于开启串」(```` 开的围栏不能被内层 ``` 关闭),行内
|
||
// 代码取「等长」,因此嵌套围栏示例(```go 套在 ````markdown 里)同样受保护。
|
||
// - 不认识的转义(`\t` / `\r` / `\uXXXX` / 行尾单独的 `\`)**一律不动**:这一层
|
||
// 只修主人报了的那一个形状,不顺手扩大语义(其余字面转义见切片报告)。
|
||
// - **不制造结构标记**:本函数只产出换行字符,绝不产出 `<br>` / `<p>` ——
|
||
// `<br>` 在 chat 口径里是**气泡/消息分隔符**(把一条列表升级成七条消息反而更吵),
|
||
// 字面 `\n` 只应是**气泡内部的换行**。
|
||
// - 幂等:渲染后的文本里不再有可被本函数改写的字面 `\n`(代码里的那些本来就不动),
|
||
// 因此二次调用逐字相同。
|
||
|
||
const (
|
||
// literalBreakEscape 是模型实际写出的「字面换行」:反斜杠 + n 两个字符。
|
||
literalBreakEscape = `\n`
|
||
// backtickCh 是代码围栏与行内代码的定界符。
|
||
backtickCh = '`'
|
||
// backslashCh 是转义引导符。
|
||
backslashCh = '\\'
|
||
// fenceRunLen 是围栏代码块的定界符长度(paragraph.go 的 codeFenceMarker 是
|
||
// 三个反引号;它在 var 块里,len() 不是常量,故这里写死同一个 3)。
|
||
fenceRunLen = 3
|
||
// unboundedRun 表示「反引号串长度不设上限」(围栏的闭合串可以更长,
|
||
// 如 ````> 也能闭合 ``` 开的围栏)。
|
||
unboundedRun = math.MaxInt
|
||
)
|
||
|
||
// RenderLiteralLineBreaks 返回把字面 `\n` 渲染成真换行后的文本:代码围栏与行内
|
||
// 代码内原样保留,`\\n` 与其余转义不动,不含字面 `\n` 的文本逐字返回(零分配路径)。
|
||
func RenderLiteralLineBreaks(text string) string {
|
||
if !strings.Contains(text, literalBreakEscape) {
|
||
return text
|
||
}
|
||
var b strings.Builder
|
||
b.Grow(len(text) + 8)
|
||
for i := 0; i < len(text); {
|
||
switch text[i] {
|
||
case backtickCh:
|
||
// 围栏/行内代码:整段逐字复制,内部的 `\n` 一个字不动。
|
||
end := codeProtectedEnd(text, i)
|
||
b.WriteString(text[i:end])
|
||
i = end
|
||
case backslashCh:
|
||
switch {
|
||
case i+1 >= len(text):
|
||
// 行尾孤立的 `\`:原文保留。
|
||
b.WriteByte(text[i])
|
||
i++
|
||
case text[i+1] == 'n':
|
||
b.WriteByte('\n')
|
||
i += 2
|
||
case text[i+1] == backslashCh:
|
||
// `\\` 成对吞掉 ⇒ 紧随其后的 `n` 是普通字母,`\\n` 因此原样保留。
|
||
b.WriteString(text[i : i+2])
|
||
i += 2
|
||
default:
|
||
// `\t` / `\r` / `\u0041` 等:本层不处理,原样保留。
|
||
b.WriteByte(text[i])
|
||
i++
|
||
}
|
||
default:
|
||
b.WriteByte(text[i])
|
||
i++
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// codeProtectedEnd 返回从 start 起的**受保护区间**的结束下标(半开):
|
||
// - `start` 处是 ``` 起(含更长):一个围栏代码块,止于**不短于开启串**的反引号串
|
||
// (CommonMark 口径:```` 开的围栏不能被内层的 ``` 关闭 —— 否则嵌套围栏示例里的
|
||
// `\n` 会被当成正文渲染,而这段文本紧接着就落库)。没有闭合围栏时**保护到文本
|
||
// 末尾** —— 宁可少渲染一处换行,也不把可能仍是代码的正文改坏。
|
||
// - `start` 处是一或两个反引号:一个行内代码 span,止于**等长**的反引号串
|
||
// (CommonMark 口径)。找不到等长闭合串时,这几个反引号只是普通字符,
|
||
// 区间就是它们自身(不吞后面的正文)。
|
||
func codeProtectedEnd(text string, start int) int {
|
||
n := backtickRunLen(text, start)
|
||
body := start + n
|
||
if n >= fenceRunLen {
|
||
if close := findBacktickRun(text, body, n, unboundedRun); close >= 0 {
|
||
return close + backtickRunLen(text, close)
|
||
}
|
||
return len(text)
|
||
}
|
||
if close := findBacktickRun(text, body, n, n); close >= 0 {
|
||
return close + n
|
||
}
|
||
return body
|
||
}
|
||
|
||
// findBacktickRun 在 text[from:] 里找**下一个**长度落在
|
||
// [atLeast, atMost] 区间内的连续反引号串的起始下标,找不到返回 -1。
|
||
// 围栏用 atLeast=fenceRunLen / atMost=unboundedRun(更长的围栏也能闭合),
|
||
// 行内代码用 atLeast==atMost==定界符长度(等长才算闭合)。
|
||
func findBacktickRun(text string, from, atLeast, atMost int) int {
|
||
for i := from; i < len(text); {
|
||
if text[i] != backtickCh {
|
||
i++
|
||
continue
|
||
}
|
||
r := backtickRunLen(text, i)
|
||
if r >= atLeast && r <= atMost {
|
||
return i
|
||
}
|
||
i += r
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// backtickRunLen 返回 text[at:] 起连续反引号的个数(at 越界时为 0)。
|
||
func backtickRunLen(text string, at int) int {
|
||
n := 0
|
||
for at+n < len(text) && text[at+n] == backtickCh {
|
||
n++
|
||
}
|
||
return n
|
||
}
|