Swift: 基于 CoreText 图文排版实践

介绍 CoreText 简单应用,主要包括文本节选,可点链接,图文混排等内容。

CoreText

CoreText 是用于处理文字和字体的底层技术。它直接和 Core Graphics 交互。

Swift: 基于 CoreText 图文排版实践_第1张图片
Coretext

CoreText 对象能直接获取文本的宽高信息,占用内存少,异步绘制等特点。在引起 UITableView 卡顿常见的原因 Cell 层级过多,离屏渲染,频繁计算 Cell 高度等耗时操作。这个时候 CoreText 就派上用场了,减少层级,CoreText 可以直接将文字和图片直接绘制在 Layer 上,并且支持异步绘制大大节约主线程资源。用来做图文混排的 UITableView 的优化,效果很明显。

基础概念

Font & Character & Glyphs

Font 在计算机意义上字体表示的是同一个大小,同一样式字形的集合

Character 字符表示信息本身,字形是它的图形表示形式,字符一般指某种编码,比如 Unicode 编码就是其中一种。字符和字形不是一一对应关系,同一个 Character 不同 Font 会生成不同的 Glyphs

Glyphs 字形常见参数

Swift: 基于 CoreText 图文排版实践_第2张图片
字形
  • Baseline : 参照线,是一条横线,一般为此为基础进行字体的渲染
  • Leading : 行与行之间的间距
  • Kerning : 字与字之间的间距
  • Origin : 基线上最左侧的点
  • Ascent : 一个字形最高点到基线的距离
  • Decent : 一个自行最低处到基线的距离,所以一个字符的高度是 ascent + decent 。当一行内有不同字体的文字时候,取最大值 max(ascent + decent)。
  • Line Height : max(ascent + decent) + Leading

富文本NSAttributedString

iOS 中用于描述富文本的类,它比 String 多了很多描述字体的属性,.font.underlineColor.foregroundColor 等,而且可以设定属性对应的区域 NSRange

  let text: NSMutableAttributedString = NSMutableAttributedString(string: "test")

  let attributes: [NSAttributedStringKey: Any] = [.font: UIFont.systemFontSize, .foregroundColor: UIColor.black, .underlineColor: UIColor.blue]

  text.addAttributes(attributes, range: NSMakeRange(0, 1))
  
Swift: 基于 CoreText 图文排版实践_第3张图片
屏幕快照 2018-01-09 下午5.12.30.png

在绘制过程中,其中 CTFramesetter 是由 CFAttributedString(NSAttributedString) 初始化而来,通过传入 CGPath 生成相应的 CTFrame 最后渲染到屏幕是 CTFrame


  let frameSetter: CTFramesetter = CTFramesetterCreateWithAttributedString(text)

  let path = UIBezierPath(rect: CGRect())

  let frame: CTFrame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path.cgPath, nil)
        

一个 CTFrame 由一个或者多个 CTLine 组成,一个 CTLine 由一个或者多个 CTRun 组成。一个 CTRun 是由相同属性的字符组成。

override func draw(_ rect: CGRect) {

   guard let context = UIGraphicsGetCurrentContext() { return }
   ...

   CTFrameDraw(ctFrame, context)
   
   ...
   let lines = CTFrameGetLines(frame) as! Array
   
   ...
   let runs = CTLineGetGlyphRuns(lines[0] as! CTLine) 
   
   ...         
}

产品需求是做一个类似知乎的问答系统,支持图文,标签,链接,短视频等基本元素。本文主要介绍基于 CoreText 图文排版一些简单实践应用。

UIKit & CoreText

文本

直接看代码吧,简单输出一段文字。


// BKCoreTextConfig.swift

// 文本配置信息

struct BKCoreTextConfig {

   let width  : CGFloat // 文本最大宽度

   let fontName : String  

   let fontSize : CGFloat

   let lineSpace : CGFloat // 行间距

   let textColor : UIColor

   init(width: CGFloat, fontName: String, fontSize: CGFloat, 

   lineSpace: CGFloat, textColor: UIColor) {

       self.width = width

       self.fontName = fontName

       self.fontSize = fontSize

       self.lineSpace = lineSpace

       self.textColor = textColor

   }

}


// BKCoreTextData.swift

