1#![forbid(unsafe_code)]
2
3use crate::cluster_map::ClusterMap;
45use crate::justification::{GlueSpec, SUBCELL_SCALE};
46use crate::shaping::ShapedRun;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub struct SpacingDelta {
60 pub x_subcell: i32,
63 pub y_subcell: i32,
66}
67
68impl SpacingDelta {
69 pub const ZERO: Self = Self {
71 x_subcell: 0,
72 y_subcell: 0,
73 };
74
75 #[inline]
77 pub const fn is_zero(&self) -> bool {
78 self.x_subcell == 0 && self.y_subcell == 0
79 }
80
81 #[inline]
83 pub const fn x_cells(&self) -> i32 {
84 self.x_subcell / SUBCELL_SCALE as i32
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum RenderHint {
99 DirectChar(char),
102 Grapheme {
105 text: String,
107 width: u8,
109 },
110 Continuation,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct CellPlacement {
125 pub cell_x: u32,
127 pub render_hint: RenderHint,
129 pub spacing: SpacingDelta,
133 pub byte_start: u32,
135 pub byte_end: u32,
136 pub grapheme_index: u32,
138}
139
140#[derive(Debug, Clone)]
150pub struct ShapedLineLayout {
151 placements: Vec<CellPlacement>,
153 total_cells: u32,
155 subcell_remainder: i32,
159 cluster_map: ClusterMap,
161}
162
163impl ShapedLineLayout {
164 pub fn from_run(text: &str, run: &ShapedRun) -> Self {
169 if text.is_empty() || run.is_empty() {
170 return Self {
171 placements: Vec::new(),
172 total_cells: 0,
173 subcell_remainder: 0,
174 cluster_map: ClusterMap::from_text(""),
175 };
176 }
177
178 let cluster_map = ClusterMap::from_shaped_run(text, run);
179 let cluster_metrics = cluster_glyph_metrics(run);
180 let mut placements = Vec::with_capacity(cluster_map.total_cells());
181 let mut subcell_accumulator: i32 = 0;
182
183 for (entry, metrics) in cluster_map.entries().iter().zip(cluster_metrics.iter()) {
185 debug_assert_eq!(entry.byte_start, metrics.byte_start);
186 let cluster_text = &text[entry.byte_start as usize..entry.byte_end as usize];
187 let nominal_width = entry.cell_width as i32;
188
189 let shaped_advance = metrics.x_advance_subcell;
191 let delta_subcell = shaped_advance - (nominal_width * SUBCELL_SCALE as i32);
192 subcell_accumulator += delta_subcell;
193 let y_offset = metrics.first_y_offset_subcell;
194
195 let spacing = if delta_subcell != 0 {
196 SpacingDelta {
197 x_subcell: delta_subcell,
198 y_subcell: y_offset,
199 }
200 } else {
201 if y_offset != 0 {
202 SpacingDelta {
203 x_subcell: 0,
204 y_subcell: y_offset,
205 }
206 } else {
207 SpacingDelta::ZERO
208 }
209 };
210
211 let hint = render_hint_for_cluster(cluster_text, entry.cell_width);
213
214 placements.push(CellPlacement {
216 cell_x: entry.cell_start,
217 render_hint: hint,
218 spacing,
219 byte_start: entry.byte_start,
220 byte_end: entry.byte_end,
221 grapheme_index: entry.grapheme_index,
222 });
223
224 for cont in 1..entry.cell_width {
226 placements.push(CellPlacement {
227 cell_x: entry.cell_start + cont as u32,
228 render_hint: RenderHint::Continuation,
229 spacing: SpacingDelta::ZERO,
230 byte_start: entry.byte_start,
231 byte_end: entry.byte_end,
232 grapheme_index: entry.grapheme_index,
233 });
234 }
235 }
236
237 Self {
238 placements,
239 total_cells: cluster_map.total_cells() as u32,
240 subcell_remainder: subcell_accumulator,
241 cluster_map,
242 }
243 }
244
245 pub fn from_text(text: &str) -> Self {
250 if text.is_empty() {
251 return Self {
252 placements: Vec::new(),
253 total_cells: 0,
254 subcell_remainder: 0,
255 cluster_map: ClusterMap::from_text(""),
256 };
257 }
258
259 let cluster_map = ClusterMap::from_text(text);
260 let mut placements = Vec::with_capacity(cluster_map.total_cells());
261
262 for entry in cluster_map.entries() {
263 let cluster_text = &text[entry.byte_start as usize..entry.byte_end as usize];
264 let hint = render_hint_for_cluster(cluster_text, entry.cell_width);
265
266 placements.push(CellPlacement {
267 cell_x: entry.cell_start,
268 render_hint: hint,
269 spacing: SpacingDelta::ZERO,
270 byte_start: entry.byte_start,
271 byte_end: entry.byte_end,
272 grapheme_index: entry.grapheme_index,
273 });
274
275 for cont in 1..entry.cell_width {
276 placements.push(CellPlacement {
277 cell_x: entry.cell_start + cont as u32,
278 render_hint: RenderHint::Continuation,
279 spacing: SpacingDelta::ZERO,
280 byte_start: entry.byte_start,
281 byte_end: entry.byte_end,
282 grapheme_index: entry.grapheme_index,
283 });
284 }
285 }
286
287 Self {
288 placements,
289 total_cells: cluster_map.total_cells() as u32,
290 subcell_remainder: 0,
291 cluster_map,
292 }
293 }
294
295 pub fn apply_justification(&mut self, _text: &str, ratio_fixed: i32, glue: &GlueSpec) {
301 if ratio_fixed == 0 || self.placements.is_empty() {
302 return;
303 }
304
305 let adjusted_width_subcell = glue.adjusted_width(ratio_fixed);
306 let natural_subcell = glue.natural_subcell;
307 let delta_per_space = adjusted_width_subcell as i32 - natural_subcell as i32;
308
309 if delta_per_space == 0 {
310 return;
311 }
312
313 for placement in &mut self.placements {
314 if matches!(
315 placement.render_hint,
316 RenderHint::DirectChar(' ' | '\u{00A0}')
317 ) {
318 placement.spacing.x_subcell += delta_per_space;
319 self.subcell_remainder += delta_per_space;
320 }
321 }
322 }
323
324 pub fn apply_tracking(&mut self, tracking_subcell: i32) {
329 if tracking_subcell == 0 || self.placements.is_empty() {
330 return;
331 }
332
333 let mut last_grapheme = u32::MAX;
335 let primary_count = self
336 .placements
337 .iter()
338 .filter(|p| !matches!(p.render_hint, RenderHint::Continuation))
339 .count();
340
341 if primary_count <= 1 {
342 return;
343 }
344
345 let mut seen = 0;
346 for placement in &mut self.placements {
347 if matches!(placement.render_hint, RenderHint::Continuation) {
348 continue;
349 }
350 seen += 1;
351 if seen < primary_count && placement.grapheme_index != last_grapheme {
352 placement.spacing.x_subcell += tracking_subcell;
353 self.subcell_remainder += tracking_subcell;
354 last_grapheme = placement.grapheme_index;
355 }
356 }
357 }
358
359 #[inline]
365 pub fn placements(&self) -> &[CellPlacement] {
366 &self.placements
367 }
368
369 #[inline]
371 pub fn total_cells(&self) -> usize {
372 self.total_cells as usize
373 }
374
375 #[inline]
380 pub fn subcell_remainder(&self) -> i32 {
381 self.subcell_remainder
382 }
383
384 #[inline]
386 pub fn cluster_map(&self) -> &ClusterMap {
387 &self.cluster_map
388 }
389
390 #[inline]
392 pub fn is_empty(&self) -> bool {
393 self.placements.is_empty()
394 }
395
396 pub fn placement_at_cell(&self, cell_x: usize) -> Option<&CellPlacement> {
398 let idx = self
399 .placements
400 .partition_point(|p| (p.cell_x as usize) < cell_x);
401 self.placements
402 .get(idx)
403 .filter(|p| p.cell_x as usize == cell_x)
404 }
405
406 pub fn placements_for_grapheme(&self, grapheme_index: usize) -> Vec<&CellPlacement> {
408 self.placements
409 .iter()
410 .filter(|p| p.grapheme_index as usize == grapheme_index)
411 .collect()
412 }
413
414 pub fn extract_text<'a>(&self, source: &'a str, cell_start: usize, cell_end: usize) -> &'a str {
416 self.cluster_map
417 .extract_text_for_cells(source, cell_start, cell_end)
418 }
419
420 pub fn has_spacing_deltas(&self) -> bool {
422 self.placements.iter().any(|p| !p.spacing.is_zero())
423 }
424}
425
426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
431struct ClusterGlyphMetrics {
432 byte_start: u32,
433 x_advance_subcell: i32,
434 first_y_offset_subcell: i32,
435}
436
437fn cluster_glyph_metrics(run: &ShapedRun) -> Vec<ClusterGlyphMetrics> {
439 let mut metrics = Vec::with_capacity(run.glyphs.len());
440 let mut i = 0;
441
442 while i < run.glyphs.len() {
443 let cluster = run.glyphs[i].cluster;
444 let first_y_offset_subcell = run.glyphs[i].y_offset * SUBCELL_SCALE as i32;
445 let mut x_advance_subcell = 0i32;
446
447 while i < run.glyphs.len() && run.glyphs[i].cluster == cluster {
448 x_advance_subcell += run.glyphs[i].x_advance * SUBCELL_SCALE as i32;
449 i += 1;
450 }
451
452 metrics.push(ClusterGlyphMetrics {
453 byte_start: cluster,
454 x_advance_subcell,
455 first_y_offset_subcell,
456 });
457 }
458
459 metrics
460}
461
462fn render_hint_for_cluster(cluster_text: &str, cell_width: u8) -> RenderHint {
464 let mut chars = cluster_text.chars();
465 let first = match chars.next() {
466 Some(c) => c,
467 None => return RenderHint::DirectChar(' '),
468 };
469
470 if chars.next().is_none() {
471 RenderHint::DirectChar(first)
473 } else {
474 RenderHint::Grapheme {
476 text: cluster_text.to_string(),
477 width: cell_width,
478 }
479 }
480}
481
482#[cfg(test)]
487mod tests {
488 use super::*;
489 use crate::script_segmentation::{RunDirection, Script};
490 use crate::shaping::{FontFeatures, NoopShaper, ShapedGlyph, TextShaper};
491
492 #[test]
497 fn empty_layout() {
498 let layout = ShapedLineLayout::from_text("");
499 assert!(layout.is_empty());
500 assert_eq!(layout.total_cells(), 0);
501 assert_eq!(layout.subcell_remainder(), 0);
502 }
503
504 #[test]
505 fn ascii_layout() {
506 let layout = ShapedLineLayout::from_text("Hello");
507 assert_eq!(layout.total_cells(), 5);
508 assert_eq!(layout.placements().len(), 5);
509 assert!(!layout.has_spacing_deltas());
510
511 for (i, p) in layout.placements().iter().enumerate() {
512 assert_eq!(p.cell_x, i as u32);
513 assert_eq!(p.spacing, SpacingDelta::ZERO);
514 match &p.render_hint {
515 RenderHint::DirectChar(c) => {
516 assert_eq!(*c, "Hello".chars().nth(i).unwrap());
517 }
518 _ => panic!("Expected DirectChar for ASCII"),
519 }
520 }
521 }
522
523 #[test]
524 fn wide_char_layout() {
525 let layout = ShapedLineLayout::from_text("A\u{4E16}B");
526 assert_eq!(layout.total_cells(), 4);
528 assert_eq!(layout.placements().len(), 4);
530
531 assert_eq!(layout.placements()[0].cell_x, 0);
533 assert!(matches!(
534 layout.placements()[0].render_hint,
535 RenderHint::DirectChar('A')
536 ));
537
538 assert_eq!(layout.placements()[1].cell_x, 1);
540 assert!(matches!(
541 layout.placements()[1].render_hint,
542 RenderHint::DirectChar('\u{4E16}')
543 ));
544
545 assert_eq!(layout.placements()[2].cell_x, 2);
547 assert!(matches!(
548 layout.placements()[2].render_hint,
549 RenderHint::Continuation
550 ));
551
552 assert_eq!(layout.placements()[3].cell_x, 3);
554 assert!(matches!(
555 layout.placements()[3].render_hint,
556 RenderHint::DirectChar('B')
557 ));
558 }
559
560 #[test]
561 fn combining_mark_uses_grapheme() {
562 let layout = ShapedLineLayout::from_text("e\u{0301}");
563 assert_eq!(layout.total_cells(), 1);
564 assert_eq!(layout.placements().len(), 1);
565
566 match &layout.placements()[0].render_hint {
567 RenderHint::Grapheme { text, width } => {
568 assert_eq!(text, "e\u{0301}");
569 assert_eq!(*width, 1);
570 }
571 _ => panic!("Expected Grapheme for combining mark"),
572 }
573 }
574
575 #[test]
580 fn from_shaped_run_noop() {
581 let text = "Hello!";
582 let shaper = NoopShaper;
583 let ff = FontFeatures::default();
584 let run = shaper.shape(text, Script::Latin, RunDirection::Ltr, &ff);
585
586 let layout = ShapedLineLayout::from_run(text, &run);
587 assert_eq!(layout.total_cells(), 6);
588 assert_eq!(layout.placements().len(), 6);
589
590 assert!(!layout.has_spacing_deltas());
592 }
593
594 #[test]
595 fn from_shaped_run_wide() {
596 let text = "Hi\u{4E16}!";
597 let shaper = NoopShaper;
598 let ff = FontFeatures::default();
599 let run = shaper.shape(text, Script::Latin, RunDirection::Ltr, &ff);
600
601 let layout = ShapedLineLayout::from_run(text, &run);
602 assert_eq!(layout.total_cells(), 5);
604 }
605
606 #[test]
607 fn from_run_empty() {
608 let layout = ShapedLineLayout::from_run(
609 "",
610 &ShapedRun {
611 glyphs: vec![],
612 total_advance: 0,
613 },
614 );
615 assert!(layout.is_empty());
616 }
617
618 #[test]
619 fn from_run_groups_multi_glyph_cluster_once() {
620 let text = "office";
621 let run = ShapedRun {
622 glyphs: vec![
623 ShapedGlyph {
624 glyph_id: 'o' as u32,
625 cluster: 0,
626 x_advance: 1,
627 y_advance: 0,
628 x_offset: 0,
629 y_offset: 0,
630 },
631 ShapedGlyph {
632 glyph_id: 42,
633 cluster: 1,
634 x_advance: 2,
635 y_advance: 0,
636 x_offset: 0,
637 y_offset: 3,
638 },
639 ShapedGlyph {
640 glyph_id: 'c' as u32,
641 cluster: 4,
642 x_advance: 1,
643 y_advance: 0,
644 x_offset: 0,
645 y_offset: 0,
646 },
647 ShapedGlyph {
648 glyph_id: 'e' as u32,
649 cluster: 5,
650 x_advance: 1,
651 y_advance: 0,
652 x_offset: 0,
653 y_offset: 0,
654 },
655 ],
656 total_advance: 5,
657 };
658
659 let layout = ShapedLineLayout::from_run(text, &run);
660 let ligature = layout
661 .placements()
662 .iter()
663 .find(|p| p.byte_start == 1)
664 .unwrap();
665
666 assert_eq!(layout.total_cells(), 5);
667 assert_eq!(ligature.byte_end, 4);
668 assert_eq!(ligature.spacing.x_subcell, 0);
669 assert_eq!(ligature.spacing.y_subcell, 3 * SUBCELL_SCALE as i32);
670 }
671
672 #[test]
677 fn placement_at_cell() {
678 let layout = ShapedLineLayout::from_text("ABC");
679 let p = layout.placement_at_cell(1).unwrap();
680 assert_eq!(p.cell_x, 1);
681 assert!(matches!(p.render_hint, RenderHint::DirectChar('B')));
682
683 assert!(layout.placement_at_cell(5).is_none());
684 }
685
686 #[test]
687 fn placement_at_cell_returns_first_duplicate_cell() {
688 let layout = ShapedLineLayout {
689 placements: vec![
690 CellPlacement {
691 cell_x: 0,
692 render_hint: RenderHint::DirectChar('a'),
693 spacing: SpacingDelta::ZERO,
694 byte_start: 0,
695 byte_end: 1,
696 grapheme_index: 0,
697 },
698 CellPlacement {
699 cell_x: 0,
700 render_hint: RenderHint::DirectChar('b'),
701 spacing: SpacingDelta::ZERO,
702 byte_start: 1,
703 byte_end: 2,
704 grapheme_index: 1,
705 },
706 ],
707 total_cells: 0,
708 subcell_remainder: 0,
709 cluster_map: ClusterMap::from_text(""),
710 };
711
712 let placement = layout.placement_at_cell(0).unwrap();
713 assert_eq!(placement.byte_start, 0);
714 }
715
716 #[test]
717 fn placements_for_grapheme_wide() {
718 let layout = ShapedLineLayout::from_text("\u{4E16}");
719 let ps = layout.placements_for_grapheme(0);
720 assert_eq!(ps.len(), 2); }
722
723 #[test]
724 fn extract_text_range() {
725 let text = "Hello World";
726 let layout = ShapedLineLayout::from_text(text);
727 assert_eq!(layout.extract_text(text, 0, 5), "Hello");
728 assert_eq!(layout.extract_text(text, 6, 11), "World");
729 }
730
731 #[test]
736 fn apply_justification_stretch() {
737 let text = "hello world";
738 let mut layout = ShapedLineLayout::from_text(text);
739
740 let ratio = SUBCELL_SCALE as i32; layout.apply_justification(text, ratio, &GlueSpec::WORD_SPACE);
743
744 assert!(layout.has_spacing_deltas());
746
747 let space_placement = layout
748 .placements()
749 .iter()
750 .find(|p| p.byte_start == 5 && !matches!(p.render_hint, RenderHint::Continuation));
751 assert!(space_placement.is_some());
752 let sp = space_placement.unwrap();
753 assert!(sp.spacing.x_subcell > 0);
754 }
755
756 #[test]
757 fn apply_justification_stretches_nbsp() {
758 let text = "hello\u{00A0}world";
759 let mut layout = ShapedLineLayout::from_text(text);
760
761 let ratio = SUBCELL_SCALE as i32;
762 layout.apply_justification(text, ratio, &GlueSpec::WORD_SPACE);
763
764 let nbsp_placement = layout
765 .placements()
766 .iter()
767 .find(|p| matches!(p.render_hint, RenderHint::DirectChar('\u{00A0}')));
768 assert!(nbsp_placement.is_some());
769 assert!(nbsp_placement.unwrap().spacing.x_subcell > 0);
770 }
771
772 #[test]
773 fn apply_justification_no_ratio() {
774 let text = "hello world";
775 let mut layout = ShapedLineLayout::from_text(text);
776 layout.apply_justification(text, 0, &GlueSpec::WORD_SPACE);
777 assert!(!layout.has_spacing_deltas());
778 }
779
780 #[test]
785 fn apply_tracking_basic() {
786 let text = "ABC";
787 let mut layout = ShapedLineLayout::from_text(text);
788 layout.apply_tracking(32); let primary: Vec<_> = layout
792 .placements()
793 .iter()
794 .filter(|p| !matches!(p.render_hint, RenderHint::Continuation))
795 .collect();
796
797 assert_eq!(primary.len(), 3);
798 assert_eq!(primary[0].spacing.x_subcell, 32);
799 assert_eq!(primary[1].spacing.x_subcell, 32);
800 assert_eq!(primary[2].spacing.x_subcell, 0); }
802
803 #[test]
804 fn apply_tracking_single_char() {
805 let text = "A";
806 let mut layout = ShapedLineLayout::from_text(text);
807 layout.apply_tracking(32);
808 assert!(!layout.has_spacing_deltas());
810 }
811
812 #[test]
817 fn placement_byte_ranges() {
818 let text = "A\u{4E16}B"; let layout = ShapedLineLayout::from_text(text);
820
821 let primary: Vec<_> = layout
822 .placements()
823 .iter()
824 .filter(|p| !matches!(p.render_hint, RenderHint::Continuation))
825 .collect();
826
827 assert_eq!(primary[0].byte_start, 0);
828 assert_eq!(primary[0].byte_end, 1);
829 assert_eq!(primary[1].byte_start, 1);
830 assert_eq!(primary[1].byte_end, 4);
831 assert_eq!(primary[2].byte_start, 4);
832 assert_eq!(primary[2].byte_end, 5);
833 }
834
835 #[test]
836 fn grapheme_indices_sequential() {
837 let text = "Hello";
838 let layout = ShapedLineLayout::from_text(text);
839
840 for (i, p) in layout.placements().iter().enumerate() {
841 assert_eq!(p.grapheme_index, i as u32);
842 }
843 }
844
845 #[test]
850 fn deterministic_output() {
851 let text = "Hello \u{4E16}\u{754C}!";
852
853 let layout1 = ShapedLineLayout::from_text(text);
854 let layout2 = ShapedLineLayout::from_text(text);
855
856 assert_eq!(layout1.total_cells(), layout2.total_cells());
857 assert_eq!(layout1.placements().len(), layout2.placements().len());
858
859 for (a, b) in layout1.placements().iter().zip(layout2.placements()) {
860 assert_eq!(a.cell_x, b.cell_x);
861 assert_eq!(a.render_hint, b.render_hint);
862 assert_eq!(a.spacing, b.spacing);
863 assert_eq!(a.byte_start, b.byte_start);
864 assert_eq!(a.byte_end, b.byte_end);
865 }
866 }
867
868 #[test]
873 fn noop_shaper_no_deltas() {
874 let texts = ["Hello", "世界", "e\u{0301}f", "ABC 123"];
875 let shaper = NoopShaper;
876 let ff = FontFeatures::default();
877
878 for text in texts {
879 let run = shaper.shape(text, Script::Latin, RunDirection::Ltr, &ff);
880 let layout = ShapedLineLayout::from_run(text, &run);
881 assert!(
882 !layout.has_spacing_deltas(),
883 "NoopShaper should produce no deltas for {text:?}"
884 );
885 }
886 }
887
888 #[test]
889 fn cell_x_monotonic() {
890 let text = "Hello \u{4E16}\u{754C}!";
891 let layout = ShapedLineLayout::from_text(text);
892
893 for window in layout.placements().windows(2) {
894 assert!(
895 window[0].cell_x <= window[1].cell_x,
896 "Cell positions must be monotonically non-decreasing"
897 );
898 }
899 }
900
901 #[test]
902 fn all_cells_covered() {
903 let text = "Hi\u{4E16}!";
904 let layout = ShapedLineLayout::from_text(text);
905
906 for col in 0..layout.total_cells() {
908 assert!(
909 layout.placement_at_cell(col).is_some(),
910 "Cell column {col} has no placement"
911 );
912 }
913 }
914}