iOS-电子书开发【基于Coretext的排版引擎】 笔记

前言

刚接手电子书项目时,和安卓开发者pt Cai老师【aipiti Cai,一个我很敬佩很资深的开发工程师,设计领域:c++、Java、安卓、QT等】共同商议了一下,因为项目要做要同步,移动端【手机端】和PC【电脑端】的同步问题,让我们无法决定该用哪种方式去呈现电子书,因为PC要展示的电子书有网络图片,有HTML标签,主要功能是能做标记(涂色、划线、书签等),而且后台数据源返回的只有这一种格式:HTML;所以我们第一时间想到了可以用加载网页的Webview来做;pt Cai老师做了一些基于JS的分页及手势操作,然后对图片进行了适配,但是当我在测试Webview时,效果并不尽人意:

 

  • Webview渲染比较慢,加载需要一定的等待时间,体验不是很好;
  • Webview内存泄漏比较严重;
  • Webview的与本地的交互,交互是有一定的延时,而且对于不断地传递参数不好控制操作;

引入Coretext

通过上面的测试,我决定放弃了Webview,用Coretext来尝试做这些排版和操作;我在网上查了很多资料,从对Coretext的基本开始了解,然后查看了猿题库开发者的博客,在其中学到了不少东西,然后就开始试着慢慢的用Coretext来尝试;

demo

1.主框架

做电子书阅读,首先要有一个翻滚阅读页的一个框架,我并没有选择用苹果自带的 UIPageViewController 因为控制效果不是很好,我再Git上找了一个不错的 DZMCoverAnimation,因为是做demo测试,就先选择一个翻滚阅读页做效果,这个覆盖翻页的效果如下:

 

2.解析数据源

首先看一下数据源demo,我要求json数据最外层必须是P标签,P标签不能嵌套P标签,但可以包含Img和Br标签,Img标签内必须含有宽高属性,以便做排版时适配,最终的数据源为

 

然后我在项目中用CocoaPods引入解析HTML文件的 hpple 三方库,在解析工具类CoreTextSource中添加解析数据模型和方法,假如上面的这个数据源是一章的内容,我把这一章内容最外层的每个P标签当做一个段落,遍历每个段落,然后在遍历每个段落里面的内容和其他标签;

 

CoreTextSource.h

#import <Foundation/Foundation.h>
#import <hpple/TFHpple.h>

#import <UIKit/UIKit.h>
typedef NS_ENUM(NSInteger,CoreTextSourceType){
    ///文本
    CoreTextSourceTypeTxt = 1,
    ///图片
    CoreTextSourceTypeImage
};

/**
 文本
 */
@interface CoreTextTxtSource : NSObject
@property (nonatomic,strong) NSString *content;
@end

/**
 图片
 */
@interface CoreTextImgSource : NSObject
@property (nonatomic,strong) NSString *name;
@property (nonatomic,assign) CGFloat width;
@property (nonatomic,assign) CGFloat height;
@property (nonatomic,strong) NSString *url;
// 此坐标是 CoreText 的坐标系,而不是UIKit的坐标系
@property (nonatomic,assign) NSInteger position;
@property (nonatomic,assign) CGRect imagePosition;
@end

/**
 段落内容
 */
@interface CoreTextParagraphSource : NSObject
@property (nonatomic,assign) CoreTextSourceType type;
@property (nonatomic,strong) CoreTextImgSource *imgData;
@property (nonatomic,strong) CoreTextTxtSource *txtData;
@end
///电子书数据源
@interface CoreTextSource : NSObject
///解析HTML格式
+ (NSArray *)arrayReaolveChapterHtmlDataWithFilePath:(NSString *)filePath;
@end
View Code

CoreTextSource.m

#import "CoreTextSource.h"

@implementation CoreTextImgSource

@end
@implementation CoreTextParagraphSource

@end
@implementation CoreTextTxtSource

@end

@implementation CoreTextSource