// 绘制信息内容
struct BKCoreTextData {

   let ctFrame : CTFrame

   let size  : CGSize

   init(ctFrame: CTFrame, contentSize: CGSize) {

       self.ctFrame = ctFrame

       self.size = contentSize

   }

}


// BKCoreTextParser.swift

// 解析

static func attributes(with config: BKCoreTextConfig) -> NSDictionary {

     // 字体大小
     let font = CTFontCreateWithName(config.fontName as CFString, config.fontSize, nil)

     //设置行间距
     var lineSpace = config.lineSpace

     let settings: [CTParagraphStyleSetting] =

     [CTParagraphStyleSetting(spec: CTParagraphStyleSpecifier.lineSpacingAdjustment, valueSize:       MemoryLayout.size, value: &lineSpace),

     CTParagraphStyleSetting(spec: CTParagraphStyleSpecifier.maximumLineSpacing, valueSize: MemoryLayout.size, value: &lineSpace),

     CTParagraphStyleSetting(spec: CTParagraphStyleSpecifier.minimumLineSpacing, valueSize: MemoryLayout.size, value: &lineSpace)]

     let paragaraph = CTParagraphStyleCreate(settings, 3)

     //设置字体颜色
     let textColor = config.textColor

     let dict = NSMutableDictionary()

     dict.setObject(font, forKey: kCTFontAttributeName as! NSCopying)

     dict.setObject(paragaraph, forKey: kCTParagraphStyleAttributeName as! NSCopying)

     dict.setObject(textColor.cgColor, forKey: kCTForegroundColorAttributeName as! NSCopying)

     return dict

}

static func createFrame(frameSetter: CTFramesetter, config: BKCoreTextConfig, height: CGFloat) -> CTFrame {

     let path = CGMutablePath()

     path.addRect(ccr(x: 0, y: 0, width: config.width, height: height))

     return CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, nil)
}

static func parse(content: NSAttributedString, config: BKCoreTextConfig) -> BKCoreTextData {

     let frameSetter = CTFramesetterCreateWithAttributedString(content)

     let restrictSize = CGSize(width: config.width, height: CGFloat(MAXFLOAT))

     let coretextSize = CTFramesetterSuggestFrameSizeWithConstraints(frameSetter, 

     CFRangeMake(0, 0), nil, restrictSize, nil)

     let height = coretextSize.height

     let frame = self.createFrame(frameSetter: frameSetter, config: config, height: height)

     retutn BKCoreTextData.init(ctFrame: frame, contentSize: coretextSize)

}

static func handleText(text: String, config: BKCoreTextConfig) -> BKCoreTextData {

/*

let text = "裘德洛论颜值的话,绝对可以称得上帅的人神共愤,海洋般蓝绿交织的双眼,优雅俊美,随随便便一个动作都能俘获万千少女的心。而且人家不止有颜还多才多艺,小小年纪开始就在音乐剧团表演,气质啊才华啊什么的,完美的让人嫉妒。美图奉上↓"

let config = BKCoreTextConfig(width: kScreenWidth - 30, fontName: "PingFangSC-Regular", fontSize: 12, textColor: UIColor(rgb: 0x9E9E9E))

*/

     let attributes = self.attributes(with: config) as? [NSAttributedStringKey : Any]

     let attributedString = NSMutableAttributedString(string: content, attributes: attributes)

     return parse(content: attributedString, config: config)

}

上面就是给定给一个文本和文本一些配置信息得到一个 frame 现在可以直接绘制出一段文本


// BKCoreTextView.swift

var data : BKCoreTextData? {

     didSet { setNeedsDisplay() }

}

....

override func draw(_ rect: CGRect) {

     super.draw(rect)

     guard let context = UIGraphicsGetCurrentContext(), let info = data else { return }

     /// !!! 坐标转换
     context.textMatrix = CGAffineTransform.identity

     context.translateBy(x: 0, y: bounds.size.height)

     context.scaleBy(x: 1, y: -1)

     CTFrameDraw(info.ctFrame, context)

}

CoreText 坐标系是以左下角为坐标原点,UIKit是以左上角为坐标原点,使用 Core Graphics 需要做坐标的转换,不然看到的内容是倒过来的。

