基于 CoreText 的排版引擎:进阶


本章前言

在上一篇《基于 CoreText 的排版引擎:基础》中,我们学会了排版的基础知识,现在我们来增加复杂性,让我们的排版引擎支持图片和链接的点击。

支持图文混排的排版引擎

改造模版文件

下面我们来进一步改造,让排版引擎支持对于图片的排版。在上一小节中,我们在设置模版文件的时候,就专门在模板文件里面留了一个名为type的字段,用于表示内容的类型。之前的type的值都是txt,这次,我们增加一个值为img的值,用于表示图片。

我们将上一节的content.json文件修改为如下内容,增加了 2 个type值为img的配置项。由于是图片的配置项,所以我们不需要设置颜色,字号这些图片不具有的属性,但是,我们另外增加了 3 个图片的配置属性:

一个名为width的属性,用于设置图片显示的宽度。

一个名为height的属性,用于设置图片显示的高度。

一个名为name的属性,用于设置图片的资源名。

1234567891011121314151617181920212223242526272829303132

[ {

    "type" : "img",

    "width" : 200,

    "height" : 108,

    "name" : "coretext-image-1.jpg"

  },

  { "color" : "blue",

    "content" : " 更进一步地,实际工作中,我们更希望通过一个排版文件,来设置需要排版的文字的 ",

    "size" : 16,

    "type" : "txt"

  },

  { "color" : "red",

    "content" : " 内容、颜色、字体 ",

    "size" : 22,

    "type" : "txt"

  },

  { "color" : "black",

    "content" : " 大小等信息。\n",

    "size" : 16,

    "type" : "txt"

  },

  {

    "type" : "img",

    "width" : 200,

    "height" : 130,

    "name" : "coretext-image-2.jpg"

  },

  { "color" : "default",

    "content" : " 我在开发猿题库应用时,自己定义了一个基于 UBB 的排版模版,但是实现该排版文件的解析器要花费大量的篇幅,考虑到这并不是本章的重点,所以我们以一个较简单的排版文件来讲解其思想。",

    "type" : "txt"

  }

]

按理说,图片本身的内容信息中,是包含宽度和高度信息的,为什么我们要在这里指定图片的宽高呢?这主要是因为,在真实的开发中,应用的模版和图片通常是通过服务器获取的,模版是纯文本的内容,获取速度比图片快很多,而图片不但获取速度慢,而且为了省流量,通常的做法是直到需要显示图片的时候,再加载图片内容。

如果我们不将图片的宽度和高度信息设置在模板里面,那么 CoreText 在排版的时候就无法知道绘制所需要的高度,我们就无法设置CoreTextData类中的height信息,没有高度信息,就会对 UITableView 一类的控件排版造成影响。所以,除非你的应用图片能够保证在绘制前都能全部在本地,否则就应该另外提前提供图片宽度和高度信息。

在完成模板文件修改后,我们选取两张测试用的图片,分别将其命名为coretext-image-1.jpg和coretext-image-2.jpg(和模板中的值一致),将其拖动增加到工程中。向 Xcode 工程增加图片资源是基础知识,在此就不详细介绍过程了。

CTLine 与 CTRun

接下来我们需要改造的是CTFrameParser类,让解析模板文件的方法支持type为img的配置。

在改造前,我们先来了解一下CTFrame内部的组成。通过之前的例子,我们可以看到,我们首先通过NSAttributeString和配置信息创建CTFrameSetter, 然后,再通过CTFrameSetter来创建CTFrame。

在CTFrame内部,是由多个CTLine来组成的,每个CTLine代表一行,每个CTLine又是由多个CTRun来组成,每个CTRun代表一组显示风格一致的文本。我们不用手工管理CTLine和CTRun的创建过程。

下图是一个CTLine和CTRun的示意图,可以看到,第三行的CTLine是由 2 个CTRun构成的,第一个CTRun为红色大字号的左边部分,第二个CTRun为右边字体较小的部分。

虽然我们不用管理CTRun的创建过程,但是我们可以设置某一个具体的CTRun的CTRunDelegate来指定该文本在绘制时的高度、宽度、排列对齐方式等信息。

