ARTICLE DETAIL

资讯详情

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

Qt富文本编辑器QTextDocument:从零搭建可复制的文档结构解析与渲染验证

Qt富文本编辑器QTextDocument:从零搭建可复制的文档结构解析与渲染验证 1. 从 QTextEdit 到 QTextDocument富文本编辑器到底在操作什么很多人第一次做 Qt 桌面端富文本编辑器会下意识把QTextEdit当成一个「大号文本框」然后试图用字符串拼接的方式去改样式。结果就是改一个加粗要重新拼一遍 HTML插入一段带缩进的引用块要手动数空格最后代码里全是b text /b这种脆弱写法。问题不在于QTextEdit不好用而在于你操作错了对象。QTextEdit只是「显示层」真正承载内容结构的是它内部的QTextDocument。你可以把QTextEdit理解成一块画布而QTextDocument是画布背后那棵结构化的文档树。这棵树由QTextFrame框架负责布局分区、QTextBlock文本块对应一个段落、QTextTable表格、QTextList列表等节点组成节点之间的包含关系是QTextDocument QTextFrame QTextBlock/QTextTable/QTextList。你往编辑器里敲的每一个回车本质上是在文档树里新增一个QTextBlock你插入的每一个带背景色的区域本质上是一个QTextFrame。理解这一点之后富文本编辑器的开发思路就变了不再是对着字符串做正则替换而是拿着QTextCursor在文档树里定位、插入、设置格式。QTextCursor是这棵树的「游标」它知道自己在哪个块、哪个框架、第几个字符位置。QTextCharFormat管字符级样式字体、颜色、粗体QTextBlockFormat管段落级样式对齐、缩进、行距QTextFrameFormat管框架级样式边框、背景、浮动、宽度。这套模型和 Word 的「字符 / 段落 / 节」分层几乎一一对应。那QTextEdit和QPlainTextEdit怎么选核心差异是QTextEdit提供toHtml()能把文档树序列化成 HTML适合「编辑完直接导出成网页/博客」的场景QPlainTextEdit针对纯文本做了优化有段落概念和撤销栈但不支持 HTML 显示。如果你的编辑器要处理富文本并导出选QTextEdit如果只是写代码日志、终端输出这类纯文本QPlainTextEdit更轻。本文聚焦前者因为「文档结构解析与渲染验证」这件事只有QTextDocument这套模型才能讲清楚。适合谁读正在用 Qt Widgets 做桌面端富文本编辑器的开发者、需要把编辑器内容导出成 HTML 的工程同学、以及被QTextCursor定位问题折磨过的人。下面我会从初始化配置、光标插入、格式设置一路写到用QTextDocumentFragment导出 HTML 做渲染验证每一步都给可复制的代码。2. TaoToken 前置准备给编辑器接一个可验证的模型能力做富文本编辑器时一个很实际的需求是用户选中一段文字点「润色」或「续写」编辑器把这段内容发给模型拿回结果再插回文档。要跑通这条链路你需要一个稳定的模型调用入口。我这边用的是 TaoToken它的官网是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 这个地址不加 UTM 参数直接用于代码里的 Base URL。先说清楚它解决什么问题你在 Qt 客户端里想调模型但不想在客户端里硬编码某一家厂商的 SDK也不想处理各家鉴权格式的差异。TaoToken 提供的是 OpenAI 兼容风格的接口你只要拿到一个 Key把 Base URL 指向它就能用统一的chat/completions格式发请求。对 Qt 来说这很友好因为 Qt 本身有QNetworkAccessManager你不需要引入额外的 HTTP 库直接发 POST 就行。拿 Key 的路径进控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 在 API Keys 页面创建一个 Key复制出来。这个 Key 就是后面代码里的Authorization: Bearer 你的Key。如果你只是想先在网页上验证模型能不能正常回话可以用模型对话页面 https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 试一句确认账号和额度没问题再回到 Qt 里写代码。这里有个工程上的建议不要把 Key 写死在mainwindow.cpp里。我试过在客户端里直接硬编码结果打包发出去之后 Key 就泄露了。正确做法是让客户端请求你自己的后端由后端持有 Key 去调 TaoToken如果只是本地自用的小工具至少也放到环境变量或配置文件里读取。本文为了演示方便会在代码里用占位符YOUR_TAOTOKEN_KEY你替换成自己的即可。另外如果你的编辑器要长期跑「选中即润色」「整篇续写」这类高频 Agent 式操作可以考虑 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 它更适合持续性的编码与文本处理任务比按次调用更省心。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有完整的请求参数说明写 Qt 网络请求时对着看就行。前置准备就这些一个 Key、一个 Base URL、一个能发 POST 的 Qt 网络模块。接下来进入正题先把QTextDocument的初始化和结构搭起来。3. 可复制配置QTextDocument 初始化与 QTextCursor 插入格式设置这一节是全文的核心我给出一份可以直接编译运行的MainWindow构造逻辑覆盖三件事获取文档与根框架、设置根框架和子框架格式、用QTextCursor插入文本并设置字符格式。代码基于 Qt Widgets.pro里加QT widgets即可。先看头文件和初始化。注意QTextEdit创建后自带一个QTextDocument你不需要自己new直接用document()拿指针#include mainwindow.h #include ui_mainwindow.h #include QTextDocument #include QTextFrame #include QTextBlock #include QTextCursor #include QTextCharFormat #include QDebug MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) , ui(new Ui::MainWindow) { ui-setupUi(this); // 1. 获取 QTextEdit 自带的文档对象不要自己 new QTextDocument *doc ui-textEdit-document(); // 2. 拿到根框架设置整篇文档的边框类似 Word 的页面边框 QTextFrame *rootFrame doc-rootFrame(); QTextFrameFormat rootFormat rootFrame-frameFormat(); rootFormat.setBorderBrush(Qt::darkBlue); rootFormat.setBorder(5); rootFrame-setFrameFormat(rootFormat); // 3. 定义一个浮动子框架放在右侧宽度 40% QTextFrameFormat frameFormat; frameFormat.setBackground(Qt::darkRed); frameFormat.setMargin(10); frameFormat.setPadding(5); frameFormat.setBorder(2); frameFormat.setBorderStyle(QTextFrameFormat::BorderStyle_Solid); frameFormat.setPosition(QTextFrameFormat::FloatRight); frameFormat.setWidth(QTextLength(QTextLength::PercentageLength, 40)); // 4. 用光标往文档里插内容 QTextCursor cursor ui-textEdit-textCursor(); cursor.insertText(A company); cursor.insertBlock(); cursor.insertText(321 City Street); cursor.insertBlock(); cursor.insertFrame(frameFormat); // 从这里开始进入子框架 cursor.insertText(Industry Park); cursor.insertBlock(); cursor.insertText(Another country); }这段代码跑起来你会看到前两行在根框架里后两行落在一个右侧浮动、带暗红背景的子框架里。关键点是insertFrame之后光标就进入了新框架后续insertText都写在这个框架内。接下来是字符格式。insertText的第二个参数可以直接传QTextCharFormat这样插入的文本自带样式不用事后遍历修改QTextCharFormat boldFormat; boldFormat.setFontWeight(QFont::Bold); boldFormat.setForeground(Qt::white); boldFormat.setFontPointSize(14); QTextCharFormat italicFormat; italicFormat.setFontItalic(true); italicFormat.setForeground(QColor(#4CAF50)); QTextCursor cursor ui-textEdit-textCursor(); cursor.movePosition(QTextCursor::End); cursor.insertBlock(); cursor.insertText(加粗白字标题, boldFormat); cursor.insertBlock(); cursor.insertText(斜体绿色说明文字, italicFormat);如果你要改「已经存在」的文本格式就不能用insertText了得先选中再设置。标准做法是cursor.setPosition(start)配合cursor.setPosition(end, QTextCursor::KeepAnchor)形成选区然后mergeCharFormatQTextCursor cursor ui-textEdit-textCursor(); cursor.setPosition(0); cursor.setPosition(9, QTextCursor::KeepAnchor); // 选中前 9 个字符 QTextCharFormat highlight; highlight.setBackground(Qt::yellow); cursor.mergeCharFormat(highlight);这里有个容易踩的坑setPosition的第二个参数默认是MoveAnchor会把选区取消掉必须显式传KeepAnchor才能形成选区。我见过不少人写了两遍setPosition结果什么都没选中就是漏了这个参数。再补一个段落级格式的例子QTextBlockFormat控制对齐和缩进QTextBlockFormat blockFormat; blockFormat.setAlignment(Qt::AlignCenter); blockFormat.setIndent(2); blockFormat.setLineHeight(150, QTextBlockFormat::ProportionalHeight); QTextCursor cursor ui-textEdit-textCursor(); cursor.movePosition(QTextCursor::End); cursor.insertBlock(blockFormat); cursor.insertText(这段是居中、缩进两格、1.5 倍行距的段落);到这里文档结构、框架格式、字符格式、段落格式四件套就齐了。你可以把这几段拼进同一个构造函数里编译运行看效果。下一步我们做渲染验证——把文档导出成 HTML确认结构真的按预期生成了。4. 验证请求与成功结果用 QTextDocumentFragment 导出 HTML 并核对结构写完插入逻辑怎么确认文档树真的长成了你想要的样子最直接的办法是导出 HTML 看结构。QTextDocument提供toHtml()但如果你只想导出「选中片段」或者想更细粒度地控制QTextDocumentFragment更合适。它可以从光标选区、从文档、从纯文本构造再调用toHtml()输出。先看从整个文档导出QTextDocument *doc ui-textEdit-document(); QString html doc-toHtml(); qDebug().noquote() html;toHtml()会输出一份带内联样式的完整 HTML你能在里面看到p对应QTextBlockspan style...对应QTextCharFormat浮动框架会变成带float: right的div或表格结构。核对时重点看三处段落数量是否等于你insertBlock的次数、字符样式有没有落到对应的span上、浮动框架的宽度百分比是不是 40%。再看用QTextDocumentFragment导出选区这个在「用户选中一段导出为 HTML 片段」的场景里非常实用QTextCursor cursor ui-textEdit-textCursor(); cursor.setPosition(0); cursor.setPosition(20, QTextCursor::KeepAnchor); QTextDocumentFragment fragment QTextDocumentFragment(cursor); QString fragmentHtml fragment.toHtml(); qDebug().noquote() 选区 HTML: fragmentHtml;QTextDocumentFragment还有个反向能力从 HTML 字符串构造片段再插回文档。这在「模型返回 HTML 结果插回编辑器」的链路里是关键一步QString modelReply pb模型返回的加粗内容/b/p; QTextDocumentFragment frag QTextDocumentFragment::fromHtml(modelReply); QTextCursor cursor ui-textEdit-textCursor(); cursor.movePosition(QTextCursor::End); cursor.insertFragment(frag);注意insertFragment和insertHtml的区别insertFragment插入的是文档片段会保留块结构insertHtml更偏向直接解析 HTML 字符串。做模型结果回填时我一般用fromHtmlinsertFragment结构更可控。现在把模型调用接进来验证「选中文字 → 发给模型 → 结果插回」这条完整链路。用QNetworkAccessManager发请求Base URL 指向 TaoToken#include QNetworkAccessManager #include QNetworkRequest #include QNetworkReply #include QJsonObject #include QJsonDocument #include QJsonArray void MainWindow::polishSelection() { QTextCursor cursor ui-textEdit-textCursor(); QString selected cursor.selectedText(); if (selected.isEmpty()) return; QNetworkAccessManager *mgr new QNetworkAccessManager(this); QNetworkRequest req(QUrl(https://taotoken.net/api/chat/completions)); req.setHeader(QNetworkRequest::ContentTypeHeader, application/json); req.setRawHeader(Authorization, Bearer YOUR_TAOTOKEN_KEY); QJsonObject msg; msg[role] user; msg[content] 请润色以下文字保持原意 selected; QJsonArray messages; messages.append(msg); QJsonObject body; body[model] gpt-4o-mini; // 按你账号可用的模型 ID 填 body[messages] messages; QNetworkReply *reply mgr-post(req, QJsonDocument(body).toJson()); connect(reply, QNetworkReply::finished, this, []() { QByteArray data reply-readAll(); QJsonObject obj QJsonDocument::fromJson(data).object(); QJsonArray choices obj[choices].toArray(); if (choices.isEmpty()) { qDebug() 返回异常: data; reply-deleteLater(); return; } QString content choices[0].toObject()[message] .toObject()[content].toString(); // 用选区替换原文本 QTextCursor c ui-textEdit-textCursor(); c.insertText(content); reply-deleteLater(); }); }成功的结果是你在编辑器里选中一段文字触发polishSelection几秒后选区被模型返回的润色结果替换同时doc-toHtml()里能看到新文本已经进入文档树。如果返回的是 HTML 格式把c.insertText(content)换成c.insertFragment(QTextDocumentFragment::fromHtml(content))即可。验证时建议打印choices数组长度和content长度确认不是空返回。这一步跑通说明文档结构、渲染、模型回填三条线都通了。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth 报错对照接入过程中最容易卡住的不是 Qt 代码本身而是网络请求和返回解析。下面按真实报错逐条对照。401 Unauthorized。这个几乎都是 Key 的问题。检查三处Authorization头是不是Bearer加空格再加 KeyKey 有没有复制时带上换行或空格Key 是不是在控制台被删了或过期了。我踩过的坑是把 Key 写进代码时前后带了引号结果请求头变成Bearer sk-xxx服务端直接 401。正确写法是req.setRawHeader(Authorization, (Bearer key).toUtf8());注意toUtf8()setRawHeader要的是QByteArray。local proxy failed / Connection refused。这个报错通常出现在你本地配了代理但 Qt 的QNetworkAccessManager没走系统代理或者代理端口不对。先确认你的网络环境本身能正常访问https://taotoken.net/api可以在浏览器或命令行里发一个最简单的请求验证。如果公司网络有出口限制联系网络管理员放行对应域名即可。Qt 侧可以用QNetworkProxyFactory::setUseSystemConfiguration(true);让网络模块跟随系统代理设置。reading choices / choices is undefined。这是解析返回时choices字段不存在。原因一般是请求体里model字段填了一个账号没有权限的模型 ID服务端返回的是错误对象而不是正常结构或者messages格式写错了比如content不是字符串。排查方法是在finished回调里先把data完整打印出来看服务端到底返回了什么。正常返回长这样{choices:[{message:{role:assistant,content:...}}]}。如果看到{error:{...}}就按 error 里的 message 去改请求参数。OAuth / authentication 相关报错。如果你用的是某些需要 OAuth 流程的工具链报错信息里出现OAuth字样说明鉴权方式用错了。TaoToken 的 API 走的是 Bearer Token不需要 OAuth 授权码流程。检查你是不是把某个需要 OAuth 的 SDK 默认配置直接搬过来了改成标准的Authorization: Bearer头即可。文档结构相关的隐性错误。有一类问题不报错但结果不对insertFrame之后忘了光标已经进入新框架继续insertText结果文字全跑到框架里了。解决办法是在插入框架前后用cursor.currentFrame()打印当前框架确认光标位置。另一个是setPosition定位偏移QTextBlock::position()返回的是块开头位置length()包含块结束符所以「块末尾」是position() length() - 1「下一块开头」是position() length()差一个字符就会插错位置。对照表整理一下报错/现象大概率原因处理动作401 UnauthorizedKey 错误或请求头格式不对检查Bearer空格与toUtf8()local proxy failed本地代理未生效或端口错开启系统代理跟随或检查出口choices undefinedmodel ID 无权限或 messages 格式错打印完整返回体核对OAuth 报错鉴权方式用错改用 Bearer Token文字插错位置光标未随框架切换打印currentFrame()确认排障时如果拿不准请求格式直接对照接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里的示例比对着猜快得多。6. 把文档结构能力用起来从验证到落地的几个实用技巧走到这里你已经有了一个能初始化文档、插入带格式内容、导出 HTML 验证、并且能接模型回填的富文本编辑器骨架。最后分享几个我在实际项目里总结的技巧都是踩过坑之后留下来的。第一个技巧遍历文档时优先用QTextBlock链而不是索引。doc-firstBlock()配合block.next()一路走到block.isValid()为 false比用blockCount()加索引更安全因为你在遍历过程中如果修改了文档索引会失效而块链的next()是相对定位。遍历嵌套框架时用rootFrame-childFrames()拿子框架列表再对每个子框架调begin()迭代比递归判断currentFrame()清晰。第二个技巧导出 HTML 做验证时别只看字符串用QTextDocumentFragment::fromHtml(html).toPlainText()反向解析一遍确认纯文本内容和原文一致。这能帮你发现「HTML 看着对但结构其实错了」的问题比如块被错误嵌套导致纯文本顺序错乱。第三个技巧模型回填内容时先做一次toPlainText()长度校验。如果模型返回的内容长度是原文的十倍大概率是它把整篇文档都复述了一遍直接插入会污染文档。加一个长度阈值判断超过就丢弃或截断。第四个技巧QTextDocument的setModified(false)和isModified()配合使用可以驱动「未保存」标记。每次用户编辑或模型回填后文档会自动置为 modified你在保存时调setModified(false)标题栏的星号就能正确联动。第五个技巧如果编辑器要支持「导出为 HTML 文件」用doc-toHtml(utf-8)指定编码避免中文乱码。写文件时用QTextStream并设置setCodec(UTF-8)两步都做才稳。这些技巧不需要额外依赖都是QTextDocument自带能力。你可以先把本文第 3 节的代码跑起来再用第 4 节的导出逻辑验证结构最后把第 5 节的排障表存下来备用。文档模型这东西看十遍不如自己插一个框架、导一次 HTML 来得实在。
返回列表