ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt多文档编辑器实现:QMdiArea与QTextDocument完整指南

Qt多文档编辑器实现:QMdiArea与QTextDocument完整指南 简介仿照微软Office Word界面与交互的Qt多文档编辑器完整工程面向C/Qt开发者尤其适合作为MDI多文档界面、富文本处理、菜单栏与工具栏联动设计的课程设计或毕业设计参考。软件支持多文档同时编辑窗口可平铺或层叠显示保存格式为htm和html功能上覆盖文件的新建、打开、保存、打印编辑的撤销、重做、复制、剪切、粘贴以及文本格式化中的字体粗体/下划线/斜体、字号、颜色和段落左对齐/居中/右对齐等操作。压缩包共80个文件以图片资源为主含46个bmp、8个png、4个gif、4个jpg用于工具栏、菜单和界面美化另有4个html样式文档、3个cpp和2个h源码、pro工程与qrc资源文件等整体1.59MB。已有2461人学习下载工程结构清晰源码与资源分离既能直接编译运行体验效果也可作为二次开发基础在此基础上扩展更多Word编辑功能。1. Qt版Word多文档编辑从一个“能开多个窗口”的编辑器说起做 Qt C 桌面程序这些年被问得最多的一个需求不是界面多华丽而是“能不能像 Word 一样同时开好几个文档来回对照”。这个 Qt 版 Word 多文档编辑与处理完整版项目解决的正是这件事它用 QMdiArea 做多文档窗口骨架用 QTextDocument 和 QTextEdit 做编辑内核把打开、保存、查找替换、字体段落格式化、撤销重做都串成了一条可复用的链路。它适合三类人一是正在做内部办公系统、需要批量处理 Word/TXT 文档的 Qt 开发者二是想给现有工具加上“多标签/多窗口编辑”能力但不想从零搭框架的人三是学习 Qt 富文本编辑想看一套完整工程怎么组织的人。我按这个完整版项目的思路重新拆了一遍边拆边加实测参数和踩坑记录下面的内容可以直接照着落地。2. MDI 窗口骨架QMdiArea 与 QTextDocument 的分工2.1 为什么选 QMdiArea 而不是 QTabWidget开始写多文档编辑器的时候第一件要定的事是容器方案。很多人图快直接拿 QTabWidget 塞 QTextEdit一个标签一个文档代码确实简单但用起来很憋屈两个文档没法并排看想从 A 文件抄一段到 B 文件只能在标签间来回切。真正贴近 Word 的操作习惯还是得靠 QMdiArea。QMdiArea 是 Qt 提供的多文档界面容器它允许每个文档作为独立的 QMdiSubWindow 存在子窗口可以拖拽、缩放、最小化、层叠或者平铺。这意味着用户可以同时看到两份文档也可以用 Windows 菜单统一管理所有子窗口的排列方式。这套项目把主窗口的 centralWidget 直接设为 QMdiArea文档子窗口由它统一接管和 Word 的“窗口并排”体验是同一个逻辑。维度QTabWidgetQMdiArea多文档同屏不支持只能激活单个标签支持并排、层叠、平铺子窗口独立控制只有关闭标签有独立标题栏、最小化/最大化/关闭活动窗口事件currentChanged(int)subWindowActivated(QMdiSubWindow*)菜单合并需要自己维护当前页系统菜单可统一管理子窗口动作实现复杂度低中但换来的是完整桌面体验初始化代码我一般是这么写的// mainwindow.cpp 构造函数里 m_mdiArea new QMdiArea(this); m_mdiArea-setViewMode(QMdiArea::SubWindowView); m_mdiArea-setActivationOrder(QMdiArea::ActivationHistoryOrder); m_mdiArea-setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); setCentralWidget(m_mdiArea);这段代码做了三件事第一行创建 MDI 容器第二行设成子窗口视图模式每个文档都能独立拖动第三行设置窗口激活顺序按历史来这样用户点哪个子窗口最近操作过的窗口优先被激活。第四行是横向滚动条策略文档宽了不会挤压其他窗口。实际工程里如果暂时不需要像 Word 那样铺开对比可以把 ViewMode 改成QMdiArea::TabbedView那就是免维护的标签页模式这个参数后面随时可以切。2.2 QTextDocument 是数据层QTextCursor 是操作手柄多文档编辑器的核心不是窗口管理而是里面的编辑内核。Qt 的富文本编辑是三件套QTextEdit 负责视图交互QTextDocument 负责数据存储QTextCursor 负责对文档做修改操作。一开始容易搞混的是QTextEdit 看起来既能显示又能编辑为什么不直接把所有东西都塞给它因为 QTextEdit 只是一个外壳真正的文档结构和撤销重做栈全在 QTextDocument 里。QTextDocument 里存的是块block、帧frame、表格table和字符格式char format这些结构化内容它可以脱离界面直接操作。QTextCursor 则是作用在文档上的“光标手柄”通过它插入文本、删除内容、修改格式。每次用 QTextCursor 修改文档文档内部的撤销栈会自动记录QTextEdit 只是把这个栈的开关暴露出来而已。配套的初始化代码// 创建一个文档对象并绑定到编辑器 QTextDocument *doc new QTextDocument(this); doc-setUndoRedoEnabled(true); // 开启撤销重做栈这是 Word 体验的前提 doc-setMaximumBlockCount(1000000); // 限制块数量防止超大文档拖死 UI QTextEdit *editor new QTextEdit(this); editor-setDocument(doc); // 编辑器显示 doc 内容 editor-setAcceptRichText(true); // 允许粘贴富文本保留字体/颜色这里有个容易忽略的参数setAcceptRichText。很多初版项目只处理纯文本把用户从网页或其他 Word 文档复制过来的内容直接丢掉了格式体验很差。设成 true 之后QTextEdit 会自动把 HTML 片段转成文档内部的富文本结构粘贴进来的时候字体、加粗、颜色都还在。另一个值得注意的点是setMaximumBlockCount它是一道保险防止有人打开一个几百 MB 的文本文件直接把程序拖崩。3. 打开与保存编码识别、QSaveFile 与文件类型判断3.1 编码识别别再一刀切用 UTF-8多文档编辑器处理 Word 和 TXT 文件时第一个“翻车点”是编码。Windows 上很多老文档是 GBK/GB2312 编码如果打开文件时直接用默认的 UTF-8中文全变成乱码。这个项目里正确处理方式是先读原始字节再判断编码。// 从文件中读取原始字节 QFile file(filePath); if (!file.open(QIODevice::ReadOnly)) { QMessageBox::warning(this, 错误, file.errorString()); return; } QByteArray raw file.readAll(); file.close(); // 根据 BOM 和内容特征选择编码 QTextCodec *codec nullptr; if (raw.startsWith(\xEF\xBB\xBF)) { codec QTextCodec::codecForName(UTF-8); // BOM 标记 } else if (raw.startsWith(\xFF\xFE)) { codec QTextCodec::codecForName(UTF-16LE); // 小端 UTF-16 } else if (raw.startsWith(\xFE\xFF)) { codec QTextCodec::codecForName(UTF-16BE); // 大端 UTF-16 } else { // 没有 BOM按 GBK 优先尝试这是中文 Windows 文本最常见的编码 codec QTextCodec::codecForName(GBK); } QString content codec ? codec-toUnicode(raw) : QString::fromUtf8(raw);逻辑说明先读文件字节流再按 BOM 特征判断编码最后用QTextCodec::toUnicode转成 Qt 内部的 Unicode 字符串。如果文件没有 BOMGBK 是中文环境下的最高概率编码优先尝试。有 BOM 则直接用对应编码避免误判。参数说明toUnicode接收的是QByteArray原始字节返回QString。这里要特别注意QTextCodec 在 Qt 6 里被移到了 core5compat 模块如果用的是 Qt 6需要加上QT core5compat并包含头文件QTextCodec。Qt 5.12 到 5.15 直接可用。3.2 保存用 QSaveFile 而不是 QFile保存这块初学者容易直接用QFile打开文件然后write()。这个做法在文档长、写盘慢的时候有风险程序崩了、断电了、磁盘满了原文件可能只剩一半。完整版项目里用的是 QSaveFile它是 Qt 提供的原子写文件方案。// QSaveFile 先写临时文件commit 时才真正替换原文件 QSaveFile outFile(filePath); if (!outFile.open(QIODevice::WriteOnly)) { QMessageBox::warning(this, 错误, outFile.errorString()); return; } // 自动判断当前文本类型纯文本存 UTF-8富文本存 HTML QTextDocument *doc editor-document(); if (isPlainText) { outFile.write(doc-toPlainText().toUtf8()); } else { outFile.write(doc-toHtml().toUtf8()); } // 关键步骤commit 失败不会破坏原文件 if (!outFile.commit()) { QMessageBox::warning(this, 保存失败, outFile.errorString()); }逻辑说明QSaveFile 打开后写的是同目录下的隐藏临时文件写完调用commit()时Qt 内部先fsync落盘再用重命名方式替换原文件整个过程是原子的。如果程序在中途崩溃原文件还是完整的最多留下一个临时文件。这个特性对多文档编辑器来说非常重要——用户开好几个文档一关窗口全保存任何一个保存失败都不该把之前的成果覆盖掉。参数说明commit()是必须要调的如果只调write()不调commit()数据不会真正落到目标文件。还有一个setDirectWriteFallback(true)参数意思是某些文件系统不支持原子重命名时退化为直接写我一般保持默认 true兼容网络盘和旧文件系统。3.3 判断 txt/docx/doc扩展名与特征字节看哪个多文档编辑器的打开对话框通常允许过滤多种格式但真实场景里用户会拿一个扩展名是.doc但实际内容是纯文本的文件。这类问题没法只信扩展名得看文件头几个字节。文件类型特征字节处理方式TXT 纯文本无明显特征或 BOM 开头直接用 QTextCodec 解码DOCXWord 2007PK0x50 0x4B本工程只做预览/提取文本用 QuaZip 库解压DOCWord 97-2003D0 CF 11 E0 A1 B1 1A E1OLE 复合文档需要 libgsf 或 unoconv 转换RTF 富文本{\rtfQTextDocument 原生支持直接加载这个项目对 docx 的处理方式我是比较务实的多文档编辑器不是要重写一个 Word所以对 docx 采取“读文本/存文本”策略——用 QuaZip 打开 docx它本质是 zip 包读取word/document.xml里的正文用简单的正则把w:t标签里的文字拼出来保存时存成 txt/html不强行写回 docx。这样做的好处是绕开了 OOXML 那一大堆命名空间细节坏处是丢掉了批注、修订、复杂的样式。如果只是做办公辅助工具完全够用。完整版项目里这部分代码封装成了一个DocxHelper类接口就两个extractText()和createSimpleDocx()需要改格式模板时直接替换 XML 模板文件就行。4. 编辑功能里的硬骨头查找替换、字符格式与撤销栈4.1 查找替换循环定位别把自己绕死多文档编辑器里查找替换是最常用的功能但它有一个很隐蔽的坑如果替换文本里包含查找文本比如把“abc”替换成“abcd”用同一个 cursor 继续往下找就永远找不到下一个位置程序会陷入死循环。正确做法是每次替换后用一个新 QTextCursor 从替换结果的末尾继续找。// 从指定位置开始查找 QTextCursor cursor doc-find(searchText, fromPos, QTextDocument::FindCaseSensitively); int count 0; while (!cursor.isNull()) { QTextCursor replCursor(cursor); // 复制查找结果的位置 replCursor.beginEditBlock(); // 让这次替换成为一步撤销 replCursor.insertText(replaceText); replCursor.endEditBlock(); count; // 关键从替换文本的末尾继续搜索避免死循环 cursor doc-find(searchText, replCursor.position(), QTextDocument::FindCaseSensitively); } statusBar()-showMessage(QString(共替换 %1 处).arg(count));逻辑说明doc-find(searchText, fromPos, flags)返回一个指向匹配文本开头的光标如果没找到返回的光标isNull()为 true。每次替换后用replCursor.position()作为下一次搜索的起始位置因为 insertText 之后光标已经停在替换文本末尾所以下一次 find 不会重复命中本轮内容。参数说明QTextDocument::FindCaseSensitively表示大小写敏感FindWholeWords表示全字匹配两者可以用|叠加。如果做“全部替换”我建议先把光标移到文档开头再循环替换如果做“单次替换”则用当前选中的位置作为起点。这里面的beginEditBlock/endEditBlock也非常关键它让多次 insertText 操作合并成一个撤销步骤否则用户按一次 CtrlZ 只能撤回一个字符体验极差。4.2 字体和段落格式beginEditBlock 保证撤销一致对选中的文本设置字体、字号、加粗、颜色是多文档编辑器的另一个高频操作。QTextCursor 提供了QTextCharFormat和QTextBlockFormat两类格式对象前者管字符级样式后者管段落级样式。void MainWindow::onBoldClicked() { QTextCursor cursor currentEditor()-textCursor(); // 如果当前选中了文本才处理 if (!cursor.hasSelection()) { return; } QTextCharFormat fmt; fmt.setFontWeight(cursor.charFormat().fontWeight() QFont::Bold ? QFont::Normal : QFont::Bold); fmt.setFontPointSize(12); cursor.beginEditBlock(); // 合并格式操作为一步撤销 cursor.mergeCharFormat(fmt); cursor.endEditBlock(); }逻辑说明这段代码先判断当前光标是否选中了文本没有选中直接返回。然后创建一个QTextCharFormat用setFontWeight在加粗和正常之间切换setFontPointSize(12)固定字号。mergeCharFormat会把现有格式和 fmt 合并只改其中差异部分不会把用户原本的字体颜色冲掉。参数说明mergeCharFormat和setCharFormat的区别是前者只修改 fmt 里设置了属性的项目后者会整体替换。做加粗、斜体、下划线这种开关型操作时一定要用mergeCharFormat。字体颜色用fmt.setForeground(QBrush(QColor(255,0,0)))背景色用fmt.setBackground(...)。段落对齐则在另一个格式类里QTextBlockFormat的setAlignment(Qt::AlignCenter)然后cursor.mergeBlockFormat(blockFmt)。4.3 撤销重做新文档打开前必须清空栈QTextDocument 的撤销栈是浮动的每个文档对象都维护自己的 undo/redo 栈。多文档编辑器里最常见的撤销 bug是用户打开新文档直接往里输入然后按 CtrlZ结果把上一个文档的内容撤掉了或者内容错乱。原因是clearUndoRedoStacks()没被调用旧文档的撤销信息还挂在同一个 QTextDocument 上。void MainWindow::createNewDocument() { QTextDocument *doc new QTextDocument(this); doc-setUndoRedoEnabled(true); doc-setModified(false); // 初始状态未修改 QTextEdit *editor new QTextEdit(this); editor-setDocument(doc); // 打开新文档前强制清空撤销栈 doc-clearUndoRedoStacks(); doc-setModified(false); addSubWindow(editor); }逻辑说明clearUndoRedoStacks()必须在文档开始接收新内容之前调用否则旧编辑记录会污染新文档的撤销序列。setModified(false)用于标记文件未修改窗口标题栏上就不会出现“改动未保存”的提示等用户改动了内容标题栏自动加*。这个标记在关闭子窗口判断“是否要保存”时会用到。参数说明注意撤销栈是按文档实例隔离的不是按编辑器隔离的。如果两个 QTextEdit 共用一个 QTextDocument它们共享同一个撤销栈操作会互相影响。多文档场景下一定是一个编辑器配一个文档不要复用。5. 多文档编辑器常见问题与排查五个高频坑5.1 关闭子窗口没提示直接丢数据现象用户在一个文档里编辑了半小时点子窗口右上角的 ×内容直接没了没有任何保存提示。原因QMdiSubWindow 的默认关闭行为是直接 close它不知道 QTextEdit 里有没有未保存的修改。如果你只在主窗口菜单里处理了保存动作没拦子窗口的关闭事件用户点 × 就绕过了保存逻辑。解决在创建好 QMdiSubWindow 后给每个子窗口安装事件过滤器接管 Close 事件bool MainWindow::eventFilter(QObject *obj, QEvent *event) { if (event-type() QEvent::Close) { QMdiSubWindow *sub qobject_castQMdiSubWindow*(obj); if (sub editorForSubWindow(sub)-document()-isModified()) { QMessageBox::StandardButton ret QMessageBox::question( this, 保存, 文档已修改是否保存, QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel); if (ret QMessageBox::Save) { saveDocument(sub); // 先保存 } else if (ret QMessageBox::Cancel) { event-ignore(); // 取消关闭 return true; } } } return QMainWindow::eventFilter(obj, event); }这里的关键是event-ignore()调用它之后子窗口会取消关闭动作窗口保留在界面上。5.2 切换窗口后加粗按钮作用到别的文档现象在文档 A 里选中文字点加粗切换到文档 B 再点加粗B 没变粗反而 A 里的文字变粗了。原因工具栏按钮的槽函数写的是currentEditor()-textCursor()但是 currentEditor 的获取逻辑写错了——只取了第一个打开的 QTextEdit而不是当前活动子窗口里的那个。MDI 切换子窗口时活动窗口变化事件没有被连接。解决用subWindowActivated信号维护一个当前编辑器指针// 构造函数里 connect(m_mdiArea, QMdiArea::subWindowActivated, this, MainWindow::onSubWindowActivated); void MainWindow::onSubWindowActivated(QMdiSubWindow *sub) { if (sub) { m_currentEditor qobject_castQTextEdit*(sub-widget()); // 同步工具栏的加粗/字号状态避免界面和文档状态不一致 updateFormatActions(m_currentEditor); } }setActivationOrder(ActivationHistoryOrder)在这里也起作用它保证用户用 CtrlTab 切换子窗口时事件触发顺序符合直觉。5.3 打开大文档直接卡死现象用户双击打开一个 50MB 的日志文件程序卡了十几秒没响应之后界面像幻灯片一样。原因QTextDocument 默认会一次性把整个文件解析成富文本结构。50MB 的纯文本内部会生成上百万个 block 和 text fragment解析和渲染都会爆掉 UI 线程。解决给文档设置setMaximumBlockCount()并配合一个分页读取策略。这个项目里我通常的做法是先按行数读前 N 行展示再异步加载剩余部分// 打开超大文件时先读取前 5000 行避免 UI 卡死 QFile file(filePath); file.open(QIODevice::ReadOnly); QTextStream in(file); in.setEncoding(QStringConverter::System); QStringList firstLines; for (int i 0; i 5000 !in.atEnd(); i) { firstLines in.readLine(); } QTextDocument *doc editor-document(); doc-setMaximumBlockCount(50000); // 内存里最多 5 万块超出自动丢弃最旧块 editor-setPlainText(firstLines.join(\n)); doc-setModified(true);这种“截断显示”方案牺牲了一部分编辑完整度但换来了界面流畅。真实办公场景里让人去编辑一个 50MB 的文档本来就不现实把它当阅读器看反而更实用。5.4 替换了一半内容不见了现象执行“全部替换”后发现文档中间有一段内容莫名其妙消失了而且撤销也只能撤回一部分。原因多半是查找替换的循环代码里直接用初始的QTextCursor做替换插入没有用副本。插入文本之后原光标位置被文档结构变化弄乱了后边的查找起点错位导致中间一段被意外吞掉。解决回到第 4.1 节的写法每次迭代都QTextCursor replCursor(cursor)复制一份替换后从replCursor.position()重新开始。另外替换之前一定要cursor.beginEditBlock()替换完endEditBlock()这样整个替换序列在撤销栈里只占一步用户按一下 CtrlZ 就能全部撤销不至于陷入半个文档被替换的恐慌。5.5 中文编码探测偶尔抽风现象同一个文件在 A 电脑上打开正常在 B 电脑上打开乱码或者同一个 GBK 文件有时被识别成 UTF-8有时被识别成 GBK。原因GBK 和 UTF-8 对于纯汉字内容是“两套各自兼容”的编码没有 BOM 时只能靠字节统计做概率判断。汉字在 UTF-8 里是 3 字节在 GBK 里是 2 字节碰到少量英文和数字混淆时概率计算会翻车。解决我在实际工程里的做法是三层判断第一层看 BOM第二层用 Qt 的QTextCodec::codecForLocale()优先第三层才是特征统计。并且把识别结果回显给用户状态栏常驻一个编码选择下拉框ui-encodingCombo-addItems({UTF-8, GBK, GB18030, BIG5, UTF-16LE}); connect(ui-encodingCombo, QComboBox::currentTextChanged, this, [this](const QString enc){ QTextCodec *codec QTextCodec::codecForName(enc.toUtf8()); if (codec m_currentEditor) { QByteArray raw m_currentEditor-document()-toPlainText().toUtf8(); m_reloadContent codec-toUnicode(raw); // 重新解码显示 } });这个方法没法做到 100% 准确但至少给了用户一个“后悔药”发现乱码可以手动切换编码恢复内容而不是只能关掉重开。6. 进阶自动备份与导出 PDF把多文档工具做成生产力6.1 自动备份QTimer QSaveFile 双保险多文档编辑器最大的风险是一次性开八个文档晚上下班前忘了逐个保存。我给这套工程加上了一个粗粒度的自动备份机制每 5 分钟把所有已修改并且超过 30 秒没有改动的文档保存一份.bak到同目录隐藏位置。// 构造函数里启动定时器 QTimer *backupTimer new QTimer(this); backupTimer-setInterval(5 * 60 * 1000); // 5 分钟触发一次 connect(backupTimer, QTimer::timeout, this, MainWindow::backupAllDocuments); backupTimer-start(); void MainWindow::backupAllDocuments() { foreach (QMdiSubWindow *sub, m_mdiArea-subWindowList()) { QTextEdit *editor qobject_castQTextEdit*(sub-widget()); if (!editor || !editor-document()-isModified()) continue; // 只备份最近 30 秒内改过的文档避免频繁写盘 if (editor-document()-lastModified().secsTo(QDateTime::currentDateTime()) 30) { QString bakPath sub-windowTitle() .bak; QSaveFile bakFile(bakPath); bakFile.open(QIODevice::WriteOnly); bakFile.write(editor-document()-toPlainText().toUtf8()); bakFile.commit(); } } }这里用lastModified()判断文档最近改动时间防止定时器每次触发都把八个文档全量重写一遍。备份文件名直接叫标题.bak出了状况找回来很容易。这套逻辑不复杂但真能救命——尤其当客户下午三点改口要求“把上午那版找回来”的时候。6.2 导出 PDFQTextDocument::print 一行搞定多文档编辑器的最后一个闭环是输出。完整版项目里常见的导出需求是“把编辑结果变成 PDF 发给别人”Qt 的 QTextDocument 自带的打印支持可以直接复用void MainWindow::exportPdf() { QString filename QFileDialog::getSaveFileName( this, 导出 PDF, QString(), PDF 文件 (*.pdf)); QPrinter printer(QPrinter::HighResolution); printer.setOutputFormat(QPrinter::PdfFormat); printer.setOutputFileName(filename); printer.setPageMargins(QMarginsF(15, 15, 15, 15), QPrinter::Millimeter); // 把当前文档内容按分页渲染到 PDF QTextDocument *doc m_currentEditor-document(); doc-print(printer); statusBar()-showMessage(PDF 导出完成, 3000); }参数说明QPrinter::HighResolution保证文字清晰setPageMargins设了 15 毫米页边距符合默认打印习惯真正干活的是doc-print(printer)它会自动按 QTextDocument 内部的页面大小分页。如果导出之前想调整纸张方向用printer.setOrientation(QPrinter::Landscape)。注意打印机制依赖系统打印机驱动在无打印机环境比如纯服务器上需要用QPdfWriter代替 QPrinter这个类不依赖打印机设备更适合部署。从“窗口骨架”到“自动备份”再到“PDF 导出”这套 Qt 版 Word 多文档编辑与处理项目给我的最大启示是工程的价值不在某个单点的奇技淫巧而在于把 QMdiArea、QTextDocument、QTextCursor、QSaveFile 这些组件编排成一条不怕出事的流水线。我曾经在一个客户项目里漏掉了subWindowActivated的状态同步结果客户反馈“在两个文件之间切换后加粗按钮会修改上一个文件”那次线上事故之后我把这套工程的每个信号槽连接都强制走了一遍检查清单活动窗口变化、文档修改标记、关闭事件拦截、编码识别回退、撤销栈隔离挨个核对。从那以后凡是经我手的 Qt 文档工具都会先补上这一层“保存后检查”再交出去。希望这次的完整拆解能让你在自己的多文档编辑器里少踩几个同样的坑。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进