Skip to main content

motion_content/text/
layout.rs

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