#[derive(Debug, Clone, Copy)]
pub struct TextBounds {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
pub ascent: f64,
pub descent: f64,
}
#[derive(Debug, Clone)]
pub struct GlyphMetric {
pub cluster: String,
pub x_offset: f64,
pub y_offset: f64,
pub advance: f64,
pub width: f64,
}
#[derive(Debug, Clone)]
pub struct WrappedLine {
pub glyphs: Vec<GlyphMetric>,
pub line_top: f64,
pub baseline_y: f64,
pub width: f64,
}
pub trait TextMetrics {
fn measure_text(&self, text: &str) -> f64;
fn text_bounds(&self, text: &str, font: &str) -> TextBounds;
fn text_to_path(&self, text: &str, font: &str) -> String {
let _ = (text, font);
String::new()
}
fn measure_text_glyphs(&self, text: &str, font: &str) -> Vec<GlyphMetric> {
let mut cumulative = 0.0f64;
text.chars()
.map(|c| {
let cluster = c.to_string();
let bounds = self.text_bounds(&cluster, font);
let advance = bounds.w;
let x_off = cumulative;
cumulative += advance;
GlyphMetric {
cluster,
x_offset: x_off,
y_offset: 0.0,
advance,
width: advance,
}
})
.collect()
}
fn measure_text_wrapped(&self, text: &str, font: &str, max_width: f64) -> Vec<WrappedLine> {
if text.is_empty() {
return Vec::new();
}
let space_w = self.measure_text(" ");
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
let mut current_w = 0.0f64;
for word in text.split_whitespace() {
let word_w = self.measure_text(word);
if current.is_empty() {
current.push_str(word);
current_w = word_w;
continue;
}
let candidate_w = current_w + space_w + word_w;
if candidate_w > max_width {
lines.push(std::mem::take(&mut current));
current.push_str(word);
current_w = word_w;
} else {
current.push(' ');
current.push_str(word);
current_w = candidate_w;
}
}
lines.push(current);
let mut line_top = 0.0f64;
lines
.into_iter()
.map(|line_text| {
let glyphs = self.measure_text_glyphs(&line_text, font);
let bounds = self.text_bounds(&line_text, font);
let baseline_y = line_top + bounds.ascent;
let line = WrappedLine {
glyphs,
line_top,
baseline_y,
width: bounds.w,
};
line_top += bounds.h.max(1.0);
line
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
struct FakeMetrics;
const CHAR_W: f64 = 10.0;
const ASCENT: f64 = 9.0;
const DESCENT: f64 = 3.0;
impl TextMetrics for FakeMetrics {
fn measure_text(&self, text: &str) -> f64 {
text.chars().count() as f64 * CHAR_W
}
fn text_bounds(&self, text: &str, _font: &str) -> TextBounds {
let w = self.measure_text(text);
TextBounds {
x: 0.0,
y: -ASCENT,
w,
h: ASCENT + DESCENT,
ascent: ASCENT,
descent: DESCENT,
}
}
}
#[test]
fn default_wrap_empty_text_is_empty() {
let fm = FakeMetrics;
assert!(fm.measure_text_wrapped("", "12px Test", 100.0).is_empty());
}
#[test]
fn default_wrap_wide_enough_is_a_single_line_matching_unwrapped() {
let fm = FakeMetrics;
let text = "short text";
let lines = fm.measure_text_wrapped(text, "12px Test", 10_000.0);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].width, fm.measure_text(text));
assert_eq!(lines[0].line_top, 0.0);
assert_eq!(lines[0].baseline_y, ASCENT);
}
#[test]
fn default_wrap_narrow_width_wraps_into_multiple_lines() {
let fm = FakeMetrics;
let text = "one two three four five six seven eight";
let max_width = 65.0;
let lines = fm.measure_text_wrapped(text, "12px Test", max_width);
assert!(
lines.len() > 1,
"expected wrap into multiple lines, got {}",
lines.len()
);
let line_height = ASCENT + DESCENT;
let mut prev_top = -1.0f64;
for (i, line) in lines.iter().enumerate() {
assert!(line.line_top > prev_top, "line_top must strictly increase");
prev_top = line.line_top;
let expected_top = i as f64 * line_height;
assert!(
(line.line_top - expected_top).abs() < 1e-9,
"line {i} top {} != expected {expected_top}",
line.line_top
);
assert!(
line.width <= max_width,
"line {i} width {} exceeds max_width {max_width}",
line.width
);
}
let total_height = prev_top + line_height;
let expected_total = lines.len() as f64 * line_height;
assert!((total_height - expected_total).abs() < 1e-9);
}
}