迈智数创建筑 AI · Coding

试试搜索

↑↓ 选择Enter 打开Esc 关闭

Lesson11:文字与字体系统

下载 .md
系列:MiniCAD第 13 / 17 篇
  1. MiniCAD 初级教程课程规划
  2. C++ 基础课:读懂 MiniCAD 所需的语言知识
  3. Lesson01:项目能跑起来
  4. Lesson02:整体架构地图
  5. Lesson03:程序入口与主循环
  6. Lesson04:输入系统与消息映射
  7. Lesson05:绘图工具如何工作
  8. Lesson06:Core 核心层:数学、几何、对象与实体
  9. Lesson07:命令模式与撤销重做
  10. Lesson08:选择、拾取与夹点编辑
  11. Lesson09:视口、相机与坐标转换
  12. Lesson10:渲染层入门
  13. Lesson11:文字与字体系统
  14. Lesson12:吸附与约束系统
  15. Lesson13:命令行系统
  16. Lesson14:多文档管理
  17. Lesson15:综合实战:扩展一个 MiniCAD 功能
目录
  1. 学习目标
  2. 相关源码
  3. 文字实体的两种类型
  4. 两条渲染路径
  5. 路径一:EmitText(纹理,TextEntity)
  6. 路径二:EmitMText(矢量,MTextEntity)
  7. IFont:统一字体接口
  8. Glyph:统一输出结构
  9. SHX 字体
  10. SHX 的三种子类型
  11. SHXParser:加载与字形表
  12. SHXVM:字节码虚拟机
  13. SHX 字符编码路由
  14. TTF 字体
  15. 贝塞尔曲线展平
  16. 坐标归一化
  17. TTFFont 的填充模式
  18. Web 端不用 TTFFont
  19. SHXCompositeFont:主字体 + 大字体组合
  20. FontEngine:懒加载与缓存
  21. GlyphCache:字形缓存
  22. TextLayoutEngine:排版
  23. FontSystem:字体系统状态机
  24. 内置样式
  25. 探索者字体(PreloadDefaultFonts)
  26. FontStyle 与跨文档共享
  27. TextInputRequest 流程
  28. 步骤一:TextTool 设置请求
  29. 步骤二:UI 层弹出输入框
  30. 步骤三:SubmitTextInput 创建实体
  31. 文字编辑(已有文字)
  32. 路径对比总结
  33. 拓展练习

学习目标

读懂 MiniCAD 文字系统的两条渲染路径(纹理路径 vs 矢量路径),理解 FontSystem 的状态机和样式注册机制,掌握 TextInputRequest 流程,以及 Windows 和 Web 两端的文字输入差异。


相关源码

  • src/Text/Font/IFont.h
  • src/Text/Font/SHXFont.h / src/Text/Parser/SHXParser.h / src/Text/Parser/SHXVM.h
  • src/Text/Font/TTFFont.h
  • src/Text/Font/SHXCompositeFont.h
  • src/Text/Font/FontEngine.h
  • src/Text/Glyph/Glyph.h / GlyphCache.h / GlyphKey.h
  • src/Text/Layout/TextLayoutEngine.h
  • src/Text/FontSystem.h
  • src/Core/Entity/TextEntity.hpp / MTextEntity.hpp
  • src/Document/DrawContext.hpp(EmitText 和 EmitMText 的实现)
  • src/Editor/Editor.h(TextInputRequest / MTextInputRequest)
  • src/App/Web/WebMain.cpp(MiniCAD_SubmitText、MiniCAD_SubmitMText)

文字实体的两种类型

类型字体路径布局用途
TextEntity纹理(ImGui 字体图集)单行,无换行简单标注
MTextEntity矢量(SHX / TTF)多行,支持文本框宽度和换行工程图注释

两者都是 CAD 对象:有插入点、高度、旋转角、图层属性,参与选择、移动、旋转、镜像和撤销重做。


两条渲染路径

路径一:EmitText(纹理,TextEntity)