+ (NSArray *)arrayReaolveChapterHtmlDataWithFilePath:(NSString *)filePath{
    NSData  * data   = [NSData dataWithContentsOfFile:filePath];
    
    TFHpple * dataSource = [[TFHpple alloc] initWithHTMLData:data];
    NSArray * elements = [dataSource searchWithXPathQuery:@"//p"];
    
    NSMutableArray *arrayData = [NSMutableArray array];
    
    for (TFHppleElement *element in elements) {
        NSArray *arrrayChild = [element children];
        for (TFHppleElement *elementChild in arrrayChild) {
            CoreTextParagraphSource *paragraphSource = [[CoreTextParagraphSource alloc]init];
            NSString *type = [elementChild tagName];
            if ([type isEqualToString:@"text"]) {
                CoreTextTxtSource *text = [[CoreTextTxtSource alloc]init];
                text.content = elementChild.content;
                paragraphSource.txtData = text;
                paragraphSource.type = CoreTextSourceTypeTxt;
            }
            else if ([type isEqualToString:@"img"]){
                CoreTextImgSource *image = [[CoreTextImgSource alloc]init];
                NSDictionary *dicAttributes = [elementChild attributes];
                image.name = [dicAttributes[@"src"] lastPathComponent];
                image.url = dicAttributes[@"src"];
                image.width = [dicAttributes[@"width"] floatValue];
                image.height = [dicAttributes[@"height"] floatValue];
                paragraphSource.imgData = image;
                paragraphSource.type = CoreTextSourceTypeImage;
                
                if (image.width >= (Scr_Width - 30)) {
                    CGFloat ratioHW = image.height/image.width;
                    image.width = Scr_Width - 30;
                    image.height = image.width * ratioHW;
                }
            }
            else if ([type isEqualToString:@"br"]){
                CoreTextTxtSource *text = [[CoreTextTxtSource alloc]init];
                text.content = @"\n";
                paragraphSource.txtData = text;
                paragraphSource.type = CoreTextSourceTypeTxt;
            }
            
            [arrayData addObject:paragraphSource];
        }
        
        ///每个个<P>后加换行
        CoreTextParagraphSource *paragraphNewline = [[CoreTextParagraphSource alloc]init];
        CoreTextTxtSource *textNewline = [[CoreTextTxtSource alloc]init];
        textNewline.content = @"\n";
        paragraphNewline.txtData = textNewline;
        paragraphNewline.type = CoreTextSourceTypeTxt;
        [arrayData addObject:paragraphNewline];
    }
    
    return arrayData;
}
@end
View Code

3.图片处理和分页

添加好CoreTextSource类之后,就可以通过 arrayReaolveChapterHtmlDataWithFilePath 方法获取这一章的所有段落内容;但是还有一个问题,既然用Coretext来渲染,那图片要在渲染之前下载好,从本地获取下载好的图片进行渲染,具体什么时候下载,视项目而定;我在CoreTextDataTools类中添加了图片下载方法,该类主要用于分页;在分页之前,添加每个阅读页的model -> CoreTextDataModel,具体图片的渲染,先详看CoreTextDataTools分页类中 wkj_coreTextPaging 方法和其中引用到的方法;

CoreTextDataModel.h

#import <Foundation/Foundation.h>

///标记显示模型
@interface CoreTextMarkModel : NSObject
@property (nonatomic,assign) BookMarkType type;
@property (nonatomic,assign) NSRange range;
@property (nonatomic,strong) NSString *content;
@property (nonatomic,strong) UIColor *color;
@end

@interface CoreTextDataModel : NSObject
///
@property (nonatomic,assign) CTFrameRef ctFrame;
@property (nonatomic,strong) NSAttributedString *content;
@property (nonatomic,assign) NSRange range;
///图片数据模型数组 CoreTextImgSource
@property (nonatomic,strong) NSArray *arrayImage;
///标记数组
@property (nonatomic,copy) NSArray *arrayMark;
@end
View Code 

CoreTextDataModel.m

#import "CoreTextDataModel.h"
@implementation CoreTextMarkModel

@end

@implementation CoreTextDataModel
- (void)setCtFrame:(CTFrameRef)ctFrame{
    if (_ctFrame != ctFrame) {
        if (_ctFrame != nil) {
            CFRelease(_ctFrame);
        }
        CFRetain(ctFrame);
        _ctFrame = ctFrame;
    }
}
@end
View Code

 

CoreTextDataTools.h

///图片下载
+ (void)wkj_downloadBookImage:(NSArray *)arrayParagraph;
///分页
+ (NSArray *)wkj_coreTextPaging:(NSAttributedString *)str
                       textArea:(CGRect)textFrame
           arrayParagraphSource:(NSArray *)arrayParagraph;
///根据一个章节的所有段落内容,来生成 AttributedString 包括图片
+ (NSAttributedString *)wkj_loadChapterParagraphArray:(NSArray *)arrayArray;
View Code

CoreTextDataTools.m

#import "CoreTextDataTools.h"
#import <SDWebImage/UIImage+MultiFormat.h>

