1#[cfg(feature = "text-shaping")]
19mod inner {
20 use std::collections::HashMap;
21 use std::sync::Arc;
22
23 use crate::symbols::{GlyphProvider, GlyphRaster, SymbolTextTransform, SymbolWritingMode};
25
26 pub const ONE_EM: f32 = 24.0;
28
29 #[derive(Clone)]
39 pub struct OwnedFont {
40 data: Arc<Vec<u8>>,
42 face_index: u32,
44 units_per_em: u16,
46 }
47
48 impl std::fmt::Debug for OwnedFont {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.debug_struct("OwnedFont")
51 .field("face_index", &self.face_index)
52 .field("units_per_em", &self.units_per_em)
53 .finish()
54 }
55 }
56
57 impl OwnedFont {
58 pub fn from_bytes(data: Vec<u8>, face_index: u32) -> Option<Self> {
62 let face = ttf_parser::Face::parse(&data, face_index).ok()?;
63 let units_per_em = face.units_per_em();
64 Some(Self {
65 data: Arc::new(data),
66 units_per_em,
67 face_index,
68 })
69 }
70
71 pub fn units_per_em(&self) -> u16 {
73 self.units_per_em
74 }
75
76 fn scale_to_layout(&self) -> f32 {
78 ONE_EM / self.units_per_em as f32
79 }
80
81 pub fn data(&self) -> &[u8] {
83 &self.data
84 }
85
86 pub fn face_index(&self) -> u32 {
88 self.face_index
89 }
90 }
91
92 #[derive(Debug, Clone, Default)]
98 pub struct FontRegistry {
99 fonts: HashMap<String, OwnedFont>,
101 stack_cache: HashMap<String, Option<String>>,
103 }
104
105 impl FontRegistry {
106 pub fn new() -> Self {
108 Self::default()
109 }
110
111 pub fn register(&mut self, family_name: impl Into<String>, font: OwnedFont) {
113 let name = family_name.into();
114 self.fonts.insert(name, font);
115 self.stack_cache.clear();
118 }
119
120 pub fn font_count(&self) -> usize {
122 self.fonts.len()
123 }
124
125 pub fn resolve_stack(&mut self, font_stack: &str) -> Option<&str> {
130 if !self.stack_cache.contains_key(font_stack) {
131 let resolved = font_stack
132 .split(',')
133 .map(str::trim)
134 .find(|name| self.fonts.contains_key(*name))
135 .map(String::from);
136 self.stack_cache.insert(font_stack.to_owned(), resolved);
137 }
138 self.stack_cache
139 .get(font_stack)
140 .and_then(|opt| opt.as_deref())
141 }
142
143 pub fn get_font(&self, family_name: &str) -> Option<&OwnedFont> {
145 self.fonts.get(family_name)
146 }
147 }
148
149 #[derive(Debug, Clone, PartialEq)]
158 pub struct PositionedGlyph {
159 pub codepoint: char,
161 pub glyph_id: u16,
163 pub x: f32,
165 pub y: f32,
167 pub advance: f32,
169 pub metrics_width: f32,
171 pub metrics_height: f32,
173 pub metrics_left: f32,
175 pub metrics_top: f32,
177 pub font_stack: String,
179 pub vertical: bool,
181 pub line_index: usize,
183 }
184
185 #[derive(Debug, Clone, PartialEq)]
187 pub struct ShapedLine {
188 pub glyphs: Vec<PositionedGlyph>,
190 pub line_offset: f32,
192 }
193
194 #[derive(Debug, Clone, PartialEq)]
196 pub struct ShapedText {
197 pub lines: Vec<ShapedLine>,
199 pub left: f32,
201 pub top: f32,
203 pub right: f32,
205 pub bottom: f32,
207 pub text: String,
209 pub writing_mode: SymbolWritingMode,
211 }
212
213 impl ShapedText {
214 pub fn glyph_count(&self) -> usize {
216 self.lines.iter().map(|l| l.glyphs.len()).sum()
217 }
218
219 pub fn width(&self) -> f32 {
221 self.right - self.left
222 }
223
224 pub fn height(&self) -> f32 {
226 self.bottom - self.top
227 }
228 }
229
230 #[derive(Debug, Clone, Copy, PartialEq)]
236 pub enum TextJustify {
237 Left,
239 Center,
241 Right,
243 }
244
245 #[derive(Debug, Clone, Copy, PartialEq)]
247 pub enum TextAnchor {
248 Center,
250 Left,
252 Right,
254 Top,
256 Bottom,
258 TopLeft,
260 TopRight,
262 BottomLeft,
264 BottomRight,
266 }
267
268 impl TextAnchor {
269 pub fn horizontal_align(self) -> f32 {
271 match self {
272 TextAnchor::Left | TextAnchor::TopLeft | TextAnchor::BottomLeft => 0.0,
273 TextAnchor::Right | TextAnchor::TopRight | TextAnchor::BottomRight => 1.0,
274 _ => 0.5,
275 }
276 }
277
278 pub fn vertical_align(self) -> f32 {
280 match self {
281 TextAnchor::Top | TextAnchor::TopLeft | TextAnchor::TopRight => 0.0,
282 TextAnchor::Bottom | TextAnchor::BottomLeft | TextAnchor::BottomRight => 1.0,
283 _ => 0.5,
284 }
285 }
286 }
287
288 #[derive(Debug, Clone)]
290 pub struct ShapeTextOptions {
291 pub font_stack: String,
293 pub max_width: Option<f32>,
296 pub line_height: f32,
298 pub letter_spacing: f32,
300 pub justify: TextJustify,
302 pub anchor: TextAnchor,
304 pub writing_mode: SymbolWritingMode,
306 pub text_transform: SymbolTextTransform,
308 }
309
310 impl Default for ShapeTextOptions {
311 fn default() -> Self {
312 Self {
313 font_stack: String::new(),
314 max_width: Some(10.0),
315 line_height: 1.2,
316 letter_spacing: 0.0,
317 justify: TextJustify::Center,
318 anchor: TextAnchor::Center,
319 writing_mode: SymbolWritingMode::Horizontal,
320 text_transform: SymbolTextTransform::None,
321 }
322 }
323 }
324
325 pub fn bidi_reorder(text: &str) -> String {
333 use unicode_bidi::{BidiInfo, Level};
334
335 let bidi = BidiInfo::new(text, Some(Level::ltr()));
336 let mut output = String::with_capacity(text.len());
337 for para in &bidi.paragraphs {
338 let line = para.range.clone();
339 let display = bidi.reorder_line(para, line);
340 output.push_str(&display);
341 }
342 output
343 }
344
345 pub fn contains_rtl(text: &str) -> bool {
347 use unicode_bidi::BidiClass;
348
349 text.chars().any(|c| {
350 let cls = unicode_bidi::bidi_class(c);
351 matches!(cls, BidiClass::R | BidiClass::AL | BidiClass::AN)
352 })
353 }
354
355 pub(crate) fn is_breakable(c: char) -> bool {
361 matches!(
362 c,
363 '\n' | ' ' | '&' | '(' | ')' | '+' | '-' | '/'
364 | '\u{00AD}' | '\u{00B7}' | '\u{200B}' | '\u{2010}' | '\u{2013}' | '\u{2027}' )
371 }
372
373 pub(crate) fn allows_ideographic_break(c: char) -> bool {
375 let cp = c as u32;
376 (0x4E00..=0x9FFF).contains(&cp)
378 || (0x3400..=0x4DBF).contains(&cp)
379 || (0x20000..=0x2A6DF).contains(&cp)
380 || (0x2A700..=0x2B73F).contains(&cp)
381 || (0x2B740..=0x2B81F).contains(&cp)
382 || (0x2B820..=0x2CEAF).contains(&cp)
383 || (0xF900..=0xFAFF).contains(&cp)
384 || (0x2F800..=0x2FA1F).contains(&cp)
385 || (0x3000..=0x303F).contains(&cp) || (0x3040..=0x309F).contains(&cp) || (0x30A0..=0x30FF).contains(&cp) || (0xFF00..=0xFFEF).contains(&cp) }
391
392 pub(crate) fn determine_line_breaks(
397 chars: &[char],
398 advances: &[f32],
399 max_width_lu: f32,
400 ) -> Vec<usize> {
401 if chars.is_empty() || max_width_lu <= 0.0 {
402 return Vec::new();
403 }
404
405 let total_width: f32 = advances.iter().sum();
406 if total_width <= max_width_lu {
407 return Vec::new();
408 }
409
410 let line_count = (total_width / max_width_lu).ceil().max(1.0);
411 let target_width = total_width / line_count;
412
413 struct BreakCandidate {
415 index: usize,
416 x: f32,
417 cost: f32,
418 prev: Option<usize>,
419 }
420
421 let mut candidates: Vec<BreakCandidate> = Vec::new();
422 candidates.push(BreakCandidate {
424 index: 0,
425 x: 0.0,
426 cost: 0.0,
427 prev: None,
428 });
429
430 let mut pen_x = 0.0f32;
431 for (i, &c) in chars.iter().enumerate() {
432 pen_x += advances[i];
433
434 let breakable = is_breakable(c) || allows_ideographic_break(c);
435 let forced = c == '\n';
436
437 if !breakable && !forced {
438 continue;
439 }
440
441 let break_after = i + 1;
443 if break_after >= chars.len() {
444 continue;
445 }
446
447 let mut best_cost = f32::INFINITY;
449 let mut best_prev = None;
450
451 for (ci, cand) in candidates.iter().enumerate() {
452 let line_width = pen_x - cand.x;
453 let diff = line_width - target_width;
454 let badness = diff * diff;
455 let total = cand.cost + badness;
456
457 if forced {
458 best_cost = total.min(best_cost);
460 best_prev = Some(ci);
461 break;
462 }
463
464 if total < best_cost {
465 best_cost = total;
466 best_prev = Some(ci);
467 }
468 }
469
470 candidates.push(BreakCandidate {
471 index: break_after,
472 x: pen_x,
473 cost: best_cost,
474 prev: best_prev,
475 });
476 }
477
478 let mut best_final_cost = f32::INFINITY;
480 let mut best_final_idx = 0usize;
481 for (ci, cand) in candidates.iter().enumerate() {
482 let remaining = total_width - cand.x;
483 let diff = remaining - target_width;
484 let penalty = if diff < 0.0 {
486 diff * diff * 0.5
487 } else {
488 diff * diff * 2.0
489 };
490 let total = cand.cost + penalty;
491 if total < best_final_cost {
492 best_final_cost = total;
493 best_final_idx = ci;
494 }
495 }
496
497 let mut breaks = Vec::new();
499 let mut cur = best_final_idx;
500 while cur > 0 {
501 let cand = &candidates[cur];
502 if cand.index > 0 {
503 breaks.push(cand.index);
504 }
505 cur = cand.prev.unwrap_or(0);
506 }
507 breaks.reverse();
508 breaks
509 }
510
511 pub fn shape_text(
519 text: &str,
520 registry: &mut FontRegistry,
521 options: &ShapeTextOptions,
522 ) -> Option<ShapedText> {
523 if text.is_empty() {
524 return None;
525 }
526
527 let transformed = match options.text_transform {
529 SymbolTextTransform::Uppercase => text.to_uppercase(),
530 SymbolTextTransform::Lowercase => text.to_lowercase(),
531 SymbolTextTransform::None => text.to_owned(),
532 };
533
534 let display_text = if contains_rtl(&transformed) {
536 bidi_reorder(&transformed)
537 } else {
538 transformed.clone()
539 };
540
541 let family_name = registry.resolve_stack(&options.font_stack)?.to_owned();
543 let font = registry.get_font(&family_name)?.clone();
544
545 let face = rustybuzz::Face::from_slice(font.data(), font.face_index())?;
547 let mut buffer = rustybuzz::UnicodeBuffer::new();
548 buffer.push_str(&display_text);
549 let shaped = rustybuzz::shape(&face, &[], buffer);
550
551 let scale = font.scale_to_layout();
552 let infos = shaped.glyph_infos();
553 let positions = shaped.glyph_positions();
554
555 let display_chars: Vec<char> = display_text.chars().collect();
557 let mut glyph_advances: Vec<f32> = Vec::with_capacity(infos.len());
558 let mut glyph_chars: Vec<char> = Vec::with_capacity(infos.len());
559 let mut glyph_ids: Vec<u16> = Vec::with_capacity(infos.len());
560 let mut glyph_x_offsets: Vec<f32> = Vec::with_capacity(infos.len());
561 let mut glyph_y_offsets: Vec<f32> = Vec::with_capacity(infos.len());
562
563 for (i, (info, pos)) in infos.iter().zip(positions.iter()).enumerate() {
564 let cluster = info.cluster as usize;
565 let c = display_chars.get(cluster).copied().unwrap_or('\u{FFFD}');
566 let advance = pos.x_advance as f32 * scale + options.letter_spacing * ONE_EM;
567 glyph_advances.push(advance);
568 glyph_chars.push(c);
569 glyph_ids.push(info.glyph_id as u16);
570 glyph_x_offsets.push(pos.x_offset as f32 * scale);
571 glyph_y_offsets.push(pos.y_offset as f32 * scale);
572
573 let _ = i; }
575
576 let max_width_lu = options
578 .max_width
579 .map(|mw| mw * ONE_EM)
580 .unwrap_or(f32::INFINITY);
581 let breaks = determine_line_breaks(&glyph_chars, &glyph_advances, max_width_lu);
582
583 let line_height_lu = options.line_height * ONE_EM;
585
586 const SHAPING_DEFAULT_OFFSET: f32 = -17.0;
588
589 let mut lines: Vec<ShapedLine> = Vec::new();
590 let mut line_start = 0usize;
591 let mut current_y = 0.0f32;
592
593 let mut break_iter = breaks.iter().peekable();
594 let total_glyphs = glyph_chars.len();
595
596 loop {
597 let line_end = break_iter.next().copied().unwrap_or(total_glyphs);
598
599 let effective_start = if !lines.is_empty() {
601 let mut s = line_start;
602 while s < line_end && glyph_chars.get(s) == Some(&' ') {
603 s += 1;
604 }
605 s
606 } else {
607 line_start
608 };
609
610 let mut positioned = Vec::new();
611 let mut pen_x = 0.0f32;
612 let line_index = lines.len();
613
614 for gi in effective_start..line_end {
615 let c = glyph_chars[gi];
616 if c == '\n' {
618 continue;
619 }
620
621 positioned.push(PositionedGlyph {
622 codepoint: c,
623 glyph_id: glyph_ids[gi],
624 x: pen_x + glyph_x_offsets[gi],
625 y: current_y + SHAPING_DEFAULT_OFFSET + glyph_y_offsets[gi],
626 advance: glyph_advances[gi],
627 metrics_width: 0.0, metrics_height: 0.0,
629 metrics_left: 0.0,
630 metrics_top: 0.0,
631 font_stack: options.font_stack.clone(),
632 vertical: options.writing_mode == SymbolWritingMode::Vertical,
633 line_index,
634 });
635 pen_x += glyph_advances[gi];
636 }
637
638 if !positioned.is_empty() {
640 let line_width = positioned.last().map(|g| g.x + g.advance).unwrap_or(0.0);
641 let justify_factor = match options.justify {
642 TextJustify::Left => 0.0,
643 TextJustify::Center => 0.5,
644 TextJustify::Right => 1.0,
645 };
646 let shift = -line_width * justify_factor;
647 for g in &mut positioned {
648 g.x += shift;
649 }
650 }
651
652 lines.push(ShapedLine {
653 glyphs: positioned,
654 line_offset: 0.0,
655 });
656
657 if line_end >= total_glyphs {
658 break;
659 }
660 line_start = line_end;
661 current_y += line_height_lu;
662 }
663
664 let mut left = f32::INFINITY;
666 let mut right = f32::NEG_INFINITY;
667 let mut top = f32::INFINITY;
668 let mut bottom = f32::NEG_INFINITY;
669
670 for line in &lines {
671 for g in &line.glyphs {
672 left = left.min(g.x);
673 right = right.max(g.x + g.advance);
674 top = top.min(g.y);
675 bottom = bottom.max(g.y + ONE_EM);
676 }
677 }
678
679 if left == f32::INFINITY {
680 left = 0.0;
682 right = 0.0;
683 top = 0.0;
684 bottom = 0.0;
685 }
686
687 let text_width = right - left;
689 let text_height = bottom - top;
690 let h_align = options.anchor.horizontal_align();
691 let v_align = options.anchor.vertical_align();
692 let dx = -left - text_width * h_align;
693 let dy = -top - text_height * v_align;
694
695 for line in &mut lines {
696 for g in &mut line.glyphs {
697 g.x += dx;
698 g.y += dy;
699 }
700 }
701
702 left += dx;
703 right += dx;
704 top += dy;
705 bottom += dy;
706
707 Some(ShapedText {
708 lines,
709 left,
710 top,
711 right,
712 bottom,
713 text: display_text,
714 writing_mode: options.writing_mode,
715 })
716 }
717
718 #[derive(Debug, Clone, Default)]
727 pub struct ShapedGlyphProvider {
728 registry: FontRegistry,
729 }
730
731 impl ShapedGlyphProvider {
732 pub fn new(registry: FontRegistry) -> Self {
734 Self { registry }
735 }
736
737 pub fn registry_mut(&mut self) -> &mut FontRegistry {
739 &mut self.registry
740 }
741
742 pub fn registry(&self) -> &FontRegistry {
744 &self.registry
745 }
746 }
747
748 impl GlyphProvider for ShapedGlyphProvider {
749 #[allow(clippy::unwrap_used)] fn load_glyph(&self, font_stack: &str, codepoint: char) -> Option<GlyphRaster> {
751 let family_name = font_stack
753 .split(',')
754 .map(str::trim)
755 .find(|name| self.registry.fonts.contains_key(*name))?;
756 let font = self.registry.fonts.get(family_name)?;
757
758 let face = ttf_parser::Face::parse(font.data(), font.face_index()).ok()?;
759 let glyph_id = face.glyph_index(codepoint)?;
760
761 let upem = face.units_per_em() as f32;
763 let target_px = ONE_EM; let scale = target_px / upem;
765
766 let h_advance = face.glyph_hor_advance(glyph_id).unwrap_or(0) as f32;
767 let advance_px = h_advance * scale;
768
769 let bbox = face.glyph_bounding_box(glyph_id);
770 let (glyph_width, glyph_height, bearing_x, bearing_y) = if let Some(bb) = bbox {
771 let w = ((bb.x_max - bb.x_min) as f32 * scale).ceil() as u16;
772 let h = ((bb.y_max - bb.y_min) as f32 * scale).ceil() as u16;
773 let bx = (bb.x_min as f32 * scale).floor() as i16;
774 let by = (bb.y_max as f32 * scale).ceil() as i16;
775 (w.max(1), h.max(1), bx, by)
776 } else {
777 return Some(GlyphRaster::new(0, 0, advance_px, 0, 0, Vec::new()));
779 };
780
781 let alpha = rasterize_glyph_alpha(
785 &face,
786 glyph_id,
787 glyph_width,
788 glyph_height,
789 scale,
790 bbox.unwrap(),
791 );
792
793 Some(GlyphRaster::new(
794 glyph_width,
795 glyph_height,
796 advance_px,
797 bearing_x,
798 bearing_y,
799 alpha,
800 ))
801 }
802 }
803
804 fn rasterize_glyph_alpha(
810 face: &ttf_parser::Face<'_>,
811 glyph_id: ttf_parser::GlyphId,
812 width: u16,
813 height: u16,
814 scale: f32,
815 bbox: ttf_parser::Rect,
816 ) -> Vec<u8> {
817 let w = width as usize;
818 let h = height as usize;
819 let mut alpha = vec![0u8; w * h];
820
821 struct SegmentCollector {
823 segments: Vec<((f32, f32), (f32, f32))>,
824 current: (f32, f32),
825 start: (f32, f32),
826 }
827
828 impl ttf_parser::OutlineBuilder for SegmentCollector {
829 fn move_to(&mut self, x: f32, y: f32) {
830 self.current = (x, y);
831 self.start = (x, y);
832 }
833 fn line_to(&mut self, x: f32, y: f32) {
834 self.segments.push((self.current, (x, y)));
835 self.current = (x, y);
836 }
837 fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
838 let steps = 4;
840 let (px, py) = self.current;
841 for i in 1..=steps {
842 let t = i as f32 / steps as f32;
843 let it = 1.0 - t;
844 let nx = it * it * px + 2.0 * it * t * x1 + t * t * x;
845 let ny = it * it * py + 2.0 * it * t * y1 + t * t * y;
846 self.segments.push((self.current, (nx, ny)));
847 self.current = (nx, ny);
848 }
849 }
850 fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
851 let steps = 8;
853 let (px, py) = self.current;
854 for i in 1..=steps {
855 let t = i as f32 / steps as f32;
856 let it = 1.0 - t;
857 let nx = it * it * it * px
858 + 3.0 * it * it * t * x1
859 + 3.0 * it * t * t * x2
860 + t * t * t * x;
861 let ny = it * it * it * py
862 + 3.0 * it * it * t * y1
863 + 3.0 * it * t * t * y2
864 + t * t * t * y;
865 self.segments.push((self.current, (nx, ny)));
866 self.current = (nx, ny);
867 }
868 }
869 fn close(&mut self) {
870 if self.current != self.start {
871 self.segments.push((self.current, self.start));
872 }
873 self.current = self.start;
874 }
875 }
876
877 let mut collector = SegmentCollector {
878 segments: Vec::new(),
879 current: (0.0, 0.0),
880 start: (0.0, 0.0),
881 };
882 face.outline_glyph(glyph_id, &mut collector);
883
884 let origin_x = bbox.x_min as f32;
886 let origin_y = bbox.y_min as f32;
887 let segments: Vec<((f32, f32), (f32, f32))> = collector
888 .segments
889 .iter()
890 .map(|&((x0, y0), (x1, y1))| {
891 let bx0 = (x0 - origin_x) * scale;
893 let by0 = (h as f32) - (y0 - origin_y) * scale;
894 let bx1 = (x1 - origin_x) * scale;
895 let by1 = (h as f32) - (y1 - origin_y) * scale;
896 ((bx0, by0), (bx1, by1))
897 })
898 .collect();
899
900 for row in 0..h {
902 let y = row as f32 + 0.5;
903 let mut crossings: Vec<f32> = Vec::new();
904
905 for &((x0, y0), (x1, y1)) in &segments {
906 if (y0 <= y && y1 > y) || (y1 <= y && y0 > y) {
907 let t = (y - y0) / (y1 - y0);
908 let x_intersect = x0 + t * (x1 - x0);
909 crossings.push(x_intersect);
910 }
911 }
912
913 crossings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
914
915 for pair in crossings.chunks(2) {
916 if pair.len() == 2 {
917 let start_col = (pair[0].max(0.0) as usize).min(w);
918 let end_col = ((pair[1] + 1.0).max(0.0) as usize).min(w);
919 for col in start_col..end_col {
920 alpha[row * w + col] = 255;
921 }
922 }
923 }
924 }
925
926 alpha
927 }
928}
929
930#[cfg(feature = "text-shaping")]
932pub use inner::*;
933
934#[cfg(test)]
939#[cfg(feature = "text-shaping")]
940mod tests {
941 use super::inner::*;
942 use super::*;
943
944 fn test_options() -> ShapeTextOptions {
951 ShapeTextOptions {
952 font_stack: "TestFont".to_owned(),
953 ..Default::default()
954 }
955 }
956
957 #[test]
958 fn bidi_reorder_pure_ltr_is_identity() {
959 let text = "Hello World";
960 let reordered = bidi_reorder(text);
961 assert_eq!(reordered, "Hello World");
962 }
963
964 #[test]
965 fn bidi_reorder_rtl_reverses_characters() {
966 let text = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"; let reordered = bidi_reorder(text);
969 assert!(!reordered.is_empty());
971 assert_eq!(reordered.chars().count(), 5);
972 }
973
974 #[test]
975 fn contains_rtl_detects_arabic() {
976 assert!(contains_rtl("\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"));
977 assert!(!contains_rtl("Hello World"));
978 }
979
980 #[test]
981 fn contains_rtl_detects_hebrew() {
982 assert!(contains_rtl("\u{05E9}\u{05DC}\u{05D5}\u{05DD}")); }
984
985 #[test]
986 fn line_breaking_no_break_within_limit() {
987 let chars: Vec<char> = "Hello".chars().collect();
988 let advances = vec![10.0; 5]; let breaks = determine_line_breaks(&chars, &advances, 100.0);
990 assert!(breaks.is_empty());
991 }
992
993 #[test]
994 fn line_breaking_wraps_at_space() {
995 let text = "Hello World Test";
996 let chars: Vec<char> = text.chars().collect();
997 let advances = vec![10.0; chars.len()];
999 let breaks = determine_line_breaks(&chars, &advances, 80.0);
1000 assert!(!breaks.is_empty());
1001 for &b in &breaks {
1003 assert!(b > 0 && b < chars.len());
1004 }
1005 }
1006
1007 #[test]
1008 fn line_breaking_forced_newline() {
1009 let text = "Line1\nLine2";
1010 let chars: Vec<char> = text.chars().collect();
1011 let advances = vec![10.0; chars.len()];
1012 let breaks = determine_line_breaks(&chars, &advances, 1000.0);
1013 assert!(breaks.is_empty());
1019 }
1020
1021 #[test]
1022 fn ideographic_break_allows_cjk() {
1023 assert!(allows_ideographic_break('中'));
1024 assert!(allows_ideographic_break('漢'));
1025 assert!(!allows_ideographic_break('A'));
1026 }
1027
1028 #[test]
1029 fn text_anchor_alignment_factors() {
1030 assert_eq!(TextAnchor::TopLeft.horizontal_align(), 0.0);
1031 assert_eq!(TextAnchor::TopLeft.vertical_align(), 0.0);
1032 assert_eq!(TextAnchor::Center.horizontal_align(), 0.5);
1033 assert_eq!(TextAnchor::Center.vertical_align(), 0.5);
1034 assert_eq!(TextAnchor::BottomRight.horizontal_align(), 1.0);
1035 assert_eq!(TextAnchor::BottomRight.vertical_align(), 1.0);
1036 }
1037
1038 #[test]
1039 fn font_registry_resolve_stack() {
1040 let mut registry = FontRegistry::new();
1041 assert!(registry.resolve_stack("SomeFont, FallbackFont").is_none());
1043 }
1044
1045 #[test]
1046 fn font_registry_font_count() {
1047 let registry = FontRegistry::new();
1048 assert_eq!(registry.font_count(), 0);
1049 }
1050
1051 #[test]
1052 fn shaped_text_options_default() {
1053 let opts = ShapeTextOptions::default();
1054 assert_eq!(opts.line_height, 1.2);
1055 assert_eq!(opts.letter_spacing, 0.0);
1056 assert!(matches!(opts.justify, TextJustify::Center));
1057 assert!(matches!(opts.anchor, TextAnchor::Center));
1058 }
1059
1060 #[test]
1061 fn shape_text_returns_none_for_empty() {
1062 let mut registry = FontRegistry::new();
1063 let options = test_options();
1064 let result = shape_text("", &mut registry, &options);
1065 assert!(result.is_none());
1066 }
1067
1068 #[test]
1069 fn shape_text_returns_none_without_font() {
1070 let mut registry = FontRegistry::new();
1071 let options = test_options();
1072 let result = shape_text("Hello", &mut registry, &options);
1073 assert!(result.is_none());
1074 }
1075
1076 #[test]
1077 fn mixed_bidi_preserves_length() {
1078 let text = "Hello \u{0645}\u{0631}\u{062D}\u{0628}\u{0627} World";
1079 let reordered = bidi_reorder(text);
1080 assert_eq!(reordered.chars().count(), text.chars().count());
1081 }
1082
1083 #[test]
1084 fn breakable_characters_recognized() {
1085 assert!(is_breakable(' '));
1086 assert!(is_breakable('-'));
1087 assert!(is_breakable('/'));
1088 assert!(is_breakable('\u{200B}')); assert!(!is_breakable('A'));
1090 assert!(!is_breakable('中'));
1091 }
1092
1093 #[test]
1094 fn one_em_constant_is_24() {
1095 assert_eq!(ONE_EM, 24.0);
1096 }
1097}