普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月17日首页

精读GitHub - swift-markdown-ui

2025年11月17日 01:13

一、项目介绍

项目地址:github.com/gonzalezrea…

swift-markdown-ui (也称为 MarkdownUI) 是一个用于在 SwiftUI 中显示和自定义 Markdown 文本的开源库。

其主要特性如下:

  1. 强大的 Markdown 支持

它兼容 GitHub 风格的 Markdown 规范(GitHub Flavored Markdown Spec),基本支持所有类型 Markdown 元素,如:普通文本、标题(H1、H2 等)、图片、链接、有序无序列表、任务(Task)、引用、代码块、表格、分割线、加粗斜体等文本样式

  1. 强大的自定义能力

提供了强大的主题(Theming)功能,让开发者可以精细的自定义 Markdown 样式,支持针对特定标签样式(如代码块、链接等)进行覆盖和修改

  1. 易用性

可以直接通过一个 Markdown 字符串来创建一个 Markdown 视图,也可以通过 MarkdownContentBuilder,使用类似 SwiftUI 的 DSL 来构建 Markdown 内容

该项目自 2021 年起,star 数一路飙升,到现在已斩获 3.6K 的 star: 在这里插入图片描述

二、使用介绍

使用方式很简单,可以直接传入通过 Markdown string 构造 UI:

struct TextView: View {
  let content = """
  Hello World
  # Heading 1
  ## Heading 2
  ### Heading 3
   """

  var body: some View {
    DemoView {
      Markdown(self.content)
    }
  }
}

可以通过markdownTextStyle覆盖默认主题样式,甚至通过markdownTheme完全传入一个新的主题:

struct TextView: View {
  let content = """
  Hello World
  # Heading 1
  ## Heading 2
  ### Heading 3
   """

  var body: some View {
    DemoView {
      Markdown(self.content)
      .markdownTheme(CustomTheme())
      
      Markdown(self.content)
      .markdownTextStyle(\.code) {
        FontFamilyVariant(.monospaced)
        BackgroundColor(.yellow.opacity(0.5))
      }
      .markdownTextStyle(\.emphasis) {
        FontStyle(.italic)
        UnderlineStyle(.single)
      }
      .markdownTextStyle(\.strong) {
        FontWeight(.heavy)
      }
    }
  }
}

也可以通过 MarkdownContentBuilder,使用 DSL 的方式构造 UI:

var body: some View {
  Markdown {
    Heading(.level2) {
      "Try MarkdownUI"
    }
    Paragraph {
      Strong("MarkdownUI")
      " is a native Markdown renderer for SwiftUI"
      " compatible with the "
      InlineLink(
        "GitHub Flavored Markdown Spec",
        destination: URL(string: "https://github.github.com/gfm/")!
      )
      "."
    }
  }
}

更多使用方式,可以参考官方 Demo:

三、架构分析

Sources/MarkdownUI/
├── Parser/           # Markdown 解析器
├── DSL/              # 领域特定语言(构建器)
├── Renderer/         # 渲染器
├── Theme/            # 主题系统
├── Views/            # SwiftUI 视图组件
├── Extensibility/    # 扩展性支持(图片提供者、语法高亮)
├── Utility/          # 工具函数
└── Documentation.docc/ # 文档

swift-markdown-ui 的目录结构如上,主要分为四大块:

  1. DSL:Markdown 构建器,提供 MarkdownContentBuilder,支持声明式语法构造 Markdown
  2. Parser:解析器,调用 cmark-gfm 将 Markdown 字符串解析成 BlockNode、InlineNode 节点
  3. Renderer & Views:渲染器,根据解析的节点类型渲染成对应的样式
  4. Theme:主题系统,提供强大的样式覆盖和自定义主题能力

整体流程如下: 在这里插入图片描述

架构分层如下: 在这里插入图片描述

四、源码分析

前面讲了大致的流程图,下面是详细的输入输出及处理过程: 在这里插入图片描述

下面我们将分别对解析、渲染、样式系统进行拆解。

4.1 Markdown 解析

使用三方库 cmark-gfm 进行 Markdown 解析,cmark-gfm 是从标准的 CommonMark 解析器 cmark fork 出来的一个扩展分支,由 GitHub 官方维护,除了 CommonMark 的标准语法外,还支持表格、删除线、任务(Task)、自动链接识别(AutoLink)等特性,通过插件的方式注入。

如下,是使用 cmark-gfm 解析的核心逻辑: 在这里插入图片描述

cmark-gfm 的解析原理是将 Markdown 字符串解析成语法树,外部可以通过遍历语法树来处理每一个节点,Markdown 的语法树可以通过网站 spec.commonmark.org/dingus/ 查看。

如下,一段简单的 Hello World 文本,对应的语法树(AST)如右图,通过 cmark-gfm 我们就能逐级访问 document -> paragarph -> text

在这里插入图片描述 再来看一个稍复杂一点的列表的例子: 在这里插入图片描述

在 swift-markdown-ui 项目中,会将 Markdown 的语法树节点映射成 BlockNode 和 InlineNode,有前端经验的小伙伴应该比较容易理解,BlockNode 对应块级元素,如段落(paragraph),列表(list、item)等,InlineNode 对应行内元素,如文本、图片、链接等

enum BlockNode: Hashable {
  case blockquote(children: [BlockNode])
  case bulletedList(isTight: Bool, items: [RawListItem])
  case numberedList(isTight: Bool, start: Int, items: [RawListItem])
  case taskList(isTight: Bool, items: [RawTaskListItem])
  case codeBlock(fenceInfo: String?, content: String)
  case htmlBlock(content: String)
  case paragraph(content: [InlineNode])
  case heading(level: Int, content: [InlineNode])
  case table(columnAlignments: [RawTableColumnAlignment], rows: [RawTableRow])
  case thematicBreak
}

enum InlineNode: Hashable, Sendable {
  case text(String)
  case softBreak
  case lineBreak
  case code(String)
  case html(String)
  case emphasis(children: [InlineNode])
  case strong(children: [InlineNode])
  case strikethrough(children: [InlineNode])
  case link(destination: String, children: [InlineNode])
  case image(source: String, children: [InlineNode])
}

如下为详细的映射过程:最终解析完成的结果就是一个 [BlockNode]数组

BlockNode解析InlineNode解析

4.2 Markdown 渲染

渲染过程分为 Block 节点处理和 Inline 节点处理。

BlockNode 处理流程如下: 在这里插入图片描述

InlineNode 处理流程如下: 在这里插入图片描述

关键代码: BlockNode 节点渲染InlineNode 节点渲染

每一个 Block 节点都是一个单独的自定义 View,文本节点使用 AttributedString 拼接各种加粗斜体等样式,最终由 Label 进行渲染。

下面我们挑几个难点进行讲解。

4.2.1 文本的加粗斜体下划线删除线样式是怎么实现的

在这里插入图片描述

这些都是使用 iOS 系统能力,配置 AttributeContainer 实现的,支持配置的样式如下: 在这里插入图片描述

4.2.2 引用的样式是怎么实现的

在这里插入图片描述

如上,引用有背景,左边有边框,背景色支持内容撑开,这是怎么做到的?

上面我们有提到每个 Block 节点都是一个单独的自定义 View,引用也是一个自定义 View,如下使用 HStack 将左边框和内容并排,高度靠内容撑开,关键配置是.fixedSize(horizontal: false, vertical: true),其中horizontal: false表示水平方向允许扩展,受父视图宽度约束影响,vertical: true表示垂直方向固定,完全靠内容撑开。

在这里插入图片描述 以此类推,代码块、任务等的样式也可以靠自定义 View 实现。

4.2.3 无序列表序号和任务标识是怎么实现的

在这里插入图片描述

无序列表前面的小圆点/方块,以及任务前面的已完成、待完成标识是怎么实现的呢。

主要代码如下,可以看出是通过 SF Symbols,即系统自带的符号 icon 实现的 无序列表序号任务(Task)

4.2.4 表格的样式是怎么实现的

在 Parser 阶段,table 会被解析成多行结构

enum BlockNode: Hashable {
  ...
  case table(columnAlignments: [RawTableColumnAlignment], rows: [RawTableRow])
}
enum RawTableColumnAlignment: Character {
  case none = "\0"
  case left = "l"
  case center = "c"
  case right = "r"
}
struct RawTableRow: Hashable {
  let cells: [RawTableCell]
}
struct RawTableCell: Hashable {
  let content: [InlineNode]
}

渲染时使用 SwiftUI 中的 Grid 布局实现:Grid 布局天然支持了同行等高、同列等宽、跨行跨列(合并单元格)等特性,不需要复杂配置就能实现表格的效果。 在这里插入图片描述