void DrawContext::EmitText(const Point3& pos, const string& utf8,
                           float height, float rotation, const Color4& color) {
    // 遍历 UTF-8 码点
    while (p < end) {
        unsigned int cp;
        p = DecodeUtf8(p, end, cp);

        GlyphInfo g;
        float fallback;
        if (!m_glyphProvider(cp, g, fallback)) { curX += fallback; continue; }
        //   ↑ GlyphProvider 回调:查询字形在 ImGui 字体图集中的 UV 坐标

        // 生成两个三角形(一个矩形),带旋转变换
        // 每个字符 6 个顶点 → 推入 m_textVerts
        curX += g.AdvanceX;
    }
}

GlyphProvider 是注入到 DrawContext 的回调,由 UIManager 在每帧提供:

// UIManager::SyncFonts 中
auto provider = [](uint32_t cp, GlyphInfo& out, float& fallback) -> bool {
    // 从 ImGui::GetFont() 查询字形 UV
    // 填充 GlyphInfo
};

字形 UV 坐标指向 ImGui 字体图集(一张 PNG 纹理),渲染时由 SubmitTextured 采样。

字形坐标系:GlyphInfo 里的坐标归一化到"字高=1"的本地空间,EmitText 乘以 height 和旋转矩阵变换到世界坐标。

路径二:EmitMText(矢量,MTextEntity)

void DrawContext::EmitMText(const Point3& pos, const string& utf8,
                            uint32_t styleId, double height, ...) {
    IFont* font = m_fontResolver(styleId);  // FontResolver 回调
    if (!font) return;

    TextLayoutEngine layout;
    auto result = layout.Layout(utf8, font, height, 1.0, rotation, boxWidth);
    // ↑ 排版引擎输出字形实例列表(每个字符的位置、缩放)

    for (const auto& inst : result.m_glyphs) {
        for (const auto& seg : inst.m_glyph.Lines) {
            // 字形线段 → 变换到世界坐标 → 推入 m_verts(普通线段缓冲)
        }
    }
}

矢量字形是线段集合,直接进入 m_sceneVertices,走普通 Submit(PrimitiveType::Line) 渲染,不需要纹理。


IFont:统一字体接口

class IFont {
    virtual Glyph       GetGlyph  (uint32_t codepoint) = 0;  // 获取字形几何
    virtual double      GetAdvance(uint32_t codepoint) = 0;  // 字符前进宽度(排版用)
    virtual double      GetHeight () const             = 0;  // 字体设计高度(基准单位)
    virtual uint64_t    GetFontId () const             = 0;  // 全局唯一 ID(GlyphCache 键)
    virtual const char* GetName   () const             = 0;
    virtual bool        HasGlyph  (uint32_t codepoint) = 0;
};

所有字体类型(SHXFont、TTFFont、SHXCompositeFont)都实现此接口,EmitMText 只调用 IFont,不关心底层是 SHX 还是 TTF。

Glyph:统一输出结构

struct Glyph {
    vector<Line>     Lines;      // 字形轮廓线段(SHX/TTF 均输出到此)
    vector<Triangle> Triangles;  // 填充三角形(TTFFont 的填充模式,SHX 不使用)
    bool   Filled   = false;
    double Advance  = 0.0;       // 水平前进量(字体单元空间)
    double MinX, MinY, MaxX, MaxY;  // 包围盒(排版/裁剪用)
};

SHX 和 TTF 最终都输出 Glyph,EmitMText 遍历 Glyph.Lines 生成世界坐标线段。


SHX 字体

SHX(Shape eXtended)是 AutoCAD 定义的二进制矢量字体格式,用字节码描述字形的笔画路径。MiniCAD 用 SHXParser 解析文件,用 SHXVM 执行字节码。

SHX 的三种子类型

SHXParser 解析时先识别文件头:

类型Kind典型文件说明
Shapes西文形状字体simplex.shx、tssdeng.shxASCII 字符,每个字符一个 shape
BigFont大字体(CJK)TSSDCHN.SHX、gbcbig.shx双字节编码(GBK),覆盖中日韩字符
UnifontUnicode 统一字体unifont.shx每个字符用 2 字节 Unicode 编号