Swift: 基于 CoreText 图文排版实践_第4张图片

文本节选

以上只是简单的绘制了一段文本,但是呢,产品有需求要限制文本的行数,超过的用 ... 来表示更多。类似于微信朋友圈,内容过多会有 收起 全部 的按钮,功能是相类似的。

如果用 UILabel 设置宽高,UILabel 会自动帮我们处理 ...。但是 CoreText 需要开发组手动处理,这里主要问题就是要找到最后一行合适的位置放置 ...

思路:

  • 文本通过相关配置文件转换为 NSAttributedString 格式

  • 计算文本的行数 Count

  • 文本行数 Count 小于指定行数 numberOfLines,返回无需处理

  • 文本行数大于指定行数,截取最后一行处理

  • 最后一行显示宽度小于 Config.width, 直接行尾添加 ...

  • 最后一行显示宽度大于等于 Config.width ,需对最后一行做 replace 操作

难点: 如何获取最后一行显示宽度


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

函数 CTLineGetOffsetForStringIndex 是获取一行文字中指定 charIndex 字符相对原点的偏移量,返回值与 secondaryOffset 同为一个值。如果 charIndex 超出一行的字符长度则返回最大长度结束位置的偏移量。因此想求一行字符所占的像素长度时,就可以使用此函数,将 charIndex 设置为大于字符长度即可这里设置了 100 但是感受算出来的长度还是有一丢丢误差。


static func handleText(text: String, config: BKCoreTextConfig, numberofLines: Int) -> BKCoreTextData {

    let attributes = self.attributes(with: config) as? [NSAttributedStringKey : Any]

    let attributedString = NSMutableAttributedString(string: content, attributes: attributes)

    let currCoreData = self.parse(content: attributedString, config: config)

    let lines = CTFrameGetLines(currCoreData.ctFrame) as Array

    let count = lines.count

    guard numberofLines > 0 else { return currCoreData }

     guard count > 0 && count > numberofLines else { return currCoreData }

     let num = min(numberofLines, count)

     let line = lines[num-1]

     let range = CTLineGetStringRange(line as! CTLine)

     let position = range.location + range.length

     let tmpAttrString = attr.attributedSubstring(from: NSMakeRange(0, position))

     var newContent = NSAttributedString()

     var offset: CGFloat = 0

     CTLineGetOffsetForStringIndex(line as! CTLine,100,&offset)

     let length = offset > (config.width - 10) ? range.length - 3 : range.length

     let lastLine: NSMutableAttributedString = tmpAttrString.attributedSubstring(from: NSMakeRange(range.location, length)) as! NSMutableAttributedString

     /// !!! 去除最后一行的 \n 
     var str = (lastLine.mutableString.mutableCopy() as! String).replacingOccurrences(of: "\n", with: "")

     str.append("...")

     let tmp = tmpAttrString.attributedSubstring(from: NSMakeRange(0, range.location))

     let newAttr: NSAttributedString = tmp.appending(NSAttributedString.init(string: str))

     let attributes = self.attributes(with: config) as? [NSAttributedStringKey : Any]

     newContent = NSMutableAttributedString(string: newAttr.string, attributes: attributes)

     return self.parseContent(content: newContent, config: config)

}

Swift: 基于 CoreText 图文排版实践_第5张图片
文本节选

可点链接

可点链接也是很常见的,比如 点我跳转

后台给的 JSON 字符串可能直接原生态甩过来


print(content)

"嘎嘎嘎嘎哈哈哈哈哈https://wapbaike.baidu.com/item/%e4%b8%9c%e4%ba%ac%e5%9b%bd%e9%99%85%e7%94%b5%e5%bd%b1%e8%8a%82/187783?fr=aladdin"

********思路:********

  • 扫描文本,找出链接地址的字符串 results

  • 文本通过相关配置文件 Config 转换为 attributedString

  • 超链接文本通过配置文件 Config 转换为 linkAttributedString

  • 遍历 resultslinkAttributedString 替换 attributedString 中链接地址

  • 记录 linkAttributedStringrangeurl 得到 coreTextLinkDatas


// BKCoreTextData.swift

/// 可点击链接

struct BKCoreTextLinkData {

     let title  : String

     let url  : String

     let range  : NSRange

}

