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), ¶graphSpacing } }; 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.效果