西文字体(Shapes)用 1 字节编码(0x00–0xFF),大字体(BigFont)用 GBK 双字节编码(cp ≥ 0x80 的字符走大字体查询)。

SHXParser:加载与字形表

SHXFont::LoadFont()
  → SHXParser::Load(filePath)
     → 读取二进制文件到 m_fileData
     → ClassifyHeader:读第一行识别 Shapes / BigFont / Unifont
     → ParseShapesContent / ParseBigFontContent / ParseUnifontContent
         → 建立 m_shapes 表:shape code → RawShape{data*, len}
           (RawShape 只存指针和长度,不解析字节码)
     → ExtractFontMeta:从 shape code 0 读字体名和基准高度

m_shapes 是一个哈希表,key 是 shape code,value 是字节码在文件缓冲区中的位置和长度。字节码不预先解析,只在 BuildGlyph() 第一次被调用时才执行。

SHXVM:字节码虚拟机

SHXParser::BuildGlyph() 把对应 shape 的字节码交给 SHXVM::Execute() 执行:

SHXVM 执行上下文(SHXContext):
  double x, y    — 当前笔的世界坐标(SHX 单元空间)
  bool pen       — 笔落下(true)= 移动时产生线段;抬起(false)= 只移动
  double scale   — 当前缩放因子(0x03/0x04 指令修改)
  vector<Pos> posStack  — 位置栈(push/pop 用于子形状调用)
  vector<Line> lines    — 输出线段列表

关键字节码指令:

字节含义
0x00形状结束
0x01抬笔(pen up)
0x02落笔(pen down)
0x03缩小:scale /= 2
0x04放大:scale *= 2
0x05压栈当前位置
0x06弹栈恢复位置
0x07调用子形状(subshape)
0x08X,Y 偏移移动(2 字节有符号增量)
0x09多段折线移动(直到 0,0 结束)
0x00A–0x0F8 方向等距移动(每步距离由 scale 决定)
0x00C弧线(圆心偏移 + 起止角度)
0x00E斜线弧(bulge arc,用于字形圆角)

执行过程:VM 顺序读取字节,遇到移动指令就更新 (x, y),pen == true 时生成 Line{旧位置, 新位置};遇到弧线指令调用 EmitOctantArc / EmitBulgeArc 将弧细分为若干线段;遇到 0x07 递归调用子形状(有深度限制,防止无限递归)。

SHX 字符编码路由

SHXFont::ResolveShxKey(codepoint) 把 Unicode 码点映射到 SHX shape code:

  • 英文字体(Shapes):ASCII 范围内直接用码点作 shape code
  • 大字体(BigFont):codepoint → GBK 编码 → shape code(通过 EncodingGBK 查表)
  • Unifont:直接用 Unicode 码点

这就是为什么 SHXCompositeFont 按 cp < 0x80 / cp >= 0x80 路由:ASCII 字符只有英文字体有,非 ASCII 字符(中文)只有大字体有。


TTF 字体

TTF(TrueType Font)用贝塞尔曲线描述字形轮廓。MiniCAD 用 stb_truetype(单头文件库)解析 TTF,将曲线展平为折线段输出到 Glyph.Lines。

// TTFFont 注释
// Web 端(MINICAD_WEB)不使用本类,全部走 WebFontAtlas 纹理路径。
class TTFFont : public IFont {
    void*  m_stbFont = nullptr;  // stbtt_fontinfo*,用 void* 隐藏,避免暴露大型头文件
    double m_scale   = 1.0;      // stbtt 坐标 → 归一化(字高=1)的比例因子
    vector<uint8_t> m_fileData;  // TTF 文件字节(必须保持生命周期,stbtt 持有指针)
};

贝塞尔曲线展平

TTF 字形轮廓由二次贝塞尔曲线(glyf 表)和三次贝塞尔曲线(CFF 表)组成,TTFFont 用自适应细分将其展平为线段:

// 二次贝塞尔:P0→控制点C→P1
void FlattenQuad(double x0, double y0,
                 double cx, double cy,
                 double x1, double y1,
                 double tolerance,
                 vector<Line>& out) const;
