Skip to main content

text_typeset/shaping/
shaper.rs

1use harfrust::{Direction, Feature, FontRef, ShapeOptions, Tag, UnicodeBuffer};
2
3use crate::font::registry::FontRegistry;
4use crate::font::resolve::ResolvedFont;
5use crate::shaping::run::{ShapedGlyph, ShapedRun};
6use crate::types::FontFeature;
7
8/// Convert public [`FontFeature`] toggles into harfrust [`Feature`]s,
9/// applied across the whole shaped string (global range). Script-mandated
10/// features apply regardless; these are the discretionary toggles.
11pub fn to_harfrust_features(features: &[FontFeature]) -> Vec<Feature> {
12    features
13        .iter()
14        .map(|f| Feature::new(Tag::new(&f.tag), f.value, ..))
15        .collect()
16}
17
18/// Read units-per-em for a font face.
19///
20/// `harfrust::FontRef` is a thin wrapper over read-fonts and exposes
21/// the `head` table only through the `read_fonts::TableProvider` trait,
22/// which harfrust doesn't re-export. Since we already depend on swash
23/// for `font_metrics_px` further down, we reuse swash's `Metrics` to
24/// pull UPEM — one less dependency surface to maintain.
25fn units_per_em(bytes: &[u8], face_index: u32) -> Option<u16> {
26    let font_ref = swash::FontRef::from_index(bytes, face_index as usize)?;
27    let upem = font_ref.metrics(&[]).units_per_em;
28    if upem == 0 { None } else { Some(upem) }
29}
30
31/// Text direction for shaping.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum TextDirection {
34    /// Auto-detect from text content (default).
35    #[default]
36    Auto,
37    LeftToRight,
38    RightToLeft,
39}
40
41/// Shape a text string with the given resolved font.
42///
43/// Returns a ShapedRun with glyph IDs and pixel-space positions.
44/// The `text_offset` is the byte offset of this text within the block
45/// (used for cluster mapping back to document positions).
46/// Shape a text string with automatic glyph fallback.
47///
48/// After shaping with the primary font, any .notdef glyphs (glyph_id==0)
49/// are detected and re-shaped with fallback fonts. If no fallback font
50/// covers a character, it remains as .notdef (renders as blank space
51/// with correct advance).
52pub fn shape_text(
53    registry: &FontRegistry,
54    resolved: &ResolvedFont,
55    text: &str,
56    text_offset: usize,
57) -> Option<ShapedRun> {
58    shape_text_with_fallback(
59        registry,
60        resolved,
61        text,
62        text_offset,
63        TextDirection::Auto,
64        &[],
65    )
66}
67
68/// Shape text with an explicit direction and glyph fallback.
69///
70/// Like `shape_text`, but caller supplies the direction instead of letting
71/// rustybuzz guess. Used by the bidi-aware layout path, which splits text
72/// into directional runs before shaping.
73pub fn shape_text_with_fallback(
74    registry: &FontRegistry,
75    resolved: &ResolvedFont,
76    text: &str,
77    text_offset: usize,
78    direction: TextDirection,
79    features: &[Feature],
80) -> Option<ShapedRun> {
81    let mut run = shape_text_directed(registry, resolved, text, text_offset, direction, features)?;
82
83    // Check for .notdef glyphs and attempt fallback
84    if run.glyphs.iter().any(|g| g.glyph_id == 0) && !text.is_empty() {
85        apply_glyph_fallback(registry, resolved, text, text_offset, features, &mut run);
86    }
87
88    Some(run)
89}
90
91/// Re-shape .notdef glyphs using fallback fonts.
92///
93/// Works on **spans** of consecutive .notdef glyphs, not on glyphs one at a
94/// time: each span is mapped back to the character range that produced it and
95/// that whole range is re-shaped in the fallback font, then spliced in.
96///
97/// Shaping a span as a unit is what makes complex scripts survive fallback.
98/// Re-shaping character by character — which this used to do — cannot join
99/// Arabic, because a letter shaped alone has no neighbours and can only take
100/// its isolated form. It also kept only the *first* glyph of each result, so
101/// any letter whose isolated form is a base plus a mark lost the mark. An
102/// Arabic word set in a font without Arabic came out as disconnected,
103/// dotless stumps.
104///
105/// A span is re-shaped with the run's own direction rather than `Auto`, so
106/// the substituted glyphs come back in the same visual order as the ones
107/// around them.
108fn apply_glyph_fallback(
109    registry: &FontRegistry,
110    primary: &ResolvedFont,
111    text: &str,
112    text_offset: usize,
113    features: &[Feature],
114    run: &mut ShapedRun,
115) {
116    use crate::font::resolve::find_fallback_font;
117
118    // Maximal spans of consecutive .notdef glyphs, as index ranges.
119    let mut spans: Vec<std::ops::Range<usize>> = Vec::new();
120    let mut i = 0;
121    while i < run.glyphs.len() {
122        if run.glyphs[i].glyph_id == 0 {
123            let start = i;
124            while i < run.glyphs.len() && run.glyphs[i].glyph_id == 0 {
125                i += 1;
126            }
127            spans.push(start..i);
128        } else {
129            i += 1;
130        }
131    }
132    if spans.is_empty() {
133        return;
134    }
135
136    // Splice from the back so earlier ranges stay valid as lengths change.
137    for span in spans.into_iter().rev() {
138        let Some((byte_start, byte_end)) = notdef_char_range(&run.glyphs, &span, text) else {
139            continue;
140        };
141        let Some(slice) = text.get(byte_start..byte_end) else {
142            continue;
143        };
144        let Some(first_char) = slice.chars().next() else {
145            continue;
146        };
147
148        let Some(fallback_id) = find_fallback_font(registry, first_char, primary.font_face_id)
149        else {
150            continue; // no fallback available — leave the span as .notdef
151        };
152        let Some(fallback_entry) = registry.get(fallback_id) else {
153            continue;
154        };
155
156        let fallback_resolved = ResolvedFont {
157            font_face_id: fallback_id,
158            size_px: primary.size_px,
159            face_index: fallback_entry.face_index,
160            swash_cache_key: fallback_entry.swash_cache_key,
161            scale_factor: primary.scale_factor,
162            weight: primary.weight,
163        };
164
165        let Some(fallback_run) = shape_text_directed(
166            registry,
167            &fallback_resolved,
168            slice,
169            text_offset + byte_start,
170            run.direction,
171            features,
172        ) else {
173            continue;
174        };
175        if fallback_run.glyphs.is_empty() {
176            continue;
177        }
178
179        // Clusters come back local to `slice`; lift them into `text` space,
180        // which is what the rest of the pipeline expects of this run.
181        let replacement: Vec<ShapedGlyph> = fallback_run
182            .glyphs
183            .into_iter()
184            .map(|mut g| {
185                g.cluster += byte_start as u32;
186                g.font_face_id = fallback_id;
187                g
188            })
189            .collect();
190
191        run.glyphs.splice(span, replacement);
192    }
193
194    run.advance_width = run.glyphs.iter().map(|g| g.x_advance).sum();
195}
196
197/// The byte range of `text` that a span of consecutive .notdef glyphs covers.
198///
199/// Glyphs sit in visual order, so clusters ascend across an LTR run and
200/// descend across an RTL one. Taking the min and max over the span rather
201/// than its first and last glyph keeps this direction-agnostic.
202///
203/// The span's end is the nearest cluster *after* it among the glyphs outside
204/// it — the start of whatever the primary font did manage to shape — or the
205/// end of the text when the span runs to the edge.
206fn notdef_char_range(
207    glyphs: &[ShapedGlyph],
208    span: &std::ops::Range<usize>,
209    text: &str,
210) -> Option<(usize, usize)> {
211    let inside = glyphs.get(span.clone())?;
212    let start = inside.iter().map(|g| g.cluster as usize).min()?;
213    let last = inside.iter().map(|g| g.cluster as usize).max()?;
214
215    let end = glyphs
216        .iter()
217        .enumerate()
218        .filter(|(i, _)| !span.contains(i))
219        .map(|(_, g)| g.cluster as usize)
220        .filter(|&c| c > last)
221        .min()
222        .unwrap_or(text.len());
223
224    if start >= end || !text.is_char_boundary(start) || !text.is_char_boundary(end) {
225        return None;
226    }
227    Some((start, end))
228}
229
230/// Shape text with an explicit direction.
231pub fn shape_text_directed(
232    registry: &FontRegistry,
233    resolved: &ResolvedFont,
234    text: &str,
235    text_offset: usize,
236    direction: TextDirection,
237    features: &[Feature],
238) -> Option<ShapedRun> {
239    let entry = registry.get(resolved.font_face_id)?;
240    let font = FontRef::from_index(entry.bytes(), entry.face_index).ok()?;
241
242    let upem = units_per_em(entry.bytes(), entry.face_index).unwrap_or(0) as f32;
243    if upem == 0.0 {
244        return None;
245    }
246    // Shape at physical ppem, then divide results by scale_factor so
247    // downstream layout stays in logical pixels. See ResolvedFont.
248    let sf = resolved.scale_factor.max(f32::MIN_POSITIVE);
249    let physical_size = resolved.size_px * sf;
250    let physical_scale = physical_size / upem;
251    let inv_sf = 1.0 / sf;
252
253    let mut buffer = UnicodeBuffer::new();
254    buffer.push_str(text);
255    match direction {
256        TextDirection::LeftToRight => buffer.set_direction(Direction::LeftToRight),
257        TextDirection::RightToLeft => buffer.set_direction(Direction::RightToLeft),
258        TextDirection::Auto => {}
259    }
260    // Always guess, including after an explicit set_direction. The guess
261    // only fills in what is still unset — it assigns `script` when it is
262    // `None` and `direction` when it is `Invalid` — so the caller's
263    // direction survives untouched and the script gets populated.
264    //
265    // That script is what picks the shaper: with `script: None` harfrust
266    // falls back to DEFAULT_SHAPER, which never requests init/medi/fina/
267    // isol, and Arabic comes out in disconnected isolated forms. Setting
268    // it selects ARABIC_SHAPER and the letters join. It also keeps
269    // harfrust from panicking on a still-Invalid direction in the Auto
270    // case, which is why this call used to live in that branch alone.
271    buffer.guess_segment_properties();
272
273    // Resolve the concrete direction (Auto is now decided by the buffer).
274    // Stored on the run so hit-testing knows RTL glyph order.
275    let resolved_direction = if buffer.direction() == Direction::RightToLeft {
276        TextDirection::RightToLeft
277    } else {
278        TextDirection::LeftToRight
279    };
280
281    // ShaperData preprocesses font tables for shaping. It's built once
282    // per face and cached on the FontEntry, so repeated shape calls
283    // (every relayout/keystroke) reuse the same preprocessed tables.
284    let shaper_data = entry.shaper_data(&font);
285    let shaper = shaper_data.shaper(&font).build();
286    let glyph_buffer = shaper.shape(buffer, ShapeOptions::new().features(features));
287
288    let infos = glyph_buffer.glyph_infos();
289    let positions = glyph_buffer.glyph_positions();
290
291    let mut glyphs = Vec::with_capacity(infos.len());
292    let mut total_advance = 0.0f32;
293
294    for (info, pos) in infos.iter().zip(positions.iter()) {
295        let x_advance = pos.x_advance as f32 * physical_scale * inv_sf;
296        let y_advance = pos.y_advance as f32 * physical_scale * inv_sf;
297        let x_offset = pos.x_offset as f32 * physical_scale * inv_sf;
298        let y_offset = pos.y_offset as f32 * physical_scale * inv_sf;
299
300        glyphs.push(ShapedGlyph {
301            glyph_id: info.glyph_id as u16,
302            cluster: info.cluster,
303            x_advance,
304            y_advance,
305            x_offset,
306            y_offset,
307            font_face_id: resolved.font_face_id,
308        });
309
310        total_advance += x_advance;
311    }
312
313    Some(ShapedRun {
314        font_face_id: resolved.font_face_id,
315        size_px: resolved.size_px,
316        weight: resolved.weight,
317        glyphs,
318        advance_width: total_advance,
319        text_range: text_offset..text_offset + text.len(),
320        direction: resolved_direction,
321        bidi_level: if resolved_direction == TextDirection::RightToLeft {
322            1
323        } else {
324            0
325        },
326        underline_style: crate::types::UnderlineStyle::None,
327        overline: false,
328        strikeout: false,
329        is_link: false,
330        foreground_color: None,
331        underline_color: None,
332        background_color: None,
333        anchor_href: None,
334        tooltip: None,
335        vertical_alignment: crate::types::VerticalAlignment::Normal,
336        image_name: None,
337        image_height: 0.0,
338    })
339}
340
341/// Shape a text string, reusing a UnicodeBuffer to avoid allocations.
342pub fn shape_text_with_buffer(
343    registry: &FontRegistry,
344    resolved: &ResolvedFont,
345    text: &str,
346    text_offset: usize,
347    buffer: UnicodeBuffer,
348    features: &[Feature],
349) -> Option<(ShapedRun, UnicodeBuffer)> {
350    let entry = registry.get(resolved.font_face_id)?;
351    let font = FontRef::from_index(entry.bytes(), entry.face_index).ok()?;
352
353    let upem = units_per_em(entry.bytes(), entry.face_index).unwrap_or(0) as f32;
354    if upem == 0.0 {
355        return None;
356    }
357    let sf = resolved.scale_factor.max(f32::MIN_POSITIVE);
358    let physical_size = resolved.size_px * sf;
359    let physical_scale = physical_size / upem;
360    let inv_sf = 1.0 / sf;
361
362    let mut buffer = buffer;
363    buffer.push_str(text);
364    // Recycled buffers come back without segment properties; explicitly
365    // guess them so harfrust doesn't panic on Direction::Invalid.
366    buffer.guess_segment_properties();
367
368    let resolved_direction = if buffer.direction() == Direction::RightToLeft {
369        TextDirection::RightToLeft
370    } else {
371        TextDirection::LeftToRight
372    };
373
374    let shaper_data = entry.shaper_data(&font);
375    let shaper = shaper_data.shaper(&font).build();
376    let glyph_buffer = shaper.shape(buffer, ShapeOptions::new().features(features));
377
378    let infos = glyph_buffer.glyph_infos();
379    let positions = glyph_buffer.glyph_positions();
380
381    let mut glyphs = Vec::with_capacity(infos.len());
382    let mut total_advance = 0.0f32;
383
384    for (info, pos) in infos.iter().zip(positions.iter()) {
385        let x_advance = pos.x_advance as f32 * physical_scale * inv_sf;
386        let y_advance = pos.y_advance as f32 * physical_scale * inv_sf;
387        let x_offset = pos.x_offset as f32 * physical_scale * inv_sf;
388        let y_offset = pos.y_offset as f32 * physical_scale * inv_sf;
389
390        glyphs.push(ShapedGlyph {
391            glyph_id: info.glyph_id as u16,
392            cluster: info.cluster,
393            x_advance,
394            y_advance,
395            x_offset,
396            y_offset,
397            font_face_id: resolved.font_face_id,
398        });
399
400        total_advance += x_advance;
401    }
402
403    let run = ShapedRun {
404        font_face_id: resolved.font_face_id,
405        size_px: resolved.size_px,
406        weight: resolved.weight,
407        glyphs,
408        advance_width: total_advance,
409        text_range: text_offset..text_offset + text.len(),
410        direction: resolved_direction,
411        bidi_level: if resolved_direction == TextDirection::RightToLeft {
412            1
413        } else {
414            0
415        },
416        underline_style: crate::types::UnderlineStyle::None,
417        overline: false,
418        strikeout: false,
419        is_link: false,
420        foreground_color: None,
421        underline_color: None,
422        background_color: None,
423        anchor_href: None,
424        tooltip: None,
425        vertical_alignment: crate::types::VerticalAlignment::Normal,
426        image_name: None,
427        image_height: 0.0,
428    };
429
430    // Reclaim the buffer for reuse
431    let recycled = glyph_buffer.clear();
432    Some((run, recycled))
433}
434
435pub struct FontMetricsPx {
436    pub ascent: f32,
437    pub descent: f32,
438    pub leading: f32,
439    pub underline_offset: f32,
440    pub strikeout_offset: f32,
441    pub stroke_size: f32,
442}
443
444/// Get font metrics (ascent, descent, leading) scaled to logical pixels.
445///
446/// Scales at `size_px * scale_factor` (physical) and divides by
447/// `scale_factor`, so callers always see logical-pixel metrics.
448pub fn font_metrics_px(registry: &FontRegistry, resolved: &ResolvedFont) -> Option<FontMetricsPx> {
449    let entry = registry.get(resolved.font_face_id)?;
450    let font_ref = swash::FontRef::from_index(entry.bytes(), entry.face_index as usize)?;
451    let sf = resolved.scale_factor.max(f32::MIN_POSITIVE);
452    let physical_size = resolved.size_px * sf;
453    let metrics = font_ref.metrics(&[]).scale(physical_size);
454    let inv_sf = 1.0 / sf;
455
456    Some(FontMetricsPx {
457        ascent: metrics.ascent * inv_sf,
458        descent: metrics.descent * inv_sf,
459        leading: metrics.leading * inv_sf,
460        underline_offset: metrics.underline_offset * inv_sf,
461        strikeout_offset: metrics.strikeout_offset * inv_sf,
462        stroke_size: metrics.stroke_size * inv_sf,
463    })
464}
465
466/// A bidi run: a contiguous range of text with the same direction.
467pub struct BidiRun {
468    pub byte_range: std::ops::Range<usize>,
469    pub direction: TextDirection,
470    /// Visual order index (for reordering after line breaking).
471    pub visual_order: usize,
472    /// UAX #9 embedding level: even is LTR, odd is RTL.
473    ///
474    /// Rule L2 reorders by level, not by direction, and the two are not
475    /// interchangeable: a Latin phrase quoted inside Arabic inside an
476    /// English paragraph sits at level 2, and reordering it as though it
477    /// were level 0 puts it on the wrong side of the Arabic. `direction`
478    /// cannot tell those apart — `level` can.
479    pub level: u8,
480}
481
482/// A paragraph's bidi structure in **logical** order.
483///
484/// [`bidi_runs`] returns runs already reordered for display, which suits
485/// a single-line label that is shaped and painted in one go. The
486/// multi-line editor cannot use that: line breaking is a logical
487/// operation, so runs have to stay in logical order until the breaker has
488/// decided where the lines fall, and only then get reordered *per line*.
489/// Reordering the paragraph up front and breaking afterwards would
490/// scramble any paragraph that wraps.
491pub struct BidiParagraph {
492    /// Runs of uniform embedding level, in logical order.
493    pub runs: Vec<BidiRun>,
494    /// The paragraph embedding level actually used — the explicit base
495    /// direction when one was given, else the rule P2/P3 auto-detection.
496    pub para_level: u8,
497}
498
499impl BidiParagraph {
500    /// The paragraph's base direction, as resolved.
501    pub fn base_direction(&self) -> TextDirection {
502        if self.para_level % 2 == 1 {
503            TextDirection::RightToLeft
504        } else {
505            TextDirection::LeftToRight
506        }
507    }
508}
509
510/// The unicode-bidi paragraph level that expresses `base`.
511///
512/// `None` asks unicode-bidi to auto-detect (rules P2/P3: the first strong
513/// character wins, defaulting to LTR when the text has none). An explicit
514/// level overrides that — which is the point of honouring a stored
515/// paragraph direction, since P2/P3 incorrectly classifies any RTL
516/// paragraph that happens to open with a digit, a Latin acronym or an
517/// opening quote.
518fn base_para_level(base: TextDirection) -> Option<unicode_bidi::Level> {
519    match base {
520        TextDirection::Auto => None,
521        TextDirection::LeftToRight => Some(unicode_bidi::Level::ltr()),
522        TextDirection::RightToLeft => Some(unicode_bidi::Level::rtl()),
523    }
524}
525
526/// Resolve `text`'s bidi structure under an explicit or auto base direction.
527///
528/// Runs come back in logical order, each tagged with its embedding level.
529/// Callers shape each run with its own direction and later reorder the
530/// runs of each line with [`visual_order`].
531pub fn analyze_paragraph(text: &str, base: TextDirection) -> BidiParagraph {
532    use unicode_bidi::BidiInfo;
533
534    if text.is_empty() {
535        return BidiParagraph {
536            runs: Vec::new(),
537            para_level: base_para_level(base).map_or(0, |l| l.number()),
538        };
539    }
540
541    let bidi_info = BidiInfo::new(text, base_para_level(base));
542
543    // A block is one paragraph as far as layout is concerned; if the text
544    // somehow carries a hard break, the first paragraph's level is the
545    // one that governs alignment.
546    let para_level = bidi_info
547        .paragraphs
548        .first()
549        .map(|p| p.level.number())
550        .or_else(|| base_para_level(base).map(|l| l.number()))
551        .unwrap_or(0);
552
553    // Group consecutive characters of equal level. Iterating by
554    // `char_indices` rather than by byte keeps a run boundary from ever
555    // landing inside a multi-byte character.
556    let mut starts: Vec<(usize, u8)> = Vec::new();
557    for (idx, _) in text.char_indices() {
558        let level = bidi_info.levels[idx].number();
559        if starts.last().map(|&(_, l)| l) != Some(level) {
560            starts.push((idx, level));
561        }
562    }
563
564    // Each run ends where the next begins; the last ends with the text.
565    let ends = starts
566        .iter()
567        .skip(1)
568        .map(|&(start, _)| start)
569        .chain(std::iter::once(text.len()));
570
571    let runs: Vec<BidiRun> = starts
572        .iter()
573        .zip(ends)
574        .map(|(&(start, level), end)| BidiRun {
575            byte_range: start..end,
576            direction: if level % 2 == 1 {
577                TextDirection::RightToLeft
578            } else {
579                TextDirection::LeftToRight
580            },
581            // Always 0 here: these runs are in *logical* order and the
582            // block path reorders per line after breaking, so a
583            // paragraph-wide visual index would be both unused and
584            // misleading. `bidi_runs` fills it in for the single-line
585            // path, which does lay a paragraph out in one go.
586            visual_order: 0,
587            level,
588        })
589        .collect();
590
591    BidiParagraph { runs, para_level }
592}
593
594/// Reorder items from logical into visual order per UAX #9 rule L2.
595///
596/// Returns indices into `levels`: `result[0]` is the item to paint
597/// leftmost. L2 reads "from the highest level down to the lowest odd
598/// level, reverse any contiguous sequence of items at that level or
599/// higher", which is what the loop below does literally.
600pub fn visual_order(levels: &[u8]) -> Vec<usize> {
601    let mut order: Vec<usize> = (0..levels.len()).collect();
602    let Some(&max) = levels.iter().max() else {
603        return order;
604    };
605    // With no odd level nothing is RTL, so logical order is already visual.
606    let Some(min_odd) = levels.iter().copied().filter(|l| l % 2 == 1).min() else {
607        return order;
608    };
609
610    let mut level = max;
611    while level >= min_odd {
612        let mut i = 0;
613        while i < order.len() {
614            if levels[order[i]] >= level {
615                let start = i;
616                while i < order.len() && levels[order[i]] >= level {
617                    i += 1;
618                }
619                order[start..i].reverse();
620            } else {
621                i += 1;
622            }
623        }
624        // min_odd is odd, hence >= 1, so this cannot wrap past zero.
625        level -= 1;
626    }
627    order
628}
629
630/// Analyze text for bidirectional content and return directional runs
631/// in **visual order** per UAX #9 (Unicode Bidirectional Algorithm, rule L2).
632///
633/// The returned runs can be shaped independently and concatenated left-to-right
634/// to produce correctly-ordered mixed-script text (e.g. Latin embedded in
635/// Arabic). For pure-LTR text, returns a single LTR run. For pure-RTL text,
636/// returns a single RTL run.
637pub fn bidi_runs(text: &str) -> Vec<BidiRun> {
638    use unicode_bidi::BidiInfo;
639
640    if text.is_empty() {
641        return Vec::new();
642    }
643
644    let bidi_info = BidiInfo::new(text, None);
645    let mut runs = Vec::new();
646
647    for para in &bidi_info.paragraphs {
648        let (levels, level_runs) = bidi_info.visual_runs(para, para.range.clone());
649        for level_run in level_runs {
650            if level_run.is_empty() {
651                continue;
652            }
653            let level = levels[level_run.start];
654            let direction = if level.is_rtl() {
655                TextDirection::RightToLeft
656            } else {
657                TextDirection::LeftToRight
658            };
659            let visual_order = runs.len();
660            runs.push(BidiRun {
661                byte_range: level_run,
662                direction,
663                visual_order,
664                level: level.number(),
665            });
666        }
667    }
668
669    if runs.is_empty() {
670        runs.push(BidiRun {
671            byte_range: 0..text.len(),
672            direction: TextDirection::LeftToRight,
673            visual_order: 0,
674            level: 0,
675        });
676    }
677
678    runs
679}
680
681#[cfg(test)]
682mod bidi_tests {
683    use super::*;
684
685    const ARABIC: &str = "\u{0643}\u{062A}\u{0628}"; // كتب
686    const HEBREW: &str = "\u{05E9}\u{05DC}\u{05D5}\u{05DD}"; // שלום
687
688    #[test]
689    fn rule_l2_leaves_all_ltr_text_alone() {
690        assert_eq!(visual_order(&[0, 0, 0]), vec![0, 1, 2]);
691        assert_eq!(visual_order(&[]), Vec::<usize>::new());
692    }
693
694    #[test]
695    fn rule_l2_reverses_a_run_of_rtl() {
696        // level 0 "a", level 1 RTL, level 0 "b" -> the RTL span reverses
697        // in place but stays between its neighbours.
698        assert_eq!(visual_order(&[0, 1, 1, 0]), vec![0, 2, 1, 3]);
699    }
700
701    #[test]
702    fn rule_l2_nests_an_ltr_island_inside_rtl() {
703        // An English phrase (level 2) quoted inside Arabic (level 1)
704        // inside an English paragraph (level 0). The level-2 island must
705        // keep its own left-to-right order while the level-1 material
706        // around it reverses — the case `direction` alone cannot express.
707        let levels = [0, 1, 2, 2, 1, 0];
708        assert_eq!(visual_order(&levels), vec![0, 4, 2, 3, 1, 5]);
709    }
710
711    #[test]
712    fn rule_l2_reverses_the_whole_line_in_an_rtl_paragraph() {
713        // Level 1 throughout with a level-2 Latin word: the paragraph
714        // reads right-to-left, so logical item 0 paints rightmost.
715        assert_eq!(visual_order(&[1, 2, 1]), vec![2, 1, 0]);
716    }
717
718    #[test]
719    fn a_leading_digit_does_not_fool_auto_detection() {
720        // Worth pinning, because it is easy to assume otherwise: digits
721        // are type EN, not strong, so rule P2 skips them and finds the
722        // Arabic. An Arabic paragraph opening with a number needs no
723        // explicit direction.
724        let text = "123 \u{0643}\u{062A}\u{0628}";
725        assert_eq!(analyze_paragraph(text, TextDirection::Auto).para_level, 1);
726    }
727
728    #[test]
729    fn an_explicit_base_direction_overrides_first_strong_detection() {
730        // A Latin acronym *is* strong (type L), so rule P2 stops at the
731        // "NASA" and calls this Arabic paragraph left-to-right — the
732        // real false detection, and the reason a stored paragraph
733        // direction has to win over guessing.
734        let text = "NASA \u{0623}\u{0639}\u{0644}\u{0646}\u{062A}";
735        assert_eq!(
736            analyze_paragraph(text, TextDirection::Auto).para_level,
737            0,
738            "auto-detection is expected to get this one wrong"
739        );
740
741        let forced = analyze_paragraph(text, TextDirection::RightToLeft);
742        assert_eq!(forced.para_level, 1);
743        assert_eq!(forced.base_direction(), TextDirection::RightToLeft);
744
745        // And the override genuinely changes the layout, not just the
746        // recorded level: the Arabic now leads visually. Runs come back
747        // in logical order, so ask rule L2 which one paints leftmost —
748        // the same thing a caller laying the paragraph out would do.
749        let auto = analyze_paragraph(text, TextDirection::Auto);
750        let first_visual = |p: &BidiParagraph| {
751            let levels: Vec<u8> = p.runs.iter().map(|r| r.level).collect();
752            visual_order(&levels).first().map(|&i| p.runs[i].direction)
753        };
754        assert_eq!(first_visual(&auto), Some(TextDirection::LeftToRight));
755        assert_eq!(first_visual(&forced), Some(TextDirection::RightToLeft));
756    }
757
758    #[test]
759    fn pure_arabic_auto_detects_as_rtl() {
760        let para = analyze_paragraph(ARABIC, TextDirection::Auto);
761        assert_eq!(para.base_direction(), TextDirection::RightToLeft);
762        assert_eq!(para.runs.len(), 1);
763        assert_eq!(para.runs[0].direction, TextDirection::RightToLeft);
764        assert_eq!(para.runs[0].byte_range, 0..ARABIC.len());
765    }
766
767    #[test]
768    fn runs_come_back_in_logical_order_and_cover_the_text() {
769        // "hello <hebrew> world" — three runs, contiguous, logical order.
770        let text = format!("hello {HEBREW} world");
771        let para = analyze_paragraph(&text, TextDirection::Auto);
772
773        assert!(para.runs.len() >= 2, "expected a directional split");
774        assert_eq!(para.runs[0].byte_range.start, 0);
775        assert_eq!(para.runs.last().unwrap().byte_range.end, text.len());
776        for pair in para.runs.windows(2) {
777            assert_eq!(
778                pair[0].byte_range.end, pair[1].byte_range.start,
779                "runs must tile the text with no gap or overlap"
780            );
781            assert!(
782                pair[0].byte_range.start < pair[1].byte_range.start,
783                "runs must be in logical order"
784            );
785        }
786        assert!(
787            para.runs
788                .iter()
789                .any(|r| r.direction == TextDirection::RightToLeft),
790            "the Hebrew span should have produced an RTL run"
791        );
792    }
793
794    #[test]
795    fn run_boundaries_never_split_a_multibyte_character() {
796        let text = format!("a{ARABIC}b{HEBREW}c");
797        for run in analyze_paragraph(&text, TextDirection::Auto).runs {
798            assert!(
799                text.is_char_boundary(run.byte_range.start)
800                    && text.is_char_boundary(run.byte_range.end),
801                "run {:?} splits a character in {text:?}",
802                run.byte_range
803            );
804        }
805    }
806
807    #[test]
808    fn rule_l2_over_a_real_paragraph_is_a_permutation() {
809        let text = format!("hello {HEBREW} world {ARABIC} end");
810        let para = analyze_paragraph(&text, TextDirection::Auto);
811        let levels: Vec<u8> = para.runs.iter().map(|r| r.level).collect();
812
813        let mut seen = visual_order(&levels);
814        seen.sort_unstable();
815        assert_eq!(
816            seen,
817            (0..para.runs.len()).collect::<Vec<_>>(),
818            "every run must appear exactly once in the visual order"
819        );
820    }
821
822    #[test]
823    fn empty_text_analyzes_without_panicking() {
824        let para = analyze_paragraph("", TextDirection::RightToLeft);
825        assert!(para.runs.is_empty());
826        assert_eq!(para.para_level, 1);
827    }
828}