首页
/ Joplin ENEX 导入:span 样式加粗文本转 Markdown 加粗的完整规则解析

Joplin ENEX 导入:span 样式加粗文本转 Markdown 加粗的完整规则解析

2026-09-06 21:52:09作者:廉皓灿Ida

本文以 Joplin 测试目录中的 ENEX 转 Markdown 对照数据 text_formatting_span_bold.md 为核心,逐条剖析 Evernote 导出的 <span style="..."> 加粗文本是如何被识别并转换为 Markdown **bold** 语法的。读完后你将掌握 Joplin 加粗识别的完整判定规则(font-weightfont-family 两类分支)、空格/跨行等边界情况的处理方式,以及该规则在 SAX 解析流中的具体实现位置。

这份对照数据说明了什么

Joplin 从 Evernote ENEX 文件导入笔记时,会把 ENEX 中 <en-note> 内的 HTML 内容转换为 Markdown。加粗是转换中最典型的“样式语义”之一:Evernote 的富文本里大量使用 <span style="font-weight: bold;"> 而不是标准的 <strong>/<b> 标签,Joplin 必须把这些 span 样式还原成 Markdown 的 **...**

这份数据是一组成对的测试夹具(fixture)

测试驱动位于 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;
	}
}

规则拆解:

  1. 先去除全部空白style.replace(/\s+/g, ''))。这解释了为什么用例 9 的 font-weight:bold(无空格)与用例 1 的 font-weight: bold;(有空格)都能命中同一条 includes('font-weight:bold') 分支——匹配发生在归一化之后。
  2. font-weight 分支只接受 bold 以及数字字重 700800900。用例 13 的 500 三个 includes 全部落空,正则分支也落空,于是返回 false,span 不加 **,文本按普通文本输出。
  3. font-family 分支用正则 /font-family:.*,Bold.*;/ 匹配“字体声明中包含 Bold”的写法,例如 'TimesNewRoman,Bold'。这是针对旧版 Evernote 客户端导出内容的兼容性处理:这些客户端有时不写字重,而是在字体名里用 Bold 后缀表达加粗。

紧邻的 isSpanStyleItalic 采用同样的归一化手法匹配 font-style:italic,两者共用同一套 span 处理骨架;对应的姊妹夹具 text_formatting_span_italic.htmltext_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_CLOSEbrNEWLINE。占位符在 processMdArrayNewLines 中统一转为真实换行,formatMdLayoutmergeMultipleNewLines 再把多个连续空行收敛为至多一个,最终得到数据文件中每条用例之间的一行空行。

如何运行与扩展验证

  • 运行: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.xmltasks.enex 等夹具验证的就是这条完整链路。

小结

text_formatting_span_bold.md 虽然只是一份 21 行的期望输出文件,但它以精确到空格的粒度钉住了 Joplin 加粗识别的全部关键行为:font-weight 的四种合法取值、font-family 的 Bold 后缀兼容、空白归一化前置、空格与换行的折叠规则,以及相邻加粗 span 各自成对包裹的输出形态。这些行为的实现锚点集中在 import-enex-md-gen.tsisSpanWithStyle / isSpanStyleBold / isSpanStyleItalic 与 span 的开合标签分支中;若要调整加粗识别规则(例如接受新的字重数值),只需修改 isSpanStyleBold 并在夹具中补充对应的 HTML/MD 对照行,测试框架会自动完成回归验证。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388