// 三次贝塞尔:P0→C1→C2→P1
void FlattenCubic(double x0, double y0,
                  double c1x, double c1y,
                  double c2x, double c2y,
                  double x1,  double y1,
                  double tolerance,
                  vector<Line>& out) const;

自适应细分原则:递归二分,当控制点与连线的偏差小于 tolerance 时停止,输出一条线段。tolerance 越小曲线越平滑,线段越多。

坐标归一化

stb_truetype 输出的坐标单位是字体设计单位(通常 2048 或 1000),TTFFont 用 m_scale 换算为"字高=1"的归一化坐标,与 SHXFont 输出坐标系一致,TextLayoutEngine 不需要区分来源。

TTFFont 的填充模式

void BuildFill(Glyph& g);
void ScanlineFill(const vector<Line>& lines, vector<Triangle>& out);

对于需要填充的字形(如粗体、实心汉字),ScanlineFill 用扫描线算法把轮廓线段转换为三角形列表,输出到 Glyph.Triangles。渲染时 PrimitiveType::Triangle 填充。

Web 端不用 TTFFont

// TTFFont.h 注释
// Web 端(MINICAD_WEB)不使用本类,全部走 WebFontAtlas 纹理路径。

Web 端所有文字(包括原本走矢量路径的 MTextEntity)统一通过浏览器字体渲染到 Canvas/WebGL 纹理图集,不使用 stb_truetype。


SHXCompositeFont:主字体 + 大字体组合

class SHXCompositeFont : public IFont {
    shared_ptr<IFont> m_mainFont;  // 西文(tssdeng.shx),处理 cp < 0x80
    shared_ptr<IFont> m_bigFont;   // 中文(TSSDCHN.SHX),处理 cp >= 0x80
    unordered_map<uint32_t, IFont*> m_routeCache;  // 路由结果缓存
};

PickFontFor(cp) 路由逻辑:

cp < 0x80  → 先查 mainFont::HasGlyph → 有:用 mainFont
                                      → 无:fallback 到 bigFont
cp >= 0x80 → 先查 bigFont::HasGlyph  → 有:用 bigFont
                                      → 无:fallback 到 mainFont
两个都没有 → 返回 nullptr(字符跳过,不渲染)

NormFactor(font):两个字体的设计高度可能不同(SHX Shapes 通常高度=1,BigFont 可能不同),NormFactor 计算比例,使两种字体的字形在同一排版行内等高。

路由结果缓存到 m_routeCache,每个码点只路由一次,后续直接查缓存。


FontEngine:懒加载与缓存

class FontEngine {
    IFont& Resolve(const FontStyle& style);  // 按样式懒加载,缓存实例
    void   SetFontDir(string dir);           // 设置字体文件搜索目录,默认 "fonts/"
private:
    unordered_map<string, shared_ptr<IFont>> m_cache;  // key = 文件名+参数
    uint64_t m_nextRuntimeFontId = 1;
};

Resolve() 流程:

1. BuildKey(style) → 生成缓存键(文件名 + widthFactor + oblique)
2. 查 m_cache:命中 → 直接返回
3. 未命中 → ResolvePath(fontFile) 拼接完整路径
4. style.isShx ? new SHXFont(...) : new TTFFont(...)
5. 分配 m_nextRuntimeFontId++ 作为 fontId
6. 存入 m_cache,返回

延迟加载:字体文件在第一次被某个 MTextEntity 渲染时才读取,不在启动时全部加载。


GlyphCache:字形缓存

class GlyphCache {
    const Glyph& Get(const GlyphKey& key, function<Glyph()> loader);
    // key = { FontId, Codepoint },首次调用 loader() 构建并缓存
};

struct GlyphKey {
    uint64_t FontId;     // IFont::GetFontId()
    uint32_t Codepoint;  // Unicode 码点(或 SHX shape code)
};

GlyphCache 用于避免每帧重复执行 SHXVM 或 TTF 贝塞尔展平。首次渲染某字符时缓存字形,后续直接返回。内置 mutex,线程安全。