struct BKCoreTextData {

...

    var linkData : [BKCoreTextLinkData]?

...

}


static func handleText(text: String, config: BKCoreTextConfig, numberofLines: Int) -> BKCoreTextData {

    return self.handleLinkAttribute(content: content, config: config) { (attributedString, linkDatas) in

// 同上

....

         let newCoreData =  self.parseContent(content: attributedString, config: config)

         if let links = linkDatas, links.count > 0 {

            newCoreData.linkData = links 

          }

         return newCoreData
     }

    static func handleLinkAttribute(content: String, config: BKCoreTextConfig, completed: @escaping
        ( _ result : NSAttributedString, _ linkDatas : [BKCoreTextLinkData]?) -> BKCoreTextData) -> BKCoreTextData {

        let dataDetector = try? NSDataDetector(types: NSTextCheckingTypes(NSTextCheckingResult.CheckingType.link.rawValue))
        let results = dataDetector?.matches(in: content, options: NSRegularExpression.MatchingOptions.reportProgress, range: NSMakeRange(0, content.length))
        
        let attributes = self.attributes(with: config) as? [NSAttributedStringKey : Any]
        let attributedString = NSMutableAttributedString(string: content, attributes: attributes)
        
        let linkAttributedString = IconCodes.attributedString(code: .link, size: config.link.fontSize, color: config.link.textColor).appending(NSAttributedString(string: " 网页链接", font: UIFont(name: config.link.fontName, size: config.link.fontSize)!, color: .app_light))
        
        var tmpLinkDatas = [BKCoreTextLinkData]()
        
        if let results = results {
            results.reversed().forEach({ (result) in
                if result.resultType == .link, let url = URL(string: (content as NSString).substring(with: result.range)) {
                    linkAttributedString.addAttributes([.link: url], range: NSMakeRange(0, linkAttributedString.length))
                    attributedString.replaceCharacters(in: result.range, with: linkAttributedString)
                    let data = BKCoreTextLinkData.init(title: linkAttributedString.string, url: url.absoluteString, range: NSMakeRange(result.range.location, linkAttributedString.length))
                    tmpLinkDatas.append(data)
                }
            })
        }
        return completed(attributedString, tmpLinkDatas)
    }
}

一个文本中可能有多个链接,需要识别链接,以及记下每个链接的 Range

Swift: 基于 CoreText 图文排版实践_第6张图片
成果

效果有了,点击事件要怎么响应,这个时候就用到了上述记录的链接对应的 Range

遍历 coreTextLinkDatas ,找到在 range 中包含获取点击位置 pointcoreTextLinkData 拿到 url 地址


// BKCoreTextView.swift

override func touchesEnded(_ touches: Set, with event: UIEvent?) {

     let touch = (touches as NSSet).anyObject() as! UITouch

     let point = touch.location(in: self)

     guard let frame = data?.ctFrame else { return }

     let lines = CTFrameGetLines(frame) as Array

     let count = lines.count

     var origins = [CGPoint].init(repeating: CGPoint(0, 0), count: count)

     CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)

     var transform = CGAffineTransform.init(translationX: 0, y: bounds.height)

     transform = transform.scaledBy(x: 1, y: -1)

     guard let links = data?.linkData, links.count > 0 else {

     print("没有可点击链接")

     return

     }

      for (index, line) in lines.enumerated() {

         let origin = origins[index]

         let lineRect = getLineBound(line: line as! CTLine, point: origin)

         let rect = lineRect.applying(transform)

         if rect.contains(point) == true {

           let relativePoint = CGPoint(point.x - rect.minX, point.y - rect.minY)

           let idx = CTLineGetStringIndexForPosition(line as! CTLine, relativePoint)

           if let link = foundLinkData(at: idx), let url = URL.init(string: link.url) {

             print("oh! 点到了。\(url)")

             return

           } else {

             print("不在点击链接范围")

         }

       }

   }

}

func getLineBound(line: CTLine, point: CGPoint) -> CGRect {

       var ascent: CGFloat = 0

       var descent: CGFloat = 0

       var leading: CGFloat = 0

       let width: CGFloat = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, &leading))

       let height: CGFloat = ascent + descent

       return ccr(point.x, point.y - descent, width, height)

}

