///|
/// 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: {},
}
///|
/// 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 = Array::make(ss_width * ss_height, false)
// Scale path commands by supersample factor
let ss_commands : Array[@svg.PathCommand] = []
for cmd in commands {
let scaled = match cmd {
MoveTo(x, y) =>
@svg.PathCommand::MoveTo(
x * ss.to_double() - offset_x.to_double() * (ss - 1).to_double(),
y * ss.to_double() - offset_y.to_double() * (ss - 1).to_double(),
)
LineTo(x, y) =>
@svg.PathCommand::LineTo(
x * ss.to_double() - offset_x.to_double() * (ss - 1).to_double(),
y * ss.to_double() - offset_y.to_double() * (ss - 1).to_double(),
)
CurveTo(cx1, cy1, cx2, cy2, x, y) =>
@svg.PathCommand::CurveTo(
cx1 * ss.to_double() - offset_x.to_double() * (ss - 1).to_double(),
cy1 * ss.to_double() - offset_y.to_double() * (ss - 1).to_double(),
cx2 * ss.to_double() - offset_x.to_double() * (ss - 1).to_double(),
cy2 * ss.to_double() - offset_y.to_double() * (ss - 1).to_double(),
x * ss.to_double() - offset_x.to_double() * (ss - 1).to_double(),
y * ss.to_double() - offset_y.to_double() * (ss - 1).to_double(),
)
QuadraticCurveTo(cx, cy, x, y) =>
@svg.PathCommand::QuadraticCurveTo(
cx * ss.to_double() - offset_x.to_double() * (ss - 1).to_double(),
cy * ss.to_double() - offset_y.to_double() * (ss - 1).to_double(),
x * ss.to_double() - offset_x.to_double() * (ss - 1).to_double(),
y * ss.to_double() - offset_y.to_double() * (ss - 1).to_double(),
)
ClosePath => @svg.PathCommand::ClosePath
_ => cmd // Pass through other commands
}
ss_commands.push(scaled)
}
let ss_offset_x = offset_x * ss
let ss_offset_y = offset_y * ss
let setter : @svg.PixelSetter = {
set: fn(px, py, _c) {
let lx = px - ss_offset_x
let ly = py - ss_offset_y
if lx >= 0 && lx < ss_width && ly >= 0 && ly < ss_height {
ss_pixels[ly * ss_width + lx] = true
}
},
}
let white = @svg.Color::rgb(255, 255, 255)
@svg.raster_path(ss_commands, Some(white), None, 0.0, setter)
// 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 = {}
// Keep bitmap cache — same fonts produce same bitmaps across provider resets
}
///|
///|
/// 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 = {}
// Keep bitmap cache — same font produces same bitmaps across sessions
}
///|
/// 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
}
}
///|
/// Draw text using the glyph provider (SVG path rendering).
/// Handles word-wrapping within max_width and baseline positioning.
/// Returns the total height used.
fn draw_text_with_glyphs(
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,
font_family? : String = "",
) -> Int {
let setter : @svg.PixelSetter = {
set: fn(px, py, c) {
if px >= 0 && px < fb.width && py >= 0 && py < fb.height {
let sixel_color = { r: c.r, g: c.g, b: c.b }
let idx = palette.get_or_add(sixel_color)
fb.set_pixel(px, py, idx)
}
},
}
let effective_line_height = if line_height > 0.0 {
line_height
} else {
font_size
}
// Baseline offset: glyphs have Y=0 at baseline, negative Y for ascenders
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 max_x_limit = if max_width > 0 {
(x + max_width).to_double()
} else {
fb.width.to_double()
}
// Word-wrap using Regular font metrics (matching layout engine),
// then render glyphs with the actual weight (bold or regular).
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
// Use regular (non-bold) metrics for wrapping to match layout engine
let space_advance_layout = get_advance(
provider,
' '.to_int(),
font_size,
false,
)
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
}
// Measure word with Regular font (matching layout)
let word_width = measure_word_width(
provider,
word,
font_size,
false,
font_family~,
)
// Calculate space before word (except at line start)
let mut space_width = 0.0
if i > 0 && word != "\n" && cur_x > x.to_double() + 0.01 {
space_width = space_advance_layout
}
// Wrap if needed (considering space + word width)
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
// After line break, no space before word
space_width = 0.0
}
}
// Add space before word (except at line start)
cur_x = cur_x + space_width
// Render each character with actual weight and kerning
let mut prev_cp = 0
for c in word {
let cp = c.to_int()
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(result) => result
None => (provider.get_glyph)(cp, font_size, is_bold)
}
_ => (provider.get_glyph)(cp, font_size, is_bold)
}
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 + advance > 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
draw_x = cur_x
}
if commands.length() > 0 {
let translated = translate_path_commands(
commands,
draw_x,
cur_y + baseline_offset,
)
@svg.raster_path(translated, Some(color), None, 0.0, setter)
}
cur_x = draw_x + advance
prev_cp = cp
}
}
(max_y_seen - y.to_double()).to_int()
}
///|
fn get_advance(
provider : GlyphProvider,
codepoint : Int,
font_size : Double,
is_bold : Bool,
) -> Double {
let (_, advance) = (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)
prev_cp = cp
}
w
}
///|
/// 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 = "",
) -> Int {
if clip is None {
return draw_text_with_glyphs(
fb,
palette,
provider,
x,
y,
text,
max_width,
color,
font_size,
line_height,
is_bold,
font_family~,
)
}
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 max_x_limit = if max_width > 0 {
(x + max_width).to_double()
} 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,
false,
)
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,
false,
font_family~,
)
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 color_idx = palette.get_or_add({ r: color.r, g: color.g, b: color.b })
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 > 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
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
fb.set_pixel(px, py, color_idx)
} else {
// Partial coverage: blend with background
let alpha = cov.to_double() / max_cov.to_double()
let bg_idx = fb.pixels[py * fb.width + px]
let bg_color = palette_color_at(palette, bg_idx)
let fg_color = palette_color_at(palette, color_idx)
let blended_r = (fg_color.r.to_double() * alpha +
bg_color.r.to_double() * (1.0 - alpha)).to_int()
let blended_g = (fg_color.g.to_double() * alpha +
bg_color.g.to_double() * (1.0 - alpha)).to_int()
let blended_b = (fg_color.b.to_double() * alpha +
bg_color.b.to_double() * (1.0 - alpha)).to_int()
let blended_idx = palette.get_or_add({
r: blended_r,
g: blended_g,
b: blended_b,
})
fb.set_pixel(px, py, blended_idx)
}
} else {
fb.set_pixel(px, py, color_idx)
}
}
}
}
}
}
cur_x = draw_x + bitmap.advance
prev_cp = cp
}
}
(max_y_seen - y.to_double()).to_int()
}