@implementation CoreTextDataTools
+ (void)wkj_downloadBookImage:(NSArray *)arrayParagraph{
    dispatch_group_t group = dispatch_group_create();
    // 有多张图片URL的数组
    for (CoreTextParagraphSource *paragraph in arrayParagraph) {
        if (paragraph.type == CoreTextSourceTypeTxt) {
            continue;
        }
        
        dispatch_group_enter(group);
        // 需要加载图片的控件(UIImageView, UIButton等)
        NSData *data = [NSData dataWithContentsOfURL:[NSURL  URLWithString:paragraph.imgData.url]];
        UIImage *image = [UIImage sd_imageWithData:data];
        // 本地沙盒目录
        NSString *path = wkj_documentPath;
        ///创建文件夹
        NSString *folderName = [path stringByAppendingPathComponent:@"wkjimage"];
        
        if (![[NSFileManager defaultManager]fileExistsAtPath:folderName]) {
            
            [[NSFileManager defaultManager] createDirectoryAtPath:folderName  withIntermediateDirectories:YES  attributes:nil error:nil];
            
        }else{
            NSLog(@"有这个文件了");
        }
        
        // 得到本地沙盒中名为"MyImage"的路径,"MyImage"是保存的图片名
        //        NSString *imageFilePath = [path stringByAppendingPathComponent:@"MyImage"];
        
        // 将取得的图片写入本地的沙盒中,其中0.5表示压缩比例,1表示不压缩,数值越小压缩比例越大
        
        folderName = [folderName stringByAppendingPathComponent:[paragraph.imgData.url lastPathComponent]];
        
        BOOL success = [UIImageJPEGRepresentation(image, 0.1) writeToFile:folderName  atomically:YES];
        if (success){
            NSLog(@"写入本地成功");
        }
        
        dispatch_group_leave(group);
        
    }
    // 下载图片完成后, 回到主线
    dispatch_group_notify(group, dispatch_get_main_queue(), ^{
        // 刷新UI
        
    });
}
/**
 CoreText 分页
 str: NSAttributedString属性字符串
 textFrame: 绘制区域
 */
