Skip to main content

graphitepdf_textkit/
lib.rs

1pub mod error;
2
3pub use error::*;
4
5use graphitepdf_font::{FontDescriptor, FontSource, FontStore, StandardFont};
6use graphitepdf_primitives::Pt;
7use std::cmp::Ordering;
8
9#[derive(Clone, Debug, PartialEq)]
10pub struct TextSpan {
11    content: String,
12    font: Option<FontDescriptor>,
13    font_size: Pt,
14}
15
16impl TextSpan {
17    pub fn new(content: impl Into<String>) -> Result<Self> {
18        let content = content.into();
19        if content.trim().is_empty() {
20            return Err(Error::EmptyText);
21        }
22
23        Ok(Self {
24            content,
25            font: None,
26            font_size: Pt::new(12.0),
27        })
28    }
29
30    pub fn content(&self) -> &str {
31        &self.content
32    }
33
34    pub fn font(&self) -> Option<&FontDescriptor> {
35        self.font.as_ref()
36    }
37
38    pub const fn font_size(&self) -> Pt {
39        self.font_size
40    }
41
42    pub fn with_font(mut self, font: FontDescriptor) -> Self {
43        self.font = Some(font);
44        self
45    }
46
47    pub fn with_font_size(mut self, font_size: Pt) -> Result<Self> {
48        if font_size.value() <= 0.0 {
49            return Err(Error::InvalidFontSize {
50                size: font_size.value(),
51            });
52        }
53
54        self.font_size = font_size;
55        Ok(self)
56    }
57}
58
59#[derive(Clone, Debug, Default, PartialEq)]
60pub struct TextBlock {
61    spans: Vec<TextSpan>,
62}
63
64impl TextBlock {
65    pub fn new(spans: impl IntoIterator<Item = TextSpan>) -> Self {
66        Self {
67            spans: spans.into_iter().collect(),
68        }
69    }
70
71    pub fn push(&mut self, span: TextSpan) {
72        self.spans.push(span);
73    }
74
75    pub fn spans(&self) -> &[TextSpan] {
76        &self.spans
77    }
78
79    pub fn plain_text(&self) -> String {
80        self.spans
81            .iter()
82            .map(TextSpan::content)
83            .collect::<Vec<_>>()
84            .join("")
85    }
86
87    pub fn is_empty(&self) -> bool {
88        self.spans.is_empty()
89    }
90}
91
92impl From<TextSpan> for TextBlock {
93    fn from(value: TextSpan) -> Self {
94        Self { spans: vec![value] }
95    }
96}
97
98impl TextBlock {
99    pub fn to_attributed_string(&self) -> Result<AttributedString> {
100        AttributedString::try_from(self)
101    }
102}
103
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub struct TextRange {
106    start: usize,
107    end: usize,
108}
109
110impl TextRange {
111    pub const fn new(start: usize, end: usize) -> Self {
112        Self { start, end }
113    }
114
115    pub const fn start(self) -> usize {
116        self.start
117    }
118
119    pub const fn end(self) -> usize {
120        self.end
121    }
122
123    pub const fn len(self) -> usize {
124        self.end.saturating_sub(self.start)
125    }
126
127    pub const fn is_empty(self) -> bool {
128        self.start >= self.end
129    }
130
131    fn validate_for(self, text: &str) -> Result<()> {
132        if self.start > self.end || self.end > text.len() {
133            return Err(Error::InvalidTextRange {
134                start: self.start,
135                end: self.end,
136                len: text.len(),
137            });
138        }
139
140        if !text.is_char_boundary(self.start) || !text.is_char_boundary(self.end) {
141            return Err(Error::NonCharacterBoundaryRange {
142                start: self.start,
143                end: self.end,
144            });
145        }
146
147        Ok(())
148    }
149}
150
151#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
152pub enum TextDirection {
153    #[default]
154    Ltr,
155    Rtl,
156}
157
158#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
159pub enum Script {
160    #[default]
161    Common,
162    Latin,
163    Arabic,
164    Hebrew,
165    Cyrillic,
166    Han,
167    Unknown,
168}
169
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub enum TextDecorationKind {
172    Underline,
173    Overline,
174    LineThrough,
175}
176
177#[derive(Clone, Debug, PartialEq)]
178pub struct TextDecoration {
179    kind: TextDecorationKind,
180    thickness: Option<Pt>,
181    offset: Option<Pt>,
182}
183
184impl TextDecoration {
185    pub const fn new(kind: TextDecorationKind) -> Self {
186        Self {
187            kind,
188            thickness: None,
189            offset: None,
190        }
191    }
192
193    pub const fn kind(&self) -> TextDecorationKind {
194        self.kind
195    }
196
197    pub const fn thickness(&self) -> Option<Pt> {
198        self.thickness
199    }
200
201    pub const fn offset(&self) -> Option<Pt> {
202        self.offset
203    }
204
205    pub fn with_thickness(mut self, thickness: Pt) -> Self {
206        self.thickness = Some(thickness);
207        self
208    }
209
210    pub fn with_offset(mut self, offset: Pt) -> Self {
211        self.offset = Some(offset);
212        self
213    }
214}
215
216#[derive(Clone, Debug, PartialEq)]
217pub struct TextAttributes {
218    font: Option<FontDescriptor>,
219    font_size: Pt,
220    letter_spacing: Pt,
221    language: Option<String>,
222    direction: Option<TextDirection>,
223    decorations: Vec<TextDecoration>,
224}
225
226impl Default for TextAttributes {
227    fn default() -> Self {
228        Self {
229            font: None,
230            font_size: Pt::new(12.0),
231            letter_spacing: Pt::zero(),
232            language: None,
233            direction: None,
234            decorations: Vec::new(),
235        }
236    }
237}
238
239impl TextAttributes {
240    pub fn font(&self) -> Option<&FontDescriptor> {
241        self.font.as_ref()
242    }
243
244    pub const fn font_size(&self) -> Pt {
245        self.font_size
246    }
247
248    pub const fn letter_spacing(&self) -> Pt {
249        self.letter_spacing
250    }
251
252    pub fn language(&self) -> Option<&str> {
253        self.language.as_deref()
254    }
255
256    pub const fn direction(&self) -> Option<TextDirection> {
257        self.direction
258    }
259
260    pub fn decorations(&self) -> &[TextDecoration] {
261        &self.decorations
262    }
263
264    pub fn with_font(mut self, font: FontDescriptor) -> Self {
265        self.font = Some(font);
266        self
267    }
268
269    pub fn with_font_size(mut self, font_size: Pt) -> Result<Self> {
270        if font_size.value() <= 0.0 {
271            return Err(Error::InvalidFontSize {
272                size: font_size.value(),
273            });
274        }
275
276        self.font_size = font_size;
277        Ok(self)
278    }
279
280    pub fn with_letter_spacing(mut self, letter_spacing: Pt) -> Self {
281        self.letter_spacing = letter_spacing;
282        self
283    }
284
285    pub fn with_language(mut self, language: impl Into<String>) -> Self {
286        self.language = Some(language.into());
287        self
288    }
289
290    pub fn with_direction(mut self, direction: TextDirection) -> Self {
291        self.direction = Some(direction);
292        self
293    }
294
295    pub fn with_decoration(mut self, decoration: TextDecoration) -> Self {
296        self.decorations.push(decoration);
297        self
298    }
299}
300
301impl From<&TextSpan> for TextAttributes {
302    fn from(value: &TextSpan) -> Self {
303        let mut attributes = TextAttributes::default();
304        if let Some(font) = value.font.clone() {
305            attributes = attributes.with_font(font);
306        }
307        attributes.font_size = value.font_size;
308        attributes
309    }
310}
311
312#[derive(Clone, Debug, PartialEq)]
313pub struct AttributeRun {
314    range: TextRange,
315    attributes: TextAttributes,
316}
317
318impl AttributeRun {
319    pub const fn new(range: TextRange, attributes: TextAttributes) -> Self {
320        Self { range, attributes }
321    }
322
323    pub const fn range(&self) -> TextRange {
324        self.range
325    }
326
327    pub const fn attributes(&self) -> &TextAttributes {
328        &self.attributes
329    }
330}
331
332#[derive(Clone, Debug, PartialEq)]
333pub struct AttributedString {
334    text: String,
335    default_attributes: TextAttributes,
336    runs: Vec<AttributeRun>,
337}
338
339impl AttributedString {
340    pub fn new(text: impl Into<String>) -> Result<Self> {
341        let text = text.into();
342        if text.is_empty() {
343            return Err(Error::EmptyText);
344        }
345
346        Ok(Self {
347            text,
348            default_attributes: TextAttributes::default(),
349            runs: Vec::new(),
350        })
351    }
352
353    pub fn text(&self) -> &str {
354        &self.text
355    }
356
357    pub fn default_attributes(&self) -> &TextAttributes {
358        &self.default_attributes
359    }
360
361    pub fn runs(&self) -> &[AttributeRun] {
362        &self.runs
363    }
364
365    pub fn with_default_attributes(mut self, attributes: TextAttributes) -> Result<Self> {
366        validate_font_size(attributes.font_size())?;
367        self.default_attributes = attributes;
368        Ok(self)
369    }
370
371    pub fn push_run(&mut self, range: TextRange, attributes: TextAttributes) -> Result<()> {
372        range.validate_for(&self.text)?;
373        validate_font_size(attributes.font_size())?;
374        self.runs.push(AttributeRun::new(range, attributes));
375        Ok(())
376    }
377
378    pub fn with_run(mut self, range: TextRange, attributes: TextAttributes) -> Result<Self> {
379        self.push_run(range, attributes)?;
380        Ok(self)
381    }
382
383    fn canonical_runs(&self) -> Result<Vec<CanonicalRun>> {
384        let mut boundaries = vec![0, self.text.len()];
385        for run in &self.runs {
386            run.range.validate_for(&self.text)?;
387            boundaries.push(run.range.start());
388            boundaries.push(run.range.end());
389        }
390        boundaries.sort_unstable();
391        boundaries.dedup();
392
393        let mut runs = Vec::new();
394        for window in boundaries.windows(2) {
395            let range = TextRange::new(window[0], window[1]);
396            if range.is_empty() {
397                continue;
398            }
399
400            let mut attributes = self.default_attributes.clone();
401            for run in &self.runs {
402                if run.range.start() <= range.start() && range.end() <= run.range.end() {
403                    attributes = run.attributes.clone();
404                }
405            }
406
407            let text = self.text[range.start()..range.end()].to_string();
408            runs.push(CanonicalRun {
409                range,
410                text,
411                attributes,
412            });
413        }
414
415        Ok(runs)
416    }
417}
418
419impl TryFrom<&TextBlock> for AttributedString {
420    type Error = Error;
421
422    fn try_from(value: &TextBlock) -> Result<Self> {
423        let text = value.plain_text();
424        if text.is_empty() {
425            return Err(Error::EmptyText);
426        }
427
428        let mut attributed = AttributedString::new(text)?;
429        let mut start = 0;
430        for span in value.spans() {
431            let end = start + span.content().len();
432            attributed.push_run(TextRange::new(start, end), TextAttributes::from(span))?;
433            start = end;
434        }
435        Ok(attributed)
436    }
437}
438
439impl TryFrom<TextBlock> for AttributedString {
440    type Error = Error;
441
442    fn try_from(value: TextBlock) -> Result<Self> {
443        Self::try_from(&value)
444    }
445}
446
447#[derive(Clone, Copy, Debug, Default, PartialEq)]
448pub struct TextRect {
449    pub x: Pt,
450    pub y: Pt,
451    pub width: Pt,
452    pub height: Pt,
453}
454
455impl TextRect {
456    pub const fn new(x: Pt, y: Pt, width: Pt, height: Pt) -> Self {
457        Self {
458            x,
459            y,
460            width,
461            height,
462        }
463    }
464
465    pub const fn from_values(x: f32, y: f32, width: f32, height: f32) -> Self {
466        Self::new(Pt::new(x), Pt::new(y), Pt::new(width), Pt::new(height))
467    }
468
469    pub const fn right(&self) -> Pt {
470        Pt::new(self.x.value() + self.width.value())
471    }
472
473    pub const fn bottom(&self) -> Pt {
474        Pt::new(self.y.value() + self.height.value())
475    }
476}
477
478#[derive(Clone, Copy, Debug, PartialEq)]
479pub struct TextContainer {
480    rect: TextRect,
481    max_lines: Option<usize>,
482}
483
484impl TextContainer {
485    pub fn new(rect: TextRect) -> Result<Self> {
486        if rect.width.value() <= 0.0 || rect.height.value() <= 0.0 {
487            return Err(Error::InvalidTextContainer {
488                width: rect.width.value(),
489                height: rect.height.value(),
490            });
491        }
492
493        Ok(Self {
494            rect,
495            max_lines: None,
496        })
497    }
498
499    pub const fn rect(&self) -> TextRect {
500        self.rect
501    }
502
503    pub const fn max_lines(&self) -> Option<usize> {
504        self.max_lines
505    }
506
507    pub fn with_max_lines(mut self, max_lines: usize) -> Self {
508        self.max_lines = Some(max_lines);
509        self
510    }
511}
512
513#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
514pub enum BidiMode {
515    #[default]
516    Auto,
517    LeftToRight,
518    RightToLeft,
519}
520
521#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
522pub enum LineBreaking {
523    #[default]
524    WordBoundary,
525    CharacterBoundary,
526    WordBoundaryOrCharacter,
527}
528
529#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
530pub enum Justification {
531    #[default]
532    Start,
533    End,
534    Center,
535    Full,
536}
537
538#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
539pub enum ScriptItemization {
540    None,
541    #[default]
542    Heuristic,
543}
544
545#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
546pub enum TextDecorationMode {
547    Suppress,
548    #[default]
549    Preserve,
550}
551
552#[derive(Clone, Debug, PartialEq, Default)]
553pub enum FontSubstitution {
554    Disabled,
555    FallbackFamilies(Vec<FontDescriptor>),
556    #[default]
557    BestEffort,
558}
559
560#[derive(Clone, Debug, PartialEq)]
561pub enum WordHyphenation {
562    Disabled,
563    Enabled { min_word_chars: usize, marker: char },
564}
565
566impl Default for WordHyphenation {
567    fn default() -> Self {
568        Self::Enabled {
569            min_word_chars: 8,
570            marker: '-',
571        }
572    }
573}
574
575#[derive(Clone, Debug, PartialEq)]
576pub struct TextEngineConfig {
577    pub bidi: BidiMode,
578    pub line_breaking: LineBreaking,
579    pub justification: Justification,
580    pub font_substitution: FontSubstitution,
581    pub script_itemization: ScriptItemization,
582    pub text_decoration_mode: TextDecorationMode,
583    pub word_hyphenation: WordHyphenation,
584    pub default_font: FontDescriptor,
585}
586
587impl Default for TextEngineConfig {
588    fn default() -> Self {
589        Self {
590            bidi: BidiMode::Auto,
591            line_breaking: LineBreaking::WordBoundaryOrCharacter,
592            justification: Justification::Start,
593            font_substitution: FontSubstitution::BestEffort,
594            script_itemization: ScriptItemization::Heuristic,
595            text_decoration_mode: TextDecorationMode::Preserve,
596            word_hyphenation: WordHyphenation::default(),
597            default_font: FontDescriptor::new(StandardFont::Helvetica.family_name()),
598        }
599    }
600}
601
602#[derive(Clone, Debug, PartialEq)]
603pub struct TextRun {
604    range: TextRange,
605    text: String,
606    requested_font: Option<FontDescriptor>,
607    resolved_font: FontDescriptor,
608    font_source: Option<FontSource>,
609    font_size: Pt,
610    letter_spacing: Pt,
611    language: Option<String>,
612    direction: TextDirection,
613    script: Script,
614    decorations: Vec<TextDecoration>,
615}
616
617impl TextRun {
618    pub const fn range(&self) -> TextRange {
619        self.range
620    }
621
622    pub fn text(&self) -> &str {
623        &self.text
624    }
625
626    pub fn requested_font(&self) -> Option<&FontDescriptor> {
627        self.requested_font.as_ref()
628    }
629
630    pub const fn resolved_font(&self) -> &FontDescriptor {
631        &self.resolved_font
632    }
633
634    pub const fn font_source(&self) -> Option<&FontSource> {
635        self.font_source.as_ref()
636    }
637
638    pub const fn font_size(&self) -> Pt {
639        self.font_size
640    }
641
642    pub const fn letter_spacing(&self) -> Pt {
643        self.letter_spacing
644    }
645
646    pub fn language(&self) -> Option<&str> {
647        self.language.as_deref()
648    }
649
650    pub const fn direction(&self) -> TextDirection {
651        self.direction
652    }
653
654    pub const fn script(&self) -> Script {
655        self.script
656    }
657
658    pub fn decorations(&self) -> &[TextDecoration] {
659        &self.decorations
660    }
661}
662
663#[derive(Clone, Debug, PartialEq)]
664pub struct TextFragment {
665    range: TextRange,
666    text: String,
667    rect: TextRect,
668    baseline: Pt,
669    direction: TextDirection,
670    script: Script,
671    font: FontDescriptor,
672    font_size: Pt,
673    decorations: Vec<TextDecoration>,
674    inserted_hyphen: bool,
675    whitespace: bool,
676}
677
678impl TextFragment {
679    pub const fn range(&self) -> TextRange {
680        self.range
681    }
682
683    pub fn text(&self) -> &str {
684        &self.text
685    }
686
687    pub const fn rect(&self) -> TextRect {
688        self.rect
689    }
690
691    pub const fn baseline(&self) -> Pt {
692        self.baseline
693    }
694
695    pub const fn direction(&self) -> TextDirection {
696        self.direction
697    }
698
699    pub const fn script(&self) -> Script {
700        self.script
701    }
702
703    pub const fn font(&self) -> &FontDescriptor {
704        &self.font
705    }
706
707    pub const fn font_size(&self) -> Pt {
708        self.font_size
709    }
710
711    pub fn decorations(&self) -> &[TextDecoration] {
712        &self.decorations
713    }
714
715    pub const fn inserted_hyphen(&self) -> bool {
716        self.inserted_hyphen
717    }
718
719    pub const fn is_whitespace(&self) -> bool {
720        self.whitespace
721    }
722}
723
724#[derive(Clone, Debug, PartialEq)]
725pub struct LineFragment {
726    rect: TextRect,
727    baseline: Pt,
728    direction: TextDirection,
729    justification: Justification,
730    fragments: Vec<TextFragment>,
731}
732
733impl LineFragment {
734    pub const fn rect(&self) -> TextRect {
735        self.rect
736    }
737
738    pub const fn baseline(&self) -> Pt {
739        self.baseline
740    }
741
742    pub const fn direction(&self) -> TextDirection {
743        self.direction
744    }
745
746    pub const fn justification(&self) -> Justification {
747        self.justification
748    }
749
750    pub fn fragments(&self) -> &[TextFragment] {
751        &self.fragments
752    }
753}
754
755#[derive(Clone, Debug, PartialEq)]
756pub struct TextLayout {
757    lines: Vec<LineFragment>,
758    runs: Vec<TextRun>,
759    bounds: TextRect,
760    overflowed: bool,
761}
762
763impl TextLayout {
764    pub fn lines(&self) -> &[LineFragment] {
765        &self.lines
766    }
767
768    pub fn runs(&self) -> &[TextRun] {
769        &self.runs
770    }
771
772    pub const fn bounds(&self) -> TextRect {
773        self.bounds
774    }
775
776    pub const fn overflowed(&self) -> bool {
777        self.overflowed
778    }
779
780    pub fn fragments(&self) -> impl Iterator<Item = &TextFragment> {
781        self.lines.iter().flat_map(|line| line.fragments.iter())
782    }
783}
784
785#[derive(Clone, Debug, Default)]
786pub struct TextEngine {
787    config: TextEngineConfig,
788}
789
790impl TextEngine {
791    pub fn new(config: TextEngineConfig) -> Self {
792        Self { config }
793    }
794
795    pub fn config(&self) -> &TextEngineConfig {
796        &self.config
797    }
798
799    pub fn layout(
800        &self,
801        attributed: &AttributedString,
802        container: &TextContainer,
803        font_store: Option<&FontStore>,
804    ) -> Result<TextLayout> {
805        let runs = self.resolve_runs(attributed, font_store)?;
806        self.layout_runs(runs, *container, font_store)
807    }
808
809    pub fn layout_text_block(
810        &self,
811        block: &TextBlock,
812        container: &TextContainer,
813        font_store: Option<&FontStore>,
814    ) -> Result<TextLayout> {
815        let attributed = block.to_attributed_string()?;
816        self.layout(&attributed, container, font_store)
817    }
818
819    fn resolve_runs(
820        &self,
821        attributed: &AttributedString,
822        font_store: Option<&FontStore>,
823    ) -> Result<Vec<TextRun>> {
824        let mut resolved = Vec::new();
825
826        for run in attributed.canonical_runs()? {
827            match self.config.script_itemization {
828                ScriptItemization::None => {
829                    resolved.push(self.build_text_run(
830                        run.range,
831                        run.text,
832                        run.attributes,
833                        font_store,
834                    )?);
835                }
836                ScriptItemization::Heuristic => {
837                    for item in self.itemize_run(run.range, &run.text, &run.attributes) {
838                        resolved.push(self.build_text_run(
839                            item.range,
840                            item.text,
841                            run.attributes.clone(),
842                            font_store,
843                        )?);
844                    }
845                }
846            }
847        }
848
849        Ok(resolved)
850    }
851
852    fn build_text_run(
853        &self,
854        range: TextRange,
855        text: String,
856        attributes: TextAttributes,
857        font_store: Option<&FontStore>,
858    ) -> Result<TextRun> {
859        let requested_font = attributes.font().cloned();
860        let (resolved_font, font_source) = self.resolve_font(attributes.font(), font_store)?;
861        let direction = resolve_direction(attributes.direction(), self.config.bidi, &text);
862        let script = detect_script_in_text(&text);
863        let decorations = match self.config.text_decoration_mode {
864            TextDecorationMode::Preserve => attributes.decorations.clone(),
865            TextDecorationMode::Suppress => Vec::new(),
866        };
867
868        Ok(TextRun {
869            range,
870            text,
871            requested_font,
872            resolved_font,
873            font_source,
874            font_size: attributes.font_size(),
875            letter_spacing: attributes.letter_spacing(),
876            language: attributes.language().map(ToOwned::to_owned),
877            direction,
878            script,
879            decorations,
880        })
881    }
882
883    fn resolve_font(
884        &self,
885        requested: Option<&FontDescriptor>,
886        font_store: Option<&FontStore>,
887    ) -> Result<(FontDescriptor, Option<FontSource>)> {
888        let requested = requested
889            .cloned()
890            .unwrap_or_else(|| self.config.default_font.clone());
891
892        let Some(font_store) = font_store else {
893            return Ok((requested, None));
894        };
895
896        if let Ok(registered) = font_store.get_font(&requested) {
897            return Ok((
898                registered.descriptor().clone(),
899                Some(registered.source().clone()),
900            ));
901        }
902
903        let mut fallbacks = Vec::new();
904        match &self.config.font_substitution {
905            FontSubstitution::Disabled => {}
906            FontSubstitution::FallbackFamilies(descriptors) => {
907                fallbacks.extend(
908                    descriptors
909                        .iter()
910                        .cloned()
911                        .map(|descriptor| harmonize_descriptor(descriptor, &requested)),
912                );
913            }
914            FontSubstitution::BestEffort => {
915                fallbacks.push(harmonize_descriptor(
916                    self.config.default_font.clone(),
917                    &requested,
918                ));
919                fallbacks.push(harmonize_descriptor(
920                    FontDescriptor::new(StandardFont::Helvetica.family_name()),
921                    &requested,
922                ));
923                fallbacks.push(harmonize_descriptor(
924                    FontDescriptor::new(StandardFont::TimesRoman.family_name()),
925                    &requested,
926                ));
927                fallbacks.push(harmonize_descriptor(
928                    FontDescriptor::new(StandardFont::Courier.family_name()),
929                    &requested,
930                ));
931            }
932        }
933
934        for fallback in fallbacks {
935            if let Ok(registered) = font_store.get_font(&fallback) {
936                return Ok((
937                    registered.descriptor().clone(),
938                    Some(registered.source().clone()),
939                ));
940            }
941        }
942
943        Err(Error::UnresolvedFont {
944            family: requested.family().to_string(),
945        })
946    }
947
948    fn itemize_run(
949        &self,
950        range: TextRange,
951        text: &str,
952        attributes: &TextAttributes,
953    ) -> Vec<ItemizedRun> {
954        if text.is_empty() {
955            return Vec::new();
956        }
957
958        let explicit_direction = attributes.direction();
959        let mut items = Vec::new();
960        let mut chunk_start = 0;
961        let mut current_script = Script::Common;
962        let mut current_direction =
963            explicit_direction.unwrap_or_else(|| resolve_direction(None, self.config.bidi, text));
964
965        for (offset, character) in text.char_indices() {
966            let script = detect_script(character);
967            let direction = explicit_direction.unwrap_or_else(|| {
968                direction_for_char(character)
969                    .unwrap_or_else(|| resolve_direction(None, self.config.bidi, text))
970            });
971
972            let script_changed = current_script != Script::Common
973                && script != Script::Common
974                && script != current_script;
975            let direction_changed = direction != current_direction
976                && direction_for_char(character).is_some()
977                && has_strong_direction(&text[chunk_start..offset]);
978
979            if offset > chunk_start && (script_changed || direction_changed) {
980                let item_text = text[chunk_start..offset].to_string();
981                items.push(ItemizedRun {
982                    range: TextRange::new(range.start() + chunk_start, range.start() + offset),
983                    text: item_text,
984                });
985                chunk_start = offset;
986            }
987
988            if script != Script::Common {
989                current_script = script;
990            }
991            current_direction = direction;
992        }
993
994        items.push(ItemizedRun {
995            range: TextRange::new(range.start() + chunk_start, range.end()),
996            text: text[chunk_start..].to_string(),
997        });
998
999        items
1000    }
1001
1002    fn layout_runs(
1003        &self,
1004        runs: Vec<TextRun>,
1005        container: TextContainer,
1006        font_store: Option<&FontStore>,
1007    ) -> Result<TextLayout> {
1008        let mut lines = Vec::new();
1009        let mut pending = Vec::<PendingFragment>::new();
1010        let mut current_width = 0.0_f32;
1011        let mut current_y = container.rect().y.value();
1012        let bottom = container.rect().bottom().value();
1013        let mut overflowed = false;
1014
1015        'layout: for run in &runs {
1016            let tokens = tokenize_run(run, self.config.line_breaking);
1017            for token in tokens {
1018                if token.is_newline {
1019                    if !pending.is_empty() {
1020                        let line = finalize_line(
1021                            &pending,
1022                            container.rect(),
1023                            current_y,
1024                            self.config.justification,
1025                            false,
1026                        );
1027                        if line.rect().bottom().value() > bottom {
1028                            overflowed = true;
1029                            break 'layout;
1030                        }
1031                        current_y = line.rect().bottom().value();
1032                        lines.push(line);
1033                        pending.clear();
1034                        current_width = 0.0;
1035                    }
1036                    continue;
1037                }
1038
1039                let available_width = container.rect().width.value() - current_width;
1040                if !pending.is_empty() && token.width.value() > available_width {
1041                    let line = finalize_line(
1042                        &pending,
1043                        container.rect(),
1044                        current_y,
1045                        self.config.justification,
1046                        false,
1047                    );
1048                    if line.rect().bottom().value() > bottom {
1049                        overflowed = true;
1050                        break 'layout;
1051                    }
1052                    current_y = line.rect().bottom().value();
1053                    lines.push(line);
1054                    pending.clear();
1055                    current_width = 0.0;
1056                }
1057
1058                let available_width = container.rect().width.value() - current_width;
1059                if token.width.value() > available_width
1060                    && !token.is_whitespace
1061                    && let Some((head, tail)) =
1062                        self.split_token_to_fit(run, &token, available_width, font_store)
1063                {
1064                    pending.push(head);
1065                    let line = finalize_line(
1066                        &pending,
1067                        container.rect(),
1068                        current_y,
1069                        self.config.justification,
1070                        false,
1071                    );
1072                    if line.rect().bottom().value() > bottom {
1073                        overflowed = true;
1074                        break 'layout;
1075                    }
1076                    current_y = line.rect().bottom().value();
1077                    lines.push(line);
1078                    pending.clear();
1079                    current_width = 0.0;
1080
1081                    if let Some(tail) = tail {
1082                        let remainder = vec![tail];
1083                        for fragment in remainder {
1084                            if fragment.width.value() > container.rect().width.value()
1085                                && !fragment.is_whitespace
1086                            {
1087                                pending.push(fragment);
1088                                let line = finalize_line(
1089                                    &pending,
1090                                    container.rect(),
1091                                    current_y,
1092                                    self.config.justification,
1093                                    false,
1094                                );
1095                                if line.rect().bottom().value() > bottom {
1096                                    overflowed = true;
1097                                    break 'layout;
1098                                }
1099                                current_y = line.rect().bottom().value();
1100                                lines.push(line);
1101                                pending.clear();
1102                                current_width = 0.0;
1103                            } else {
1104                                current_width += fragment.width.value();
1105                                pending.push(fragment);
1106                            }
1107                        }
1108                    }
1109                    continue;
1110                }
1111
1112                current_width += token.width.value();
1113                pending.push(PendingFragment::from_token(run, token));
1114            }
1115        }
1116
1117        if !overflowed && !pending.is_empty() {
1118            let line = finalize_line(
1119                &pending,
1120                container.rect(),
1121                current_y,
1122                self.config.justification,
1123                true,
1124            );
1125            if line.rect().bottom().value() <= bottom {
1126                lines.push(line);
1127            } else {
1128                overflowed = true;
1129            }
1130        }
1131
1132        if let Some(max_lines) = container.max_lines()
1133            && lines.len() > max_lines
1134        {
1135            lines.truncate(max_lines);
1136            overflowed = true;
1137        }
1138
1139        let max_width = lines
1140            .iter()
1141            .map(|line| line.rect().width.value())
1142            .fold(0.0_f32, f32::max);
1143        let total_height = lines
1144            .last()
1145            .map(|line| line.rect().bottom().value() - container.rect().y.value())
1146            .unwrap_or(0.0);
1147
1148        Ok(TextLayout {
1149            lines,
1150            runs,
1151            bounds: TextRect::from_values(
1152                container.rect().x.value(),
1153                container.rect().y.value(),
1154                max_width,
1155                total_height,
1156            ),
1157            overflowed,
1158        })
1159    }
1160
1161    fn split_token_to_fit(
1162        &self,
1163        run: &TextRun,
1164        token: &Token,
1165        available_width: f32,
1166        font_store: Option<&FontStore>,
1167    ) -> Option<(PendingFragment, Option<PendingFragment>)> {
1168        if available_width <= 0.0 {
1169            return None;
1170        }
1171
1172        if let Some((head, tail)) =
1173            self.try_hyphenate_token(run, token, available_width, font_store)
1174        {
1175            return Some((head, Some(tail)));
1176        }
1177
1178        if matches!(
1179            self.config.line_breaking,
1180            LineBreaking::CharacterBoundary | LineBreaking::WordBoundaryOrCharacter
1181        ) {
1182            return split_token_by_characters(run, token, available_width);
1183        }
1184
1185        None
1186    }
1187
1188    fn try_hyphenate_token(
1189        &self,
1190        run: &TextRun,
1191        token: &Token,
1192        available_width: f32,
1193        font_store: Option<&FontStore>,
1194    ) -> Option<(PendingFragment, PendingFragment)> {
1195        let WordHyphenation::Enabled {
1196            min_word_chars,
1197            marker,
1198        } = self.config.word_hyphenation
1199        else {
1200            return None;
1201        };
1202
1203        let store = font_store?;
1204        if token.text.chars().count() < min_word_chars {
1205            return None;
1206        }
1207
1208        let parts = store.hyphenate(&token.text);
1209        if parts.len() <= 1 {
1210            return None;
1211        }
1212
1213        let mut byte_count = 0;
1214        let mut head_text = String::new();
1215        let mut best_split = None;
1216        for (index, part) in parts.iter().enumerate().take(parts.len().saturating_sub(1)) {
1217            head_text.push_str(part);
1218            byte_count += part.len();
1219            let candidate = format!("{head_text}{marker}");
1220            let width = measure_text(&candidate, run.font_size, run.letter_spacing);
1221            if width <= available_width {
1222                best_split = Some((index + 1, byte_count, candidate, width));
1223            } else {
1224                break;
1225            }
1226        }
1227
1228        let (split_index, byte_count, candidate, width) = best_split?;
1229        let remainder = parts[split_index..].join("");
1230        let head = PendingFragment {
1231            range: TextRange::new(token.range.start(), token.range.start() + byte_count),
1232            text: candidate,
1233            width: Pt::new(width),
1234            direction: run.direction,
1235            script: run.script,
1236            font: run.resolved_font.clone(),
1237            font_size: run.font_size,
1238            decorations: run.decorations.clone(),
1239            inserted_hyphen: true,
1240            is_whitespace: false,
1241        };
1242        let tail = PendingFragment {
1243            range: TextRange::new(token.range.start() + byte_count, token.range.end()),
1244            width: Pt::new(measure_text(&remainder, run.font_size, run.letter_spacing)),
1245            text: remainder,
1246            direction: run.direction,
1247            script: run.script,
1248            font: run.resolved_font.clone(),
1249            font_size: run.font_size,
1250            decorations: run.decorations.clone(),
1251            inserted_hyphen: false,
1252            is_whitespace: false,
1253        };
1254        Some((head, tail))
1255    }
1256}
1257
1258#[derive(Clone, Debug)]
1259struct CanonicalRun {
1260    range: TextRange,
1261    text: String,
1262    attributes: TextAttributes,
1263}
1264
1265#[derive(Clone, Debug)]
1266struct ItemizedRun {
1267    range: TextRange,
1268    text: String,
1269}
1270
1271#[derive(Clone, Debug)]
1272struct Token {
1273    range: TextRange,
1274    text: String,
1275    width: Pt,
1276    is_whitespace: bool,
1277    is_newline: bool,
1278}
1279
1280#[derive(Clone, Debug)]
1281struct PendingFragment {
1282    range: TextRange,
1283    text: String,
1284    width: Pt,
1285    direction: TextDirection,
1286    script: Script,
1287    font: FontDescriptor,
1288    font_size: Pt,
1289    decorations: Vec<TextDecoration>,
1290    inserted_hyphen: bool,
1291    is_whitespace: bool,
1292}
1293
1294impl PendingFragment {
1295    fn from_token(run: &TextRun, token: Token) -> Self {
1296        Self {
1297            range: token.range,
1298            text: token.text,
1299            width: token.width,
1300            direction: run.direction,
1301            script: run.script,
1302            font: run.resolved_font.clone(),
1303            font_size: run.font_size,
1304            decorations: run.decorations.clone(),
1305            inserted_hyphen: false,
1306            is_whitespace: token.is_whitespace,
1307        }
1308    }
1309}
1310
1311fn validate_font_size(font_size: Pt) -> Result<()> {
1312    if font_size.value() <= 0.0 {
1313        Err(Error::InvalidFontSize {
1314            size: font_size.value(),
1315        })
1316    } else {
1317        Ok(())
1318    }
1319}
1320
1321fn harmonize_descriptor(descriptor: FontDescriptor, requested: &FontDescriptor) -> FontDescriptor {
1322    descriptor
1323        .with_style(requested.font_style())
1324        .with_weight(requested.font_weight())
1325}
1326
1327fn tokenize_run(run: &TextRun, strategy: LineBreaking) -> Vec<Token> {
1328    match strategy {
1329        LineBreaking::CharacterBoundary => run
1330            .text
1331            .char_indices()
1332            .map(|(offset, character)| {
1333                let end = offset + character.len_utf8();
1334                Token {
1335                    range: TextRange::new(run.range.start() + offset, run.range.start() + end),
1336                    text: character.to_string(),
1337                    width: Pt::new(measure_text(
1338                        &character.to_string(),
1339                        run.font_size,
1340                        run.letter_spacing,
1341                    )),
1342                    is_whitespace: character.is_whitespace() && character != '\n',
1343                    is_newline: character == '\n',
1344                }
1345            })
1346            .collect(),
1347        LineBreaking::WordBoundary | LineBreaking::WordBoundaryOrCharacter => {
1348            let mut tokens = Vec::new();
1349            let mut start = None;
1350            let mut current_kind = None::<TokenKind>;
1351
1352            for (offset, character) in run.text.char_indices() {
1353                let kind = if character == '\n' {
1354                    TokenKind::Newline
1355                } else if character.is_whitespace() {
1356                    TokenKind::Whitespace
1357                } else {
1358                    TokenKind::Word
1359                };
1360
1361                if current_kind.is_none() {
1362                    start = Some(offset);
1363                    current_kind = Some(kind);
1364                    continue;
1365                }
1366
1367                if current_kind != Some(kind) || kind == TokenKind::Newline {
1368                    if let (Some(token_start), Some(token_kind)) = (start, current_kind) {
1369                        push_token(&mut tokens, run, token_start, offset, token_kind);
1370                    }
1371                    start = Some(offset);
1372                    current_kind = Some(kind);
1373                }
1374
1375                if kind == TokenKind::Newline {
1376                    if let Some(token_start) = start {
1377                        push_token(
1378                            &mut tokens,
1379                            run,
1380                            token_start,
1381                            offset + character.len_utf8(),
1382                            TokenKind::Newline,
1383                        );
1384                    }
1385                    start = None;
1386                    current_kind = None;
1387                }
1388            }
1389
1390            if let (Some(token_start), Some(token_kind)) = (start, current_kind) {
1391                push_token(&mut tokens, run, token_start, run.text.len(), token_kind);
1392            }
1393
1394            tokens
1395        }
1396    }
1397}
1398
1399#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1400enum TokenKind {
1401    Word,
1402    Whitespace,
1403    Newline,
1404}
1405
1406fn push_token(tokens: &mut Vec<Token>, run: &TextRun, start: usize, end: usize, kind: TokenKind) {
1407    let text = run.text[start..end].to_string();
1408    tokens.push(Token {
1409        range: TextRange::new(run.range.start() + start, run.range.start() + end),
1410        width: Pt::new(measure_text(&text, run.font_size, run.letter_spacing)),
1411        is_whitespace: kind == TokenKind::Whitespace,
1412        is_newline: kind == TokenKind::Newline,
1413        text,
1414    });
1415}
1416
1417fn split_token_by_characters(
1418    run: &TextRun,
1419    token: &Token,
1420    available_width: f32,
1421) -> Option<(PendingFragment, Option<PendingFragment>)> {
1422    let mut last_fit = None;
1423
1424    for (offset, _) in token.text.char_indices().skip(1) {
1425        let head = &token.text[..offset];
1426        let width = measure_text(head, run.font_size, run.letter_spacing);
1427        match width.partial_cmp(&available_width) {
1428            Some(Ordering::Greater) => break,
1429            _ => {
1430                last_fit = Some((offset, width));
1431            }
1432        }
1433    }
1434
1435    let (offset, width) = last_fit?;
1436    let head = PendingFragment {
1437        range: TextRange::new(token.range.start(), token.range.start() + offset),
1438        text: token.text[..offset].to_string(),
1439        width: Pt::new(width),
1440        direction: run.direction,
1441        script: run.script,
1442        font: run.resolved_font.clone(),
1443        font_size: run.font_size,
1444        decorations: run.decorations.clone(),
1445        inserted_hyphen: false,
1446        is_whitespace: false,
1447    };
1448    let tail_text = token.text[offset..].to_string();
1449    let tail = if tail_text.is_empty() {
1450        None
1451    } else {
1452        Some(PendingFragment {
1453            range: TextRange::new(token.range.start() + offset, token.range.end()),
1454            width: Pt::new(measure_text(&tail_text, run.font_size, run.letter_spacing)),
1455            text: tail_text,
1456            direction: run.direction,
1457            script: run.script,
1458            font: run.resolved_font.clone(),
1459            font_size: run.font_size,
1460            decorations: run.decorations.clone(),
1461            inserted_hyphen: false,
1462            is_whitespace: false,
1463        })
1464    };
1465
1466    Some((head, tail))
1467}
1468
1469fn finalize_line(
1470    pending: &[PendingFragment],
1471    container: TextRect,
1472    line_y: f32,
1473    justification: Justification,
1474    is_last_line: bool,
1475) -> LineFragment {
1476    let natural_width = pending
1477        .iter()
1478        .map(|fragment| fragment.width.value())
1479        .sum::<f32>();
1480    let max_font_size = pending
1481        .iter()
1482        .map(|fragment| fragment.font_size.value())
1483        .fold(0.0_f32, f32::max);
1484    let line_height = if max_font_size > 0.0 {
1485        max_font_size * 1.2
1486    } else {
1487        0.0
1488    };
1489    let baseline = line_y + (max_font_size * 0.8);
1490    let direction = pending
1491        .iter()
1492        .find(|fragment| !fragment.is_whitespace)
1493        .map(|fragment| fragment.direction)
1494        .unwrap_or(TextDirection::Ltr);
1495    let available_width = container.width.value();
1496    let whitespace_slots = pending
1497        .iter()
1498        .filter(|fragment| fragment.is_whitespace)
1499        .count();
1500    let justify_fully =
1501        justification == Justification::Full && !is_last_line && whitespace_slots > 0;
1502    let extra_per_whitespace = if justify_fully && available_width > natural_width {
1503        (available_width - natural_width) / whitespace_slots as f32
1504    } else {
1505        0.0
1506    };
1507    let line_width = if justify_fully {
1508        available_width
1509    } else {
1510        natural_width.min(available_width.max(natural_width))
1511    };
1512    let slack = (available_width - natural_width).max(0.0);
1513
1514    let mut cursor = match (direction, justification) {
1515        (TextDirection::Ltr, Justification::End) => container.x.value() + slack,
1516        (TextDirection::Ltr, Justification::Center) => container.x.value() + (slack / 2.0),
1517        (TextDirection::Rtl, Justification::End) => container.right().value() - slack,
1518        (TextDirection::Rtl, Justification::Center) => container.right().value() - (slack / 2.0),
1519        (TextDirection::Rtl, _) => container.right().value(),
1520        _ => container.x.value(),
1521    };
1522
1523    let mut fragments = Vec::with_capacity(pending.len());
1524    for fragment in pending {
1525        let extra_width = if justify_fully && fragment.is_whitespace {
1526            extra_per_whitespace
1527        } else {
1528            0.0
1529        };
1530        let fragment_width = fragment.width.value() + extra_width;
1531        let x = match direction {
1532            TextDirection::Ltr => {
1533                let x = cursor;
1534                cursor += fragment_width;
1535                x
1536            }
1537            TextDirection::Rtl => {
1538                cursor -= fragment_width;
1539                cursor
1540            }
1541        };
1542
1543        fragments.push(TextFragment {
1544            range: fragment.range,
1545            text: fragment.text.clone(),
1546            rect: TextRect::from_values(x, line_y, fragment_width, line_height),
1547            baseline: Pt::new(baseline),
1548            direction: fragment.direction,
1549            script: fragment.script,
1550            font: fragment.font.clone(),
1551            font_size: fragment.font_size,
1552            decorations: fragment.decorations.clone(),
1553            inserted_hyphen: fragment.inserted_hyphen,
1554            whitespace: fragment.is_whitespace,
1555        });
1556    }
1557
1558    LineFragment {
1559        rect: TextRect::from_values(container.x.value(), line_y, line_width, line_height),
1560        baseline: Pt::new(baseline),
1561        direction,
1562        justification,
1563        fragments,
1564    }
1565}
1566
1567fn measure_text(text: &str, font_size: Pt, letter_spacing: Pt) -> f32 {
1568    let mut width = 0.0_f32;
1569    let mut characters = 0_usize;
1570
1571    for character in text.chars() {
1572        width += glyph_advance(character, font_size);
1573        characters += 1;
1574    }
1575
1576    if characters > 1 {
1577        width += letter_spacing.value() * (characters.saturating_sub(1) as f32);
1578    }
1579
1580    width
1581}
1582
1583fn glyph_advance(character: char, font_size: Pt) -> f32 {
1584    let multiplier = if character == ' ' {
1585        0.33
1586    } else if character == '\t' {
1587        1.32
1588    } else if matches!(detect_script(character), Script::Han) {
1589        1.0
1590    } else if matches!(detect_script(character), Script::Arabic | Script::Hebrew) {
1591        0.68
1592    } else if character.is_ascii_uppercase() {
1593        0.62
1594    } else if character.is_ascii_punctuation() {
1595        0.35
1596    } else if character.is_ascii_digit() {
1597        0.55
1598    } else if character.is_alphabetic() {
1599        0.56
1600    } else {
1601        0.5
1602    };
1603
1604    font_size.value() * multiplier
1605}
1606
1607fn resolve_direction(explicit: Option<TextDirection>, bidi: BidiMode, text: &str) -> TextDirection {
1608    if let Some(direction) = explicit {
1609        return direction;
1610    }
1611
1612    match bidi {
1613        BidiMode::LeftToRight => TextDirection::Ltr,
1614        BidiMode::RightToLeft => TextDirection::Rtl,
1615        BidiMode::Auto => text
1616            .chars()
1617            .find_map(direction_for_char)
1618            .unwrap_or(TextDirection::Ltr),
1619    }
1620}
1621
1622fn has_strong_direction(text: &str) -> bool {
1623    text.chars()
1624        .any(|character| direction_for_char(character).is_some())
1625}
1626
1627fn direction_for_char(character: char) -> Option<TextDirection> {
1628    match detect_script(character) {
1629        Script::Arabic | Script::Hebrew => Some(TextDirection::Rtl),
1630        Script::Latin | Script::Cyrillic | Script::Han => Some(TextDirection::Ltr),
1631        Script::Common | Script::Unknown => None,
1632    }
1633}
1634
1635fn detect_script_in_text(text: &str) -> Script {
1636    text.chars()
1637        .map(detect_script)
1638        .find(|script| *script != Script::Common)
1639        .unwrap_or(Script::Common)
1640}
1641
1642fn detect_script(character: char) -> Script {
1643    let code = character as u32;
1644
1645    if character.is_ascii_alphabetic() {
1646        Script::Latin
1647    } else if character.is_whitespace()
1648        || character.is_ascii_punctuation()
1649        || character.is_ascii_digit()
1650    {
1651        Script::Common
1652    } else if (0x0600..=0x06ff).contains(&code)
1653        || (0x0750..=0x077f).contains(&code)
1654        || (0x08a0..=0x08ff).contains(&code)
1655    {
1656        Script::Arabic
1657    } else if (0x0590..=0x05ff).contains(&code) {
1658        Script::Hebrew
1659    } else if (0x0400..=0x052f).contains(&code) {
1660        Script::Cyrillic
1661    } else if (0x4e00..=0x9fff).contains(&code)
1662        || (0x3400..=0x4dbf).contains(&code)
1663        || (0xf900..=0xfaff).contains(&code)
1664    {
1665        Script::Han
1666    } else if character.is_alphabetic() {
1667        Script::Latin
1668    } else {
1669        Script::Unknown
1670    }
1671}
1672
1673#[cfg(test)]
1674mod tests {
1675    use super::*;
1676    use graphitepdf_font::FontStore;
1677
1678    #[test]
1679    fn converts_text_block_into_attributed_string_runs() {
1680        let block = TextBlock::new([
1681            TextSpan::new("Hello")
1682                .expect("valid span")
1683                .with_font(FontDescriptor::new("Helvetica"))
1684                .with_font_size(Pt::new(14.0))
1685                .expect("valid font size"),
1686            TextSpan::new(" world")
1687                .expect("valid span")
1688                .with_font_size(Pt::new(12.0))
1689                .expect("valid font size"),
1690        ]);
1691
1692        let attributed = block
1693            .to_attributed_string()
1694            .expect("block should convert to attributed text");
1695
1696        assert_eq!(attributed.text(), "Hello world");
1697        assert_eq!(attributed.runs().len(), 2);
1698        assert_eq!(attributed.runs()[0].range(), TextRange::new(0, 5));
1699        assert_eq!(
1700            attributed.runs()[0]
1701                .attributes()
1702                .font()
1703                .map(|font| font.family().to_string()),
1704            Some(String::from("Helvetica"))
1705        );
1706    }
1707
1708    #[test]
1709    fn itemizes_scripts_and_substitutes_missing_fonts() {
1710        let store = FontStore::new();
1711        let attributes = TextAttributes::default()
1712            .with_font(FontDescriptor::new("MissingFamily"))
1713            .with_font_size(Pt::new(12.0))
1714            .expect("font size is valid");
1715        let attributed = AttributedString::new("Hello مرحبا")
1716            .expect("text should be valid")
1717            .with_default_attributes(attributes)
1718            .expect("default attributes should be valid");
1719        let container = TextContainer::new(TextRect::from_values(0.0, 0.0, 200.0, 80.0))
1720            .expect("container should be valid");
1721        let engine = TextEngine::new(TextEngineConfig {
1722            font_substitution: FontSubstitution::FallbackFamilies(vec![FontDescriptor::new(
1723                "Helvetica",
1724            )]),
1725            ..TextEngineConfig::default()
1726        });
1727
1728        let layout = engine
1729            .layout(&attributed, &container, Some(&store))
1730            .expect("layout should succeed with fallback fonts");
1731
1732        assert!(layout.runs().len() >= 2);
1733        assert_eq!(layout.runs()[0].script(), Script::Latin);
1734        assert!(
1735            layout
1736                .runs()
1737                .iter()
1738                .any(|run| run.script() == Script::Arabic)
1739        );
1740        assert!(
1741            layout
1742                .runs()
1743                .iter()
1744                .all(|run| run.resolved_font().family() == "Helvetica")
1745        );
1746    }
1747
1748    #[test]
1749    fn hyphenates_long_words_in_narrow_containers() {
1750        let mut store = FontStore::new();
1751        store.register_hyphenation_callback(|word| {
1752            if word == "graphitepdf" {
1753                vec!["graph".into(), "ite".into(), "pdf".into()]
1754            } else {
1755                vec![word.to_string()]
1756            }
1757        });
1758
1759        let attributed = AttributedString::new("graphitepdf")
1760            .expect("text should be valid")
1761            .with_default_attributes(
1762                TextAttributes::default()
1763                    .with_font(FontDescriptor::new("Helvetica"))
1764                    .with_font_size(Pt::new(12.0))
1765                    .expect("font size is valid"),
1766            )
1767            .expect("default attributes should be valid");
1768        let container = TextContainer::new(TextRect::from_values(0.0, 0.0, 40.0, 100.0))
1769            .expect("container should be valid");
1770        let engine = TextEngine::new(TextEngineConfig {
1771            word_hyphenation: WordHyphenation::Enabled {
1772                min_word_chars: 6,
1773                marker: '-',
1774            },
1775            line_breaking: LineBreaking::WordBoundaryOrCharacter,
1776            ..TextEngineConfig::default()
1777        });
1778
1779        let layout = engine
1780            .layout(&attributed, &container, Some(&store))
1781            .expect("hyphenated layout should succeed");
1782
1783        assert!(layout.lines().len() >= 2);
1784        assert_eq!(layout.lines()[0].fragments()[0].text(), "graph-");
1785        assert!(layout.lines()[0].fragments()[0].inserted_hyphen());
1786    }
1787
1788    #[test]
1789    fn fully_justifies_intermediate_lines_and_preserves_decorations() {
1790        let store = FontStore::new();
1791        let attributed = AttributedString::new("rust text layout")
1792            .expect("text should be valid")
1793            .with_default_attributes(
1794                TextAttributes::default()
1795                    .with_font(FontDescriptor::new("Helvetica"))
1796                    .with_font_size(Pt::new(12.0))
1797                    .expect("font size is valid")
1798                    .with_decoration(TextDecoration::new(TextDecorationKind::Underline)),
1799            )
1800            .expect("default attributes should be valid");
1801        let container = TextContainer::new(TextRect::from_values(0.0, 0.0, 75.0, 100.0))
1802            .expect("container should be valid");
1803        let engine = TextEngine::new(TextEngineConfig {
1804            justification: Justification::Full,
1805            ..TextEngineConfig::default()
1806        });
1807
1808        let layout = engine
1809            .layout(&attributed, &container, Some(&store))
1810            .expect("layout should succeed");
1811
1812        assert!(layout.lines().len() >= 2);
1813        assert_eq!(layout.lines()[0].rect().width.value(), 75.0);
1814        assert!(
1815            layout.lines()[0]
1816                .fragments()
1817                .iter()
1818                .all(|fragment| !fragment.decorations().is_empty())
1819        );
1820    }
1821
1822    #[test]
1823    fn rtl_layout_places_fragments_from_right_to_left() {
1824        let store = FontStore::new();
1825        let attributed = AttributedString::new("مرحبا بكم")
1826            .expect("text should be valid")
1827            .with_default_attributes(
1828                TextAttributes::default()
1829                    .with_font(FontDescriptor::new("Helvetica"))
1830                    .with_font_size(Pt::new(12.0))
1831                    .expect("font size is valid"),
1832            )
1833            .expect("default attributes should be valid");
1834        let container = TextContainer::new(TextRect::from_values(0.0, 0.0, 120.0, 60.0))
1835            .expect("container should be valid");
1836        let engine = TextEngine::new(TextEngineConfig {
1837            bidi: BidiMode::RightToLeft,
1838            ..TextEngineConfig::default()
1839        });
1840
1841        let layout = engine
1842            .layout(&attributed, &container, Some(&store))
1843            .expect("layout should succeed");
1844        let first_line = &layout.lines()[0];
1845
1846        assert_eq!(first_line.direction(), TextDirection::Rtl);
1847        assert!(
1848            first_line.fragments()[0].rect().x.value() > first_line.fragments()[2].rect().x.value()
1849        );
1850    }
1851}