对于图片的排版,其实 CoreText 本质上不是直接支持的,但是,我们可以在要显示文本的地方,用一个特殊的空白字符代替,同时设置该字体的CTRunDelegate信息为要显示的图片的宽度和高度信息,这样最后生成的CTFrame实例,就会在绘制时将图片的位置预留出来。

因为我们的CTDisplayView的绘制代码是在drawRect里面的,所以我们可以方便地把需要绘制的图片,用CGContextDrawImage方法直接绘制出来就可以了。

改造模版解析类

在了解了以上原理后,我们就可以开始进行改造了。

我们需要做的工作包括:

改造CTFrameParser的parseTemplateFile:(NSString *)path config:(CTFrameParserConfig*)config;方法,使其支持对type为img的节点解析。并且对type为img的节点,设置其CTRunDelegate信息,使其在绘制时,为图片预留相应的空白位置。

改造CoreTextData类,增加图片相关的信息,并且增加计算图片绘制区域的逻辑。

改造CTDisplayView类,增加绘制图片相关的逻辑。

首先介绍对于CTFrameParser的改造:

我们修改了parseTemplateFile方法,增加了一个名为imageArray的参数来保存解析时的图片信息。

1234567

+ (CoreTextData *)parseTemplateFile:(NSString *)path config:(CTFrameParserConfig*)config {

    NSMutableArray *imageArray = [NSMutableArray array];

    NSAttributedString *content = [self loadTemplateFile:path config:config imageArray:imageArray];

    CoreTextData *data = [self parseAttributedContent:content config:config];

    data.imageArray = imageArray;

    return data;

}

接着我们修改loadTemplateFile方法,增加了对于type是img的节点处理逻辑,该逻辑主要做 2 件事情:

保存当前图片节点信息到imageArray变量中

新建一个空白的占位符。

1234567891011121314151617181920212223242526272829303132

+ (NSAttributedString *)loadTemplateFile:(NSString *)path

                                  config:(CTFrameParserConfig*)config

                              imageArray:(NSMutableArray *)imageArray {

    NSData *data = [NSData dataWithContentsOfFile:path];

    NSMutableAttributedString *result = [[NSMutableAttributedString alloc] init];

    if (data) {

        NSArray *array = [NSJSONSerialization JSONObjectWithData:data

                            options:NSJSONReadingAllowFragments

                              error:nil];

        if ([array isKindOfClass:[NSArray class]]) {

            for (NSDictionary *dict in array) {

                NSString *type = dict[@"type"];

                if ([type isEqualToString:@"txt"]) {

                    NSAttributedString *as =

                        [self parseAttributedContentFromNSDictionary:dict

                                                              config:config];

                    [result appendAttributedString:as];

                } else if ([type isEqualToString:@"img"]) {

                    // 创建 CoreTextImageData

                    CoreTextImageData *imageData = [[CoreTextImageData alloc] init];

                    imageData.name = dict[@"name"];

                    imageData.position = [result length];

                    [imageArray addObject:imageData];

                    // 创建空白占位符,并且设置它的 CTRunDelegate 信息

                    NSAttributedString *as = [self parseImageDataFromNSDictionary:dict config:config];

                    [result appendAttributedString:as];

                }

            }

        }

    }

    return result;

}

最后我们新建一个最关键的方法:parseImageDataFromNSDictionary,生成图片空白的占位符,并且设置其CTRunDelegate信息。其代码如下:

1234567891011121314151617181920212223242526272829303132333435

static CGFloat ascentCallback(void *ref){

    return [(NSNumber*)[(__bridge NSDictionary*)ref objectForKey:@"height"] floatValue];

}

static CGFloat descentCallback(void *ref){

    return 0;

}

static CGFloat widthCallback(void* ref){

    return [(NSNumber*)[(__bridge NSDictionary*)ref objectForKey:@"width"] floatValue];

}

