首页
/ Joplin 换行符保真转换:<br> 到"两空格加换行"的 HTML→Markdown 机制与 softbreaks 兼容设计

Joplin 换行符保真转换:<br> 到"两空格加换行"的 HTML→Markdown 机制与 softbreaks 兼容设计

2026-09-07 16:17:40作者:翟萌耘Ralph

本文以 Joplin 仓库中的换行测试样例 linebreaks.md 及其输入 linebreaks.html 为蓝本,讲解 Joplin 在 HTML→Markdown 转换中如何把 <br> 标签转换为"两个空格 + 换行",并保证生成的 Markdown 在 markdown.plugin.softbreaks 开关两种取值下都渲染出等价 HTML;结合 Turndown 分支源码,读者可以掌握这套换行策略的完整决策分支、反向(Markdown→HTML)渲染链路,以及代码块、连续换行等相关边界行为。

一、为什么需要这条转换规则:Markdown 换行的两种语义

Joplin 的笔记内容在内部以 Markdown 存储,渲染时经 packages/renderer/MdToHtml.ts 转成 HTML,而网页剪藏(Clipper)、富文本编辑器保存、导入等场景则要把 HTML 转回 Markdown。换行在这条"双向链路"里有两种截然不同的语义:

  • 软换行(soft break):源码里单独一个换行符。在 CommonMark 默认规则下,它渲染为一个普通空格(即两行文字会连成一行)。
  • 硬换行(hard break):行尾带两个(或更多)空格再加换行,渲染为 <br>;或者直接写成字面 <br/> 标签。

Joplin 的渲染器使用 markdown-it,其 breaks 选项决定"单个源码换行"是否升级为硬换行。从 MdToHtml.ts 的初始化代码可以看到:

const markdownIt = new MarkdownIt({
    breaks: !this.pluginEnabled('softbreaks'),
    ...
});

对应设置项 markdown.plugin.softbreaks 定义在 builtInMetadata.ts

'markdown.plugin.softbreaks': {
    storage: SettingStorage.File, isGlobal: true, value: false,
    type: SettingItemType.Bool, section: 'markdownPlugins', public: true,
    appTypes: [AppType.Mobile, AppType.Desktop],
    label: () => `${_('Enable soft breaks')}${wysiwygYes}`
},

默认关闭软换行(value: false),Joplin 使用硬换行:源码中的单个换行默认就渲染为 <br>。此外 migrations/27.ts 还会把旧的 markdown.softbreaks 值迁移到新的 markdown.plugin.softbreaks,说明该设置从私有设置演进为了公开的 Markdown 插件开关。

这就引出一个看似矛盾的问题:既然默认是硬换行,单个换行本身就等于 <br>,为什么 HTML→Markdown 还要特意在行尾补两个空格?因为转换出的 Markdown 必须对两种设置取值都成立——用户随时可能启用 softbreaks,而一旦启用,"行尾两空格"才是不依赖渲染器默认行为、在纯 CommonMark 语义下也成立的硬换行写法。这正是测试样例开头那句话的含义:

<br>-style linebreaks should be replaced with two spaces followed by a newline. This allows the generated markdown to render to equivalent HTML even if the markdown.plugin.softbreaks setting is enabled.

二、测试样例逐行拆解:linebreaks.html → linebreaks.md

测试输入 linebreaks.html 全文只有 10 行:

<h1>Linebreaks</h1>
<div>&lt;br&gt;-style linebreaks should be replaced with two spaces followed by a
	newline.<br/>This allows the generated mark<br/>down to render to equivalent HTML
	even if the <code>markdown.plugin.softbreaks</code> setting is enabled.</div>
<pre class="some-code" style="font-family:monospace;">&lt;br&gt;s shouldn't<br/>lead<br/>to trailing spaces in
code
however.
</pre>
<p>Because it isn't</p>
<div>necessary.<br/><br/><br/><br/>...</div>

期望输出 linebreaks.md

# Linebreaks

