///|
/// Glyph rendering for text using SVG path-based fonts.
/// Replaces bitmap font rendering when a GlyphProvider is configured.
///|
/// Provides glyph outlines and advance widths for text rendering.
pub(all) struct GlyphProvider {
/// Get SVG path commands for a glyph. Args: (codepoint, font_size, is_bold).
/// Returns (commands, advance_width).
get_glyph : (Int, Double, Bool) -> (Array[@svg.PathCommand], Double)
/// Get kerning adjustment between two codepoints. Args: (cp1, cp2, font_size, is_bold).
get_kern : (Int, Int, Double, Bool) -> Double
/// Font ascent in pixels at font_size=1. Multiply by actual font_size.
ascent_ratio : Double // ascent / units_per_em (e.g. 1854/2048 ≈ 0.905)
/// Multi-font glyph provider. Args: (codepoint, font_size, is_bold, font_family).
/// Returns (commands, advance_width) or None to fall back to get_glyph.
get_glyph_for_family : ((Int, Double, Bool, String) -> (
Array[@svg.PathCommand],
Double,
)?)?
/// Multi-font kerning. Args: (cp1, cp2, font_size, is_bold, font_family).
get_kern_for_family : ((Int, Int, Double, Bool, String) -> Double)?
/// Multi-font ascent ratio. Args: (font_family) -> ratio.
get_ascent_for_family : ((String) -> Double)?
}
///|
let glyph_provider_override : Ref[GlyphProvider?] = { val: None }
///|
/// Cached rasterized glyph bitmap.
priv struct GlyphBitmap {
width : Int
height : Int
pixels : Array[Bool] // true = has pixel (any coverage > 0)
advance : Double
offset_x : Int // bounding box x offset from origin
offset_y : Int // bounding box y offset from origin
coverage : Array[Int] // 0..ss*ss coverage from supersampling (0=transparent, ss*ss=full)
ss_factor : Int // supersample factor (e.g. 4 means 4x4 grid, coverage 0..16)
}
///|
/// Bitmap cache for rasterized glyphs.
/// Key: "cp:fs_int:bold:family" → GlyphBitmap
let glyph_bitmap_cache : Ref[Map[String, GlyphBitmap]] = { val: {} }
///|
/// Cache for parsed glyph path commands (still used for advance width).
/// Key: "cp:fs_int:bold" → (commands, advance)
let glyph_cache : Ref[Map[String, (Array[@svg.PathCommand], Double)]] = {
val: {},
}
///|
let text_wrap_tolerance : Double = 10.0
///|
fn resolve_text_wrap_tolerance(max_width : Int, font_size : Double) -> Double {
if max_width <= 0 {
return text_wrap_tolerance
}
let width_tolerance = max_width.to_double() * 0.2
let font_tolerance = if font_size > 0.0 {
font_size * 1.5
} else {
text_wrap_tolerance
}
let capped = if width_tolerance < font_tolerance {
width_tolerance
} else {
font_tolerance
}
if capped > text_wrap_tolerance {
capped
} else {
text_wrap_tolerance
}
}
///|
fn insert_sorted_double(values : Array[Double], value : Double) -> Unit {
let mut index = 0
while index < values.length() && values[index] <= value {
index = index + 1
}
values.insert(index, value)
}
///|
fn scanline_intersections_even_odd(
polylines : Array[Array[(Double, Double)]],
py : Double,
) -> Array[Double] {
let intersections : Array[Double] = []
for polyline in polylines {
if polyline.length() < 3 {
continue
}
let mut j = polyline.length() - 1
for i = 0; i < polyline.length(); i = i + 1 {
let (xi, yi) = polyline[i]
let (xj, yj) = polyline[j]
let crosses = (yi > py && yj <= py) || (yj > py && yi <= py)
if crosses {
let x_at_y = xi + (py - yi) * (xj - xi) / (yj - yi)
insert_sorted_double(intersections, x_at_y)
}
j = i
}
}
intersections
}
///|
fn rasterize_compound_path_even_odd_to_pixels(
commands : Array[@svg.PathCommand],
offset_x : Int,
offset_y : Int,
ss : Int,
ss_width : Int,
ss_height : Int,
) -> Array[Bool] {
let pixels = Array::make(ss_width * ss_height, false)
let polylines = @svg.path_to_polylines(commands, 0.5)
let sample_scale = ss.to_double()
for y = 0; y < ss_height; y = y + 1 {
let py = offset_y.to_double() + (y.to_double() + 0.5) / sample_scale
let intersections = scanline_intersections_even_odd(polylines, py)
let mut i = 0
while i + 1 < intersections.length() {
let start = ((intersections[i] - offset_x.to_double()) * sample_scale -
0.5)
.ceil()
.to_int()
let end = ((intersections[i + 1] - offset_x.to_double()) * sample_scale -
0.5)
.ceil()
.to_int()
let clamped_start = if start < 0 {
0
} else if start > ss_width {
ss_width
} else {
start
}
let clamped_end = if end < 0 {
0
} else if end > ss_width {
ss_width
} else {
end
}
for x = clamped_start; x < clamped_end; x = x + 1 {
pixels[y * ss_width + x] = true
}
i = i + 2
}
}
pixels
}
///|
/// Rasterize a glyph to a bitmap. Called once per unique glyph.
fn rasterize_glyph_to_bitmap(
commands : Array[@svg.PathCommand],
advance : Double,
) -> GlyphBitmap {
if commands.length() == 0 {
return {
width: 0,
height: 0,
pixels: [],
advance,
offset_x: 0,
offset_y: 0,
coverage: [],
ss_factor: 1,
}
}
// Find bounding box
let mut min_x = 99999.0
let mut min_y = 99999.0
let mut max_x = -99999.0
let mut max_y = -99999.0
for cmd in commands {
match cmd {
MoveTo(x, y) | LineTo(x, y) | SmoothQuadraticCurveTo(x, y) => {
if x < min_x {
min_x = x
}
if y < min_y {
min_y = y
}
if x > max_x {
max_x = x
}
if y > max_y {
max_y = y
}
}
QuadraticCurveTo(cx, cy, x, y) => {
for px in [cx, x] {
if px < min_x {
min_x = px
}
if px > max_x {
max_x = px
}
}
for py in [cy, y] {
if py < min_y {
min_y = py
}
if py > max_y {
max_y = py
}
}
}
CurveTo(cx1, cy1, cx2, cy2, x, y) => {
for px in [cx1, cx2, x] {
if px < min_x {
min_x = px
}
if px > max_x {
max_x = px
}
}
for py in [cy1, cy2, y] {
if py < min_y {
min_y = py
}
if py > max_y {
max_y = py
}
}
}
ArcTo(_, _, _, _, _, x, y) => {
if x < min_x {
min_x = x
}
if y < min_y {
min_y = y
}
if x > max_x {
max_x = x
}
if y > max_y {
max_y = y
}
}
_ => ()
}
}
if min_x >= max_x || min_y >= max_y {
return {
width: 0,
height: 0,
pixels: [],
advance,
offset_x: 0,
offset_y: 0,
coverage: [],
ss_factor: 1,
}
}
let offset_x = min_x.to_int() - 1
let offset_y = min_y.to_int() - 1
let width = (max_x - min_x).to_int() + 3
let height = (max_y - min_y).to_int() + 3
if width <= 0 || height <= 0 || width > 200 || height > 200 {
return {
width: 0,
height: 0,
pixels: [],
advance,
offset_x: 0,
offset_y: 0,
coverage: [],
ss_factor: 1,
}
}
// 4x supersampling for antialiasing (16 coverage levels)
let ss = 4 // supersample factor
let ss_width = width * ss
let ss_height = height * ss
let ss_pixels = rasterize_compound_path_even_odd_to_pixels(
commands, offset_x, offset_y, ss, ss_width, ss_height,
)
// Downsample: count coverage in each ss×ss block → 0..ss*ss levels
// Store as coverage (0=transparent, 1-3=partial, 4=full) in pixels array
// We encode coverage as: false=0, true=4 for backward compat,
// but also store coverage values for antialiased blitting
let pixels = Array::make(width * height, false)
let coverage = Array::make(width * height, 0)
for y in 0.. 0 {
pixels[y * width + x] = true
}
}
}
{
width,
height,
pixels,
advance,
offset_x,
offset_y,
coverage,
ss_factor: ss,
}
}
///|
fn glyph_cache_key(
cp : Int,
font_size : Double,
is_bold : Bool,
font_family : String,
) -> String {
let bold_flag = if is_bold { 1 } else { 0 }
let fs_key = (font_size * 10.0).to_int()
cp.to_string() +
":" +
fs_key.to_string() +
":" +
bold_flag.to_string() +
":" +
font_family
}
///|
pub fn set_glyph_provider(provider : GlyphProvider) -> Unit {
glyph_provider_override.val = Some(provider)
glyph_cache.val = {}
glyph_bitmap_cache.val = {}
}
///|
///|
/// Pre-rasterize common ASCII glyphs at given font sizes.
/// Call after set_glyph_provider to warm the bitmap cache.
pub fn pre_rasterize_glyphs(
font_sizes : Array[Double],
font_family? : String = "",
) -> Int {
let provider = match glyph_provider_override.val {
Some(p) => p
None => return 0
}
let mut count = 0
// ASCII printable range: space (32) to tilde (126)
for fs in font_sizes {
let baseline_offset = match provider.get_ascent_for_family {
Some(get_ascent) if font_family.length() > 0 =>
get_ascent(font_family) * fs
_ => provider.ascent_ratio * fs
}
for cp = 32; cp <= 126; cp = cp + 1 {
let key = glyph_cache_key(cp, fs, false, font_family)
if glyph_bitmap_cache.val.get(key) is None {
let (commands, advance) = match provider.get_glyph_for_family {
Some(glyph_fn) if font_family.length() > 0 =>
match glyph_fn(cp, fs, false, font_family) {
Some(r) => r
None => (provider.get_glyph)(cp, fs, false)
}
_ => (provider.get_glyph)(cp, fs, false)
}
let translated = translate_path_commands(commands, 0.0, baseline_offset)
let bm = rasterize_glyph_to_bitmap(translated, advance)
glyph_bitmap_cache.val.set(key, bm)
count = count + 1
}
}
}
count
}
///|
pub fn clear_glyph_provider() -> Unit {
glyph_provider_override.val = None
glyph_cache.val = {}
glyph_bitmap_cache.val = {}
}
///|
/// Convert mizchi/svg PathCommand to crater's svg PathCommand.
fn convert_msvg_path_command(cmd : @msvg.PathCommand) -> @svg.PathCommand {
match cmd {
MoveTo(x, y) => @svg.PathCommand::MoveTo(x, y)
LineTo(x, y) => @svg.PathCommand::LineTo(x, y)
QuadraticCurveTo(cx, cy, x, y) =>
@svg.PathCommand::QuadraticCurveTo(cx, cy, x, y)
CurveTo(cx1, cy1, cx2, cy2, x, y) =>
@svg.PathCommand::CurveTo(cx1, cy1, cx2, cy2, x, y)
ClosePath => @svg.PathCommand::ClosePath
_ => @svg.PathCommand::ClosePath
}
}
///|
/// Create a GlyphProvider from a parsed TTFont.
/// This allows native text rendering without JS FFI.
pub fn glyph_provider_from_font(font : @font.TTFont) -> GlyphProvider {
let units_per_em = font.units_per_em.to_double()
let ascent_ratio = font.ascent.to_double() / units_per_em
{
get_glyph: fn(codepoint, font_size, _is_bold) {
let outline = font.scaled_outline(codepoint, font_size)
let commands : Array[@svg.PathCommand] = outline.map(
convert_msvg_path_command,
)
let glyph_id = font.glyph_index(codepoint)
let advance = if glyph_id != 0 {
let metrics = font.glyph_metrics(glyph_id)
metrics.advance_width.to_double() / units_per_em * font_size
} else {
0.0
}
(commands, advance)
},
get_kern: fn(cp1, cp2, font_size, _is_bold) {
let gid1 = font.glyph_index(cp1)
let gid2 = font.glyph_index(cp2)
let kern = font.kern_pair(gid1, gid2)
kern.to_double() * font_size / units_per_em
},
ascent_ratio,
get_glyph_for_family: None,
get_kern_for_family: None,
get_ascent_for_family: None,
}
}
///|
/// Parse font bytes and create a GlyphProvider.
/// Returns None if font parsing fails.
pub fn glyph_provider_from_bytes(data : Bytes) -> GlyphProvider? {
match @font.parse_font(data) {
Some(font) => Some(glyph_provider_from_font(font))
None => None
}
}
///|
fn get_advance(
provider : GlyphProvider,
codepoint : Int,
font_size : Double,
is_bold : Bool,
font_family? : String = "",
) -> Double {
let (_, advance) = match provider.get_glyph_for_family {
Some(glyph_fn) if font_family.length() > 0 =>
match glyph_fn(codepoint, font_size, is_bold, font_family) {
Some(r) => r
None => (provider.get_glyph)(codepoint, font_size, is_bold)
}
_ => (provider.get_glyph)(codepoint, font_size, is_bold)
}
advance
}
///|
fn measure_word_width(
provider : GlyphProvider,
word : String,
font_size : Double,
is_bold : Bool,
font_family? : String = "",
) -> Double {
let mut w = 0.0
let mut prev_cp = 0
for c in word {
let cp = c.to_int()
if prev_cp != 0 {
let kern = match provider.get_kern_for_family {
Some(kern_fn) if font_family.length() > 0 =>
kern_fn(prev_cp, cp, font_size, is_bold, font_family)
_ => (provider.get_kern)(prev_cp, cp, font_size, is_bold)
}
w = w + kern
}
w = w + get_advance(provider, cp, font_size, is_bold, font_family~)
prev_cp = cp
}
w
}
///|
fn clamp_opacity(value : Double) -> Double {
if value < 0.0 {
0.0
} else if value > 1.0 {
1.0
} else {
value
}
}
///|
fn blend_color_over_pixel_alpha(
fb : Framebuffer,
palette : DynamicPalette,
px : Int,
py : Int,
color : Color,
alpha : Double,
) -> Unit {
let clamped = clamp_opacity(alpha)
if clamped <= 0.0 {
return
}
if clamped >= 1.0 {
let idx = palette.get_or_add(color)
fb.set_pixel(px, py, idx)
return
}
let bg_idx = fb.pixels[py * fb.width + px]
let bg_color = palette_color_at(palette, bg_idx)
let a_byte = (clamped * 255.0).to_int()
let blended = color.blend(bg_color, a_byte)
let idx = palette.get_or_add(blended)
fb.set_pixel(px, py, idx)
}
///|
/// Split text into words (by whitespace). Preserves newlines as "\n" tokens.
fn split_text_into_words(text : String) -> Array[String] {
let words : Array[String] = []
let buf = StringBuilder::new()
for c in text {
if c == '\n' {
if buf.to_string().length() > 0 {
words.push(buf.to_string())
buf.reset()
}
words.push("\n")
} else if c == ' ' || c == '\t' || c == '\r' {
if buf.to_string().length() > 0 {
words.push(buf.to_string())
buf.reset()
}
} else {
buf.write_char(c)
}
}
if buf.to_string().length() > 0 {
words.push(buf.to_string())
}
words
}
///|
/// Translate path commands by (dx, dy) offset.
fn translate_path_commands(
commands : Array[@svg.PathCommand],
dx : Double,
dy : Double,
) -> Array[@svg.PathCommand] {
commands.map(fn(cmd) {
match cmd {
MoveTo(x, y) => @svg.PathCommand::MoveTo(x + dx, y + dy)
LineTo(x, y) => @svg.PathCommand::LineTo(x + dx, y + dy)
HorizontalLineTo(x) => @svg.PathCommand::HorizontalLineTo(x + dx)
VerticalLineTo(y) => @svg.PathCommand::VerticalLineTo(y + dy)
CurveTo(x1, y1, x2, y2, x, y) =>
@svg.PathCommand::CurveTo(
x1 + dx,
y1 + dy,
x2 + dx,
y2 + dy,
x + dx,
y + dy,
)
SmoothCurveTo(x2, y2, x, y) =>
@svg.PathCommand::SmoothCurveTo(x2 + dx, y2 + dy, x + dx, y + dy)
QuadraticCurveTo(x1, y1, x, y) =>
@svg.PathCommand::QuadraticCurveTo(x1 + dx, y1 + dy, x + dx, y + dy)
SmoothQuadraticCurveTo(x, y) =>
@svg.PathCommand::SmoothQuadraticCurveTo(x + dx, y + dy)
ArcTo(rx, ry, rot, large, sweep, x, y) =>
@svg.PathCommand::ArcTo(rx, ry, rot, large, sweep, x + dx, y + dy)
ClosePath => @svg.PathCommand::ClosePath
_ => cmd
}
})
}
///|
/// Draw text using the glyph provider with clip rect support.
fn draw_text_with_glyphs_clipped(
fb : Framebuffer,
palette : DynamicPalette,
provider : GlyphProvider,
x : Int,
y : Int,
text : String,
max_width : Int,
color : @svg.Color,
font_size : Double,
line_height : Double,
is_bold : Bool,
clip : ClipRect?,
font_family? : String = "",
opacity? : Double = 1.0,
) -> Int {
let text_opacity = clamp_opacity(opacity) * color.a.to_double() / 255.0
let effective_line_height = if line_height > 0.0 {
line_height
} else {
font_size
}
let baseline_offset = match provider.get_ascent_for_family {
Some(get_ascent) if font_family.length() > 0 =>
get_ascent(font_family) * font_size
_ => provider.ascent_ratio * font_size
}
let wrap_tolerance = resolve_text_wrap_tolerance(max_width, font_size)
let max_x_limit = if max_width > 0 {
(x + max_width).to_double() + wrap_tolerance
} else {
fb.width.to_double()
}
let words = split_text_into_words(text)
let mut cur_x = x.to_double()
let mut cur_y = y.to_double()
let mut max_y_seen = y.to_double() + effective_line_height
let space_advance_layout = get_advance(
provider,
' '.to_int(),
font_size,
is_bold,
font_family~,
)
for i, word in words {
if word == "\n" {
cur_x = x.to_double()
cur_y = cur_y + effective_line_height
max_y_seen = cur_y + effective_line_height
continue
}
let word_width = measure_word_width(
provider,
word,
font_size,
is_bold,
font_family~,
)
let hard_wrap_limit = if max_width > 0 &&
word_width > max_width.to_double() + wrap_tolerance {
(x + max_width).to_double()
} else {
max_x_limit
}
let mut space_width = 0.0
if i > 0 && word != "\n" && cur_x > x.to_double() + 0.01 {
space_width = space_advance_layout
}
if max_width > 0 {
let total_width = space_width + word_width
if cur_x + total_width > max_x_limit && cur_x > x.to_double() + 0.01 {
cur_x = x.to_double()
cur_y = cur_y + effective_line_height
max_y_seen = cur_y + effective_line_height
space_width = 0.0
}
}
cur_x = cur_x + space_width
// Pre-compute palette index for this word's color (constant per word)
let sixel_color = { r: color.r, g: color.g, b: color.b }
let color_idx = palette.get_or_add(sixel_color)
let mut prev_cp = 0
for c in word {
let cp = c.to_int()
// Use bitmap cache: rasterize each glyph once, then blit
let cache_key = glyph_cache_key(cp, font_size, is_bold, font_family)
let bitmap = match glyph_bitmap_cache.val.get(cache_key) {
Some(bm) => bm
None => {
let (commands, advance) = match provider.get_glyph_for_family {
Some(glyph_fn) if font_family.length() > 0 =>
match glyph_fn(cp, font_size, is_bold, font_family) {
Some(r) => r
None => (provider.get_glyph)(cp, font_size, is_bold)
}
_ => (provider.get_glyph)(cp, font_size, is_bold)
}
let translated = translate_path_commands(
commands, 0.0, baseline_offset,
)
let bm = rasterize_glyph_to_bitmap(translated, advance)
glyph_bitmap_cache.val.set(cache_key, bm)
bm
}
}
let kern = if prev_cp != 0 {
match provider.get_kern_for_family {
Some(kern_fn) if font_family.length() > 0 =>
kern_fn(prev_cp, cp, font_size, is_bold, font_family)
_ => (provider.get_kern)(prev_cp, cp, font_size, is_bold)
}
} else {
0.0
}
let mut draw_x = cur_x + kern
if max_width > 0 &&
draw_x + bitmap.advance > hard_wrap_limit &&
cur_x > x.to_double() + 0.01 {
cur_x = x.to_double()
cur_y = cur_y + effective_line_height
max_y_seen = cur_y + effective_line_height
draw_x = cur_x
}
let line_top = cur_y.to_int()
let line_bottom = (cur_y + effective_line_height).to_int()
let in_clip_v = match clip {
Some(cr) => line_bottom >= cr.y && line_top < cr.y + cr.h
None => line_bottom >= 0 && line_top < fb.height
}
// Blit cached bitmap to framebuffer with antialiasing
if in_clip_v && bitmap.width > 0 {
let dx = draw_x.to_int() + bitmap.offset_x
let dy = cur_y.to_int() + bitmap.offset_y
let has_coverage = bitmap.coverage.length() ==
bitmap.width * bitmap.height
for by = 0; by < bitmap.height; by = by + 1 {
let py = dy + by
if py < 0 || py >= fb.height {
continue
}
for bx = 0; bx < bitmap.width; bx = bx + 1 {
let bi = by * bitmap.width + bx
if bitmap.pixels[bi] {
let px = dx + bx
if px >= 0 && px < fb.width && pixel_in_clip(clip, px, py) {
if has_coverage {
let cov = bitmap.coverage[bi]
let max_cov = bitmap.ss_factor * bitmap.ss_factor
if cov >= max_cov {
// Full coverage: solid pixel
if text_opacity >= 1.0 {
fb.set_pixel(px, py, color_idx)
} else {
blend_color_over_pixel_alpha(
fb, palette, px, py, sixel_color, text_opacity,
)
}
} else {
// Partial coverage: blend with background
let alpha = cov.to_double() /
max_cov.to_double() *
text_opacity
blend_color_over_pixel_alpha(
fb, palette, px, py, sixel_color, alpha,
)
}
} else if text_opacity >= 1.0 {
fb.set_pixel(px, py, color_idx)
} else {
blend_color_over_pixel_alpha(
fb, palette, px, py, sixel_color, text_opacity,
)
}
}
}
}
}
}
cur_x = draw_x + bitmap.advance
prev_cp = cp
}
}
(max_y_seen - y.to_double()).to_int()
}