用 C++ 写一个带实时预览的 Markdown 编辑器,听起来像是一个不小的工作量,其实拆开看只有三件事:左侧的文本编辑区、右侧的预览区,以及把 Markdown 内容即时转换为 HTML 的解析层。这篇文章用 Qt 和 cmark 库,从零搭出一个最小可运行版本,并逐步把语法高亮、本地图片加载、常见环境问题排查等内容补上。如果你已经能写完一个简单的 Qt Widgets 窗口程序,但对 Markdown 解析和实时刷新还没有完整思路,这篇文章会给你一条清晰的落地路径。
实时预览不是指用户点一下按钮才渲染,而是编辑区内容发生变化后,预览区自动更新。要做到这一点,依赖一个稳定的信号循环和解析库。读完这篇内容后,你可以得到一个带实时预览的桌面 Markdown 编辑器骨架;如果要继续扩展成笔记工具、技术写作工具或内部文档客户端,也可以在此基础上直接加文件管理、主题切换、导入导出等功能。
1. 实时预览编辑器要拆成哪几个模块
1.1 输入、解析、渲染三层
Markdown 编辑器的核心并不神秘。用户在一个文本控件里输入 Markdown 原文,程序把原文交给解析器,解析器生成 HTML,再把 HTML 交给预览控件渲染。整个流程就是“输入 -> 解析 -> 渲染”三个环节。
- 输入层:
QPlainTextEdit,负责接收键盘输入、支持基本的文本选择、撤销重做。 - 解析层:
cmark,把 Markdown 文本转换成 HTML 字符串。 - 渲染层:
QTextBrowser,把 HTML 字符串展示为富文本,外部链接可以交给系统浏览器处理。
把这三个环节拆开之后,实时预览就只是一个信号连接问题:当编辑区的文本发生变化时,重新执行一次“解析 -> 渲染”即可。
这段链路里,最容易被低估的是解析层。很多人会先写一个 replace 函数,把#替换成<h1>,把**替换成<strong>。这种方式处理简单的单行语法没问题,一旦遇到嵌套列表、代码块、转义字符和链接,就会漏洞百出。所以直接使用成熟的 Markdown 解析库,是更值得投入的选择。
1.2 为什么选 Qt + cmark,不自己写解析器
Markdown 语法看起来简单,真正解析起来非常容易出错。列表嵌套、段落换行、行内代码、转义字符、引用块、任务列表、表格,每一类都有边界情况。自己写一个支持常用语法的解析器,至少要几百行,而且处理不完整会造成预览和源码不一致。直接使用开源解析库是更稳妥的方案。
cmark 是 CommonMark 官方参考实现,用 C 语言编写,提供了非常简洁的 C API。它没有额外依赖,编译出来只有一个静态库或动态库,很适合嵌入到 C++ 工程里。Qt 则负责 UI、事件循环、富文本显示和跨平台编译。这两个库搭配,可以比较轻松地完成一个可用的桌面编辑器。
在选型时还要考虑渲染层。最简单的是QTextBrowser,它基于QTextDocument,支持 HTML 子集,不需要额外安装浏览器内核。若需要完整支持 CSS、JavaScript、代码高亮,可以使用QWebEngineView,但代价是运行时依赖 WebEngine。对于大多数笔记场景,QTextBrowser已经足够。
注意:预览控件的选型会影响发布包体积和启动速度。QTextBrowser 轻量,QWebEngineView 渲染能力强,两者不是替代关系,而是不同场景的取舍。
1.3 最小闭环目标和学习环境差异
这篇文章的最小目标很明确:打开程序后,左侧输入 Markdown,右侧实时刷新 HTML 渲染结果。先把这条链路跑通,再谈高亮、文件保存、主题切换等功能。
在学习环境里,建议直接使用 Qt Creator 或 CMake 命令行工程,不要一开始就加入复杂的插件系统。生产环境则需要考虑打包、日志、配置外置、崩溃收集等问题。如果你是在公司项目里落地,还需要把 Markdown 解析结果做缓存、控制在每次按键时是否全量解析,以及兼容不同电脑上的运行时版本。
2. 环境准备与依赖配置
2.1 编译工具链和 Qt 版本选择
文章示例使用 Qt Widgets 模块,不依赖 QML。推荐 Qt 5.15 或 Qt 6.5 以上,二者 API 在本文用到的范围内基本一致。如果你在 Windows 上使用 MSVC,需要同时安装 Visual C++ Redistributable;在 Linux 上使用 GCC,则需要安装 base-devel 或 build-essential。很多 C++ 项目启动时报缺少VCRUNTIME140.dll,本质上就是 Visual C++ Redistributable 版本没有装全。
如果你是第一次创建工程,建议用 Qt Creator 自带的 “Qt Widgets Application” 模板,再手动加入 cmark 依赖。本文的代码结构如下:
MarkdownEditor/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── MainWindow.h │ ├── MainWindow.cpp │ ├── MarkdownHighlighter.h │ └── MarkdownHighlighter.cpp这个结构足够支撑一个带预览、高亮和文件处理的小型桌面程序。如果你后续要加入测试或插件,可以在src旁边继续增加tests、plugins目录。
2.2 安装 cmark 的三种方式
cmark 的安装方式取决于操作系统和包管理器。
在 Ubuntu / Debian 上:
sudo apt update sudo apt install libcmark-dev在 Windows 上使用 vcpkg:
vcpkg install cmark如果你不想使用系统包管理器,也可以直接源码编译:
git clone https://github.com/commonmark/cmark.git cd cmark mkdir build && cd build cmake .. cmake --build . --config Release sudo cmake --install .源码编译方式是通用兜底方案。需要注意的是,不同发行版提供的 cmark 版本可能不同,CMake config 文件的 target 名称也可能不同。落地上机前先确认安装版本和 CMake 能找到的 target 名称。
2.3 CMake 工程里同时连接 Qt 和 cmark
在 CMakeLists.txt 中,先查找 Qt,再查找 cmark。下面是一个兼容 Qt5 / Qt6 的示例:
cmake_minimum_required(VERSION 3.16) project(MarkdownEditor LANGUAGES CXX C) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt6 COMPONENTS Widgets QUIET) if(NOT Qt6_FOUND) find_package(Qt5 5.15 REQUIRED COMPONENTS Widgets) endif() find_package(cmark CONFIG QUIET) if(NOT cmark_FOUND) find_package(PkgConfig REQUIRED) pkg_check_modules(CMARK IMPORTED_TARGET REQUIRED libcmark) endif() if(TARGET cmark::cmark) set(CMARK_TARGET cmark::cmark) elseif(TARGET PkgConfig::CMARK) set(CMARK_TARGET PkgConfig::CMARK) else() set(CMARK_TARGET cmark) endif() add_executable(markdown_editor src/main.cpp src/MainWindow.cpp src/MainWindow.h src/MarkdownHighlighter.cpp src/MarkdownHighlighter.h ) target_link_libraries(markdown_editor PRIVATE Qt${QT_VERSION_MAJOR}::Widgets ${CMARK_TARGET} )关键点有两处。一是CMAKE_AUTOMOC必须开启,因为MainWindow和MarkdownHighlighter都是 QObject 子类,需要处理信号槽。二是LANGUAGES CXX C要同时包含 C,因为 cmark 本身是 C 项目,虽然最终通过库连接,但某些构建方式会需要 C 语言编译规则。
如果find_package找不到 cmark,通常是因为没有安装开发包或没有设置CMAKE_PREFIX_PATH。不要直接手写target_link_libraries指向一个假设的路径,优先使用 CMake 的 package 机制,这样换机器后更容易复现。
3. 实现一个最小可运行版本
3.1 创建主窗口与左右分栏布局
主窗口使用QSplitter把编辑区和预览区左右排布。QSplitter不仅提供分隔条,还允许用户拖动调整左右宽度,这对 Markdown 编辑器是基本体验要求。
#include <QMainWindow> #include <QSplitter> #include <QPlainTextEdit> #include <QTextBrowser> class MainWindow : public QMainWindow { Q_OBJECT public: explicit MainWindow(QWidget *parent = nullptr); private slots: void renderMarkdown(); private: QPlainTextEdit *editor_; QTextBrowser *preview_; };构造函数的实现如下:
#include "MainWindow.h" MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), editor_(new QPlainTextEdit(this)), preview_(new QTextBrowser(this)) { auto *splitter = new QSplitter(Qt::Horizontal, this); splitter->addWidget(editor_); splitter->addWidget(preview_); splitter->setStretchFactor(0, 1); splitter->setStretchFactor(1, 1); setCentralWidget(splitter); setWindowTitle("C++ Markdown Live Preview"); resize(1000, 700); connect(editor_, &QPlainTextEdit::textChanged, this, &MainWindow::renderMarkdown); }在renderMarkdown()尚未实现之前,程序可以编译运行,但右侧不会更新。先把界面搭出来是为了验证布局是否正常,再继续加入解析逻辑。
3.2 用 cmark 把 Markdown 转成 HTML
这是整篇文章最核心的一段代码。先写一个工具函数,输入QString,输出QString:
#include <cmark.h> QString markdownToHtml(const QString &markdown) { QByteArray utf8 = markdown.toUtf8(); char *html = cmark_markdown_to_html( utf8.constData(), utf8.size(), CMARK_OPT_DEFAULT ); QString result = QString::fromUtf8(html); free(html); return result; }cmark_markdown_to_html接受const char*和字节长度,所以必须先把 QString 转成 UTF-8 字节数组。返回的char*是 cmark 内部通过 malloc 分配的内存,用完后必须调用free(),否则每次预览都会泄漏一块内存。
CMARK_OPT_DEFAULT表示使用 CommonMark 默认行为。常用选项如下:
| 选项 | 作用 | 使用建议 |
|---|---|---|
CMARK_OPT_DEFAULT | 默认解析行为,不输出源码位置,保留 HTML 块中的潜在危险标签 | 基础场景使用 |
CMARK_OPT_SOURCEPOS | 在输出 HTML 中加入 sourcepos 属性 | 调试解析问题 |
CMARK_OPT_HARDBREAKS | 将普通换行渲染为<br> | 需要保留换行时使用 |
CMARK_OPT_UNSAFE | 允许渲染原始 HTML 和危险链接 | 仅信任输入时使用 |
CMARK_OPT_SMART | 将直引号、省略号等转为弯引号 | 文档排版美化场景 |
默认情况下,<script>等原始 HTML 不会被渲染成可执行内容,这是安全设计,不是 bug。若编辑器用于公开内容,不要随便开启CMARK_OPT_UNSAFE。
3.3 用 textChanged 信号驱动实时预览
QPlainTextEdit::textChanged在每次文本内容变化时触发。在这里连接renderMarkdown,就能实现“敲一个字,预览区刷新”的效果。
void MainWindow::renderMarkdown() { QString html = markdownToHtml(editor_->toPlainText()); preview_->setHtml(html); }这段代码简单直接,但每次按键都会对全文重新解析。对于几 KB 的文本没有问题,一旦打开几百 KB 的文档,连续打字会造成明显卡顿。更稳妥的做法是加一个去抖定时器,把连续触发合并为一次解析。
auto *deb