建實(shí)時(shí)預(yù)覽的Markdown編輯器)
用 C 寫一個(gè)帶實(shí)時(shí)預(yù)覽的 Markdown 編輯器聽起來像是一個(gè)不小的工作量其實(shí)拆開看只有三件事左側(cè)的文本編輯區(qū)、右側(cè)的預(yù)覽區(qū)以及把 Markdown 內(nèi)容即時(shí)轉(zhuǎn)換為 HTML 的解析層。這篇文章用 Qt 和 cmark 庫從零搭出一個(gè)最小可運(yùn)行版本并逐步把語法高亮、本地圖片加載、常見環(huán)境問題排查等內(nèi)容補(bǔ)上。如果你已經(jīng)能寫完一個(gè)簡單的 Qt Widgets 窗口程序但對 Markdown 解析和實(shí)時(shí)刷新還沒有完整思路這篇文章會(huì)給你一條清晰的落地路徑。實(shí)時(shí)預(yù)覽不是指用戶點(diǎn)一下按鈕才渲染而是編輯區(qū)內(nèi)容發(fā)生變化后預(yù)覽區(qū)自動(dòng)更新。要做到這一點(diǎn)依賴一個(gè)穩(wěn)定的信號(hào)循環(huán)和解析庫。讀完這篇內(nèi)容后你可以得到一個(gè)帶實(shí)時(shí)預(yù)覽的桌面 Markdown 編輯器骨架如果要繼續(xù)擴(kuò)展成筆記工具、技術(shù)寫作工具或內(nèi)部文檔客戶端也可以在此基礎(chǔ)上直接加文件管理、主題切換、導(dǎo)入導(dǎo)出等功能。1. 實(shí)時(shí)預(yù)覽編輯器要拆成哪幾個(gè)模塊1.1 輸入、解析、渲染三層Markdown 編輯器的核心并不神秘。用戶在一個(gè)文本控件里輸入 Markdown 原文程序把原文交給解析器解析器生成 HTML再把 HTML 交給預(yù)覽控件渲染。整個(gè)流程就是“輸入 - 解析 - 渲染”三個(gè)環(huán)節(jié)。輸入層QPlainTextEdit負(fù)責(zé)接收鍵盤輸入、支持基本的文本選擇、撤銷重做。解析層cmark把 Markdown 文本轉(zhuǎn)換成 HTML 字符串。渲染層QTextBrowser把 HTML 字符串展示為富文本外部鏈接可以交給系統(tǒng)瀏覽器處理。把這三個(gè)環(huán)節(jié)拆開之后實(shí)時(shí)預(yù)覽就只是一個(gè)信號(hào)連接問題當(dāng)編輯區(qū)的文本發(fā)生變化時(shí)重新執(zhí)行一次“解析 - 渲染”即可。這段鏈路里最容易被低估的是解析層。很多人會(huì)先寫一個(gè) replace 函數(shù)把#替換成h1把**替換成strong。這種方式處理簡單的單行語法沒問題一旦遇到嵌套列表、代碼塊、轉(zhuǎn)義字符和鏈接就會(huì)漏洞百出。所以直接使用成熟的 Markdown 解析庫是更值得投入的選擇。1.2 為什么選 Qt cmark不自己寫解析器Markdown 語法看起來簡單真正解析起來非常容易出錯(cuò)。列表嵌套、段落換行、行內(nèi)代碼、轉(zhuǎn)義字符、引用塊、任務(wù)列表、表格每一類都有邊界情況。自己寫一個(gè)支持常用語法的解析器至少要幾百行而且處理不完整會(huì)造成預(yù)覽和源碼不一致。直接使用開源解析庫是更穩(wěn)妥的方案。cmark 是 CommonMark 官方參考實(shí)現(xiàn)用 C 語言編寫提供了非常簡潔的 C API。它沒有額外依賴編譯出來只有一個(gè)靜態(tài)庫或動(dòng)態(tài)庫很適合嵌入到 C 工程里。Qt 則負(fù)責(zé) UI、事件循環(huán)、富文本顯示和跨平臺(tái)編譯。這兩個(gè)庫搭配可以比較輕松地完成一個(gè)可用的桌面編輯器。在選型時(shí)還要考慮渲染層。最簡單的是QTextBrowser它基于QTextDocument支持 HTML 子集不需要額外安裝瀏覽器內(nèi)核。若需要完整支持 CSS、JavaScript、代碼高亮可以使用QWebEngineView但代價(jià)是運(yùn)行時(shí)依賴 WebEngine。對于大多數(shù)筆記場景QTextBrowser已經(jīng)足夠。注意預(yù)覽控件的選型會(huì)影響發(fā)布包體積和啟動(dòng)速度。QTextBrowser 輕量QWebEngineView 渲染能力強(qiáng)兩者不是替代關(guān)系而是不同場景的取舍。1.3 最小閉環(huán)目標(biāo)和學(xué)習(xí)環(huán)境差異這篇文章的最小目標(biāo)很明確打開程序后左側(cè)輸入 Markdown右側(cè)實(shí)時(shí)刷新 HTML 渲染結(jié)果。先把這條鏈路跑通再談高亮、文件保存、主題切換等功能。在學(xué)習(xí)環(huán)境里建議直接使用 Qt Creator 或 CMake 命令行工程不要一開始就加入復(fù)雜的插件系統(tǒng)。生產(chǎn)環(huán)境則需要考慮打包、日志、配置外置、崩潰收集等問題。如果你是在公司項(xiàng)目里落地還需要把 Markdown 解析結(jié)果做緩存、控制在每次按鍵時(shí)是否全量解析以及兼容不同電腦上的運(yùn)行時(shí)版本。2. 環(huán)境準(zhǔn)備與依賴配置2.1 編譯工具鏈和 Qt 版本選擇文章示例使用 Qt Widgets 模塊不依賴 QML。推薦 Qt 5.15 或 Qt 6.5 以上二者 API 在本文用到的范圍內(nèi)基本一致。如果你在 Windows 上使用 MSVC需要同時(shí)安裝 Visual C Redistributable在 Linux 上使用 GCC則需要安裝 base-devel 或 build-essential。很多 C 項(xiàng)目啟動(dòng)時(shí)報(bào)缺少VCRUNTIME140.dll本質(zhì)上就是 Visual C Redistributable 版本沒有裝全。如果你是第一次創(chuàng)建工程建議用 Qt Creator 自帶的 “Qt Widgets Application” 模板再手動(dòng)加入 cmark 依賴。本文的代碼結(jié)構(gòu)如下MarkdownEditor/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── MainWindow.h │ ├── MainWindow.cpp │ ├── MarkdownHighlighter.h │ └── MarkdownHighlighter.cpp這個(gè)結(jié)構(gòu)足夠支撐一個(gè)帶預(yù)覽、高亮和文件處理的小型桌面程序。如果你后續(xù)要加入測試或插件可以在src旁邊繼續(xù)增加tests、plugins目錄。2.2 安裝 cmark 的三種方式cmark 的安裝方式取決于操作系統(tǒng)和包管理器。在 Ubuntu / Debian 上sudo apt update sudo apt install libcmark-dev在 Windows 上使用 vcpkgvcpkg install cmark如果你不想使用系統(tǒng)包管理器也可以直接源碼編譯git clone https://github.com/commonmark/cmark.git cd cmark mkdir build cd build cmake .. cmake --build . --config Release sudo cmake --install .源碼編譯方式是通用兜底方案。需要注意的是不同發(fā)行版提供的 cmark 版本可能不同CMake config 文件的 target 名稱也可能不同。落地上機(jī)前先確認(rèn)安裝版本和 CMake 能找到的 target 名稱。2.3 CMake 工程里同時(shí)連接 Qt 和 cmark在 CMakeLists.txt 中先查找 Qt再查找 cmark。下面是一個(gè)兼容 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} )關(guān)鍵點(diǎn)有兩處。一是CMAKE_AUTOMOC必須開啟因?yàn)镸ainWindow和MarkdownHighlighter都是 QObject 子類需要處理信號(hào)槽。二是LANGUAGES CXX C要同時(shí)包含 C因?yàn)?cmark 本身是 C 項(xiàng)目雖然最終通過庫連接但某些構(gòu)建方式會(huì)需要 C 語言編譯規(guī)則。如果find_package找不到 cmark通常是因?yàn)闆]有安裝開發(fā)包或沒有設(shè)置CMAKE_PREFIX_PATH。不要直接手寫target_link_libraries指向一個(gè)假設(shè)的路徑優(yōu)先使用 CMake 的 package 機(jī)制這樣換機(jī)器后更容易復(fù)現(xiàn)。3. 實(shí)現(xiàn)一個(gè)最小可運(yùn)行版本3.1 創(chuàng)建主窗口與左右分欄布局主窗口使用QSplitter把編輯區(qū)和預(yù)覽區(qū)左右排布。QSplitter不僅提供分隔條還允許用戶拖動(dòng)調(diào)整左右寬度這對 Markdown 編輯器是基本體驗(yàn)要求。#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_; };構(gòu)造函數(shù)的實(shí)現(xiàn)如下#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()尚未實(shí)現(xiàn)之前程序可以編譯運(yùn)行但右側(cè)不會(huì)更新。先把界面搭出來是為了驗(yàn)證布局是否正常再繼續(xù)加入解析邏輯。3.2 用 cmark 把 Markdown 轉(zhuǎn)成 HTML這是整篇文章最核心的一段代碼。先寫一個(gè)工具函數(shù)輸入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*和字節(jié)長度所以必須先把 QString 轉(zhuǎn)成 UTF-8 字節(jié)數(shù)組。返回的char*是 cmark 內(nèi)部通過 malloc 分配的內(nèi)存用完后必須調(diào)用free()否則每次預(yù)覽都會(huì)泄漏一塊內(nèi)存。CMARK_OPT_DEFAULT表示使用 CommonMark 默認(rèn)行為。常用選項(xiàng)如下選項(xiàng)作用使用建議CMARK_OPT_DEFAULT默認(rèn)解析行為不輸出源碼位置保留 HTML 塊中的潛在危險(xiǎn)標(biāo)簽基礎(chǔ)場景使用CMARK_OPT_SOURCEPOS在輸出 HTML 中加入 sourcepos 屬性調(diào)試解析問題CMARK_OPT_HARDBREAKS將普通換行渲染為br需要保留換行時(shí)使用CMARK_OPT_UNSAFE允許渲染原始 HTML 和危險(xiǎn)鏈接僅信任輸入時(shí)使用CMARK_OPT_SMART將直引號(hào)、省略號(hào)等轉(zhuǎn)為彎引號(hào)文檔排版美化場景默認(rèn)情況下script等原始 HTML 不會(huì)被渲染成可執(zhí)行內(nèi)容這是安全設(shè)計(jì)不是 bug。若編輯器用于公開內(nèi)容不要隨便開啟CMARK_OPT_UNSAFE。3.3 用 textChanged 信號(hào)驅(qū)動(dòng)實(shí)時(shí)預(yù)覽QPlainTextEdit::textChanged在每次文本內(nèi)容變化時(shí)觸發(fā)。在這里連接renderMarkdown就能實(shí)現(xiàn)“敲一個(gè)字預(yù)覽區(qū)刷新”的效果。void MainWindow::renderMarkdown() { QString html markdownToHtml(editor_-toPlainText()); preview_-setHtml(html); }這段代碼簡單直接但每次按鍵都會(huì)對全文重新解析。對于幾 KB 的文本沒有問題一旦打開幾百 KB 的文檔連續(xù)打字會(huì)造成明顯卡頓。更穩(wěn)妥的做法是加一個(gè)去抖定時(shí)器把連續(xù)觸發(fā)合并為一次解析。auto *deb