&lt;br&gt;-style linebreaks should be replaced with two spaces followed by a newline.
This allows the generated mark
down to render to equivalent HTML even if the `markdown.plugin.softbreaks` setting is enabled.

<br>s shouldn't lead to trailing spaces in code however.


Because it isn't

necessary.<br/><br/><br/>...

(以上 Markdown 代码块内的行尾空格在展示环境中不可见,实际文件里第 4、5 行末尾各带两个空格。)逐段对照:

输入片段 输出 命中的规则
行中 <br/>(如 newline.<br/>This newline. 行尾两空格 + 换行 br 选项:' \n'
<pre> 代码块内的 <br/> 仅普通换行,无尾随空格 node.isCode 分支
连续 <br/><br/><br/><br/> 后的 <br/> 前三个保留为字面 <br/> 标签,末一个仍按两空格处理 相邻 BR 节点分支
<code> 行内代码 反引号包裹,softbreaks 名称原样保留 常规 code 规则

其中代码块一行的存在是有深意的:fencedCodeBlock 规则会把代码内容 trimEnd()(见 commonmark-rules.jscode.replace(/^([ \t]*\n)+/, '').trimEnd()),因此即便误把 <br/> 换成两空格,尾随空格也会被裁掉;但更根本的做法是规则里直接对代码块内的 <br> 只输出换行,从源头上保证代码块不出现尾随空格——这正是样例中 "<br>s shouldn't lead to trailing spaces in code" 一句所固化的行为。

三、源码级实现:lineBreak 规则的三分支决策

Joplin 维护的 Turndown 分支中,<br> 的处理集中在 commonmark-rules.jslineBreak 规则:

rules.lineBreak = {
  filter: 'br',

  replacement: function (_content, node, options, previousNode) {
    let brReplacement = options.br + '\n';

    // Code blocks may include <br/>s -- replacing them should not be necessary
    // in code blocks.
    if (node.isCode) {
      brReplacement = '\n';
    } else if (previousNode && previousNode.nodeName === 'BR') {
      brReplacement = '<br/>';
    }

    return brReplacement;
  }
}

三个分支各有明确目的:

  1. 默认分支 options.br + '\n'options.br 在 Joplin 中被显式设为两个空格(见下文),因此输出 \n。这是"行尾两空格 + 换行"的硬换行标准写法,不依赖渲染器默认行为。

  2. node.isCode 分支:节点处于代码上下文时只输出 \n,避免代码块中出现尾随空格,保证代码内容逐字节保真。

  3. 前驱节点是 BR 的分支:输出字面 <br/> 标签而不是两空格。原因在 repeated_brs.md 用例中看得很清楚——输入 A<br/><br/><br/>test.<br/> 输出为:

    A
    <br/><br/>test.
    

    如果第二个、第三个 <br/> 也被替换为"两空格 + 换行",那么这两个"空格行"的行尾空格会被 markdown 渲染器修剪掉,在启用 softbreaks(即单个换行 = 空格)的渲染模式下,空行就消失了,两个硬换行会被压缩成一个。保留字面 <br/> 标签可以确保连续换行的行数在任何设置下都不丢失repeated_brs.md 的注释也点明了这一点:the markdown renderer discards these if the line is otherwise empty(渲染器会丢弃空行行尾的空格)。

br 选项的默认值在 turndown.js 的默认配置里就是 ' ';而 Joplin 的封装层 HtmlToMd.ts 又显式写死并留下注释,说明这是有意为之的关键参数:

// If soft-breaks are enabled, lines need to end with two or more spaces for
// trailing <br/>s to render. See
// https://github.com/laurent22/joplin/issues/8430
br: '  ',

注释指向的 issue #8430 正是本次策略的来源:桌面版更新日志 changelog/desktop.md 中记录的修复——"Make HTML <br/> tags convert to markdown compatible with the softbreaks setting"。也就是说,br: ' ' 并不是 Turndown 的"顺带行为",而是为了让 <br/> 的语义在 softbreaks 两种取值下都稳定不变而做的针对性设计。

四、反向链路:softbreaks 如何决定渲染结果

反向(Markdown→HTML)由 markdown-it 的 breaks 选项控制,即前文 MdToHtml.tsbreaks: !this.pluginEnabled('softbreaks')。把两个方向合起来,转换后的 \n 硬换行在两种设置下的行为是:

markdown.plugin.softbreaks breaks 选项 源码单个换行 "行尾两空格 + 换行"
关闭(默认) true 渲染为 <br> 渲染为 <br>
开启 false 渲染为空格 渲染为 <br>(纯 CommonMark 规则,行尾两空格 = 硬换行)

注意两种模式下"行尾两空格"的渲染结果一致,这正是 linebreaks.md 第一句所承诺的"render to equivalent HTML even if the setting is enabled"。差异只存在于普通单换行(softbreaks 关闭时单换行也会变成 <br>)——而这类单换行大多来自段落内的真实换行,不是 <br/> 转换产物,不属于本规则的职责范围。

五、相关行为与可调选项

5.1 连续空行的折叠

linebreaks.md 末尾的 necessary.<br/><br/><br/>... 展示了连续 <br/> 会被原样保留为字面标签,这在剪藏带大量空行的网页时可能产生视觉上的"大空白"。Joplin 为此提供了 collapseMultipleBlankLines 选项(见 HtmlToMd.tsParseOptions 声明),其测试在 tests/HtmlToMd.ts 中验证:开启后会把多个连续空行折叠为一行,同时保证单个空行不会被完全删除。这与 lineBreak 规则正交——前者处理"空行数量",后者处理"单个换行的表达形式"。

5.2 为什么不用更简单的 <br/> 全量保留?

从源码结构看,Joplin 选择在"单换行→两空格、连续换行→字面标签"之间做混合处理,而非全部保留 <br/>:两空格写法是更"Markdown 原生"的硬换行,配合默认硬换行渲染不会出现 HTML 标签污染;而连续换行场景中两空格写法存在被渲染器修剪的风险(见 3.3 分支),此时字面标签反而更可靠。两种写法各取所长,最终由 lineBreak 规则统一裁决。

六、如何验证这一行为

换行行为由 HTML/Markdown 文件对回归测试守护。tests/HtmlToMd.ts 的主测试逻辑是:遍历 packages/app-cli/tests/html_to_md/ 下所有 .html 文件,读取同名 .md 作为期望值,调用 htmlToMd.parse(

${html}
, options) 做逐字符比对;不匹配时按行打印 Got/Expected 差异。因此:

  • linebreaks.html / linebreaks.md 这对文件本身就是该规则的可执行规范——任何改动 lineBreak 规则或 br 选项的代码,若破坏了"行尾两空格 / 代码块无尾随空格 / 连续换行保留标签"中任意一条,此用例会直接失败;
  • 相邻用例 repeated_brsparagraph_with_nonbreaking_space 分别补充覆盖了"相邻 BR"和"行首不间断空格"两类边界。

七、适用前提与小结

  • 本文涉及的规则代码位于 Joplin 仓库内的 Turndown 分支 packages/turndown,其中 node.isCode、相邻 BR 检测等属于 Joplin 在分支上加入/依赖的行为;上游 Turndown 只有 br: ' ' 默认值这一个"基础件",其余决策逻辑以本仓库源码为准。
  • 设置项 markdown.plugin.softbreaksappTypes[Mobile, Desktop],默认值 false;行为描述均以当前仓库代码为准。

小结:Joplin 的 <br> → Markdown 策略可以浓缩为三条——行中换行输出"两空格 + 换行"以保证 softbreaks 开/关下渲染一致;代码块内换行只输出换行以杜绝尾随空格;相邻的第二个起 <br/> 输出字面标签以对抗渲染器对空行尾随空格的修剪。这三条规则连同 linebreaks.mdrepeated_brs.md 两个回归用例与 HtmlToMd.tsbr: ' ' 配置一起,构成了换行语义在 HTML 与 Markdown 之间无损往返的完整闭环。

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

项目优选

收起
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