+ (NSAttributedString *)parseImageDataFromNSDictionary:(NSDictionary *)dict

                                                config:(CTFrameParserConfig*)config {

    CTRunDelegateCallbacks callbacks;

    memset(&callbacks, 0, sizeof(CTRunDelegateCallbacks));

    callbacks.version = kCTRunDelegateVersion1;

    callbacks.getAscent = ascentCallback;

    callbacks.getDescent = descentCallback;

    callbacks.getWidth = widthCallback;

    CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, (__bridge void *)(dict));

    // 使用 0xFFFC 作为空白的占位符

    unichar objectReplacementChar = 0xFFFC;

    NSString * content = [NSString stringWithCharacters:&objectReplacementChar length:1];

    NSDictionary * attributes = [self attributesWithConfig:config];

    NSMutableAttributedString * space =

      [[NSMutableAttributedString alloc] initWithString:content

                                              attributes:attributes];

    CFAttributedStringSetAttribute((CFMutableAttributedStringRef)space,

              CFRangeMake(0, 1), kCTRunDelegateAttributeName, delegate);

    CFRelease(delegate);

    return space;

}

接着我们对CoreTextData进行改造,增加了imageArray成员变量,用于保存图片绘制时所需的信息。

1234567891011

#import

#import "CoreTextImageData.h"

@interface CoreTextData : NSObject

@property (assign, nonatomic) CTFrameRef ctFrame;

@property (assign, nonatomic) CGFloat height;

// 新增加的成员

@property (strong, nonatomic) NSArray * imageArray;

@end

在设置imageArray成员时,我们还会调一个新创建的fillImagePosition方法,用于找到每张图片在绘制时的位置。

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364

- (void)setImageArray:(NSArray *)imageArray {

    _imageArray = imageArray;

    [self fillImagePosition];

}

- (void)fillImagePosition {

    if (self.imageArray.count == 0) {

        return;

    }

    NSArray *lines = (NSArray *)CTFrameGetLines(self.ctFrame);

    int lineCount = [lines count];

    CGPoint lineOrigins[lineCount];

    CTFrameGetLineOrigins(self.ctFrame, CFRangeMake(0, 0), lineOrigins);

    int imgIndex = 0;

    CoreTextImageData * imageData = self.imageArray[0];

    for (int i = 0; i < lineCount; ++i) {

        if (imageData == nil) {

            break;

        }

        CTLineRef line = (__bridge CTLineRef)lines[i];

        NSArray * runObjArray = (NSArray *)CTLineGetGlyphRuns(line);

        for (id runObj in runObjArray) {

            CTRunRef run = (__bridge CTRunRef)runObj;

            NSDictionary *runAttributes = (NSDictionary *)CTRunGetAttributes(run);

            CTRunDelegateRef delegate = (__bridge CTRunDelegateRef)[runAttributes valueForKey:(id)kCTRunDelegateAttributeName];

            if (delegate == nil) {

                continue;

            }

            NSDictionary * metaDic = CTRunDelegateGetRefCon(delegate);

            if (![metaDic isKindOfClass:[NSDictionary class]]) {

                continue;

            }

            CGRect runBounds;

            CGFloat ascent;

            CGFloat descent;

            runBounds.size.width = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, &descent, NULL);

            runBounds.size.height = ascent + descent;

            CGFloat xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, NULL);

            runBounds.origin.x = lineOrigins[i].x + xOffset;

            runBounds.origin.y = lineOrigins[i].y;

            runBounds.origin.y -= descent;

            CGPathRef pathRef = CTFrameGetPath(self.ctFrame);

            CGRect colRect = CGPathGetBoundingBox(pathRef);

            CGRect delegateBounds = CGRectOffset(runBounds, colRect.origin.x, colRect.origin.y);

            imageData.imagePosition = delegateBounds;

            imgIndex++;

            if (imgIndex == self.imageArray.count) {

                imageData = nil;

                break;

            } else {

                imageData = self.imageArray[imgIndex];

            }

        }

    }

}

添加对图片的点击支持

实现方式

为了实现对图片的点击支持,我们需要给CTDisplayView类增加用户点击操作的检测函数,在检测函数中,判断当前用户点击的区域是否在图片上,如果在图片上,则触发点击图片的逻辑。苹果提供的UITapGestureRecognizer可以很好的满足我们的要求,所以我们这里用它来检测用户的点击操作。

我们这里实现的是点击图片后,先用NSLog打印出一行日志。实际应用中,读者可以根据业务需求自行调整点击后的效果。

我们先为CTDisplayView类增加UITapGestureRecognizer:

12345678910111213141516