func foundLinkData(at index: Int) -> BKCoreTextLinkData? {

       var link : BKCoreTextLinkData?

       data?.linkData?.forEach( {

         if NSLocationInRange(index, $0.range ) == true {

           link = $0

         }

       })

     return link

}

这只是比较简单的链接样式,可以给它添加下划线,按压态等等,设置它的 NSAttributedString 样式就可以了。

图片混排

就是图片和文字混合排版,如果图片比较多文字少不建议用 Core Text

Core Text 是一个文本处理框架,不能直接绘制图片,但是它可以给图片预留空间,结合Core Graphic 来绘图。

单排

Swift: 基于 CoreText 图文排版实践_第7张图片

思路

  • 根据 Config 图片的宽高,设置 CTRunDelegateCallbacks

  • 生成 runDelegate

  • 找到要插入图片的位置,将图片信息封装成一个 attributedString 富文本类型的占位符

  • 富文本类型的占位符


struct BKCoreTextData {

     let ctFrame : CTFrame

     let size : CGSize

     let imageUrl: String

}


struct BKCoreTextConfig {

     let size : CGSize

}


static func parse(content: String, imageUrl: String, config: BKCoreTextConfig) -> BKCoreTextData {

     var imageCallback = CTRunDelegateCallbacks(version: kCTRunDelegateVersion1, dealloc: { (refCon) -> Void in

     }, getAscent: { ( refCon) -> CGFloat in

       return config.size.height // 高度

     }, getDescent: { (refCon) -> CGFloat in

       return 0 // 底部距离

     }) { (refCon) -> CGFloat in

       return config.size.width // 宽度

     }

     var imageName = "avatar"

     let runDelegate = CTRunDelegateCreate(&imageCallback,&imageName)

     // 富文本类型的占位符

     let imageAttributedString = NSMutableAttributedString(string: " ")

     imageAttributedString.addAttribute(NSAttributedStringKey(rawValue:

     kCTRunDelegateAttributeName as String), value: runDelegate!, range:

     NSMakeRange(0, 1))     

     imageAttributedString.addAttribute(NSAttributedStringKey(rawValue:

     "avatarImage"), value: imageName, range: NSMakeRange(0, 1))

     // 富文本类型的占位符插到要显示图片的位置

     // 这里的设定是图片插在文本行首。。。
     content.insert(imageAttributedString, at: 0)

     // 文本绘制同上 多了一个imageUrl信息
    let data: BKCoreTextData = ...

     return data

}

图片和文本混合怎么显示???

思路

  • 遍历 CTLine

  • 遍历每个 LineCTRun

  • 通过 CTRunGetAttributes 得到所有属性

  • 通过 KVC 取得属性中的代理属性,图片占位符绑定了代理

  • 判断是否之前设置的图片代理来区分文本和图片

  • 获取图片 距离原点偏移量 来计算图片绘制区域的 CGRect

  • 使用 Core Graphics 异步绘制图片


    var data: BKCoreTextData? {

     didSet { setNeedsDisplay() }

    }

    private var avatarImage: Image = #默认占位符

    override func draw(_ rect: CGRect) {

       super.draw(rect)

       guard let context = UIGraphicsGetCurrentContext() else { return }

       guard let frame = data?.ctFrame else { return }

       context.textMatrix = CGAffineTransform.identity

       context.translateBy(x: 0, y: bounds.size.height)

       context.scaleBy(x: 1, y: -1)

       CTFrameDraw(frame, context)

       let lines = CTFrameGetLines(frame) as Array

       let count = lines.count

       var origins = [CGPoint].init(repeating: CGPoint(0, 0), count: count)

       CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)

       for (index, line) in lines.enumerated() {

         (CTLineGetGlyphRuns(line as! CTLine) as Array).forEach({

           var runAscent : CGFloat = 0

           var runDescent : CGFloat = 0

           let lineOrigin = origins[index]

           let attributes = CTRunGetAttributes($0 as! CTRun)

           let width = CGFloat( CTRunGetTypographicBounds($0 as! 

           CTRun, CFRangeMake(0,0), &runAscent, &runDescent, nil))

           let location = CTLineGetOffsetForStringIndex(line as! CTLine, 

           CTRunGetStringRange($0 as! CTRun).location, nil)

           let runRect = ccr(lineOrigin.x + location, lineOrigin.y - runDescent, 

           width, runAscent + runDescent)

           let imageNames = attributes.object(forKey: "avatarImage")

           if imageNames is String {

             DispatchQueue.global().async { [weak self] in

             // 获取图片 data.imageUrl
             let tmp = ....

             DispatchQueue.main.async {

                 self?.avatarImage = tmp!

                 self?.setNeedsDisplay(runRect)

             }

         }

         context.draw(avatarImage.cgImage!, in: runRect)

       }

      })

    }

}