但是 Grid 布局也有一些局限:

  • Grid 布局不支持滚动,如下当列很多时内容会很窄;更好的做法是嵌套在 ScrollView 中,进行横向滚动
  • 大数据量时可能有性能问题:Grid 布局是非懒加载的,也不存在 Cell 复用,在大数据量时 FPS、内存可能都是挑战 在这里插入图片描述

4.3 自定义样式 & Theme 系统

如下是样式系统的架构图:

在这里插入图片描述

swift-markdown-ui 提供了 basic、github、docC 三种内置主题,在这三个主题的基础上,支持开发者覆盖默认配置,也可以完全自定义一个新的主题传入。

样式通过 SwiftUI 的 Environment,可以很方便的实现自动注入和父子视图数据传递:

在这里插入图片描述

五、广告位

每周精读一个开源项目,文章首发公众号「非专业程序员 Ping」【精读 GitHub Weekly】专集,欢迎订阅 & 投稿!

昨天以前首页

Rust RefCell 多线程读为什么也panic了?

2025年11月15日 16:36

这是最近实战中遇到的一个小知识点,没理解之前觉得「不可能」,反应过来之后,觉得自己很蠢🤣,借本文记录下。

看一段复现代码:

struct MyRefCell<T>(RefCell<T>);

unsafe impl<T> Sync for MyRefCell<T> {}

fn main() {
    let shared = Arc::new(MyRefCell(RefCell::new(0usize)));

    let mut handles = Vec::new();
    for i in 0..100 {
        let s = shared.clone();
        handles.push(thread::spawn(move || {
            thread::sleep(Duration::from_millis(10 * (i % 3) as u64));
            let r = s.0.borrow();
            let r = s.0.borrow_mut();
            println!("thread {} read {}", i, *r);
        }));
    }

    for h in handles {
        let _ = h.join();
    }

    println!("done");
}

多线程读一个RefCell封装的变量,却发生了panic,原因是:**already mutably borrowed: BorrowError**

即 RefCell修饰的变量在borrow时检测到已经borrow_mut了,但是代码里其实没有borrow_mut的地方,就很神奇。

另一个迷惑的地方是,多线程读变量居然也是不安全的,也会panic。

或许有小伙伴不理解RefCell,这里简单介绍下:

Rust的借用检查一般在编译期,即一个可变借用(&mut T)同时只能存在一个,不可变借用(&T)和可变借用不能共存;但在实际场景中,借用关系往往很难在编译期满足,这时候就可以用RefCell,RefCell提供两个操作符:borrow()borrow_mut(),支持在运行时检查借用关系,如果运行时违法借用规则,会panic。

在我们的代码中,其实没有违反借用规则,因为我们只有不可变借用,但还是panic了,为什么呢?

原因在于RefCell borrow()底层实现不是原子的,看着是多线程读,其实内部存在写操作,变成了隐藏的多线程写,如下:

可以看出borrow()borrow_mut()内部实现存在写操作,多线程访问时,flag 状态管理可能出错,导致panic。

同样的问题,在Swift中,如果是多线程读一个变量,是安全的吗?

答案我们将在公众号「非专业程序员Ping」的下一期文章揭晓,欢迎订阅交流!

Vibe Coding 实战!花了两天时间,让 AI 写了一个富文本渲染引擎!

2025年11月12日 23:58

一、先上效果图

最近动手实践了下 Vibe Coding,想尝试一行代码不写,纯通过 Prompt 让 AI 写了一个富文本渲染引擎

整体花了两天时间不到,效果如上图,支持的特性有:

  • 类似前端的 Block、InlineBlock、Inline 布局
  • 文本样式:加粗、斜体、下划线、删除线,前景色,背景色,同一行不同字体大小混排等
  • Attachment:图文混排或插入自定义 View 等
  • 异步排版和算高:基于 CoreText API,支持子线程布局算高,主线程渲染
  • 单测覆盖

项目用的 Claude AI,差不多耗费了 50$(是真的贵!但也是真的强!),本文将记录整个过程和一些经验总结。

二、过程记录

2.1 Claude 安装和项目初始化

Claude 安装和使用在网上有很多教程,细节这里不再赘述,推荐直接使用 VSCode 的 Claude AI 插件;后文「经验总结」部分也会总结 Claude AI 的常用命令,感兴趣可以直接跳转。

首先,我们需要新建一个空的 iOS 项目和富文本渲染引擎的 pod(这里我们叫 RichView),创建完成之后在 VSCode 中打开,点击右上角 Claude AI 的图标开启会话,输入/init 命令初始化工程。

/init命令的作用是让 Claude 理解整个项目,这是在项目中使用 Claude 的第一步,只需要执行一次就好。

/init会在根目录下自动创建一个CLAUDE.md文件,这个文件可以理解成全局上下文,即每次新开 Claude 会话都会自动加载其中的内容,我们可以在这里记录一些如修改历史、全局说明等内容。

2.2 技术选型、架构

让 AI 写代码,和我们自己写代码基本类似,不过是将我们的思路转换成 Prompt 告诉 AI。

编码之前需要先确定几件事情:这些确定好之后,我们后续的任务拆分才会更顺利。

1)需要支持哪些 Feature

  • 支持文本样式:加粗、斜体、下划线、删除线,前景色,背景色,同一行不同字体大小混排等
  • 支持 Attachment:图文混排或插入自定义 View 等
  • 支持子线程排版算高
  • 支持单元测试

2)技术选型

自定义富文本渲染引擎,最难的点在于如何实现精确的文本分词排版(原理可以参考从 0 到 1 自定义文字排版引擎:原理篇),iOS 有内置的 CoreText API(见链接)用于文本分词排版,当然也可以基于开源的跨端排版引擎 HarfBuzz(见链接)进行处理。

我们这里不需要跨端,因此选择 CoreText 作为方案选型。

官方封装的 NSAttributedString 当然也能做这件事情,但是从工程实践看,NSAttributedString 在扩展性(比如支持列表、表格等自定义布局)、使用方便性,以及长文本的性能方面不尽如人意。

3)技术架构

文本分词之后,还需要进行布局排版,为方便后续拓展布局,我们这里参考前端的布局模型,引入 Block、InlineBlock、Inline 的概念。

同时参考浏览器的布局渲染过程,引入三棵树的概念:

  • ElementTree:用户输入,整个富文本可以通过一颗 ElementTree 来表示
  • LayoutTree:负责布局排版,会在这一层处理好文本的分词、图文混排时各自的位置等
  • RenderTree:负责渲染,这一层接收布局完成的结果,进行最终的上屏绘制

敲定技术选型、技术架构之后,我们就可以按思路拆分子任务了。

2.3 子任务:ElementTree

由于我们参考了前端的布局模型,因此我们需要告诉 AI 在 CSS 中 Block、InlineBlock、Inline 的布局规范,这个在 MDN 中可以直接摘录,当然也可以直接让 AI 帮我们生成(如上图)。

接着,我们需要告诉 AI 怎么构建 ElementTree,也就是上图所示 Prompt。

最后,我们就可以让 AI 参照 Prompt,生成 ElementTree 了。

ElementTree 生成完成后,我们发现遗漏了单测环节,继续完善 ElementTree 的 Prompt,然后明确告诉 AI xx 文件新增了 xx 任务,让 AI 继续完成任务,如下图:

ElementTree 的创建还算比较顺利,AI 理解也比较到位,生成的代码基本符合预期。

2.4 子任务:LayoutTree

同样,我们定义好 Prompt,让 AI 生成 LayoutTree。

LayoutTree 的生成不太顺利,而且从最后的测试效果看也有很多 Bug,主要如下:

  • AI 将绘制相关逻辑也加到了 LayoutTree 中,但预期绘制是单独的 RenderTree
  • 布局问题:InlineBlock 无法整体换行,多个 Inline 在同一行时被换行展示,margin、padding 不生效等
  • 对齐问题:同一行包含不同字号的文本时,对齐方式不对
  • attachment 无法显示

2.5 子任务:RenderTree

由于 LayoutTree 这个底层基础没扎实,RenderTree 的搭建也不顺利,RenderTree 的 Prompt 如上。

2.6 BugFix

至此,AI 生成了初版的富文本渲染引擎,接下来就是让 AI 写个 Demo 试用一下,在使用过程中,发现了很多上面罗列的 Bug,针对这些 Bug,也可以让 AI 来修复:

在让 AI 修 Bug 过程中,也踩了一些坑,参见下文经验总结。

三、一些经验总结

