| 1 | //! Math layout engine |
| 2 | //! |
| 3 | //! Computes bounding boxes and positions for mathematical typesetting. |
| 4 | //! Uses baseline-aligned layout with proper ascent/descent metrics. |
| 5 | |
| 6 | use crate::mathbox::{LimitDirection, MathBox, Operator}; |
| 7 | use cairo::Context; |
| 8 | |
| 9 | /// Layout metrics for a rendered element |
| 10 | #[derive(Debug, Clone, Default)] |
| 11 | pub struct LayoutBox { |
| 12 | /// Total width of the element |
| 13 | pub width: f64, |
| 14 | /// Height above the baseline |
| 15 | pub ascent: f64, |
| 16 | /// Depth below the baseline |
| 17 | pub descent: f64, |
| 18 | /// Positioned children: (x_offset, y_offset, child_layout) |
| 19 | pub children: Vec<(f64, f64, LayoutBox)>, |
| 20 | } |
| 21 | |
| 22 | impl LayoutBox { |
| 23 | /// Total height (ascent + descent) |
| 24 | pub fn height(&self) -> f64 { |
| 25 | self.ascent + self.descent |
| 26 | } |
| 27 | |
| 28 | /// Create an empty layout box |
| 29 | pub fn empty() -> Self { |
| 30 | Self::default() |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | /// Layout engine for mathematical typesetting |
| 35 | pub struct MathLayoutEngine { |
| 36 | /// Base font size in points |
| 37 | pub base_font_size: f64, |
| 38 | /// Font family name |
| 39 | pub font_family: String, |
| 40 | } |
| 41 | |
| 42 | impl Default for MathLayoutEngine { |
| 43 | fn default() -> Self { |
| 44 | Self { |
| 45 | base_font_size: 16.0, |
| 46 | font_family: "serif".to_string(), |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | impl MathLayoutEngine { |
| 52 | /// Create a new layout engine with given font settings |
| 53 | pub fn new(font_family: &str, font_size: f64) -> Self { |
| 54 | Self { |
| 55 | base_font_size: font_size, |
| 56 | font_family: font_family.to_string(), |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | /// Compute layout for a MathBox tree |
| 61 | pub fn layout(&self, mathbox: &MathBox, ctx: &Context) -> LayoutBox { |
| 62 | self.layout_at_depth(mathbox, ctx, 0) |
| 63 | } |
| 64 | |
| 65 | /// Compute layout from an explicit starting depth |
| 66 | pub fn layout_with_depth(&self, mathbox: &MathBox, ctx: &Context, depth: u32) -> LayoutBox { |
| 67 | self.layout_at_depth(mathbox, ctx, depth) |
| 68 | } |
| 69 | |
| 70 | /// Compute layout at a specific nesting depth |
| 71 | fn layout_at_depth(&self, mathbox: &MathBox, ctx: &Context, depth: u32) -> LayoutBox { |
| 72 | let scale = self.scale_for_depth(depth); |
| 73 | let font_size = self.base_font_size * scale; |
| 74 | |
| 75 | match mathbox { |
| 76 | MathBox::Number(s) => self.layout_text(s, ctx, font_size), |
| 77 | MathBox::Symbol(s) => self.layout_symbol(s, ctx, font_size), |
| 78 | MathBox::Operator(op) => self.layout_operator(*op, ctx, font_size), |
| 79 | MathBox::Slot => self.layout_slot(ctx, font_size), |
| 80 | MathBox::Fraction { num, den } => self.layout_fraction(num, den, ctx, depth), |
| 81 | MathBox::Power { base, exp } => self.layout_power(base, exp, ctx, depth), |
| 82 | MathBox::Subscript { base, sub } => self.layout_subscript(base, sub, ctx, depth), |
| 83 | MathBox::Root { index, radicand } => { |
| 84 | self.layout_root(index.as_deref(), radicand, ctx, depth) |
| 85 | } |
| 86 | MathBox::Func { name, args } => self.layout_func(name, args, ctx, depth), |
| 87 | MathBox::Abs(inner) => self.layout_abs(inner, ctx, depth), |
| 88 | MathBox::Parens(inner) => self.layout_parens(inner, ctx, depth), |
| 89 | MathBox::Integral { |
| 90 | lower, |
| 91 | upper, |
| 92 | body, |
| 93 | var, |
| 94 | } => self.layout_integral(lower.as_deref(), upper.as_deref(), body, var, ctx, depth), |
| 95 | MathBox::Derivative { order, var, body } => { |
| 96 | self.layout_derivative(*order, var, body, ctx, depth) |
| 97 | } |
| 98 | MathBox::Limit { |
| 99 | var, |
| 100 | to, |
| 101 | direction, |
| 102 | body, |
| 103 | } => self.layout_limit(var, to, *direction, body, ctx, depth), |
| 104 | MathBox::Sum { |
| 105 | var, |
| 106 | lower, |
| 107 | upper, |
| 108 | body, |
| 109 | } => self.layout_bigop("∑", var, lower, upper, body, ctx, depth), |
| 110 | MathBox::Product { |
| 111 | var, |
| 112 | lower, |
| 113 | upper, |
| 114 | body, |
| 115 | } => self.layout_bigop("∏", var, lower, upper, body, ctx, depth), |
| 116 | MathBox::Matrix { rows } => self.layout_matrix(rows, ctx, depth), |
| 117 | MathBox::Row(items) => self.layout_row(items, ctx, depth), |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | /// Scale factor for nested elements |
| 122 | pub fn scale_for_depth(&self, depth: u32) -> f64 { |
| 123 | match depth { |
| 124 | 0 => 1.0, |
| 125 | 1 => 0.8, |
| 126 | _ => 0.65, |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | /// Layout plain text |
| 131 | fn layout_text(&self, text: &str, ctx: &Context, font_size: f64) -> LayoutBox { |
| 132 | ctx.set_font_size(font_size); |
| 133 | let extents = ctx.text_extents(text).unwrap(); |
| 134 | let font_extents = ctx.font_extents().unwrap(); |
| 135 | |
| 136 | LayoutBox { |
| 137 | width: extents.x_advance(), |
| 138 | ascent: font_extents.ascent(), |
| 139 | descent: font_extents.descent(), |
| 140 | children: vec![], |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | /// Layout a symbol (may use italic) |
| 145 | fn layout_symbol(&self, symbol: &str, ctx: &Context, font_size: f64) -> LayoutBox { |
| 146 | ctx.select_font_face( |
| 147 | &self.font_family, |
| 148 | cairo::FontSlant::Italic, |
| 149 | cairo::FontWeight::Normal, |
| 150 | ); |
| 151 | let layout = self.layout_text(symbol, ctx, font_size); |
| 152 | ctx.select_font_face( |
| 153 | &self.font_family, |
| 154 | cairo::FontSlant::Normal, |
| 155 | cairo::FontWeight::Normal, |
| 156 | ); |
| 157 | layout |
| 158 | } |
| 159 | |
| 160 | /// Layout an operator |
| 161 | fn layout_operator(&self, op: Operator, ctx: &Context, font_size: f64) -> LayoutBox { |
| 162 | let ch = op.as_char().to_string(); |
| 163 | let mut layout = self.layout_text(&ch, ctx, font_size); |
| 164 | // Add padding around operators |
| 165 | layout.width += font_size * 0.3; |
| 166 | layout |
| 167 | } |
| 168 | |
| 169 | /// Layout an empty slot (placeholder box) |
| 170 | fn layout_slot(&self, _ctx: &Context, font_size: f64) -> LayoutBox { |
| 171 | // Keep slots readable at nested depths by enforcing a minimum visual size. |
| 172 | let slot_width = (font_size * 0.86).max(10.0); |
| 173 | let slot_height = (font_size * 0.9).max(11.0); |
| 174 | |
| 175 | LayoutBox { |
| 176 | width: slot_width, |
| 177 | ascent: slot_height * 0.62, |
| 178 | descent: slot_height * 0.38, |
| 179 | children: vec![], |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | /// Layout a fraction |
| 184 | fn layout_fraction( |
| 185 | &self, |
| 186 | num: &MathBox, |
| 187 | den: &MathBox, |
| 188 | ctx: &Context, |
| 189 | depth: u32, |
| 190 | ) -> LayoutBox { |
| 191 | let num_layout = self.layout_at_depth(num, ctx, depth + 1); |
| 192 | let den_layout = self.layout_at_depth(den, ctx, depth + 1); |
| 193 | |
| 194 | let scale = self.scale_for_depth(depth); |
| 195 | let bar_thickness = 1.0 * scale; |
| 196 | let gap = self.base_font_size * 0.1 * scale; |
| 197 | |
| 198 | let width = num_layout.width.max(den_layout.width) + self.base_font_size * 0.2; |
| 199 | let num_x = (width - num_layout.width) / 2.0; |
| 200 | let den_x = (width - den_layout.width) / 2.0; |
| 201 | |
| 202 | // Position numerator above bar, denominator below |
| 203 | let num_y = -(gap + bar_thickness / 2.0 + num_layout.descent); |
| 204 | let den_y = gap + bar_thickness / 2.0 + den_layout.ascent; |
| 205 | |
| 206 | LayoutBox { |
| 207 | width, |
| 208 | ascent: gap + bar_thickness / 2.0 + num_layout.height(), |
| 209 | descent: gap + bar_thickness / 2.0 + den_layout.height(), |
| 210 | children: vec![(num_x, num_y, num_layout), (den_x, den_y, den_layout)], |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | /// Layout a power (superscript) |
| 215 | fn layout_power(&self, base: &MathBox, exp: &MathBox, ctx: &Context, depth: u32) -> LayoutBox { |
| 216 | let base_layout = self.layout_at_depth(base, ctx, depth); |
| 217 | let exp_layout = self.layout_at_depth(exp, ctx, depth + 1); |
| 218 | let scale = self.scale_for_depth(depth); |
| 219 | let font_size = self.base_font_size * scale; |
| 220 | |
| 221 | // Exponent is raised above the baseline and slightly kerned to the right. |
| 222 | let exp_raise = base_layout.ascent * 0.58 + exp_layout.descent * 0.1; |
| 223 | let exp_kern = (font_size * 0.06).max(0.8); |
| 224 | |
| 225 | LayoutBox { |
| 226 | width: base_layout.width + exp_kern + exp_layout.width, |
| 227 | ascent: base_layout.ascent.max(exp_raise + exp_layout.ascent), |
| 228 | descent: base_layout.descent, |
| 229 | children: vec![ |
| 230 | (0.0, 0.0, base_layout.clone()), |
| 231 | (base_layout.width + exp_kern, -exp_raise, exp_layout), |
| 232 | ], |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | /// Layout a subscript |
| 237 | fn layout_subscript( |
| 238 | &self, |
| 239 | base: &MathBox, |
| 240 | sub: &MathBox, |
| 241 | ctx: &Context, |
| 242 | depth: u32, |
| 243 | ) -> LayoutBox { |
| 244 | let base_layout = self.layout_at_depth(base, ctx, depth); |
| 245 | let sub_layout = self.layout_at_depth(sub, ctx, depth + 1); |
| 246 | let scale = self.scale_for_depth(depth); |
| 247 | let font_size = self.base_font_size * scale; |
| 248 | |
| 249 | // Subscript is lowered with a small right kern to avoid touching the base. |
| 250 | let sub_lower = |
| 251 | (base_layout.descent * 0.6 + sub_layout.ascent * 0.9).max(sub_layout.ascent * 0.75); |
| 252 | let sub_kern = (font_size * 0.05).max(0.6); |
| 253 | |
| 254 | LayoutBox { |
| 255 | width: base_layout.width + sub_kern + sub_layout.width, |
| 256 | ascent: base_layout |
| 257 | .ascent |
| 258 | .max((sub_layout.ascent - sub_lower).max(0.0)), |
| 259 | descent: base_layout.descent.max(sub_lower + sub_layout.descent), |
| 260 | children: vec![ |
| 261 | (0.0, 0.0, base_layout.clone()), |
| 262 | (base_layout.width + sub_kern, sub_lower, sub_layout), |
| 263 | ], |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | /// Layout a root (square or nth) |
| 268 | fn layout_root( |
| 269 | &self, |
| 270 | index: Option<&MathBox>, |
| 271 | radicand: &MathBox, |
| 272 | ctx: &Context, |
| 273 | depth: u32, |
| 274 | ) -> LayoutBox { |
| 275 | let radicand_layout = self.layout_at_depth(radicand, ctx, depth); |
| 276 | let scale = self.scale_for_depth(depth); |
| 277 | let font_size = self.base_font_size * scale; |
| 278 | |
| 279 | // Radical symbol width |
| 280 | let radical_width = font_size * 0.6; |
| 281 | let bar_overhang = font_size * 0.1; |
| 282 | let gap = font_size * 0.1; |
| 283 | |
| 284 | let mut total_width = radical_width + radicand_layout.width + bar_overhang; |
| 285 | let mut children = vec![]; |
| 286 | |
| 287 | // Handle nth root index |
| 288 | let index_width = if let Some(idx) = index { |
| 289 | let idx_layout = self.layout_at_depth(idx, ctx, depth + 2); |
| 290 | let idx_x = 0.0; |
| 291 | let idx_y = -(radicand_layout.ascent * 0.5); |
| 292 | children.push((idx_x, idx_y, idx_layout.clone())); |
| 293 | idx_layout.width |
| 294 | } else { |
| 295 | 0.0 |
| 296 | }; |
| 297 | |
| 298 | total_width += index_width; |
| 299 | |
| 300 | // Radicand position |
| 301 | let radicand_x = index_width + radical_width; |
| 302 | children.push((radicand_x, 0.0, radicand_layout.clone())); |
| 303 | |
| 304 | LayoutBox { |
| 305 | width: total_width, |
| 306 | ascent: radicand_layout.ascent + gap + 1.0, // +1 for bar |
| 307 | descent: radicand_layout.descent, |
| 308 | children, |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | /// Layout a function call |
| 313 | fn layout_func(&self, name: &str, args: &[MathBox], ctx: &Context, depth: u32) -> LayoutBox { |
| 314 | if name == "factorial" && args.len() == 1 { |
| 315 | return self.layout_factorial(&args[0], ctx, depth); |
| 316 | } |
| 317 | |
| 318 | let scale = self.scale_for_depth(depth); |
| 319 | let font_size = self.base_font_size * scale; |
| 320 | |
| 321 | // Function name |
| 322 | let name_layout = self.layout_text(name, ctx, font_size); |
| 323 | let paren_width = font_size * 0.3; |
| 324 | |
| 325 | let mut width = name_layout.width + paren_width; // opening paren |
| 326 | let mut max_ascent = name_layout.ascent; |
| 327 | let mut max_descent = name_layout.descent; |
| 328 | let mut children = vec![(0.0, 0.0, name_layout.clone())]; |
| 329 | |
| 330 | let mut x = name_layout.width + paren_width; |
| 331 | |
| 332 | for (i, arg) in args.iter().enumerate() { |
| 333 | if i > 0 { |
| 334 | // Comma separator |
| 335 | let comma_layout = self.layout_text(",", ctx, font_size); |
| 336 | x += comma_layout.width; |
| 337 | width += comma_layout.width; |
| 338 | } |
| 339 | |
| 340 | let arg_layout = self.layout_at_depth(arg, ctx, depth); |
| 341 | max_ascent = max_ascent.max(arg_layout.ascent); |
| 342 | max_descent = max_descent.max(arg_layout.descent); |
| 343 | children.push((x, 0.0, arg_layout.clone())); |
| 344 | x += arg_layout.width; |
| 345 | width += arg_layout.width; |
| 346 | } |
| 347 | |
| 348 | width += paren_width; // closing paren |
| 349 | |
| 350 | LayoutBox { |
| 351 | width, |
| 352 | ascent: max_ascent, |
| 353 | descent: max_descent, |
| 354 | children, |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | fn layout_factorial(&self, arg: &MathBox, ctx: &Context, depth: u32) -> LayoutBox { |
| 359 | let scale = self.scale_for_depth(depth); |
| 360 | let font_size = self.base_font_size * scale; |
| 361 | let gap = (font_size * 0.06).max(0.6); |
| 362 | |
| 363 | let arg_layout = self.layout_at_depth(arg, ctx, depth); |
| 364 | let arg_width = arg_layout.width; |
| 365 | let bang_layout = self.layout_text("!", ctx, font_size); |
| 366 | |
| 367 | LayoutBox { |
| 368 | width: arg_width + gap + bang_layout.width, |
| 369 | ascent: arg_layout.ascent.max(bang_layout.ascent), |
| 370 | descent: arg_layout.descent.max(bang_layout.descent), |
| 371 | children: vec![(0.0, 0.0, arg_layout), (arg_width + gap, 0.0, bang_layout)], |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | /// Layout absolute value |
| 376 | fn layout_abs(&self, inner: &MathBox, ctx: &Context, depth: u32) -> LayoutBox { |
| 377 | let inner_layout = self.layout_at_depth(inner, ctx, depth); |
| 378 | let scale = self.scale_for_depth(depth); |
| 379 | let bar_width = self.base_font_size * 0.15 * scale; |
| 380 | |
| 381 | LayoutBox { |
| 382 | width: inner_layout.width + 2.0 * bar_width, |
| 383 | ascent: inner_layout.ascent, |
| 384 | descent: inner_layout.descent, |
| 385 | children: vec![(bar_width, 0.0, inner_layout)], |
| 386 | } |
| 387 | } |
| 388 | |
| 389 | /// Layout parenthesized expression |
| 390 | fn layout_parens(&self, inner: &MathBox, ctx: &Context, depth: u32) -> LayoutBox { |
| 391 | let inner_layout = self.layout_at_depth(inner, ctx, depth); |
| 392 | let scale = self.scale_for_depth(depth); |
| 393 | let paren_width = self.base_font_size * 0.25 * scale; |
| 394 | |
| 395 | LayoutBox { |
| 396 | width: inner_layout.width + 2.0 * paren_width, |
| 397 | ascent: inner_layout.ascent + 2.0, |
| 398 | descent: inner_layout.descent + 2.0, |
| 399 | children: vec![(paren_width, 0.0, inner_layout)], |
| 400 | } |
| 401 | } |
| 402 | |
| 403 | /// Layout an integral |
| 404 | fn layout_integral( |
| 405 | &self, |
| 406 | lower: Option<&MathBox>, |
| 407 | upper: Option<&MathBox>, |
| 408 | body: &MathBox, |
| 409 | var: &MathBox, |
| 410 | ctx: &Context, |
| 411 | depth: u32, |
| 412 | ) -> LayoutBox { |
| 413 | let scale = self.scale_for_depth(depth); |
| 414 | let font_size = self.base_font_size * scale; |
| 415 | |
| 416 | let body_layout = self.layout_at_depth(body, ctx, depth); |
| 417 | let var_layout = self.layout_at_depth(var, ctx, depth); |
| 418 | let d_layout = self.layout_text("d", ctx, font_size); |
| 419 | |
| 420 | // Integral symbol sizing and metrics |
| 421 | let symbol_size = body_layout.height().max(font_size * 1.8); |
| 422 | ctx.set_font_size(symbol_size); |
| 423 | let int_extents = ctx.text_extents("∫").unwrap(); |
| 424 | let int_font_extents = ctx.font_extents().unwrap(); |
| 425 | let symbol_width = int_extents.x_advance(); |
| 426 | let symbol_ascent = int_font_extents.ascent(); |
| 427 | let symbol_descent = int_font_extents.descent(); |
| 428 | |
| 429 | let lo_layout = lower.map(|lo| self.layout_at_depth(lo, ctx, depth + 1)); |
| 430 | let hi_layout = upper.map(|hi| self.layout_at_depth(hi, ctx, depth + 1)); |
| 431 | |
| 432 | let bound_gap = font_size * 0.14; |
| 433 | let bounds_width = symbol_width |
| 434 | .max(lo_layout.as_ref().map(|l| l.width).unwrap_or(0.0)) |
| 435 | .max(hi_layout.as_ref().map(|l| l.width).unwrap_or(0.0)); |
| 436 | let body_gap = font_size * 0.22; |
| 437 | let dx_gap = font_size * 0.14; |
| 438 | |
| 439 | let mut children = vec![]; |
| 440 | let body_x = bounds_width + body_gap; |
| 441 | children.push((body_x, 0.0, body_layout.clone())); |
| 442 | |
| 443 | let d_x = body_x + body_layout.width + dx_gap; |
| 444 | children.push((d_x, 0.0, d_layout.clone())); |
| 445 | let var_x = d_x + d_layout.width; |
| 446 | children.push((var_x, 0.0, var_layout.clone())); |
| 447 | |
| 448 | let ascent = |
| 449 | symbol_ascent + bound_gap + hi_layout.as_ref().map(|l| l.height()).unwrap_or(0.0); |
| 450 | let descent = |
| 451 | symbol_descent + bound_gap + lo_layout.as_ref().map(|l| l.height()).unwrap_or(0.0); |
| 452 | |
| 453 | LayoutBox { |
| 454 | width: var_x + var_layout.width, |
| 455 | ascent: ascent.max(body_layout.ascent), |
| 456 | descent: descent.max(body_layout.descent), |
| 457 | children, |
| 458 | } |
| 459 | } |
| 460 | |
| 461 | /// Layout a derivative |
| 462 | fn layout_derivative( |
| 463 | &self, |
| 464 | order: u32, |
| 465 | var: &MathBox, |
| 466 | body: &MathBox, |
| 467 | ctx: &Context, |
| 468 | depth: u32, |
| 469 | ) -> LayoutBox { |
| 470 | let scale = self.scale_for_depth(depth); |
| 471 | let font_size = self.base_font_size * scale; |
| 472 | let frac_font_size = font_size * 0.8; |
| 473 | |
| 474 | let var_layout = self.layout_at_depth(var, ctx, depth + 1); |
| 475 | let body_layout = self.layout_at_depth(body, ctx, depth); |
| 476 | |
| 477 | // Build "d/dx" or "d²/dx²" |
| 478 | let d_str = if order > 1 { |
| 479 | format!("d{}", superscript_digits(order)) |
| 480 | } else { |
| 481 | "d".to_string() |
| 482 | }; |
| 483 | |
| 484 | let dx_str = if order > 1 { |
| 485 | format!("d{}", superscript_digits(order)) |
| 486 | } else { |
| 487 | "d".to_string() |
| 488 | }; |
| 489 | |
| 490 | let d_layout = self.layout_text(&d_str, ctx, frac_font_size); |
| 491 | let dx_layout = self.layout_text(&dx_str, ctx, frac_font_size); |
| 492 | let den_sep = (frac_font_size * 0.08).max(0.6); |
| 493 | let denom_width = dx_layout.width + den_sep + var_layout.width; |
| 494 | let denom_height = dx_layout.height().max(var_layout.height()); |
| 495 | let frac_width = d_layout.width.max(denom_width) + font_size * 0.18; |
| 496 | let bar_gap = font_size * 0.14; |
| 497 | let body_gap = font_size * 0.3; |
| 498 | |
| 499 | LayoutBox { |
| 500 | width: frac_width + body_gap + body_layout.width, |
| 501 | ascent: (bar_gap + d_layout.height()).max(body_layout.ascent), |
| 502 | descent: (bar_gap + denom_height).max(body_layout.descent), |
| 503 | children: vec![(frac_width + body_gap, 0.0, body_layout)], |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | /// Layout a limit |
| 508 | fn layout_limit( |
| 509 | &self, |
| 510 | var: &MathBox, |
| 511 | to: &MathBox, |
| 512 | direction: Option<LimitDirection>, |
| 513 | body: &MathBox, |
| 514 | ctx: &Context, |
| 515 | depth: u32, |
| 516 | ) -> LayoutBox { |
| 517 | let scale = self.scale_for_depth(depth); |
| 518 | let font_size = self.base_font_size * scale; |
| 519 | |
| 520 | let body_layout = self.layout_at_depth(body, ctx, depth); |
| 521 | let var_layout = self.layout_at_depth(var, ctx, depth + 1); |
| 522 | let to_layout = self.layout_at_depth(to, ctx, depth + 1); |
| 523 | |
| 524 | // "lim" text |
| 525 | let lim_layout = self.layout_text("lim", ctx, font_size); |
| 526 | let lim_width = lim_layout.width; |
| 527 | |
| 528 | // Build subscript: "x→a" or "x→a⁺" or "x→a⁻" |
| 529 | let sub_font_size = font_size * 0.7; |
| 530 | let arrow_layout = self.layout_text("→", ctx, sub_font_size); |
| 531 | let dir_str = match direction { |
| 532 | Some(LimitDirection::FromRight) => "⁺", |
| 533 | Some(LimitDirection::FromLeft) => "⁻", |
| 534 | None => "", |
| 535 | }; |
| 536 | let dir_layout = if !dir_str.is_empty() { |
| 537 | Some(self.layout_text(dir_str, ctx, sub_font_size * 0.6)) |
| 538 | } else { |
| 539 | None |
| 540 | }; |
| 541 | |
| 542 | let sub_sep = (sub_font_size * 0.08).max(0.5); |
| 543 | let subscript_width = var_layout.width |
| 544 | + sub_sep |
| 545 | + arrow_layout.width |
| 546 | + sub_sep |
| 547 | + to_layout.width |
| 548 | + dir_layout.as_ref().map(|l| l.width).unwrap_or(0.0); |
| 549 | let subscript_height = var_layout |
| 550 | .height() |
| 551 | .max(arrow_layout.height()) |
| 552 | .max(to_layout.height()); |
| 553 | |
| 554 | let lim_col_width = lim_width.max(subscript_width); |
| 555 | let body_gap = font_size * 0.3; |
| 556 | let subscript_drop = font_size * 0.2 + subscript_height; |
| 557 | |
| 558 | LayoutBox { |
| 559 | width: lim_col_width + body_gap + body_layout.width, |
| 560 | ascent: lim_layout.ascent.max(body_layout.ascent), |
| 561 | descent: subscript_drop.max(body_layout.descent), |
| 562 | children: vec![(lim_col_width + body_gap, 0.0, body_layout)], |
| 563 | } |
| 564 | } |
| 565 | |
| 566 | /// Layout a big operator (sum, product) |
| 567 | fn layout_bigop( |
| 568 | &self, |
| 569 | symbol: &str, |
| 570 | var: &MathBox, |
| 571 | lower: &MathBox, |
| 572 | upper: &MathBox, |
| 573 | body: &MathBox, |
| 574 | ctx: &Context, |
| 575 | depth: u32, |
| 576 | ) -> LayoutBox { |
| 577 | let scale = self.scale_for_depth(depth); |
| 578 | let font_size = self.base_font_size * scale; |
| 579 | |
| 580 | let body_layout = self.layout_at_depth(body, ctx, depth); |
| 581 | let var_layout = self.layout_at_depth(var, ctx, depth + 1); |
| 582 | let lower_layout = self.layout_at_depth(lower, ctx, depth + 1); |
| 583 | let upper_layout = self.layout_at_depth(upper, ctx, depth + 1); |
| 584 | |
| 585 | // Big operator symbol |
| 586 | let symbol_size = font_size * 1.5; |
| 587 | ctx.set_font_size(symbol_size); |
| 588 | let symbol_extents = ctx.text_extents(symbol).unwrap(); |
| 589 | let symbol_width = symbol_extents.x_advance(); |
| 590 | let symbol_font_extents = ctx.font_extents().unwrap(); |
| 591 | let symbol_ascent = symbol_font_extents.ascent(); |
| 592 | let symbol_descent = symbol_font_extents.descent(); |
| 593 | |
| 594 | let bound_font_size = self.base_font_size * self.scale_for_depth(depth + 1); |
| 595 | let eq_layout = self.layout_text("=", ctx, bound_font_size); |
| 596 | let lower_sep = (bound_font_size * 0.08).max(0.6); |
| 597 | let lower_block_width = |
| 598 | var_layout.width + lower_sep + eq_layout.width + lower_sep + lower_layout.width; |
| 599 | |
| 600 | let op_width = symbol_width.max(lower_block_width).max(upper_layout.width); |
| 601 | let bounds_gap = font_size * 0.16; |
| 602 | let body_gap = font_size * 0.32; |
| 603 | // Rendering centers big-op symbols around the expression baseline. |
| 604 | // Reserve half of total glyph height above and below for consistent placement. |
| 605 | let symbol_half_height = (symbol_ascent + symbol_descent) * 0.5; |
| 606 | |
| 607 | let ascent = |
| 608 | (symbol_half_height + bounds_gap + upper_layout.height()).max(body_layout.ascent); |
| 609 | let descent = |
| 610 | (symbol_half_height + bounds_gap + lower_layout.height().max(var_layout.height())) |
| 611 | .max(body_layout.descent); |
| 612 | |
| 613 | LayoutBox { |
| 614 | width: op_width + body_gap + body_layout.width, |
| 615 | ascent, |
| 616 | descent, |
| 617 | children: vec![(op_width + body_gap, 0.0, body_layout)], |
| 618 | } |
| 619 | } |
| 620 | |
| 621 | /// Layout a matrix |
| 622 | fn layout_matrix(&self, rows: &[Vec<MathBox>], ctx: &Context, depth: u32) -> LayoutBox { |
| 623 | let scale = self.scale_for_depth(depth); |
| 624 | let font_size = self.base_font_size * scale; |
| 625 | let cell_padding = font_size * 0.3; |
| 626 | |
| 627 | if rows.is_empty() { |
| 628 | return LayoutBox::empty(); |
| 629 | } |
| 630 | |
| 631 | let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0); |
| 632 | if num_cols == 0 { |
| 633 | return LayoutBox::empty(); |
| 634 | } |
| 635 | |
| 636 | // Compute layouts for all cells |
| 637 | let cell_layouts: Vec<Vec<LayoutBox>> = rows |
| 638 | .iter() |
| 639 | .map(|row| { |
| 640 | row.iter() |
| 641 | .map(|cell| self.layout_at_depth(cell, ctx, depth)) |
| 642 | .collect() |
| 643 | }) |
| 644 | .collect(); |
| 645 | |
| 646 | // Find max width per column and max height per row |
| 647 | let mut col_widths = vec![0.0f64; num_cols]; |
| 648 | let mut row_heights = vec![0.0f64; rows.len()]; |
| 649 | |
| 650 | for (r, row) in cell_layouts.iter().enumerate() { |
| 651 | for (c, cell) in row.iter().enumerate() { |
| 652 | col_widths[c] = col_widths[c].max(cell.width); |
| 653 | row_heights[r] = row_heights[r].max(cell.height()); |
| 654 | } |
| 655 | } |
| 656 | |
| 657 | // Total dimensions |
| 658 | let bracket_width = font_size * 0.2; |
| 659 | let total_width: f64 = col_widths.iter().sum::<f64>() |
| 660 | + cell_padding * (num_cols as f64 - 1.0) |
| 661 | + 2.0 * bracket_width; |
| 662 | let total_height: f64 = |
| 663 | row_heights.iter().sum::<f64>() + cell_padding * (rows.len() as f64 - 1.0); |
| 664 | |
| 665 | // Position cells |
| 666 | let mut children = vec![]; |
| 667 | let mut y = -total_height / 2.0; |
| 668 | |
| 669 | for (r, row) in cell_layouts.iter().enumerate() { |
| 670 | let mut x = bracket_width; |
| 671 | let row_h = row_heights[r]; |
| 672 | |
| 673 | for (c, cell) in row.iter().enumerate() { |
| 674 | let col_w = col_widths[c]; |
| 675 | // Center cell in its column |
| 676 | let cell_x = x + (col_w - cell.width) / 2.0; |
| 677 | let cell_y = y + row_h / 2.0; |
| 678 | children.push((cell_x, cell_y, cell.clone())); |
| 679 | x += col_w + cell_padding; |
| 680 | } |
| 681 | |
| 682 | y += row_h + cell_padding; |
| 683 | } |
| 684 | |
| 685 | LayoutBox { |
| 686 | width: total_width, |
| 687 | ascent: total_height / 2.0 + cell_padding, |
| 688 | descent: total_height / 2.0 + cell_padding, |
| 689 | children, |
| 690 | } |
| 691 | } |
| 692 | |
| 693 | /// Layout a horizontal row of elements |
| 694 | fn layout_row(&self, items: &[MathBox], ctx: &Context, depth: u32) -> LayoutBox { |
| 695 | let scale = self.scale_for_depth(depth); |
| 696 | let _font_size = self.base_font_size * scale; |
| 697 | |
| 698 | let mut width: f64 = 0.0; |
| 699 | let mut max_ascent: f64 = 0.0; |
| 700 | let mut max_descent: f64 = 0.0; |
| 701 | let mut children = vec![]; |
| 702 | |
| 703 | for item in items { |
| 704 | let item_layout = self.layout_at_depth(item, ctx, depth); |
| 705 | children.push((width, 0.0, item_layout.clone())); |
| 706 | width += item_layout.width; |
| 707 | max_ascent = max_ascent.max(item_layout.ascent); |
| 708 | max_descent = max_descent.max(item_layout.descent); |
| 709 | } |
| 710 | |
| 711 | LayoutBox { |
| 712 | width, |
| 713 | ascent: max_ascent, |
| 714 | descent: max_descent, |
| 715 | children, |
| 716 | } |
| 717 | } |
| 718 | } |
| 719 | |
| 720 | /// Convert a number to superscript Unicode digits |
| 721 | fn superscript_digits(n: u32) -> String { |
| 722 | n.to_string() |
| 723 | .chars() |
| 724 | .map(|c| match c { |
| 725 | '0' => '⁰', |
| 726 | '1' => '¹', |
| 727 | '2' => '²', |
| 728 | '3' => '³', |
| 729 | '4' => '⁴', |
| 730 | '5' => '⁵', |
| 731 | '6' => '⁶', |
| 732 | '7' => '⁷', |
| 733 | '8' => '⁸', |
| 734 | '9' => '⁹', |
| 735 | _ => c, |
| 736 | }) |
| 737 | .collect() |
| 738 | } |
| 739 | |
| 740 | #[cfg(test)] |
| 741 | mod tests { |
| 742 | use super::*; |
| 743 | use cairo::{Context, Format, ImageSurface}; |
| 744 | |
| 745 | fn test_context() -> Context { |
| 746 | let surface = ImageSurface::create(Format::ARgb32, 256, 256).unwrap(); |
| 747 | Context::new(&surface).unwrap() |
| 748 | } |
| 749 | |
| 750 | #[test] |
| 751 | fn test_scale_for_depth() { |
| 752 | let engine = MathLayoutEngine::default(); |
| 753 | assert_eq!(engine.scale_for_depth(0), 1.0); |
| 754 | assert_eq!(engine.scale_for_depth(1), 0.8); |
| 755 | assert_eq!(engine.scale_for_depth(2), 0.65); |
| 756 | assert_eq!(engine.scale_for_depth(5), 0.65); |
| 757 | } |
| 758 | |
| 759 | #[test] |
| 760 | fn test_superscript_digits() { |
| 761 | assert_eq!(superscript_digits(2), "²"); |
| 762 | assert_eq!(superscript_digits(123), "¹²³"); |
| 763 | } |
| 764 | |
| 765 | #[test] |
| 766 | fn test_slot_min_size_at_nested_depth() { |
| 767 | let engine = MathLayoutEngine::default(); |
| 768 | let ctx = test_context(); |
| 769 | let slot = engine.layout_with_depth(&MathBox::Slot, &ctx, 2); |
| 770 | assert!(slot.width >= 10.0); |
| 771 | assert!(slot.height() >= 11.0); |
| 772 | } |
| 773 | |
| 774 | #[test] |
| 775 | fn test_power_layout_raises_exponent() { |
| 776 | let engine = MathLayoutEngine::default(); |
| 777 | let ctx = test_context(); |
| 778 | let power = MathBox::Power { |
| 779 | base: Box::new(MathBox::Number("2".to_string())), |
| 780 | exp: Box::new(MathBox::Slot), |
| 781 | }; |
| 782 | let layout = engine.layout(&power, &ctx); |
| 783 | let base_layout = engine.layout(&MathBox::Number("2".to_string()), &ctx); |
| 784 | |
| 785 | assert!(layout.width > base_layout.width); |
| 786 | assert!(layout.ascent > base_layout.ascent); |
| 787 | assert_eq!(layout.children.len(), 2); |
| 788 | assert!(layout.children[1].1 < 0.0); |
| 789 | } |
| 790 | |
| 791 | #[test] |
| 792 | fn test_subscript_layout_lowers_and_extends_descent() { |
| 793 | let engine = MathLayoutEngine::default(); |
| 794 | let ctx = test_context(); |
| 795 | let sub = MathBox::Subscript { |
| 796 | base: Box::new(MathBox::Number("x".to_string())), |
| 797 | sub: Box::new(MathBox::Slot), |
| 798 | }; |
| 799 | let layout = engine.layout(&sub, &ctx); |
| 800 | let base_layout = engine.layout(&MathBox::Number("x".to_string()), &ctx); |
| 801 | |
| 802 | assert!(layout.descent > base_layout.descent); |
| 803 | assert_eq!(layout.children.len(), 2); |
| 804 | assert!(layout.children[1].1 > 0.0); |
| 805 | } |
| 806 | |
| 807 | #[test] |
| 808 | fn test_bigop_layout_reserves_space_for_var_equals_lower() { |
| 809 | let engine = MathLayoutEngine::default(); |
| 810 | let ctx = test_context(); |
| 811 | |
| 812 | let short = MathBox::Sum { |
| 813 | var: Box::new(MathBox::Symbol("i".to_string())), |
| 814 | lower: Box::new(MathBox::Number("1".to_string())), |
| 815 | upper: Box::new(MathBox::Number("5".to_string())), |
| 816 | body: Box::new(MathBox::Slot), |
| 817 | }; |
| 818 | let wide = MathBox::Sum { |
| 819 | var: Box::new(MathBox::Symbol("index".to_string())), |
| 820 | lower: Box::new(MathBox::Number("123456".to_string())), |
| 821 | upper: Box::new(MathBox::Number("5".to_string())), |
| 822 | body: Box::new(MathBox::Slot), |
| 823 | }; |
| 824 | |
| 825 | let short_layout = engine.layout(&short, &ctx); |
| 826 | let wide_layout = engine.layout(&wide, &ctx); |
| 827 | assert!(wide_layout.width > short_layout.width); |
| 828 | } |
| 829 | |
| 830 | #[test] |
| 831 | fn test_bigop_layout_expands_ascent_descent_for_bounds() { |
| 832 | let engine = MathLayoutEngine::default(); |
| 833 | let ctx = test_context(); |
| 834 | |
| 835 | let sum = MathBox::Sum { |
| 836 | var: Box::new(MathBox::Symbol("i".to_string())), |
| 837 | lower: Box::new(MathBox::Slot), |
| 838 | upper: Box::new(MathBox::Slot), |
| 839 | body: Box::new(MathBox::Number("x".to_string())), |
| 840 | }; |
| 841 | let body_layout = engine.layout(&MathBox::Number("x".to_string()), &ctx); |
| 842 | let sum_layout = engine.layout(&sum, &ctx); |
| 843 | |
| 844 | assert!(sum_layout.ascent > body_layout.ascent); |
| 845 | assert!(sum_layout.descent > body_layout.descent); |
| 846 | } |
| 847 | |
| 848 | #[test] |
| 849 | fn test_derivative_layout_widens_for_long_variable() { |
| 850 | let engine = MathLayoutEngine::default(); |
| 851 | let ctx = test_context(); |
| 852 | |
| 853 | let short = MathBox::Derivative { |
| 854 | order: 1, |
| 855 | var: Box::new(MathBox::Symbol("x".to_string())), |
| 856 | body: Box::new(MathBox::Slot), |
| 857 | }; |
| 858 | let long = MathBox::Derivative { |
| 859 | order: 1, |
| 860 | var: Box::new(MathBox::Symbol("variable".to_string())), |
| 861 | body: Box::new(MathBox::Slot), |
| 862 | }; |
| 863 | |
| 864 | let short_layout = engine.layout(&short, &ctx); |
| 865 | let long_layout = engine.layout(&long, &ctx); |
| 866 | assert!(long_layout.width > short_layout.width); |
| 867 | } |
| 868 | |
| 869 | #[test] |
| 870 | fn test_integral_layout_bounds_expand_vertical_space() { |
| 871 | let engine = MathLayoutEngine::default(); |
| 872 | let ctx = test_context(); |
| 873 | |
| 874 | let plain = MathBox::Integral { |
| 875 | lower: None, |
| 876 | upper: None, |
| 877 | body: Box::new(MathBox::Slot), |
| 878 | var: Box::new(MathBox::Symbol("x".to_string())), |
| 879 | }; |
| 880 | let bounded = MathBox::Integral { |
| 881 | lower: Some(Box::new(MathBox::Slot)), |
| 882 | upper: Some(Box::new(MathBox::Slot)), |
| 883 | body: Box::new(MathBox::Slot), |
| 884 | var: Box::new(MathBox::Symbol("x".to_string())), |
| 885 | }; |
| 886 | |
| 887 | let plain_layout = engine.layout(&plain, &ctx); |
| 888 | let bounded_layout = engine.layout(&bounded, &ctx); |
| 889 | assert!(bounded_layout.ascent > plain_layout.ascent); |
| 890 | assert!(bounded_layout.descent > plain_layout.descent); |
| 891 | } |
| 892 | |
| 893 | #[test] |
| 894 | fn test_limit_layout_widens_for_wide_subscript_and_extends_descent() { |
| 895 | let engine = MathLayoutEngine::default(); |
| 896 | let ctx = test_context(); |
| 897 | |
| 898 | let narrow = MathBox::Limit { |
| 899 | var: Box::new(MathBox::Symbol("x".to_string())), |
| 900 | to: Box::new(MathBox::Number("1".to_string())), |
| 901 | direction: None, |
| 902 | body: Box::new(MathBox::Slot), |
| 903 | }; |
| 904 | let wide = MathBox::Limit { |
| 905 | var: Box::new(MathBox::Symbol("veryLongVariable".to_string())), |
| 906 | to: Box::new(MathBox::Number("123456".to_string())), |
| 907 | direction: Some(LimitDirection::FromRight), |
| 908 | body: Box::new(MathBox::Slot), |
| 909 | }; |
| 910 | |
| 911 | let narrow_layout = engine.layout(&narrow, &ctx); |
| 912 | let wide_layout = engine.layout(&wide, &ctx); |
| 913 | let body_layout = engine.layout(&MathBox::Slot, &ctx); |
| 914 | |
| 915 | assert!(wide_layout.width > narrow_layout.width); |
| 916 | assert!(wide_layout.descent > body_layout.descent); |
| 917 | } |
| 918 | } |
| 919 |