天天看點

iOS文字排版(CoreText)

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

iOS文字排版(CoreText)

選擇自己寫而不是直接使用現有第三方庫的原因有三:

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)的存在,多個字元也會存在對應一個字形的情況。

iOS文字排版(CoreText)

字形描述集(glyphs metris):即字形的各個參數。如下面的兩張圖:

iOS文字排版(CoreText)
iOS文字排版(CoreText)

邊框(bounding box):一個假想的邊框,盡可能地容納整個字形。

基線(baseline):一條假想的參照線,以此為基礎進行字形的渲染。一般來說是一條橫線。

基礎原點(origin):基線上最左側的點。

行間距(leading):行與行之間的間距。

字間距(kerning):字與字之間的距離,為了排版的美觀,并不是所有的字形之間的距離都是一緻的,但是這個基本步影響到我們的文字排版。

上行高度(ascent)和下行高度(decent):一個字形最高點和最低點到基線的距離,前者為正數,而後者為負數。當同一行内有不同字型的文字時,就取最大值作為相應的值。如下圖:

iOS文字排版(CoreText)

紅框高度既為目前行的行高,綠線為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。他們的關系如下: 

iOS文字排版(CoreText)

其中ctframesetter是由cfattributedstring(nsattributedstring)初始化而來,可以認為它是ctframe的一個factory,通過傳入cgpath生成相應的ctframe并使用它進行渲染:直接以ctframe為參數使用ctframedraw繪制或者從ctframe中擷取ctline進行微調後使用ctlinedraw進行繪制。

一個ctframe是由一行一行的cline組成,每個ctline又會包含若幹個ctrun(既字形繪制的最小單元),通過相應的方法可以擷取到不同位置的ctrun和ctline,以實作對不同位置touch事件的響應。

iOS文字排版(CoreText)

圖文混排的實作

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

繼續閱讀