3.1 Claude AI 常用命令

  • /init:项目初始化,第一次使用 Claude AI 时执行,每个项目只需要执行一次即可;会生成一个CLAUDE.md文件,这是项目的全局上下文,每次新建 Claude 会话时,会自动读取其中的内容;可以在CLAUDE.md文件中补充修改历史、全局说明等
  • @:可以输入@来添加文件到会话窗口,将文件作为上下文给 AI
  • /exit:关闭当前会话
  • /clear:清除当前会话上下文,和退出会话然后新开一个会话效果一样
  • /compact:压缩和总结当前会话上下文,和/clear的区别是,/compact会将当前会话上下文总结后作为当前会话的新上下文,/clear会直接清除所有上下文
  • /resume:显示和恢复历史上下文
  • 自定义 command:可以将通用的 Prompt 做成自定义 command,文件位置在.claude/commands/;还可以通过 $ARGUMENUTS 来接收自定义参数

  • /agents:有的任务比较复杂,或上下文较多,那可以拆分成多个 agents 进行组合,比如写业务逻辑 -> 构建单元测试 -> CI/CD 等,可以拆分多个 agents 组合使用

  • 会话模式:在最新版本的 Claude AI 插件中,除了之前命令行风格的 GUI 以外,还提供了会话框风格的 GUI,切换会话模式,查看历史会话等会更方便;如下,会话模式可以在输入框左下角切换

    • Edit automatically:AI 根据输入 Prompt 进行理解并直接编辑文件,一般使用该模式即可

    • Plan mode:AI 根据输入 Prompt 列出修改计划,你可以进一步校验和修改 Plan

    • Ask before edits:AI 修改文件前询问

  • MCP:常用的 MCP 是context7context7是用于帮助 AI 查找最新文档的,避免使用过时 API

3.2 经验总结

不得不感叹,AI 编程实在太强大了,相信在不久的将来,一个只会写 Prompt 的非专业程序员,也能完整交付一个 App 了。

让 AI 编程,并不是说给一句话就能让 AI 完成代码,各种细节还是需要人来提前想清楚,毕竟最终维护代码和解决问题的还是我们自己,AI 只是帮我们提效和扩展思路的工具;有句话总结的蛮好:你可以将 AI 视为一个非常聪明,甚至资深,但是没有业务经验的程序员。

下面我想总结下最近实战的一些经验,希望对各位有帮助:

1)架构设计需要提前规划好,尽量想清楚细节

谋定而后动,不管是我们自己写代码,还是让 AI 写代码,我觉得提前想好要做什么,怎么做是非常重要的。

架构设计好了,细节想清楚了,那怎么拆分子任务,其实也就明确了。

2)任务拆分越小越好,上下文越明确越好

AI 最适合做有明确输入输出的事情,给的上下文越明确,AI 产生幻觉的概率越低,输出结果也会越准确。

当然,如果是输入输出明确的任务,也可以让 AI 先输出测试用例,测试用例人工检测完备之后,再让 AI 编码也是可以的(测试驱动开发/TDD)。

3)每一项目任务做好之后再进行下一项任务

基础不牢,地动山摇!

推荐打磨好每一项子任务再继续下一项任务,否则千里之堤毁于蚁穴,每个任务都留一点坑,最终可能带来灾难性的结果!

另外,单测是个好东西,对每项任务补齐单测,可以有效防止后续 AI 改出问题。

4)善用 Git,防止代码污染

Claude 在 Edit automatically 模式下会直接修改文件,为了防止污染其他代码,每次让 AI 修改前尽量保证工作区干净,这样也能方便我们 Review 代码。

5)写 Prompt 尽量用明确的词汇,不要表意不清

比如在构建 ElementTree 时,我会明确告诉 AI 要支持哪些 Style,可以有效避免 AI 臆测

与之相反的反例是,在构建 LayoutTree 时,限定不足,导致 AI 自由发挥,最终实现出很多 Bug。

6)善用提示词:think < think hard < think harder < ultrathink

可以在 Prompt 中追加 think hard / think harder 等词汇,来让 AI 进入深度思考,这并不是什么黑魔法,而是 Claude AI 官方认证的,参见:www.anthropic.com/engineering…

实践下来,确实还是有效果的,如下是让 AI 修复文本对齐问题,加了 think hard AI 会更深入理解代码,找到问题原因;当然,这种方式也有弊端,就是会耗费更多的 token(money)👺

7)善用 /compact /clear命令,减少模型幻觉

如果不主动清除,Claude AI 会话中的上下文是会一直保存的,当一个会话中问答轮次过多,可能会导致 AI 理解不准确(幻觉)。

可以通过/compact/clear命令,来压缩/清除上下文。

一般我在修复有关联性的 Bug 时,会使用/compact命令,这样 AI 就不需要重新理解工程,理解 Bug 了,可以提高效率。

8)BugFix 尽量构造最小可复现 Demo

BugFix 其实也是一个子任务,最小可复现 Demo 减少 AI 的理解负担。

9)及时人工介入,避免在一个问题上死磕

有时候让 AI 修复 Bug 时,可能反复修改都解决不了,这时候大概率是 AI 没有真正理解问题,或者就是输入的 Prompt 有问题,这种情况下就没必要让 AI 死磕问题了,我们可以及时人工介入,避免浪费时间。

10)善用 Plan 模式

在任务拆分时,我们自己可能也没想明白应该怎么做,那可以切换到 Plan 模式,让 AI 和我们一起拆任务。

3.3 Vibe Coding 的一些弊端

1)付费,而且还挺贵!

这是一个挺现实的问题,一些好的模型都挺贵,而且还是消耗的刀乐,国内厂商的模型质量又不尽如人意。

2)编码风格问题 & 扩展性、易用性、鲁棒性不足

AI 写的代码还是挺容易看出来的,感觉很难带有程序员的个人风格,一个明显的表现是会用一些比较少见的 API,虽然,这可能也是 AI 的厉害之处。

另外,AI 在一些函数复用性、扩展性、使用方便性上有时候差强人意,比如 AI 生成代码如下:如果要配置 Element 的 Style,需要不断的调用text.style.xxx,但其实写成链式调用使用起来会更舒服,如下注释部分

let text = TextElement(text: "一、晨光初照")
text.style.color = .red
text.style.font = UIFont.systemFont(ofSize: 17)

// 更好的写法
// text.style.setColor(xxx).setFont(xxx)

鲁棒性方面,AI 不会主动考虑调用场景,比如我虽然告诉了 AI 我要支持子线程布局,但是 AI 生成的代码并不是线程安全的。

当然,上述这些,可以通过完善 Prompt 来部分弥补。

3)问题定位幻觉

有时候让 AI 排查一些 Bug,它无法找到真正的原因,反复修改后还是有问题。

这种情况下,就需要人工介入了,我们可以自己定位问题,再告诉 AI 怎么修改,而不要让 AI 死磕问题,避免浪费时间。

四、贴下源码 & Prompt

github.com/HusterYP/Ri…

内容首发在公众号「非专业程序员Ping」,觉得有用的话,三连再走吧~ (⁎˃ᴗ˂⁎)
富文本相关,你可能感兴趣:

HarfBuzz 实战:五大核心API 实例详解【附iOS/Swift实战示例】

2025年11月1日 21:15

本文概述

本文是 HarfBuzz 系列的完结篇。

本文主要结合示例来讲解HarfBuzz中的核心API,不会面面俱到,只会介绍常用和重要的。

本文是HarfBuzz系列的第三篇,在阅读本文前,推荐先阅读以下两篇文章:

1)第一篇:HarfBuzz概览

2)第二篇:HarfBuzz核心概念

更多内容在公众号「非专业程序员Ping」,此外你可能还感兴趣:

一、hb-blob

1)定义

blob 是一个抽象概念,是对一段二进制数据的封装,一般用来承载字体数据,在HarfBuzz中用 hb_blob_t 结构体表示。

2)hb_blob_create

hb_blob_t 的构造方法,签名如下:表示从一段二进制数据(u8序列)中创建

hb_blob_t *
hb_blob_create (const char *data,
                unsigned int length,
                hb_memory_mode_t mode,
                void *user_data,
                hb_destroy_func_t destroy);
  • data:原始二进制数据,比如字体文件内容
  • length:二进制长度
  • mode:内存管理策略,即如何管理二进制数据,一般使用 HB_MEMORY_MODE_DUPLICATE 最安全,类型如下