- (id)initWithCoder:(NSCoder *)aDecoder {

    self = [super initWithCoder:aDecoder];

    if (self) {

        [self setupEvents];

    }

    return self;

}

- (void)setupEvents {

    UIGestureRecognizer * tapRecognizer =

          [[UITapGestureRecognizer alloc] initWithTarget:self

                    action:@selector(userTapGestureDetected:)];

    tapRecognizer.delegate = self;

    [self addGestureRecognizer:tapRecognizer];

    self.userInteractionEnabled = YES;

}

然后增加UITapGestureRecognizer的回调函数:

1234567891011121314151617

- (void)userTapGestureDetected:(UIGestureRecognizer *)recognizer {

    CGPoint point = [recognizer locationInView:self];

    for (CoreTextImageData * imageData in self.data.imageArray) {

        // 翻转坐标系,因为 imageData 中的坐标是 CoreText 的坐标系

        CGRect imageRect = imageData.imagePosition;

        CGPoint imagePosition = imageRect.origin;

        imagePosition.y = self.bounds.size.height - imageRect.origin.y

                          - imageRect.size.height;

        CGRect rect = CGRectMake(imagePosition.x, imagePosition.y, imageRect.size.width, imageRect.size.height);

        // 检测点击位置 Point 是否在 rect 之内

        if (CGRectContainsPoint(rect, point)) {

            // 在这里处理点击后的逻辑

            NSLog(@"bingo");

            break;

        }

    }

}

事件处理

在界面上,CTDisplayView通常在UIView的树形层级结构中,一个 UIView 可能是最外层 View Controller 的 View 的孩子的孩子的孩子(如下图所示)。在这种多级层次结构中,很难通过delegate模式将图片点击的事件一层一层往外层传递,所以最好使用NSNotification,来处理图片点击事件。

在 Demo 中,我们在最外层的 View Controller 中监听图片点击的通知,当收到通知后,进入到一个新的界面来显示图片点击内容。

注:读者可以将 demo 工程切换到image_click分支,查看示例代码。

添加对链接的点击支持

修改模板文件

我们修改模版文件,增加一个名为 link 的类型,用于表示链接内容。如下所示:

123456789101112131415

[

  { "color" : "default",

    "content" : " 这在这里尝试放一个参考链接:",

    "type" : "txt"

  },

  { "color" : "blue",

    "content" : " 链接文字 ",

    "url" : "http://blog.devtang.com",

    "type" : "link"

  },

  { "color" : "default",

    "content" : " 大家可以尝试点击一下 ",

    "type" : "txt"

  }

]

解析模版中的链接信息

我们首先增加一个CoreTextLinkData类,用于记录解析 JSON 文件时的链接信息:

1234567

@interface CoreTextLinkData : NSObject

@property (strong, nonatomic) NSString * title;

@property (strong, nonatomic) NSString * url;

@property (assign, nonatomic) NSRange range;

@end

然后我们修改 CTFrameParser 类,增加解析链接的逻辑:

12345678910111213141516171819202122232425262728293031323334353637

+ (NSAttributedString *)loadTemplateFile:(NSString *)path

                                  config:(CTFrameParserConfig*)config

                              imageArray:(NSMutableArray *)imageArray

                              linkArray:(NSMutableArray *)linkArray {

    NSData *data = [NSData dataWithContentsOfFile:path];

    NSMutableAttributedString *result = [[NSMutableAttributedString alloc] init];

    if (data) {

        NSArray *array = [NSJSONSerialization JSONObjectWithData:data

                                        options:NSJSONReadingAllowFragments

                                          error:nil];

        if ([array isKindOfClass:[NSArray class]]) {

            for (NSDictionary *dict in array) {

                NSString *type = dict[@"type"];

                if ([type isEqualToString:@"txt"]) {

                    // 省略

                } else if ([type isEqualToString:@"img"]) {

                    // 省略

                } else if ([type isEqualToString:@"link"]) {

                    NSUInteger startPos = result.length;

                    NSAttributedString *as =

                      [self parseAttributedContentFromNSDictionary:dict

                                                            config:config];

                    [result appendAttributedString:as];

                    // 创建 CoreTextLinkData

                    NSUInteger length = result.length - startPos;

                    NSRange linkRange = NSMakeRange(startPos, length);

                    CoreTextLinkData *linkData = [[CoreTextLinkData alloc] init];

                    linkData.title = dict[@"content"];

                    linkData.url = dict[@"url"];

                    linkData.range = linkRange;

                    [linkArray addObject:linkData];

                }

            }

        }

    }

    return result;

}