这是比较理想的图文混合,图片的高度和文本高度差不多,以及图片的位置又刚好在行首。

组队

来看个实际需求的图文混合

Swift: 基于 CoreText 图文排版实践_第8张图片
0

绘制的方式是和上面的是一样的,不同在于图片和文本的排版不一样。

实践步骤:

0.不做处理

Swift: 基于 CoreText 图文排版实践_第9张图片
1

1.文字都堆在一起了,给文本不同样式划分段落

Swift: 基于 CoreText 图文排版实践_第10张图片
2

2.恩,跟目标很接近了 ,根据不同段落展示样式,调整行首缩进距离 firstLineHeadIndent 以及基线的距离 baselineOffset


let margin : CGFloat = 20

let paragraphStyle0 = NSMutableParagraphStyle()

paragraphStyle0.alignment = .left

paragraphStyle0.firstLineHeadIndent = image.size.width + margin // 首行缩进

title.addAttributes([.baselineOffset: 15,.paragraphStyle: paragraphStyle0], range: NSMakeRange(0, title.length - 1))

subTitle.addAttributes([.baselineOffset: 10, .paragraphStyle: paragraphStyle0], range: NSMakeRange(0, subTitle.length))

let paragraphStyle1 = NSMutableParagraphStyle()

paragraphStyle1.alignment = .left

paragraphStyle1.firstLineHeadIndent = kScreenWidth - 30 - 20

indicator.addAttributes([.baselineOffset: 28, .paragraphStyle: paragraphStyle1], range: NSMakeRange(0, 1))

3.貌似已经达到目的了,这里也可以体现使用 Core Text 的优势,减少不必要的图层。

Swift: 基于 CoreText 图文排版实践_第11张图片
3

但是呢,这只是当前场景一种取巧的方式,hard code 间距,基线距离。如果 title 或者 subTitle 多行,这种方法就失效了。所以类似问题就是要解决图片和文字环绕的排版方式。

图文环绕

draw 函数是直接调用 frame 将内容绘制出来的,frame 是怎么来的


let path = CGMutablePath()

path.addRect(CGRect(x: 0, y: 0, width: config.width, height: height))

let frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, nil)

frame 是根据指定的 path 生成的,所以如果这个 path 将图片区域去掉,得到的 frame 就不包含该区域。但是这个 frame 里面也不再包含图片信息了。


let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: bounds.width, height: bounds.height))

// !!! 这个左下角为坐标原点
let imagePath = UIBezierPath(rect: CGRect(x: 3, y: 3, width: image.size.width, height: image.size.height))

// 减去图片区域
path.append(imagePath)

let frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path.cgPath, nil)

Swift: 基于 CoreText 图文排版实践_第12张图片
4-Path

可以看到整个绘制区域被分成了两个部分,一个是图片一个是文本。通过 UIBezierPath 还可以绘制任何想要的形状。剩下的问题就是处理段落之间的行间距。

Swift: 基于 CoreText 图文排版实践_第13张图片
5

Done!

总结

本文只是简单介绍了一些 Core Text 的东西,实际上还是有许多的细节还需要细细磨。实际开发过程中可能业务的形式不一,但是知识点是相通的,灵活应用都能达到目的。希望本文能给使用 CoreText 的同学一些启发。

参考

http://blog.devtang.com/2015/06/27/using-coretext-1/
http://blog.devtang.com/2015/06/27/using-coretext-2/
https://www.raywenderlich.com/153591/core-text-tutorial-ios-making-magazine-app
http://blog.cnbang.net/tech/2729/

你可能感兴趣的:(Swift: 基于 CoreText 图文排版实践)