模式 含义 优缺点
HB_MEMORY_MODE_DUPLICATE 复制模式,HarfBuzz会将传入的数据完整复制一份到私有内存 优点是不受传入的 data 生命周期影响缺点是多一次内存分配
HB_MEMORY_MODE_READONLY 只读模式,HarfBuzz会直接使用传入的数据,数据不会被修改 优点是无额外性能开销缺点是外部需要保证在 hb_blob_t 及其衍生的所有对象(如 hb_face_t)被销毁之前,始终保持有效且内容不变
HB_MEMORY_MODE_WRITABLE 可写模式,HarfBuzz会直接使用传入的指针,同时修改这块内存数据, 优点同READONLY缺点同READONLY,同时还可能修改数据
HB_MEMORY_MODE_READONLY_MAY_MAKE_WRITABLE 写时复制,HarfBuzz会直接使用传入的指针,在需要修改这块内存时才复制一份到私有内存 优点同READONLY缺点同READONLY,同时还可能修改数据
  • user_data:可以通过 user_data 携带一些上下文
  • destroy:blob释放时的回调

使用示例:

// 准备字体文件
let ctFont = UIFont.systemFont(ofSize: 18) as CTFont
let url = CTFontCopyAttribute(ctFont, kCTFontURLAttribute) as! URL
guard let fontData = try? Data(contentsOf: url) else {
    return
}
// 创建 HarfBuzz Blob 和 Face
// 'withUnsafeBytes' 确保指针在 'hb_blob_create' 调用期间是有效的。
// 'HB_MEMORY_MODE_DUPLICATE' 告诉 HarfBuzz 复制数据,这是在 Swift 中管理内存最安全的方式。
let blob = fontData.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> OpaquePointer? in
    let charPtr = ptr.baseAddress?.assumingMemoryBound(to: CChar.self)
    return hb_blob_create(charPtr, UInt32(fontData.count), HB_MEMORY_MODE_DUPLICATE, nil, nil)
}

3)hb_blob_create_from_file

hb_blob_t 的构造方法,签名如下:表示从文件路径创建

hb_blob_t *
hb_blob_create_from_file (const char *file_name);
  • file_name:文件绝对路径,注意非文件名

使用示例:

let ctFont = UIFont.systemFont(ofSize: 18) as CTFont
let url = CTFontCopyAttribute(ctFont, kCTFontURLAttribute) as! URL
let blob = url.path.withCString { ptr in
    hb_blob_create_from_file(ptr)
}

查看 hb_blob_create_from_file 函数实现,会通过 mmap 的方式来映射字体文件,可以共享系统的字体内存缓存,相比自己读取二进制数据来创建blob来说,这种方式会少一次IO,且内存占用也可能更小(复用系统内存缓存)。

二、hb-face

1)定义

face 表示一个单独的字体,它会解析blob中的二进制字体数据,通过face可以访问字体中的各种table,如GSUB、GPOS、cmap表等,在HarfBuzz中用 hb_face_t 结构体表示。

2)hb_face_create

hb_face_t的构造方法,签名如下:表示从一段字体二进制数据中构造face

hb_face_t *
hb_face_create (hb_blob_t *blob,
                unsigned int index);
  • blob:字体数据
  • index:有的字体文件是一个字体集合(ttc),index表示使用第几个字体数据来创建face;对于单字体文件(ttf)来说,index传0即可

关于字体更多知识可以参考:一文读懂字体文件

3)hb_face_reference

hb_face_t的引用计数 +1

hb_face_t *
hb_face_reference (hb_face_t *face);

3)hb_face_destroy

hb_face_t的引用计数 -1,注意不是直接销毁对象,在HarfBuzz中,所有对象类型都提供了特定的生命周期管理API(create、reference、destroy),对象采用引用计数方式管理生命周期,当引用计数为0时才会释放内存

void
hb_face_destroy (hb_face_t *face);

在实际使用时,需要注意调用顺序,需要保证所有从face创建出的对象销毁之后,再调用hb_face_destroy。

4)hb_face_get_upem

获取字体的upem。

unsigned int
hb_face_get_upem (const hb_face_t *face);

upem 即 unitsPerEm,在字体文件中一般存储在 head 表中,字体的upem通常很大(一般是1000或2048),其单位并不是像素值,而是 em unit,<unitsPerEm value="2048"/> 表示 2048 units = 1 em = 设计的字高,比如当字体在屏幕上以 16px 渲染时,1 em = 16px,其他数值可按比例换算。

5)hb_face_reference_table

从字体中获取原始的table数据,这个函数返回的是table数据的引用,而不是拷贝,所以这个函数几乎没有性能开销;如果对应 tag 的table不存在,会返回一个空的blob,可以通过 hb_blob_get_length 来检查获取是否成功。

hb_blob_t *
hb_face_reference_table (const hb_face_t *face,
                         hb_tag_t tag);

使用示例:

// 构造tag,这里是获取head表
let headTag = "head".withCString { ptr in
    hb_tag_from_string(ptr, -1)
}
let headBlob = hb_face_reference_table(face, headTag);
// 检查是否成功
if (hb_blob_get_length(headBlob) > 0) {
    // 获取原始数据指针并解析
    var length: UInt32 = 0
    let ptr = hb_blob_get_data(headBlob, &length);
    // ... 在这里执行自定义解析 ...
}
// 必须销毁返回的 blob!
hb_blob_destroy(headBlob);

6)hb_face_collect_unicodes

获取字体文件支持的所有Unicode,这个函数会遍历cmap表,收集cmap中定义的所有code point。

void
hb_face_collect_unicodes (hb_face_t *face,
                          hb_set_t *out);

可以用收集好的结果来判断字体文件是否支持某个字符,这在做字体回退时非常有用。

使用示例:

let set = hb_set_create()
hb_face_collect_unicodes(face, set)
var cp: UInt32 = 0
while hb_set_next(set, &cp) == 1 {
    print("code point: ", cp)
}
hb_set_destroy(set)

三、hb-font

1)定义

font 表示字体实例,可以在face的基础上,设置字号、缩放等feature来创建一个font,在HarfBuzz中用 hb_font_t 结构体表示。

2)hb_font_create & hb_font_reference & hb_font_destroy

hb_font_t 的创建、引用、销毁函数,整体同face对象一样,采用引用计数的方式管理生命周期。

3)hb_font_get_glyph_advance_for_direction

获取一个字形在指定方向上的默认前进量(advance)

void
hb_font_get_glyph_advance_for_direction
                               (hb_font_t *font,
                                hb_codepoint_t glyph,
                                hb_direction_t direction,
                                hb_position_t *x,
                                hb_position_t *y);
  • font:指定字体
  • glyph:目标字形
  • direction:指定方向,HB_DIRECTION_LTR/HB_DIRECTION_LTR/HB_DIRECTION_TTB/HB_DIRECTION_BTT
  • x:返回值,advance.x
  • y:返回值,advance.y

这个函数会从 hmtx(横向)或vmtx(纵向)表中读取advance。

一般情况下,我们不需要直接使用这个函数,这个函数是直接查表返回静态的默认前进量,但实际塑形时,一般还涉及kerning等调整,所以一般常用hb_shape()的返回值,hb_shape()返回的是包含字形上下文调整(如kerning)等的结果。

使用示例:

let glyph_A: hb_codepoint_t = 65
var x_adv: hb_position_t = 0
var y_adv: hb_position_t = 0
// 1. 获取 'A' 在水平方向上的前进位移
hb_font_get_glyph_advance_for_direction(font,
                                        glyph_A,
                                        HB_DIRECTION_LTR, // 水平方向
                                        &x_adv,
                                        &y_adv)

4)hb_font_set_ptem & hb_font_get_ptem

设置和获取字体大小(point size),ptem 即 points per Em,也就是 iOS 中的 point size

void
hb_font_set_ptem (hb_font_t *font,
                  float ptem);

这个函数是 hb_font_set_scale() 简易封装,在HarfBuzz内部,字体大小不是用 points 来存储的,而是用一个称为 scale 的 26.6 的整数格式来存储的。

使用示例:

// 设置字体大小为 18 pt
hb_font_set_ptem(myFont, 18.0f);

// 等价于
// 手动计算 scale
int32_t scale = (int32_t)(18.0f * 64); // scale = 1152
// 手动设置 scale
hb_font_set_scale(myFont, scale, scale);

Q:什么是 26.6 整数格式?

"26.6" 格式是一种定点数(Fixed-Point Number)表示法,用于将浮点数转换成整数存储和运算;在 HarfBuzz 中,这个格式用于 hb_position_t 类型(int32_t),用来表示所有的坐标和度量值(如字形位置、前进量等)。

26.6 表示将一个 32 位整数划分为:高26位用于存储整数部分(一个有符号的 25 位整数 + 1 个符号位)+ 低6位用于存储小数部分。

