///|
/// Metrics describing the rendered geometry of a string, mirroring the
/// subset of `TextMetrics` we currently expose.
pub struct TextMetrics {
width : Double
ascent : Double
descent : Double
}
///|
pub impl Show for TextMetrics with fn output(self, logger) {
logger.write_string("TextMetrics { width: ")
logger.write_object(self.width)
logger.write_string(", ascent: ")
logger.write_object(self.ascent)
logger.write_string(", descent: ")
logger.write_object(self.descent)
logger.write_string(" }")
}
///|
/// Measure a string using the currently-installed font. If no font has been
/// set on the context, returns all-zero metrics (matching the degenerate
/// default of HTML canvas before a font is chosen).
pub fn Context::measure_text(self : Context, text : String) -> TextMetrics {
match self.state.font {
None => { width: 0.0, ascent: 0.0, descent: 0.0 }
Some(font) => {
let size = self.state.font_size
let width = font.measure_text(text, size)
let upem = font.units_per_em.to_double()
let ascent = font.ascent.to_double() / upem * size
// `font.descent` is typically negative in TTF metrics; invert to get a
// positive "pixels below the baseline" value, as TextMetrics expects.
let descent = -font.descent.to_double() / upem * size
{ width, ascent, descent }
}
}
}
///|
/// Pixel advance (horizontal) for a single codepoint under the given font
/// and size. Falls back to `0.0` when the glyph id is out of range of the
/// `advance_widths` table (e.g. for unmapped codepoints).
fn advance_for(font : @font.TTFont, cp : Int, size_px : Double) -> Double {
let gid = font.glyph_index(cp)
if gid >= font.advance_widths.length() {
0.0
} else {
font.advance_widths[gid].to_double() /
font.units_per_em.to_double() *
size_px
}
}
///|
/// Rasterize `text` onto the canvas, using the current fill_style as the
/// glyph tint and the current transform to position the pen.
///
/// Text is drawn with an alphabetic baseline at `(x, y)`; each glyph bitmap
/// is produced by `@font.rasterize_glyph` and then blitted using source-over
/// blending with the glyph's alpha channel as coverage. If no font is set,
/// this is a no-op.
pub fn Context::fill_text(
self : Context,
text : String,
x : Double,
y : Double,
) -> Unit {
let font = match self.state.font {
None => return
Some(f) => f
}
let size = self.state.font_size
// Text rendering uses a single tint color. For solid fills we use the
// color directly; for gradient fills we fall back to the first stop
// (gradient-tinted text is a future enhancement).
let color = match self.state.fill_style {
FillStyle::Solid(c) => c
FillStyle::Linear(g) => g.stops[0].color
FillStyle::Radial(g) => g.stops[0].color
}
let global_alpha = self.state.global_alpha
let pixels = self.canvas.pixels
let cw = self.canvas.width
let ch = self.canvas.height
let mut pen_x = x
let pen_y = y
let size_bucket = (size * 4.0).to_int()
for ch_char in text {
let codepoint = ch_char.to_int()
let gid = font.glyph_index(codepoint)
let cache_key = gid * 10000 + size_bucket
let bitmap = match self.glyph_cache.get(cache_key) {
Some(cached) => cached
None =>
match @font.rasterize_glyph(font, codepoint, size) {
None => {
pen_x = pen_x + advance_for(font, codepoint, size)
continue
}
Some(bm) => {
self.glyph_cache[cache_key] = bm
bm
}
}
}
let (dest_x, dest_y) = self.state.transform.transform_point(pen_x, pen_y)
let dst_x0 = dest_x.floor().to_int()
let dst_y0 = (dest_y - bitmap.height.to_double()).floor().to_int()
blit_glyph(
pixels,
cw,
ch,
bitmap,
dst_x0,
dst_y0,
color,
global_alpha,
self.state.clip,
)
pen_x = pen_x + advance_for(font, codepoint, size)
}
// `pen_y` is intentionally unused after the loop; keep it bound so the
// reader can see the baseline stays fixed across glyphs.
ignore(pen_y)
}
///|
/// Tints a grayscale/alpha glyph bitmap with `color` and blends via
/// source-over, optionally masked by a `ClipMask`.
///
/// `@font.rasterize_glyph` returns an RGBA interleaved buffer where the alpha
/// channel carries the coverage signal (the RGB channels are constant white).
/// We scale the 0..=255 alpha to the 0..=256 range that `blend_over` expects,
/// and when a `clip` mask is present we combine its per-pixel coverage with
/// the glyph coverage so text respects the active clip region.
fn blit_glyph(
pixels : FixedArray[Byte],
canvas_w : Int,
canvas_h : Int,
glyph : @font.GlyphBitmap,
dst_x : Int,
dst_y : Int,
color : Color,
global_alpha : Double,
clip : ClipMask?,
) -> Unit {
let gw = glyph.width
let gh = glyph.height
for gy in 0..= 0 && py < canvas_h {
for gx in 0..= 0 && px < canvas_w {
let src_offset = (gy * gw + gx) * 4
let a_int = glyph.pixels[src_offset + 3]
if a_int > 0 {
let coverage = a_int * 256 / 255
let clip_cov = match clip {
None => 255
Some(mask) => mask.data[py * canvas_w + px].to_int()
}
if clip_cov != 0 {
let effective = if clip_cov == 255 {
coverage
} else {
(coverage * clip_cov + 127) / 255
}
blend_over(
pixels,
(py * canvas_w + px) * 4,
color,
effective,
global_alpha,
)
}
}
}
}
}
}
}