# 7-16 [done][bug] mnote.doc.markdown_edit 中文归一化替换 byte index 误用 v1

> 发现时间：2026-05-17
>
> 状态：`[done]`
>
> 关联主线：`07-ai`
>
> 关联代码：
> - `rust/crates/mnote-web/src/hermes_tools/doc.rs:895-956` — `search_replace`
> - `rust/crates/mnote-web/src/hermes_tools/doc.rs:920-936` — 归一化匹配后的切片计算

## 1. 问题定义

`mnote.doc.markdown_edit` 的 `search_replace` 在 Level 2 “忽略首尾空白和全角 / 半角差异”分支中，先对 `norm_line` 调用 `find(&norm_search)`，得到的是 UTF-8 byte offset：

```rust
let start = norm_line.find(&norm_search).unwrap();
let end = start + norm_search.len();
```

随后代码把 `start / end` 当成字符序号传给 `line.char_indices().nth(...)`：

```rust
&line[..line.char_indices().nth(start).map(|(i, _)| i).unwrap_or(0)]
&line[line.char_indices().nth(end).map(|(i, _)| i).unwrap_or(line.len())..]
```

这在 ASCII 文本里不明显，但中文、中文标点、全角字符都是多字节。byte offset 不等于字符序号，最终替换范围会偏移。

## 2. 影响

- 页面 AI 对中文正文执行 `mnote.doc.markdown_edit` 时，归一化匹配可能替换错位置。
- 本地 `.md` 文件和在线 Convex 文档共用该工具，因此两条 AI 编辑路径都会受影响。
- 如果替换结果继续写回，用户看到的正文可能被局部破坏，而不是简单失败。

## 3. 复现思路

构造一行中文正文，让精确匹配失败但归一化匹配命中，例如带首尾空白或全角 / 半角差异的 search：

```text
原文：第一段内容
search： 一段
replace：二段
```

`find()` 得到的是 byte offset；当前代码按字符序号切片后，替换边界会落到错误字符位置。

## 4. 根因

`str::find` 返回 byte index；`char_indices().nth(n)` 的 `n` 是第几个字符。当前实现把两种索引体系混用。

## 5. 建议修复

- 在归一化时保留原文字符到归一化字符的 offset map。
- 或者只在同一字符串上使用 byte index，并确保切片边界来自同一个原始字符串的 `char_indices` 映射。
- 增加中文、多字节标点、全角英文 / 数字混排的 `search_replace` 单测。

## 6. 修复

已修复：

- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:928) 抽出 `search_replace_exact_or_normalized`，归一化匹配时不再把 `str::find` 的 byte offset 当作字符序号使用。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:959) 新增 `normalize_line_with_byte_map`，在归一化字符串与原始行之间保留 byte 边界映射。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:670) 增加中文归一化替换回归测试，覆盖 `一 段` 命中 `一段` 时的多字节切片边界。

## 7. 验证

```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web test_search_replace_normalized_chinese_byte_boundaries -- --nocapture
```

结果：1 个测试通过。