换算规则:2^6 = 64

  • 从「浮点数」转为「26.6 格式」:hb_position_t = (float_value * 64)
  • 从「26.6 格式」转回「浮点数」:float_value = hb_position_t / 64.0

那为什么不直接用整数呢,因为文本布局需要极高的精度,如果只用整数,那任何小于1的误差都会被忽略,在一行文本中累计下来,误差就很大了。

那为什么不直接用浮点数呢,因为整数比浮点数的运算快,且浮点数在不同平台上存储和计算产生的误差还确定。

因此为了兼顾性能和精确,将浮点数「放大」成整数参与计算。

5)hb_font_get_glyph

用于查询指定 unicode 在字体中的有效字形(glyph),这在做字体回退时非常有用。

hb_bool_t
hb_font_get_glyph (hb_font_t *font,
                   hb_codepoint_t unicode,
                   hb_codepoint_t variation_selector,
                   hb_codepoint_t *glyph);
  • 返回值 hb_bool_t:true 表示成功,glyph 被设置有效字形,false 表示失败,即字体不支持该 unicode
  • font:字体
  • unicode:待查询 unicode
  • variation_selector:变体选择符的code point,比如在 CJK 中日韩表意文字中,一个汉字可能有不同的字形(如下图),一个字体可能包含这些所有的变体,那我们可以通过 variation_selector 指定要查询哪个变体;如果只想获取默认字形,那该参数可传 0

在这里插入图片描述

  • glyph:返回值,用于存储 unicode 对应字形

当然,还有与之对应的批量查询的函数:hb_font_get_nominal_glyphs

四、hb-buffer

1)定义

buffer 在HarfBuzz中表示输入输出的缓冲区,用 hb_buffer_t 结构体表示,一般用于存储塑形函数的输入和塑形结束的输出。

2)hb_buffer_create & hb_buffer_reference & hb_buffer_destroy

hb_buffer_t 的创建、引用、销毁函数,整体同face对象一样,采用引用计数的方式管理生命周期。

3)hb_buffer_add_utf8 & hb_buffer_add_utf16 & hb_buffer_add_utf32

将字符串添加到buffer,使用哪个函数取决于字符串编码方式。

void
hb_buffer_add_utf8 (hb_buffer_t *buffer,
                    const char *text,
                    int text_length,
                    unsigned int item_offset,
                    int item_length);
  • buffer:目标buffer
  • text:文本
  • text_length:文本长度,传 -1 会自动查找到字符串末尾的 \0
  • item_offset:偏移量,0 表示从头开始
  • item_length:添加长度,-1 表示全部长度

使用示例:

let buffer = hb_buffer_create()
let text = "Hello World!"
let cText = text.cString(using: .utf8)!
hb_buffer_add_utf8(buffer, cText, -1, 0, -1)

4)hb_buffer_guess_segment_properties

猜测并设置buffer的塑形属性(script、language、direction等)。

void
hb_buffer_guess_segment_properties (hb_buffer_t *buffer);

这个函数一般取第一个字符的属性作为整体buffer的属性,所以如果要使用这个函数来猜测属性的话,需要保证字符串已经被提前分段。

当然也可以手动调用hb_buffer_set_script、hb_buffer_set_language 等来手动设置。

五、hb-shape

hb_shape是HarfBuzz的核心塑形函数,签名如下:

void
hb_shape (hb_font_t *font,
          hb_buffer_t *buffer,
          const hb_feature_t *features,
          unsigned int num_features);
  • font:用于塑形的字体实例,需要提前设置好字体大小等属性
  • buffer:既是输入,待塑形的字符串会通过buffer传入;也是输出,塑形完成后,塑形结果会通过buffer返回
  • features:feature数组,用于启用或禁用字体中的某些特性,不需要的话可以传nil
  • num_features:上一参数features数组的数量

hb_shape 会执行一系列复杂操作,比如:

  • 字符到字形映射:查询cmap表,将字符转换为字形
  • 字形替换:查询 GSUB 表,进行连字替换、上下文替换等
  • 字形定位:查询 GPOS 表,微调每个字形的位置,比如kerning,标记定位,草书连接等

详细的塑形操作可以参考HarfBuzz核心概念

下面重点介绍塑形结果,可以通过 hb_buffer_get_glyph_infos 和 hb_buffer_get_glyph_positions 从buffer中获取塑形结果。

hb_buffer_get_glyph_infos 签名如下:

// hb_buffer_get_glyph_infos
hb_glyph_info_t *
hb_buffer_get_glyph_infos (hb_buffer_t *buffer,
                           unsigned int *length);

typedef struct {
  hb_codepoint_t codepoint;
  uint32_t       cluster;
} hb_glyph_info_t;

hb_buffer_get_glyph_infos 返回一个 hb_glyph_info_t 数组,用于获取字形信息,hb_glyph_info_t 中有两个重要参数:

  • codepoint:glyphID,注意这里不是 unicode 码点
  • cluster:映射回原始字符串的字节索引

这里需要展开介绍下cluster:

  • 在连字 (多对一)情况下:比如 "f" 和 "i" (假设在索引 0 和 1) 被塑形为一个 "fi" 字形。这个 "fi" 字形的 cluster 值会是 0(即它所代表的第一个字符的索引)
  • 拆分 (一对多)情况下:在某些语言中,一个字符可能被拆分为两个字形,这两个字形都会有相同的 cluster 值,都指向那个原始字符
  • 高亮与光标:当我们需要高亮显示原始文本的第 3 到第 5 个字符时,就是通过 cluster 值来查找所有 cluster 在 3 和 5 之间的字形,然后绘制它们的选区

hb_buffer_get_glyph_positions 的签名如下:

hb_glyph_position_t *
hb_buffer_get_glyph_positions (hb_buffer_t *buffer,
                               unsigned int *length);
                               
typedef struct {
  hb_position_t  x_advance;
  hb_position_t  y_advance;
  hb_position_t  x_offset;
  hb_position_t  y_offset;
} hb_glyph_position_t;

hb_buffer_get_glyph_positions 返回一个 hb_glyph_position_t 的数组,用于获取字形的位置信息,hb_glyph_position_t 参数有:

  • x_advance / y_advance:x / y 方向的前进量;前进量指的是绘制完一个字形后,光标应该移动多远继续绘制下一个字形;对于横向排版而言,y_advance 一般是0;需要注意的是 advance 值中已经包含了 kernig 的计算结果
  • x_offset / y_offset:x / y 方向的绘制偏移,对于带重音符的字符如 é 来说,塑形时可能拆分成 e + ´,重音符 ´ 塑形结果往往会带 offset,以保证绘制在 e 的上方

position主要在排版/绘制时使用,以绘制为例,通常用法如下:

// (x, y) 是“笔尖”或“光标”位置
var current_x: Double = 0.0 
var current_y: Double = 0.0

// 获取塑形结果
var glyphCount: UInt32 = 0
let infos = hb_buffer_get_glyph_infos(buffer, &glyphCount)
let positions = hb_buffer_get_glyph_positions(buffer, &glyphCount)

// 遍历所有输出的字形
for i in 0..<Int(glyphCount) {
    let info = infos[i]
    let pos = positions[i]

    // 1. 计算这个字形的绘制位置 (Draw Position)
    //    = 当前光标位置 + 本字形的偏移
    let draw_x = current_x + (Double(pos.x_offset) / 64.0)
    let draw_y = current_y + (Double(pos.y_offset) / 64.0)

    // 2. 在该位置绘制字形
    //    (info.codepoint 就是字形 ID)
    drawGlyph(glyphID: info.codepoint, x: draw_x, y: draw_y)

    // 3. 将光标移动到下一个字形的起点
    //    = 当前光标位置 + 本字形的前进位移
    current_x += (Double(pos.x_advance) / 64.0)
    current_y += (Double(pos.y_advance) / 64.0)
}

六、完整示例

下面我们以 Swift 中调用 HarfBuzz 塑形一段文本为例:

