diff --git a/code/font/VEFontCache/LRU.odin b/code/font/VEFontCache/LRU.odin index db5d440..441bc97 100644 --- a/code/font/VEFontCache/LRU.odin +++ b/code/font/VEFontCache/LRU.odin @@ -28,11 +28,11 @@ PoolList :: struct { pool_list_init :: proc( pool : ^PoolList, capacity : u32, dbg_name : string = "" ) { error : AllocatorError - pool.items, error = make( [dynamic]PoolListItem, u64(capacity) ) + pool.items, error = make( [dynamic]PoolListItem, int(capacity) ) assert( error == .None, "VEFontCache.pool_list_init : Failed to allocate items array") resize( & pool.items, capacity ) - pool.free_list, error = make( [dynamic]PoolListIter, u64(capacity) ) + pool.free_list, error = make( [dynamic]PoolListIter, len = 0, cap = int(capacity) ) assert( error == .None, "VEFontCache.pool_list_init : Failed to allocate free_list array") resize( & pool.free_list, capacity ) @@ -55,7 +55,7 @@ pool_list_init :: proc( pool : ^PoolList, capacity : u32, dbg_name : string = "" pool_list_free :: proc( pool : ^PoolList ) { - // TODO(Ed): Implement + // TODO(Ed): Implement } pool_list_reload :: proc( pool : ^PoolList, allocator : Allocator ) @@ -160,7 +160,7 @@ LRU_init :: proc( cache : ^LRU_Cache, capacity : u32, dbg_name : string = "" ) { LRU_free :: proc( cache : ^LRU_Cache ) { - // TODO(Ed): Implement + // TODO(Ed): Implement } LRU_reload :: #force_inline proc( cache : ^LRU_Cache, allocator : Allocator ) diff --git a/code/font/VEFontCache/Readme.md b/code/font/VEFontCache/Readme.md index 05f7611..a9dd600 100644 --- a/code/font/VEFontCache/Readme.md +++ b/code/font/VEFontCache/Readme.md @@ -27,3 +27,10 @@ TODO Additional Features: * Ability to set a draw transform, viewport and projection * By default the library's position is in unsigned normalized render space * Allow curve_quality to be set on a per-font basis + +TODO Optimization: + +* Look into caching the draw_list for each shape instead of the glyphs/positions + * Each shape is already constrained to a Entry which is restricted to already a size-class for the glyphs + * Caching a glyph to atlas or generating the draw command for a glyph quad to screen is expensive for large batches. +* Attempt to look into chunking shapes again if caching the draw_list for a shape is found to be optimal diff --git a/code/font/VEFontCache/VEFontCache.odin b/code/font/VEFontCache/VEFontCache.odin index 2a8c710..87d5cae 100644 --- a/code/font/VEFontCache/VEFontCache.odin +++ b/code/font/VEFontCache/VEFontCache.odin @@ -161,25 +161,26 @@ startup :: proc( ctx : ^Context, parser_kind : ParserKind, ctx.curve_quality = curve_quality error : AllocatorError - entries, error = make( [dynamic]Entry, entires_reserve ) + entries, error = make( [dynamic]Entry, len = 0, cap = entires_reserve ) assert(error == .None, "VEFontCache.init : Failed to allocate entries") - temp_path, error = make( [dynamic]Vec2, temp_path_reserve ) + temp_path, error = make( [dynamic]Vec2, len = 0, cap = temp_path_reserve ) assert(error == .None, "VEFontCache.init : Failed to allocate temp_path") temp_codepoint_seen, error = make( map[u64]bool, uint(temp_codepoint_seen_reserve) ) assert(error == .None, "VEFontCache.init : Failed to allocate temp_path") - draw_list.vertices, error = make( [dynamic]Vertex, 4 * Kilobyte ) + draw_list.vertices, error = make( [dynamic]Vertex, len = 0, cap = 4 * Kilobyte ) assert(error == .None, "VEFontCache.init : Failed to allocate draw_list.vertices") - draw_list.indices, error = make( [dynamic]u32, 8 * Kilobyte ) + draw_list.indices, error = make( [dynamic]u32, len = 0, cap = 8 * Kilobyte ) assert(error == .None, "VEFontCache.init : Failed to allocate draw_list.indices") - draw_list.calls, error = make( [dynamic]DrawCall, 512 ) + draw_list.calls, error = make( [dynamic]DrawCall, len = 0, cap = 512 ) assert(error == .None, "VEFontCache.init : Failed to allocate draw_list.calls") - init_atlas_region :: proc( region : ^AtlasRegion, params : InitAtlasParams, region_params : InitAtlasRegionParams, factor : Vec2i, expected_cap : i32 ) { + init_atlas_region :: proc( region : ^AtlasRegion, params : InitAtlasParams, region_params : InitAtlasRegionParams, factor : Vec2i, expected_cap : i32 ) + { using region next_idx = 0; @@ -225,11 +226,20 @@ startup :: proc( ctx : ^Context, parser_kind : ParserKind, for idx : u32 = 0; idx < shape_cache_params.capacity; idx += 1 { stroage_entry := & shape_cache.storage[idx] using stroage_entry - glyphs, error = make( [dynamic]Glyph, shape_cache_params.reserve_length ) + glyphs, error = make( [dynamic]Glyph, len = 0, cap = shape_cache_params.reserve_length ) assert( error == .None, "VEFontCache.init : Failed to allocate glyphs array for shape cache storage" ) - positions, error = make( [dynamic]Vec2, shape_cache_params.reserve_length ) + positions, error = make( [dynamic]Vec2, len = 0, cap = shape_cache_params.reserve_length ) assert( error == .None, "VEFontCache.init : Failed to allocate positions array for shape cache storage" ) + + draw_list.calls, error = make( [dynamic]DrawCall, len = 0, cap = glyph_draw_params.buffer_batch * 2 ) + assert( error == .None, "VEFontCache.init : Failed to allocate calls for draw_list" ) + + draw_list.indices, error = make( [dynamic]u32, len = 0, cap = glyph_draw_params.buffer_batch * 2 * 6 ) + assert( error == .None, "VEFontCache.init : Failed to allocate indices array for draw_list" ) + + draw_list.vertices, error = make( [dynamic]Vertex, len = 0, cap = glyph_draw_params.buffer_batch * 2 * 4 ) + assert( error == .None, "VEFontCache.init : Failed to allocate vertices array for draw_list" ) } // Note(From original author): We can actually go over VE_FONTCACHE_GLYPHDRAW_BUFFER_BATCH batches due to smart packing! @@ -241,22 +251,22 @@ startup :: proc( ctx : ^Context, parser_kind : ParserKind, height = atlas.region_d.height * u32(over_sample.y) draw_padding = glyph_draw_params.draw_padding - draw_list.calls, error = make( [dynamic]DrawCall, cast(u64) glyph_draw_params.buffer_batch * 2 ) + draw_list.calls, error = make( [dynamic]DrawCall, len = 0, cap = glyph_draw_params.buffer_batch * 2 ) assert( error == .None, "VEFontCache.init : Failed to allocate calls for draw_list" ) - draw_list.indices, error = make( [dynamic]u32, cast(u64) glyph_draw_params.buffer_batch * 2 * 6 ) + draw_list.indices, error = make( [dynamic]u32, len = 0, cap = glyph_draw_params.buffer_batch * 2 * 6 ) assert( error == .None, "VEFontCache.init : Failed to allocate indices array for draw_list" ) - draw_list.vertices, error = make( [dynamic]Vertex, glyph_draw_params.buffer_batch * 2 * 4 ) + draw_list.vertices, error = make( [dynamic]Vertex, len = 0, cap = glyph_draw_params.buffer_batch * 2 * 4 ) assert( error == .None, "VEFontCache.init : Failed to allocate vertices array for draw_list" ) - clear_draw_list.calls, error = make( [dynamic]DrawCall, cast(u64) glyph_draw_params.buffer_batch * 2 ) + clear_draw_list.calls, error = make( [dynamic]DrawCall, len = 0, cap = glyph_draw_params.buffer_batch * 2 ) assert( error == .None, "VEFontCache.init : Failed to allocate calls for calls for clear_draw_list" ) - clear_draw_list.indices, error = make( [dynamic]u32, cast(u64) glyph_draw_params.buffer_batch * 2 * 4 ) + clear_draw_list.indices, error = make( [dynamic]u32, len = 0, cap = glyph_draw_params.buffer_batch * 2 * 4 ) assert( error == .None, "VEFontCache.init : Failed to allocate calls for indices array for clear_draw_list" ) - clear_draw_list.vertices, error = make( [dynamic]Vertex, glyph_draw_params.buffer_batch * 2 * 4 ) + clear_draw_list.vertices, error = make( [dynamic]Vertex, len = 0, cap = glyph_draw_params.buffer_batch * 2 * 4 ) assert( error == .None, "VEFontCache.init : Failed to allocate vertices array for clear_draw_list" ) } @@ -395,7 +405,7 @@ configure_snap :: #force_inline proc( ctx : ^Context, snap_width, snap_height : get_cursor_pos :: #force_inline proc "contextless" ( ctx : ^Context ) -> Vec2 { return ctx.cursor_pos } set_colour :: #force_inline proc "contextless" ( ctx : ^Context, colour : Colour ) { ctx.colour = colour } -draw_text :: proc( ctx : ^Context, font : FontID, text_utf8 : string, position : Vec2, scale : Vec2 ) -> b32 +draw_text :: proc( ctx : ^Context, font : FontID, text_utf8 : string, position, scale : Vec2 ) -> b32 { // profile(#procedure) assert( ctx != nil ) @@ -471,24 +481,9 @@ measure_text_size :: proc( ctx : ^Context, font : FontID, text_utf8 : string ) - assert( ctx != nil ) assert( font >= 0 && int(font) < len(ctx.entries) ) - atlas := ctx.atlas - entry := & ctx.entries[ font ] - shaped := shape_text_cached( ctx, font, text_utf8, entry ) - padding := cast(f32) atlas.glyph_padding - - for index : i32 = 0; index < i32(len(shaped.glyphs)); index += 1 - { - glyph_index := shaped.glyphs[ index ] - if is_empty( ctx, entry, glyph_index ) do continue - - bounds_0, bounds_1 := parser_get_glyph_box( & entry.parser_info, glyph_index ) - bounds_size := bounds_1 - bounds_0 - - glyph_size := Vec2 { f32(bounds_size.x), f32(bounds_size.y) } * entry.size_scale - measured.y = max(measured.y, glyph_size.y) - } - measured.x = shaped.end_cursor_pos.x - return measured + entry := &ctx.entries[font] + shaped := shape_text_cached(ctx, font, text_utf8, entry) + return shaped.size } get_font_vertical_metrics :: #force_inline proc ( ctx : ^Context, font : FontID ) -> ( ascent, descent, line_gap : i32 ) diff --git a/code/font/VEFontCache/docs/draw_text_codepaths.pur b/code/font/VEFontCache/docs/draw_text_codepaths.pur index 061c9b9..801029c 100644 Binary files a/code/font/VEFontCache/docs/draw_text_codepaths.pur and b/code/font/VEFontCache/docs/draw_text_codepaths.pur differ diff --git a/code/font/VEFontCache/draw.odin b/code/font/VEFontCache/draw.odin index 752b9f2..2957943 100644 --- a/code/font/VEFontCache/draw.odin +++ b/code/font/VEFontCache/draw.odin @@ -60,25 +60,25 @@ blit_quad :: proc( draw_list : ^DrawList, p0 : Vec2 = {0, 0}, p1 : Vec2 = {1, 1} {p0.x, p0.y}, uv0.x, uv0.y } - append_elem( & draw_list.vertices, vertex ) + append( & draw_list.vertices, vertex ) vertex = Vertex { {p0.x, p1.y}, uv0.x, uv1.y } - append_elem( & draw_list.vertices, vertex ) + append( & draw_list.vertices, vertex ) vertex = Vertex { {p1.x, p0.y}, uv1.x, uv0.y } - append_elem( & draw_list.vertices, vertex ) + append( & draw_list.vertices, vertex ) vertex = Vertex { {p1.x, p1.y}, uv1.x, uv1.y } - append_elem( & draw_list.vertices, vertex ) + append( & draw_list.vertices, vertex ) quad_indices : []u32 = { 0, 1, 2, @@ -332,7 +332,8 @@ cache_glyph_to_atlas :: proc( ctx : ^Context, cache_glyph( ctx, font, glyph_index, entry, vec2(bounds_0), vec2(bounds_1), glyph_draw_scale, glyph_draw_translate ) } -can_batch_glyph :: #force_inline proc( ctx : ^Context, font : FontID, entry : ^Entry, glyph_index : Glyph, +// If the glyuph is found in the atlas, nothing occurs, otherwise, the glyph call is setup to catch it to the atlas +check_glyph_in_atlas :: #force_inline proc( ctx : ^Context, font : FontID, entry : ^Entry, glyph_index : Glyph, lru_code : u64, atlas_index : i32, region_kind : AtlasRegionKind, @@ -438,7 +439,7 @@ directly_draw_massive_glyph :: proc( ctx : ^Context, append( & ctx.draw_list.calls, call ) } -draw_cached_glyph :: proc( ctx : ^Context, +draw_cached_glyph :: proc( ctx : ^Context, shaped : ^ShapedText, entry : ^Entry, glyph_index : Glyph, lru_code : u64, @@ -486,6 +487,24 @@ draw_cached_glyph :: proc( ctx : ^Context, textspace_x_form( & slot_position, & glyph_scale, atlas_size ) + // Shape call setup + if false + { + call := DrawCall_Default + { + using call + pass = .Target + colour = ctx.colour + start_index = cast(u32) len(shaped.draw_list.indices) + + blit_quad( & shaped.draw_list, + dst, dst + dst_scale, + slot_position, slot_position + glyph_scale ) + end_index = cast(u32) len(shaped.draw_list.indices) + } + append( & shaped.draw_list.calls, call ) + } + // Add the glyph drawcall call := DrawCall_Default { @@ -500,6 +519,7 @@ draw_cached_glyph :: proc( ctx : ^Context, end_index = cast(u32) len(ctx.draw_list.indices) } append( & ctx.draw_list.calls, call ) + return true } @@ -575,7 +595,7 @@ draw_text_batch :: proc( ctx : ^Context, entry : ^Entry, shaped : ^ShapedText, shaped_position := shaped.positions[index] glyph_translate := position + shaped_position * scale - glyph_cached := draw_cached_glyph( ctx, + glyph_cached := draw_cached_glyph( ctx, shaped, entry, glyph_index, lru_code, atlas_index, vec2(bounds_0), vec2(bounds_1), @@ -594,6 +614,9 @@ draw_text_shape :: proc( ctx : ^Context, snap_width, snap_height : f32 ) -> (cursor_pos : Vec2) { + draw_hash := shape_draw_hash( shaped, position, scale ) + dirty_shape := ! (len(shaped.draw_list.calls) > 0) || draw_hash != shaped.draw_hash + // position := position //+ ctx.cursor_pos * scale // profile(#procedure) batch_start_idx : i32 = 0 @@ -607,7 +630,10 @@ draw_text_shape :: proc( ctx : ^Context, atlas_index := cast(i32) -1 if region_kind != .E do atlas_index = LRU_get( & region.state, lru_code ) - if can_batch_glyph( ctx, font, entry, glyph_index, lru_code, atlas_index, region_kind, region, over_sample ) do continue + if check_glyph_in_atlas( ctx, font, entry, glyph_index, lru_code, atlas_index, region_kind, region, over_sample ) do continue + + // We can no longer directly append the shape as it has missing glyphs in the atlas + dirty_shape = true // Glyph has not been catched, needs to be directly drawn. @@ -621,10 +647,18 @@ draw_text_shape :: proc( ctx : ^Context, batch_start_idx = index } - // flush_glyph_buffer_to_atlas(ctx) - draw_text_batch( ctx, entry, shaped, batch_start_idx, cast(i32) len(shaped.glyphs), position, scale, snap_width , snap_height ) + // if dirty_shape { + flush_glyph_buffer_to_atlas(ctx) + draw_text_batch( ctx, entry, shaped, batch_start_idx, cast(i32) len(shaped.glyphs), position, scale, snap_width , snap_height ) + // shaped.draw_hash = draw_hash + // } + // else { + // flush_glyph_buffer_to_atlas( ctx ) + // merge_draw_list( & ctx.draw_list, & shaped.draw_list ) + // } reset_batch_codepoint_state( ctx ) - cursor_pos = position + shaped.end_cursor_pos * scae + + cursor_pos = position + shaped.end_cursor_pos * scale return } diff --git a/code/font/VEFontCache/misc.odin b/code/font/VEFontCache/misc.odin index 7cc52a0..733ebdc 100644 --- a/code/font/VEFontCache/misc.odin +++ b/code/font/VEFontCache/misc.odin @@ -50,13 +50,6 @@ font_glyph_lru_code :: #force_inline proc "contextless" ( font : FontID, glyph_i return } -shape_lru_hash :: #force_inline proc "contextless" ( label : string ) -> u64 { - hash : u64 - for str_byte in transmute([]byte) label { - hash = ((hash << 8) + hash) + u64(str_byte) - } - return hash -} // For a provided alpha value, // allows the function to calculate the position of a point along the curve at any given fraction of its total length diff --git a/code/font/VEFontCache/shaped_text.odin b/code/font/VEFontCache/shaped_text.odin index 510ed30..50768ef 100644 --- a/code/font/VEFontCache/shaped_text.odin +++ b/code/font/VEFontCache/shaped_text.odin @@ -1,11 +1,13 @@ package VEFontCache -import "core:math" - ShapedText :: struct { + draw_list : DrawList, glyphs : [dynamic]Glyph, positions : [dynamic]Vec2, end_cursor_pos : Vec2, + size : Vec2, + storage_hash : u64, + draw_hash : u64, } ShapedTextCache :: struct { @@ -14,36 +16,68 @@ ShapedTextCache :: struct { next_cache_id : i32, } + +shape_draw_hash :: #force_inline proc "contextless" ( shaped : ^ShapedText, pos, scale : Vec2 ) -> (draw_hash : u64) +{ + pos := pos + scale := scale + pos_bytes := slice_ptr( transmute(^byte) & pos, size_of(Vec2)) + scale_bytes := slice_ptr( transmute(^byte) & scale, size_of(Vec2)) + + draw_hash = shaped.storage_hash + shape_lru_hash( & shaped.draw_hash, pos_bytes ) + shape_lru_hash( & shaped.draw_hash, scale_bytes ) + return +} + +// shape_lru_hash_og :: #force_inline proc "contextless" ( label : string ) -> u64 { +// hash : u64 +// for str_byte in transmute([]byte) label { +// hash = ((hash << 8) + hash) + u64(str_byte) +// } +// return hash +// } + +shape_lru_hash :: #force_inline proc "contextless" ( hash : ^u64, bytes : []byte ) { + for value in bytes { + (hash^) = (( (hash^) << 8) + (hash^) ) + u64(value) + } +} + shape_text_cached :: proc( ctx : ^Context, font : FontID, text_utf8 : string, entry : ^Entry ) -> ^ShapedText { // profile(#procedure) - @static buffer : [64 * Kilobyte]byte + font := font + font_bytes := slice_ptr( transmute(^byte) & font, size_of(FontID) ) + text_bytes := transmute( []byte) text_utf8 - font := font - text_size := len(text_utf8) - sice_end_offset := size_of(FontID) + len(text_utf8) + lru_code : u64 + shape_lru_hash( & lru_code, font_bytes ) + shape_lru_hash( & lru_code, text_bytes ) - buffer_slice := buffer[:] - font_bytes := slice_ptr( transmute(^byte) & font, size_of(FontID) ) - copy( buffer_slice, font_bytes ) + // @static buffer : [64 * Kilobyte]byte + // text_size := len(text_utf8) + // sice_end_offset := size_of(FontID) + len(text_utf8) - text_bytes := transmute( []byte) text_utf8 - buffer_slice_post_font := buffer[ size_of(FontID) : sice_end_offset ] - copy( buffer_slice_post_font, text_bytes ) + // buffer_slice := buffer[:] + // copy( buffer_slice, font_bytes ) - hash := shape_lru_hash( transmute(string) buffer[: sice_end_offset ] ) + // buffer_slice_post_font := buffer[ size_of(FontID) : sice_end_offset ] + // copy( buffer_slice_post_font, text_bytes ) + + // lru_code := shape_lru_hash_og( transmute(string) buffer[: sice_end_offset ] ) shape_cache := & ctx.shape_cache state := & ctx.shape_cache.state - shape_cache_idx := LRU_get( state, hash ) + shape_cache_idx := LRU_get( state, lru_code ) if shape_cache_idx == -1 { if shape_cache.next_cache_id < i32(state.capacity) { shape_cache_idx = shape_cache.next_cache_id shape_cache.next_cache_id += 1 - evicted := LRU_put( state, hash, shape_cache_idx ) - assert( evicted == hash ) + evicted := LRU_put( state, lru_code, shape_cache_idx ) + assert( evicted == lru_code ) } else { @@ -53,16 +87,17 @@ shape_text_cached :: proc( ctx : ^Context, font : FontID, text_utf8 : string, en shape_cache_idx = LRU_peek( state, next_evict_idx, must_find = true ) assert( shape_cache_idx != - 1 ) - LRU_put( state, hash, shape_cache_idx ) + LRU_put( state, lru_code, shape_cache_idx ) } - shape_text_uncached( ctx, font, text_utf8, entry, & shape_cache.storage[ shape_cache_idx ] ) + shape_entry := & shape_cache.storage[ shape_cache_idx ] + shape_entry.storage_hash = lru_code + shape_text_uncached( ctx, font, text_utf8, entry, shape_entry ) } return & shape_cache.storage[ shape_cache_idx ] } -// TODO(Ed): Make position rounding an option shape_text_uncached :: proc( ctx : ^Context, font : FontID, text_utf8 : string, entry : ^Entry, output : ^ShapedText ) { // profile(#procedure) @@ -71,15 +106,21 @@ shape_text_uncached :: proc( ctx : ^Context, font : FontID, text_utf8 : string, use_full_text_shape := ctx.text_shape_adv + clear_draw_list( & output.draw_list ) clear( & output.glyphs ) clear( & output.positions ) - ascent, descent, line_gap := parser_get_font_vertical_metrics( & entry.parser_info ) + ascent_i32, descent_i32, line_gap_i32 := parser_get_font_vertical_metrics( & entry.parser_info ) + ascent := f32(ascent_i32) + descent := f32(descent_i32) + line_gap := f32(line_gap_i32) + line_height := (ascent - descent + line_gap) * entry.size_scale if use_full_text_shape { // assert( entry.shaper_info != nil ) - shaper_shape_from_text( & ctx.shaper_ctx, & entry.shaper_info, output, text_utf8, ascent, descent, line_gap, entry.size, entry.size_scale ) + shaper_shape_from_text( & ctx.shaper_ctx, & entry.shaper_info, output, text_utf8, ascent_i32, descent_i32, line_gap_i32, entry.size, entry.size_scale ) + // TODO(Ed): Need to be able to provide the text height as well return } else @@ -87,13 +128,10 @@ shape_text_uncached :: proc( ctx : ^Context, font : FontID, text_utf8 : string, // Note(Original Author): // We use our own fallback dumbass text shaping. // WARNING: PLEASE USE HARFBUZZ. GOOD TEXT SHAPING IS IMPORTANT FOR INTERNATIONALISATION. - ascent := f32(ascent) - descent := f32(descent) - line_gap := f32(line_gap) - position : Vec2 - advance : i32 = 0 - to_left_side_glyph : i32 = 0 + line_count : int = 1 + max_line_width : f32 = 0 + position : Vec2 prev_codepoint : rune for codepoint in text_utf8 @@ -104,29 +142,34 @@ shape_text_uncached :: proc( ctx : ^Context, font : FontID, text_utf8 : string, } if codepoint == '\n' { - position.x = 0.0 - position.y -= (ascent - descent + line_gap) * entry.size_scale - position.y = ceil(position.y) + line_count += 1 + max_line_width = max(max_line_width, position.x) + position.x = 0.0 + position.y -= line_height + position.y = ceil(position.y) prev_codepoint = rune(0) continue } if abs( entry.size ) <= Advance_Snap_Smallfont_Size { - position.x = math.ceil( position.x ) + position.x = ceil( position.x ) } append( & output.glyphs, parser_find_glyph_index( & entry.parser_info, codepoint )) - advance, to_left_side_glyph = parser_get_codepoint_horizontal_metrics( & entry.parser_info, codepoint ) + advance, _ := parser_get_codepoint_horizontal_metrics( & entry.parser_info, codepoint ) append( & output.positions, Vec2 { ceil(position.x), position.y }) - // append( & output.positions, position ) position.x += f32(advance) * entry.size_scale prev_codepoint = codepoint } output.end_cursor_pos = position + max_line_width = max(max_line_width, position.x) + + output.size.x = max_line_width + output.size.y = f32(line_count) * line_height } }