第一次比较深入接触iOS文字排版相关内容是在12年底,实现某IM项目聊天内容的图文混排,照着nimbus的AttributedLabel和Raywenderlish上的这篇文章《 Core Text Tutorial for iOS: Making a Magazine App 》改出了一个比较适用于聊天内容展现的图文混排(文字和表情)控件。

选择自己写而不是直接使用现有第三方库的原因有三:

1. 在这之前也做过一个iOS上的IM产品,当时这个模块并不是我负责,图文混排的实现非常诡异(通过二分法计算出文字所占区域大小),效率极低,所以需要重新做一个效率比较高的控件出来。

2. 看过一些开源的实现,包括OHAttribtuedLabel,DTCoreText和Nimbus,总觉得他们实现插入图片的接口有点别扭,对于上层调用者来说CoreText部分不是完全透明的:调用者需要考虑怎么用自己的图片把原来内容替换掉。(当时的印象,现在具体怎么样已经不清楚了)

3. 这是重新造轮子的机会!

直接拿了Nimbus的AttributedLabel作为基础,然后重新整理图文混排那部分的代码,调整接口,一共也就花了一个晚上的时间:拜一下Nimbus的作者们。后来也根据项目的需求做了一些小改动,比如hack iOS7下不准的问题,支持在Label上添加UIView的特性等等。最新的代码可以在github上找到:M80AttributedLabel。

不过写这篇文章最重要的原因不是为了放个代码出来,而是在闲暇时整理一下iOS/OSX文字排版相关的知识。 

文字排版的基础概念

字体(Font)

字符(Character)和字形(Glyphs)





字形描述集(Glyphs Metris)



边框(Bounding Box)

基线(Baseline)

基础原点(Origin)

行间距(Leading)

字间距(Kerning)

上行高度(Ascent)和下行高度(Decent)


红框高度既为当前行的行高,绿线为baseline,绿色到红框上部分为当前行的最大Ascent,绿线到黄线为当前行的最大Desent,而黄框的高即为行间距。由此可以得出:lineHeight = Ascent + |Decent| + Leading。

更加详细的内容可以参考苹果的这篇文档: 《 Cocoa Text Architecture Guide》。当然如果要做到更完善的排版,还需要掌握段落排版(Paragragh Style)相关的知识,但是如果只是完成聊天框内的文字排版,以上的基础知识已经够用了。详细的段落样式相关知识可以参考: 《 Ruler and Paragraph Style Programming Topics 


CoreText


iOS/OSX中用于描述富文本的类是NSAttributedString,顾名思义,它比NSString多了Attribute的概念。它可以包含很多属性,粗体,斜体,下划线,颜色,背景色等等,每个属性都有其对应的字符区域。在OSX上我们只需解析完毕相应的数据,准备好NSAttributedString即可,底层的绘制完全可以交给相应的控件完成。但是在iOS上就没有这么方便,想要绘制Attributed String就需要用到CoreText了。(当然iOS6之后已经有AttributedLabel了。)

使用CoreText进行NSAttributedString的绘制,最重要的两个概念就是CTFrameSetter和CTFrame。他们的关系如下: 


其中CTFramesetter是由CFAttributedString(NSAttributedString)初始化而来,可以认为它是CTFrame的一个Factory,通过传入CGPath生成相应的CTFrame并使用它进行渲染:直接以CTFrame为参数使用CTFrameDraw绘制或者从CTFrame中获取CTLine进行微调后使用CTLineDraw进行绘制。

一个CTFrame是由一行一行的CLine组成,每个CTLine又会包含若干个CTRun(既字形绘制的最小单元),通过相应的方法可以获取到不同位置的CTRun和CTLine,以实现对不同位置touch事件的响应。



图文混排的实现


CoreText实际上并没有相应API直接将一个图片转换为CTRun并进行绘制,它所能做的只是为图片预留相应的空白区域,而真正的绘制则是交由CoreGraphics完成。(像OSX就方便很多,直接将图片打包进NSTextAttachment即可,根本无须操心绘制的事情,所以基于这个想法,M80AttributedLabel的接口和实现也是使用了attachment这么个概念,图片或者UIView都是被当作文字段中的attachment。)

在CoreText中提供了CTRunDelegate这么个Core Foundation类,顾名思义它可以对CTRun进行拓展:AttributedString某个段设置kCTRunDelegateAttributeName属性之后,CoreText使用它生成CTRun是通过当前Delegate的回调来获取自己的ascent,descent和width,而不是根据字体信息。这样就给我们留下了可操作的空间:用一个空白字符作为图片的占位符,设好Delegate,占好位置,然后用CoreGraphics进行图片的绘制。以下就是整个图文混排代码描述的过程:


