和我們平時說的字型不同,計算機意義上的字型表示的是同一大小,同一樣式(style)字形的集合。從這個意義上來說,當我們為文字設定粗體,斜體時其實是使用了另外一種字型(下劃線不算)。

選擇自己寫而不是直接使用現有第三方庫的原因有三:
1. 在這之前也做過一個ios上的im産品,當時這個子產品并不是我負責,圖文混排的實作非常詭異(通過二分法計算出文字所占區域大小),效率極低,是以需要重新做一個效率比較高的控件出來。
2. 看過一些開源的實作,包括ohattribtuedlabel,dtcoretext和nimbus,總覺得他們實作插入圖檔的接口有點别扭,對于上層調用者來說coretext部分不是完全透明的:調用者需要考慮怎麼用自己的圖檔把原來内容替換掉。(當時的印象,現在具體怎麼樣已經不清楚了)
3. 這是重新造輪子的機會!
直接拿了nimbus的attributedlabel作為基礎,然後重新整理圖文混排那部分的代碼,調整接口,一共也就花了一個晚上的時間:拜一下nimbus的作者們。後來也根據項目的需求做了一些小改動,比如hack ios7下不準的問題,支援在label上添加uiview的特性等等。最新的代碼可以在github上找到:m80attributedlabel。
不過寫這篇文章最重要的原因不是為了放個代碼出來,而是在閑暇時整理一下ios/osx文字排版相關的知識。
文字排版的基礎概念
字型(font):和我們平時說的字型不同,計算機意義上的字型表示的是同一大小,同一樣式(style)字形的集合。從這個意義上來說,當我們為文字設定粗體,斜體時其實是使用了另外一種字型(下劃線不算)。而平時我們所說的字型隻是具有相同設計屬性的字型集合,即font family或typeface。
字元(character)和字形(glyphs):排版過程中一個重要的步驟就是從字元到字形的轉換,字元表示資訊本身,而字形是它的圖形表現形式。字元一般就是指某種編碼,如unicode編碼,而字形則是這些編碼對應的圖檔。但是他們之間不是一一對應關系,同個字元的不同字型族,不同字型大小,不同字型樣式都對應了不同的字形。而由于連寫(ligatures)的存在,多個字元也會存在對應一個字形的情況。
字形描述集(glyphs metris):即字形的各個參數。如下面的兩張圖:
邊框(bounding box):一個假想的邊框,盡可能地容納整個字形。
基線(baseline):一條假想的參照線,以此為基礎進行字形的渲染。一般來說是一條橫線。
基礎原點(origin):基線上最左側的點。
行間距(leading):行與行之間的間距。
字間距(kerning):字與字之間的距離,為了排版的美觀,并不是所有的字形之間的距離都是一緻的,但是這個基本步影響到我們的文字排版。
上行高度(ascent)和下行高度(decent):一個字形最高點和最低點到基線的距離,前者為正數,而後者為負數。當同一行内有不同字型的文字時,就取最大值作為相應的值。如下圖:
紅框高度既為目前行的行高,綠線為baseline,綠色到紅框上部分為目前行的最大ascent,綠線到黃線為目前行的最大desent,而黃框的高即為行間距。由此可以得出:lineheight = ascent + |decent| + leading。
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進行圖檔的繪制。以下就是整個圖文混排代碼描述的過程:
占位:
- (void)appendattachment: (m80attributedlabelattachment *)attachment
{
attachment.fontascent = _fontascent;
attachment.fontdescent = _fontdescent;
unichar objectreplacementchar = 0xfffc;
nsstring *objectreplacementstring = [nsstring stringwithcharacters:&objectreplacementchar length:1];
nsmutableattributedstring *attachtext = [[nsmutableattributedstring alloc]initwithstring:objectreplacementstring];
ctrundelegatecallbacks callbacks;
callbacks.version = kctrundelegateversion1;
callbacks.getascent = ascentcallback;
callbacks.getdescent = descentcallback;
callbacks.getwidth = widthcallback;
callbacks.dealloc = dealloccallback;
ctrundelegateref delegate = ctrundelegatecreate(&callbacks, (void *)attachment);
nsdictionary *attr = [nsdictionary dictionarywithobjectsandkeys:(__bridge id)delegate,kctrundelegateattributename, nil];
[attachtext setattributes:attr range:nsmakerange(0, 1)];
cfrelease(delegate);
[_attachments addobject:attachment];
[self appendattributedtext:attachtext];
}
實作委托回調:
cgfloat ascentcallback(void *ref)
m80attributedlabelattachment *image = (__bridge m80attributedlabelattachment *)ref;
cgfloat ascent = 0;
cgfloat height = [image boxsize].height;
switch (image.alignment)
{
case m80imagealignmenttop:
ascent = image.fontascent;
break;
case m80imagealignmentcenter:
{
cgfloat fontascent = image.fontascent;
cgfloat fontdescent = image.fontdescent;
cgfloat baseline = (fontascent + fontdescent) / 2 - fontdescent;
ascent = height / 2 + baseline;
}
case m80imagealignmentbottom:
ascent = height - image.fontdescent;
default:
}
return ascent;
cgfloat descentcallback(void *ref)
cgfloat descent = 0;
descent = height - image.fontascent;
descent = height / 2 - baseline;
descent = image.fontdescent;
return descent;
cgfloat widthcallback(void* ref)
m80attributedlabelattachment *image = (__bridge m80attributedlabelattachment *)ref;
return [image boxsize].width;
真正的繪制:
- (void)drawattachments
if ([_attachments count] == 0)
return;
cgcontextref ctx = uigraphicsgetcurrentcontext();
if (ctx == nil)
cfarrayref lines = ctframegetlines(_textframe);
cfindex linecount = cfarraygetcount(lines);
cgpoint lineorigins[linecount];
ctframegetlineorigins(_textframe, cfrangemake(0, 0), lineorigins);
nsinteger numberoflines = [self numberofdisplayedlines];
for (cfindex i = 0; i < numberoflines; i++)
ctlineref line = cfarraygetvalueatindex(lines, i);
cfarrayref runs = ctlinegetglyphruns(line);
cfindex runcount = cfarraygetcount(runs);
cgpoint lineorigin = lineorigins[i];
cgfloat lineascent;
cgfloat linedescent;
ctlinegettypographicbounds(line, &lineascent, &linedescent, null);
cgfloat lineheight = lineascent + linedescent;
cgfloat linebottomy = lineorigin.y - linedescent;
// iterate through each of the "runs" (i.e. a chunk of text) and find the runs that
// intersect with the range.
for (cfindex k = 0; k < runcount; k++)
ctrunref run = cfarraygetvalueatindex(runs, k);
nsdictionary *runattributes = (nsdictionary *)ctrungetattributes(run);
ctrundelegateref delegate = (__bridge ctrundelegateref)[runattributes valueforkey:(id)kctrundelegateattributename];
if (nil == delegate)
{
continue;
}
m80attributedlabelattachment* attributedimage = (m80attributedlabelattachment *)ctrundelegategetrefcon(delegate);
cgfloat ascent = 0.0f;
cgfloat descent = 0.0f;
cgfloat width = (cgfloat)ctrungettypographicbounds(run,
cfrangemake(0, 0),
&ascent,
&descent,
null);
cgfloat imageboxheight = [attributedimage boxsize].height;
cgfloat xoffset = ctlinegetoffsetforstringindex(line, ctrungetstringrange(run).location, nil);
cgfloat imageboxoriginy = 0.0f;
switch (attributedimage.alignment)
case m80imagealignmenttop:
imageboxoriginy = linebottomy + (lineheight - imageboxheight);
break;
case m80imagealignmentcenter:
imageboxoriginy = linebottomy + (lineheight - imageboxheight) / 2.0;
case m80imagealignmentbottom:
imageboxoriginy = linebottomy;
cgrect rect = cgrectmake(lineorigin.x + xoffset, imageboxoriginy, width, imageboxheight);
uiedgeinsets flippedmargins = attributedimage.margin;
cgfloat top = flippedmargins.top;
flippedmargins.top = flippedmargins.bottom;
flippedmargins.bottom = top;
cgrect attatchmentrect = uiedgeinsetsinsetrect(rect, flippedmargins);
id content = attributedimage.content;
if ([content iskindofclass:[uiimage class]])
cgcontextdrawimage(ctx, attatchmentrect, ((uiimage *)content).cgimage);
else if ([content iskindofclass:[uiview class]])
uiview *view = (uiview *)content;
if (view.superview == nil)
{
[self addsubview:view];
}
cgrect viewframe = cgrectmake(attatchmentrect.origin.x,
self.bounds.size.height - attatchmentrect.origin.y - attatchmentrect.size.height,
attatchmentrect.size.width,
attatchmentrect.size.height);
[view setframe:viewframe];
else
nslog(@"attachment content not supported %@",content);