1#![forbid(unsafe_code)]
2
3use crate::cluster_map::{ClusterEntry, 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 mut placements = Vec::with_capacity(cluster_map.total_cells());
180 let mut subcell_accumulator: i32 = 0;
181
182 for entry in cluster_map.entries() {
184 let cluster_text = &text[entry.byte_start as usize..entry.byte_end as usize];
185 let nominal_width = entry.cell_width as i32;
186
187 let shaped_advance = sum_cluster_advance(run, entry);
189 let delta_subcell = shaped_advance - (nominal_width * SUBCELL_SCALE as i32);
190 subcell_accumulator += delta_subcell;
191
192 let spacing = if delta_subcell != 0 {
193 let y_offset = first_cluster_y_offset(run, entry);
195 SpacingDelta {
196 x_subcell: delta_subcell,
197 y_subcell: y_offset,
198 }
199 } else {
200 let y_offset = first_cluster_y_offset(run, entry);
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!(placement.render_hint, RenderHint::Continuation) {
315 continue;
316 }
317
318 let byte_start = placement.byte_start as usize;
319 let byte_end = placement.byte_end as usize;
320 if byte_start < text.len() && byte_end <= text.len() {
321 let cluster = &text[byte_start..byte_end];
322 if cluster.chars().all(|c| c == ' ' || c == '\u{00A0}') {
323 placement.spacing.x_subcell += delta_per_space;
324 self.subcell_remainder += delta_per_space;
325 }
326 }
327 }
328 }
329
330 pub fn apply_tracking(&mut self, tracking_subcell: i32) {
335 if tracking_subcell == 0 || self.placements.is_empty() {
336 return;
337 }
338
339 let mut last_grapheme = u32::MAX;
341 let primary_count = self
342 .placements
343 .iter()
344 .filter(|p| !matches!(p.render_hint, RenderHint::Continuation))
345 .count();
346
347 if primary_count <= 1 {
348 return;
349 }
350
351 let mut seen = 0;
352 for placement in &mut self.placements {
353 if matches!(placement.render_hint, RenderHint::Continuation) {
354 continue;
355 }
356 seen += 1;
357 if seen < primary_count && placement.grapheme_index != last_grapheme {
358 placement.spacing.x_subcell += tracking_subcell;
359 self.subcell_remainder += tracking_subcell;
360 last_grapheme = placement.grapheme_index;
361 }
362 }
363 }
364
365 #[inline]
371 pub fn placements(&self) -> &[CellPlacement] {
372 &self.placements
373 }
374
375 #[inline]
377 pub fn total_cells(&self) -> usize {
378 self.total_cells as usize
379 }
380
381 #[inline]
386 pub fn subcell_remainder(&self) -> i32 {
387 self.subcell_remainder
388 }
389
390 #[inline]
392 pub fn cluster_map(&self) -> &ClusterMap {
393 &self.cluster_map
394 }
395
396 #[inline]
398 pub fn is_empty(&self) -> bool {
399 self.placements.is_empty()
400 }
401
402 pub fn placement_at_cell(&self, cell_x: usize) -> Option<&CellPlacement> {
404 self.placements.iter().find(|p| p.cell_x as usize == cell_x)
405 }
406
407 pub fn placements_for_grapheme(&self, grapheme_index: usize) -> Vec<&CellPlacement> {
409 self.placements
410 .iter()
411 .filter(|p| p.grapheme_index as usize == grapheme_index)
412 .collect()
413 }
414
415 pub fn extract_text<'a>(&self, source: &'a str, cell_start: usize, cell_end: usize) -> &'a str {
417 self.cluster_map
418 .extract_text_for_cells(source, cell_start, cell_end)
419 }
420
421 pub fn has_spacing_deltas(&self) -> bool {
423 self.placements.iter().any(|p| !p.spacing.is_zero())
424 }
425}
426
427fn sum_cluster_advance(run: &ShapedRun, entry: &ClusterEntry) -> i32 {
433 let byte_start = entry.byte_start;
434 let mut total = 0i32;
435
436 for glyph in &run.glyphs {
437 if glyph.cluster == byte_start {
438 total += glyph.x_advance * SUBCELL_SCALE as i32;
439 }
440 }
441
442 total
443}
444
445fn first_cluster_y_offset(run: &ShapedRun, entry: &ClusterEntry) -> i32 {
447 let byte_start = entry.byte_start;
448
449 for glyph in &run.glyphs {
450 if glyph.cluster == byte_start {
451 return glyph.y_offset * SUBCELL_SCALE as i32;
452 }
453 }
454
455 0
456}
457
458fn render_hint_for_cluster(cluster_text: &str, cell_width: u8) -> RenderHint {
460 let mut chars = cluster_text.chars();
461 let first = match chars.next() {
462 Some(c) => c,
463 None => return RenderHint::DirectChar(' '),
464 };
465
466 if chars.next().is_none() {
467 RenderHint::DirectChar(first)
469 } else {
470 RenderHint::Grapheme {
472 text: cluster_text.to_string(),
473 width: cell_width,
474 }
475 }
476}
477
478#[cfg(test)]
483mod tests {
484 use super::*;
485 use crate::script_segmentation::{RunDirection, Script};
486 use crate::shaping::{FontFeatures, NoopShaper, TextShaper};
487
488 #[test]
493 fn empty_layout() {
494 let layout = ShapedLineLayout::from_text("");
495 assert!(layout.is_empty());
496 assert_eq!(layout.total_cells(), 0);
497 assert_eq!(layout.subcell_remainder(), 0);
498 }
499
500 #[test]
501 fn ascii_layout() {
502 let layout = ShapedLineLayout::from_text("Hello");
503 assert_eq!(layout.total_cells(), 5);
504 assert_eq!(layout.placements().len(), 5);
505 assert!(!layout.has_spacing_deltas());
506
507 for (i, p) in layout.placements().iter().enumerate() {
508 assert_eq!(p.cell_x, i as u32);
509 assert_eq!(p.spacing, SpacingDelta::ZERO);
510 match &p.render_hint {
511 RenderHint::DirectChar(c) => {
512 assert_eq!(*c, "Hello".chars().nth(i).unwrap());
513 }
514 _ => panic!("Expected DirectChar for ASCII"),
515 }
516 }
517 }
518
519 #[test]
520 fn wide_char_layout() {
521 let layout = ShapedLineLayout::from_text("A\u{4E16}B");
522 assert_eq!(layout.total_cells(), 4);
524 assert_eq!(layout.placements().len(), 4);
526
527 assert_eq!(layout.placements()[0].cell_x, 0);
529 assert!(matches!(
530 layout.placements()[0].render_hint,
531 RenderHint::DirectChar('A')
532 ));
533
534 assert_eq!(layout.placements()[1].cell_x, 1);
536 assert!(matches!(
537 layout.placements()[1].render_hint,
538 RenderHint::DirectChar('\u{4E16}')
539 ));
540
541 assert_eq!(layout.placements()[2].cell_x, 2);
543 assert!(matches!(
544 layout.placements()[2].render_hint,
545 RenderHint::Continuation
546 ));
547
548 assert_eq!(layout.placements()[3].cell_x, 3);
550 assert!(matches!(
551 layout.placements()[3].render_hint,
552 RenderHint::DirectChar('B')
553 ));
554 }
555
556 #[test]
557 fn combining_mark_uses_grapheme() {
558 let layout = ShapedLineLayout::from_text("e\u{0301}");
559 assert_eq!(layout.total_cells(), 1);
560 assert_eq!(layout.placements().len(), 1);
561
562 match &layout.placements()[0].render_hint {
563 RenderHint::Grapheme { text, width } => {
564 assert_eq!(text, "e\u{0301}");
565 assert_eq!(*width, 1);
566 }
567 _ => panic!("Expected Grapheme for combining mark"),
568 }
569 }
570
571 #[test]
576 fn from_shaped_run_noop() {
577 let text = "Hello!";
578 let shaper = NoopShaper;
579 let ff = FontFeatures::default();
580 let run = shaper.shape(text, Script::Latin, RunDirection::Ltr, &ff);
581
582 let layout = ShapedLineLayout::from_run(text, &run);
583 assert_eq!(layout.total_cells(), 6);
584 assert_eq!(layout.placements().len(), 6);
585
586 assert!(!layout.has_spacing_deltas());
588 }
589
590 #[test]
591 fn from_shaped_run_wide() {
592 let text = "Hi\u{4E16}!";
593 let shaper = NoopShaper;
594 let ff = FontFeatures::default();
595 let run = shaper.shape(text, Script::Latin, RunDirection::Ltr, &ff);
596
597 let layout = ShapedLineLayout::from_run(text, &run);
598 assert_eq!(layout.total_cells(), 5);
600 }
601
602 #[test]
603 fn from_run_empty() {
604 let layout = ShapedLineLayout::from_run(
605 "",
606 &ShapedRun {
607 glyphs: vec![],
608 total_advance: 0,
609 },
610 );
611 assert!(layout.is_empty());
612 }
613
614 #[test]
619 fn placement_at_cell() {
620 let layout = ShapedLineLayout::from_text("ABC");
621 let p = layout.placement_at_cell(1).unwrap();
622 assert_eq!(p.cell_x, 1);
623 assert!(matches!(p.render_hint, RenderHint::DirectChar('B')));
624
625 assert!(layout.placement_at_cell(5).is_none());
626 }
627
628 #[test]
629 fn placements_for_grapheme_wide() {
630 let layout = ShapedLineLayout::from_text("\u{4E16}");
631 let ps = layout.placements_for_grapheme(0);
632 assert_eq!(ps.len(), 2); }
634
635 #[test]
636 fn extract_text_range() {
637 let text = "Hello World";
638 let layout = ShapedLineLayout::from_text(text);
639 assert_eq!(layout.extract_text(text, 0, 5), "Hello");
640 assert_eq!(layout.extract_text(text, 6, 11), "World");
641 }
642
643 #[test]
648 fn apply_justification_stretch() {
649 let text = "hello world";
650 let mut layout = ShapedLineLayout::from_text(text);
651
652 let ratio = SUBCELL_SCALE as i32; layout.apply_justification(text, ratio, &GlueSpec::WORD_SPACE);
655
656 assert!(layout.has_spacing_deltas());
658
659 let space_placement = layout
660 .placements()
661 .iter()
662 .find(|p| p.byte_start == 5 && !matches!(p.render_hint, RenderHint::Continuation));
663 assert!(space_placement.is_some());
664 let sp = space_placement.unwrap();
665 assert!(sp.spacing.x_subcell > 0);
666 }
667
668 #[test]
669 fn apply_justification_no_ratio() {
670 let text = "hello world";
671 let mut layout = ShapedLineLayout::from_text(text);
672 layout.apply_justification(text, 0, &GlueSpec::WORD_SPACE);
673 assert!(!layout.has_spacing_deltas());
674 }
675
676 #[test]
681 fn apply_tracking_basic() {
682 let text = "ABC";
683 let mut layout = ShapedLineLayout::from_text(text);
684 layout.apply_tracking(32); let primary: Vec<_> = layout
688 .placements()
689 .iter()
690 .filter(|p| !matches!(p.render_hint, RenderHint::Continuation))
691 .collect();
692
693 assert_eq!(primary.len(), 3);
694 assert_eq!(primary[0].spacing.x_subcell, 32);
695 assert_eq!(primary[1].spacing.x_subcell, 32);
696 assert_eq!(primary[2].spacing.x_subcell, 0); }
698
699 #[test]
700 fn apply_tracking_single_char() {
701 let text = "A";
702 let mut layout = ShapedLineLayout::from_text(text);
703 layout.apply_tracking(32);
704 assert!(!layout.has_spacing_deltas());
706 }
707
708 #[test]
713 fn placement_byte_ranges() {
714 let text = "A\u{4E16}B"; let layout = ShapedLineLayout::from_text(text);
716
717 let primary: Vec<_> = layout
718 .placements()
719 .iter()
720 .filter(|p| !matches!(p.render_hint, RenderHint::Continuation))
721 .collect();
722
723 assert_eq!(primary[0].byte_start, 0);
724 assert_eq!(primary[0].byte_end, 1);
725 assert_eq!(primary[1].byte_start, 1);
726 assert_eq!(primary[1].byte_end, 4);
727 assert_eq!(primary[2].byte_start, 4);
728 assert_eq!(primary[2].byte_end, 5);
729 }
730
731 #[test]
732 fn grapheme_indices_sequential() {
733 let text = "Hello";
734 let layout = ShapedLineLayout::from_text(text);
735
736 for (i, p) in layout.placements().iter().enumerate() {
737 assert_eq!(p.grapheme_index, i as u32);
738 }
739 }
740
741 #[test]
746 fn deterministic_output() {
747 let text = "Hello \u{4E16}\u{754C}!";
748
749 let layout1 = ShapedLineLayout::from_text(text);
750 let layout2 = ShapedLineLayout::from_text(text);
751
752 assert_eq!(layout1.total_cells(), layout2.total_cells());
753 assert_eq!(layout1.placements().len(), layout2.placements().len());
754
755 for (a, b) in layout1.placements().iter().zip(layout2.placements()) {
756 assert_eq!(a.cell_x, b.cell_x);
757 assert_eq!(a.render_hint, b.render_hint);
758 assert_eq!(a.spacing, b.spacing);
759 assert_eq!(a.byte_start, b.byte_start);
760 assert_eq!(a.byte_end, b.byte_end);
761 }
762 }
763
764 #[test]
769 fn noop_shaper_no_deltas() {
770 let texts = ["Hello", "世界", "e\u{0301}f", "ABC 123"];
771 let shaper = NoopShaper;
772 let ff = FontFeatures::default();
773
774 for text in texts {
775 let run = shaper.shape(text, Script::Latin, RunDirection::Ltr, &ff);
776 let layout = ShapedLineLayout::from_run(text, &run);
777 assert!(
778 !layout.has_spacing_deltas(),
779 "NoopShaper should produce no deltas for {text:?}"
780 );
781 }
782 }
783
784 #[test]
785 fn cell_x_monotonic() {
786 let text = "Hello \u{4E16}\u{754C}!";
787 let layout = ShapedLineLayout::from_text(text);
788
789 for window in layout.placements().windows(2) {
790 assert!(
791 window[0].cell_x <= window[1].cell_x,
792 "Cell positions must be monotonically non-decreasing"
793 );
794 }
795 }
796
797 #[test]
798 fn all_cells_covered() {
799 let text = "Hi\u{4E16}!";
800 let layout = ShapedLineLayout::from_text(text);
801
802 for col in 0..layout.total_cells() {
804 assert!(
805 layout.placement_at_cell(col).is_some(),
806 "Cell column {col} has no placement"
807 );
808 }
809 }
810}