+ (NSArray *)wkj_coreTextPaging:(NSAttributedString *)str
                       textArea:(CGRect)textFrame
           arrayParagraphSource:(NSArray *)arrayParagraph{
    NSMutableArray *arrayCoretext = [NSMutableArray array];
    
    CFAttributedStringRef cfStrRef = (__bridge CFAttributedStringRef)str;
    CTFramesetterRef framesetterRef = CTFramesetterCreateWithAttributedString(cfStrRef);
    CGPathRef path = CGPathCreateWithRect(textFrame, NULL);
    
    int textPos = 0;
    NSUInteger strLength = [str length];
    while (textPos < strLength)  {
        //设置路径
        CTFrameRef frame = CTFramesetterCreateFrame(framesetterRef, CFRangeMake(textPos, 0), path, NULL);
        CFRange frameRange = CTFrameGetVisibleStringRange(frame);
        NSRange range = NSMakeRange(frameRange.location, frameRange.length);
        
        //        [arrayPagingRange addObject:[NSValue valueWithRange:range]];
        //        [arrayPagingStr addObject:[str attributedSubstringFromRange:range]];
    
        
        CoreTextDataModel *model = [[CoreTextDataModel alloc]init];
        model.ctFrame = frame;
        model.range = range;
        model.content = [str attributedSubstringFromRange:range];
        model.arrayImage = [self wkj_arrayCoreTextImgRect:[self wkj_arrayCoreTextImg:arrayParagraph range:range] cfFrame:frame];
        
        [arrayCoretext addObject:model];
        //移动
        textPos += frameRange.length;
        CFRelease(frame);
    }
    CGPathRelease(path);
    CFRelease(framesetterRef);
    //    return arrayPagingStr;
    return arrayCoretext;
}
///获取每页区域内存在的图片
+ (NSArray *)wkj_arrayCoreTextImg:(NSArray *)arrayParagraph
                                  range:(NSRange)range{
    NSMutableArray *array = [NSMutableArray array];
    
    for (CoreTextParagraphSource *paragraph in arrayParagraph) {
        if (paragraph.type == CoreTextSourceTypeTxt) {
            continue;
        }
        
        if (paragraph.imgData.position >= range.location &&
            paragraph.imgData.position < (range.location + range.length)) {
            [array addObject:paragraph.imgData];
        }
    }
    
    return array;
}
///获取每个区域内存在的图片位置
+ (NSArray *)wkj_arrayCoreTextImgRect:(NSArray *)arrayCoreTextImg cfFrame:(CTFrameRef)frameRef{
    NSMutableArray *arrayImgData = [NSMutableArray array];
    
    if (arrayCoreTextImg.count == 0) {
        return arrayCoreTextImg;
    }
    NSArray *lines = (NSArray *)CTFrameGetLines(frameRef);
    NSUInteger lineCount = [lines count];
    CGPoint lineOrigins[lineCount];
    CTFrameGetLineOrigins(frameRef, CFRangeMake(0, 0), lineOrigins);
    int imgIndex = 0;
    CoreTextImgSource * imageData = arrayCoreTextImg[0];
    for (int i = 0; i < lineCount; ++i) {

        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;
            }
            

            
            CoreTextImgSource * metaImgSource = CTRunDelegateGetRefCon(delegate);
            if (![metaImgSource isKindOfClass:[CoreTextImgSource 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(frameRef);
            CGRect colRect = CGPathGetBoundingBox(pathRef);
            
            CGRect delegateBounds = CGRectOffset(runBounds, colRect.origin.x, colRect.origin.y);
            
            imageData.imagePosition = delegateBounds;
            CoreTextImgSource *img = imageData;
            [arrayImgData addObject:img];
            imgIndex++;
            if (imgIndex == arrayCoreTextImg.count) {
                imageData = nil;
                break;
            } else {
                imageData = arrayCoreTextImg[imgIndex];
            }
        }
        
        if (imgIndex == arrayCoreTextImg.count) {
            break;
        }
        
    }
    
    return arrayImgData;
    
}




///获取属性字符串字典
+ (NSMutableDictionary *)wkj_attributes{
    CGFloat fontSize = [BookThemeManager sharedManager].fontSize;
    CTFontRef fontRef = CTFontCreateWithName((CFStringRef)@"ArialMT", fontSize, NULL);
    ///行间距
    CGFloat lineSpacing = [BookThemeManager sharedManager].lineSpace;
    ///首行缩进
    CGFloat firstLineHeadIndent = [BookThemeManager sharedManager].firstLineHeadIndent;
    ///段落间距
    CGFloat paragraphSpacing = [BookThemeManager sharedManager].ParagraphSpacing;
    //换行模式
    CTLineBreakMode lineBreak = kCTLineBreakByCharWrapping;
    const CFIndex kNumberOfSettings = 6;
    CTParagraphStyleSetting theSettings[kNumberOfSettings] = {
        ///行间距
        { kCTParagraphStyleSpecifierLineSpacingAdjustment, sizeof(CGFloat), &lineSpacing },
        { kCTParagraphStyleSpecifierMaximumLineSpacing, sizeof(CGFloat), &lineSpacing },
        { kCTParagraphStyleSpecifierMinimumLineSpacing, sizeof(CGFloat), &lineSpacing },
        ///首行缩进
        { kCTParagraphStyleSpecifierFirstLineHeadIndent, sizeof(CGFloat), &firstLineHeadIndent },
        ///换行模式
        { kCTParagraphStyleSpecifierLineBreakMode, sizeof(CTLineBreakMode), &lineBreak },
        ///段落间距
        { kCTParagraphStyleSpecifierParagraphSpacing, sizeof(CGFloat), &paragraphSpacing }
    };
    
    CTParagraphStyleRef theParagraphRef = CTParagraphStyleCreate(theSettings, kNumberOfSettings);
    
    UIColor * textColor = [BookThemeManager sharedManager].textColor;
    
    NSMutableDictionary * dict = [NSMutableDictionary dictionary];
    dict[(id)kCTForegroundColorAttributeName] = (id)textColor.CGColor;
    dict[(id)kCTFontAttributeName] = (__bridge id)fontRef;
    dict[(id)kCTParagraphStyleAttributeName] = (__bridge id)theParagraphRef;
    CFRelease(theParagraphRef);
    CFRelease(fontRef);
    return dict;
}






///根据一个章节的所有段落内容,来生成 AttributedString 包括图片
+ (NSAttributedString *)wkj_loadChapterParagraphArray:(NSArray *)arrayArray{
    
    NSMutableAttributedString *resultAtt = [[NSMutableAttributedString alloc] init];
    
    for (CoreTextParagraphSource *paragraph in arrayArray) {
        if (paragraph.type == CoreTextSourceTypeTxt) {///文本
            NSAttributedString *txtAtt = [self wkj_parseContentFromCoreTextParagraph:paragraph];
            [resultAtt appendAttributedString:txtAtt];
        }
        else if (paragraph.type == CoreTextSourceTypeImage){///图片
            paragraph.imgData.position = resultAtt.length;
            NSAttributedString *imageAtt = [self wkj_parseImageFromCoreTextParagraph:paragraph];
            [resultAtt appendAttributedString:imageAtt];
        }
    }
    
    return resultAtt;
}

///根据段落文本内容获取 AttributedString
+ (NSAttributedString  *)wkj_parseContentFromCoreTextParagraph:(CoreTextParagraphSource *)paragraph{
    NSMutableDictionary *attributes = [self wkj_attributes];
    return [[NSAttributedString alloc] initWithString:paragraph.txtData.content attributes:attributes];
}


/////根据段落图片内容获取 AttributedString 空白占位符
+ (NSAttributedString *)wkj_parseImageFromCoreTextParagraph:(CoreTextParagraphSource *)paragraph{

    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 *)(paragraph.imgData));

    // 使用0xFFFC作为空白的占位符
    unichar objectReplacementChar = 0xFFFC;
    NSString * content = [NSString stringWithCharacters:&objectReplacementChar length:1];
    NSMutableDictionary * attributes = [self wkj_attributes];
    //    attributes[(id)kCTBackgroundColorAttributeName] = (id)[UIColor yellowColor].CGColor;
    NSMutableAttributedString * space = [[NSMutableAttributedString alloc] initWithString:content attributes:attributes];
    CFAttributedStringSetAttribute((CFMutableAttributedStringRef)space, CFRangeMake(0, 1),
                                   kCTRunDelegateAttributeName, delegate);
    CFRelease(delegate);
    return space;
}

