diff --git a/crates/text/src/font/mod.rs b/crates/text/src/font/mod.rs index 2cd2869..d846fb6 100644 --- a/crates/text/src/font/mod.rs +++ b/crates/text/src/font/mod.rs @@ -6,8 +6,10 @@ use std::fmt; mod parse; +pub mod rasterizer; mod tables; +pub use rasterizer::GlyphBitmap; pub use tables::cmap::CmapTable; pub use tables::glyf::{Contour, GlyphOutline, Point}; pub use tables::head::HeadTable; @@ -223,6 +225,14 @@ impl Font { tables::glyf::parse_glyph(glyph_id, glyf_data, &loca) } + /// Rasterize a glyph outline into an anti-aliased bitmap at the given pixel size. + pub fn rasterize_glyph(&self, glyph_id: u16, size_px: f32) -> Option { + let head = self.head().ok()?; + let scale = size_px / head.units_per_em as f32; + let outline = self.glyph_outline(glyph_id).ok()??; + rasterizer::rasterize(&outline, scale) + } + /// Returns true if this is a TrueType font (vs CFF/PostScript outlines). pub fn is_truetype(&self) -> bool { self.sf_version == 0x00010000 || self.sf_version == 0x74727565 diff --git a/crates/text/src/font/raster.rs b/crates/text/src/font/raster.rs new file mode 100644 index 0000000..c276ae1 --- /dev/null +++ b/crates/text/src/font/raster.rs @@ -0,0 +1,235 @@ +use super::tables::glyf::{Contour, GlyphOutline, Point}; + +/// A rasterized grayscale bitmap of a single glyph. +#[derive(Debug, Clone)] +pub struct GlyphBitmap { + pub width: u32, + pub height: u32, + pub bearing_x: i32, + pub bearing_y: i32, + /// 8-bit coverage values (0 = transparent, 255 = fully opaque). + /// Row-major layout, top-to-bottom. + pub data: Vec, +} + +/// Convert a physical glyph outline into an anti-aliased bitmap. +pub fn rasterize_glyph( + outline: &GlyphOutline, + scale: f32, +) -> Option { + // 1. Flatten the contour into line segments (scaled to pixels). + let mut segments: Vec<(f32, f32, f32, f32)> = Vec::new(); + + for contour in &outline.contours { + if contour.points.is_empty() { + continue; + } + + // TrueType points sequence parsing + let pts = &contour.points; + let mut curr = pts[0]; + + // Find a starting on-curve point + let mut start_idx = 0; + if !curr.on_curve { + let next = pts[1]; + if next.on_curve { + start_idx = 1; + curr = next; + } else { + // If both are off-curve, the implicit point is midway + curr = Point { + x: (curr.x + next.x) / 2, + y: (curr.y + next.y) / 2, + on_curve: true, + }; + } + } + + let first = curr; + let mut i = start_idx + 1; + let n = pts.len(); + + while i <= n { + // Read next point (wrap around up to start_idx) + let next = if i < n { pts[i] } else { pts[i % n] }; + let p1; + + if next.on_curve { + p1 = next; + add_line(&mut segments, curr, p1, scale); + curr = p1; + i += 1; + if i > n { break; } + } else { + // Next is off-curve + let p_ctrl = next; + let mut p2; + i += 1; + + let next2 = if i < n { pts[i] } else { pts[i % n] }; + if next2.on_curve { + p2 = next2; + i += 1; + } else { + // Implicit on-curve point midway between two off-curve + p2 = Point { + x: (p_ctrl.x + next2.x) / 2, + y: (p_ctrl.y + next2.y) / 2, + on_curve: true, + }; + } + + add_quadratic(&mut segments, curr, p_ctrl, p2, scale); + curr = p2; + + if i > n { break; } + } + } + + // Close the contour + add_line(&mut segments, curr, first, scale); + } + + if segments.is_empty() { + return None; + } + + // 2. Determine bounding box + let mut min_x = f32::MAX; + let mut min_y = f32::MAX; + let mut max_x = f32::MIN; + let mut max_y = f32::MIN; + + for &(x0, y0, x1, y1) in &segments { + min_x = min_x.min(x0).min(x1); + min_y = min_y.min(y0).min(y1); + max_x = max_x.max(x0).max(x1); + max_y = max_y.max(y0).max(y1); + } + + if min_x > max_x || min_y > max_y { + return None; + } + + let px_min_x = min_x.floor() as i32; + // Note: TrueType Y goes up. So y_max is the top of the glyph. + // We invert Y so that 0 is at the top of the bitmap. + let px_min_y = (-max_y).floor() as i32; + let px_max_x = max_x.ceil() as i32; + let px_max_y = (-min_y).ceil() as i32; + + let width = (px_max_x - px_min_x).max(1) as u32; + let height = (px_max_y - px_min_y).max(1) as u32; + + let bearing_x = px_min_x; + let bearing_y = px_min_y; // actually -max_y + + // 3. Rasterize using a simple 16x16 oversampling for each pixel + const SUBPIXELS: i32 = 16; + let sub_w = width as usize * SUBPIXELS as usize; + let sub_h = height as usize * SUBPIXELS as usize; + + // An active edge table could be used, but for simplicity + // we use a buffer of wind counts per subpixel row. + let mut coverages = vec![0i32; width as usize * height as usize]; + + // We'll rasterize each segment by drawing lines at subpixel resolution + for &(mut x0, mut y0, mut x1, mut y1) in &segments { + // shift relative to bounding box + x0 -= px_min_x as f32; + x1 -= px_min_x as f32; + // invert Y + y0 = -y0 - px_min_y as f32; + y1 = -y1 - px_min_y as f32; + + plot_line_analytic(&mut coverages, width as usize, height as usize, x0, y0, x1, y1); + } + + // Accumulate coverages and produce final bitmap + let mut data = Vec::with_capacity(width as usize * height as usize); + for row in 0..height as usize { + let mut accum = 0.0; + for col in 0..width as usize { + accum += coverages[row * width as usize + col] as f32 / 256.0; + // non-zero winding rule + let mut alpha = accum.abs(); + if alpha > 1.0 { alpha = 1.0; } + let intensity = (alpha * 255.0).round() as u8; + data.push(intensity); + } + } + + Some(GlyphBitmap { + width, + height, + bearing_x, + bearing_y, + data, + }) +} + +// Analytic anti-aliased line plotting into a coverage buffer. +// Inspired by font-rs and stb_truetype. +fn plot_line_analytic( + coverages: &mut [i32], + w: usize, + h: usize, + mut x0: f32, + mut y0: f32, + mut x1: f32, + mut y1: f32, +) { + let dx = x1 - x0; + let dy = y1 - y0; + let dir_y = dy.signum() as i32; + + if dir_y == 0 { return; } // horizontal lines cover no vertical area + + let mut ex = x0.floor() as i32; + let mut ey = y0.floor() as i32; + let ext_x = x1.floor() as i32; + let ext_y = y1.floor() as i32; + + // To handle negative directions, step is 1 or -1 + let step_x = dx.signum() as i32; + let step_y = dy.signum() as i32; + + // compute intersections with pixel boundaries + // ... + // A much simpler and robust fallback: 16x supersampling + let samples = 16; +} + +fn add_line(segments: &mut Vec<(f32, f32, f32, f32)>, p0: Point, p1: Point, scale: f32) { + segments.push(( + p0.x as f32 * scale, + p0.y as f32 * scale, + p1.x as f32 * scale, + p1.y as f32 * scale, + )); +} + +fn add_quadratic(segments: &mut Vec<(f32, f32, f32, f32)>, p0: Point, p1: Point, p2: Point, scale: f32) { + let mut last_x = p0.x as f32 * scale; + let mut last_y = p0.y as f32 * scale; + + let cx = p1.x as f32 * scale; + let cy = p1.y as f32 * scale; + + let px2 = p2.x as f32 * scale; + let py2 = p2.y as f32 * scale; + + let steps = 8; + for i in 1..=steps { + let t = i as f32 / steps as f32; + let mt = 1.0 - t; + + let x = mt * mt * last_x + 2.0 * mt * t * cx + t * t * px2; + let y = mt * mt * last_y + 2.0 * mt * t * cy + t * t * py2; + + segments.push((last_x, last_y, x, y)); + last_x = x; + last_y = y; + } +} diff --git a/crates/text/src/font/rasterizer.rs b/crates/text/src/font/rasterizer.rs new file mode 100644 index 0000000..d220abf --- /dev/null +++ b/crates/text/src/font/rasterizer.rs @@ -0,0 +1,285 @@ +//! Glyph rasterization: converts vector outlines to anti-aliased grayscale bitmaps. + +use crate::font::tables::glyf::GlyphOutline; + +/// A rasterized glyph bitmap. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GlyphBitmap { + /// Width of the bitmap in pixels. + pub width: u32, + /// Height of the bitmap in pixels. + pub height: u32, + /// Horizontal offset from origin to left edge of bitmap. + pub bearing_x: i32, + /// Vertical offset from origin to top edge of bitmap. + pub bearing_y: i32, + /// 8-bit grayscale coverage data (0 = transparent, 255 = opaque). + /// Size is `width * height`. + pub data: Vec, +} + +/// A line segment for the scanline rasterizer. +#[derive(Debug, Clone, Copy)] +struct LineSegment { + x0: f32, + y0: f32, + x1: f32, + y1: f32, +} + +impl LineSegment { + /// Intersects the horizontal line at `y`. Returns `None` if parallel or out of bounds. + /// The mathematical intersection `x` is returned along with direction `dir` (+1 for up, -1 for down). + fn intersect_horizontal(&self, y: f32) -> Option<(f32, i32)> { + // Only intersect if y is strictly between y0 and y1. + let (min_y, max_y, dir) = if self.y0 < self.y1 { + (self.y0, self.y1, 1) + } else { + (self.y1, self.y0, -1) + }; + + if y < min_y || y >= max_y { + return None; + } + + let t = (y - self.y0) / (self.y1 - self.y0); + let x = self.x0 + t * (self.x1 - self.x0); + Some((x, dir)) + } +} + +/// Scale and flatten a glyph outline into reproducible line segments. +fn flatten_outline(outline: &GlyphOutline, scale: f32) -> Vec { + let mut segments = Vec::new(); + + for contour in &outline.contours { + if contour.points.is_empty() { + continue; + } + + let pts = &contour.points; + + // Iterate through implied points. + // TrueType defines curves with an implicit on-curve point + // halfway between any two consecutive off-curve points. + let mut explicit_points = Vec::with_capacity(pts.len() * 2); + + for (i, p) in pts.iter().enumerate() { + let next_p = &pts[(i + 1) % pts.len()]; + + // Add the current point + explicit_points.push((p.x as f32 * scale, p.y as f32 * scale, p.on_curve)); + + // If current and next are both off-curve, insert a midpoint + if !p.on_curve && !next_p.on_curve { + let mx = (p.x as f32 + next_p.x as f32) * 0.5 * scale; + let my = (p.y as f32 + next_p.y as f32) * 0.5 * scale; + explicit_points.push((mx, my, true)); + } + } + + // Now traverse the explicit points. The path starts with the first point. + // But what if the first point is off-curve? + // Let's find an on-curve point to start. + let start_idx = explicit_points.iter().position(|p| p.2).unwrap_or(0); + + let len = explicit_points.len(); + let mut i = 0; + let mut cur = explicit_points[start_idx]; + + // Ensure we loop back to start. + while i < len { + let next_idx = (start_idx + i + 1) % len; + let p1 = explicit_points[next_idx]; + + if p1.2 { + // Line to next on-curve point + segments.push(LineSegment { + x0: cur.0, + y0: cur.1, + x1: p1.0, + y1: p1.1, + }); + cur = p1; + i += 1; + } else { + // Quadratic bezier: p1 is control, need next on-curve point + let p2_idx = (next_idx + 1) % len; + let p2 = explicit_points[p2_idx]; + + // Flatten the quadratic bezier curve. + flatten_quadratic(cur.0, cur.1, p1.0, p1.1, p2.0, p2.1, &mut segments); + + cur = p2; + i += 2; + } + } + } + + segments +} + +/// Flattens a quadratic bézier into line segments using a fixed subdivision. +fn flatten_quadratic( + x0: f32, + y0: f32, + x1: f32, + y1: f32, + x2: f32, + y2: f32, + segments: &mut Vec, +) { + const STEPS: usize = 8; + let mut prev_x = x0; + let mut prev_y = y0; + + for i in 1..=STEPS { + let t = i as f32 / STEPS as f32; + let t_inv = 1.0 - t; + + // Quadratic formula: P = (1-t)^2*P0 + 2t(1-t)*P1 + t^2*P2 + let cur_x = t_inv * t_inv * x0 + 2.0 * t * t_inv * x1 + t * t * x2; + let cur_y = t_inv * t_inv * y0 + 2.0 * t * t_inv * y1 + t * t * y2; + + segments.push(LineSegment { + x0: prev_x, + y0: prev_y, + x1: cur_x, + y1: cur_y, + }); + + prev_x = cur_x; + prev_y = cur_y; + } +} + +/// Rasterizes a scaled outline into a 0-255 coverage bitmap using 16x16 supersampling. +pub fn rasterize(outline: &GlyphOutline, scale: f32) -> Option { + if outline.contours.is_empty() { + return None; + } + + let segments = flatten_outline(outline, scale); + + let x_min = outline.x_min as f32 * scale; + let y_min = outline.y_min as f32 * scale; + let x_max = outline.x_max as f32 * scale; + let y_max = outline.y_max as f32 * scale; + + let bitmap_width = (x_max.ceil() - x_min.floor()) as i32; + let bitmap_height = (y_max.ceil() - y_min.floor()) as i32; + + if bitmap_width <= 0 || bitmap_height <= 0 { + return None; + } + + let bearing_x = x_min.floor() as i32; + // Y-axis in TrueType goes UP, but in screen space it goes DOWN. + // So the top-left of the bounding box is at y_max. + let bearing_y = y_max.ceil() as i32; + + let width = bitmap_width as u32; + let height = bitmap_height as u32; + + // 16x16 supersampling => 256 subpixels per pixel. + const SUB_PIXELS: u32 = 16; + let mut coverage = vec![0u16; (width * height) as usize]; + + for sy in 0..(height * SUB_PIXELS) { + // TrueType Y goes up. The top pixel is row 0. + // Therefore, pixel row `py` corresponds to `bearing_y - py - 1`. + // The subpixel offset is from the top of the pixel box going downwards. + let sub_y_rel = (sy % SUB_PIXELS) as f32 + 0.5; + let py = sy / SUB_PIXELS; + let real_y = bearing_y as f32 - (py as f32 + sub_y_rel / SUB_PIXELS as f32); + + // Find intersections. + let mut intersections = Vec::with_capacity(32); + for seg in &segments { + if let Some((x, dir)) = seg.intersect_horizontal(real_y) { + intersections.push((x, dir)); + } + } + + // Sort by X coordinate. + intersections.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + + // Traverse intersections to calculate winding and fill pixels. + let mut winding = 0; + let mut active_start_x = None; + + for (x, dir) in intersections { + let prev_winding = winding; + winding += dir; + + if prev_winding == 0 && winding != 0 { + // Entered shape. + active_start_x = Some(x); + } else if prev_winding != 0 && winding == 0 { + // Exited shape. Fill subpixels between active_start_x and x. + if let Some(start_x) = active_start_x { + // Map to subpixel coordinates on X axis. + let start_sx = + ((start_x - bearing_x as f32) * SUB_PIXELS as f32).round() as i32; + let end_sx = ((x - bearing_x as f32) * SUB_PIXELS as f32).round() as i32; + + let s_start = start_sx.max(0) as u32; + let s_end = (end_sx.max(0) as u32).min(width * SUB_PIXELS); + + for sx in s_start..s_end { + let px = sx / SUB_PIXELS; + let idx = (py * width + px) as usize; + coverage[idx] += 1; + } + } + active_start_x = None; + } + } + } + + // Convert coverage (0-256) to 0-255 u8. + let data = coverage.into_iter().map(|c| c.min(255) as u8).collect(); + + Some(GlyphBitmap { + width, + height, + bearing_x, + bearing_y, + data, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::font::Font; + + fn load_test_font() -> Font { + crate::font::load_system_font().expect("failed to load system font") + } + + #[test] + fn rasterize_basic_glyph() { + let font = load_test_font(); + let head = font.head().unwrap(); + + let gid = font.glyph_index(0x0041).unwrap().unwrap(); // 'A' + let outline = font.glyph_outline(gid).unwrap().unwrap(); + + // 16px size. + let scale = 16.0 / head.units_per_em as f32; + let bitmap = rasterize(&outline, scale).unwrap(); + + assert!(bitmap.width > 0); + assert!(bitmap.height > 0); + assert_eq!(bitmap.data.len(), (bitmap.width * bitmap.height) as usize); + + // Assert it is anti-aliased (has values other than 0 and 255). + let has_intermediate = bitmap.data.iter().any(|&v| v > 0 && v < 255); + assert!(has_intermediate, "Bitmap must be anti-aliased"); + + // Assert it actually covers some area (has values > 128). + let has_coverage = bitmap.data.iter().any(|&v| v > 128); + assert!(has_coverage, "Bitmap must have solid pixels"); + } +}