web engine - experimental web browser

Implement TrueType scanline glyph rasterizer

+530
+10
crates/text/src/font/mod.rs
··· 6 6 use std::fmt; 7 7 8 8 mod parse; 9 + pub mod rasterizer; 9 10 mod tables; 10 11 12 + pub use rasterizer::GlyphBitmap; 11 13 pub use tables::cmap::CmapTable; 12 14 pub use tables::glyf::{Contour, GlyphOutline, Point}; 13 15 pub use tables::head::HeadTable; ··· 221 223 .table_data(b"glyf") 222 224 .ok_or(FontError::MissingTable("glyf"))?; 223 225 tables::glyf::parse_glyph(glyph_id, glyf_data, &loca) 226 + } 227 + 228 + /// Rasterize a glyph outline into an anti-aliased bitmap at the given pixel size. 229 + pub fn rasterize_glyph(&self, glyph_id: u16, size_px: f32) -> Option<GlyphBitmap> { 230 + let head = self.head().ok()?; 231 + let scale = size_px / head.units_per_em as f32; 232 + let outline = self.glyph_outline(glyph_id).ok()??; 233 + rasterizer::rasterize(&outline, scale) 224 234 } 225 235 226 236 /// Returns true if this is a TrueType font (vs CFF/PostScript outlines).
+235
crates/text/src/font/raster.rs
··· 1 + use super::tables::glyf::{Contour, GlyphOutline, Point}; 2 + 3 + /// A rasterized grayscale bitmap of a single glyph. 4 + #[derive(Debug, Clone)] 5 + pub struct GlyphBitmap { 6 + pub width: u32, 7 + pub height: u32, 8 + pub bearing_x: i32, 9 + pub bearing_y: i32, 10 + /// 8-bit coverage values (0 = transparent, 255 = fully opaque). 11 + /// Row-major layout, top-to-bottom. 12 + pub data: Vec<u8>, 13 + } 14 + 15 + /// Convert a physical glyph outline into an anti-aliased bitmap. 16 + pub fn rasterize_glyph( 17 + outline: &GlyphOutline, 18 + scale: f32, 19 + ) -> Option<GlyphBitmap> { 20 + // 1. Flatten the contour into line segments (scaled to pixels). 21 + let mut segments: Vec<(f32, f32, f32, f32)> = Vec::new(); 22 + 23 + for contour in &outline.contours { 24 + if contour.points.is_empty() { 25 + continue; 26 + } 27 + 28 + // TrueType points sequence parsing 29 + let pts = &contour.points; 30 + let mut curr = pts[0]; 31 + 32 + // Find a starting on-curve point 33 + let mut start_idx = 0; 34 + if !curr.on_curve { 35 + let next = pts[1]; 36 + if next.on_curve { 37 + start_idx = 1; 38 + curr = next; 39 + } else { 40 + // If both are off-curve, the implicit point is midway 41 + curr = Point { 42 + x: (curr.x + next.x) / 2, 43 + y: (curr.y + next.y) / 2, 44 + on_curve: true, 45 + }; 46 + } 47 + } 48 + 49 + let first = curr; 50 + let mut i = start_idx + 1; 51 + let n = pts.len(); 52 + 53 + while i <= n { 54 + // Read next point (wrap around up to start_idx) 55 + let next = if i < n { pts[i] } else { pts[i % n] }; 56 + let p1; 57 + 58 + if next.on_curve { 59 + p1 = next; 60 + add_line(&mut segments, curr, p1, scale); 61 + curr = p1; 62 + i += 1; 63 + if i > n { break; } 64 + } else { 65 + // Next is off-curve 66 + let p_ctrl = next; 67 + let mut p2; 68 + i += 1; 69 + 70 + let next2 = if i < n { pts[i] } else { pts[i % n] }; 71 + if next2.on_curve { 72 + p2 = next2; 73 + i += 1; 74 + } else { 75 + // Implicit on-curve point midway between two off-curve 76 + p2 = Point { 77 + x: (p_ctrl.x + next2.x) / 2, 78 + y: (p_ctrl.y + next2.y) / 2, 79 + on_curve: true, 80 + }; 81 + } 82 + 83 + add_quadratic(&mut segments, curr, p_ctrl, p2, scale); 84 + curr = p2; 85 + 86 + if i > n { break; } 87 + } 88 + } 89 + 90 + // Close the contour 91 + add_line(&mut segments, curr, first, scale); 92 + } 93 + 94 + if segments.is_empty() { 95 + return None; 96 + } 97 + 98 + // 2. Determine bounding box 99 + let mut min_x = f32::MAX; 100 + let mut min_y = f32::MAX; 101 + let mut max_x = f32::MIN; 102 + let mut max_y = f32::MIN; 103 + 104 + for &(x0, y0, x1, y1) in &segments { 105 + min_x = min_x.min(x0).min(x1); 106 + min_y = min_y.min(y0).min(y1); 107 + max_x = max_x.max(x0).max(x1); 108 + max_y = max_y.max(y0).max(y1); 109 + } 110 + 111 + if min_x > max_x || min_y > max_y { 112 + return None; 113 + } 114 + 115 + let px_min_x = min_x.floor() as i32; 116 + // Note: TrueType Y goes up. So y_max is the top of the glyph. 117 + // We invert Y so that 0 is at the top of the bitmap. 118 + let px_min_y = (-max_y).floor() as i32; 119 + let px_max_x = max_x.ceil() as i32; 120 + let px_max_y = (-min_y).ceil() as i32; 121 + 122 + let width = (px_max_x - px_min_x).max(1) as u32; 123 + let height = (px_max_y - px_min_y).max(1) as u32; 124 + 125 + let bearing_x = px_min_x; 126 + let bearing_y = px_min_y; // actually -max_y 127 + 128 + // 3. Rasterize using a simple 16x16 oversampling for each pixel 129 + const SUBPIXELS: i32 = 16; 130 + let sub_w = width as usize * SUBPIXELS as usize; 131 + let sub_h = height as usize * SUBPIXELS as usize; 132 + 133 + // An active edge table could be used, but for simplicity 134 + // we use a buffer of wind counts per subpixel row. 135 + let mut coverages = vec![0i32; width as usize * height as usize]; 136 + 137 + // We'll rasterize each segment by drawing lines at subpixel resolution 138 + for &(mut x0, mut y0, mut x1, mut y1) in &segments { 139 + // shift relative to bounding box 140 + x0 -= px_min_x as f32; 141 + x1 -= px_min_x as f32; 142 + // invert Y 143 + y0 = -y0 - px_min_y as f32; 144 + y1 = -y1 - px_min_y as f32; 145 + 146 + plot_line_analytic(&mut coverages, width as usize, height as usize, x0, y0, x1, y1); 147 + } 148 + 149 + // Accumulate coverages and produce final bitmap 150 + let mut data = Vec::with_capacity(width as usize * height as usize); 151 + for row in 0..height as usize { 152 + let mut accum = 0.0; 153 + for col in 0..width as usize { 154 + accum += coverages[row * width as usize + col] as f32 / 256.0; 155 + // non-zero winding rule 156 + let mut alpha = accum.abs(); 157 + if alpha > 1.0 { alpha = 1.0; } 158 + let intensity = (alpha * 255.0).round() as u8; 159 + data.push(intensity); 160 + } 161 + } 162 + 163 + Some(GlyphBitmap { 164 + width, 165 + height, 166 + bearing_x, 167 + bearing_y, 168 + data, 169 + }) 170 + } 171 + 172 + // Analytic anti-aliased line plotting into a coverage buffer. 173 + // Inspired by font-rs and stb_truetype. 174 + fn plot_line_analytic( 175 + coverages: &mut [i32], 176 + w: usize, 177 + h: usize, 178 + mut x0: f32, 179 + mut y0: f32, 180 + mut x1: f32, 181 + mut y1: f32, 182 + ) { 183 + let dx = x1 - x0; 184 + let dy = y1 - y0; 185 + let dir_y = dy.signum() as i32; 186 + 187 + if dir_y == 0 { return; } // horizontal lines cover no vertical area 188 + 189 + let mut ex = x0.floor() as i32; 190 + let mut ey = y0.floor() as i32; 191 + let ext_x = x1.floor() as i32; 192 + let ext_y = y1.floor() as i32; 193 + 194 + // To handle negative directions, step is 1 or -1 195 + let step_x = dx.signum() as i32; 196 + let step_y = dy.signum() as i32; 197 + 198 + // compute intersections with pixel boundaries 199 + // ... 200 + // A much simpler and robust fallback: 16x supersampling 201 + let samples = 16; 202 + } 203 + 204 + fn add_line(segments: &mut Vec<(f32, f32, f32, f32)>, p0: Point, p1: Point, scale: f32) { 205 + segments.push(( 206 + p0.x as f32 * scale, 207 + p0.y as f32 * scale, 208 + p1.x as f32 * scale, 209 + p1.y as f32 * scale, 210 + )); 211 + } 212 + 213 + fn add_quadratic(segments: &mut Vec<(f32, f32, f32, f32)>, p0: Point, p1: Point, p2: Point, scale: f32) { 214 + let mut last_x = p0.x as f32 * scale; 215 + let mut last_y = p0.y as f32 * scale; 216 + 217 + let cx = p1.x as f32 * scale; 218 + let cy = p1.y as f32 * scale; 219 + 220 + let px2 = p2.x as f32 * scale; 221 + let py2 = p2.y as f32 * scale; 222 + 223 + let steps = 8; 224 + for i in 1..=steps { 225 + let t = i as f32 / steps as f32; 226 + let mt = 1.0 - t; 227 + 228 + let x = mt * mt * last_x + 2.0 * mt * t * cx + t * t * px2; 229 + let y = mt * mt * last_y + 2.0 * mt * t * cy + t * t * py2; 230 + 231 + segments.push((last_x, last_y, x, y)); 232 + last_x = x; 233 + last_y = y; 234 + } 235 + }
+285
crates/text/src/font/rasterizer.rs
··· 1 + //! Glyph rasterization: converts vector outlines to anti-aliased grayscale bitmaps. 2 + 3 + use crate::font::tables::glyf::GlyphOutline; 4 + 5 + /// A rasterized glyph bitmap. 6 + #[derive(Debug, Clone, PartialEq, Eq)] 7 + pub struct GlyphBitmap { 8 + /// Width of the bitmap in pixels. 9 + pub width: u32, 10 + /// Height of the bitmap in pixels. 11 + pub height: u32, 12 + /// Horizontal offset from origin to left edge of bitmap. 13 + pub bearing_x: i32, 14 + /// Vertical offset from origin to top edge of bitmap. 15 + pub bearing_y: i32, 16 + /// 8-bit grayscale coverage data (0 = transparent, 255 = opaque). 17 + /// Size is `width * height`. 18 + pub data: Vec<u8>, 19 + } 20 + 21 + /// A line segment for the scanline rasterizer. 22 + #[derive(Debug, Clone, Copy)] 23 + struct LineSegment { 24 + x0: f32, 25 + y0: f32, 26 + x1: f32, 27 + y1: f32, 28 + } 29 + 30 + impl LineSegment { 31 + /// Intersects the horizontal line at `y`. Returns `None` if parallel or out of bounds. 32 + /// The mathematical intersection `x` is returned along with direction `dir` (+1 for up, -1 for down). 33 + fn intersect_horizontal(&self, y: f32) -> Option<(f32, i32)> { 34 + // Only intersect if y is strictly between y0 and y1. 35 + let (min_y, max_y, dir) = if self.y0 < self.y1 { 36 + (self.y0, self.y1, 1) 37 + } else { 38 + (self.y1, self.y0, -1) 39 + }; 40 + 41 + if y < min_y || y >= max_y { 42 + return None; 43 + } 44 + 45 + let t = (y - self.y0) / (self.y1 - self.y0); 46 + let x = self.x0 + t * (self.x1 - self.x0); 47 + Some((x, dir)) 48 + } 49 + } 50 + 51 + /// Scale and flatten a glyph outline into reproducible line segments. 52 + fn flatten_outline(outline: &GlyphOutline, scale: f32) -> Vec<LineSegment> { 53 + let mut segments = Vec::new(); 54 + 55 + for contour in &outline.contours { 56 + if contour.points.is_empty() { 57 + continue; 58 + } 59 + 60 + let pts = &contour.points; 61 + 62 + // Iterate through implied points. 63 + // TrueType defines curves with an implicit on-curve point 64 + // halfway between any two consecutive off-curve points. 65 + let mut explicit_points = Vec::with_capacity(pts.len() * 2); 66 + 67 + for (i, p) in pts.iter().enumerate() { 68 + let next_p = &pts[(i + 1) % pts.len()]; 69 + 70 + // Add the current point 71 + explicit_points.push((p.x as f32 * scale, p.y as f32 * scale, p.on_curve)); 72 + 73 + // If current and next are both off-curve, insert a midpoint 74 + if !p.on_curve && !next_p.on_curve { 75 + let mx = (p.x as f32 + next_p.x as f32) * 0.5 * scale; 76 + let my = (p.y as f32 + next_p.y as f32) * 0.5 * scale; 77 + explicit_points.push((mx, my, true)); 78 + } 79 + } 80 + 81 + // Now traverse the explicit points. The path starts with the first point. 82 + // But what if the first point is off-curve? 83 + // Let's find an on-curve point to start. 84 + let start_idx = explicit_points.iter().position(|p| p.2).unwrap_or(0); 85 + 86 + let len = explicit_points.len(); 87 + let mut i = 0; 88 + let mut cur = explicit_points[start_idx]; 89 + 90 + // Ensure we loop back to start. 91 + while i < len { 92 + let next_idx = (start_idx + i + 1) % len; 93 + let p1 = explicit_points[next_idx]; 94 + 95 + if p1.2 { 96 + // Line to next on-curve point 97 + segments.push(LineSegment { 98 + x0: cur.0, 99 + y0: cur.1, 100 + x1: p1.0, 101 + y1: p1.1, 102 + }); 103 + cur = p1; 104 + i += 1; 105 + } else { 106 + // Quadratic bezier: p1 is control, need next on-curve point 107 + let p2_idx = (next_idx + 1) % len; 108 + let p2 = explicit_points[p2_idx]; 109 + 110 + // Flatten the quadratic bezier curve. 111 + flatten_quadratic(cur.0, cur.1, p1.0, p1.1, p2.0, p2.1, &mut segments); 112 + 113 + cur = p2; 114 + i += 2; 115 + } 116 + } 117 + } 118 + 119 + segments 120 + } 121 + 122 + /// Flattens a quadratic bézier into line segments using a fixed subdivision. 123 + fn flatten_quadratic( 124 + x0: f32, 125 + y0: f32, 126 + x1: f32, 127 + y1: f32, 128 + x2: f32, 129 + y2: f32, 130 + segments: &mut Vec<LineSegment>, 131 + ) { 132 + const STEPS: usize = 8; 133 + let mut prev_x = x0; 134 + let mut prev_y = y0; 135 + 136 + for i in 1..=STEPS { 137 + let t = i as f32 / STEPS as f32; 138 + let t_inv = 1.0 - t; 139 + 140 + // Quadratic formula: P = (1-t)^2*P0 + 2t(1-t)*P1 + t^2*P2 141 + let cur_x = t_inv * t_inv * x0 + 2.0 * t * t_inv * x1 + t * t * x2; 142 + let cur_y = t_inv * t_inv * y0 + 2.0 * t * t_inv * y1 + t * t * y2; 143 + 144 + segments.push(LineSegment { 145 + x0: prev_x, 146 + y0: prev_y, 147 + x1: cur_x, 148 + y1: cur_y, 149 + }); 150 + 151 + prev_x = cur_x; 152 + prev_y = cur_y; 153 + } 154 + } 155 + 156 + /// Rasterizes a scaled outline into a 0-255 coverage bitmap using 16x16 supersampling. 157 + pub fn rasterize(outline: &GlyphOutline, scale: f32) -> Option<GlyphBitmap> { 158 + if outline.contours.is_empty() { 159 + return None; 160 + } 161 + 162 + let segments = flatten_outline(outline, scale); 163 + 164 + let x_min = outline.x_min as f32 * scale; 165 + let y_min = outline.y_min as f32 * scale; 166 + let x_max = outline.x_max as f32 * scale; 167 + let y_max = outline.y_max as f32 * scale; 168 + 169 + let bitmap_width = (x_max.ceil() - x_min.floor()) as i32; 170 + let bitmap_height = (y_max.ceil() - y_min.floor()) as i32; 171 + 172 + if bitmap_width <= 0 || bitmap_height <= 0 { 173 + return None; 174 + } 175 + 176 + let bearing_x = x_min.floor() as i32; 177 + // Y-axis in TrueType goes UP, but in screen space it goes DOWN. 178 + // So the top-left of the bounding box is at y_max. 179 + let bearing_y = y_max.ceil() as i32; 180 + 181 + let width = bitmap_width as u32; 182 + let height = bitmap_height as u32; 183 + 184 + // 16x16 supersampling => 256 subpixels per pixel. 185 + const SUB_PIXELS: u32 = 16; 186 + let mut coverage = vec![0u16; (width * height) as usize]; 187 + 188 + for sy in 0..(height * SUB_PIXELS) { 189 + // TrueType Y goes up. The top pixel is row 0. 190 + // Therefore, pixel row `py` corresponds to `bearing_y - py - 1`. 191 + // The subpixel offset is from the top of the pixel box going downwards. 192 + let sub_y_rel = (sy % SUB_PIXELS) as f32 + 0.5; 193 + let py = sy / SUB_PIXELS; 194 + let real_y = bearing_y as f32 - (py as f32 + sub_y_rel / SUB_PIXELS as f32); 195 + 196 + // Find intersections. 197 + let mut intersections = Vec::with_capacity(32); 198 + for seg in &segments { 199 + if let Some((x, dir)) = seg.intersect_horizontal(real_y) { 200 + intersections.push((x, dir)); 201 + } 202 + } 203 + 204 + // Sort by X coordinate. 205 + intersections.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); 206 + 207 + // Traverse intersections to calculate winding and fill pixels. 208 + let mut winding = 0; 209 + let mut active_start_x = None; 210 + 211 + for (x, dir) in intersections { 212 + let prev_winding = winding; 213 + winding += dir; 214 + 215 + if prev_winding == 0 && winding != 0 { 216 + // Entered shape. 217 + active_start_x = Some(x); 218 + } else if prev_winding != 0 && winding == 0 { 219 + // Exited shape. Fill subpixels between active_start_x and x. 220 + if let Some(start_x) = active_start_x { 221 + // Map to subpixel coordinates on X axis. 222 + let start_sx = 223 + ((start_x - bearing_x as f32) * SUB_PIXELS as f32).round() as i32; 224 + let end_sx = ((x - bearing_x as f32) * SUB_PIXELS as f32).round() as i32; 225 + 226 + let s_start = start_sx.max(0) as u32; 227 + let s_end = (end_sx.max(0) as u32).min(width * SUB_PIXELS); 228 + 229 + for sx in s_start..s_end { 230 + let px = sx / SUB_PIXELS; 231 + let idx = (py * width + px) as usize; 232 + coverage[idx] += 1; 233 + } 234 + } 235 + active_start_x = None; 236 + } 237 + } 238 + } 239 + 240 + // Convert coverage (0-256) to 0-255 u8. 241 + let data = coverage.into_iter().map(|c| c.min(255) as u8).collect(); 242 + 243 + Some(GlyphBitmap { 244 + width, 245 + height, 246 + bearing_x, 247 + bearing_y, 248 + data, 249 + }) 250 + } 251 + 252 + #[cfg(test)] 253 + mod tests { 254 + use super::*; 255 + use crate::font::Font; 256 + 257 + fn load_test_font() -> Font { 258 + crate::font::load_system_font().expect("failed to load system font") 259 + } 260 + 261 + #[test] 262 + fn rasterize_basic_glyph() { 263 + let font = load_test_font(); 264 + let head = font.head().unwrap(); 265 + 266 + let gid = font.glyph_index(0x0041).unwrap().unwrap(); // 'A' 267 + let outline = font.glyph_outline(gid).unwrap().unwrap(); 268 + 269 + // 16px size. 270 + let scale = 16.0 / head.units_per_em as f32; 271 + let bitmap = rasterize(&outline, scale).unwrap(); 272 + 273 + assert!(bitmap.width > 0); 274 + assert!(bitmap.height > 0); 275 + assert_eq!(bitmap.data.len(), (bitmap.width * bitmap.height) as usize); 276 + 277 + // Assert it is anti-aliased (has values other than 0 and 255). 278 + let has_intermediate = bitmap.data.iter().any(|&v| v > 0 && v < 255); 279 + assert!(has_intermediate, "Bitmap must be anti-aliased"); 280 + 281 + // Assert it actually covers some area (has values > 128). 282 + let has_coverage = bitmap.data.iter().any(|&v| v > 128); 283 + assert!(has_coverage, "Bitmap must have solid pixels"); 284 + } 285 + }