占位:


1. - ( void )appendAttachment: (M80AttributedLabelAttachment *)attachment 
2. { 
3.     attachment.fontAscent                   = _fontAscent; 
4.     attachment.fontDescent                  = _fontDescent; 
5.     unichar objectReplacementChar           = 0xFFFC; 
6.     NSString *objectReplacementString       = [NSString stringWithCharacters:&objectReplacementChar length:1]; 
7.     NSMutableAttributedString *attachText   = [[NSMutableAttributedString alloc]initWithString:objectReplacementString]; 
8.  
9.     CTRunDelegateCallbacks callbacks; 
10.     callbacks.version       = kCTRunDelegateVersion1; 
11.     callbacks.getAscent     = ascentCallback; 
12.     callbacks.getDescent    = descentCallback; 
13.     callbacks.getWidth      = widthCallback; 
14.     callbacks.dealloc       = deallocCallback; 
15.  
16.     CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, ( void *)attachment); 
17.     NSDictionary *attr = [NSDictionary dictionaryWithObjectsAndKeys:(__bridge id)delegate,kCTRunDelegateAttributeName, nil]; 
18.     [attachText setAttributes:attr range:NSMakeRange(0, 1)]; 
19.     CFRelease(delegate); 
20.  
21.     [_attachments addObject:attachment]; 
22.     [self appendAttributedText:attachText]; 
23. }

实现委托回调:


1. CGFloat ascentCallback( void  *ref) 
2. { 
3.     M80AttributedLabelAttachment *image = (__bridge M80AttributedLabelAttachment *)ref; 
4.     CGFloat ascent = 0; 
5.     CGFloat height = [image boxSize].height; 
6.      switch  (image.alignment) 
7.     { 
8.          case  M80ImageAlignmentTop: 
9.             ascent = image.fontAscent; 
10.              break ; 
11.          case  M80ImageAlignmentCenter: 
12.         { 
13.             CGFloat fontAscent  = image.fontAscent; 
14.             CGFloat fontDescent = image.fontDescent; 
15.             CGFloat baseLine = (fontAscent + fontDescent) / 2 - fontDescent; 
16.             ascent = height / 2 + baseLine; 
17.         } 
18.              break ; 
19.          case  M80ImageAlignmentBottom: 
20.             ascent = height - image.fontDescent; 
21.              break ; 
22.          default : 
23.              break ; 
24.     } 
25.      return  ascent; 
26. } 
27.  
28. CGFloat descentCallback( void  *ref) 
29. { 
30.     M80AttributedLabelAttachment *image = (__bridge M80AttributedLabelAttachment *)ref; 
31.     CGFloat descent = 0; 
32.     CGFloat height = [image boxSize].height; 
33.      switch  (image.alignment) 
34.     { 
35.          case  M80ImageAlignmentTop: 
36.         { 
37.             descent = height - image.fontAscent; 
38.              break ; 
39.         } 
40.          case  M80ImageAlignmentCenter: 
41.         { 
42.             CGFloat fontAscent  = image.fontAscent; 
43.             CGFloat fontDescent = image.fontDescent; 
44.             CGFloat baseLine = (fontAscent + fontDescent) / 2 - fontDescent; 
45.             descent = height / 2 - baseLine; 
46.         } 
47.              break ; 
48.          case  M80ImageAlignmentBottom: 
49.         { 
50.             descent = image.fontDescent; 
51.              break ; 
52.         } 
53.          default : 
54.              break ; 
55.     } 
56.  
57.      return  descent; 
58.  
59. } 
60.  
61. CGFloat widthCallback( void * ref) 
62. { 
63.     M80AttributedLabelAttachment *image  = (__bridge M80AttributedLabelAttachment *)ref; 
64.      return  [image boxSize].width; 
65. }

真正的绘制:


