简介:一款基于Qt框架开发的多文档编辑器完整工程,仿照微软Word操作方式,面向Qt/C++学习者与桌面应用开发者,重点展示多文档界面(MDI)及富文本处理功能的代码实现。压缩包共80个文件,大小仅1.59MB,内含cpp/h源文件、.pro工程与Makefile,另配46个bmp、8个png等图标图片,以及html示例文档,结构清晰便于按模块阅读。已有2460人学习/下载。工程支持多文档同时编辑,窗口可平铺或层叠,并实现了新建、打开、保存、打印,撤销重做、复制剪切粘贴,以及字体粗体/斜体/下划线、字号、颜色设置和段落左对齐/居中/右对齐等Word常用功能。读者可直接编译运行,并可通过源码研究MDI子窗口管理、QTextEdit样式控制与资源文件组织方式。
1. Qt 版 Word 多文档编辑器:为什么值得用 C++ 落地
做过桌面工具的人八成都有这种经历:需求方要一个“能同时开好几个文档、能改字号加粗居中、还能另存成 PDF”的内部工具,Web 页面越写越重,浏览器下载保存窗口一顿弹,烦。用 Qt 写这套反而痛快。Qt版Word多文档编辑与处理(完整版)就是一套以 Qt C++ 为主线的实战资源,从 QMdiArea 多窗口骨架、QTextDocument 排版到查找替换、多格式存取导出,按一个能直接开工的项目去组织。它适合两类人:要接内部富文本办公工具、不想从零造轮子的 Qt 开发者;以及刚掌握 Qt 基础、想借一个完整项目理解窗口管理和编辑栈深浅的新手。下面按落地顺序把架构、编辑、存取和坑位拆开说。
2. 搭多文档框架:从 QMainWindow 到 QMdiArea 的窗口体系
多文档编辑器第一关不是怎么写文字,而是怎么把“多个文档”这个容器搭稳。很多半成品资源只做单窗口,一加第二个窗口就乱套,根子就在主窗口的中央部件选错了。
2.1 为什么用 QMdiArea:两个候选方案的取舍
常见做法是 QTabWidget 或 QMdiArea 二选一。QTabWidget 看起来更现代,页签在一排,切文档直观,但要在 tabBar 上做右键菜单、关闭提示、拖动排序得自己补一堆逻辑。QMdiArea 是 Qt 原生的多文档容器,每个子文档是一个 QMdiSubWindow,天然支持独立拖动、缩放、层叠、平铺,关闭事件也能在子窗口层面单独拦截。
| 能力 | QMdiArea | QTabWidget |
|---|---|---|
| 子窗口独立拖动/缩放 | 支持 | 不支持 |
| 每个文档独立处理关闭事件 | 子窗口级,好拦 | 要接管 tabBar 逻辑 |
| 子窗口自带菜单合并 | 支持 | 需要手写 |
| 代码量 | 多一层 subWindow 查找 | 少 |
从这份资源的命名就能看出,它是按“多文档编辑”来设计的,而不是“多页签”。在 Qt Creator 里新建工程时,MainWindow 的中央部件直接填 QMdiArea,后续所有子文档都以 addSubWindow 方式挂进去。这个选型不是炫技,而是文档编辑器需求单上动不动就有“多个文档同时编辑、各自保存”这类要求,QTabWidget 后面会越补越难受。
2.2 主窗口骨架:QAction 复用与菜单组织
主窗口的菜单、工具栏、快捷键在 Qt 里建议共用一套 QAction,不要在按钮里 setText、在菜单里再 new 一个动作。Qt Designer 的 action editor 可以把动作先建好,ui 文件里直接拖进菜单和工具栏,动作触发信号统一连到一个槽。这样做的好处是:快捷键、禁用状态、提示文本只维护一份。
// MainWindow.cpp —— 多文档框架初始化 MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { m_mdiArea = new QMdiArea(this); m_mdiArea->setViewMode(QMdiArea::SubWindowView); setCentralWidget(m_mdiArea); createActions(); createMenus(); setWindowTitle(QStringLiteral("Qt 多文档编辑器")); } void MainWindow::createActions() { QAction *newAct = new QAction(QStringLiteral("新建"), this); newAct->setShortcut(QKeySequence::New); newAct->setStatusTip(QStringLiteral("新建一个文档窗口")); connect(newAct, &QAction::triggered, this, &MainWindow::newDocument); QAction *openAct = new QAction(QStringLiteral("打开..."), this); openAct->setShortcut(QKeySequence::Open); connect(openAct, &QAction::triggered, this, &MainWindow::openDocuments); }这里的关键点是 QKeySequence::New 会自动映射到平台标准快捷键,Windows 上是 Ctrl+N,macOS 上是 Cmd+N。用字符串写死 "Ctrl+N" 虽然能跑,但跨平台表现不一致,我一般都用 QKeySequence 枚举。createMenus() 里把 action 塞进文件菜单和工具栏就好。
2.3 新建、打开与当前窗口定位
新建文档不是 new 一个 QTextEdit 就完事,要把编辑器和子窗口的删除策略、修改状态都定清楚,否则关窗口时内存泄漏、再次打开空文档这类问题会接踵而来。
// MainWindow.cpp —— 新建文档窗口 void MainWindow::newDocument() { QTextEdit *edit = new DocumentEditor(this); edit->setAttribute(Qt::WA_DeleteOnClose); QMdiSubWindow *sub = m_mdiArea->addSubWindow(edit); sub->setAttribute(Qt::WA_DeleteOnClose); sub->setWindowTitle(QStringLiteral("未命名_%1").arg(++m_untitledCounter)); sub->resize(800, 600); edit->show(); sub->show(); }DocumentEditor 是完整版资源里对 QTextEdit 的封装子类,预览、保存、关闭提示都放在里面。setAttribute(Qt::WA_DeleteOnClose) 让窗口关闭时自动 delete 对应对象,避免子窗口关掉后 QTextEdit 还留在内存里。我习惯编辑器和子窗口都设,这样无论从哪个路径关闭都不至于泄漏。
打开多个文件时,常见做法是一次性多选,循环建子窗口:
void MainWindow::openDocuments() { QStringList files = QFileDialog::getOpenFileNames( this, QStringLiteral("打开文档"), QString(), QStringLiteral("文本文件 (*.txt *.md);;HTML (*.html *.htm);;所有文件 (*)")); for (const QString &path : files) { QTextEdit *edit = new DocumentEditor(this); edit->setAttribute(Qt::WA_DeleteOnClose); if (DocumentEditor::loadFile(edit, path)) { QMdiSubWindow *sub = m_mdiArea->addSubWindow(edit); sub->setWindowTitle(QFileInfo(path).fileName()); sub->show(); } else { delete edit; } } }加载失败时要主动 delete edit,否则会留下一个空白子窗口,这是个很容易忽略的细节。接下来所有格式操作、查找替换,都先取当前活动子窗口里的编辑器:
QTextEdit *MainWindow::currentEditor() const { QMdiSubWindow *sub = m_mdiArea->currentSubWindow(); if (!sub) return nullptr; return qobject_cast<QTextEdit *>(sub->widget()); }currentSubWindow() 在没有活动子窗口时返回空指针,后续调用处必须先判空再操作。这个函数会成为工具栏、菜单、快捷键操作的统一入口,完整版资源里几乎每个格式动作都会先调它。
3. 编辑与排版核心:QTextDocument、光标操作与查找替换
窗口框架立住之后,真正决定“像不像 Word”的是编辑能力。QTextEdit 自带行编辑和基本的撤销重做,但字号、颜色、段落对齐、查找替换这些动作,都要落到 QTextDocument 和 QTextCursor 的细节上。
3.1 核心不是 QTextEdit,而是 QTextDocument
Qt 里编辑框只是外壳,真正的排版模型是 QTextDocument。QTextEdit 只是把 QTextDocument 渲染出来并转发键盘事件。理解这个分层后,很多困惑会瞬间解开:为什么在 QTextEdit 上直接 setFont 会让行距忽大忽小?因为是给 widget 设置字体,而不是给文档模型设置,打印时用的是 document 的字体。
| 对象 | 职责 | 对应编辑动作 |
|---|---|---|
| QTextBlock | 一个段落 | 回车、段落对齐 |
| QTextFragment | 连续同格式的文本片段 | 加粗、变色 |
| QTextCharFormat | 字符级格式 | 字体、字号、颜色 |
| QTextBlockFormat | 段落级格式 | 对齐、缩进、行距 |
我一般会在 DocumentEditor 构造时把文档的默认字体、页面边距先设好,这比在 QTextEdit 上 setFont 靠谱:
// DocumentEditor.cpp —— 文档级默认配置 QTextDocument *doc = document(); doc->setDefaultFont(QFont(QStringLiteral("Noto Serif CJK SC"), 12)); doc->setDocumentMargin(12.0);参数说明:setDefaultFont 是给整个文档一个基准字体,正文没有显式字体设置时会用它;setDocumentMargin 控制所有段落距页面边缘的距离,等同于 Word 里的页边距。中文环境建议选系统里确定存在的字体,否则刚打开文档时看到的是回退字体,打印出来又可能变样式。
3.2 字符格式:加粗、颜色、字号背后的 merge 与 set 差别
对选中文字做加粗、变色,正确姿势是用 QTextCursor 选中目标,再调用 mergeCharFormat。merge 和 set 的区别在于:setCharFormat 会清掉选区上原有格式,只保留你设置的这一项;mergeCharFormat 只覆盖你指定的属性,其他属性保留。对编辑器来说,几乎永远应该用 merge,否则用户设了红色之后还想加粗,set 一下颜色就把加粗抹掉了。
// DocumentEditor.cpp —— 加粗处理 void DocumentEditor::setBold(bool on) { QTextCursor cursor = textCursor(); if (!cursor.hasSelection()) cursor.select(QTextCursor::WordUnderCursor); QTextCharFormat fmt; fmt.setFontWeight(on ? QFont::Bold : QFont::Normal); cursor.mergeCharFormat(fmt); }注意三个细节。第一,没有选区时自动选中当前光标下的整个词,这是 Word 的常见行为,用户不需要先拖选才能加粗。第二,fmt 里只设置 font-weight 一项,其他格式不动。第三,merge 之后光标位置会回到原来的位置,但选区会保留,如果需要取消选区,可以再调一次 setTextCursor。颜色、字号、下划线同一套路,只改 QTextCharFormat 对应属性即可。
3.3 段落格式:对齐、行距与光标跳转
段落对齐操作的是 QTextBlockFormat。它不像字符格式要选中文,只要光标在某一段内,对齐动作作用于整个段落。这也是编辑器的直觉逻辑:光标停在段落的任何位置,点击居中,整段居中。
// DocumentEditor.cpp —— 段落对齐 void DocumentEditor::setAlignment(Qt::Alignment align) { QTextBlockFormat fmt = textCursor().blockFormat(); fmt.setAlignment(align); textCursor().mergeBlockFormat(fmt); }setAlignment 的入参可以是 Qt::AlignLeft、Qt::AlignHCenter、Qt::AlignRight 或 Qt::AlignJustify。blockFormat() 取的是当前段落已有的格式,如果你直接构造一个空 QTextBlockFormat 再 setAlignment,会丢失原有的缩进和行距,这里同样要 merge。段落的行距设置在完整版里通常用 setLineHeight 配合 QTextBlockFormat::LineHeightPercent 或 ProportionalHeight,比如固定倍数行距:
QTextBlockFormat fmt = textCursor().blockFormat(); fmt.setLineHeight(150, QTextBlockFormat::ProportionalHeight); textCursor().mergeBlockFormat(fmt);参数说明:150 表示 1.5 倍行距,第二参数还可以用 FixedHeight 或 MinimumHeight。这个设置在打印导出时是直接进 QTextDocument 布局的,所以 PDF 里看到的就是编辑器里看到的,不会出现“编辑器里好看,导出 PDF 行距塌了”的玄学问题。
3.4 查找替换与撤销栈
查找替换是文档编辑器使用频率极高的功能,也是新手最容易写死循环的地方。QTextDocument::find 函数能按字符串在文档里找匹配,并返回一个选中了匹配文本的 QTextCursor。全部替换的常见实现是循环查找、不断替换:
// DocumentEditor.cpp —— 全部替换 void DocumentEditor::replaceAll(const QString &from, const QString &to) { if (from.isEmpty()) return; QTextCursor cursor(document()); cursor.beginEditBlock(); // 合并为一次撤销动作 int count = 0; while (true) { QTextCursor found = document()->find(from, cursor); if (found.isNull()) break; found.insertText(to); cursor = found; // 继续从替换后的位置向后找 ++count; } cursor.endEditBlock(); }这段代码里 beginEditBlock 和 endEditBlock 之间做的所有修改,在撤销栈里只算一步。如果漏掉这个,用户按一次 Ctrl+Z 只能撤销替换掉的一个词,几百处替换就要按几百下撤销,体验很难受。cursor = found 保证了每次查找是从上一处替换结果的尾部继续,不会重复命中同一个位置;from 为空时直接返回,因为空字符串会让 find 返回奇怪的结果甚至死循环。单次查找定位用的也是 QTextDocument::find,找到后 setTextCursor(found),再配合 QTextEdit::ensureCursorVisible 让选中区域滚动到视野内。
4. 文档存取与导出:编码处理、HTML 落盘与 PDF 打印
编辑功能做完,真正让这套工具能落地的是存取。Qt 的 QTextDocument 原生支持的格式是富文本内部格式、txt 和 HTML,并不直接写 .docx。完整版资源里提到的“Word 多文档处理”,边界通常落在 txt、HTML、RTF 和 PDF 上。如果宣传里说能直接导出 .docx,那基本是额外接了库,不在 Qt 原生能力里,这点要先分清。
4.1 打开文件:先解决编码再谈内容
打开文本文件时,最常见的翻车点是中文乱码。原因很直接:字节流没有经过正确的编解码就进了 QString。Windows 上的旧文档很多是 GBK/GB2312,而 Qt 源码和 UI 字符串多数按 UTF-8 处理。我一般先读取全部字节,再做编码试探:
// DocumentEditor.cpp —— 读取文件并解码 bool DocumentEditor::loadFile(const QString &path) { QFile f(path); if (!f.open(QIODevice::ReadOnly)) return false; QByteArray raw = f.readAll(); f.close(); QString text; QTextCodec::ConverterState state; QString utf8str = QTextCodec::codecForName("UTF-8")->toUnicode(raw, &state); if (state.invalidChars == 0) { text = utf8str; } else { text = QTextCodec::codecForName("GBK")->toUnicode(raw); } if (text.trimmed().startsWith(QLatin1String("<!DOCTYPE")) || text.trimmed().startsWith(QLatin1String("<html"))) { setHtml(text); } else { setPlainText(text); } document()->setModified(false); return true; }这个启发式看起来很朴素,但效果在 Windows 办公文档上非常稳:先用严格模式把字节按 UTF-8 解码,只要出现非法字符就退回 GBK。ConverterState 默认状态下invalidChars会统计解码失败的数量,为 0 才认定是合法 UTF-8。这里为什么不直接 QTextCodec::codecForUtfText?因为无 BOM 的 GBK 文本会被它误判成 UTF-8,而按 UTF-8 合法性试探能避开多数误判。如果文件带 UTF-8 BOM,按上述流程 UTF-8 解码也必然是 0 个非法字符,行为一致。
4.2 保存文件:按扩展名决定 toHtml 还是 toPlainText
保存的核心是区分纯文本和富文本。 .txt/.md 用 toPlainText,.html/.htm 用 toHtml,不要反过来。尤其注意:toHtml 输出的是一整个完整 HTML 文档,包含 head、body 和样式表,不是文本文件。另存为时按扩展名自动切,能避免用户手动改错格式。
// DocumentEditor.cpp —— 保存到指定路径 bool DocumentEditor::saveToPath(const QString &path) { QFile f(path); if (!f.open(QIODevice::WriteOnly)) return false; QTextStream out(&f); out.setCodec("UTF-8"); // Qt 5.15 习惯写法 out.setGenerateByteOrderMark(true); // 写 BOM,Windows 记事本不乱码 if (path.endsWith(".html", Qt::CaseInsensitive) || path.endsWith(".htm", Qt::CaseInsensitive)) { out << document()->toHtml(); } else { out << document()->toPlainText(); } f.close(); document()->setModified(false); return true; }setGenerateByteOrderMark(true) 看起来多余,但在 Windows 下非常重要。不带 BOM 的 UTF-8 文件在旧版记事本里会被识别成 ANSI,中文全乱。注意 toHtml 保存的是当前文档的完整富文本,重新打开时会走 loadFile 里的 setHtml 分支,样式会原样回来,这是这份资源里“处理”二字的直接体现。RTF 支持在 Qt 里要额外装插件或引入第三方库,如果源文件清单里没有对应的 .so/.dll,就别在保存过滤器里加 RTF,免得运行时找不到组件报错。
4.3 导出 PDF:QPrinter 与打印分辨率
PDF 导出是完整版里最常被问的功能,实现上一点也不复杂,QTextDocument 直接支持打印到 QPrinter:
// DocumentEditor.cpp —— 导出 PDF void DocumentEditor::exportPdf(const QString &outPath) { QPrinter printer(QPrinter::HighResolution); printer.setPageSize(QPageSize(QPageSize::A4)); printer.setPageMargins(QMarginsF(20, 20, 20, 20), QPageLayout::Millimeter); printer.setOutputFormat(QPrinter::PdfFormat); printer.setOutputFileName(outPath); document()->print(&printer); }两个参数要知道。QPrinter::HighResolution 在 Qt 5.15 里对应 1200 DPI,不用它的话导出的 PDF 文字边缘发虚,打印放大后尤其明显。setPageMargins 的 20 就是页边距 20 毫米,和 Word 默认差不多。常见坑是导出的 PDF 里文字用的是回退字体,这通常是目标系统缺少中文字体,所以在嵌入式和精简 Linux 环境上,导出前先确认系统装了 Noto Sans CJK 或文泉驿,否则中文区块直接是方块。多文档框架下导出当前文档,currentEditor 判空后把 edit->exportPdf(path) 走一遍就行。
5. 常见问题排查:5 个容易让 Qt 编辑器翻车的现场
这套资源拆下来,真正劝退人的往往不是功能设计,而是运行环境相关的坑。下面的问题基本是复盘时一定会遇到的,每一条都按现象、原因、解决整理好了。
5.1 中文打开乱码、保存后又乱
- 现象:打开 GBK 文本是乱码,保存完再打开,原来正常的字符变成问号。
- 原因:打开时直接把 QByteArray 给了 QString 构造函数,没有经过解码;保存时统一写 UTF-8 但没写 BOM。
- 解决:打开时用 4.1 节的双编码试探;保存时 QTextStream 设置 UTF-8 并打开 setGenerateByteOrderMark(true)。保存和打开用同一套解码策略,程序自己读写不乱,换到记事本里也不乱。
5.2 fatal: cannot mix incompatible Qt library (version ex50601) with this library
- 现象:程序一启动就弹这个错,或者直接崩溃,错误信息会带 Qt 版本号。
- 原因:exe 链接的 Qt 库和运行时加载的 Qt 库版本不一致,最常见是 MinGW 编译的程序拿 MSVC 的 Qt 运行库去跑,或者用 Qt 5.15.2 的安装包混进了 5.9.4 的模块。
- 解决:装 Qt 时把 MinGW 和 MSVC 想清楚,界面工具链用 MinGW 就整个工程用 MinGW 编译,CMake 里 find_package 的路径也要指向同一套前缀。Qt 5.15.2 的在线安装器会把 32 位、64 位、msvc2017_64、mingw73_64 分开列,勾选时按自己编译器选,不要顺手全勾。发布时把对应平台的 bin、platforms、styles 一起拷走,不然开发机好好的,拷到别的机器立刻翻车。
5.3 启动报 could not find the Qt platform plugin "linuxfb"
- 现象:在 Linux 或嵌入式板子上运行,报
qt.qpa.plugin: could not find the Qt platform plugin "linuxfb" in ...,程序起不来。 - 原因:要么环境变量 QT_QPA_PLATFORM 被设成了 linuxfb,但部署目录里的 platforms 文件夹没有对应的 libqlinuxfb.so;要么交叉编译时没把 linuxfb 插件编进去。
- 解决:桌面 Linux 直接改回默认 xcb,删掉 QT_QPA_PLATFORM 或设置成 xcb;嵌入式场景则要确认插件真在路径下,QT_QPA_PLATFORM_PLUGIN_PATH 指向包含 platforms 的目录。这个坑在树莓派、ARM 麒麟这类环境上出现的频率很高,部署包里漏掉 platforms 文件夹是最常见原因。
5.4 MDI 子窗口关闭不提示保存
- 现象:编辑完文档,直接点子窗口右上角关闭,内容没保存就没了,没有任何确认。
- 原因:QTextEdit 没有重写 closeEvent,document 的 isModified 状态没人拦。
- 解决:DocumentEditor 里重写 closeEvent,判断修改状态再弹 QMessageBox。关键是 event->ignore() 要拦在关闭动作上,否则关窗直接走了:
// DocumentEditor.cpp —— 关闭前保存确认 void DocumentEditor::closeEvent(QCloseEvent *event) { if (!document()->isModified()) { event->accept(); return; } QMessageBox::StandardButton ret = QMessageBox::warning( window(), QStringLiteral("未保存"), QStringLiteral("当前文档已修改,是否保存?"), QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel); if (ret == QMessageBox::Save) { saveFile(); event->accept(); } else if (ret == QMessageBox::Discard) { event->accept(); } else { event->ignore(); } }这里弹窗父对象用 window() 而不是 this,否则子窗口关闭信号一出,消息框可能跟着被销毁。isModified 是 QTextDocument 自带的修改标记,只要初始 setModified(false),之后每次输入、撤销、格式修改都会自动置真,不用自己维护。
5.5 长时间编辑后内存暴涨、界面卡顿
- 现象:多个文档同时开着,大量粘贴、批量替换,内存越涨越高,滚动和输入都开始钝。
- 原因:每个 QTextDocument 的撤销栈是无限增长的,大段 insertText 和频繁格式修改会让 undo stack 堆积,文档打开后从未清过。
- 解决:接到撤销栈优化的一般手段有两个。一是打开大文件成功后主动执行一次
document()->clearUndoRedoStacks(),让保存前的老状态不再进撤销链;二是 3.4 节的 beginEditBlock/endEditBlock 合并批量操作,把几千次 replace 压成一步撤销。如果资源里自己封装了 DocumentEditor,我建议在保存文件后不要清栈,这样用户还能“保存后撤销到保存前”,但打开大文件时清一次是稳妥的。
6. 进阶:批量导出 PDF 与一套自查清单
把多文档编辑器做稳定之后,往上加价值的最快方式就是“批量”。与其一个一个文档点导出,不如直接遍历 MDI 里所有子窗口,一键全出。
6.1 全量导出 PDF 批处理脚本化
// MainWindow.cpp —— 批量导出全部可见文档为 PDF void MainWindow::exportAllPdf(const QString &dir) { QDir outDir(dir); if (!outDir.exists()) outDir.mkpath("."); const QList<QMdiSubWindow *> subs = m_mdiArea->subWindowList(); for (QMdiSubWindow *sub : subs) { DocumentEditor *edit = qobject_cast<DocumentEditor *>(sub->widget()); if (!edit) continue; QString base = QFileInfo(sub->windowTitle()).completeBaseName(); edit->exportPdf(outDir.filePath(base + QStringLiteral(".pdf"))); } }subWindowList() 默认按创建顺序返回所有子窗口,包括最小化的,所以这个动作不需要用户手动激活任何窗口。文件名用 completeBaseName() 去掉原扩展名再拼 .pdf,避免出现 report.txt.pdf 这种名字。
6.2 拿到资源后先自查的四个点
这几个点是我拆同类资源时必看的位置,基本决定一份源码能不能直接驱动起来:
| 检查点 | 位置 | 目的 |
|---|---|---|
| 默认字体与边距 | DocumentEditor 构造 | 确认打印与编辑字体一致 |
| QTextCodec 解码 | loadFile | 确认处理过 GBK/UTF-8 |
| beginEditBlock | replaceAll | 确认批量操作合并撤销 |
| QMdiSubWindow 关闭事件 | closeEvent | 确认未保存有提示 |
如果四个点都在,这套资源就算不完美,骨架也是健康的,往里填功能不会塌。我相信一份完整的 Qt 多文档编辑器资源,代码能跑只是底线,经得起多平台运行和长时间编辑才是真正值钱的地方。从那以后,我拿到任何一份 Qt 多文档项目,都会强制先看这四个位置,帮省出最多的排查时间,也希望这份拆解能帮到你。
本文还有配套的精品资源,点击获取