然后,我们增加一个 Utils 类来专门处理检测用户点击是否在链接上。主要的方法是使用 CTLineGetStringIndexForPosition 函数来获得用户点击的位置与 NSAttributedString 字符串上的位置的对应关系。这样就知道是点击的哪个字符了。然后判断该字符串是否在链接上即可。该 Util 在实现逻辑如下:

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556

// 检测点击位置是否在链接上

+ (CoreTextLinkData *)touchLinkInView:(UIView *)view atPoint:(CGPoint)point data:(CoreTextData *)data {

    CTFrameRef textFrame = data.ctFrame;

    CFArrayRef lines = CTFrameGetLines(textFrame);

    if (!lines) return nil;

    CFIndex count = CFArrayGetCount(lines);

    CoreTextLinkData *foundLink = nil;

    // 获得每一行的 origin 坐标

    CGPoint origins[count];

    CTFrameGetLineOrigins(textFrame, CFRangeMake(0,0), origins);

    // 翻转坐标系

    CGAffineTransform transform =  CGAffineTransformMakeTranslation(0, view.bounds.size.height);

    transform = CGAffineTransformScale(transform, 1.f, -1.f);

    for (int i = 0; i < count; i++) {

        CGPoint linePoint = origins[i];

        CTLineRef line = CFArrayGetValueAtIndex(lines, i);

        // 获得每一行的 CGRect 信息

        CGRect flippedRect = [self getLineBounds:line point:linePoint];

        CGRect rect = CGRectApplyAffineTransform(flippedRect, transform);

        if (CGRectContainsPoint(rect, point)) {

            // 将点击的坐标转换成相对于当前行的坐标

            CGPoint relativePoint = CGPointMake(point.x-CGRectGetMinX(rect),

                                                point.y-CGRectGetMinY(rect));

            // 获得当前点击坐标对应的字符串偏移

            CFIndex idx = CTLineGetStringIndexForPosition(line, relativePoint);

            // 判断这个偏移是否在我们的链接列表中

            foundLink = [self linkAtIndex:idx linkArray:data.linkArray];

            return foundLink;

        }

    }

    return nil;

}

+ (CGRect)getLineBounds:(CTLineRef)line point:(CGPoint)point {

    CGFloat ascent = 0.0f;

    CGFloat descent = 0.0f;

    CGFloat leading = 0.0f;

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

    CGFloat height = ascent + descent;

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

}

+ (CoreTextLinkData *)linkAtIndex:(CFIndex)i linkArray:(NSArray *)linkArray {

    CoreTextLinkData *link = nil;

    for (CoreTextLinkData *data in linkArray) {

        if (NSLocationInRange(i, data.range)) {

            link = data;

            break;

        }

    }

    return link;

}

最后改造一下CTDisplayView,使其在检测到用户点击后,调用上面的 Util 方法即可。我们这里实现的是点击链接后,先用NSLog打印出一行日志。实际应用中,读者可以根据业务需求自行调整点击后的效果。

12345678910

- (void)userTapGestureDetected:(UIGestureRecognizer *)recognizer {

    CGPoint point = [recognizer locationInView:self];

    // 此处省略上一节中介绍的,对图片点击检测的逻辑

    CoreTextLinkData *linkData = [CoreTextUtils touchLinkInView:self atPoint:point data:self.data];

    if (linkData) {

        NSLog(@"hint link!");

        return;

    }

}

注:在 Demo 中工程中,我们实现了点击链接跳转到一个新的界面,然后用 UIWebView 来显示链接内容的逻辑。读者可以将 demo 工程切换到link_click分支,查看示例代码。

Demo 工程的 Gif 效果图如下,读者可以将示例工程用git checkout image_support切换到当前章节状态,查看相关代码逻辑。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,311评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,339评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,671评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,252评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,253评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,031评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,340评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,973评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,466评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,937评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,039评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,701评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,254评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,259评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,497评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,786评论 2 345

推荐阅读更多精彩内容