1. - ( void )drawAttachments 
2. { 
3.      if  ([_attachments count] == 0) 
4.     { 
5.          return ; 
6.     } 
7.     CGContextRef ctx = UIGraphicsGetCurrentContext(); 
8.      if  (ctx == nil) 
9.     { 
10.          return ; 
11.     } 
12.  
13.     CFArrayRef lines = CTFrameGetLines(_textFrame); 
14.     CFIndex lineCount = CFArrayGetCount(lines); 
15.     CGPoint lineOrigins[lineCount]; 
16.     CTFrameGetLineOrigins(_textFrame, CFRangeMake(0, 0), lineOrigins); 
17.     NSInteger numberOfLines = [self numberOfDisplayedLines]; 
18.      for  (CFIndex i = 0; i < numberOfLines; i++) 
19.     { 
20.         CTLineRef line = CFArrayGetValueAtIndex(lines, i); 
21.         CFArrayRef runs = CTLineGetGlyphRuns(line); 
22.         CFIndex runCount = CFArrayGetCount(runs); 
23.         CGPoint lineOrigin = lineOrigins[i]; 
24.         CGFloat lineAscent; 
25.         CGFloat lineDescent; 
26.         CTLineGetTypographicBounds(line, &lineAscent, &lineDescent, NULL); 
27.         CGFloat lineHeight = lineAscent + lineDescent; 
28.         CGFloat lineBottomY = lineOrigin.y - lineDescent; 
29.  
30.           
31.          // intersect with the range.  
32.          for  (CFIndex k = 0; k < runCount; k++) 
33.         { 
34.             CTRunRef run = CFArrayGetValueAtIndex(runs, k); 
35.             NSDictionary *runAttributes = (NSDictionary *)CTRunGetAttributes(run); 
36.             CTRunDelegateRef delegate = (__bridge CTRunDelegateRef)[runAttributes valueForKey:(id)kCTRunDelegateAttributeName]; 
37.              if  (nil == delegate) 
38.             { 
39.                  continue ; 
40.             } 
41.             M80AttributedLabelAttachment* attributedImage = (M80AttributedLabelAttachment *)CTRunDelegateGetRefCon(delegate); 
42.  
43.             CGFloat ascent = 0.0f; 
44.             CGFloat descent = 0.0f; 
45.             CGFloat width = (CGFloat)CTRunGetTypographicBounds(run, 
46.                                                                CFRangeMake(0, 0), 
47.                                                                &ascent, 
48.                                                                &descent, 
49.                                                                NULL); 
50.  
51.             CGFloat imageBoxHeight = [attributedImage boxSize].height; 
52.             CGFloat xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil); 
53.  
54.             CGFloat imageBoxOriginY = 0.0f; 
55.              switch  (attributedImage.alignment) 
56.             { 
57.                  case  M80ImageAlignmentTop: 
58.                     imageBoxOriginY = lineBottomY + (lineHeight - imageBoxHeight); 
59.                      break ; 
60.                  case  M80ImageAlignmentCenter: 
61.                     imageBoxOriginY = lineBottomY + (lineHeight - imageBoxHeight) / 2.0; 
62.                      break ; 
63.                  case  M80ImageAlignmentBottom: 
64.                     imageBoxOriginY = lineBottomY; 
65.                      break ; 
66.             } 
67.  
68.             CGRect rect = CGRectMake(lineOrigin.x + xOffset, imageBoxOriginY, width, imageBoxHeight); 
69.             UIEdgeInsets flippedMargins = attributedImage.margin; 
70.             CGFloat top = flippedMargins.top; 
71.             flippedMargins.top = flippedMargins.bottom; 
72.             flippedMargins.bottom = top; 
73.  
74.             CGRect attatchmentRect = UIEdgeInsetsInsetRect(rect, flippedMargins); 
75.  
76.             id content = attributedImage.content; 
77.              if  ([content isKindOfClass:[UIImage  class ]]) 
78.             { 
79.                 CGContextDrawImage(ctx, attatchmentRect, ((UIImage *)content).CGImage); 
80.             } 
81.              else   if  ([content isKindOfClass:[UIView  class ]]) 
82.             { 
83.                 UIView *view = (UIView *)content; 
84.                  if  (view.superview == nil) 
85.                 { 
86.                     [self addSubview:view]; 
87.                 } 
88.                 CGRect viewFrame = CGRectMake(attatchmentRect.origin.x, 
89.                                               self.bounds.size.height - attatchmentRect.origin.y - attatchmentRect.size.height, 
90.                                               attatchmentRect.size.width, 
91.                                               attatchmentRect.size.height); 
92.                 [view setFrame:viewFrame]; 
93.             } 
94.              else  
95.             { 
96.                 NSLog(@ "Attachment Content Not Supported %@" ,content); 
97.             } 
98.  
99.         } 
100.     } 
101. }