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!(
352 cls,
353 BidiClass::R | BidiClass::AL | BidiClass::AN
354 )
355 })
356 }
357
358 pub(crate) fn is_breakable(c: char) -> bool {
364 matches!(
365 c,
366 '\n' | ' ' | '&' | '(' | ')' | '+' | '-' | '/'
367 | '\u{00AD}' | '\u{00B7}' | '\u{200B}' | '\u{2010}' | '\u{2013}' | '\u{2027}' )
374 }
375
376 pub(crate) fn allows_ideographic_break(c: char) -> bool {
378 let cp = c as u32;
379 (0x4E00..=0x9FFF).contains(&cp)
381 || (0x3400..=0x4DBF).contains(&cp)
382 || (0x20000..=0x2A6DF).contains(&cp)
383 || (0x2A700..=0x2B73F).contains(&cp)
384 || (0x2B740..=0x2B81F).contains(&cp)
385 || (0x2B820..=0x2CEAF).contains(&cp)
386 || (0xF900..=0xFAFF).contains(&cp)
387 || (0x2F800..=0x2FA1F).contains(&cp)
388 || (0x3000..=0x303F).contains(&cp) || (0x3040..=0x309F).contains(&cp) || (0x30A0..=0x30FF).contains(&cp) || (0xFF00..=0xFFEF).contains(&cp) }
394
395 pub(crate) fn determine_line_breaks(
400 chars: &[char],
401 advances: &[f32],
402 max_width_lu: f32,
403 ) -> Vec<usize> {
404 if chars.is_empty() || max_width_lu <= 0.0 {
405 return Vec::new();
406 }
407
408 let total_width: f32 = advances.iter().sum();
409 if total_width <= max_width_lu {
410 return Vec::new();
411 }
412
413 let line_count = (total_width / max_width_lu).ceil().max(1.0);
414 let target_width = total_width / line_count;
415
416 struct BreakCandidate {
418 index: usize,
419 x: f32,
420 cost: f32,
421 prev: Option<usize>,
422 }
423
424 let mut candidates: Vec<BreakCandidate> = Vec::new();
425 candidates.push(BreakCandidate {
427 index: 0,
428 x: 0.0,
429 cost: 0.0,
430 prev: None,
431 });
432
433 let mut pen_x = 0.0f32;
434 for (i, &c) in chars.iter().enumerate() {
435 pen_x += advances[i];
436
437 let breakable = is_breakable(c) || allows_ideographic_break(c);
438 let forced = c == '\n';
439
440 if !breakable && !forced {
441 continue;
442 }
443
444 let break_after = i + 1;
446 if break_after >= chars.len() {
447 continue;
448 }
449
450 let mut best_cost = f32::INFINITY;
452 let mut best_prev = None;
453
454 for (ci, cand) in candidates.iter().enumerate() {
455 let line_width = pen_x - cand.x;
456 let diff = line_width - target_width;
457 let badness = diff * diff;
458 let total = cand.cost + badness;
459
460 if forced {
461 best_cost = total.min(best_cost);
463 best_prev = Some(ci);
464 break;
465 }
466
467 if total < best_cost {
468 best_cost = total;
469 best_prev = Some(ci);
470 }
471 }
472
473 candidates.push(BreakCandidate {
474 index: break_after,
475 x: pen_x,
476 cost: best_cost,
477 prev: best_prev,
478 });
479 }
480
481 let mut best_final_cost = f32::INFINITY;
483 let mut best_final_idx = 0usize;
484 for (ci, cand) in candidates.iter().enumerate() {
485 let remaining = total_width - cand.x;
486 let diff = remaining - target_width;
487 let penalty = if diff < 0.0 { diff * diff * 0.5 } else { diff * diff * 2.0 };
489 let total = cand.cost + penalty;
490 if total < best_final_cost {
491 best_final_cost = total;
492 best_final_idx = ci;
493 }
494 }
495
496 let mut breaks = Vec::new();
498 let mut cur = best_final_idx;
499 while cur > 0 {
500 let cand = &candidates[cur];
501 if cand.index > 0 {
502 breaks.push(cand.index);
503 }
504 cur = cand.prev.unwrap_or(0);
505 }
506 breaks.reverse();
507 breaks
508 }
509
510 pub fn shape_text(
518 text: &str,
519 registry: &mut FontRegistry,
520 options: &ShapeTextOptions,
521 ) -> Option<ShapedText> {
522 if text.is_empty() {
523 return None;
524 }
525
526 let transformed = match options.text_transform {
528 SymbolTextTransform::Uppercase => text.to_uppercase(),
529 SymbolTextTransform::Lowercase => text.to_lowercase(),
530 SymbolTextTransform::None => text.to_owned(),
531 };
532
533 let display_text = if contains_rtl(&transformed) {
535 bidi_reorder(&transformed)
536 } else {
537 transformed.clone()
538 };
539
540 let family_name = registry.resolve_stack(&options.font_stack)?.to_owned();
542 let font = registry.get_font(&family_name)?.clone();
543
544 let face = rustybuzz::Face::from_slice(font.data(), font.face_index())?;
546 let mut buffer = rustybuzz::UnicodeBuffer::new();
547 buffer.push_str(&display_text);
548 let shaped = rustybuzz::shape(&face, &[], buffer);
549
550 let scale = font.scale_to_layout();
551 let infos = shaped.glyph_infos();
552 let positions = shaped.glyph_positions();
553
554 let display_chars: Vec<char> = display_text.chars().collect();
556 let mut glyph_advances: Vec<f32> = Vec::with_capacity(infos.len());
557 let mut glyph_chars: Vec<char> = Vec::with_capacity(infos.len());
558 let mut glyph_ids: Vec<u16> = Vec::with_capacity(infos.len());
559 let mut glyph_x_offsets: Vec<f32> = Vec::with_capacity(infos.len());
560 let mut glyph_y_offsets: Vec<f32> = Vec::with_capacity(infos.len());
561
562 for (i, (info, pos)) in infos.iter().zip(positions.iter()).enumerate() {
563 let cluster = info.cluster as usize;
564 let c = display_chars.get(cluster).copied().unwrap_or('\u{FFFD}');
565 let advance = pos.x_advance as f32 * scale + options.letter_spacing * ONE_EM;
566 glyph_advances.push(advance);
567 glyph_chars.push(c);
568 glyph_ids.push(info.glyph_id as u16);
569 glyph_x_offsets.push(pos.x_offset as f32 * scale);
570 glyph_y_offsets.push(pos.y_offset as f32 * scale);
571
572 let _ = i; }
574
575 let max_width_lu = options
577 .max_width
578 .map(|mw| mw * ONE_EM)
579 .unwrap_or(f32::INFINITY);
580 let breaks = determine_line_breaks(&glyph_chars, &glyph_advances, max_width_lu);
581
582 let line_height_lu = options.line_height * ONE_EM;
584
585 const SHAPING_DEFAULT_OFFSET: f32 = -17.0;
587
588 let mut lines: Vec<ShapedLine> = Vec::new();
589 let mut line_start = 0usize;
590 let mut current_y = 0.0f32;
591
592 let mut break_iter = breaks.iter().peekable();
593 let total_glyphs = glyph_chars.len();
594
595 loop {
596 let line_end = break_iter.next().copied().unwrap_or(total_glyphs);
597
598 let effective_start = if !lines.is_empty() {
600 let mut s = line_start;
601 while s < line_end && glyph_chars.get(s) == Some(&' ') {
602 s += 1;
603 }
604 s
605 } else {
606 line_start
607 };
608
609 let mut positioned = Vec::new();
610 let mut pen_x = 0.0f32;
611 let line_index = lines.len();
612
613 for gi in effective_start..line_end {
614 let c = glyph_chars[gi];
615 if c == '\n' {
617 continue;
618 }
619
620 positioned.push(PositionedGlyph {
621 codepoint: c,
622 glyph_id: glyph_ids[gi],
623 x: pen_x + glyph_x_offsets[gi],
624 y: current_y + SHAPING_DEFAULT_OFFSET + glyph_y_offsets[gi],
625 advance: glyph_advances[gi],
626 metrics_width: 0.0, metrics_height: 0.0,
628 metrics_left: 0.0,
629 metrics_top: 0.0,
630 font_stack: options.font_stack.clone(),
631 vertical: options.writing_mode == SymbolWritingMode::Vertical,
632 line_index,
633 });
634 pen_x += glyph_advances[gi];
635 }
636
637 if !positioned.is_empty() {
639 let line_width = positioned
640 .last()
641 .map(|g| g.x + g.advance)
642 .unwrap_or(0.0);
643 let justify_factor = match options.justify {
644 TextJustify::Left => 0.0,
645 TextJustify::Center => 0.5,
646 TextJustify::Right => 1.0,
647 };
648 let shift = -line_width * justify_factor;
649 for g in &mut positioned {
650 g.x += shift;
651 }
652 }
653
654 lines.push(ShapedLine {
655 glyphs: positioned,
656 line_offset: 0.0,
657 });
658
659 if line_end >= total_glyphs {
660 break;
661 }
662 line_start = line_end;
663 current_y += line_height_lu;
664 }
665
666 let mut left = f32::INFINITY;
668 let mut right = f32::NEG_INFINITY;
669 let mut top = f32::INFINITY;
670 let mut bottom = f32::NEG_INFINITY;
671
672 for line in &lines {
673 for g in &line.glyphs {
674 left = left.min(g.x);
675 right = right.max(g.x + g.advance);
676 top = top.min(g.y);
677 bottom = bottom.max(g.y + ONE_EM);
678 }
679 }
680
681 if left == f32::INFINITY {
682 left = 0.0;
684 right = 0.0;
685 top = 0.0;
686 bottom = 0.0;
687 }
688
689 let text_width = right - left;
691 let text_height = bottom - top;
692 let h_align = options.anchor.horizontal_align();
693 let v_align = options.anchor.vertical_align();
694 let dx = -left - text_width * h_align;
695 let dy = -top - text_height * v_align;
696
697 for line in &mut lines {
698 for g in &mut line.glyphs {
699 g.x += dx;
700 g.y += dy;
701 }
702 }
703
704 left += dx;
705 right += dx;
706 top += dy;
707 bottom += dy;
708
709 Some(ShapedText {
710 lines,
711 left,
712 top,
713 right,
714 bottom,
715 text: display_text,
716 writing_mode: options.writing_mode,
717 })
718 }
719
720 #[derive(Debug, Clone, Default)]
729 pub struct ShapedGlyphProvider {
730 registry: FontRegistry,
731 }
732
733 impl ShapedGlyphProvider {
734 pub fn new(registry: FontRegistry) -> Self {
736 Self { registry }
737 }
738
739 pub fn registry_mut(&mut self) -> &mut FontRegistry {
741 &mut self.registry
742 }
743
744 pub fn registry(&self) -> &FontRegistry {
746 &self.registry
747 }
748 }
749
750 impl GlyphProvider for ShapedGlyphProvider {
751 fn load_glyph(&self, font_stack: &str, codepoint: char) -> Option<GlyphRaster> {
752 let family_name = font_stack
754 .split(',')
755 .map(str::trim)
756 .find(|name| self.registry.fonts.contains_key(*name))?;
757 let font = self.registry.fonts.get(family_name)?;
758
759 let face = ttf_parser::Face::parse(font.data(), font.face_index()).ok()?;
760 let glyph_id = face.glyph_index(codepoint)?;
761
762 let upem = face.units_per_em() as f32;
764 let target_px = ONE_EM as f32; let scale = target_px / upem;
766
767 let h_advance = face.glyph_hor_advance(glyph_id).unwrap_or(0) as f32;
768 let advance_px = h_advance * scale;
769
770 let bbox = face.glyph_bounding_box(glyph_id);
771 let (glyph_width, glyph_height, bearing_x, bearing_y) = if let Some(bb) = bbox {
772 let w = ((bb.x_max - bb.x_min) as f32 * scale).ceil() as u16;
773 let h = ((bb.y_max - bb.y_min) as f32 * scale).ceil() as u16;
774 let bx = (bb.x_min as f32 * scale).floor() as i16;
775 let by = (bb.y_max as f32 * scale).ceil() as i16;
776 (w.max(1), h.max(1), bx, by)
777 } else {
778 return Some(GlyphRaster::new(0, 0, advance_px, 0, 0, Vec::new()));
780 };
781
782 let alpha = rasterize_glyph_alpha(
786 &face,
787 glyph_id,
788 glyph_width,
789 glyph_height,
790 scale,
791 bbox.unwrap(),
792 );
793
794 Some(GlyphRaster::new(
795 glyph_width,
796 glyph_height,
797 advance_px,
798 bearing_x,
799 bearing_y,
800 alpha,
801 ))
802 }
803 }
804
805 fn rasterize_glyph_alpha(
811 face: &ttf_parser::Face<'_>,
812 glyph_id: ttf_parser::GlyphId,
813 width: u16,
814 height: u16,
815 scale: f32,
816 bbox: ttf_parser::Rect,
817 ) -> Vec<u8> {
818 let w = width as usize;
819 let h = height as usize;
820 let mut alpha = vec![0u8; w * h];
821
822 struct SegmentCollector {
824 segments: Vec<((f32, f32), (f32, f32))>,
825 current: (f32, f32),
826 start: (f32, f32),
827 }
828
829 impl ttf_parser::OutlineBuilder for SegmentCollector {
830 fn move_to(&mut self, x: f32, y: f32) {
831 self.current = (x, y);
832 self.start = (x, y);
833 }
834 fn line_to(&mut self, x: f32, y: f32) {
835 self.segments.push((self.current, (x, y)));
836 self.current = (x, y);
837 }
838 fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
839 let steps = 4;
841 let (px, py) = self.current;
842 for i in 1..=steps {
843 let t = i as f32 / steps as f32;
844 let it = 1.0 - t;
845 let nx = it * it * px + 2.0 * it * t * x1 + t * t * x;
846 let ny = it * it * py + 2.0 * it * t * y1 + t * t * y;
847 self.segments.push((self.current, (nx, ny)));
848 self.current = (nx, ny);
849 }
850 }
851 fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
852 let steps = 8;
854 let (px, py) = self.current;
855 for i in 1..=steps {
856 let t = i as f32 / steps as f32;
857 let it = 1.0 - t;
858 let nx = it * it * it * px
859 + 3.0 * it * it * t * x1
860 + 3.0 * it * t * t * x2
861 + t * t * t * x;
862 let ny = it * it * it * py
863 + 3.0 * it * it * t * y1
864 + 3.0 * it * t * t * y2
865 + t * t * t * y;
866 self.segments.push((self.current, (nx, ny)));
867 self.current = (nx, ny);
868 }
869 }
870 fn close(&mut self) {
871 if self.current != self.start {
872 self.segments.push((self.current, self.start));
873 }
874 self.current = self.start;
875 }
876 }
877
878 let mut collector = SegmentCollector {
879 segments: Vec::new(),
880 current: (0.0, 0.0),
881 start: (0.0, 0.0),
882 };
883 face.outline_glyph(glyph_id, &mut collector);
884
885 let origin_x = bbox.x_min as f32;
887 let origin_y = bbox.y_min as f32;
888 let segments: Vec<((f32, f32), (f32, f32))> = collector
889 .segments
890 .iter()
891 .map(|&((x0, y0), (x1, y1))| {
892 let bx0 = (x0 - origin_x) * scale;
894 let by0 = (h as f32) - (y0 - origin_y) * scale;
895 let bx1 = (x1 - origin_x) * scale;
896 let by1 = (h as f32) - (y1 - origin_y) * scale;
897 ((bx0, by0), (bx1, by1))
898 })
899 .collect();
900
901 for row in 0..h {
903 let y = row as f32 + 0.5;
904 let mut crossings: Vec<f32> = Vec::new();
905
906 for &((x0, y0), (x1, y1)) in &segments {
907 if (y0 <= y && y1 > y) || (y1 <= y && y0 > y) {
908 let t = (y - y0) / (y1 - y0);
909 let x_intersect = x0 + t * (x1 - x0);
910 crossings.push(x_intersect);
911 }
912 }
913
914 crossings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
915
916 for pair in crossings.chunks(2) {
917 if pair.len() == 2 {
918 let start_col = (pair[0].max(0.0) as usize).min(w);
919 let end_col = ((pair[1] + 1.0).max(0.0) as usize).min(w);
920 for col in start_col..end_col {
921 alpha[row * w + col] = 255;
922 }
923 }
924 }
925 }
926
927 alpha
928 }
929}
930
931#[cfg(feature = "text-shaping")]
933pub use inner::*;
934
935#[cfg(test)]
940#[cfg(feature = "text-shaping")]
941mod tests {
942 use super::inner::*;
943 use super::*;
944
945 fn test_options() -> ShapeTextOptions {
952 ShapeTextOptions {
953 font_stack: "TestFont".to_owned(),
954 ..Default::default()
955 }
956 }
957
958 #[test]
959 fn bidi_reorder_pure_ltr_is_identity() {
960 let text = "Hello World";
961 let reordered = bidi_reorder(text);
962 assert_eq!(reordered, "Hello World");
963 }
964
965 #[test]
966 fn bidi_reorder_rtl_reverses_characters() {
967 let text = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"; let reordered = bidi_reorder(text);
970 assert!(!reordered.is_empty());
972 assert_eq!(reordered.chars().count(), 5);
973 }
974
975 #[test]
976 fn contains_rtl_detects_arabic() {
977 assert!(contains_rtl("\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"));
978 assert!(!contains_rtl("Hello World"));
979 }
980
981 #[test]
982 fn contains_rtl_detects_hebrew() {
983 assert!(contains_rtl("\u{05E9}\u{05DC}\u{05D5}\u{05DD}")); }
985
986 #[test]
987 fn line_breaking_no_break_within_limit() {
988 let chars: Vec<char> = "Hello".chars().collect();
989 let advances = vec![10.0; 5]; let breaks = determine_line_breaks(&chars, &advances, 100.0);
991 assert!(breaks.is_empty());
992 }
993
994 #[test]
995 fn line_breaking_wraps_at_space() {
996 let text = "Hello World Test";
997 let chars: Vec<char> = text.chars().collect();
998 let advances = vec![10.0; chars.len()];
1000 let breaks = determine_line_breaks(&chars, &advances, 80.0);
1001 assert!(!breaks.is_empty());
1002 for &b in &breaks {
1004 assert!(b > 0 && b < chars.len());
1005 }
1006 }
1007
1008 #[test]
1009 fn line_breaking_forced_newline() {
1010 let text = "Line1\nLine2";
1011 let chars: Vec<char> = text.chars().collect();
1012 let advances = vec![10.0; chars.len()];
1013 let breaks = determine_line_breaks(&chars, &advances, 1000.0);
1014 assert!(breaks.is_empty());
1020 }
1021
1022 #[test]
1023 fn ideographic_break_allows_cjk() {
1024 assert!(allows_ideographic_break('中'));
1025 assert!(allows_ideographic_break('漢'));
1026 assert!(!allows_ideographic_break('A'));
1027 }
1028
1029 #[test]
1030 fn text_anchor_alignment_factors() {
1031 assert_eq!(TextAnchor::TopLeft.horizontal_align(), 0.0);
1032 assert_eq!(TextAnchor::TopLeft.vertical_align(), 0.0);
1033 assert_eq!(TextAnchor::Center.horizontal_align(), 0.5);
1034 assert_eq!(TextAnchor::Center.vertical_align(), 0.5);
1035 assert_eq!(TextAnchor::BottomRight.horizontal_align(), 1.0);
1036 assert_eq!(TextAnchor::BottomRight.vertical_align(), 1.0);
1037 }
1038
1039 #[test]
1040 fn font_registry_resolve_stack() {
1041 let mut registry = FontRegistry::new();
1042 assert!(registry.resolve_stack("SomeFont, FallbackFont").is_none());
1044 }
1045
1046 #[test]
1047 fn font_registry_font_count() {
1048 let registry = FontRegistry::new();
1049 assert_eq!(registry.font_count(), 0);
1050 }
1051
1052 #[test]
1053 fn shaped_text_options_default() {
1054 let opts = ShapeTextOptions::default();
1055 assert_eq!(opts.line_height, 1.2);
1056 assert_eq!(opts.letter_spacing, 0.0);
1057 assert!(matches!(opts.justify, TextJustify::Center));
1058 assert!(matches!(opts.anchor, TextAnchor::Center));
1059 }
1060
1061 #[test]
1062 fn shape_text_returns_none_for_empty() {
1063 let mut registry = FontRegistry::new();
1064 let options = test_options();
1065 let result = shape_text("", &mut registry, &options);
1066 assert!(result.is_none());
1067 }
1068
1069 #[test]
1070 fn shape_text_returns_none_without_font() {
1071 let mut registry = FontRegistry::new();
1072 let options = test_options();
1073 let result = shape_text("Hello", &mut registry, &options);
1074 assert!(result.is_none());
1075 }
1076
1077 #[test]
1078 fn mixed_bidi_preserves_length() {
1079 let text = "Hello \u{0645}\u{0631}\u{062D}\u{0628}\u{0627} World";
1080 let reordered = bidi_reorder(text);
1081 assert_eq!(reordered.chars().count(), text.chars().count());
1082 }
1083
1084 #[test]
1085 fn breakable_characters_recognized() {
1086 assert!(is_breakable(' '));
1087 assert!(is_breakable('-'));
1088 assert!(is_breakable('/'));
1089 assert!(is_breakable('\u{200B}')); assert!(!is_breakable('A'));
1091 assert!(!is_breakable('中'));
1092 }
1093
1094 #[test]
1095 fn one_em_constant_is_24() {
1096 assert_eq!(ONE_EM, 24.0);
1097 }
1098}