SHXFont 和 TTFFont 各自也有 m_glyphCache(unordered_map<uint32_t, Glyph>)作为第一级缓存,GlyphCache 是可选的全局第二级缓存。


TextLayoutEngine:排版

class TextLayoutEngine {
    LayoutResult Layout(const string& text, IFont* font,
                        double height, double widthFactor,
                        double rotation, double boxWidth,
                        HAlign align = HAlign::Left);

    // 输出
    struct GlyphInstance {
        Glyph        m_glyph;
        Point3       m_position;  // 世界坐标(由 EmitMText 进一步旋转)
        double       m_scale;     // 字高比例
    };
};

排版流程:

1. DecodeLine(text):解析控制码(%%d=°、%%c=Ø、%%p=±、%%nnn=shape 编号)
                     → 码点序列
2. BreakLines:按 boxWidth 换行
   对每个码点:
     font->GetAdvance(cp) × widthFactor → 累加行宽
     超过 boxWidth → 换行
3. 对每行按 HAlign 对齐计算起始 X
4. 对每个码点:
     font->GetGlyph(cp) → Glyph(含 Lines)
     记录 GlyphInstance{glyph, 当前笔位置, height/font->GetHeight()}
     curX += glyph.Advance × widthFactor × scale
5. 返回 LayoutResult(GlyphInstance 列表)

EmitMText 收到 LayoutResult 后,再对每个 GlyphInstance.m_glyph.Lines 中的线段施加旋转变换(rotation 角度),最终推入 m_verts。


FontSystem:字体系统状态机

class FontSystem {
    enum class State { Uninitialized, Initializing, Ready, Shutdown };

    void Initialize();         // 创建 FontEngine,注册 Standard 样式
    void Shutdown();
    bool IsReady() const;

    FontStyleId RegisterStyle(FontStyle style);       // 注册新样式,返回 ID
    const FontStyle* FindStyle(const string& name);
    IFont& ResolveFont(FontStyleId id);               // 懒加载,首次调用时加载字体文件
    void PreloadDefaultFonts();                       // 预加载探索者 SHX 字体
};

状态约束:任何方法调用前必须 Initialize(),否则抛 runtime_error("FontSystem not initialized")。

内置样式

// InitDefaultStyles() 中注册
FontStyle standard;
standard.id       = kStandardStyleId;  // = 1
standard.name     = "Standard";
standard.fontFile = "simplex.shx";     // 内置西文 SHX 字体
standard.isShx    = true;

kStandardStyleId = 1 是保留的内置样式,所有 MTextEntity 默认使用此样式。

探索者字体(PreloadDefaultFonts)

void PreloadDefaultFonts() {
    auto mainFont = make_shared<SHXFont>("tssdeng", "fonts/tssdeng.shx", 100);  // 英文
    auto bigFont  = make_shared<SHXFont>("tssdchn", "fonts/TSSDCHN.SHX",  101); // 中文
    m_shxCompositeFont = make_unique<SHXCompositeFont>(
        "tssdeng+tssdchn", mainFont, bigFont, 200);  // 英中合体
}

SHXCompositeFont 是组合字体:查字形时先查英文字体,找不到再查中文字体。这是 CAD 工程字体(探索者)的标准做法。

字体文件路径:

  • Windows:相对于 .exe 所在目录的 fonts/ 文件夹
  • Web:由 --preload-file=assets/fonts@/fonts 打包进 WASM 虚拟文件系统的 /fonts/ 路径

FontStyle 与跨文档共享

FontStyle 结构:

struct FontStyle {
    using FontStyleId = uint32_t;
    FontStyleId  id       = 0;
    string       name;
    string       fontFile;
    bool         isShx    = false;
};

FontSystem 由 DocumentManager 持有,所有文档共用一套字体样式。每个 MTextEntity 存储 styleId(整数),运行时通过 FontResolver 回调查找实际的 IFont*:

// DrawContext 中的 FontResolver 回调
auto resolver = [&docManager](uint32_t styleId) -> IFont* {
    const FontStyle* style = docManager.FindFontStyle(styleId);
    if (!style) return nullptr;
    return &docManager.GetFontSystem().ResolveFont(*style);
};

