Skip to main content

motion_content/text/
layout.rs

1//! Line fitting with ellipsis truncation and paragraph layout of rich text.
2
3use super::*;
4
5impl Text {
6    /// Precomputes a paragraph limited to 1..=512 lines, adding an ellipsis only
7    /// when truncated. Returns the text and whether source content was removed.
8    /// Rich styles and extended grapheme boundaries are retained. The supplied
9    /// font snapshot is frozen on the returned text; no system discovery occurs.
10    /// Preparation may reshape bounded suffix candidates and is not a frame-loop API.
11    pub fn fit_lines(
12        &self,
13        max_lines: usize,
14        fonts: &FontLibrary,
15    ) -> Result<(Self, bool), ValidationError> {
16        use unicode_segmentation::UnicodeSegmentation;
17        if !(1..=512).contains(&max_lines) {
18            return Err(invalid("max_lines", "Text line limit requires 1..=512"));
19        }
20        let full = self.layout(fonts)?;
21        let fits = |layout: &ParagraphLayout| {
22            layout.lines.len() <= max_lines
23                && self.width.is_none_or(|width| {
24                    layout.bounds.width() <= width
25                        && layout.lines.iter().all(|line| line.width() <= width)
26                })
27        };
28        if fits(&full) {
29            return Ok((self.clone().fonts(fonts), false));
30        }
31        let end = full
32            .line_sources
33            .iter()
34            .take(max_lines)
35            .map(|range| range.end)
36            .max()
37            .unwrap_or(0);
38        let mut cuts = full
39            .text
40            .grapheme_indices(true)
41            .map(|(index, _)| index)
42            .filter(|index| *index <= end)
43            .collect::<Vec<_>>();
44        if end == full.text.len() && !cuts.contains(&end) {
45            cuts.push(end);
46        }
47        cuts.sort_unstable();
48        for end in cuts.into_iter().rev() {
49            let mut spans = Vec::new();
50            let mut consumed = 0;
51            for source in self.spans.iter() {
52                let count = end.saturating_sub(consumed).min(source.text.len());
53                if count > 0 {
54                    let mut span = source.clone();
55                    span.text = Arc::from(&source.text[..count]);
56                    spans.push(span);
57                }
58                consumed += source.text.len();
59                if consumed >= end {
60                    break;
61                }
62            }
63            if let Some(last) = spans.last_mut() {
64                last.text = Arc::from(format!("{}\u{2026}", last.text));
65            } else {
66                let mut span = self
67                    .spans
68                    .first()
69                    .cloned()
70                    .unwrap_or_else(|| TextSpan::new(""));
71                span.text = Arc::from("\u{2026}");
72                spans.push(span);
73            }
74            let mut candidate = self.clone();
75            candidate.spans = spans.into();
76            candidate.fonts = Some(fonts.clone());
77            candidate.invalidate_layout_cache();
78            let layout = candidate.layout(fonts)?;
79            if fits(&layout) {
80                return Ok((candidate, true));
81            }
82        }
83        Err(invalid(
84            "max_width",
85            "No grapheme-safe ellipsis fits the requested paragraph",
86        ))
87    }
88
89    /// Shapes once per text-style/font snapshot, returning actual line/ink metrics.
90    pub fn layout(&self, fonts: &FontLibrary) -> Result<Arc<ParagraphLayout>, ValidationError> {
91        self.validate_layout()?;
92        if let Some(result) = self
93            .layout_cache
94            .lock()
95            .map_err(|_| invalid("cache", "Layout cache is poisoned"))?
96            .get(&fonts.snapshot_id())
97        {
98            return result.clone();
99        }
100        let key = self.layout_key();
101        let result = if let Some(layout) = fonts
102            .layout_cache
103            .lock()
104            .map_err(|_| invalid("cache", "Shared paragraph cache is poisoned"))?
105            .get(&key)
106            .cloned()
107        {
108            Ok(layout)
109        } else {
110            let result = self.shape(fonts).map(Arc::new);
111            if let Ok(layout) = &result {
112                let mut shared = fonts
113                    .layout_cache
114                    .lock()
115                    .map_err(|_| invalid("cache", "Shared paragraph cache is poisoned"))?;
116                if shared.len() >= 64 {
117                    shared.clear();
118                }
119                shared.insert(key, layout.clone());
120            }
121            result
122        };
123        let mut cache = self
124            .layout_cache
125            .lock()
126            .map_err(|_| invalid("cache", "Layout cache is poisoned"))?;
127        if cache.len() >= 8 {
128            cache.clear();
129        }
130        cache.insert(fonts.snapshot_id(), result.clone());
131        result
132    }
133
134    fn validate_layout(&self) -> Result<(), ValidationError> {
135        self.fill.validate()?;
136        if let Some((width, paint)) = &self.stroke {
137            if !width.is_finite() || *width < 0.0 {
138                return Err(invalid(
139                    "stroke",
140                    "Text stroke requires finite nonnegative width",
141                ));
142            }
143            paint.validate()?;
144        }
145        for span in self.spans.iter() {
146            if let Some(paint) = &span.fill {
147                paint.validate()?;
148            }
149        }
150        Ok(())
151    }
152
153    fn layout_key(&self) -> LayoutKey {
154        LayoutKey {
155            spans: self
156                .spans
157                .iter()
158                .map(|span| {
159                    (
160                        span.text.clone(),
161                        span.family.clone(),
162                        span.size.map(f64::to_bits),
163                        span.weight.map(|weight| weight.0),
164                        span.italic,
165                    )
166                })
167                .collect(),
168            family: self.family.clone(),
169            size: self.size.to_bits(),
170            weight: self.weight.0,
171            line_height: self.line_height.to_bits(),
172            spacing: self.spacing.to_bits(),
173            width: self.width.map(f64::to_bits),
174            alignment: self.alignment,
175        }
176    }
177
178    pub(super) fn resolve(
179        &self,
180        context: &DrawContext,
181    ) -> Result<Arc<ParagraphLayout>, ValidationError> {
182        let fonts = self
183            .fonts
184            .as_ref()
185            .or(context
186                .fonts()
187                .and_then(|environment| environment.downcast_ref::<FontLibrary>()))
188            .ok_or_else(|| invalid("fonts", "paragraphs require an explicit FontLibrary"))?;
189        if context.layout_cache().is_none() {
190            return self.layout(fonts);
191        }
192        let fonts = context
193            .configured_render_cache()
194            .map_or_else(|| fonts.clone(), |cache| fonts.with_render_cache(cache));
195        self.validate_layout()?;
196        let key = self.layout_key();
197        let key_bytes = std::mem::size_of::<LayoutKey>()
198            + std::mem::size_of_val(key.spans.as_slice())
199            + key.family.len()
200            + key
201                .spans
202                .iter()
203                .map(|span| span.0.len() + span.1.as_ref().map_or(0, |family| family.len()))
204                .sum::<usize>();
205        context
206            .cache_layout(
207                (fonts.snapshot_id(), key),
208                || self.shape(&fonts).map(Arc::new),
209                |result| {
210                    key_bytes.saturating_add(result.as_ref().map_or_else(
211                        |error| error.message.len(),
212                        |layout| {
213                            std::mem::size_of::<ParagraphLayout>()
214                                + std::mem::size_of_val(layout.glyphs.as_ref())
215                                + std::mem::size_of_val(layout.lines.as_ref())
216                                + std::mem::size_of_val(layout.line_sources.as_ref())
217                                + layout.text.len()
218                        },
219                    ))
220                },
221            )
222            .as_ref()
223            .clone()
224    }
225
226    fn paragraph_text(&self) -> Result<String, ValidationError> {
227        if self.spans.len() > 256 {
228            return Err(limited("Text exceeds 256 rich spans"));
229        }
230        if !(1..=1000).contains(&self.weight.0)
231            || self.spans.iter().any(|span| {
232                span.weight
233                    .is_some_and(|weight| !(1..=1000).contains(&weight.0))
234            })
235        {
236            return Err(invalid("weight", "Font weight must be in 1..=1000"));
237        }
238        let length = self
239            .spans
240            .iter()
241            .try_fold(0usize, |length, span| length.checked_add(span.text.len()))
242            .ok_or_else(|| limited("Paragraph size overflow"))?;
243        if length > 16_384 {
244            return Err(limited("Paragraph exceeds 16384 UTF-8 bytes"));
245        }
246        let text = self
247            .spans
248            .iter()
249            .map(|span| span.text.as_ref())
250            .collect::<String>();
251        if text.len() > 16_384
252            || text
253                .chars()
254                .filter(|value| matches!(value, '\n' | '\r'))
255                .count()
256                > 4096
257        {
258            return Err(limited("Paragraph exceeds its text/line allowance"));
259        }
260        if !self.size.is_finite()
261            || self.size <= 0.0
262            || self.size > 4096.0
263            || self.size as f32 == 0.0
264            || !self.line_height.is_finite()
265            || !(0.1..=16.0).contains(&self.line_height)
266            || !self.spacing.is_finite()
267            || self.spacing.abs() > 4096.0
268            || !(self.spacing / self.size).is_finite()
269            || !((self.spacing / self.size) as f32).is_finite()
270            || self.width.is_some_and(|width| {
271                !width.is_finite() || width <= 0.0 || width > 1e7 || width as f32 == 0.0
272            })
273        {
274            return Err(invalid(
275                "style",
276                "Invalid paragraph size, line-height, tracking or width",
277            ));
278        }
279        self.fill.validate()?;
280        if let Some((width, paint)) = &self.stroke {
281            if !width.is_finite() || *width < 0.0 {
282                return Err(invalid(
283                    "stroke",
284                    "Text stroke requires finite nonnegative width",
285                ));
286            }
287            paint.validate()?;
288        }
289        Ok(text)
290    }
291
292    fn shape(&self, fonts: &FontLibrary) -> Result<ParagraphLayout, ValidationError> {
293        let text = self.paragraph_text()?;
294        let mut features = cosmic_text::FontFeatures::new();
295        features
296            .disable(cosmic_text::FeatureTag::STANDARD_LIGATURES)
297            .disable(cosmic_text::FeatureTag::CONTEXTUAL_LIGATURES);
298
299        fn family(name: &str) -> cosmic_text::Family<'_> {
300            if name.is_empty() {
301                cosmic_text::Family::SansSerif
302            } else {
303                cosmic_text::Family::Name(name)
304            }
305        }
306        let defaults = cosmic_text::Attrs::new()
307            .family(family(&self.family))
308            .weight(self.weight)
309            .letter_spacing((self.spacing / self.size) as f32)
310            .font_features(features.clone());
311        let mut styled = Vec::new();
312        for (index, span) in self.spans.iter().enumerate() {
313            let size = span.size.unwrap_or(self.size);
314            if !size.is_finite()
315                || size <= 0.0
316                || size > 4096.0
317                || size as f32 == 0.0
318                || !((self.spacing / size) as f32).is_finite()
319                || (size * self.line_height) as f32 == 0.0
320            {
321                return Err(invalid("span", "Invalid rich-span font size or tracking"));
322            }
323            if let Some(paint) = &span.fill {
324                paint.validate()?;
325            }
326            styled.push((
327                span.text.as_ref(),
328                cosmic_text::Attrs::new()
329                    .family(family(span.family.as_deref().unwrap_or(&self.family)))
330                    .weight(span.weight.unwrap_or(self.weight))
331                    .style(if span.italic {
332                        cosmic_text::Style::Italic
333                    } else {
334                        cosmic_text::Style::Normal
335                    })
336                    .metadata(index)
337                    .metrics(cosmic_text::Metrics::new(
338                        size as f32,
339                        (size * self.line_height) as f32,
340                    ))
341                    .letter_spacing((self.spacing / size) as f32)
342                    .font_features(features.clone()),
343            ));
344        }
345        if text.is_empty() {
346            return Ok(ParagraphLayout {
347                glyphs: Arc::from([]),
348                bounds: Rect::default(),
349                baseline: 0.0,
350                lines: Arc::from([]),
351                line_sources: Arc::from([]),
352                text: text.into(),
353            });
354        }
355        if fonts.is_empty() {
356            return Err(invalid("fonts", "The font library is empty"));
357        }
358        let mut system = fonts.lock_font_system()?;
359        let mut buffer = cosmic_text::Buffer::new(
360            &mut system,
361            cosmic_text::Metrics::new(self.size as f32, (self.size * self.line_height) as f32),
362        );
363        buffer.set_size(self.width.map(|width| width as f32), None);
364        buffer.set_wrap(cosmic_text::Wrap::WordOrGlyph);
365        let alignment = match self.alignment {
366            TextAlignment::Start => None,
367            TextAlignment::End => Some(cosmic_text::Align::End),
368            TextAlignment::Center => Some(cosmic_text::Align::Center),
369            TextAlignment::Justified => Some(cosmic_text::Align::Justified),
370        };
371        buffer.set_rich_text(styled, &defaults, cosmic_text::Shaping::Advanced, alignment);
372        buffer.shape_until_scroll(&mut system, false);
373        let mut offset = 0;
374        let mut offsets = Vec::with_capacity(buffer.lines.len());
375        for line in &buffer.lines {
376            if !text[offset..].starts_with(line.text()) {
377                return Err(invalid(
378                    "source",
379                    "Shaped line no longer matches original UTF-8 text",
380                ));
381            }
382            offsets.push(offset);
383            offset += line.text().len();
384            let remaining = &text[offset..];
385            if remaining.starts_with("\r\n") {
386                offset += 2;
387            } else if let Some(separator) = remaining
388                .chars()
389                .next()
390                .filter(|value| matches!(value, '\r' | '\n' | '\u{0085}' | '\u{2028}' | '\u{2029}'))
391            {
392                offset += separator.len_utf8();
393            }
394        }
395        self.collect_layout(fonts, &mut system, &buffer, text, offsets)
396    }
397
398    fn collect_layout(
399        &self,
400        fonts: &FontLibrary,
401        system: &mut cosmic_text::FontSystem,
402        buffer: &cosmic_text::Buffer,
403        text: String,
404        offsets: Vec<usize>,
405    ) -> Result<ParagraphLayout, ValidationError> {
406        let mut glyphs = Vec::new();
407        let mut lines = Vec::new();
408        let mut line_sources = Vec::new();
409        let mut bounds = Rect::default();
410        let mut baseline = 0.0;
411        for run in buffer.layout_runs() {
412            if lines.len() >= 4096 {
413                return Err(limited("Paragraph exceeds 4096 visual lines"));
414            }
415            if lines.is_empty() {
416                baseline = f64::from(run.line_y);
417            }
418            let line = Rect::new(
419                0.0,
420                f64::from(run.line_top),
421                self.width.unwrap_or(f64::from(run.line_w)),
422                f64::from(run.line_top + run.line_height),
423            );
424            bounds = bounds.union(line);
425            let line_index = lines.len();
426            lines.push(line);
427            let start = run
428                .glyphs
429                .iter()
430                .map(|glyph| glyph.start)
431                .min()
432                .unwrap_or(0);
433            let end = run
434                .glyphs
435                .iter()
436                .map(|glyph| glyph.end)
437                .max()
438                .unwrap_or(start);
439            line_sources.push(offsets[run.line_i] + start..offsets[run.line_i] + end);
440            for glyph in run.glyphs {
441                if glyphs.len() >= 4096 {
442                    return Err(limited("Paragraph exceeds 4096 shaped glyphs"));
443                }
444                if glyph.glyph_id == 0 {
445                    return Err(ValidationError::unsupported(
446                        "text",
447                        "missing_glyph",
448                        "Registered fonts do not cover a shaped text cluster",
449                    ));
450                }
451                let artwork = glyph::Artwork::load(
452                    fonts,
453                    system,
454                    glyph.font_id,
455                    glyph.font_weight,
456                    glyph.glyph_id,
457                )?;
458                let scale = f64::from(glyph.font_size) / artwork.units;
459                let horizontal =
460                    f64::from(glyph.x) + f64::from(glyph.font_size) * f64::from(glyph.x_offset);
461                let vertical = f64::from(run.line_y + glyph.y)
462                    - f64::from(glyph.font_size) * f64::from(glyph.y_offset);
463                let transform = Transform::scale_non_uniform(scale, -scale)
464                    .then(Transform::translate(horizontal, vertical));
465                let hit = Rect::new(
466                    f64::from(glyph.x),
467                    f64::from(run.line_top),
468                    f64::from(glyph.x + glyph.w),
469                    f64::from(run.line_top + run.line_height),
470                );
471                let ink = artwork
472                    .bounds(transform)?
473                    .map_or(hit, |bounds| hit.union(bounds));
474                bounds = bounds.union(ink);
475                let family = system
476                    .db()
477                    .face(glyph.font_id)
478                    .and_then(|face| face.families.first())
479                    .map(|family| Arc::from(family.0.as_str()))
480                    .unwrap_or_else(|| Arc::from("<font>"));
481                glyphs.push(TextGlyph {
482                    source: offsets[run.line_i] + glyph.start..offsets[run.line_i] + glyph.end,
483                    line: line_index,
484                    family,
485                    bounds: ink,
486                    artwork,
487                    transform,
488                    span: glyph.metadata,
489                });
490            }
491        }
492        Ok(ParagraphLayout {
493            glyphs: glyphs.into(),
494            bounds,
495            baseline,
496            lines: lines.into(),
497            line_sources: line_sources.into(),
498            text: text.into(),
499        })
500    }
501}