ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Qt版Word多文档编辑器:基于QMdiArea与QTextDocument的完整实现

Qt版Word多文档编辑器:基于QMdiArea与QTextDocument的完整实现 简介一款基于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天然支持独立拖动、缩放、层叠、平铺关闭事件也能在子窗口层面单独拦截。能力QMdiAreaQTabWidget子窗口独立拖动/缩放支持不支持每个文档独立处理关闭事件子窗口级好拦要接管 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 上是 CtrlNmacOS 上是 CmdN。用字符串写死 CtrlN 虽然能跑但跨平台表现不一致我一般都用 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_castQTextEdit *(sub-widget()); }currentSubWindow() 在没有活动子窗口时返回空指针后续调用处必须先判空再操作。这个函数会成为工具栏、菜单、快捷键操作的统一入口完整版资源里几乎每个格式动作都会先调它。3. 编辑与排版核心QTextDocument、光标操作与查找替换窗口框架立住之后真正决定“像不像 Word”的是编辑能力。QTextEdit 自带行编辑和基本的撤销重做但字号、颜色、段落对齐、查找替换这些动作都要落到 QTextDocument 和 QTextCursor 的细节上。3.1 核心不是 QTextEdit而是 QTextDocumentQt 里编辑框只是外壳真正的排版模型是 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 之间做的所有修改在撤销栈里只算一步。如果漏掉这个用户按一次 CtrlZ 只能撤销替换掉的一个词几百处替换就要按几百下撤销体验很难受。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); // 写 BOMWindows 记事本不乱码 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 导出 PDFQPrinter 与打印分辨率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 没有重写 closeEventdocument 的 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 QListQMdiSubWindow * subs m_mdiArea-subWindowList(); for (QMdiSubWindow *sub : subs) { DocumentEditor *edit qobject_castDocumentEditor *(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-8beginEditBlockreplaceAll确认批量操作合并撤销QMdiSubWindow 关闭事件closeEvent确认未保存有提示如果四个点都在这套资源就算不完美骨架也是健康的往里填功能不会塌。我相信一份完整的 Qt 多文档编辑器资源代码能跑只是底线经得起多平台运行和长时间编辑才是真正值钱的地方。从那以后我拿到任何一份 Qt 多文档项目都会强制先看这四个位置帮省出最多的排查时间也希望这份拆解能帮到你。本文还有配套的精品资源点击获取
返回列表