TextInputRequest 流程

文字工具分两步:先在视口确定插入点,再弹出输入框输入内容。

步骤一:TextTool 设置请求

// TextTool::OnInput 中,用户左键点击后:
auto& req     = m_ctx->editor.GetTextInputRequest();
req.Active    = true;
req.InsertPos = clickedWorldPos;
req.Height    = fontSystem.GetDefaultTextHeight();

步骤二:UI 层弹出输入框

Windows(UIManager 每帧检查):

if (editor.GetTextInputRequest().Active) {
    DrawTextInputPopup(editor);  // ImGui 弹窗,用户输入后调用 SubmitTextInput
}

Web(JS 层检查,HTML input 元素):

// WebMain.cpp 导出函数,由 JS 调用
EMSCRIPTEN_KEEPALIVE void MiniCAD_SubmitText(const char* text, float height) {
    g_editor.GetTextInputRequest().Height = height;
    g_editor.SubmitTextInput(string(text));
}
EMSCRIPTEN_KEEPALIVE void MiniCAD_CancelText() {
    g_editor.SubmitTextInput("");  // 空字符串 = 取消
}

步骤三:SubmitTextInput 创建实体

void Editor::SubmitTextInput(const string& utf8Text) {
    auto& req = m_textRequest;
    if (!req.Active) return;
    req.Active = false;

    if (utf8Text.empty()) return;  // 取消

    auto id   = m_doc->GetScene().NextObjectID();
    auto text = make_unique<TextEntity>(id, req.InsertPos, utf8Text,
                                        req.Height, req.Rotation);
    text->SetAttr(/* 当前图层属性 */);

    auto cmd = make_unique<AddEntityCommand>(std::move(text));
    m_doc->GetCommandStack().Execute(std::move(cmd), m_doc->GetScene());
}

MTextInputRequest 与此相同,额外有 BoxWidth(文本框宽度)字段。


文字编辑(已有文字)

双击已有 TextEntity 时,TextTool 设置:

req.EditTargetId = existingEntity->GetID();
req.InitialText  = existingEntity->GetText();

提交时走 EditTextCommand(保存旧文本,Undo 时还原),而不是 AddEntityCommand。


路径对比总结

TextEntity(单行):
  Draw() → EmitText() → DrawContext::m_textVerts → ViewState.TextScene
  → SubmitTextured(fontTexture) → ImGui 字体图集采样 → GPU 渲染纹理四边形

MTextEntity(多行):
  Draw() → EmitMText() → FontResolver → IFont::GetGlyph() → TextLayoutEngine
  → 字形线段 → DrawContext::m_verts → ViewState.Scene
  → Submit(PrimitiveType::Line) → GPU 渲染线段

拓展练习

  1. SHX 字节码:打开 SHXVM.h,找到 0x08 指令(X,Y 偏移移动)。如果当前 scale = 2.0,指令字节是 {0x08, 0x03, 0x04},笔会移动多少世界单位?pen = true 时会产生几条线段?
  2. TTF 展平:FlattenQuad 的 tolerance 参数控制精度。改成很大的值(如 1.0)会有什么视觉效果?改成很小的值(如 0.0001)有什么代价?
  3. SHXCompositeFont 路由:输入汉字"A"(其 Unicode 码点 0x41 < 0x80),PickFontFor(0x41) 走哪条分支?"中"(0x4E2D >= 0x80)呢?如果大字体里有英文字形,会影响"A"的路由吗?
  4. GlyphCache 键设计:GlyphKey 同时包含 FontId 和 Codepoint,为什么要带 FontId?只用 Codepoint 作为键会有什么问题?
  5. Web vs Windows:TTFFont.h 注释说 Web 端不使用此类。找到 MTextEntity 在 Web 端是否能正常渲染——如果能,走的是哪条路径?如果不能,会有什么表现?
  6. TextLayoutEngine 控制码:DecodeLine 处理 %%d(度数符号°)。找到它解析后输出的 Unicode 码点是什么(0x00B0),再追踪这个码点在 SHXCompositeFont 里走哪个字体。