//+ (NSAttributedString *)wkj_NewlineAttributes{
//    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 *)(paragraph));
//
//    // 使用0xFFFC作为空白的占位符
//    unichar objectReplacementChar = 0xFFFC;
//    NSString * content = [NSString stringWithCharacters:&objectReplacementChar length:1];
//    NSMutableDictionary * attributes = [self wkj_attributes];
//    //    attributes[(id)kCTBackgroundColorAttributeName] = (id)[UIColor yellowColor].CGColor;
//    NSMutableAttributedString * space = [[NSMutableAttributedString alloc] initWithString:content attributes:attributes];
//    CFAttributedStringSetAttribute((CFMutableAttributedStringRef)space, CFRangeMake(0, 1),
//                                   kCTRunDelegateAttributeName, delegate);
//    CFRelease(delegate);
//    return space;
//}

static CGFloat ascentCallback(void *ref){
//    return [(NSNumber*)[(__bridge NSDictionary*)ref objectForKey:@"height"] floatValue];
    CoreTextImgSource *refP = (__bridge CoreTextImgSource *)ref;
    return refP.height;
}

static CGFloat descentCallback(void *ref){
    return 0;
}

static CGFloat widthCallback(void* ref){
//    return [(NSNumber*)[(__bridge NSDictionary*)ref objectForKey:@"width"] floatValue];
    
    CoreTextImgSource *refP = (__bridge CoreTextImgSource *)ref;
    return refP.width;
}

@end
View Code

添加好CoreTextDataTools类之后,就可以通过 wkj_downloadBookImage 方法来下载图片;图片下载完之后,就可以对每页显示的内容区域进行分页;划线和涂色的一些方法在上一篇中已提到;

    ///获取测试数据源文件
    NSString *path = [[NSBundle mainBundle] pathForResource:@"index" ofType:@"html"];
    ///获取该章所有段落内容
    NSArray *arrayParagraphSource = [CoreTextSource arrayReaolveChapterHtmlDataWithFilePath:path];
    ///下载该章中的所有图片
    [CoreTextDataTools wkj_downloadBookImage:arrayParagraphSource];
    ///根据一个章节的所有段落内容,来生成 AttributedString 包括图片
    NSAttributedString *att = [CoreTextDataTools wkj_loadChapterParagraphArray:arrayParagraphSource];
    ///给章所有内容分页 返回 CoreTextDataModel 数组
    NSArray *array = [CoreTextDataTools wkj_coreTextPaging:att textArea:CGRectMake(5, 5, self.view.bounds.size.width - 10, self.view.bounds.size.heigh                     t- 120) arrayParagraphSource:arrayParagraphSource];

 

4.效果

 

posted @ 2018-02-09 16:41  macroK  阅读(4166)  评论(3编辑  收藏  举报