func shapeTextExample() {
    // 1. 准备字体
    let ctFont = UIFont.systemFont(ofSize: 18) as CTFont
    let url = CTFontCopyAttribute(ctFont, kCTFontURLAttribute) as! URL

    // 2. 从字体文件路径创建blob
    let blob = url.path.withCString { ptr in
        hb_blob_create_from_file(ptr)
    }

    guard let face = hb_face_create(blob, 0) else { // 0 是字体索引 (TTC/OTF collections)
        print("无法创建 HarfBuzz face。")
        hb_blob_destroy(blob) // 即使失败也要清理
        return
    }

    // Blob 已经被 face 引用,现在可以安全销毁
    hb_blob_destroy(blob)

    // --- 3. 创建 HarfBuzz 字体对象 ---
    guard let font = hb_font_create(face) else {
        print("无法创建 HarfBuzz font。")
        hb_face_destroy(face)
        return
    }

    // 告诉 HarfBuzz 使用其内置的 OpenType 函数来获取字形等信息
    // 这对于 OpenType 字体(.otf, .ttf)是必需的
    hb_ot_font_set_funcs(font)

    hb_font_set_synthetic_slant(font, 1.0)

    // 设置字体大小 (例如 100pt)。
    // HarfBuzz 内部使用 26.6 整数坐标系,即 1 单位 = 1/64 像素。
    let points: Int32 = 100
    let scale = points * 64
    hb_font_set_scale(font, scale, scale)

    // --- 4. 创建 HarfBuzz 缓冲区 ---
    guard let buffer = hb_buffer_create() else {
        print("无法创建 HarfBuzz buffer。")
        hb_font_destroy(font)
        hb_face_destroy(face)
        return
    }

    // --- 5. 添加文本到缓冲区 ---
    let text = "Hello World!"
    let cText = text.cString(using: .utf8)!

    // hb_buffer_add_utf8:
    // - buffer: 缓冲区
    // - cText: UTF-8 字符串指针
    // - -1: 字符串长度 (传 -1 表示自动计算直到 null 终止符)
    // - 0: item_offset (从字符串开头)
    // - -1: item_length (处理整个字符串)
    hb_buffer_add_utf8(buffer, cText, -1, 0, -1)

    // 猜测文本属性 (语言、文字方向、脚本)
    // 这对于阿拉伯语 (RTL - 从右到左) 至关重要!
    hb_buffer_guess_segment_properties(buffer)

    // --- 6. 执行塑形 (Shape!) ---
    // 使用 nil 特征 (features),表示使用字体的默认 OpenType 特征
    hb_shape(font, buffer, nil, 0)

    // --- 7. 获取塑形结果 ---
    var glyphCount: UInt32 = 0
    // 获取字形信息 (glyph_info)
    let glyphInfoPtr = hb_buffer_get_glyph_infos(buffer, &glyphCount)
    // 获取字形位置 (glyph_position)
    let glyphPosPtr = hb_buffer_get_glyph_positions(buffer, &glyphCount)

    guard glyphCount > 0, let glyphInfo = glyphInfoPtr, let glyphPos = glyphPosPtr else {
        print("塑形失败或没有返回字形。")
        // 清理
        hb_buffer_destroy(buffer)
        hb_font_destroy(font)
        hb_face_destroy(face)
        return
    }

    print("\n--- 塑形结果 for '\(text)' (\(glyphCount) glyphs) ---")

    // --- 8. 遍历并打印结果 ---
    // 'cluster' 字段将字形映射回原始 UTF-8 字符串中的字节索引。
    // 这对于高亮显示、光标定位等非常重要。
    var currentX: Int32 = 0
    var currentY: Int32 = 0

    // 注意:阿拉伯语是从右到左 (RTL) 的。
    // hb_buffer_get_direction(buffer) 会返回 HB_DIRECTION_RTL。
    // HarfBuzz 会自动处理布局,所以我们只需按顺序迭代字形。

    for i in 0..<Int(glyphCount) {
        let info = glyphInfo[i]
        let pos = glyphPos[i]

        let glyphID = info.codepoint // 这是字形 ID (不是 Unicode 码点!)
        let cluster = info.cluster  // 映射回原始字符串的字节索引

        let x_adv = pos.x_advance   // X 轴前进
        let y_adv = pos.y_advance   // Y 轴前进
        let x_off = pos.x_offset    // X 轴偏移 (绘制位置)
        let y_off = pos.y_offset    // Y 轴偏移 (绘制位置)

        print("Glyph[\(i)]: ID=\(glyphID)")
        print("  Cluster (string index): \(cluster)")
        print("  Advance: (x=\(Double(x_adv) / 64.0), y=\(Double(y_adv) / 64.0)) pt") // 除以 64 转回 pt
        print("  Offset:  (x=\(Double(x_off) / 64.0), y=\(Double(y_off) / 64.0)) pt")
        print("  Cursor pos before draw: (x=\(Double((currentX + x_off)) / 64.0), y=\(Double((currentY + y_off)) / 64.0)) pt")

        // 累加光标位置
        currentX += x_adv
        currentY += y_adv
    }

    print("------------------------------------------")
    print("Total Advance: (x=\(currentX / 64), y=\(currentY / 64)) pt")

    // --- 9. 清理所有 HarfBuzz 对象 ---
    // 按照创建的相反顺序销毁
    hb_buffer_destroy(buffer)
    hb_font_destroy(font)
    hb_face_destroy(face)

    print("✅ 塑形和清理完成。")
}

输出结果如下: 在这里插入图片描述

iOS/Swift:深入理解iOS CoreText API

2025年10月19日 21:40

这篇文章是从0到1自定义富文本渲染的原理篇之一,此外你还可能感兴趣:

更多内容可订阅公众号「非专业程序员Ping」,文中所有代码可在公众号后台回复 “CoreText” 获取。

一、引言

CoreText是iOS/macOS中的文字排版引擎,提供了一系列对文本精确操作的API;UIKit中UILabel、UITextView等文本组件底层都是基于CoreText的,可以看官方提供的层级图:

在这里插入图片描述 本文的目的是结合实际使用例子,来介绍和总结CoreText中的重要概念和API。

二、重要概念

CoreText中有几个重要概念:CTTypesetter、CTFramesetter、CTFrame、CTLine、CTRun;它们之间的关系可以看官方提供的层级图:

在这里插入图片描述

一篇文档可以分为:文档 -> 段落 -> 段落中的行 -> 行中的文字,类似的,CoreText也是按这个结构来组织和管理API的,我们也可以根据诉求来选择不同层级的API。

2.1 CTFramesetter

CTFramesetter类似于文档的概念,它负责将多段文本进行排版,管理多个段落(CTFrame)。

CTFramesetter的输入是属性字符串(NSAttributedString)和路径(CGPath),负责将文本在指定路径上进行排版。

2.2 CTFrame

CTFrame类似于段落的概念,其中包含了若干行(CTLine)以及对应行的位置、方向、行间距等信息。

2.3 CTLine

CTLine类似于行的概念,其中包含了若干个字形(CTRun)以及对应字形的位置等信息。

2.4 CTRun

需要注意CTRun不是单个的字符,而是一段连续的且具有相同属性(字体、颜色等)的字形(Glyph)。

如下,每个虚线框都代表一个CTRun:

在这里插入图片描述

2.5 CTTypesetter

CTTypesetter支持对属性字符串进行换行,可以通过CTTypesetter来自定义换行(比如按word换行、按char换行等)或控制每行的内容,可以理解成更精细化的控制。

三、重要API

3.1 CTFramesetter

1)CTFramesetterCreateWithAttributedString

func CTFramesetterCreateWithAttributedString(_ attrString: CFAttributedString) -> CTFramesetter

通过属性字符串来创建CTFramesetter。

我们可以构造不同字体、颜色、大小的属性字符串,然后从属性字符串构造CTFramesetter,之后可以继续往下拆分得到段落、行、字形等信息,这样可以实现自定义排版、图文混排等复杂富文本样式。

2)CTFramesetterCreateWithTypesetter

func CTFramesetterCreateWithTypesetter(_ typesetter: CTTypesetter) -> CTFramesetter

通过CTTypesetter来创建CTFramesetter,当我们需要对文本实现更精细控制,比如自定义换行时,可以自己构造CTTypesetter。

3)CTFramesetterCreateFrame

func CTFramesetterCreateFrame(
    _ framesetter: CTFramesetter,
    _ stringRange: CFRange,
    _ path: CGPath,
    _ frameAttributes: CFDictionary?
) -> CTFrame

生成CTFrame:在指定路径(path)为属性字符串的指定范围(stringRange)生成CTFrame。

  • framesetter
  • stringRange:字符范围,注意需要以UTF-16编码格式计算;当 stringRange.length = 0 时,表示从起点(stringRange.location)到字符结束为止;比如当 CFRangeMake(0, 0) 表示全字符范围
  • path:排版路径,可以是不规则矩形,这意味着可以传入不规则图形来实现文字环绕等高级效果
  • frameAttributes:一个可选的字典,可以用于控制段落级别的布局行为,比如行间距等,一般用不到,可传 nil

4)CTFramesetterSuggestFrameSizeWithConstraints

