前言
AI时代,前端开发者在将大模型输出的Markdown文本解析成HTML渲染时,想必有遇到过加粗未生效的问题:
模型幻觉是模型因统计偏差**“无意识”生成错误内容,而模型欺骗是模型“有意识”**地隐瞒或歪曲真相,两者本质不同但常被混淆。
乍一看,心想:完蛋了,又写bug了。预期是 “无意识” 与 “有意识” 被加粗,怎么变成中间内容加粗了??
别慌,其实这不是前端的问题,也不是Markdown解析器的问题,而是CommonMark的规定如此。
具体可以看看这篇文章:为什么掘金的 Markdown 加粗语法(……)有时候不生效?
这项规范在中文语境下很不友好,同理,韩文、日文一样。
比如,截图MDN上的这篇文章,其中的WebSocket()在英文时可以正常加粗展示,而中文时却展示出了 **WebSocket()**。这种Markdown加粗未按预期渲染的情况,在网络平台上常见,刚好掘金也是其中之一。(**叠甲:**没有说掘金不对,掘金这是合理的)
Markdown规范
主流的Markdown解析是基于CommonMark及GFM规范实现的。
其中CommonMark对于加粗或者斜体的定义:spec.commonmark.org/0.30/#right…
总结:定界符序列(一个或多个*或者_)的规则。
左侧定界符序列:
- 后面不能是空白;
- 后面是标点符号时,前面必须有空白或标点符号。
右侧定界符序列:
- 前面不能是空白;
- 前面是标点符号时,后面必须有空白或标点符号。
关于加粗规范的定义,以及为何在中英文会有所区别,在上面中提到的文章里写的很好,此处便不再赘述。
项目里用到的解析器是markedjs@14.1.2
当初遇到 ** 的加粗问题时,查看issue,发现众多人也有相同的困扰,仓库作者表示这是符合规则定义的,如果需要按个人预期结果解析可以自定义插件实现。
目之所及大部分是关于加粗的疑问:
随便贴一个issue:github.com/markedjs/ma…
如何解决?
既然明确了Markdown规则,那就按照正确的规则来书写文本。
1. 添加空白
两侧定界符外围各添加1个空格
今年收益率 99% 表现不俗,继续加油!
模型幻觉是模型因统计偏差 “无意识” 生成错误内容,而模型欺骗是模型 “有意识” 地隐瞒或歪曲真相,两者本质不同但常被混淆。
文本之间有明显的空格,对于严谨的产品方可能不会接受这种效果。
2. 添加标点符号
零宽字符:一类在视觉上不可见、不占用显示宽度,但在计算机底层实际存在并占用存储空间的Unicode控制字符。
比如:今年收益率‌**-45%**‌表现很俗,需改进!
今年收益率**-45%**表现很俗,需改进!
还有其它类型的零宽字符:
| 字符名称 | 英文全称 | 简称 | Unicode 码位 | HTML 实体名称 | HTML 实体编号 | 主要用途 |
|---|---|---|---|---|---|---|
| 零宽空格 | Zero Width Space | ZWSP | U+200B | &zwsp; | ​ | 用于长单词内部的换行分隔,防止自动换行破坏布局;也可用于文本分析中的隐形分隔。 |
| 零宽非连接符 | Zero Width Non-Joiner | ZWNJ | U+200C | ‌ | ‌ | 阻止字符间的连字效果。例如在波斯语或阿拉伯语中,强制两个本应连写的字符分开显示。 |
| 零宽连接符 | Zero Width Joiner | ZWJ | U+200D | ‍ | ‍ | 强制字符间产生连字效果。广泛用于Emoji组合(如👨👩👦家庭表情)及复杂文字系统的排版。 |
| 零宽无断空格 | Zero Width No-Break Space | ZWNBSP / BOM | U+FEFF | (无标准命名实体) |  | 通常用作字节顺序标记(BOM)。在文本中使用时,可防止在该位置换行,功能类似不间断空格但宽度为零。 |
以上方案对于熟知规则的人来讲,可以在编辑时解决加粗标识未生效的问题,比如使用掘金编辑器时。
那对于大模型输出的随机答案呢?即使可以在模型训练时额外要求,但是也难以避免模型输出,并且不是所有的企业都能自己训练模型,那这种情况出现在生产环境,用户看到的就是 ** 了,产品经理就来找前端了。(前端苦啊)
3. 正则匹配替换
由开发者处理,在文本经过Markdown解析器之前,为 ** 外围添加空格或零宽字符,再调用解析器输出渲染。
左侧 ** 替换为
‌**,右侧 ** 替换为**‌
不过,正则匹配 ** 的规则该怎么写,需要考虑空格吗?需要考虑嵌套吗?会导致其它问题吗?
其实这里又回到了定界符的定义。Markdown解析器本质也是正则匹配,按照CommonMark规范书写正则,去匹配 ** 。
既然如此,那我们可以根据使用的Markdown解析器修改定界符的规则,使其满足中文语境。
邪修大法改造
按markedjs作者说的,可以实现自定义插件去解析加粗 **
markedjs官方自定义插件文档:marked.js.org/using_pro#e…
结果一看源码里关于加粗这块的实现,真的很复杂,如果开发者自己写插件额外实现,可能会导致更多的潜在问题。
如果能在解析器的基础上(站在巨人的肩膀上),只将关于标点符号的规则调整一下,这样便可以很少的改动完成预期。
那就先看源码试试,找到源码里关于规则的定义:github.com/markedjs/ma…
截图是v14.1.2版本:
可以很快识别到.replace(/punct/g, _punctuation)这行代码的作用。
看看_punctuation的定义const _punctuation = '\\p{P}\\p{S}';。这类是正则表达式里用于匹配特定类别的unicode字符的模式,这些模式基于unicode字符属性,可以更精确地匹配不同类型的字符。
那这里的\\p{P}\\p{S}表示匹配所有的标点符号和空白字符,即要求定界符前后需要这样的字符限定。
按照本土化改造,我们期望在定界符前后有标点符号(%、+、—)时也满足定界符的定义,可以将\\p{P}模式删掉。为了不影响原有的变量,新增变量_punctuationCustom替换 ** 规则replace的地方:
如此便可以最小改动实现预期,既解决了标点符号问题,又不影响原有规则下空格分词实现嵌套的场景。
改造前:
改造后:
两种实操方案
-
在 markedjs GitHub仓库找到你需要的版本,clone一份,修改源码后再打包使用。优点:可以使用最新的release包。
-
或者
npm i marked@14.1.2,这个版本的npm包里有编译后未混淆的源码可以直接修改使用。为什么是这个版本?在我使用
markedjs时,安装的恰好是这个版本,后续的版本产物混淆了,没办法直接修改js文件。
另一个较多使用的解析器是markdown-it,社区里提供了支持中文加粗规则的插件:markdown-it-cjk-friendly
最后
市面上多个Markdown编辑器、网页的表现也不一样。比如:
- VScode:完全符合
CommonMark规范。 - Typora:有自己的解析规则,所见即所得,输入 ** 就会被解析为加粗,不考虑空格和标点符号,在嵌套场景下完全混乱了。
- 各家App、网页:展示也会有所不同,处理方式也不同。
一个合适自己项目的方案就是最好的方案。