简介基于MFC与RichEdit实现的一款简易RTF编辑器面向Windows平台C开发者和需要快速搭建富文本编辑功能的初学者。工程代码采用Visual Studio工程结构包含头文件、源文件、界面资源与项目配置文档共27个文件压缩包仅198KB轻巧便于下载。目前已有1192人学习下载。功能上覆盖字体字号、文本及背景颜色、对齐方式、加粗斜体、超链接、上下标、删除线等常用排版能力并实现撤销重做、格式刷、查找替换、插入图片、缩放、打印及段落格式设置复制粘贴时可保留源格式或转为纯文本。保存与打开的文件兼容Microsoft Word 2013。对于想通过实际项目学习MFC界面开发、RichEdit控件操作或RTF格式处理的读者可直接编译运行并在此基础上扩展工程附带编译好的exe但不同电脑可能出现不兼容建议在本地重新编译。作者表示会不定期更新可作为课设参考或练手项目。1. MFC 简易 RTF 编辑器为什么说这是新手最容易低估的 MFC 项目我拆过不少用 MFC 做的小工具很多同学卡在同一个点上想让文本框支持粗体、颜色、多级撤销结果用 CEdit 一顿操作猛如虎最后发现 CEdit 根本不支持富文本只能逐个字符拼接格式信息代码越写越难看。其实 MFC 里自带一个被严重低估的控件 CRichEditCtrl它封装了 RichEdit 2.0 引擎直接支持 RTF 读写、字体颜色、段落对齐、撤销重做做编辑器从零到能用可能就用一个周末。这篇就围绕一个「MFC 简易 RTF 编辑器」的完整实现思路展开覆盖控件初始化、文件读写、格式设置和几个高频翻车点适合想用 MFC 或者 C 快速做文本类工具的开发者和在校学生。2. 先把架子立起来CRichEditCtrl 的初始化与工程接入方式2.1 为什么选 CRichEditCtrl 而不是重写一个富文本控件先说结论RTF 编辑器最难的从来不是界面上放一个文本框而是「文本内容的结构化表达」。RTF 本质上是带控制字比如\b表示粗体、\cf1表示颜色的纯文本标记语言你要自己解析它需要处理控制字、转义序列、字符集声明、嵌套组这套逻辑没有几个星期很难稳定。而 CRichEditCtrl 已经把这层东西做完了——它在 Windows 里对应 RichEdit 控件底层封装了 RichEdit 2.0 的消息系统包括EM_SETCHARFORMAT、EM_SETPARAFORMAT、EM_STREAMIN这些核心消息。换句话说RichEdit 引擎负责把 RTF 字符串变成屏幕上的可视格式化内容以及反向把可视内容序列化成 RTF你的工作重心只需要放在「怎么把用户的操作翻译成对应的消息」上。这也决定了选型方向如果你的编辑器只需要纯文本CEdit 就够了但只要涉及颜色、字体、缩进、撤销多步操作直接用 CRichEditCtrl 是最省力的路径。再往上走用第三方库比如 Scintilla 或定制自绘控件功能确实更强但引入依赖、重写交互逻辑的成本就不是「简易编辑器」能承受的了。作为一个工具型软件CRichEditCtrl 的性价比很高。2.2 工程初始化AfxInitRichEdit2、控件创建与基础属性设置很多人在对话框编辑器里直接把 RichEdit Control 拖到界面上然后就开始写逻辑编译也通过了但一运行就发现控件区域是灰色的——什么都没渲染出来。原因是 RichEdit 控件的 DLL 没有加载。从 MFC 的角度需要在InitInstance里显式调用AfxInitRichEdit2()这个函数会去加载 RichEdit 2.0 的 DLL 并保证消息路由可用。如果你的工程同时在用 RichEdit 1.0 的老接口也可以用AfxInitRichEdit()但它们对应的控件版本不同混用容易出问题建议统一用AfxInitRichEdit2。BOOL CMyApp::InitInstance() { // 加载 RichEdit 2.0 控件类必须在创建控件之前调用 AfxInitRichEdit2(); CWinApp::InitInstance(); // ... 其他初始化流程 CMainFrame* pFrame new CMainFrame; if (!pFrame-LoadFrame(IDR_MAINFRAME)) { return FALSE; } m_pMainWnd pFrame; pFrame-ShowWindow(SW_SHOW); return TRUE; }这段代码背后的逻辑很简单RichEdit 2.0 在系统里以 COM 组件的形式注册AfxInitRichEdit2本质上是在进程里完成组件初始化。这个调用必须在任何CRichEditCtrl或CRichEditView创建之前执行否则控件创建虽然不报错但没有正确的窗口类注册信息表现出来就是白板一块或者创建失败。参数方面不需要传参只需要确认它在InitInstance且位于控件创建代码之前即可。如果你用的是对话框程序控件通常是通过资源文件创建的那么还需要在DoDataExchange里绑定变量void CMyDlg::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); DDX_Control(pDX, IDC_RICHEDIT_CONTENT, m_richContent); }这个DDX_Control做的事情是把资源里声明的控件句柄与m_richContent对象关联起来。到这里控件就可以接收消息了但还需要设几个基础属性SetOptions(ECOOP_SET, ECO_AUTOVSCROLL | ECO_AUTOHSCROLL)让滚动条自动出现SetFont设定默认字体通常用系统 UI 字体字号SetEventMask决定哪些通知消息需要回调到父窗口。2.3 文档/视图架构下的接入CRichEditView 与 CRichEditDoc 的分工如果你不是做单对话框小工具而是想做一个像写字板那样的 MDI/SDI 程序MFC 还提供了一套完整的文档/视图支持CRichEditView、CRichEditDoc和CRichEditCntrItem。这套组合把文档序列化、OLE 对象嵌入、视图刷新都封装好了。CRichEditDoc负责 RTF 数据的存储和读写CRichEditView负责界面交互两者的关系比直接在对话框里塞一个CRichEditCtrl要清晰。选择哪种方式取决于程序形态对话框布局灵活、开发快适合「工具型」需求文档/视图架构适合「文件型」需求天然支持多文档、打印预览、OLE 嵌入。简易编辑器我一般建议从对话框起步因为CRichEditView虽然自动化程度高但它的行为改动必须通过重写CRichEditView的虚函数完成反而把一些简单需求变复杂了。先搞清CRichEditCtrl的消息通讯方式后续要迁移到CRichEditView时思路也是一脉相承的。这一章的关键在于理解不要试图绕过控件直接操作 RTF 文本。所有用户能看到的格式变化底层都是富文本消息在起作用你的代码只负责把「意图」翻译成消息。3. RTF 文件读写实战StreamIn 与 StreamOut 的完整实现和参数细节3.1 从文件加载 RTFEDITSTREAM 结构、回调函数与编码陷阱RTF 文件读入不是一个简单的SetWindowText就能搞定的。SetWindowText只能放纯文本RTF 控制字会被原样显示成乱码。正确姿势是使用StreamIn消息把文件二进制流交给 RichEdit 引擎解析。这里要借助EDITSTREAM结构体和自定义回调函数一个标准的读取流程如下// 文件读取回调函数RichEdit 引擎通过它从文件读数据 DWORD CALLBACK EditStreamReadCallback(DWORD_PTR dwCookie, LPBYTE pBuffer, LONG cb, LONG* pcb) { CFile* pFile reinterpret_castCFile*(dwCookie); try { *pcb pFile-Read(pBuffer, cb); } catch (CFileException* e) { *pcb 0; e-Delete(); return 1; // 非零表示读取出错 } return 0; } void CMyDlg::LoadRtfFile(LPCTSTR lpszFilePath) { CFile file; if (!file.Open(lpszFilePath, CFile::modeRead)) { AfxMessageBox(_T(无法打开文件)); return; } EDITSTREAM es { 0 }; es.dwCookie reinterpret_castDWORD_PTR(file); // Cookie 传给回调的上下文 es.pfnCallback EditStreamReadCallback; // 回调函数指针 // 清空当前内容必须是先清空再 StreamIn m_richContent.SetSel(0, -1); m_richContent.Clear(); m_richContent.StreamIn(SF_RTF, es); file.Close(); // 把光标挪到文档开头 m_richContent.SetSel(0, 0); }这里有几个容易踩的细节。dwCookie是一个DWORD_PTR本质是一个不透明上下文回调函数里再把它还原成CFile*指针因为回调是同步执行的所以这个指针的生命周期在StreamIn返回前都有效不需要担心悬空。cb参数是 RichEdit 请求读取的字节数回调函数必须把实际读到的字节数写回pcb如果写的字节数小于cb引擎会认为文件到这里结束。SF_RTF表示按 RTF 格式解析如果你的文件内容实际是纯文本用SF_RTF会把纯文本的之类字符误判成控制字造成丢失这时应该用SF_TEXT。但反过来如果内容实际是 RTF 格式却用了SF_TEXT读展示出来就是控制字原文乱码。因此判断文件里存的是哪种格式是很重要的一件事。3.2 保存为 RTF 文件StreamOut 与字符集设置保存逻辑与读取对称用StreamOut把当前文档内容写出去。保存前可以先通过GetModify()判断内容是否改过避免无意义的重复写入DWORD CALLBACK EditStreamWriteCallback(DWORD_PTR dwCookie, LPBYTE pBuffer, LONG cb, LONG* pcb) { CFile* pFile reinterpret_castCFile*(dwCookie); try { *pcb pFile-Write(pBuffer, cb); } catch (CFileException* e) { *pcb 0; e-Delete(); return 1; } return 0; } // 保存 RTF 文件 BOOL CMyDlg::SaveRtfFile(LPCTSTR lpszFilePath) { CFile file; if (!file.Open(lpszFilePath, CFile::modeCreate | CFile::modeWrite)) { AfxMessageBox(_T(无法创建文件)); return FALSE; } EDITSTREAM es { 0 }; es.dwCookie reinterpret_castDWORD_PTR(file); es.pfnCallback EditStreamWriteCallback; // SF_RTF 表示按 RTF 格式写出会包含控制字和格式信息 m_richContent.StreamOut(SF_RTF, es); file.Close(); m_richContent.SetModify(FALSE); return TRUE; }在保存这一侧通常需要面对的是编码问题。Windows 下 RichEdit 默认按 ANSI 代码页写入 RTF 头里的\ansi如果你的系统区域是简体中文那么 RTF 内容里的中文字符会以 GBK 编码写入并带上\ansicpg936头。这个行为在纯中文 Windows 环境里没问题但如果你把文件发给使用非中文系统的用户可能出现中文显示成乱码的情况。解决思路是保存时强行指定 Unicode在StreamOut前用EM_SETCHARFORMAT设置默认字体不影响编码但你可以用 SendMessage 发EM_SETOPTIONS或先操作 RTF 头。由于 RTF 头由引擎自动生成一般做法是保存后用字符串替换把\ansicpg936换成\ansicpg65001\uc1并在内容数据里把汉字按 Unicode 转义成\uN?形式这个步骤建议拆成独立的 RTF 后处理函数避免每次都靠手写。3.3 撤销栈与修改状态GetModify、SetModify 和 EM_GETUNDONAME 的配合RTF 编辑器里用户会高频执行撤销、重做CRichEditCtrl 内部维护着一个很大的操作栈但栈本身不受 MFC 的文档状态管理。具体表现是你改了半天内容标题栏的「未保存」提示可能不会自动出现。这是因为 CRichEditCtrl 的修改标记没有被系统自动同步到窗口标题。需要手动在消息处理里更新状态void CMyDlg::OnRichContentChange() { // 假设 m_bModified 是成员变量记录文档是否修改过 CString strTitle; GetWindowText(strTitle); if (m_richContent.GetModify()) { if (!m_bModified) { m_bModified TRUE; // 在标题栏加个星号表示有未保存改动 SetWindowText(strTitle _T( *)); } } else { m_bModified FALSE; } }这个OnRichContentChange通常是响应EN_CHANGE通知的。要记住GetModify()返回的是 RichEdit 控件内部修改标志不是你的逻辑标志。有些操作会改变文本内容但不会置位修改标志比如StreamIn本身所以在StreamIn完成后要手动SetModify(FALSE)再让用户操作触发GetModify() TRUE。撤销栈的清理由EM_EMPTYUNDOBUFFER消息完成打开新文件前应该发这个消息把旧操作栈清空否则用户在新文档里点撤销可能撤销出上一个文档的内容操作这是很莫名其妙的玄学 bug。4. 格式化功能实现CHARFORMAT 与 PARAFORMAT 的结构化设置4.1 CHARFORMAT2字体、字号、粗体斜体、颜色和上下标格式化是通过EM_SETCHARFORMAT消息实现的它的参数核心是一个CHARFORMAT2结构体比CHARFORMAT多了一些扩展字段比如缩放、字间距、下划线颜色等。关键点在于这个结构体使用了「掩码」机制dwMask指定哪些字段生效dwEffects指定效果粗体、斜体、下划线、删除线的开关格式值本身放在yHeight、szFaceName、crTextColor这些字段里。设置当前选中文字的格式示例代码如下void CMyDlg::OnFormatBold() { CHARFORMAT2 cf { 0 }; cf.cbSize sizeof(CHARFORMAT2); // 只修改粗体这一项其他格式保持不变 cf.dwMask CFM_BOLD; // 触发按一下粗体开再按一下粗体关的切换逻辑 cf.dwEffects m_bBoldOn ? CFE_BOLD : 0; // SCF_SELECTION 表示作用于当前选中的文本 // SCF_ALL 表示作用于全部文本 m_richContent.SendMessage(EM_SETCHARFORMAT, SCF_SELECTION, (LPARAM)cf); }SCF_SELECTION与SCF_ALL的区别要分清SCF_SELECTION作用于当前选区如果当前没有选区则作用于插入点之后的输入格式SCF_ALL作用于整个文档。对工具栏的粗体按钮通常需要结合GetSelectionType判断当前选中区域是否已经包含粗体文字再把按钮置为按下状态。CHARFORMAT2里还有一个容易被忽略的字段bPitchAndFamily它表示字体间距属性和字符集归属复制这个结构体时忘了处理这个字段可能导致某些字体比如以 开头的竖排字体在系统上不显示而是被替换成默认字体。设置颜色有点不同因为crTextColor是一个COLORREF需要用户从颜色选择对话框里拿到值void CMyDlg::OnFormatTextColor() { CColorDialog dlg; if (dlg.DoModal() IDOK) { CHARFORMAT2 cf { 0 }; cf.cbSize sizeof(CHARFORMAT2); cf.dwMask CFM_COLOR; cf.crTextColor dlg.GetColor(); // 注意要 RGB 宏转成 COLORREF m_richContent.SendMessage(EM_SETCHARFORMAT, SCF_SELECTION, (LPARAM)cf); } }这里有个坑crTextColor存的是COLORREF即0x00BBGGRR格式而 RTF 文本里颜色是按\red255\green0\blue0倒序描述的。RichEdit 引擎会做转换所以你在设置阶段不用操心 RTF 内部表示只要确认传给crTextColor的值来源正确比如从CColorDialog得到的是COLORREF直接用即可。4.2 PARAFORMAT2对齐方式、首行缩进与行距段落格式的接口与字符格式对称用EM_SETPARAFORMAT结构体主要用PARAFORMAT2。需要先看一下哪些字段是真正常用的字段对应消息掩码说明wAlignmentPFM_ALIGNMENTPFA_LEFT / PFA_RIGHT / PFA_CENTERdxStartIndentPFM_STARTINDENT首行缩进单位是 twips1/20 磅dxOffsetPFM_OFFSET段落悬挂缩进量cTabCount rgxTabsPFM_TABSTOPS自定义 Tab 停靠位dyLineSpacingPFM_LINESPACING行距取值结合 bLineSpacingRule一个常见需求是「居中」按钮实现起来非常直观void CMyDlg::OnParaCenter() { PARAFORMAT2 pf { 0 }; pf.cbSize sizeof(PARAFORMAT2); pf.dwMask PFM_ALIGNMENT; pf.wAlignment PFA_CENTER; m_richContent.SendMessage(EM_SETPARAFORMAT, SCF_SELECTION, (LPARAM)pf); }行距设置比对齐稍微复杂因为dyLineSpacing的解析依赖bLineSpacingRule字段当bLineSpacingRule为2时dyLineSpacing表示 1/240 英寸的倍数为4时表示 1/20 磅的点数为5时是精确值。实际使用中如果你想设置「1.5 倍行距」比较稳妥的做法是dyLineSpacing 240bLineSpacingRule 1对应 1.5 倍行距。不同版本文档对行距规则的定义比较绕我的建议是不管 UI 上用户选几倍行距内部统一换算成 twips 再设置不要直接拿倍数往dyLineSpacing里填否则在 RichEdit 2.0 和 3.0 的不同底层实现上表现不一致。另一个坑是缩进单位。dxStartIndent的单位是 twips缇1 像素大约等于 15 twips1 厘米等于 567 twips。如果你从 UI 上让用户填「厘米」或「字符数」换算逻辑要在进结构体之前做好而不是直接传递否则缩进值要么大得离谱要么零效果。4.3 工具栏按钮联动UpdateCommandUI 与 EN_SELCHANGE 的配合逻辑工具栏上的 B/I/U 按钮需要呈现「当前选区是否已经应用该格式」的按下状态。这需要两个消息配合一个是EN_SELCHANGE当光标位置改变或选区改变时触发在这个通知里重新查询当前格式另一个是 MFC 的更新命令 UI 机制ON_UPDATE_COMMAND_UI它可以定时或按需刷新按钮状态。一个典型的查询逻辑是void CMyDlg::OnUpdateFormatBold(CCmdUI* pCmdUI) { CHARFORMAT2 cf { 0 }; cf.cbSize sizeof(CHARFORMAT2); // 获取当前选区的格式属性 m_richContent.SendMessage(EM_GETCHARFORMAT, SCF_SELECTION, (LPARAM)cf); // CFE_BOLD 存在 dwEffects 中同时需要 dwMask 里有 CFM_BOLD 才能判断 BOOL bIsBold (cf.dwMask CFM_BOLD) (cf.dwEffects CFE_BOLD); pCmdUI-SetCheck(bIsBold); }有些新手在这里直接判断cf.dwEffects CFE_BOLD少检查dwMask会导致一个误判如果当前选区包含混合格式一部分粗体一部分非粗体dwMask可能不含CFM_BOLD这时dwEffects里的值没有参考意义。看了不少开源项目这个细节做得好的不多顺手在这里提一句。EN_SELCHANGE的响应函数里除了刷新工具栏按钮还需要更新状态栏里的行列号另外它也是在格式化操作后恢复选区状态的重要时机。一个需要注意的雷区是OnUpdateFormatBold里调用EM_GETCHARFORMAT不会触发递归通知可以放心使用但如果在EN_SELCHANGE里直接调用SetSel去调整选区会再次触发EN_SELCHANGE形成递归所以任何对选区有副作用的操作都要用一个m_bProcessingSelChange标志防重入。5. MFC RTF 编辑器避坑指南五个高频翻车现场与排查思路5.1 打开文件后界面是空白StreamIn 正常返回但内容没显示现象调用StreamIn(SF_RTF, es)返回成功接口返回值也正确但编辑框里干干净净什么都没有查看文件字节数也没有变化。原因StreamIn之前没有清空旧内容或者光标位置不对。在某些情况下控件内部有不可见内容你用SetSel(0, -1); Clear();清空了可视文本但插入点还在文档末尾。此时StreamIn的写入位置取决于插入点位置如果插入点在末尾且后面还有其他控制字符就会把内容写在看不见的区域。解决在StreamIn前强制把插入点放到文档开头m_richContent.SetSel(0, -1); m_richContent.Clear(); // 必须把插入点放回起点再流式读入否则内容是追加在末尾而非替换 m_richContent.SetSel(0, 0); m_richContent.StreamIn(SF_RTF, es);另外一个隐藏因素EDITSTREAM的dwError字段在StreamIn返回后不一定有意义因为引擎可能部分成功里层回调才真正执行了读文件逻辑。调试时建议先在回调里用OutputDebugString打印每次读取的字节数确认回调真的被调用了。5.2 中文内容保存后变成问号或乱码现象在中文 Windows 上编辑和显示正常但把.rtf文件放到英文系统或用记事本打开中文全部变成?或乱码。原因RichEdit 默认按 ANSI 代码页输出 RTF 头\ansicpg936但实际内容的原始字节因字体设置不同也可能以 Unicode 转义形式写入。记事本打开时如果按\ansicpg1252解析中文就乱码。解决思路是保存后手动处理 RTF 头与转义序列。更稳妥的做法是用StreamOut到内存缓冲再用自定义后处理替换\ansi为\ansicpg65001\uc1并把高位字符转换成\uN?形式。这个转换脚本可以直接用 CString 的替换 循环扫描实现也可以用现成的字符串库在内存缓冲中操作。示例思路// 保存后对内存缓冲做编码头替换伪码示意 CStringA strRtf GetStreamOutBufferAsAString(); strRtf.Replace(\\ansi, \\ansicpg65001\\uc1); // 对每个 127 的字节处理成 \uN? 格式经过这一处理RTF 文件携带明确的 Unicode 标记跨平台打开时中文就能正常显示。如果只是给自己的工具自用不跨平台不处理也能运行但正经产品建议都加上。5.3 撤销一次把整个文档清空了现象在一段比较长的编辑内容中用户执行一次撤销整个文档变成空白撤销栈里全没了。原因多数是没有清空撤销栈或者在StreamIn之前用SetWindowText/Clear做清空操作这两个操作都往撤销栈里塞了一条「清空动作」。用户按一次撤销引擎实际执行的是「删除所有文本」这一条历史动作看起来就是把整个文档清空。解决在加载新文件或新建文档时先发EM_EMPTYUNDOBUFFER清空撤销栈再执行内容替换// 新文档/打开文件时 m_richContent.SendMessage(EM_EMPTYUNDOBUFFER, 0, 0); m_richContent.SetSel(0, -1); m_richContent.Clear(); m_richContent.SetSel(0, 0); m_richContent.StreamIn(SF_RTF, es); // 重置修改标志 m_richContent.SetModify(FALSE);这个操作必须放在StreamIn前后都确认一次严格来说只需在清空前调用一次即可因为StreamIn本身不产生撤销记录而Clear()会产生。如果你先发EM_EMPTYUNDOBUFFER再执行Clear()撤销栈里依然有这条清空记录这就是这个坑最隐蔽的地方。5.4 设置字体后界面上的字体没有变化现象通过EM_SETCHARFORMAT给选中文本设置szFaceName为某个系统字体但编辑框里的文字还是旧字体没有任何视觉变化。原因CHARFORMAT2结构体里szFaceName是WCHAR数组赋值时如果用wcscpy或_tcscpy要注意字段长度是LF_FACESIZE32如果字体名超过长度且没有以\0结尾整个结构体校验会失败。另一个可能dwMask包含CFM_SIZE但yHeight单位是 twips你把 12 磅直接赋给yHeight导致字号变成 12 twips约 0.6 磅显示效果几乎不变。解决方法CHARFORMAT2 cf { 0 }; cf.cbSize sizeof(CHARFORMAT2); cf.dwMask CFM_FACE | CFM_SIZE; _tcscpy_s(cf.szFaceName, LF_FACESIZE - 1, _T(微软雅黑)); // 12磅字号 12 * 20 240 twips cf.yHeight 12 * 20; m_richContent.SendMessage(EM_SETCHARFORMAT, SCF_SELECTION, (LPARAM)cf);另外如果当前选区的文本「全是 RichEdit 引擎无法渲染的字体」比如一种只支持西文字符的字体显示汉字引擎会做字体回退而不是替换需要勾选「允许字体替换」的兼容选项。这个问题在英文版 Windows 上更明显建议在做跨平台需求时直接在设置字体后调用GetCharFormat反向检查是否生效。5.5 设置行距后段落没有变化或行距过大现象通过EM_SETPARAFORMAT把dyLineSpacing设成 500bLineSpacingRule设为 1段落行距没有变化换个数值设成 5000 又变得巨大。原因dyLineSpacing与bLineSpacingRule的组合关系很多人没读懂。在 RichEdit 2.0 里bLineSpacingRule为0表示单倍行距此时dyLineSpacing被忽略为1表示 1.5 倍行距dyLineSpacing必须是 240为2表示按 twips 精确指定比如dyLineSpacing300表示 300/2015 磅行距为4表示以当前字体大小为基准乘倍数dyLineSpacing的值是百分比。很多人按「数值越大行距越大」直觉去调忽略了规则位。解决固定封装一套参数映射比如用户选择 1.0/1.5/2.0/2.5 倍行距时内部计算对应的dyLineSpacing和bLineSpacingRule不要把 UI 倍数直接硬编码进消息。示例void CMyDlg::SetLineSpacing(double dMultiplier) { PARAFORMAT2 pf { 0 }; pf.cbSize sizeof(PARAFORMAT2); pf.dwMask PFM_LINESPACING; if (dMultiplier 1.0) { pf.bLineSpacingRule 0; // 单倍行距忽略 dyLineSpacing pf.dyLineSpacing 0; } else if (dMultiplier 1.5) { pf.bLineSpacingRule 1; pf.dyLineSpacing 240; } else { // 其余倍数用精确值240 * 倍数 pf.bLineSpacingRule 2; pf.dyLineSpacing (LONG)(240 * dMultiplier); } m_richContent.SendMessage(EM_SETPARAFORMAT, SCF_SELECTION, (LPARAM)pf); }这里的核心问题在于搞清楚规则位的语义而不是死记数值。设置前用EM_GETPARAFORMAT读取当前值也可以辅助排查拿不到预期值就说明消息没生效而不是数值本身的问题。6. 收尾实操查找替换功能做到可用的四个细节查找替换是文本编辑器的标配但在 RichEdit 里做查找替换比在 CEdit 里多一点讲究。简单做法是每次查找都拿到全文的纯文本调用CString::Find定位再映射到 RichEdit 的位置。但这样有两个问题一个是把格式信息丢掉了查找结果在界面上不准确另一个是碰到嵌入对象、表格时位置映射会错位。更稳妥的做法是直接基于纯文本做匹配然后靠SetSel定位到实际位置这个方案对简易编辑器够用。分享四个我实践中觉得最有用的细节第一个是查找方向与光标行为。查找下一个时必须从当前选区之后开始搜索而不是从文档头开始。很多人的实现是每次查找都从 0 开始找于是永远停在第一处匹配。我一般会记录当前查找位置后先SetSel到该位置然后对当前文本搜索子串。这里要注意RichEdit 的行尾与文本缓冲区里的换行符可能不一致RTF 里是\par控制字所以从GetWindowText拿到的字符串和实际显示的行结构有偏移。处理办法是用GetTextRange按查找区间分段取文本而不是一次性拿全文。第二个是选区边界与整词匹配。如果你的查找对话框提供「全字匹配」选项需要自己判断匹配子串的前后字符是不是字母数字。用CString::Find只能拿到子串索引需要额外取前后字符判断边界BOOL IsWholeWordMatch(CString strText, int nPos, int nLen) { if (nPos 0 _istalnum(strText[nPos - 1])) return FALSE; if (nPos nLen strText.GetLength() _istalnum(strText[nPos nLen])) return FALSE; return TRUE; }这个检查放在循环匹配里如果命中了IsWholeWordMatch为假就继续往下找找完整个文档后回到开头形成环绕。环绕查找还要额外记录「是否已经完整绕了一圈」避免用户在无匹配时陷入死循环。第三个是替换后的选区保持。很多人替换完以后发现光标跳到了文档开头或者选中的不是刚替换的那段文字这是因为替换时执行了SetWindowText或Clear操作。正确做法是通过SetSel选中有问题的区域后直接发EM_REPLACESEL但这个消息不会把光标放在替换文本之后需要手动SetSel。建议封装一个替换函数void CMyDlg::ReplaceSelection(CString strNewText) { long nStart, nEnd; m_richContent.GetSel(nStart, nEnd); // 用新文本替换当前选中内容 m_richContent.ReplaceSel(strNewText); // 替换后可选择性恢复定位到替换文本末尾 m_richContent.SetSel(nStart, nStart strNewText.GetLength()); }这里有个参数细节ReplaceSel的默认行为是替换当前选区并保留格式如果想让它使用当前插入点格式需要传TRUE作为第二个参数否则会保留替换位置的旧格式。这个参数在样式合并时经常被忽略。第四个是高亮所有匹配项。如果编辑器有条件最好支持「标出所有匹配项」这个功能。RichEdit 没有直接的高亮 API但可以通过给匹配文本设置临时背景色实现。副作用是相对明显的一旦用户继续输入文字背景色依旧存在需要额外做一轮「清除颜色」操作。我一般维护一个vectorCHARRANGE记录所有匹配位置高亮时对每个匹配设置CFM_BACKCOLOR取消高亮时恢复成默认背景色。匹配项数量大比如超过 500 处、又做了格式化恢复时性能会明显下降这时只在可见区域渲染是更聪明的做法。把一个简易 MFC RTF 编辑器做到这个程度算是一个工作日就能交付的小工具。我后来每次做这一类控件型工具都强制给自己走一遍「初始化 → 文件流 → 格式 → 边界异常 → 查找替换」的流程能省掉不少返工时间。希望这篇文章的思路和踩坑记录能帮到你尤其是那些准备用 MFC 做编辑器的同学先把消息机制和结构体语义吃透比贴更多代码管用。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?