func CTFramesetterSuggestFrameSizeWithConstraints(
    _ framesetter: CTFramesetter,
    _ stringRange: CFRange,
    _ frameAttributes: CFDictionary?,
    _ constraints: CGSize,
    _ fitRange: UnsafeMutablePointer<CFRange>?
) -> CGSize

计算文本宽高:在给定约束尺寸(constraints)下计算文本范围(stringRange)的实际宽高。

如下,我们可以计算出在宽高 100 x 100 的范围内排版,实际能放下的文本范围(fitRange)以及实际的文本尺寸:

let attr = NSAttributedString(string: "这是一段测试文本,通过调用CTFramesetterSuggestFrameSizeWithConstraints来计算文本的宽高信息,并返回实际的range", attributes: [
    .font: UIFont.systemFont(ofSize: 16),
    .foregroundColor: UIColor.black
])
let framesetter = CTFramesetterCreateWithAttributedString(attr)
var fitRange = CFRange(location: 0, length: 0)
let size = CTFramesetterSuggestFrameSizeWithConstraints(
    framesetter,
    CFRangeMake(0, 0),
    nil,
    CGSize(width: 100, height: 100),
    &fitRange
)
print(size, fitRange, attr.length)

这个API在分页时非常有用,比如微信读书的翻页效果,需要知道在哪个地方截断,PDF的分页排版等。

3.1.1 CTFramesetter使用示例

1)实现一个支持AutoLayout且高度靠内容撑开的富文本View

在这里插入图片描述

2)在圆形路径中绘制文本

在这里插入图片描述

3)文本分页:模拟微信读书的分页逻辑 在这里插入图片描述

3.2 CTFrame

1)CTFramesetterCreateFrame

func CTFramesetterCreateFrame(
    _ framesetter: CTFramesetter,
    _ stringRange: CFRange,
    _ path: CGPath,
    _ frameAttributes: CFDictionary?
) -> CTFrame

创建CTFrame,在CTFramesetter一节中有介绍过,这是创建CTFrame的唯一方式。

2)CTFrameGetStringRange

func CTFrameGetStringRange(_ frame: CTFrame) -> CFRange

获取CTFrame包含的字符范围。

我们在调用CTFramesetterCreateFrame创建CTFrame时,会传入一个 stringRange 的参数,CTFrameGetStringRange也可以理解成获取这个 stringRange,区别是处理了当 stringRange.length 为0的情况。

3)CTFrameGetVisibleStringRange

func CTFrameGetVisibleStringRange(_ frame: CTFrame) -> CFRange

获取CTFrame实际可见的字符范围。

我们在调用CTFramesetterCreateFrame创建CTFrame时,会传入path,可能会把字符截断,CTFrameGetVisibleStringRange返回的就是可见的字符范围。

需要注意和CTFrameGetStringRange进行区分,可以用如下Demo验证:

let longText = String(repeating: "这是一个分栏布局的例子。Core Text 允许我们将一个长的属性字符串(CFAttributedString)流动到多个不同的路径(CGPath)中。我们只需要创建一个 CTFramesetter,然后循环调用 CTFramesetterCreateFrame。每次调用后,我们使用 CTFrameGetStringRange 来找出有多少文本被排入了当前的框架,然后将下一个框架的起始索引设置为这个范围的末尾。 ", count: 10)
let attributedText = NSAttributedString(string: longText, attributes: [
    .font: UIFont.systemFont(ofSize: 12),
    .foregroundColor: UIColor.darkText
])
let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString)
let path = CGPath(rect: .init(x: 10, y: 100, width: 400, height: 200), transform: nil)
let frame = CTFramesetterCreateFrame(
    framesetter,
    CFRange(location: 100, length: 0),
    path,
    nil
)
// 输出:CFRange(location: 100, length: 1980)
print(CTFrameGetStringRange(frame))
// 输出:CFRange(location: 100, length: 584)
print(CTFrameGetVisibleStringRange(frame))

4)CTFrameGetPath

func CTFrameGetPath(_ frame: CTFrame) -> CGPath

获取创建CTFrame时传入的path。

5)CTFrameGetLines

func CTFrameGetLines(_ frame: CTFrame) -> CFArray

获取CTFrame中所有的行(CTLine)。

6)CTFrameGetLineOrigins

func CTFrameGetLineOrigins(
    _ frame: CTFrame,
    _ range: CFRange,
    _ origins: UnsafeMutablePointer<CGPoint>
)

获取每一行的起点坐标。

用法示例:

let lines = CTFrameGetLines(frame) as! [CTLine]
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)

7)CTFrameDraw

func CTFrameDraw(
    _ frame: CTFrame,
    _ context: CGContext
)

绘制CTFrame。

3.2.1 CTFrame使用示例

1)绘制CTFrame

在这里插入图片描述

2)高亮某一行

在这里插入图片描述

3)检测点击字符

在这里插入图片描述

3.3 CTLine

1)CTLineCreateWithAttributedString

func CTLineCreateWithAttributedString(_ attrString: CFAttributedString) -> CTLine

从属性字符串创建单行CTLine,如果字符串中有换行符(\n)的话,换行符会被转换成空格,如下:

let line = CTLineCreateWithAttributedString(
    NSAttributedString(string: "Hello CoreText\nWorld", attributes: [.font: UIFont.systemFont(ofSize: 16)])
)

2)CTLineCreateTruncatedLine

func CTLineCreateTruncatedLine(
    _ line: CTLine,
    _ width: Double,
    _ truncationType: CTLineTruncationType,
    _ truncationToken: CTLine?
) -> CTLine?

创建一个被截断的新行。

  • line:待截断的行
  • width:在多少宽度截断
  • truncationType:start/end/middle,截断类型
  • truncationToken:在截断处添加的字符,nil表示不添加,一般使用省略符(...)
let truncationToken = CTLineCreateWithAttributedString(
    NSAttributedString(string: "…", attributes: [.font: UIFont.systemFont(ofSize: 16)])
)
let truncated = CTLineCreateTruncatedLine(line, 100, .end, truncationToken)

3)CTLineCreateJustifiedLine

func CTLineCreateJustifiedLine(
    _ line: CTLine,
    _ justificationFactor: CGFloat,
    _ justificationWidth: Double
) -> CTLine?

创建一个两端对齐的新行,类似书籍或报纸中两端对齐的排版效果。

  • line:原始行
  • justificationFactorjustificationFactor <= 0表示不缩放,即与原始行相同;justificationFactor >= 1表示完全缩放到指定宽度;0 < justificationFactor < 1表示部分缩放到指定宽度,可以看示例代码
  • justificationWidth:缩放指定宽度

示例:

在这里插入图片描述

4)CTLineDraw

func CTLineDraw(
    _ line: CTLine,
    _ context: CGContext
)

绘制行。

5)CTLineGetGlyphCount

func CTLineGetGlyphCount(_ line: CTLine) -> CFIndex

获取行内字形总数。

6)CTLineGetGlyphRuns

func CTLineGetGlyphRuns(_ line: CTLine) -> CFArray

获取行内所有的CTRun。

7)CTLineGetStringRange

func CTLineGetStringRange(_ line: CTLine) -> CFRange

获取该行对应的字符范围。

8)CTLineGetPenOffsetForFlush

func CTLineGetPenOffsetForFlush(
    _ line: CTLine,
    _ flushFactor: CGFloat,
    _ flushWidth: Double
) -> Double

获取在指定宽度绘制时的水平偏移,一般配合 CGContext.textPosition 使用,可用于实现在固定宽度下文本的左对齐、右对齐、居中对齐及自定义水平偏移等。

示例:

在这里插入图片描述

9)CTLineGetImageBounds

func CTLineGetImageBounds(
    _ line: CTLine,
    _ context: CGContext?
) -> CGRect

获取行的视觉边界;注意 CTLineGetImageBounds 获取的是相对于CTLine局部坐标系的矩形,即以textPosition为原点的矩形。

视觉边界可以看下面的例子,与之相对的是布局边界;这个API在实际应用中不常见,除非有特殊诉求,比如要检测精确的内容点击范围,给行绘制紧贴背景等。

在这里插入图片描述

10)CTLineGetTypographicBounds

func CTLineGetTypographicBounds(
    _ line: CTLine,
    _ ascent: UnsafeMutablePointer<CGFloat>?,
    _ descent: UnsafeMutablePointer<CGFloat>?,
    _ leading: UnsafeMutablePointer<CGFloat>?
) -> Double

获取上行(ascent)、下行(descent)、行距(leading)。

这几个概念不熟悉的可以参考:一文读懂字符、字形、字体

想了解这几个数值最终是从哪个地方读取的可以参考:一文读懂字体文件

