前言
最近为了解决切章过程中碰到的各种疑难杂症,不得不深入到TxtEngine中调试分析一些细节,尽管之前为了解决卡牌的排版问题也接触过,但是每次都是只见树木不见森林。为了避免一叶障目不见泰山,所以下决心梳理下该阅读引擎,从整体上了解引擎的工作原理,包括打开文件、排版、渲染、翻页等环节。
打开文件
获取文件大小和文件流
1 | // 给定文件路径,获取文件大小。 |
编码检测
在对文件流解析之前,需要确定其编码类型。那么如何判断一个文件的编码类型呢 ?
有BOM文件的识别
BOM(Byte Order Mark),字节序标志,通常在文件的最开头,添加额外的几个无效字节,用来专门说明该文件是什么类型编码的文件,有的情况下BOM还带着大小端的信息。读取到文件的开头几个字节,就能明确该用什么样的编码方式解析文件流。
常见的UTF编码方式有以下这些,其中UTF-16和UTF-32还有大小端之分1
2
3
4
5FF FE 00 00 : UTF-32/UCS-4, 小端
00 00 FE FF : UTF-32/UCS-4, 大端
FF FE : UTF-16, 小端
FE FF : UTF-16, 大端
EF BB BF : UTF-8 没有大小端之分
为什么UTF-8没有大小端的问题呢?这是因为如果一个字符使用utf-8表示,就需要将这个字符的Unicode码,编码成字节数组,所以对于字节数组写入内存时,只需要按照数组的顺序,一个一个字节写入,不存在高位和低位的问题。 所以,一个汉字在任何类型的CPU中生成的utf-8序列是一样的。
另外,在微软Window操作系统中,会给utf-8文件添加BOM【EF BB BF】(虽然不需要这么做),这并不是说明UTF-8需要字节序,而是仅仅表名该文件是utf-8编码的文件。有的情况下这样多余的动作有时候反而会带来额外的麻烦,所以在编码判定过程中也应该将这样的情况考虑在内,切莫把BOM作为该文件开头正文的一部分。
到此为止,打开一个文件,如果看到有BOM,就能通过识别BOM,来判断该文件的编码是UTF-16、UTF-32,UTF-8,同时判断是大端还是小端。
无BOM文件的识别
对于没有BOM的文件,又该如何判定它的编码类型呢?
这里拿UTF-8和GBK举例说明(GBK编码没有BOM,UIF-8允许含BOM,但通常没有BOM)
先看UTF-8:
UTF-8(Unicode Transformation Format-8bit)是用以解决国际上字符的一种多字节编码,它对英文使用8位(即一个字节),中文使用24为(三个字节)来编码。UTF-8包含全世界所有国家需要用到的字符,是国际编码,通用性强。它是一种多字节编码的字符集,表示一个Unicode字符时,它可以是1个至多个字节,在表示上有规律:
1 | 1字节:0xxxxxxx |
通过识别头部,就能知道以几个字节为单位进行解析,并且头部只可能出现这几种情况,之后的每个字节均是以10开头的,规律非常明显。 这样就可以根据上面的特征对字符串进行遍历来判断一个字符串是不是UTF-8编码了。
再看GBK:
GBK是国家标准GB2312基础上扩容后兼容GB2312的标准。GBK的文字编码是用双字节来表示的,即不论中、英文字符均使用双字节来表示,为了区分中文,将其最高位都设定成1。GBK编码就是基于区位码的,用双字节编码表示中文和中文符号。一般编码方式是:0xA0+区号,0xA0+位号。例如汉字:“安”,区位号是1618(十进制),那么“安”字的GBK编码就是 0xA0+16 0xA0+18 也就是 0xB0 0xB2。虽然是双字节显示,但是GBK的每个字节的取值也是有范围的,GBK总体编码范围为0x8140~0xFEFE,首字节在 0x81~0xFE 之间,尾字节在 0x40~0xFE 之间,剔除 xx7F 一条线。
通过以上,我们可以看出虽然GBK和UTF-8的文件没有标记,但是他们的编码结果都是有鲜明特征的,我们可以很容易的通过查看文件字节序列来判断该文件是GBK还是UTF-8编码的。
然而这样也带来了一定的性能消耗:必须从头到尾检测每一个字符是否都在某种编码正常的规则范围之内,例如下面的代码中,全文检测每个字符是否符合UTF-8规范。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34bool isUTF8( const char *str, int length )
{
int i, mode = 0, m2;
unsigned long ucs;
char ch;
for( i = 0 ; i < length - 2 ; i++ ){
ch = str[ i ];
if( mode == 0 )
{
if( (ch&0x80) == 0 ) continue;
if( (ch&0xe0) == 0xc0 ){ mode = m2 = 1; ucs = (ch&0x1f); continue; }
if( (ch&0xf0) == 0xe0 ){ mode = m2 = 2; ucs = (ch&0x0f); continue; }
if( (ch&0xf8) == 0xf0 ){ mode = m2 = 3; ucs = (ch&0x07); continue; }
if( (ch&0xfc) == 0xf8 ){ mode = m2 = 4; ucs = (ch&0x03); continue; }
if( (ch&0xfe) == 0xfc ){ mode = m2 = 5; ucs = (ch&0x01); continue; }
return false;
}
else
{
if( (ch&0xc0) != 0x80 ) return 0;
ucs <<= 6; ucs += (ch&0x3f);
mode--;
if( !mode ){
if( m2 == 1 && ucs < 0x0000080 ) return 0;
if( m2 == 2 && ucs < 0x0000800 ) return 0;
if( m2 == 3 && ucs < 0x0010000 ) return 0;
if( m2 == 4 && ucs < 0x0200000 ) return 0;
if( m2 == 5 && ucs < 0x4000000 ) return 0;
}
}
}
return true;
}
获取到文件的编码格式、字节序等信息后,确定一些无意义的字节_docBOMSize
,
排版设置
- 设置显示区域
- 设置字体类型和字体大小
- 设置行高、行间距、段间距
区域、行间距、段间距都比较简单,这里重点说明下字体、行高、字宽的问题
字体注册
如果是系统自带的字体,那么这里无需做什么,然而对于不是内置的字体,首先要加载字体并注册:1
2
3
4
5
6
7
8
9
10
11
12
13// 加载字体
NSString *fontFilePath = [self getFontFilePath:fontType];
NSString *url = [NSURL fileURLWithPath:fontFilePath];
CGDataProviderRef fontDataProvider = CGDataProviderCreateWithURL((CFURLRef)url);
CGFontRef newFont = CGFontCreateWithDataProvider(fontDataProvider);
CGDataProviderRelease(fontDataProvider);
// 注册字体
CTFontManagerRegisterGraphicsFont(newFont, NULL);
// 获取CTFontRef
CTFontRef ctFont = CTFontCreateWithGraphicsFont(newFont, fontSize,NULL,NULL);
return ctFont;
字宽计算
TxtEngine中涉及到的主要字符:ASCII字符、CJK字符和一些标点符号,对于每一种字体,在某个行高下,都需要去计算对应的字宽
目前QQ阅读的字体有多种选择:除了自带的系统黑体,还提供了很多其它可供下载的字体:汉仪书宋、汉仪旗黑、汉仪楷体、方正兰亭、方正书宋、方正卡通、方正启体、方正行黑等,因为有字体注册功能,所以这里可以继续扩展新的字体。
对于每一种字体,TxtEngine支持的字体高度介于10~55
1 |
- 对于每一种字体,在每一个行高下,都需要额外计算并缓存相应的字宽(懒加载方式)
1 | unsigned char _bak_ascii_width[CacheFontSizeMax-CacheFontSizeMin+1][256][20]; // 字体大小|ASCII字符|字体类型 |
ASCII字符宽度
1 | // cache visible character width |
font_character_width
会根据当前的字体类型确定是否是系统内置字体,如果不是,还需要找到字体文件进行加载注册,最后将字体对应的CTFontRef
返回,然后根据字体的CTFontRef和字符计算宽度1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33int font_string_width_byCTFont(CTFontRef ctFont, NSString* uniChar)
{
UniChar *characters;
CGGlyph *glyphs;
CFIndex count = [uniChar length] ;//1;
// Allocate our buffers for characters and glyphs.
characters = (UniChar *)malloc(sizeof(UniChar) * count);
//assert(characters != NULL);
glyphs = (CGGlyph *)malloc(sizeof(CGGlyph) * count);
//assert(glyphs != NULL);
// Get the characters from the string.
CFStringGetCharacters((CFStringRef)uniChar, CFRangeMake(0, count), characters);
// Get the glyphs for the characters.
CTFontGetGlyphsForCharacters(ctFont, characters, glyphs, count);
// Do something with the glyphs here, if a character is unmapped
CGSize *cSize = (CGSize *)malloc(sizeof(CGSize)*count);
// 英文字体可能会有小数,所以这里做四舍五入,以免英文字体挤压。
int fontwidth = (int) (0.5 + CTFontGetAdvancesForGlyphs(ctFont, kCTFontDefaultOrientation, glyphs, cSize, count) );
// Free our buffers
free(characters);
free(glyphs);
free(cSize);
return fontwidth;
}
设置汉字宽度
中日韩文字(CJK)宽度基本上是固定的,因此只需要计算某一个字符的宽度并缓存下来,而不是每次都计算一个字的宽度,这样可以加快排版速度。这里font_character_width_2
的第二个参数就是取了CJK的第一个unicode 0x4E00,用来代表所有的CJK符号的宽度。1
2
3
4
5
6
7
8
9
10(chr >= 0x4E00 && chr <= 0x9FBF) // 4E00-9FBF:CJK 统一表意符号 (CJK Unified Ideographs)
(chr >= 0xF900 && chr <= 0xFAFF) // F900-FAFF:CJK 兼容象形文字 (CJK Compatibility Ideographs)
(chr >= 0xFE30 && chr <= 0xFE4F) // FE30-FE4F:CJK 兼容形式 (CJK Compatibility Forms)
(chr >= 0xFF00 && chr <= 0xFFEF) // FF00-FFEF:半型及全型形式 (Halfwidth and Fullwidth Form)全角标点、全角ASCII
(chr >= 0x3200 && chr <= 0x32FF) // 3200-32FF:封闭式 CJK 文字和月份 (Enclosed CJK Letters and Months)
(chr >= 0xAC00 && chr <= 0xD7AF) // 韩文拼音
(chr >= 0x1100 && chr <= 0x11FF) // 韩文字母
(chr >= 0x3130 && chr <= 0x318F) // 韩文兼容字母
(chr >= 0x3040 && chr <= 0x309F) // 日文平假名 (Hiragana)
(chr >= 0x30A0 && chr <= 0x30FF) // 日文片假名 (Katakana)
1 | // cache chinese character |
设置标点符号宽度
排版过程中用到的标点符号(这里是全角,半角的应该包含在ASCII字符中)主要包括以下类型
1 | // 标点符合类型 |
对于这些标点符号,会计算每一个标点符号在某种字体大小条件下的宽度,并进行缓存,计算过程和前面计算ASCII的过程一致
1 | // cache biaodian character |
排版
在打开文件之后,一般都会从一个锚点进入,锚点包含2部分:段落偏移和行偏移。
分段
如果锚点所在的段落链表尚未建立,便会触发段落解析逻辑。分段逻辑是根据这几个标识符来进行的:【\n\r】【\n】【\r】【\r\n】,每次都会从当前的数据流中找到下一个标识符,如果找到了,就可以根据前后两个偏移值确定一个新段落的偏移和大小
1 | - (BOOL) NextBreaker:(unsigned char *) buffcountCharInWidth |
以UTF-8格式为例:
1 | "第1章 下辈子也不会爱上你\r\n6月6日,星期六。\r\n 这是一个十分吉利的日子,更是一个黄道吉日。\r\n 这一日,是京城有名的富家之女梁贝璇与其男友曹瑞年的婚礼,婚礼早就被各路媒体公布出去了,好多人的眼睛都在看着这一个家室及其不匹配的婚姻,所有人都报以怀疑的态度,只是没有人会主动说出来。\r\n 此时的梁贝璇坐在宽敞明亮、奢华至极的房间内,穿着洁白定制的婚纱,脸上带着甜蜜的梦幻般的笑容。\r\n 只是这样的笑容在她收到一个快递之后就彻底的失去了......\r\n |
这是一部分章节内容,如何进行分段呢?当然是从前往后一次遍历,逐个寻找\r\n,直到结束
1 | bool qr_utf8_next_breaker( unsigned char * buff, unsigned long buff_size, unsigned long * brk_off, unsigned long * skip_bytes ) |
这样一次遍历后,便会找到一个段落的开始偏移和结束偏移
获取段落字数
拿到一个段落对应的字节流之后,需要确定其对应的字符个数
1 | - (BOOL) CharCount:( unsigned char * )buff |
还是以UTF-8举例,一般情况下,utf8每一个字可能有1到3各字节1
2
3
41字节:0xxxxxxx 0000~0111 8种情况都是1个字节
2字节:110xxxxx 1100和1101 索引为12和13的两种情况都是2个字节
3字节:1110xxxx 1110,只有1种情况,是3个字节
0字节:其它的都是0个字节
有了上面的基础,就可以用一个数组__bpc存放字节数,取utf8首字节的高4位,转换为索引,直接获取对应的字节数1
2
3
4
5
6
7
8
9static const unsigned long __bpc[16] =
{
1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 2, 2, 3, 0
};
int qr_utf_char_bytes(unsigned char ch)
{
return (int)(__bpc[ch >> 4]);
}
有了上面的铺垫,就可以快速计算某一段字节流对应的字符数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26unsigned long qr_utf8_charcnt( unsigned char * buff, unsigned long buff_size )
{
unsigned long chr_cnt;
unsigned long offset;
unsigned long chr_bytes;
if ( NULL == buff || 0 == buff_size )
return 0;
for ( chr_cnt = 0, offset = 0; offset < buff_size; ++chr_cnt )
{
if ( qr_is_end_char( buff[offset] ) )
break;
chr_bytes = qr_utf_char_bytes( buff[offset] );//
if ( chr_bytes == 0 )
break;
if ( offset + chr_bytes > buff_size )
break;
offset += chr_bytes;
}
return chr_cnt;
}
生成段落结构体
TxtEngine里面有2种段落结构体,分别是段文本__struct_TextPrgf_Text
和段属性__struct_TextPrgf
1 | typedef struct __struct_TextPrgf_Text TextPrgf_Text_t; |
1 | typedef struct __struct_TextPrgf TextPrgf_t; |
值得注意的是:段文本的第一个字段link
正好是段属性类型,所以给定一个段文本结构体:
1 | __struct_TextPrgf_Text textPrgf_Text; |
可以直接强制转换1
__struct_TextPrgf textPrgf = (__struct_TextPrgf)textPrgf_Text;
前面解析了段落的开始和结束偏移,并且计算出了字符数,确定了需要申请的内存大小,此时创建段落:
1 | //申请空间 |
1 | TextPrgf_ptr_t qr_alloc_prgf_text( unsigned long char_cnt ) |
计算字符偏移
计算每个字相对文件的字节offset
1 | [_endcoder countCharactersOffset:&_readCache[prgf_offset] |
1 | - (BOOL)_utf8CountCharactersOffset:(unsigned char *)buff |
编码转换
将字符编码转成Unicode,并存在&text_prgf->wide_buff[0]
1 | - (BOOL) EncodeUCS2:( unsigned short *) dst_buff |
这里以utf8转换为unicode为例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69unsigned long qr_utf8_to_ucs2( unsigned short * buff, unsigned long buff_size, unsigned char * utf8_str, unsigned long utf8_len,unsigned long* used_utf8_len )
{
unsigned long conv_len = 0;
unsigned long char_cnt = 0;
unsigned long char_bytes;
unsigned char * us_utf8_str = (unsigned char*)utf8_str;
unsigned long i;
unsigned short tmp_ch = '\0';
if ( NULL == buff || NULL == us_utf8_str || buff_size < 2 || 0 == utf8_len )
return 0;
*used_utf8_len = 0;
if ( utf8_len >= 3 && (us_utf8_str[0] == 0xEF && us_utf8_str[1] == 0xBB && us_utf8_str[2] == 0xBF) )
{
// skip BOM
us_utf8_str += 3;
*used_utf8_len += 3;
}
for ( i = 0; i < utf8_len; ++i )
{
if ( '\0' == *us_utf8_str ) break;
char_bytes = __utf8_bpc[((unsigned char)*us_utf8_str) >> 4];
if ( 1 == char_bytes )
{
tmp_ch = (unsigned short)us_utf8_str[0];
*used_utf8_len += char_bytes;
}
else if ( 2 == char_bytes )
{
if ( '\0' != us_utf8_str[1] )
{
tmp_ch = ((unsigned short)(us_utf8_str[0] & 0x1F) << 6) | ((unsigned short)(us_utf8_str[1] ^ 0x80));
*used_utf8_len += char_bytes;
}
}
else if ( 3 == char_bytes )
{
if ( '\0' != us_utf8_str[1] && '\0' != us_utf8_str[2] )
{
tmp_ch = ((unsigned short)(us_utf8_str[0] & 0x0F) << 12) | ((unsigned short)(us_utf8_str[1] ^ 0x80) << 6) | ((unsigned short)(us_utf8_str[2] ^ 0x80));
*used_utf8_len += char_bytes;
}
}
else
{
*used_utf8_len += 1;
}
if ( char_cnt >= ((buff_size / 2) - 1) )
break;
buff[char_cnt] = tmp_ch;
char_cnt += 1;
conv_len += 2;
us_utf8_str += char_bytes;
}
buff[char_cnt] = '\0';
return conv_len;
}
确定段落类型
为了确定段的具体类型,需要对段落类型进行识别
- 判断一个段落是否为一个章节的标题
1 | - (BOOL)isPrafChapterTitle:( TextPrgf_Text_ptr_t) text_prgf group:(TextPrgfGroup_ptr_t)groupPtr |
- 该段落是否为作者的话,作者的话可以有多个段落,每个段落都以特殊字符”\u3000\u2029\u3000”结尾。另外,作者的话的第1段以”\u2029\u3000\u2029”开始
1 | // 是否是作者的话 |
- 该段落是否为”(本章完)”,这个是判断ptr指向的内容是否是:”(本章完)\r\n”
1 | - (BOOL)isParagraphEndStr:(unsigned char *)ptr |
段落分行
对于前面提到的段落拆分、编码转换以及字符偏移计算等,只需要执行1次即可,之后便无需更新,因为这部分信息是相对独立的,没有依赖其它的布局条件。然而,将段内文本根据当前字体、方向等条件进行布局会依赖相关设置,例如绘制区域大小、横竖屏、字体大小、字体颜色、行间距、段间距等,一旦其中的某一个设置信息发生变化,那么这里的布局也需要更新。
布局的本质:根据排版设置,将一个段落生成逐行的排版结果,需要注意的是,这里不会包含高度偏移信息
1 | // 这里的代码省略了对标题的判断逻辑 |
先来解决第一个问题:这里为什么会引入mid_off呢?主要是因为有些段落可能会跨页,在这种情况下,一旦发生转屏,那么当前页面会继续从开始锚点重新排版,而跨到上一页的段落的最后一行可能无法排满一行,所以这里引入了这样的规则:跨到上页段落的最后一行只排版到锚点的前一个字符,这样前一页的最后一行就可能会发生断行现象。而对于没有跨页的段落,mid_off自然是0,不会执行前半部分逻辑
分行的核心在于计算其中每个字符的宽度,并根据排版页面的宽度决定某个字符是否是当前行的最后一个字符,从而生成段落中的一行
另外,除了正常的计算过程外,还有一些额外的规则:
(1)过滤掉emoji表情字符
(2)标点符号避头尾规则:
- 1、当前行行尾是左引号,左括号,左书名号,左单引号 (单双),破折号,省略号 ,移到下一行
- 2、下一行连续两个标点符号,当前行行尾不是标点符号,将当前行的最后一个字符移到下一行,避免标点出现在行首
- 3、下一行行首只有一个标点符号,将该标点符号往上一行移
(3)行尾逗号、句号算一半宽度
(4)每排版完一行,最后需要将空格考虑在内,重新计算字符的x值和宽度
1 | - (unsigned long)countCharInWidth:(unsigned short*)buff |
对于英文文章,还有一些特殊的排版规则:有些英文字符过长,可能某一行的最后一个单词(数字)到行尾还没有排版完,为了避免断行不切断单词(数字),需要遵守一些规则:
- 如果下一行的开始字符不是英文字符(数字),这说明当前行完全可以容纳最后一个单词,属于正常情况,直接返回;
- 如果下一行的开始字符是数字或者英文字符,那么就需要查看当前行最后一个字符是否是字符或者数字
如果是,需要统计当前行的最后一个单词(数字)长度,如果单词(数字)的长度有限,那么将该单词(数字)的前半部分移到下一行,否则不做处理; 如果不是,不做处理;
1 | unsigned long qr_relayout_english_2(unsigned short * buff, |
插入段处理
业务层要插入到排版里面的数据
- 有的需要插入到段落中间,例如神想法,而有的需要插入到段落尾部,例如章尾的各种业务数据:文字广告、图片广告、QA问答、卡牌、广点通数据等等。
- 这些插入的段落中有的需要向正文一样进行排版,例如作者的话,其排版规则和正文是一致的,除了字体、字体大小和颜色可能不同,而有的仅仅是需要占位,占位大小由业务层决定,引擎在排版完成后,需要将占位的坐标传递给业务层,让业务层在排版预留的占位区域内完成业务逻辑
1 | TextPrgf_ptr_t custom_prgf = qr_alloc_prgf_space(); |
插入逻辑如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41- (void)_insertPrgf:(TextPrgf_ptr_t)dstPrgf
beforePrgf:(TextPrgf_ptr_t)srcPrgf
atPrgfGroup:(TextPrgfGroup_ptr_t)prgfGroup
{
if (dstPrgf == NULL ||
srcPrgf == NULL ||
prgfGroup == NULL) {
return;
}
if (srcPrgf == _currPage.beginPrgf &&
_currPage.beginLine.ref != NULL &&
_currPage.beginLine.ref->prev == NULL) {
_currPage.beginPrgf = dstPrgf;
[self setFirstLine:_currPage.beginPrgf];
}
_nextPage.isValid = NO;
_prevPage.isValid = NO;
if (srcPrgf->prev == NULL) {
if (srcPrgf != prgfGroup->head) {
// 说明src不在prgfGroup中
//
return;
}
dstPrgf->prev = NULL;
dstPrgf->next = srcPrgf;
srcPrgf->prev = dstPrgf;
prgfGroup->head = dstPrgf;
}
else {
dstPrgf->prev = srcPrgf->prev;
dstPrgf->next = srcPrgf;
srcPrgf->prev = dstPrgf;
dstPrgf->prev->next = dstPrgf;
}
prgfGroup->count += 1;
}
渲染
有了排版过程的各种前期准备,渲染过程就相对简单了:
(1)准备好画布、画笔、颜料
(2)找到当前页的首段落指针和首行指针;
(3)确定好行高、行间距、段间距;
逐行根据排版得到每个字符的x值和w值绘制字符,直到当前画布无法容纳为止;
这里的渲染过程省略了一些代码,方便流程梳理1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122- (BOOL)repaint:(QRCanvas *)canvas rect:(QRRect *)rect
{
// 各种数据准备
...
// 开始绘制
[canvas drawTextBatchStart:0 length:0 destinationRect:0 alignment:align_flag];
// 设置高度区域
unsigned int yOffset = _docBoundRc.y;
unsigned int yOffsetMax = _docBoundRc.y+_docBoundRc.h-_fontHeight;
// 设置绘制区域
[self recalc];
draw_rect.x = _docBoundRc.x;
draw_rect.y = yOffset;
draw_rect.w = _docBoundRc.w;
draw_rect.h = _fontHeight;
// 设置画笔颜色
UIColor *lineColor = [QRThemeColorWithKey(kGlobalQRReadViewTextSelectionMainColor) getValue];
for (; yOffset < yOffsetMax && NULL != p_curr_prgf;)
{
if (QR_TEXT_PRGF_TYPE_SPACE == p_curr_prgf->type) {
// 空段不处理
}
else if (QR_TEXT_PRGF_TYPE_TEXT == p_curr_prgf->type ||
QR_TEXT_PRGF_TYPE_AUTHOR == p_curr_prgf->type ||
QR_TEXT_PRGF_TYPE_BUSINISS_TEXT == p_curr_prgf->type)
{
// 获取某段落开始绘制的行
text_prgf = (TextPrgf_Text_ptr_t)p_curr_prgf;
if (p_curr_prgf == _currPrgf) {
p_curr_line = _currLine.ref;
}
else {
p_curr_line = text_prgf->first_line;
}
if (text_prgf->line_count <= 0) {
continue;
}
unsigned int curLineH = 0;
QRSetting *fontSetting = nil;
// 逐行绘制
for (; yOffset < yOffsetMax && NULL != p_curr_line;) {
draw_rect.y = yOffset;
if (QR_TEXT_PRGF_TYPE_AUTHOR == p_curr_prgf->type ||
QR_TEXT_PRGF_TYPE_BUSINISS_TEXT == p_curr_prgf->type) {
fontSetting = [self authorTipsSetting];
curLineH = [self authorTipsLineHeight];
[canvas setContentFont:[fontSetting getFontSize:FontState_Render] Type:[fontSetting getFontType:FontState_Render]];
[canvas setPenColor:textcolor];
}
else {
curLineH = _lineHeight;
[canvas setContentFont:[_setting getFontSize:FontState_Render] Type:[_setting getFontType:FontState_Render]];
fontSetting = _setting;
[canvas setPenColor:textcolor];
}
QRFont *currentFont = fontSetting.font_obj;
unsigned int currentFontHeight = 0;
[currentFont getHeight:¤tFontHeight];
draw_rect.h = currentFontHeight;
//画一行文字
[canvas drawTextBatchDoing:(text_prgf->wide_buff + p_curr_line->offset)
inRect:(QRRect *)(text_prgf->char_rects + p_curr_line->offset)
length:p_curr_line->length
destinationRect:(QRRect *)&draw_rect
alignment:align_flag];
// 增加偏移值
yOffset += curLineH;
// 和高亮区间有重合就画高亮,全文搜索高亮显示会走到这里(代码省略)
...
// 段评气泡绘制(代码省略)
...
// 指向下一行
p_curr_line = p_curr_line->next;
}
// 段间距偏移
if (p_curr_prgf->need_remove_para_space == 1) {
yOffset -= _lineSpace/2;
}else{
yOffset += _paraSpace;
}
}
else if (QR_TEXT_PRGF_TYPE_BOTTOMMENU == p_curr_prgf->type) {
// 底部4tab入口绘制(代码省略)
}
else if (QR_TEXT_PRGF_TYPE_EXCELLENTPARACOMMENT == p_curr_prgf->type) {
// 神想法绘制(代码省略)
}
else if (QR_TEXT_PRGF_TYPE_CUSTOM == p_curr_prgf->type) {
// 自定义模块(代码省略)
}
else {
break;
}
// 指向下一段
p_curr_prgf = p_curr_prgf->next;
}
// 结束绘制
[canvas drawTextBatchEnd:0 length:0 destinationRect:0 alignment:0];
...
return YES;
}
这里最后的真正绘制还是系统的方法:1
[text drawAtPoint:CGPointMake(x, y) withAttributes:@{NSFontAttributeName : gc->fontBatch, NSForegroundColorAttributeName : textColor}];
有一些额外的信息需要注意:
(1)绘制高亮区域,例如有些笔记划线,所以每绘制完一行,都要去进行判断;
(2)在每个文字段的结尾判断是否需要绘制段评气泡;
(3)中间遇到一些定制的业务信息,根据给定的高度预留位置;
翻页
当前页已经渲染完成,此时如果想移动到下一页或者上一页,就需要寻找下一页的开始锚点或者上一页的开始锚点。TxtEngine主要用到了下面的几个方法:
1 | - (BOOL)moveNextPage; |
前两个方法的逻辑和渲染方法的逻辑大概是一致的,只是整个过程中仅仅计算Y的偏移,而不进行绘制
(1)moveNextPage:根据当前页的锚点,逐行排版到当前页的结尾,然后找到下一页的开始段落偏移和行偏移;
(2)movePrevPage:根据当前页的锚点,倒着排版到上一页的开始,找到上一页的开始段落偏移和行偏移;
这里有几个缺陷:
- 当前页已经渲染完成,寻找下一页的开始锚点只需要根据当前页的尾指针就可以很快定位,无需大循环跑一遍,遗憾的是,尽管很早之前就引入了
TxtPage
,里面就包含尾指针,却未能利用起来;这也是斯雨目前正在努力做的事情;- 判断是否是当前章的结尾
isBottom
,也是只需要根据尾指针进行判断,遗憾的是,isBottom
也在跑着大循环圈;- 无论是哪个方法,每次跑完循环,都不做任何缓存,导致大圈会执行很多次;
- 前面这两个循环和渲染的循环存在着一定的误差,有时候会造成段落丢失或者重复排版的现象。
如果能用TxtPage
记录下当前页面的开始指针和结束指针,一方面保证每一页的排版和渲染过程只走1次,那么可以去除很多没有必要的性能消耗,另一方面保证渲染和排版的结果是一致的,省去很多烦人的排版问题1
2
3
4
5
6@interface TxtPage : NSObject
@property (nonatomic, assign) TextPrgf_ptr_t beginPrgf;
@property (nonatomic, assign) LinePos_t beginLine;
@property (nonatomic, assign) TextPrgf_ptr_t endPrgf;
@property (nonatomic, assign) LinePos_t endLine
@end
段落缓存
前后翻页的时候往往首先需要判定当前内存中构建的段落是否足够构建上一页或者下一页:
(1)有的情况下,章节文件较大,可能无法一次性将章节文件全部读入,
(2)有的情况下,从章节中间的某个锚点进入,此时只加载当前锚点之后的数据流,此时往前翻页的时候,就需要加载锚点之前的数据流,构建之前的段落。
这里的判定是设置了一个阈值:cache_bytes = 8 * 1024
1 | /** |
1 | /** |
新加载的缓存会立刻被解析为段落链表,包括段落拆分、编码转换、及字符偏移等,并且将段内文本根据当前字体、方向等条件进行布局。最后将新缓存解析的结果prgf_group
merge 到总的段落组中_prgfGroup
中1
qr_merge_front_prgf( &_prgfGroup, &prgf_group );
在线书的章节文件普遍都比较小,某个章节对应的段落链表大部分情况下都会被缓存到内存中,然而碰到文件比较大的情况时(例如导入书),段落链表就不能一直缓存到内存中,需要根据当前阅读的偏移值,前后缓存一定距离的段落,在这个距离之外的段落就需要清除掉。
具体的逻辑可以参考:1
2- (void) recycleFromTop:(unsigned long) recycle_size;
- (void) recycleFromBottom:(unsigned long )recycle_size;
通用思路
对于在线书,其章节文件本身就比较小,完全可以尝试下新的思路:将章节文件内容一次性全部读入,段落拆分、编码转换、分行,然后从头到尾根据当前设置进行分页,并将分页结果缓存下来,每次渲染直接拿页码去获取页面的开始指针和结束指针直接绘制,这样就不会存在正翻和倒翻的区别了,更不会存在段落丢失或者重复排版的现象
断章
何为断章?有的文件中可能包含多个章节,并且除了该文件外,没有其它任何信息,这样就需要根据文件内容确定章节信息(章节个数,每个章节标题、内容和起始偏移等)。而确定的过程就是寻找”第xxx章(章节回卷篇部)”的过程,而寻找这些字符的过程和前面寻找\n\r的过程是极其类似的1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23static const unsigned short __charset_spc[] =
{
// (半角空格,TAB,全角空格)
0x0020,0x0009,0x3000
};
static const unsigned short __charset_1st[] =
{
// 第
0x7B2C
};
static const unsigned short __charset_2nd[] =
{
// 零一二三四五六七八九十百千零壹贰叁肆伍陆柒捌玖拾佰仟0123456789
0x96F6,0x4E00,0x4E8C,0x4E09,0x56DB,0x4E94,0x516D,0x4E03,0x516B,0x4E5D,
0x5341,0x767E,0x5343,0x96F6,0x58F9,0x8D30,0x53C1,0x8086,0x4F0D,0x9646,
0x67D2,0x634C,0x7396,0x62FE,0x4F70,0x4EDF,0x0030,0x0031,0x0032,0x0033,
0x0034,0x0035,0x0036,0x0037,0x0038,0x0039
};
static const unsigned short __charset_3rd[] =
{
// 章节回卷篇部
0x7AE0,0x8282,0x56DE,0x5377,0x7BC7,0x90E8
};
总结
文章尚有诸多不足,后续完善