Qt 打造多文档 Word 编辑器:QTextDocument 格式处理与 QTabWidget 页签管理实战
发布时间:2026/10/7 13:53:02 锦皓数字建站

简介这是一份基于Qt开发的完整版Word多文档编辑器源码面向C/Qt初中级开发者以及需要仿制Office办公软件界面的学习者。项目参照微软Word交互模式支持多文档同时编辑与平铺/层叠视图文件可保存为htm/html格式菜单与工具栏覆盖新建、打开、保存、打印、撤销、重做、复制、剪切、粘贴等基础操作同时提供字体粗体/下划线/斜体、字号、颜色和段落对齐方式等格式化能力功能结构贴近常规文字处理软件。压缩包共80个文件大小约1.59MB以cpp、h、pro、qrc等工程源码与配置为主辅以大量bmp、png、gif、jpg图标素材和html示例文档便于直接加载工程并查看效果。已有2461人学习下载。借助该资源可系统梳理Qt多文档界面QMdiArea的设计方法、富文本编辑与打印输出流程也可作为课程设计或自定义编辑器开发的参考起点。1. 为什么用 Qt 重写一个多文档 Word 编辑器从 QMdiArea 到 QTabWidget 的选型思考做过桌面工具的人都知道Word 最值钱的地方不在打开时的那张白纸而在于它把“编辑”拆成了文档、视图、格式、撤销栈这几个相互咬合的齿轮。用 Qt 写一个多文档 Word 编辑器等于拿现有的 QTextEdit 去重组一套这样的齿轮既要管多个文档的页签切换又要让每一次加粗、换字号、存盘都落在同一个文档对象上。下面从一个可运行的完整框架出发讲清页签怎么管理、格式怎么持久化、打印与查找怎么顺着 QTextDocument 的接口落地。适合已经把基础 Qt 工程跑通、想在编辑器方向深入一层的开发者。2. 搭出多文档框架QTabWidget 页签管理、关闭策略与拖拽排序2.1 页签承载文档的两种姿势QMdiArea 与 QTabWidget 的取舍多文档界面的经典方案是 QMdiArea——每个文档对应一个内部窗口可以自由排布、最小化、还原。但现代 Word 的工作流是单窗口多页签页签还能拖拽排序。QMdiArea 默认没有页签条你要自己写 QMdiSubWindow 与 QTabBar 的联动关闭、激活、标题同步都要手动管。QTabWidget 天生就是页签容器封装了 addTab、removeTab、currentChanged 这套完整信号链省掉的代码不是一星半点。我一般直接选 QTabWidget再配合几个关键属性setDocumentMode(true) 在 Windows 上让页签更像浏览器那种扁平细条setTabsClosable(true) 开启页签上的关闭按钮setMovable(true) 允许拖拽排序。MdiArea 的迁回界面也不是不行但如果你面向的是“打开一堆资料边看边写边对比”的办公场景页签的切换效率远高于子窗口的堆叠。后面所有代码都以 QTabWidget 为容器展开。2.2 用一个结构体管理文档列表新建、打开、关闭的代码骨架先把“一个文档页签”抽象成一个结构体。不要把 QTextEdit 和 QTextDocument 混在一个变量里它们是两层QTextDocument 管数据和撤销栈QTextEdit 管显示交互。把两者分开存保存格式、统计字数、自动存档时直接操作 doc刷新页签标题时监听 doc 的修改信号。// EditorPage.h 里定义一个简单结构 struct EditorPage { QTextEdit* edit nullptr; // 编辑视图 QTextDocument* doc nullptr; // 文档数据层 QString filePath; // 当前磁盘路径 }; // MainWindow 里维护页签列表 QVectorEditorPage* m_pages;新建页签的函数要包含初始化动作开撤销栈、允许富文本粘贴、设置默认中文字体。EditorPage* addNewPage(const QString title 未命名) { EditorPage* page new EditorPage; page-edit new QTextEdit(this); page-edit-setUndoRedoEnabled(true); // 开启 Qt 内置撤销栈 page-edit-setAcceptRichText(true); // 粘贴时保留原格式 page-edit-setFont(QFont(Microsoft YaHei, 11)); page-doc page-edit-document(); // doc 由 QTextEdit 内部持有 page-doc-setModified(false); page-filePath.clear(); int idx ui-tabWidget-addTab(page-edit, title); ui-tabWidget-setCurrentIndex(idx); m_pages.append(page); // 文档修改状态变化时页签标题前加星号 connect(page-doc, QTextDocument::modificationChanged, this, [this, page, idx](bool modified) { ui-tabWidget-setTabText(idx, (modified ? * : ) page-doc-metaInformation(QTextDocument::DocumentTitle)); }); return page; }这里有几个参数值得说清。setUndoRedoEnabled(true) 是默认值但显式写出来提醒你自己别去动它。setAcceptRichText(true) 决定从网页或另一个文档复制过来的内容是否保留颜色和字号桌面编辑器这个一定要开。addTab 的 idx 在页签关闭后可能失效所以 lambda 里捕获 idx 时要小心后面 5.1 会带你处理这个坑。打开文件的动作更直接读文件内容塞进 edit再把路径记到 page 上。注意大文件不能这么一把梭5.2 会单独说。EditorPage* openFileToPage(const QString filePath) { EditorPage* page addNewPage(); QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { ui-tabWidget-removeTab(ui-tabWidget-indexOf(page-edit)); delete page-edit; return nullptr; } page-edit-setPlainText(QString::fromUtf8(file.readAll())); page-filePath filePath; QFileInfo info(filePath); page-doc-setMetaInformation(QTextDocument::DocumentTitle, info.fileName()); ui-tabWidget-setTabText(ui-tabWidget-indexOf(page-edit), info.fileName()); page-doc-setModified(false); return page; }setMetaInformation 把文件名写进文档的属性里这一步看似多余实际是页签标题和保存对话框标题的数据来源后面改标题、自动保存都用它取。2.3 页签拖拽排序与右键菜单文档列表的人机交互细节QTabWidget 的拖拽排序是一行代码的事但交互上还差两步右键菜单关闭指定页签、关闭时判断是否保存。这两步是桌面编辑器的及格线。// 初始化部分 ui-tabWidget-setMovable(true); ui-tabWidget-setTabsClosable(true); ui-tabWidget-setContextMenuPolicy(Qt::CustomContextMenu); connect(ui-tabWidget, QTabWidget::tabCloseRequested, this, MainWindow::closeTabByIndex); connect(ui-tabWidget, QTabWidget::customContextMenuRequested, this, [this](const QPoint pos) { int idx ui-tabWidget-tabBar()-tabAt(pos); QMenu menu(this); menu.addAction(关闭当前, [this, idx]() { closeTabByIndex(idx); }); menu.addAction(关闭其他, [this, idx]() { for (int i m_pages.size() - 1; i 0; --i) { if (i ! idx) closeTabByIndex(i); } }); menu.exec(ui-tabWidget-mapToGlobal(pos)); });参数说明tabAt 返回的是点击位置的页签序号不是控件坐标别拿 QPoint 去和 currentIndex 对比exec 是模态弹出菜单关闭前用户做不了别的操作。关闭逻辑统一收敛到 closeTabByIndex 里让“关闭按钮”和“右键菜单”走同一条路径少维护一套分支。3. 让文本编辑器“见字如面”字体、段落与格式持久化3.1 为什么说 QTextEdit 比 QPlainTextEdit 更接近 Word 的底层模型QPlainTextEdit 只处理纯文本它的内部结构是行号的数组性能好但格式能力为零。QTextEdit 基于 QTextDocument后者是一棵块格式与字符格式组成的树段落有 QTextBlockFormat决定缩进、行距、对齐段落内部每个片段有 QTextCharFormat决定字体、字号、粗斜体、颜色。Word 的核心操作全是格式操作所以这里必须用 QTextEdit。你这次写的是多文档 Word 编辑器不是记事本选 QPlainTextEdit 后面做格式工具栏时一定会返工。另外要记住 QTextCursor 是这份格式树的操作手柄所有加粗、改字号、插表格都要通过它落到 QTextDocument 上直接操作 QTextEdit 的 text() 是拿不到格式的。3.2 把格式写进文档流setHtml 与 setText 的边界QTextDocument 能完整导出 HTML这是 Qt 给我们的最直接的“Word 兼容”路径。setPlainText 会丢掉全部格式setHtml 则把 font、p、div 解析回块格式和字符格式。实际项目里我建议内部统一按 HTML 保存预览、导出、转换都有兜底。// 保存当前页签为 HTML bool saveCurrentAsHtml(EditorPage* page) { if (!page) return false; QString fileName page-filePath; if (fileName.isEmpty() || !fileName.endsWith(.html, Qt::CaseInsensitive)) { fileName QFileDialog::getSaveFileName(this, 保存文档, QString(), HTML 文档 (*.html);;纯文本 (*.txt)); if (fileName.isEmpty()) return false; } QTextDocumentWriter writer(fileName); bool ok writer.write(page-doc); if (ok) page-doc-setModified(false); return ok; }QTextDocumentWriter 是 Qt Widgets 模块自带的类支持 HTML 和 ODF。它内部会处理字符编码你不用手动转 utf8。很多新人在这里手写 QFile 然后 toHtml().toUtf8() 再写入遇到中文路径和特殊字符就会翻车writer 替你处理了这些边界。参数说明filter 里第一项是默认保存格式用户直接回车就是 HTML纯文本放在后面避免误选。3.3 工具栏与格式按钮联动用 QTextCharFormat 做局部加粗与字号工具栏最核心的动作是“改了格式还要保留光标后的格式”。正确姿势是 mergeCurrentCharFormat它会把你设置的属性合并进当前选区而不是整行替换。void toggleBold() { QTextEdit* edit currentEdit(); if (!edit) return; QTextCharFormat fmt; bool isBold edit-currentCharFormat().fontWeight() QFont::Bold; fmt.setFontWeight(isBold ? QFont::Normal : QFont::DemiBold); edit-mergeCurrentCharFormat(fmt); } void applyFontSize(int pt) { QTextEdit* edit currentEdit(); if (!edit) return; QTextCharFormat fmt; fmt.setFontPointSize(static_castqreal(pt)); edit-mergeCurrentCharFormat(fmt); }两个函数的关键都在 currentCharFormat 和 mergeCurrentCharFormat 的配合。先用 currentCharFormat 读当前光标状态决定加粗切换方向再用 merge 写回这样光标后面的输入会延续新格式而不是跳回默认体。fontWeight 的阈值判断我习惯用 QFont::Bold因为有些字体加粗值会带变体只用 QFont::Bold 判断容易漏。字号的单位用 pt磅这是 Word 里“五号、小四”对应的物理单位你如果拿像素值往里填Word 里打开会缩得很小。字号下拉框的数据源建议直接写 8、9、10、11、12、14、16、18、20、24、36。4. 文档的落盘与再加工保存格式组合、打印导出与查找替换4.1 保存对话框的格式三角rtf/html/txt 该给谁留窗口Qt 原生没有 docx 写入能力这一点要先把期待拉平。QTextDocument 能无损处理的是 HTMLWord 能直接打开 HTML所以“保存成 Word 能看的文件”这条路是通的。RTF 呢QTextDocument 不支持直接导出 RTF你拿 toHtml 硬转出来的 RTF 只有文本和简单样式图片、形状、复杂表格进 RTF 基本是残缺的。// 统一保存入口按扩展名分发 bool saveDocument(EditorPage* page) { if (!page) return false; QString fileName page-filePath; if (fileName.isEmpty()) { fileName QFileDialog::getSaveFileName(this, 保存文档, QString(), HTML 文档 (*.html);;纯文本 (*.txt);;ODF 文档 (*.odt)); if (fileName.isEmpty()) return false; } if (fileName.endsWith(.odt, Qt::CaseInsensitive)) { QTextDocumentWriter writer(fileName); return writer.write(page-doc); } QFile file(fileName); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) return false; file.write(page-doc-toHtml().toUtf8()); file.close(); page-doc-setModified(false); return true; }这段代码真实可跑但你要注意到这里的两个分支ODT 走 QTextDocumentWriterHTML 走 toHtml 手写。如果你的导出目标里有 docx 或正规的 RTF我的做法是把内部格式定为 HTML到导出一层再用格式转换脚本处理Qt 这边用 QProcess 调外部转换器不动编辑器主流程。标题里的“完整版”指的就是这个逻辑闭环编辑、保存、导出各司其职不把不存在的支持硬写成黑匣子。4.2 打印与导出 PDFQTextDocument 的 print 接口与分页参数打印是 QTextEdit 自带的活它内部会拿 QTextDocument 去做分页你要做的只是把 QPrinter 配置好。导出 PDF 和打印走的是同一套代码唯一的差别是 QPrinter 的输出目标。void printDocument(EditorPage* page) { if (!page) return; QPrinter printer(QPrinter::HighResolution); QPrintDialog dlg(printer, this); if (dlg.exec() ! QDialog::Accepted) return; page-edit-print(printer); } void exportPdf(EditorPage* page) { if (!page) return; QString fileName QFileDialog::getSaveFileName(this, 导出 PDF, QString(), PDF 文件 (*.pdf)); if (fileName.isEmpty()) return; QPrinter printer(QPrinter::HighResolution); printer.setOutputFormat(QPrinter::PdfFormat); printer.setOutputFileName(fileName); printer.setPageMargins(18, 18, 18, 18, QPrinter::Millimeter); page-edit-print(printer); }参数说明HighResolution 是 1200dpi 的输出分辨率文字边缘更干净代价是打印慢一点setPageMargins 的四参单位是毫米和 Word 的页边距概念一致。分页边界由 QTextDocument 的 pageSize 决定默认取打印机可用宽高如果你自己在代码里 setPageSize记得要匹配打印机的实际纸张否则会出现最后一页空白的诡异现象。4.3 查找替换的“选中态”处理高亮与光标移动的具体实现查找的核心是 QTextDocument::find它以当前光标为锚点向后搜索返回一个定位到匹配项的 QTextCursor。替换则是在查到目标后用光标替换选中文本。void findNextText(const QString needle) { QTextEdit* edit currentEdit(); if (!edit || needle.isEmpty()) return; QTextDocument::FindFlags flags QTextDocument::FindCaseSensitively; QTextCursor found edit-document()-find(needle, edit-textCursor(), flags); if (!found.isNull()) { edit-setTextCursor(found); edit-ensureCursorVisible(); } } void replaceSelected(const QString needle, const QString replacement) { QTextEdit* edit currentEdit(); if (!edit) return; QTextCursor cursor edit-textCursor(); if (cursor.hasSelection() cursor.selectedText() needle) { cursor.insertText(replacement); edit-setTextCursor(cursor); } }参数说明find 第三个参数如果不传默认忽略大小写中文场景下大小写无所谓但包含英文关键词时建议强制打开 FindCaseSensitively否则用户以为“Qt”和“qt”是一回事。ensureCursorVisible 是滚动到匹配行很多编辑器查完没有这一句定位到了用户却看不见这个坑至少值一次产品吐槽。替换最好只在当前选区上做不要一上来就 replaceAll否则误替换了不该动的词撤销起来才叫痛苦。5. 【避坑】Qt 多文档编辑器高频问题5 个典型案例与排查思路5.1 关闭页签时程序崩溃现象点击页签上的关闭按钮程序直接退出崩溃栈指向 ~QTextEdit 或 QTabWidget::removeTab。原因两种常见写法引雷。一是关闭时直接把 page-edit 下的 document() 删掉而 QTextEdit 还持有这个指针二是从 m_pages 移除指针后忘了从 tabWidget 移除对应 widget后面 currentChanged 触发的 lambda 拿到的是悬垂索引。解决统一关闭顺序先移除列表项再断开信号最后用 deleteLater 异步释放。void closeTabByIndex(int idx) { if (idx 0 || idx m_pages.size()) return; EditorPage* page m_pages.at(idx); if (page-doc-isModified()) { QMessageBox::StandardButton btn QMessageBox::warning(this, 未保存, 文档尚未保存确定关闭, QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel); if (btn QMessageBox::Cancel) return; if (btn QMessageBox::Save) saveDocument(page); } m_pages.removeAt(idx); page-edit-disconnect(); // 断开本页所有信号槽 ui-tabWidget-removeTab(idx); page-edit-deleteLater(); // 事件循环里再释放 }deleteLater 比 delete 安全尤其在信号槽触发链里直接 delete 对象会造成悬垂访问。removeTab 放 deleteLater 之前还是之后我习惯先从界面摘掉再安排释放顺序别反过来。5.2 打开稍大的文档界面卡死现象双击打开一个几 MB 的 .txt 或 .html界面无响应任务管理器显示单核满载。原因同步读完整个文件后 setPlainText 全量渲染。QTextEdit 的富文本引擎对每个段落都要解析格式纯文本文件段落越多耗时越夸张这和 qt 表格大数据卡顿是同一类问题小数据量优雅大数据量性能塌方。解决分两步先把加载期间的重绘停掉再分段写入。void loadLargeTextFile(QTextEdit* edit, const QString filePath) { edit-setUpdatesEnabled(false); // 暂停重绘光标刷成沙漏 QFile file(filePath); if (file.open(QIODevice::ReadOnly | QIODevice::Text)) { // 分块读取可避免一次性大分配 QTextStream in(file); in.setCodec(UTF-8); QString part, buffer; while (!in.atEnd()) { part in.readLine(); buffer.append(part \n); if (buffer.size() 1024 * 1024) { // 每 1MB 写一次 edit-append(buffer); buffer.clear(); } } if (!buffer.isEmpty()) edit-append(buffer); } edit-setUpdatesEnabled(true); edit-repaint(); }参数说明setUpdatesEnabled(false) 只停重绘不停逻辑所以用户不会看到文本从零渲染到满的过程append 会带换行符注意别重复追加空行。这个方案能顶到 5MB 左右再大的文件我就不主张用 QTextEdit 了换 QPlainTextEdit 或者接入分页加载是更彻底的路。5.3 保存的 RTF 在 Word 里打开样式错乱现象编辑器里排得好好的一篇文档存成 .rtf 后拿到 Word 里打开字体、行距、表格样式全变了。原因Qt 没有完整的 RTF 写支持很多项目拿 QTextDocument::toHtml 的结果外包一层 .rtf 后缀Word 会尝试用 HTML 模式解析格式必然损。这是典型的“扩展名骗人”案例。解决务实的选择编辑器内部和默认保存格式都用 HTMLWord 能直接打开样式保留度比假 RTF 好一个量级。如果有交付到老系统的强约束在导出层单独做一个 HTML 到 RTF 的转换通道不在编辑线程里做失败时至少不影响正在写的内容。排查手法也简单用记事本打开你保存的“RTF”看到{\rtf1才正常如果看到一堆!DOCTYPE html就是挂羊头卖狗肉趁早改。5.4 页签标题不随内容刷新现象文档改了半天页签上的标题始终没加星号切到别的文档再切回来标题还是旧的。原因只连接了 QTextEdit::textChanged没连接 QTextDocument::modificationChanged。textChanged 在每次按键都触发但 QTextDocument 的 modified 状态要保存成功后才会归位两者不是同一个信号。解决监听 modificationChanged 信号见 2.2 节的 lambda用它的 bool 参数控制星号或者保存成功后手动调 doc-setModified(false)让标题立刻回到干净态。connect(page-doc, QTextDocument::modificationChanged, this, [this, page](bool mod) { int idx ui-tabWidget-indexOf(page-edit); if (idx 0) return; // 页签已关闭放弃刷新 ui-tabWidget-setTabText(idx, (mod ? * : ) page-doc-metaInformation(QTextDocument::DocumentTitle)); });注意我这次用 indexOf 动态取 idx而不是捕获创建时的旧序号。页签关闭后旧序号会失效捕获它去 setTabText 可能把标题写到别的文档页签上。5.5 撤销栈跨文档串味现象在文档 A 里敲了几行字切到文档 B 点撤销A 的内容被回滚了。原因多个 QTextEdit 共享了同一个 QTextDocument。常见错误写法是先 new 一个全局 QTextDocument再对每个编辑框 setDocument 同一个指针。撤销栈挂在 QTextDocument 上共享它等于共享撤销历史。解决每个编辑页签独立使用 QTextEdit 自带的 document()不要自己 new QTextDocument 或者跨页签 setDocument。// 错误写法 QTextDocument* shared new QTextDocument(this); editA-setDocument(shared); editB-setDocument(shared); // 正确写法 EditorPage* pageA addNewPage(A); EditorPage* pageB addNewPage(B); // 每个 page 内部的 page-doc 就是独立的 QTextDocument如果你确实需要把一个文档的分栏视图做在同一个 document 上那就得接受撤销栈被共享的现实再去想办法隔离命令列表。正常的多文档编辑器没这个诉求别学。6. 最后一道工序给文档处理装上撤销栈与文档比较技巧6.1 用 find 遍历做两份文档的删改高亮多文档编辑器天然适合做对比左边一份底稿右边一份修改稿把两边的差异标记出来。Qt 不提供现成的 diff 算法但 QTextDocument::find 可以当扫描器用。下面是一个最小实现把左侧文本先整体加删除线作为基线再逐个词在左边文档里找右边出现的词找到的去掉删除线并加绿底最后剩下的删除线就是“被删掉的内容”。void highlightDifferences(QTextEdit* baseline, QTextEdit* revision) { baseline-setUpdatesEnabled(false); // 1. 基准文档全部加删除线 QTextCharFormat deleted; deleted.setFontStrikeOut(true); baseline-selectAll(); baseline-mergeCurrentCharFormat(deleted); // 2. 右键文档逐词在左侧查找命中则标记为保留 QTextCursor searchCursor(baseline-document()); const QString revText revision-toPlainText(); QTextDocument probeDoc(revText); QTextCursor probeCursor(probeDoc); while (!probeCursor.atEnd()) { probeCursor.select(QTextCursor::WordUnderCursor); QString word probeCursor.selectedText(); if (!word.trimmed().isEmpty()) { QTextCursor hit baseline-document()-find(word, searchCursor); if (!hit.isNull()) { QTextCharFormat kept; kept.setFontStrikeOut(false); kept.setBackground(QColor(#DFF0D8)); hit.mergeCharFormat(kept); searchCursor.setPosition(hit.selectionEnd()); // 续扫避免从头开始 } } probeCursor.movePosition(QTextCursor::NextCharacter); } baseline-setUpdatesEnabled(true); baseline-repaint(); }参数说明WordUnderCursor 一次选中一个词中英文分词效果都可用但对“抱着”这种词元切分不如 NLP 分词这是简化 diff 的天然边界。searchCursor 的 setPosition 是关键优化它让查找一直往前走不会每次命中都从文档头重新扫O(n*m) 会退化成 O(n)。实际项目里我会把词频做成哈希再倒排两个文档都上万词时这个线性扫描会明显变慢。6.2 一个常被忽略的小习惯给子文档挂 owner 属性还有一个我踩过两次坑才养成的习惯每个 QTextDocument 创建后立刻给它挂一个自定义属性记录它属于哪个页签编号。自动保存定时器遍历所有文档时需要知道“这个文档是谁”才能决定存到哪个路径而 QTextDocument 本身不保存 UI 层信息直接找 edit 的 parent 又容易拿错。用 setProperty 最省事page-doc-setProperty(pageIndex, m_pages.size()); // 定时保存时 int idx page-doc-property(pageIndex).toInt(); if (idx 0 idx m_pages.size()) { saveDocument(m_pages[idx]); }这个方案在页签关闭后有个陷阱m_pages 已经 removeAtproperty 里的旧索引就悬空了。我的习惯是关闭页签时先把 doc 的 property 清掉或者干脆不用索引改用 filePath 做 key更稳。做完整这一版后我最大的教训是别贪图省事共享 document 对象Qt 文档系统虽然灵活但撤销栈和格式状态绑死在一个数据对象上共享它等于把编辑器的底裤分给了所有人穿。希望这些边界能帮你在这个方向上少返工几次也希望你做完后能体会到 Qt 文本处理体系里“编辑器只是个壳文档树才是灵魂”这句话的分量。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。