通过这个API我们可以手动构造布局边界(见上面的例子),一般用于点击检测、绘制行背景等。

11)CTLineGetTrailingWhitespaceWidth

func CTLineGetTrailingWhitespaceWidth(_ line: CTLine) -> Double

获取行尾空白字符的宽度(比如空格、制表符 (\t) 等),一般用于实现对齐时基于可见文本对齐等。

示例:

let line = CTLineCreateWithAttributedString(
    NSAttributedString(string: "Hello  ", attributes: [.font: UIFont.systemFont(ofSize: 16)])
)

let totalWidth = CTLineGetTypographicBounds(line, nil, nil, nil)
let trailingWidth = CTLineGetTrailingWhitespaceWidth(line)

print("总宽度: \(totalWidth)")
print("尾部空白宽度: \(trailingWidth)")
print("可见文字宽度: \(totalWidth - trailingWidth)")

12)CTLineGetStringIndexForPosition

func CTLineGetStringIndexForPosition(
    _ line: CTLine,
    _ position: CGPoint
) -> CFIndex

获取给定位置处的字符串索引。

注意:虽然官方文档说这个API一般用于点击检测,但实际测试下来这个API返回的点击索引不准确,比如虽然点击的是当前字符,但实际返回的索引是后一个字符的,如下:

在这里插入图片描述

查了下,发现这个API一般是用于计算光标位置的,比如点击「行」的左半部分,希望光标出现在「行」左侧,如果点击「行」的右半部分,希望光标出现在「行」的右侧。

如果我们想精确做字符的点击检测,推荐使用字符/行的bounds来计算,参考「CTFrame使用示例-3」例子。

13)CTLineGetOffsetForStringIndex

func CTLineGetOffsetForStringIndex(
    _ line: CTLine,
    _ charIndex: CFIndex,
    _ secondaryOffset: UnsafeMutablePointer<CGFloat>?
) -> CGFloat

获取指定字符索引相对于行的 x 轴偏移量。

  • line:待查询的行
  • charIndex:要查询的字符在原始属性字符串中的索引
  • secondaryOffset:次要偏移值,在简单的LTR文本中,可以忽略(传nil即可),但在复杂的双向文本(BiDi)中会用到

使用场景:

  • 字符点击检测:见「CTFrame使用示例-3」例子
  • 给某段字符绘制高亮和下划线
  • 定位某个字符:比如想在一段文本中的某个字符上方显示弹窗,可以用这个API先定位该字符

14)CTLineEnumerateCaretOffsets

func CTLineEnumerateCaretOffsets(
    _ line: CTLine,
    _ block: @escaping (Double, CFIndex, Bool, UnsafeMutablePointer<Bool>) -> Void
)

遍历一行中光标所有的有效位置。

  • line
  • block
    • Double:offset,相对于行的 x 轴偏移
    • CFIndex:与此光标位置相关的字符串索引
    • Bool:true 表示光标位于字符的前边(在 LTR 中即左侧),false 表示光标位于字符的后边(在 LTR 中即右侧);在 BiDi 中需要特殊同一个字符可能会回调两次(比如 BiDi 边界的地方),需要用这个值区分前后
    • UnsafeMutablePointer:stop 指针,赋值为 true 会停止遍历

使用场景:

  • 绘制光标:富文本选区或者文本编辑器中,要绘制光标时,可以先通过 CTLineGetStringIndexForPosition 获取字符索引,再通过这个函数或者 CTLineGetOffsetForStringIndex 获取光标偏移
  • 实现光标的左右键移动:可以用这个API将所有的光标位置存储到数组,并按offset排序,当用户按下右箭头 -> 时,可以找到当前光标index,将index + 1即是下一个光标位置

3.3.1 CTLine使用示例

除了上面例子,再举一个:

1)高亮特定字符

在这里插入图片描述

3.4 CTRun

CTRun相关API比较基础,这里主要介绍常用的。

1)CTLineGetGlyphRuns

func CTLineGetGlyphRuns(_ line: CTLine) -> CFArray

获取CTRun的唯一方式。

2)CTRunGetAttributes

func CTRunGetAttributes(_ run: CTRun) -> CFDictionary

获取CTRun的属性;比如想知道这个CTRun是不是粗体,是不是链接,是不是目标Run等,都可以通过这个API。

示例:

guard let attributes = CTRunGetAttributes(run) as? [NSAttributedString.Key: Any] else { continue }
// 现在你可以检查属性
if let color = attributes[.foregroundColor] as? UIColor {
    // ...
}
if let font = attributes[.font] as? UIFont {
    // ...
}
if let link = attributes[NSAttributedString.Key("my_custom_link_key")] {
    // 这就是那个可点击的 run!
}

3)CTRunGetStringRange

func CTRunGetStringRange(_ run: CTRun) -> CFRange

获取CTRun对应于原始属性字符串的哪个范围。

4)CTRunGetTypographicBounds

func CTRunGetTypographicBounds(
    _ run: CTRun,
    _ range: CFRange,
    _ ascent: UnsafeMutablePointer<CGFloat>?,
    _ descent: UnsafeMutablePointer<CGFloat>?,
    _ leading: UnsafeMutablePointer<CGFloat>?
) -> Double

获取CTRun的度量信息,同上面许多API一样,当 range.length 为0时表示直到CTRun文本末尾。

5)CTRunGetPositions

func CTRunGetPositions(
    _ run: CTRun,
    _ range: CFRange,
    _ buffer: UnsafeMutablePointer<CGPoint>
)

获取CTRun中每一个字形的位置,注意这里的位置是相对于CTLine原点的。

6)CTRunDelegate

CTRunDelegate允许为属性字符串中的一段文本提供自定义布局测量信息,一般用于在文本中插入图片、自定义View等非文本元素。

比如在文本中间插入图片:

在这里插入图片描述

3.4.1 CTRun使用示例

1)基础绘制

在这里插入图片描述

2)链接点击识别

在这里插入图片描述

3.5 CTTypesetter

CTFramesetter会自动处理换行,当我们想手动控制换行时,可以用CTTypesetter。

1)CTTypesetterSuggestLineBreak

func CTTypesetterSuggestLineBreak(
    _ typesetter: CTTypesetter,
    _ startIndex: CFIndex,
    _ width: Double
) -> CFIndex

按单词(word)换行。

如下示例,输出:Try word wrapping

let attrStringWith = NSAttributedString(string: "Try word wrapping", attributes: [.font: UIFont.systemFont(ofSize: 18)])
let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
let totalLength = attributedString.length // UTF-16 长度
var startIndex = 0
var lineCount = 1

while startIndex < totalLength {
    let charCount = CTTypesetterSuggestLineBreak(typesetter, startIndex, 100)
    // 如果返回 0,意味着一个字符都放不下(或已结束)
    if charCount == 0 {
        if startIndex < totalLength {
            print("Line \(lineCount): (Error) 无法放下剩余字符。")
        }
        break
    }
    // 获取这一行的子字符串
    let range = NSRange(location: startIndex, length: charCount)
    let lineString = (attributedString.string as NSString).substring(with: range)
    print("Line \(lineCount): '\(lineString)' (UTF-16 字符数: \(charCount))")
    // 更新下一次循环的起始索引
    startIndex += charCount
    lineCount += 1
}

2)CTTypesetterSuggestClusterBreak

func CTTypesetterSuggestClusterBreak(
    _ typesetter: CTTypesetter,
    _ startIndex: CFIndex,
    _ width: Double
) -> CFIndex

按字符(char)换行。

如下示例,输出:Try word wrapping

let attrStringWith = NSAttributedString(string: "Try word wrapping", attributes: [.font: UIFont.systemFont(ofSize: 18)])
let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
let totalLength = attributedString.length // UTF-16 长度
var startIndex = 0
var lineCount = 1

while startIndex < totalLength {
    let charCount = CTTypesetterSuggestClusterBreak(typesetter, startIndex, 100)
    // 如果返回 0,意味着一个字符都放不下(或已结束)
    if charCount == 0 {
        if startIndex < totalLength {
            print("Line \(lineCount): (Error) 无法放下剩余字符。")
        }
        break
    }
    // 获取这一行的子字符串
    let range = NSRange(location: startIndex, length: charCount)
    let lineString = (attributedString.string as NSString).substring(with: range)
    print("Line \(lineCount): '\(lineString)' (UTF-16 字符数: \(charCount))")
    // 更新下一次循环的起始索引
    startIndex += charCount
    lineCount += 1
}

四、总结

以上是CoreText中常用的API及其场景代码举例,完整示例代码可在公众号「非专业程序员Ping」回复 “CoreText” 获取。

❌
❌