Joplin ENEX 导入:span 样式加粗文本转 Markdown 加粗的完整规则解析
本文以 Joplin 测试目录中的 ENEX 转 Markdown 对照数据 text_formatting_span_bold.md 为核心,逐条剖析 Evernote 导出的 <span style="..."> 加粗文本是如何被识别并转换为 Markdown **bold** 语法的。读完后你将掌握 Joplin 加粗识别的完整判定规则(font-weight 与 font-family 两类分支)、空格/跨行等边界情况的处理方式,以及该规则在 SAX 解析流中的具体实现位置。
这份对照数据说明了什么
Joplin 从 Evernote ENEX 文件导入笔记时,会把 ENEX 中 <en-note> 内的 HTML 内容转换为 Markdown。加粗是转换中最典型的“样式语义”之一:Evernote 的富文本里大量使用 <span style="font-weight: bold;"> 而不是标准的 <strong>/<b> 标签,Joplin 必须把这些 span 样式还原成 Markdown 的 **...**。
这份数据是一组成对的测试夹具(fixture):
- 输入:text_formatting_span_bold.html,每一行是一个带 span 样式的
<div>段落,段落之间用<div><br/></div>分隔; - 期望输出:text_formatting_span_bold.md,即转换后应得到的 Markdown 文本。
测试驱动位于 import-enex-md-gen.test.ts:该用例会扫描 enex_to_md 目录下的所有 .html 文件,对每个文件调用 enexXmlToMd 执行转换,然后与同名的 .md 文件做逐字符的精确比对,不一致时打印“Got / Expected”差异并让测试失败。因此下面表格中的每一行都代表一条会被 CI 自动验证的转换规则。
完整用例对照:输入 HTML 与期望 Markdown
期望输出的原文全文如下(文件中的 ** 均为 Markdown 加粗语法本身):
**singleline bold text with span style font-weight: bold;.**
**singleline bold text with span style font-family: 'TimesNewRoman,Bold';.**
**multiline bold text with span style font-weight: bold;.**
**multiline bold text with span style font-family: 'TimesNewRoman,Bold';.**
**singleline bold text with span style font-weight: bold;** next to normal text with leading space.
**singleline bold text with span style font-weight: bold; and with trailing space **next to normal text.
**singleline bold text with span style font-weight: bold;**** next to more bold text with span style font-weight: bold; and with leading space.**
**singleline bold text with span style font-weight: bold; and with trailing space ****next to more bold text with span style font-weight: bold;.**
**bold no space**
**bold 700**
**bold 800**
**bold 900**
not bold
对应的输入 HTML 全文:
<div><span style="font-weight: bold;">singleline bold text with span style font-weight: bold;.</span></div><div><br/></div>
<div><span style="font-family: 'TimesNewRoman,Bold';">singleline bold text with span style font-family: 'TimesNewRoman,Bold';.</span></div><div><br/></div>
<div><span style="font-weight: bold;">multiline bold
text with span style font-weight: bold;.</span></div><div><br/></div>
<div><span style="font-family: 'TimesNewRoman,Bold';">multiline bold
text with span style font-family: 'TimesNewRoman,Bold';.</span></div><div><br/></div>
<div><span style="font-weight: bold;">singleline bold text with span style font-weight: bold;</span> next to normal text with leading space.</div><div><br/></div>
<div><span style="font-weight: bold;">singleline bold text with span style font-weight: bold; and with trailing space </span>next to normal text.</div><div><br/></div>
<div><span style="font-weight: bold;">singleline bold text with span style font-weight: bold;</span><span style="font-weight: bold;"> next to more bold text with span style font-weight: bold; and with leading space.</span></div><div><br/></div>
<div><span style="font-weight: bold;">singleline bold text with span style font-weight: bold; and with trailing space </span><span style="font-weight: bold;">next to more bold text with span style font-weight: bold;.</span></div>
<div><span style="font-weight:bold;">bold no space</span></div>
<div><span style="font-weight:700;">bold 700</span></div>
<div><span style="font-weight:800;">bold 800</span></div>
<div><span style="font-weight:900;">bold 900</span></div>
<div><span style="font-weight:500;">not bold</span></div>
逐条用例的语义如下:
| # | 用例 | 输入特征 | 期望结果 | 覆盖的规则 |
|---|---|---|---|---|
| 1 | 单行 font-weight: bold; |
标准写法(冒号后带空格) | 整段被 ** 包裹 |
font-weight 分支的基础匹配 |
| 2 | 单行 font-family: 'TimesNewRoman,Bold'; |
加粗通过字体名中的 Bold 表达(旧版 Evernote 客户端的常见写法) |
整段被 ** 包裹 |
font-family 分支匹配 |
| 3 | 跨行 font-weight: bold; |
span 内部文本含换行(multiline bold\n text) |
换行被折叠为空格,仍是单段加粗 | 文本内部换行折叠 |
| 4 | 跨行 font-family Bold |
同上,字体名分支 | 同上 | 换行折叠与字体名分支组合 |
| 5 | 加粗后紧跟普通文本,且普通文本以空格开头 | ...bold;</span> next to normal text... |
空格只属于普通文本:**...bold;** next to normal text... |
前导空格归属判定 |
| 6 | 加粗文本以空格结尾后紧跟普通文本 | ...trailing space </span>next to... |
空格留在加粗内:...trailing space **next to... |
尾随空格归属判定 |
| 7 | 相邻两个加粗 span,第二个 span 文本以空格开头 | </span><span ...> next to more bold text... |
输出为连续的 ;**** next to...,两个 span 各自成对包裹 |
相邻加粗段的边界与空格位置 |
| 8 | 相邻两个加粗 span,第一个 span 文本以空格结尾 | ...trailing space </span><span...>next to... |
空格落在第一段的 ** 之前:space ****next to... |
与用例 7 互为镜像 |
| 9 | font-weight:bold(冒号后无空格) |
CSS 无空格写法 | 加粗 | 匹配前需先去除全部空白 |
| 10–12 | font-weight:700 / 800 / 900 |
数字字重 | 全部加粗 | 数字字重 ≥700 视为粗体 |
| 13 | font-weight:500 |
中等字重 | 不加粗,输出纯文本 not bold |
500(及更低)不属于粗体 |
可以看到,这组数据刻意覆盖了加粗识别的四类问题:两种样式写法(font-weight / font-family)、文本边界(前导空格、尾随空格、跨行)、相邻 span(两个加粗段紧邻时空格归属哪一侧)、字重取值(bold、700/800/900 加粗,500 不加粗)。
源码规则:isSpanStyleBold 如何判定“加粗”
上述每一行用例的行为都由 import-enex-md-gen.ts 中的两个函数决定。先由 isSpanWithStyle 检查 <span> 是否携带 style 属性——没有 style 的 span 完全不进入样式处理分支;再由 isSpanStyleBold 做真正的判定:
function isSpanStyleBold(attributes: { style?: string }) {
let style = attributes.style;
if (!style) return false;
style = style.replace(/\s+/g, '');
if (style.includes('font-weight:bold') || style.includes('font-weight:700') || style.includes('font-weight:800') || style.includes('font-weight:900')) {
return true;
} else if (style.search(/font-family:.*,Bold.*;/) !== -1) {
return true;
} else {
return false;
}
}
规则拆解:
- 先去除全部空白(
style.replace(/\s+/g, ''))。这解释了为什么用例 9 的font-weight:bold(无空格)与用例 1 的font-weight: bold;(有空格)都能命中同一条includes('font-weight:bold')分支——匹配发生在归一化之后。 font-weight分支只接受bold以及数字字重700、800、900。用例 13 的500三个includes全部落空,正则分支也落空,于是返回false,span 不加**,文本按普通文本输出。font-family分支用正则/font-family:.*,Bold.*;/匹配“字体声明中包含 Bold”的写法,例如'TimesNewRoman,Bold'。这是针对旧版 Evernote 客户端导出内容的兼容性处理:这些客户端有时不写字重,而是在字体名里用Bold后缀表达加粗。
紧邻的 isSpanStyleItalic 采用同样的归一化手法匹配 font-style:italic,两者共用同一套 span 处理骨架;对应的姊妹夹具 text_formatting_span_italic.html 与 text_formatting_span_italic.md 验证的是斜体分支。
转换流程:SAX 流中 span 的开启与闭合
转换入口 enexXmlToMd 把 ENEX 的 HTML 字符串转成流,交给 @joplin/fork-sax(见 packages/fork-sax)做 SAX 风格的事件解析。解析器不构建 DOM,而是边扫边向当前 section 的 lines 数组推送 Markdown 片段与特殊占位符([[BLOCK_OPEN]]、[[NEWLINE]]、[[SPACE]] 等)。
<span> 的处理分布在 opentag 与 closetag 两个事件里:
- 开启(L978-L990):若 span 带 style,先把属性对象压入
state.spanAttributes栈,然后调用isSpanStyleBold——为真就向lines推一个**;接着再调isSpanStyleItalic——为真就推一个*。两个判断是独立叠加的,理论上可以同时输出***(粗斜体)。 - 闭合(L1182-L1193):从栈顶弹回开启时保存的属性,重新判定一次,再推一个配对闭合的
**或*。用“弹栈后重新判定”而不是记录标记,保证了嵌套、错序场景下闭合符号与开启时的判定一致。
注意用例 7、8 中 **** 的来源:两个相邻加粗 span 的处理是独立发生的——第一个 span 闭合时推 **,第二个 span 开启时立即又推 **,于是 lines 里出现相邻的两个 ** 片段,最终在输出中呈现为 ;****。这正是“span 粒度包裹”而非“合并连续加粗段”的直观证据。
空格与换行为什么不会破坏输出
空格的归属由 collapseWhiteSpaceAndAppend 完成:文本事件到达时,若不在代码块内,会先剥掉首尾的 \n/\r,再通过 simplifyString 把连续空白折叠为一个空格;同时记录 spaceLeft/spaceRight 标志,分别用 SPACE 占位符推送到文本片段的前/后。用例 5 的空格属于 span 之后的普通文本事件,所以落在 ** 之外;用例 6 的空格在 span 文本内部,所以落在 ** 之内。最终在 processMdArrayNewLines 中,SPACE 占位符只有在“前一个片段不是空格、不是换行、且不在行首”时才真正输出为空格,这保证了 ** 边界处的空格恰好只保留一份。
跨行文本(用例 3、4)经过 unwrapInnerText:按行切分后把各行用空格拼接(保留空行为换行),再交给上面的折叠逻辑,于是 multiline bold\n text 变成单行 multiline bold text,** 仍然完整包住整段。
段落之间的空行来自分隔用的 <div><br/></div>:div 属块级标签,开合时各推 BLOCK_OPEN/BLOCK_CLOSE;br 推 NEWLINE。占位符在 processMdArrayNewLines 中统一转为真实换行,formatMdLayout 与 mergeMultipleNewLines 再把多个连续空行收敛为至多一个,最终得到数据文件中每条用例之间的一行空行。
如何运行与扩展验证
- 运行:
text_formatting_span_bold这一对夹具由 import-enex-md-gen.test.ts 中“should convert ENEX content to Markdown”用例驱动,按仓库常规测试流程运行 lib 包的 Jest 即可被自动覆盖;新增用例时只需在 enex_to_md 目录放入新的xxx.html与手工写好的期望文件xxx.md,无需改动测试代码。 - 对比相邻夹具:通用
<b>/<i>/<strong>等标签的转换由 text_formatting.html / text_formatting.md 覆盖,span 样式分支(本篇主题)则拆分为 bold 与 italic 两对独立夹具,规则边界互不干扰。 - 完整链路:夹具直接调用的是纯转换函数
enexXmlToMd;真实的.enex文件导入则由 import-enex.ts 串起 XML 外层解析、资源落盘、任务列表提取,再调用本模块完成正文转换,目录中的sample-enex.xml、tasks.enex等夹具验证的就是这条完整链路。
小结
text_formatting_span_bold.md 虽然只是一份 21 行的期望输出文件,但它以精确到空格的粒度钉住了 Joplin 加粗识别的全部关键行为:font-weight 的四种合法取值、font-family 的 Bold 后缀兼容、空白归一化前置、空格与换行的折叠规则,以及相邻加粗 span 各自成对包裹的输出形态。这些行为的实现锚点集中在 import-enex-md-gen.ts 的 isSpanWithStyle / isSpanStyleBold / isSpanStyleItalic 与 span 的开合标签分支中;若要调整加粗识别规则(例如接受新的字重数值),只需修改 isSpanStyleBold 并在夹具中补充对应的 HTML/MD 对照行,测试框架会自动完成回归验证。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00