1use std::fmt;
26
27const SUBPX_SCALE: u32 = 256;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
36pub enum LeadingSpec {
37 #[default]
39 None,
40 Fixed(u32),
42 Proportional(u32),
50}
51
52impl LeadingSpec {
53 #[must_use]
55 pub fn resolve(&self, line_height_subpx: u32) -> u32 {
56 match *self {
57 LeadingSpec::None => 0,
58 LeadingSpec::Fixed(v) => v,
59 LeadingSpec::Proportional(frac) => {
60 let product = (line_height_subpx as u64) * (frac as u64);
62 (product / SUBPX_SCALE as u64) as u32
63 }
64 }
65 }
66
67 pub const CSS_DEFAULT: Self = Self::Proportional(51);
69
70 pub const ONE_HALF: Self = Self::Proportional(128);
72
73 pub const DOUBLE: Self = Self::Proportional(256);
75}
76
77impl fmt::Display for LeadingSpec {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 match self {
80 Self::None => write!(f, "none"),
81 Self::Fixed(v) => write!(f, "fixed({:.1}px)", *v as f64 / SUBPX_SCALE as f64),
82 Self::Proportional(frac) => {
83 write!(f, "{:.0}%", *frac as f64 / SUBPX_SCALE as f64 * 100.0)
84 }
85 }
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95pub struct ParagraphSpacing {
96 pub before_subpx: u32,
98 pub after_subpx: u32,
100}
101
102impl ParagraphSpacing {
103 pub const NONE: Self = Self {
105 before_subpx: 0,
106 after_subpx: 0,
107 };
108
109 #[must_use]
111 pub fn one_line(line_height_subpx: u32) -> Self {
112 Self {
113 before_subpx: 0,
114 after_subpx: line_height_subpx,
115 }
116 }
117
118 #[must_use]
120 pub fn half_line(line_height_subpx: u32) -> Self {
121 Self {
122 before_subpx: 0,
123 after_subpx: line_height_subpx / 2,
124 }
125 }
126
127 #[must_use]
129 pub const fn custom(before: u32, after: u32) -> Self {
130 Self {
131 before_subpx: before,
132 after_subpx: after,
133 }
134 }
135
136 #[must_use]
138 pub const fn total(&self) -> u32 {
139 self.before_subpx.saturating_add(self.after_subpx)
140 }
141}
142
143impl Default for ParagraphSpacing {
144 fn default() -> Self {
145 Self::NONE
146 }
147}
148
149impl fmt::Display for ParagraphSpacing {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 write!(
152 f,
153 "before={:.1}px after={:.1}px",
154 self.before_subpx as f64 / SUBPX_SCALE as f64,
155 self.after_subpx as f64 / SUBPX_SCALE as f64,
156 )
157 }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
170pub struct BaselineGrid {
171 pub interval_subpx: u32,
176 pub offset_subpx: u32,
180}
181
182impl BaselineGrid {
183 pub const NONE: Self = Self {
185 interval_subpx: 0,
186 offset_subpx: 0,
187 };
188
189 #[must_use]
191 pub const fn from_line_height(line_height_subpx: u32, leading_subpx: u32) -> Self {
192 Self {
193 interval_subpx: line_height_subpx.saturating_add(leading_subpx),
194 offset_subpx: 0,
195 }
196 }
197
198 #[must_use]
200 pub const fn is_active(&self) -> bool {
201 self.interval_subpx > 0
202 }
203
204 #[must_use]
208 pub const fn snap(&self, pos_subpx: u32) -> u32 {
209 if self.interval_subpx == 0 {
210 return pos_subpx;
211 }
212 let adjusted = pos_subpx.saturating_sub(self.offset_subpx);
213 let remainder = adjusted % self.interval_subpx;
214 if remainder == 0 {
215 pos_subpx
216 } else {
217 pos_subpx.saturating_add(self.interval_subpx - remainder)
218 }
219 }
220}
221
222impl Default for BaselineGrid {
223 fn default() -> Self {
224 Self::NONE
225 }
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
237pub enum VerticalPolicy {
238 #[default]
241 Compact,
242 Readable,
245 Typographic,
248}
249
250impl VerticalPolicy {
251 #[must_use]
254 pub fn resolve(&self, line_height_subpx: u32) -> VerticalMetrics {
255 match self {
256 Self::Compact => VerticalMetrics {
257 leading: LeadingSpec::None,
258 paragraph_spacing: ParagraphSpacing::NONE,
259 baseline_grid: BaselineGrid::NONE,
260 first_line_indent_subpx: 0,
261 },
262 Self::Readable => {
263 let leading = LeadingSpec::CSS_DEFAULT;
264 let leading_val = leading.resolve(line_height_subpx);
265 VerticalMetrics {
266 leading,
267 paragraph_spacing: ParagraphSpacing::half_line(
268 line_height_subpx.saturating_add(leading_val),
269 ),
270 baseline_grid: BaselineGrid::NONE,
271 first_line_indent_subpx: 0,
272 }
273 }
274 Self::Typographic => {
275 let leading = LeadingSpec::CSS_DEFAULT;
276 let leading_val = leading.resolve(line_height_subpx);
277 let total_line = line_height_subpx.saturating_add(leading_val);
278 VerticalMetrics {
279 leading,
280 paragraph_spacing: ParagraphSpacing::one_line(total_line),
281 baseline_grid: BaselineGrid::from_line_height(line_height_subpx, leading_val),
282 first_line_indent_subpx: 2 * SUBPX_SCALE, }
284 }
285 }
286 }
287}
288
289impl fmt::Display for VerticalPolicy {
290 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291 match self {
292 Self::Compact => write!(f, "compact"),
293 Self::Readable => write!(f, "readable"),
294 Self::Typographic => write!(f, "typographic"),
295 }
296 }
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
307pub struct VerticalMetrics {
308 pub leading: LeadingSpec,
310 pub paragraph_spacing: ParagraphSpacing,
312 pub baseline_grid: BaselineGrid,
314 pub first_line_indent_subpx: u32,
316}
317
318impl VerticalMetrics {
319 #[must_use]
326 pub fn paragraph_height(&self, line_count: usize, line_height_subpx: u32) -> u32 {
327 if line_count == 0 {
328 return 0;
329 }
330
331 let leading_val = self.leading.resolve(line_height_subpx);
332 let lines_height = (line_count as u32) * line_height_subpx;
333 let inter_leading = if line_count > 1 {
334 ((line_count - 1) as u32) * leading_val
335 } else {
336 0
337 };
338
339 let content_height = lines_height.saturating_add(inter_leading);
340 let total = self
341 .paragraph_spacing
342 .before_subpx
343 .saturating_add(content_height)
344 .saturating_add(self.paragraph_spacing.after_subpx);
345
346 if self.baseline_grid.is_active() {
347 self.baseline_grid.snap(total)
348 } else {
349 total
350 }
351 }
352
353 #[must_use]
357 pub fn line_y(&self, line_index: usize, line_height_subpx: u32) -> u32 {
358 let leading_val = self.leading.resolve(line_height_subpx);
359 let line_step = line_height_subpx.saturating_add(leading_val);
360
361 let raw_y = self
362 .paragraph_spacing
363 .before_subpx
364 .saturating_add((line_index as u32) * line_step);
365
366 if self.baseline_grid.is_active() {
367 self.baseline_grid.snap(raw_y)
368 } else {
369 raw_y
370 }
371 }
372
373 #[must_use]
377 pub fn document_height(&self, paragraphs: &[usize], line_height_subpx: u32) -> u32 {
378 let mut total = 0u32;
379 for (idx, &line_count) in paragraphs.iter().enumerate() {
380 if idx > 0 {
381 let collapsed = self
384 .paragraph_spacing
385 .after_subpx
386 .max(self.paragraph_spacing.before_subpx);
387 total = total.saturating_add(collapsed);
388 } else {
389 total = total.saturating_add(self.paragraph_spacing.before_subpx);
390 }
391
392 let leading_val = self.leading.resolve(line_height_subpx);
393 let lines_height = (line_count as u32) * line_height_subpx;
394 let inter_leading = if line_count > 1 {
395 ((line_count - 1) as u32) * leading_val
396 } else {
397 0
398 };
399 total = total
400 .saturating_add(lines_height)
401 .saturating_add(inter_leading);
402
403 if idx == paragraphs.len() - 1 {
405 total = total.saturating_add(self.paragraph_spacing.after_subpx);
406 }
407 }
408
409 if self.baseline_grid.is_active() {
410 self.baseline_grid.snap(total)
411 } else {
412 total
413 }
414 }
415
416 #[must_use]
420 pub fn to_cell_rows(height_subpx: u32, cell_height_subpx: u32) -> u16 {
421 if cell_height_subpx == 0 {
422 return 0;
423 }
424 let rows = height_subpx.div_ceil(cell_height_subpx);
425 rows.min(u16::MAX as u32) as u16
426 }
427}
428
429#[cfg(test)]
434mod tests {
435 use super::*;
436
437 const LINE_H: u32 = 16 * SUBPX_SCALE; #[test]
442 fn leading_none() {
443 assert_eq!(LeadingSpec::None.resolve(LINE_H), 0);
444 }
445
446 #[test]
447 fn leading_fixed() {
448 let spec = LeadingSpec::Fixed(2 * SUBPX_SCALE); assert_eq!(spec.resolve(LINE_H), 2 * SUBPX_SCALE);
450 }
451
452 #[test]
453 fn leading_proportional_20_percent() {
454 let spec = LeadingSpec::CSS_DEFAULT; let leading = spec.resolve(LINE_H);
456 assert_eq!(leading, 816);
458 }
459
460 #[test]
461 fn leading_proportional_50_percent() {
462 let leading = LeadingSpec::ONE_HALF.resolve(LINE_H);
463 assert_eq!(leading, 2048);
465 }
466
467 #[test]
468 fn leading_proportional_double() {
469 let leading = LeadingSpec::DOUBLE.resolve(LINE_H);
470 assert_eq!(leading, LINE_H);
472 }
473
474 #[test]
475 fn leading_display() {
476 assert_eq!(format!("{}", LeadingSpec::None), "none");
477 let fixed = LeadingSpec::Fixed(2 * SUBPX_SCALE);
478 assert!(format!("{fixed}").contains("2.0px"));
479 }
480
481 #[test]
484 fn spacing_none() {
485 assert_eq!(ParagraphSpacing::NONE.total(), 0);
486 }
487
488 #[test]
489 fn spacing_one_line() {
490 let sp = ParagraphSpacing::one_line(LINE_H);
491 assert_eq!(sp.before_subpx, 0);
492 assert_eq!(sp.after_subpx, LINE_H);
493 assert_eq!(sp.total(), LINE_H);
494 }
495
496 #[test]
497 fn spacing_half_line() {
498 let sp = ParagraphSpacing::half_line(LINE_H);
499 assert_eq!(sp.after_subpx, LINE_H / 2);
500 }
501
502 #[test]
503 fn spacing_custom() {
504 let sp = ParagraphSpacing::custom(100, 200);
505 assert_eq!(sp.before_subpx, 100);
506 assert_eq!(sp.after_subpx, 200);
507 assert_eq!(sp.total(), 300);
508 }
509
510 #[test]
511 fn spacing_display() {
512 let s = format!("{}", ParagraphSpacing::NONE);
513 assert!(s.contains("0.0px"));
514 }
515
516 #[test]
519 fn grid_none_is_inactive() {
520 assert!(!BaselineGrid::NONE.is_active());
521 }
522
523 #[test]
524 fn grid_from_line_height() {
525 let grid = BaselineGrid::from_line_height(LINE_H, 2 * SUBPX_SCALE);
526 assert!(grid.is_active());
527 assert_eq!(grid.interval_subpx, LINE_H + 2 * SUBPX_SCALE);
528 }
529
530 #[test]
531 fn grid_snap_exact() {
532 let grid = BaselineGrid {
533 interval_subpx: 1000,
534 offset_subpx: 0,
535 };
536 assert_eq!(grid.snap(2000), 2000);
537 assert_eq!(grid.snap(3000), 3000);
538 }
539
540 #[test]
541 fn grid_snap_rounds_up() {
542 let grid = BaselineGrid {
543 interval_subpx: 1000,
544 offset_subpx: 0,
545 };
546 assert_eq!(grid.snap(1), 1000);
547 assert_eq!(grid.snap(999), 1000);
548 assert_eq!(grid.snap(1001), 2000);
549 }
550
551 #[test]
552 fn grid_snap_with_offset() {
553 let grid = BaselineGrid {
554 interval_subpx: 1000,
555 offset_subpx: 200,
556 };
557 assert_eq!(grid.snap(200), 200);
559 assert_eq!(grid.snap(500), 1200);
561 }
562
563 #[test]
564 fn grid_snap_disabled() {
565 assert_eq!(BaselineGrid::NONE.snap(42), 42);
566 }
567
568 #[test]
571 fn policy_compact() {
572 let m = VerticalPolicy::Compact.resolve(LINE_H);
573 assert_eq!(m.leading, LeadingSpec::None);
574 assert_eq!(m.paragraph_spacing, ParagraphSpacing::NONE);
575 assert!(!m.baseline_grid.is_active());
576 assert_eq!(m.first_line_indent_subpx, 0);
577 }
578
579 #[test]
580 fn policy_readable() {
581 let m = VerticalPolicy::Readable.resolve(LINE_H);
582 assert_eq!(m.leading, LeadingSpec::CSS_DEFAULT);
583 assert!(!m.baseline_grid.is_active());
584 assert!(m.paragraph_spacing.after_subpx > 0);
585 }
586
587 #[test]
588 fn policy_typographic() {
589 let m = VerticalPolicy::Typographic.resolve(LINE_H);
590 assert_eq!(m.leading, LeadingSpec::CSS_DEFAULT);
591 assert!(m.baseline_grid.is_active());
592 assert!(m.paragraph_spacing.after_subpx > 0);
593 assert!(m.first_line_indent_subpx > 0);
594 }
595
596 #[test]
597 fn policy_display() {
598 assert_eq!(format!("{}", VerticalPolicy::Compact), "compact");
599 assert_eq!(format!("{}", VerticalPolicy::Readable), "readable");
600 assert_eq!(format!("{}", VerticalPolicy::Typographic), "typographic");
601 }
602
603 #[test]
604 fn policy_default_is_compact() {
605 assert_eq!(VerticalPolicy::default(), VerticalPolicy::Compact);
606 }
607
608 #[test]
611 fn paragraph_height_zero_lines() {
612 let m = VerticalPolicy::Compact.resolve(LINE_H);
613 assert_eq!(m.paragraph_height(0, LINE_H), 0);
614 }
615
616 #[test]
617 fn paragraph_height_single_line_compact() {
618 let m = VerticalPolicy::Compact.resolve(LINE_H);
619 assert_eq!(m.paragraph_height(1, LINE_H), LINE_H);
620 }
621
622 #[test]
623 fn paragraph_height_multi_line_compact() {
624 let m = VerticalPolicy::Compact.resolve(LINE_H);
625 assert_eq!(m.paragraph_height(3, LINE_H), 3 * LINE_H);
627 }
628
629 #[test]
630 fn paragraph_height_with_leading() {
631 let mut m = VerticalPolicy::Compact.resolve(LINE_H);
632 m.leading = LeadingSpec::Fixed(2 * SUBPX_SCALE); assert_eq!(
635 m.paragraph_height(3, LINE_H),
636 3 * LINE_H + 2 * 2 * SUBPX_SCALE
637 );
638 }
639
640 #[test]
641 fn paragraph_height_with_spacing() {
642 let mut m = VerticalPolicy::Compact.resolve(LINE_H);
643 m.paragraph_spacing = ParagraphSpacing::custom(SUBPX_SCALE, SUBPX_SCALE);
644 assert_eq!(m.paragraph_height(1, LINE_H), LINE_H + 2 * SUBPX_SCALE);
646 }
647
648 #[test]
649 fn line_y_compact() {
650 let m = VerticalPolicy::Compact.resolve(LINE_H);
651 assert_eq!(m.line_y(0, LINE_H), 0);
652 assert_eq!(m.line_y(1, LINE_H), LINE_H);
653 assert_eq!(m.line_y(2, LINE_H), 2 * LINE_H);
654 }
655
656 #[test]
657 fn line_y_with_leading() {
658 let mut m = VerticalPolicy::Compact.resolve(LINE_H);
659 m.leading = LeadingSpec::Fixed(SUBPX_SCALE); assert_eq!(m.line_y(0, LINE_H), 0);
661 assert_eq!(m.line_y(1, LINE_H), LINE_H + SUBPX_SCALE);
662 assert_eq!(m.line_y(2, LINE_H), 2 * (LINE_H + SUBPX_SCALE));
663 }
664
665 #[test]
666 fn line_y_with_before_spacing() {
667 let mut m = VerticalPolicy::Compact.resolve(LINE_H);
668 m.paragraph_spacing.before_subpx = SUBPX_SCALE; assert_eq!(m.line_y(0, LINE_H), SUBPX_SCALE);
670 assert_eq!(m.line_y(1, LINE_H), SUBPX_SCALE + LINE_H);
671 }
672
673 #[test]
674 fn document_height_single_paragraph() {
675 let m = VerticalPolicy::Compact.resolve(LINE_H);
676 assert_eq!(m.document_height(&[3], LINE_H), 3 * LINE_H);
677 }
678
679 #[test]
680 fn document_height_multi_paragraph() {
681 let m = VerticalPolicy::Compact.resolve(LINE_H);
682 assert_eq!(m.document_height(&[3, 2], LINE_H), 5 * LINE_H);
684 }
685
686 #[test]
687 fn document_height_with_spacing() {
688 let mut m = VerticalPolicy::Compact.resolve(LINE_H);
689 m.paragraph_spacing = ParagraphSpacing::custom(0, SUBPX_SCALE);
690 assert_eq!(
696 m.document_height(&[3, 2], LINE_H),
697 5 * LINE_H + 2 * SUBPX_SCALE
698 );
699 }
700
701 #[test]
702 fn document_height_empty() {
703 let m = VerticalPolicy::Compact.resolve(LINE_H);
704 assert_eq!(m.document_height(&[], LINE_H), 0);
705 }
706
707 #[test]
708 fn to_cell_rows_exact() {
709 assert_eq!(VerticalMetrics::to_cell_rows(LINE_H * 3, LINE_H), 3);
710 }
711
712 #[test]
713 fn to_cell_rows_rounds_up() {
714 assert_eq!(VerticalMetrics::to_cell_rows(LINE_H * 3 + 1, LINE_H), 4);
715 }
716
717 #[test]
718 fn to_cell_rows_zero_height() {
719 assert_eq!(VerticalMetrics::to_cell_rows(0, LINE_H), 0);
720 }
721
722 #[test]
723 fn to_cell_rows_zero_cell_height() {
724 assert_eq!(VerticalMetrics::to_cell_rows(LINE_H, 0), 0);
725 }
726
727 #[test]
730 fn same_inputs_same_outputs() {
731 let m1 = VerticalPolicy::Typographic.resolve(LINE_H);
732 let m2 = VerticalPolicy::Typographic.resolve(LINE_H);
733 assert_eq!(
734 m1.paragraph_height(5, LINE_H),
735 m2.paragraph_height(5, LINE_H)
736 );
737 assert_eq!(m1.line_y(3, LINE_H), m2.line_y(3, LINE_H));
738 }
739
740 #[test]
741 fn baseline_grid_deterministic() {
742 let grid = BaselineGrid::from_line_height(LINE_H, SUBPX_SCALE);
743 let a = grid.snap(1234);
744 let b = grid.snap(1234);
745 assert_eq!(a, b);
746 }
747}